api-proxy.ts 152 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520
  1. /**
  2. * Host-side ApiProxy implementation. Signature discipline: unary takes the
  3. * narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
  4. */
  5. import { randomUUID } from 'node:crypto'
  6. import { mkdir, stat } from 'node:fs/promises'
  7. import { dirname } from 'node:path'
  8. import type { Context } from '@deepseek-ai/cordis'
  9. import { installModelSelection } from '@deepseek-ai/dsh-agent'
  10. import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
  11. import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
  12. import { AttachmentError } from '@deepseek-ai/dsh-attachment'
  13. import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  14. import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  15. import { errorChain } from '@deepseek-ai/dsh-llm'
  16. import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
  17. import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
  18. import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
  19. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  20. import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
  21. import { SubagentError } from '@deepseek-ai/dsh-subagent'
  22. import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
  23. import { isUserInvocable } from '@deepseek-ai/dsh-skill'
  24. import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
  25. import {
  26. workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
  27. WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
  28. } from '@deepseek-ai/dsh-workspace'
  29. // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
  30. import {
  31. InvalidPresetIdError, PresetExistsError, PresetMountError,
  32. PresetNotWritableError, resolveSessionPreset,
  33. SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
  34. } from '@deepseek-ai/dsh-agent-presets'
  35. import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets'
  36. import type {} from '@deepseek-ai/dsh-tools'
  37. import type {
  38. ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
  39. ModelCatalogFailure, ModelProviderGroup,
  40. ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
  41. QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
  42. WorkspaceId, WorkspaceView,
  43. } from './api/index.ts'
  44. import {
  45. sessionLogExportDeps,
  46. sessionLogZipFilename,
  47. streamSessionLogZip,
  48. type SessionLogExportReady,
  49. } from './session-export.ts'
  50. import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
  51. import {
  52. SESSION_SEARCH_RESULT_LIMIT,
  53. SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
  54. truncateUnicodeCodePoints,
  55. } from './api/session-search.ts'
  56. // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
  57. import type {} from '@deepseek-ai/dsh-session-projection'
  58. // Type-only: resolves `ctx.get('tasks')` to the background task registry.
  59. import type {} from '@deepseek-ai/dsh-tasks'
  60. import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
  61. // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
  62. import type {} from '@deepseek-ai/dsh-session-projection-cache'
  63. // GoalError narrows domain rejections to their stable codes at the wire boundary.
  64. import { GoalError } from '@deepseek-ai/dsh-goal'
  65. import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
  66. // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
  67. import type {} from '@deepseek-ai/dsh-commands'
  68. import type {} from '@deepseek-ai/dsh-skill'
  69. // The settings/credentials seams: brand guards run at this wire boundary; the
  70. // service reads stay optional (`ctx.get`) so a composition without either
  71. // provider still serves every other domain.
  72. import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
  73. import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings'
  74. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  75. // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
  76. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
  77. import type { CallId } from '@deepseek-ai/dsh-llm/brand'
  78. import type { ScopeKey } from '@deepseek-ai/dsh-scope'
  79. import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
  80. // Side-effect type import: resolves the `approval/request` waterfall and
  81. // `ctx.get('approval')` without a value dependency on the seam (optional composition).
  82. import type {} from '@deepseek-ai/dsh-user-approval'
  83. import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
  84. import { questionResponsePayloadSchema } from './api/questions.schema.ts'
  85. import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
  86. import { RpcId } from './api/rpc.ts'
  87. import type {
  88. AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
  89. } from '@deepseek-ai/dsh-user-interaction'
  90. import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
  91. import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
  92. import {
  93. ApiRemoteSessionNotFound as SessionNotFound,
  94. ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership,
  95. apiRemoteSubagentOwnershipError,
  96. createApiRemoteAgentResolver,
  97. hasApiRemoteSubagentOwner,
  98. inspectApiRemoteSession,
  99. } from '@deepseek-ai/dsh-api-remotes'
  100. import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
  101. /** Page size when history is called without maxMessages. */
  102. const DEFAULT_MAX_MESSAGES = 50
  103. /** Non-model settings namespaces intentionally served to the Web client. */
  104. const WEB_SETTINGS_NAMESPACES = ['locale', 'permission', 'ui-conversation', 'ui-theme'] as const
  105. /** Provider work budget: at most 100 calls and 2,000 inspected hits. */
  106. const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
  107. /** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */
  108. const COLD_SUMMARY_BATCH_SIZE = 16
  109. /** Conversation message event types (the pagination counting unit). */
  110. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
  111. /** Decode the browser payload while rejecting non-canonical base64 forms. */
  112. function decodeBase64(data: string): Uint8Array {
  113. const decoded = Buffer.from(data, 'base64')
  114. if (data.length === 0 || decoded.toString('base64') !== data) {
  115. throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
  116. }
  117. return new Uint8Array(decoded)
  118. }
  119. /** Validate one prompt as a batch before publishing any durable image object. */
  120. async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise<ContentBlock[]> {
  121. if (content.every(part => part.type === 'text')) {
  122. return content.map(part => ({ type: 'text', text: part.text }))
  123. }
  124. const limits = ctx.attachments.imageLimits
  125. if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) {
  126. throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
  127. }
  128. const prepared = content.map(part => part.type === 'text'
  129. ? part
  130. : { part, data: decodeBase64(part.data) })
  131. const images = prepared.filter((part): part is Extract<typeof part, { data: Uint8Array }> => 'data' in part)
  132. const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0)
  133. if (totalBytes > limits.maxMessageImageBytes) {
  134. throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
  135. }
  136. for (const image of images) {
  137. await ctx.attachments.validateImage({
  138. data: image.data,
  139. mediaType: image.part.mediaType,
  140. ...image.part.name === undefined ? {} : { name: image.part.name },
  141. })
  142. }
  143. const blocks: ContentBlock[] = []
  144. for (const item of prepared) {
  145. if (!('data' in item)) {
  146. blocks.push({ type: 'text', text: item.text })
  147. continue
  148. }
  149. const attachment = await ctx.attachments.saveImage({
  150. data: item.data,
  151. mediaType: item.part.mediaType,
  152. ...item.part.name === undefined ? {} : { name: item.part.name },
  153. })
  154. blocks.push({ type: 'image', attachment })
  155. }
  156. return blocks
  157. }
  158. /** Search durable content for an image reference, including nested tool results. */
  159. function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
  160. if (!Array.isArray(content)) return undefined
  161. for (const value of content) {
  162. if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
  163. const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
  164. if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
  165. const ref = block.attachment as ImageAttachmentRef
  166. if (match(ref)) return ref
  167. }
  168. if (block.type === 'tool-result') {
  169. const nested = imageBlockIn(block.content, match)
  170. if (nested !== undefined) return nested
  171. }
  172. }
  173. return undefined
  174. }
  175. /** Search every durable event carrier that can own model-visible content. */
  176. function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
  177. const data = event.data as {
  178. content?: unknown
  179. message?: { content?: unknown }
  180. inserted?: Array<{ content?: unknown }>
  181. chunk?: { type?: unknown; block?: unknown }
  182. }
  183. const direct = imageBlockIn(data.content, match)
  184. if (direct !== undefined) return direct
  185. if (data.message !== undefined) {
  186. const wrapped = imageBlockIn(data.message.content, match)
  187. if (wrapped !== undefined) return wrapped
  188. }
  189. if (data.inserted !== undefined) {
  190. for (const message of data.inserted) {
  191. const inserted = imageBlockIn(message.content, match)
  192. if (inserted !== undefined) return inserted
  193. }
  194. }
  195. if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
  196. return imageBlockIn([data.chunk.block], match)
  197. }
  198. return undefined
  199. }
  200. /** True when the current model-visible surface contains an image. */
  201. function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean {
  202. return messages.some(message => contentHasImage(message.content))
  203. }
  204. /** Resolve the first reference matching one opaque id. */
  205. function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
  206. for (const event of events) {
  207. const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
  208. if (found !== undefined) return found
  209. }
  210. return undefined
  211. }
  212. /**
  213. * Product settings intentionally exposed beside model-provider namespaces.
  214. *
  215. * The agent-preset namespace carries one field — which preset a session with
  216. * no explicit choice is composed from — and both browser surfaces that offer
  217. * that choice write it through `settings.update`, so it has to cross the
  218. * configuration boundary or the pickers silently fail to persist.
  219. */
  220. const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE])
  221. /** Read live abort state across awaits without treating it as synchronously immutable. */
  222. function isAborted(signal: AbortSignal): boolean {
  223. return signal.aborted
  224. }
  225. /**
  226. * Message-boundary pagination: count maxMessages append-origin messages
  227. * backwards from the window tail. Replacement copies never entered the
  228. * conversation a reader sees — they restate a shadowed range for the model
  229. * alone — so they consume no quota; the page stays one contiguous raw range,
  230. * which keeps a compaction's log-only `compact/summary` record on the same page as its
  231. * replacement. The cut is the starting seq of the oldest message group (chunks
  232. * group via sourceEventSeqs — never cut mid-message). The tail page naturally
  233. * includes the in-progress partial.
  234. */
  235. function paginate(
  236. events: readonly SessionEvent[],
  237. beforeSeq: number | undefined,
  238. maxMessages: number,
  239. ): { events: SessionEvent[]; hasMore: boolean } {
  240. const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
  241. let count = 0
  242. let cut = 0
  243. for (let i = window.length - 1; i >= 0; i--) {
  244. const event = window[i] as SessionEvent
  245. if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
  246. count++
  247. const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
  248. const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
  249. if (count >= maxMessages) {
  250. cut = groupStart
  251. break
  252. }
  253. }
  254. const page = window.filter(event => event.seq >= cut)
  255. return { events: page, hasMore: cut > 0 }
  256. }
  257. /** Wrap an ok result echoing the request's rpcId. */
  258. function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
  259. return { rpcId: request.rpcId, result: { ok: true, value } }
  260. }
  261. /**
  262. * Build the provider/model catalog over every registered route. Shared by the
  263. * session-scoped `session.models` and host-scoped `llm.models`. Catalog
  264. * membership stays advisory: an unlisted session selection remains valid for
  265. * provider dispatch, but is not injected back into the selector after its
  266. * owning catalog stops advertising it. Per-provider failures ride `failures`
  267. * without failing the sound groups; groups that advertise nothing are dropped.
  268. */
  269. async function buildModelCatalog(ctx: Context): Promise<{
  270. groups: ModelProviderGroup[]
  271. failures: ModelCatalogFailure[]
  272. }> {
  273. const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
  274. try {
  275. const models = await ctx.llm.listModels(provider.id)
  276. const entries = await Promise.all(models.map(async (model) => {
  277. const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
  278. const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
  279. ? undefined
  280. : {
  281. efforts: resolved.reasoning.efforts.map(effort => ({
  282. id: effort.id,
  283. name: effort.name,
  284. ...effort.description === undefined
  285. ? {}
  286. : { description: effort.description },
  287. })),
  288. ...resolved.reasoning.defaultEffort === undefined
  289. ? {}
  290. : { defaultEffort: resolved.reasoning.defaultEffort },
  291. }
  292. return {
  293. id: model.id,
  294. name: model.name,
  295. ...model.description === undefined ? {} : { description: model.description },
  296. ...reasoning === undefined ? {} : { reasoning },
  297. }
  298. }))
  299. const group: ModelProviderGroup = {
  300. id: provider.id,
  301. name: provider.name,
  302. models: entries,
  303. }
  304. return { kind: 'group' as const, group }
  305. } catch (error: unknown) {
  306. const failure: ModelCatalogFailure = {
  307. id: provider.id,
  308. name: provider.name,
  309. message: error instanceof Error ? error.message : String(error),
  310. }
  311. return { kind: 'failure' as const, failure }
  312. }
  313. }))
  314. return {
  315. groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []).filter(group => group.models.length > 0),
  316. failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
  317. }
  318. }
  319. /** Wrap an error result echoing the request's rpcId. */
  320. function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
  321. return { rpcId: request.rpcId, result: { ok: false, error } }
  322. }
  323. /**
  324. * The RPC refusal a preset failure becomes, or undefined when the failure is
  325. * about something else.
  326. *
  327. * Both the session-create path and the switch path can be handed the same two
  328. * failures, and a client that has to branch on the code needs them worded the
  329. * same from either.
  330. * @param request - the request being answered.
  331. * @param error - the thrown value.
  332. * @returns the refusal, or undefined when the caller should keep handling.
  333. */
  334. function presetFailure(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> | undefined {
  335. if (error instanceof UnknownPresetError) {
  336. return err(request, {
  337. code: 'agent-preset-not-found',
  338. message: error.message,
  339. details: { agentPreset: error.presetId, available: [...error.available] },
  340. })
  341. }
  342. if (error instanceof PresetMountError) {
  343. return err(request, {
  344. code: 'agent-preset-invalid',
  345. message: error.message,
  346. details: { agentPreset: error.presetId, reason: error.reason },
  347. })
  348. }
  349. return undefined
  350. }
  351. /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
  352. class FrameQueue<F> {
  353. private buffer: F[] = []
  354. private waiter: (() => void) | undefined
  355. private done = false
  356. push(item: F): void {
  357. if (this.done) return
  358. this.buffer.push(item)
  359. this.waiter?.()
  360. }
  361. end(): void {
  362. this.done = true
  363. this.waiter?.()
  364. }
  365. async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
  366. const onAbort = (): void => { this.end() }
  367. signal.addEventListener('abort', onAbort, { once: true })
  368. try {
  369. while (true) {
  370. while (this.buffer.length > 0) yield this.buffer.shift() as F
  371. if (this.done || signal.aborted) return
  372. await new Promise<void>((resolve) => { this.waiter = resolve })
  373. this.waiter = undefined
  374. }
  375. } finally {
  376. signal.removeEventListener('abort', onAbort)
  377. cleanup()
  378. }
  379. }
  380. }
  381. /**
  382. * Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable
  383. * frames — approval/question requested — mint their stable id in their
  384. * pending registries instead).
  385. */
  386. function frame<F>(payload: F): RpcRequest<F> {
  387. return { rpcId: RpcId(randomUUID()), payload }
  388. }
  389. /** Queue the subscription baseline frame. */
  390. function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
  391. queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
  392. }
  393. /**
  394. * Project registry snapshots onto the wire view, dropping the three internal
  395. * fields {@link TaskView} documents as absent.
  396. */
  397. function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] {
  398. return snapshots.map(task => ({
  399. id: task.id,
  400. kind: task.kind,
  401. label: task.label,
  402. status: task.status,
  403. ...task.detail === undefined ? {} : { detail: task.detail },
  404. startedAt: task.startedAt,
  405. ...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt },
  406. }))
  407. }
  408. /**
  409. * Whether the session's conversation has started: no turn has run yet (a
  410. * turn is one model-loop execution). Standalone plugin events — command
  411. * lifecycle records, plan/mode, titles, goals — never open a turn, so
  412. * running `/plan` or `/goal` on a fresh session keeps it blank
  413. * (list-hidden, reusable).
  414. */
  415. function sessionBlank(session: Session): boolean {
  416. return !session.events.some(event => event.type === 'turn/start')
  417. }
  418. /** Shared Session-header projection for list baselines and creation frames. */
  419. function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
  420. parentSessionId?: SessionId
  421. origin?: 'subagent'
  422. cwd?: string
  423. agentPreset?: string
  424. } {
  425. // The preset comes from the log, not the header: a session that switched
  426. // while blank ran its turns under the newer composition, and a picker
  427. // showing the creation-time value would contradict what the model saw.
  428. const agentPreset = resolveSessionPreset({ header, events })
  429. return {
  430. ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
  431. ...header.origin === undefined ? {} : { origin: header.origin },
  432. ...header.cwd === undefined ? {} : { cwd: header.cwd },
  433. ...agentPreset === undefined ? {} : { agentPreset },
  434. }
  435. }
  436. /** SessionSummary projection for attached (in-memory) sessions. */
  437. function summarize(session: Session, running: boolean): SessionSummary {
  438. return {
  439. sessionId: session.id,
  440. // Excludes end-seed: a resumed-but-untouched session
  441. // must not sort as freshly worked in.
  442. updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
  443. running,
  444. blank: sessionBlank(session),
  445. ...sessionListFields(session.header, session.events),
  446. }
  447. }
  448. /**
  449. * SessionSummary projection for cold (persisted, unattached) sessions.
  450. * updatedAt is the log file's mtime; backends without a per-session file
  451. * (locate() undefined) fall back to the header's createdAt.
  452. */
  453. async function summarizeCold(
  454. persistence: SessionPersistence,
  455. meta: SessionHeader,
  456. signal?: AbortSignal,
  457. ): Promise<SessionSummary> {
  458. signal?.throwIfAborted()
  459. let updatedAt = meta.createdAt
  460. const location = persistence.locate(meta)
  461. signal?.throwIfAborted()
  462. if (location !== undefined) {
  463. try {
  464. updatedAt = (await stat(location.path)).mtimeMs
  465. } catch {
  466. // The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
  467. }
  468. signal?.throwIfAborted()
  469. }
  470. return {
  471. sessionId: meta.id,
  472. updatedAt,
  473. running: false,
  474. // Lazy persistence keeps never-appended sessions out of list(); reading
  475. // a cold log to check for turns would defeat the index read, so a listed
  476. // cold session is served as not-blank (its log holds its conversation).
  477. blank: false,
  478. // Header-only: reading the log for a blank-window preset switch would
  479. // defeat the same index read, and attaching the session replaces this row
  480. // with `summarize()`, which resolves the switch from the events.
  481. ...sessionListFields(meta),
  482. }
  483. }
  484. /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
  485. function directoryError(error: unknown): RpcError {
  486. if (error instanceof DirectoryPickerError) {
  487. return { code: error.code, message: error.message, details: { path: error.path } }
  488. }
  489. return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }
  490. }
  491. /** Resolved Agent model and project-directory defaults consumed by the API implementation. */
  492. export interface ApiProxyDefaults {
  493. /**
  494. * The model selection a session starts from when its own log names none. Read on
  495. * every access rather than captured, so a default saved during this process
  496. * reaches the sessions that have not run a turn yet.
  497. */
  498. defaultModelSelection: () => ModelSelection
  499. /**
  500. * Record a selection as the new default. Either absent, or a closure that
  501. * may itself decline — the gateway plugin always passes one, and it no-ops
  502. * when the deployment mounts no settings provider or when the write races
  503. * service teardown. A switch then stays process-local. A rejection is
  504. * reported and swallowed: the switch already applies to its own session,
  505. * and undoing it because storage failed would be the worse outcome.
  506. */
  507. saveDefaultModelSelection?: (selection: ModelSelection) => Promise<void>
  508. /** Default project directory for new sessions whose create request carries no cwd. */
  509. cwd: string
  510. /** Native open-with-default-application; injectable for carrier tests. */
  511. openPath?: (path: string, signal: AbortSignal) => Promise<void>
  512. /** Native text-editor handoff; injectable for settings-document tests. */
  513. openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
  514. /**
  515. * Whether handing a path to the native opener can work at all — the
  516. * `hasDocument` capability the preset roster reports, and the switch
  517. * between opening a preset directory and answering its path as text.
  518. * Absent, an injected `openPath` counts as openable and everything else
  519. * falls back to platform detection ({@link canOpenNativePath}).
  520. */
  521. canOpenPath?: () => boolean
  522. }
  523. /** The tool/call payload fields the presenter path reads. */
  524. interface ToolCallData { callId: string; name: string; arguments: string }
  525. /**
  526. * One outstanding approval question: the stable server-request id, the frame
  527. * material replayed to late mux subscribers, and the resolver that settles the
  528. * answerer's promise back into `ctx.approval`.
  529. */
  530. interface PendingApproval {
  531. rpcId: RpcId
  532. sessionId: SessionId
  533. approvalId: ApprovalRequestId
  534. toolName: string
  535. callId?: CallId
  536. reason?: string
  537. resolve(outcome: ApprovalOutcome): void
  538. }
  539. /** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */
  540. function requestedFrame(pending: PendingApproval): RpcRequest<MuxFrame> {
  541. return {
  542. rpcId: pending.rpcId,
  543. payload: {
  544. type: 'approval/requested',
  545. sessionId: pending.sessionId,
  546. approvalId: pending.approvalId,
  547. toolName: pending.toolName,
  548. ...pending.callId === undefined ? {} : { callId: pending.callId },
  549. ...pending.reason === undefined ? {} : { reason: pending.reason },
  550. },
  551. }
  552. }
  553. /** One host-owned question wait, addressed by the stable server-request id. */
  554. interface PendingQuestion {
  555. rpcId: RpcId
  556. sessionId: SessionId
  557. questions: AskUserQuestionItem[]
  558. resolve: (answer: AskUserQuestionAnswer) => void
  559. reject: (error: UserInteractionError) => void
  560. signal?: AbortSignal
  561. onAbort?: () => void
  562. }
  563. /** Validate one answer batch against the exact question request it resolves. */
  564. function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
  565. if (payload.sessionId !== pending.sessionId) return false
  566. const answers = payload.answer.answers
  567. if (answers.length !== pending.questions.length) return false
  568. return answers.every((answer, index) => {
  569. const question = pending.questions[index] as AskUserQuestionItem
  570. if (answer.id !== question.id) return false
  571. if (new Set(answer.selected).size !== answer.selected.length) return false
  572. const custom = answer.custom?.trim()
  573. if (custom !== undefined && custom === '') return false
  574. if (question.multiSelect !== true) {
  575. if (custom !== undefined && answer.selected.length > 0) return false
  576. if (answer.selected.length > 1) return false
  577. }
  578. const labels = new Set(question.options?.map(option => option.label) ?? [])
  579. return answer.selected.every(label => labels.has(label))
  580. })
  581. }
  582. /**
  583. * Compute the render intent for a tool/call or tool/result event through the
  584. * presenters registered at this moment; every other event type gets none. A
  585. * result's presenter needs its call's parsed args — `argsFor` supplies them
  586. * (live: the per-session call table; history: an in-page backscan), returning
  587. * undefined when the pairing is unavailable (e.g. the call fell off the page),
  588. * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
  589. * the client's documented default (generic JSON card) covers every miss.
  590. */
  591. function viewFor(
  592. ctx: Context,
  593. event: SessionEvent,
  594. argsFor: (callId: string) => unknown,
  595. // Presenters live with the definitions, and definitions live in the scope
  596. // chain: a preset registers its tools into its standing layer. A live agent
  597. // is a scope whose chain passes through its preset; a cold read passes the
  598. // preset's standing key directly — no agent, no resume. An undefined scope
  599. // sees only the global layer, which is the pre-preset deployment shape.
  600. scope?: ScopeKey,
  601. ): ToolEventView | undefined {
  602. try {
  603. if (event.type === 'tool/call') {
  604. const { name, arguments: raw } = event.data as ToolCallData
  605. const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw))
  606. return view === undefined ? undefined : { for: 'call', view }
  607. }
  608. if (event.type === 'tool/result') {
  609. const { message, meta } = event.data
  610. const [result] = message.content
  611. const callId = message.source.callId
  612. const call = argsFor(callId) as { name: string; args: unknown } | undefined
  613. if (call === undefined) return undefined
  614. const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, {
  615. content: result.content,
  616. isError: result.isError === true,
  617. ...meta === undefined ? {} : { meta },
  618. })
  619. return view === undefined ? undefined : { for: 'result', view }
  620. }
  621. } catch (error: unknown) {
  622. // A throwing presenter (or unparseable arguments) must not break delivery;
  623. // the event still ships, just without a view.
  624. console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
  625. }
  626. return undefined
  627. }
  628. /**
  629. * Resolve a tool/result's call pairing by scanning a window of events backwards
  630. * for the matching tool/call. Used by the history path (the page is the
  631. * window — a cross-page pairing soft-falls to no view) and by live-path table
  632. * misses after a reconnect-eviction.
  633. */
  634. function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
  635. for (let i = events.length - 1; i >= 0; i--) {
  636. const event = events[i] as SessionEvent
  637. if (event.type !== 'tool/call') continue
  638. const data = event.data as ToolCallData
  639. if (data.callId !== callId) continue
  640. try {
  641. return { name: data.name, args: JSON.parse(data.arguments) }
  642. } catch {
  643. // Unparseable stored arguments: same soft-fall as a live parse failure.
  644. return undefined
  645. }
  646. }
  647. return undefined
  648. }
  649. /** Render one detached history page through the same presenter path as ordinary history. */
  650. function historyPage(
  651. ctx: Context,
  652. events: readonly SessionEvent[],
  653. beforeSeq: number | undefined,
  654. maxMessages: number | undefined,
  655. scope?: ScopeKey,
  656. ): { events: HistoryEntry[]; hasMore: boolean } {
  657. const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
  658. return {
  659. events: page.events.map((event) => {
  660. const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope)
  661. return { event, ...view === undefined ? {} : { view } }
  662. }),
  663. hasMore: page.hasMore,
  664. }
  665. }
  666. /**
  667. * The projection baseline for one history tail page: the registry's
  668. * watermark-cache snapshot — one fully synchronous read (no await between the
  669. * page slice and this), so all values and `asOfSeq` form a single consistent
  670. * cut and `asOfSeq` equals the window tail event seq. The carrier holds zero
  671. * domain knowledge (each value passed its unit's own schema inside the
  672. * registry). An absent registry means the deployment has no projection seam:
  673. * the whole block is absent and clients treat every key as capability-absent.
  674. */
  675. function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
  676. const registry = ctx.get('sessionProjections')
  677. if (registry === undefined) return undefined
  678. return registry.snapshot(session)
  679. }
  680. /**
  681. * The projection baseline of one session.list row, fail-soft: attached
  682. * sessions cut the registry's live watermark cache; cold sessions view the
  683. * persisted projection cache's identity-checked stored rows (zero log loads
  684. * either way — the listing use case the cache exists for). The block shape
  685. * (values + asOfSeq) matches the history tail's, so a client seeds its
  686. * value store under the same higher-seq-wins rule. Any failure — and an
  687. * empty value set — yields an absent block: a listing without projections
  688. * is degraded, never broken.
  689. */
  690. function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
  691. try {
  692. const block = session !== undefined
  693. ? ctx.get('sessionProjections')?.snapshot(session)
  694. : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
  695. return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
  696. } catch (error) {
  697. ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
  698. return undefined
  699. }
  700. }
  701. /** Projection baseline for a detached history tail without Agent activation. */
  702. function detachedProjectionsFor(
  703. ctx: Context,
  704. events: readonly SessionEvent[],
  705. ): SessionProjectionsBlock | undefined {
  706. const registry = ctx.get('sessionProjections')
  707. if (registry === undefined) return undefined
  708. return registry.restore({}, events, 0).snapshot
  709. }
  710. /**
  711. * Best-effort projections for one subagent history page, fail-soft like
  712. * {@link listProjectionsFor}: a registered unit throwing on a corrupt payload
  713. * never blocks transcript reading — the page is served without the block.
  714. * @param ctx - context carrying the logger for the degradation warning.
  715. * @param childSessionId - the child whose page is being decorated.
  716. * @param compute - the arm-specific fold (live watermark or detached restore).
  717. * @returns the projections block, or undefined when the fold failed.
  718. */
  719. function subagentHistoryProjections(
  720. ctx: Context,
  721. childSessionId: SessionId,
  722. compute: () => SessionProjectionsBlock | undefined,
  723. ): SessionProjectionsBlock | undefined {
  724. try {
  725. return compute()
  726. } catch (error) {
  727. ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`)
  728. return undefined
  729. }
  730. }
  731. /** Map continuation admission failures without exposing provider details. */
  732. function subagentPromptError(
  733. request: RpcRequest<{ childSessionId: SessionId }>,
  734. error: unknown,
  735. signal: AbortSignal,
  736. ): RpcResponse<never> {
  737. const childSessionId = request.payload.childSessionId
  738. if (signal.aborted) {
  739. return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
  740. }
  741. if (error instanceof SubagentError) {
  742. switch (error.code) {
  743. case 'NOT_RESUMABLE':
  744. return err(request, {
  745. code: 'subagent-not-resumable',
  746. message: 'subagent cannot be resumed',
  747. details: { childSessionId },
  748. })
  749. case 'UNAUTHORIZED':
  750. return err(request, {
  751. code: 'subagent-unauthorized',
  752. message: 'subagent does not belong to this parent',
  753. details: { childSessionId },
  754. })
  755. case 'DRAINING':
  756. case 'ACTIVATION_CLOSING':
  757. case 'CONTINUATION_UNAVAILABLE':
  758. case 'PERSISTENCE_UNAVAILABLE':
  759. return err(request, {
  760. code: 'subagent-delivery-unavailable',
  761. message: 'subagent follow-up is temporarily unavailable',
  762. details: { childSessionId },
  763. })
  764. default:
  765. break
  766. }
  767. }
  768. return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
  769. }
  770. /** Stable RPC face of the missing projections capability, shared by every catalog read path. */
  771. function projectionsUnavailableError(): RpcError {
  772. return {
  773. code: 'internal',
  774. message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
  775. details: {},
  776. }
  777. }
  778. /** Verify one address and mode against the complete direct-child catalog. */
  779. async function catalogChild(
  780. ctx: Context,
  781. address: SubagentAddress,
  782. signal?: AbortSignal,
  783. ): Promise<{
  784. entry?: Extract<CatalogSubagentListEntry, { kind: 'child' }>
  785. error?: RpcError
  786. }> {
  787. const { parentSessionId, childSessionId, mode } = address
  788. try {
  789. const entries = await ctx.subagents.listChildren(parentSessionId, signal)
  790. const entry = entries.find(candidate => candidate.id === childSessionId)
  791. if (entry === undefined || (entry.kind === 'child' && entry.mode !== mode)) {
  792. return {
  793. error: {
  794. code: 'subagent-not-found',
  795. message: `session "${childSessionId}" is not a ${mode} direct child of "${parentSessionId}"`,
  796. details: { parentSessionId, childSessionId },
  797. },
  798. }
  799. }
  800. if (entry.kind === 'diagnostic') {
  801. return {
  802. error: {
  803. code: 'subagent-catalog-diagnostic',
  804. message: `subagent "${childSessionId}" is ${entry.reason}`,
  805. details: { parentSessionId, childSessionId, reason: entry.reason },
  806. },
  807. }
  808. }
  809. return { entry }
  810. } catch (error: unknown) {
  811. if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
  812. return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
  813. }
  814. if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
  815. return { error: projectionsUnavailableError() }
  816. }
  817. return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
  818. }
  819. }
  820. /**
  821. * The requested preset differs from the one this session already runs.
  822. *
  823. * A session's composition is fixed at creation: its history was produced under
  824. * that preset's tools, so adopting the identity under a different one would
  825. * replay tool calls the rebuilt agent cannot make. Naming a different preset
  826. * is therefore a caller error rather than a switch.
  827. */
  828. /** The roster is absent: this deployment composes no agent presets at all. */
  829. function noRoster(agentPreset: string): RpcError {
  830. return {
  831. code: 'agent-preset-not-found',
  832. message: 'this deployment composes no agent presets',
  833. details: { agentPreset, available: [] },
  834. }
  835. }
  836. /** Map one authoring/roster failure onto its wire code. */
  837. function presetError(agentPreset: string, error: unknown): RpcError {
  838. if (error instanceof UnknownPresetError) {
  839. return {
  840. code: 'agent-preset-not-found',
  841. message: error.message,
  842. details: { agentPreset: error.presetId, available: [...error.available] },
  843. }
  844. }
  845. if (error instanceof PresetNotWritableError) {
  846. return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } }
  847. }
  848. if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
  849. return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
  850. }
  851. return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
  852. }
  853. class AgentPresetConflict extends Error {
  854. constructor(
  855. readonly sessionId: SessionId,
  856. readonly requestedPreset: string,
  857. readonly existingPreset: string | undefined,
  858. ) {
  859. super(
  860. existingPreset === undefined
  861. ? `session "${sessionId}" records no agent preset, so it cannot be adopted under one; `
  862. + 'a deployment composing no roster records none on any session — '
  863. : `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; `
  864. + `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`,
  865. )
  866. }
  867. }
  868. /** Requested identity already belongs to a session with another project cwd. */
  869. class SessionCwdConflict extends Error {
  870. constructor(
  871. readonly sessionId: SessionId,
  872. readonly requestedCwd: string,
  873. readonly existingCwd: string | undefined,
  874. ) {
  875. super(
  876. `session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
  877. + `requested ${JSON.stringify(requestedCwd)}`,
  878. )
  879. }
  880. }
  881. /** An explicit Host naming operation would duplicate another Workspace title. */
  882. class WorkspaceNameConflictError extends Error {
  883. constructor(readonly workspaceName: string) {
  884. super(`workspace name '${workspaceName}' is already in use`)
  885. this.name = 'WorkspaceNameConflictError'
  886. }
  887. }
  888. /** Shared workspace-not-found error response of the workspace.* mutation rows. */
  889. function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
  890. return err(request, {
  891. code: 'workspace-not-found',
  892. message: `workspace "${workspaceId}" not found`,
  893. details: { workspaceId },
  894. })
  895. }
  896. /** Wire projection of one workspace entity (the workspace.* value row). */
  897. function workspaceView(workspace: Workspace): WorkspaceView {
  898. return {
  899. workspaceId: workspace.id,
  900. path: workspace.path,
  901. title: workspace.title,
  902. sessionIds: [...workspace.sessionIds],
  903. createdAt: workspace.createdAt,
  904. updatedAt: workspace.updatedAt,
  905. }
  906. }
  907. /** Wire projection of the durable record carried by `domain/changed`. */
  908. function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
  909. const record: WorkspaceRecord = workspaceRecord.parse(value)
  910. return {
  911. workspaceId: workspaceId as WorkspaceId,
  912. path: record.path,
  913. title: record.title,
  914. sessionIds: [...record.sessionIds],
  915. createdAt: record.createdAt,
  916. updatedAt: record.updatedAt,
  917. }
  918. }
  919. /**
  920. * Implement ApiProxy over a composed host context.
  921. * @param ctx - a context with the Host spine and Workspace registry mounted.
  922. * @param defaults - Agent model and project-directory defaults.
  923. * @returns the ApiProxy implementation.
  924. */
  925. export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
  926. /** The seed model each create/resume declares; re-read so it never goes stale. */
  927. const agentOptions = (): AgentOptions => {
  928. const { provider, model } = defaults.defaultModelSelection()
  929. return { provider, model }
  930. }
  931. type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection }
  932. const selections = new WeakMap<Agent, WebModelSelectionRef>()
  933. /**
  934. * Serializes `agentPreset.select` per session. Two concurrent selects both
  935. * pass the blank check, and the second `unmountPresetFor` then finds nothing
  936. * to unmount because the first already removed the record — leaving two
  937. * compositions registered into one agent layer. The client's `busy` flag is
  938. * not enforcement: the wire is reachable directly.
  939. */
  940. const presetSwitches = new Map<SessionId, Promise<unknown>>()
  941. /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
  942. const sessionCreations = new Map<SessionId, Promise<Agent>>()
  943. /** Serializes path ownership and explicit title checks with Workspace mutations. */
  944. let workspaceCreationChain = Promise.resolve()
  945. const pendingQuestions = new Map<RpcId, PendingQuestion>()
  946. const pendingApprovals = new Map<RpcId, PendingApproval>()
  947. const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
  948. const imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
  949. /** Serialize image admission with model selection for one agent. */
  950. function serializeImageAdmission<T>(agent: Agent, operation: () => Promise<T>): Promise<T> {
  951. const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
  952. imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
  953. return result
  954. }
  955. /**
  956. * Install or return the session-local model selection that prompt assembly snapshots.
  957. *
  958. * Precedence, resolved on EVERY read rather than seeded once: a selection
  959. * made in this process, else the session's own latest logged request/header,
  960. * else the live Agent default. Re-reading keeps the two tiers exact in both
  961. * directions: a session with a recorded request derives its selection from
  962. * its log, while a blank session (New Session reuses one rather than minting
  963. * another) reads any default saved after it was created. There is no create-time
  964. * per-session override tier on this wire — if one returns (a create-options
  965. * contribution), it must fold in between the selection and the log.
  966. */
  967. function selectionFor(agent: Agent): WebModelSelectionRef {
  968. const installed = selections.get(agent)
  969. if (installed !== undefined) return installed
  970. let picked: ModelSelection | undefined
  971. const selection: WebModelSelectionRef = {
  972. get current(): ModelSelection {
  973. if (picked !== undefined) return picked
  974. // Incrementally folded by the session, so a per-step read costs
  975. // O(new events) rather than a rescan.
  976. const logged = agent.session.requestHeader()?.config
  977. if (logged === undefined) return defaults.defaultModelSelection()
  978. return {
  979. provider: logged.provider,
  980. model: logged.model,
  981. ...logged.reasoningEffort === undefined
  982. ? {}
  983. : { reasoningEffort: logged.reasoningEffort },
  984. }
  985. },
  986. set current(next: ModelSelection) {
  987. picked = next
  988. },
  989. assembled: undefined,
  990. }
  991. installModelSelection(agent.ctx, selection)
  992. selections.set(agent, selection)
  993. return selection
  994. }
  995. /** Pre-publication setup used by both fresh and resumed Web agents. */
  996. function installSelection(agentCtx: Context): void {
  997. const agent = agentCtx.agent
  998. if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
  999. selectionFor(agent)
  1000. }
  1001. /**
  1002. * Reject an attempt to run an existing session under a different preset.
  1003. *
  1004. * A caller that names no preset always adopts the session as it is, so the
  1005. * common paths — reconnecting, resuming, retrying a create — are unaffected.
  1006. * @param sessionId - the identity being adopted.
  1007. * @param requested - the preset the request named, if any.
  1008. * @param existing - the preset the session RUNS, if any; both callers resolve
  1009. * it from the log, which differs from the creation header once a blank
  1010. * session has switched.
  1011. * @throws when both are present and differ.
  1012. */
  1013. function assertPresetUnchanged(
  1014. sessionId: SessionId,
  1015. requested: string | undefined,
  1016. existing: string | undefined,
  1017. ): void {
  1018. if (requested === undefined || requested === existing) return
  1019. throw new AgentPresetConflict(sessionId, requested, existing)
  1020. }
  1021. /**
  1022. * Resolve the preset an agent will be composed from, and the setup that
  1023. * installs it.
  1024. *
  1025. * The id is resolved BEFORE the session exists because the session boundary
  1026. * snapshots `meta` before asynchronous setup begins — a preset discovered
  1027. * during setup could never reach the header. Mounting still happens in
  1028. * setup, where a failure rolls the whole creation back rather than leaving a
  1029. * published session whose capabilities are half-installed.
  1030. *
  1031. * A deployment with no preset roster composes nothing and every session
  1032. * shares the host composition, which is the behavior before presets existed.
  1033. * @param presetId - the requested preset, or `undefined` for the default.
  1034. * @returns the id to record on the header (absent without a roster) and the setup callback.
  1035. * @throws when the roster supplies no such preset.
  1036. */
  1037. async function composeAgent(presetId: string | undefined): Promise<{
  1038. agentPreset?: string
  1039. setup: (agentCtx: Context) => Promise<void>
  1040. }> {
  1041. const presets = ctx.get('agentPresets')
  1042. if (presets === undefined) {
  1043. return {
  1044. setup: (agentCtx: Context) => {
  1045. installSelection(agentCtx)
  1046. return Promise.resolve()
  1047. },
  1048. }
  1049. }
  1050. const resolvedId = (await presets.resolve(presetId)).id
  1051. return {
  1052. agentPreset: resolvedId,
  1053. setup: async (agentCtx: Context) => {
  1054. installSelection(agentCtx)
  1055. await presets.mount(agentCtx, resolvedId)
  1056. },
  1057. }
  1058. }
  1059. const hasSubagentOwner = (
  1060. session: Pick<Session, 'header'>,
  1061. agent: Agent | undefined,
  1062. ): boolean => hasApiRemoteSubagentOwner(ctx, session, agent)
  1063. const subagentOwnershipError = (sessionId: SessionId): RpcError =>
  1064. apiRemoteSubagentOwnershipError(sessionId)
  1065. const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> =>
  1066. inspectApiRemoteSession(ctx, sessionId)
  1067. // Cold resume composes the preset the session recorded, for the same reason
  1068. // `session.create` does: its history was produced under that composition.
  1069. // Every generic entry point — prompt, models, commands — arrives here, so
  1070. // leaving it out meant a session opened after a restart ran on host tools
  1071. // and the deployment persona. Resolved from the LOG, not the header: a
  1072. // session that switched while blank ran its turns under the newer
  1073. // composition, and the header is written once at creation. Reading the
  1074. // header here would silently undo the switch on the next restart and
  1075. // restore that history under the old tool set.
  1076. const agentFor = createApiRemoteAgentResolver(ctx, {
  1077. agentOptions,
  1078. setup: async ({ meta, events }) =>
  1079. (await composeAgent(resolveSessionPreset({ header: meta, events }))).setup,
  1080. })
  1081. /** Send one transient frame to every connected mux consumer. */
  1082. function broadcast(payload: MuxFrame): void {
  1083. const envelope = frame(payload)
  1084. for (const queue of muxQueues) queue.push(envelope)
  1085. }
  1086. // Projection change feed → session/projection push frames. The carrier
  1087. // mints the wire frame (the Service Definition package holds no wire vocabulary); the
  1088. // child activates only when a projection registry is composed, and the
  1089. // subscription unwinds with this gateway's fiber.
  1090. ctx.inject(['sessionProjections'], (projectionCtx) => {
  1091. projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
  1092. broadcast({ type: 'session/projection', sessionId: session.id, key, value, seq })
  1093. })
  1094. })
  1095. /** Project both durable inbox lists, optionally including the splice currently being emitted. */
  1096. const queueItems = (
  1097. agent: Agent,
  1098. splice?: SessionEventMap['agent/inbox/spliced'],
  1099. ): QueuedInboxItem[] => {
  1100. const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
  1101. const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
  1102. return splice?.target === target
  1103. ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
  1104. : messages
  1105. }
  1106. return [
  1107. ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })),
  1108. ...project('next-step').map(message => ({
  1109. id: message.id,
  1110. // Only user-origin messages are steering; injected context (approval
  1111. // notices, task completion, attached snapshots) is not a user action
  1112. // and must not render as a pending steering bubble.
  1113. placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
  1114. message,
  1115. })),
  1116. ]
  1117. }
  1118. ctx.on('session/event', (session, event) => {
  1119. if (event.type !== 'agent/inbox/spliced') return
  1120. const agent = ctx.agents.get(session.id)
  1121. if (agent?.session !== session) return
  1122. broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) })
  1123. })
  1124. /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
  1125. function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
  1126. pendingQuestions.delete(pending.rpcId)
  1127. if (pending.signal !== undefined && pending.onAbort !== undefined) {
  1128. pending.signal.removeEventListener('abort', pending.onAbort)
  1129. }
  1130. broadcast({
  1131. type: 'question/resolved', sessionId: pending.sessionId,
  1132. questionRpcId: pending.rpcId, outcome,
  1133. })
  1134. }
  1135. const disposeProvider = ctx.userInteraction.registerProvider({
  1136. ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
  1137. const sessionId = request.agent?.id
  1138. if (sessionId === undefined) {
  1139. return Promise.reject(new UserInteractionError(
  1140. 'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
  1141. }
  1142. return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
  1143. const rpcId = RpcId(randomUUID())
  1144. const pending: PendingQuestion = {
  1145. rpcId, sessionId, questions: request.questions, resolve, reject,
  1146. ...(request.signal === undefined ? {} : { signal: request.signal }),
  1147. }
  1148. const onAbort = (): void => {
  1149. claimQuestion(pending, 'cancelled')
  1150. reject(new UserInteractionError(
  1151. 'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
  1152. }
  1153. pending.onAbort = onAbort
  1154. pendingQuestions.set(rpcId, pending)
  1155. request.signal?.addEventListener('abort', onAbort, { once: true })
  1156. const envelope: RpcRequest<MuxFrame> = {
  1157. rpcId,
  1158. payload: { type: 'question/requested', sessionId, questions: request.questions },
  1159. }
  1160. for (const queue of muxQueues) queue.push(envelope)
  1161. })
  1162. },
  1163. })
  1164. ctx.effect(() => () => {
  1165. disposeProvider()
  1166. for (const pending of [...pendingQuestions.values()]) {
  1167. claimQuestion(pending, 'cancelled')
  1168. pending.reject(new UserInteractionError(
  1169. 'web user-interaction provider was disposed', 'ASK_ABORTED'))
  1170. }
  1171. }, 'api-proxy: user-interaction provider')
  1172. // --- Approval pending registry ------------------------------------------
  1173. // The proxy is the approval channel for every agent this host owns: an ask
  1174. // through `ctx.approval` becomes an answerable server-request on the mux
  1175. // stream (stable rpcId), settled by POST /api/respond. The entry survives
  1176. // client disconnects — mux-open replays still-pending requested frames with
  1177. // the same rpcId (the refresh-recovery baseline) — and withdraws on the
  1178. // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
  1179. if (ctx.get('approval') !== undefined) {
  1180. // Teardown parity with the question provider above: a gateway disposed
  1181. // while approvals are pending settles every entry as 'cancelled' (the
  1182. // service's fail-closed vocabulary), so no ask promise dangles past the
  1183. // proxy's lifetime and subscribers see the withdrawal.
  1184. ctx.effect(() => () => {
  1185. for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled')
  1186. }, 'api-proxy: approval registry teardown')
  1187. ctx.on('approval/request', (req, next) => {
  1188. // Dispatch rides a microtask behind the service's own signal check: an
  1189. // abort landing in that window would register the abort listener AFTER
  1190. // the signal fired — never invoked, entry pending forever, zombie frame
  1191. // on every mux replay. Settle synchronously instead of publishing.
  1192. if (req.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
  1193. // The audit pair `approval/asked` is already appended by the service
  1194. // before dispatch, but dispatch rides a microtask: parallel tool calls
  1195. // can append several asked events before any answerer runs. THIS
  1196. // request's event is therefore the newest asked event that is still
  1197. // undecided, unclaimed by another pending entry, and — when the ask
  1198. // names a call — carries the same callId.
  1199. const events = req.agent.session.events
  1200. const claimed = new Set<ApprovalRequestId>()
  1201. for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId)
  1202. const decided = new Set<ApprovalRequestId>()
  1203. let approvalId: ApprovalRequestId | undefined
  1204. for (let i = events.length - 1; i >= 0; i -= 1) {
  1205. const event = events[i] as SessionEvent
  1206. if (event.type === 'approval/decided') {
  1207. decided.add(event.data.id)
  1208. } else if (event.type === 'approval/asked') {
  1209. if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
  1210. // Symmetric pairing: a callId-bearing ask only takes its own call's
  1211. // record, and a callId-less ask only takes a callId-less record —
  1212. // so neither shape can steal the other's audit id under parallel
  1213. // asks. (Today every producer — the tool executor — passes callId;
  1214. // the callId-less arm guards any future non-tool asker.)
  1215. if ((req.callId ?? null) !== (event.data.callId ?? null)) continue
  1216. approvalId = event.data.id
  1217. break
  1218. }
  1219. }
  1220. // No asked event means the request bypassed the service's audit path —
  1221. // not this channel's question; delegate to the fail-closed default.
  1222. if (approvalId === undefined) return next()
  1223. const id = approvalId
  1224. return new Promise<ApprovalOutcome>((resolve) => {
  1225. const settle = (outcome: ApprovalOutcome): void => {
  1226. /* v8 ignore next 3 -- defensive double-settle guard: respond() routes
  1227. through the pending table (a settled id is not-pending before it can
  1228. re-settle) and the first settle removes the abort listener, so no
  1229. reachable path settles twice; kept against future settle callers. */
  1230. if (!pendingApprovals.delete(pending.rpcId)) return
  1231. req.signal?.removeEventListener('abort', onAbort)
  1232. broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome })
  1233. // A cancelled ask was already settled by the service's own signal
  1234. // race, which discards this late resolution; resolving is a no-op
  1235. // there and keeps this promise from dangling forever.
  1236. resolve(outcome)
  1237. }
  1238. const onAbort = (): void => { settle('cancelled') }
  1239. const pending: PendingApproval = {
  1240. rpcId: RpcId(randomUUID()),
  1241. sessionId: req.agent.session.id,
  1242. approvalId: id,
  1243. toolName: req.toolName,
  1244. ...req.callId === undefined ? {} : { callId: req.callId },
  1245. ...req.reason === undefined ? {} : { reason: req.reason },
  1246. resolve: settle,
  1247. }
  1248. pendingApprovals.set(pending.rpcId, pending)
  1249. req.signal?.addEventListener('abort', onAbort, { once: true })
  1250. const envelope = requestedFrame(pending)
  1251. for (const queue of muxQueues) queue.push(envelope)
  1252. })
  1253. })
  1254. }
  1255. type SessionReadState = {
  1256. id: SessionId
  1257. header: SessionHeader
  1258. events: SessionEvent[]
  1259. }
  1260. /** Read one stable session prefix without acquiring an Agent owner. */
  1261. async function readSessionState(sessionId: SessionId): Promise<SessionReadState> {
  1262. const attached = ctx.sessions.get(sessionId)
  1263. if (attached !== undefined) {
  1264. return {
  1265. id: attached.id,
  1266. header: attached.header,
  1267. events: [...attached.events],
  1268. }
  1269. }
  1270. const inspected = await inspectServable(sessionId)
  1271. return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
  1272. }
  1273. /** Resolve the Workspace inherited by a fork without making ordinary loose lineage grouped. */
  1274. async function forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
  1275. const workspaces = ctx.workspace.list()
  1276. const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
  1277. if (direct !== undefined || source.header.origin !== 'subagent') return direct
  1278. const lineage = await ctx.sessionQuery.traceSession(source.id)
  1279. for (const ancestor of lineage.ancestors) {
  1280. const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))
  1281. if (workspace !== undefined) return workspace
  1282. }
  1283. return undefined
  1284. }
  1285. /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
  1286. async function historyStateFor(
  1287. sessionId: SessionId,
  1288. includeProjections: boolean,
  1289. ): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
  1290. const attached = ctx.sessions.get(sessionId)
  1291. if (attached !== undefined) {
  1292. const events = [...attached.events]
  1293. const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
  1294. return { header: attached.header, events, ...projections === undefined ? {} : { projections } }
  1295. }
  1296. const inspected = await inspectServable(sessionId)
  1297. const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
  1298. return {
  1299. header: inspected.meta,
  1300. events: inspected.events,
  1301. ...projections === undefined ? {} : { projections },
  1302. }
  1303. }
  1304. /**
  1305. * The registry view scope a transcript's presenters resolve in.
  1306. *
  1307. * A live agent is that scope itself (its chain passes through its preset's
  1308. * standing layer). A cold session resolves its preset from the LOG, and the
  1309. * preset's STANDING key serves without resuming anything — ensuring the
  1310. * mount composes plugins but starts no agent, session, or turn. No roster,
  1311. * no recorded preset, or a preset the roster no longer supplies all fall
  1312. * back to the global layer: the transcript still serves, with the generic
  1313. * cards a viewless entry renders.
  1314. *
  1315. * Reading the header alone would render a session that switched while blank
  1316. * through the composition it was CREATED with. Every tool only the newer
  1317. * preset registers resolves to no presenter there, and the transcript
  1318. * silently degrades to generic cards for exactly the calls its history is
  1319. * made of.
  1320. * @param sessionId - the transcript being read.
  1321. * @param session - that session's header and log (attached or inspected).
  1322. * @returns the scope to pass to presenter lookups, or undefined for global.
  1323. */
  1324. async function presenterScopeFor(
  1325. sessionId: SessionId,
  1326. session: PresetBearingSession,
  1327. ): Promise<ScopeKey | undefined> {
  1328. const live = ctx.get('agents')?.get(sessionId)
  1329. if (live !== undefined) return live
  1330. const presets = ctx.get('agentPresets')
  1331. if (presets === undefined) return undefined
  1332. try {
  1333. // An unrecorded preset (a log from before the roster existed) renders
  1334. // through the DEFAULT preset's standing layer: that is the composition
  1335. // an unnamed session composes today, and presenters are pure display,
  1336. // so the worst a mismatch produces is the generic card it had anyway.
  1337. return await presets.standingKeyFor(resolveSessionPreset(session))
  1338. } catch {
  1339. // Swallows only the unknown/unusable-preset rejection from the roster:
  1340. // a deleted or broken preset must degrade this read, never fail it.
  1341. return undefined
  1342. }
  1343. }
  1344. /** Resolve one requested identity to a live agent, creating or resuming it once. */
  1345. async function ensureSession(
  1346. sessionId: SessionId,
  1347. cwd: string,
  1348. checkPersistedIdentity: boolean,
  1349. presetId?: string,
  1350. ): Promise<Agent> {
  1351. let creation = sessionCreations.get(sessionId)
  1352. if (creation === undefined) {
  1353. creation = (async () => {
  1354. const attached = ctx.sessions.get(sessionId)
  1355. const live = ctx.agents.get(sessionId)
  1356. if (attached !== undefined && hasSubagentOwner(attached, live)) {
  1357. throw new SubagentSessionOwnership(sessionId)
  1358. }
  1359. if (live !== undefined) return live
  1360. const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
  1361. const stored = persistence === undefined
  1362. ? undefined
  1363. : (await persistence.list()).find(header => header.id === sessionId)
  1364. if (persistence !== undefined && stored !== undefined) {
  1365. const inspected = await persistence.inspect(sessionId)
  1366. // Ownership first: explicit-id adoption of a session-backed
  1367. // subagent must answer `agent-busy` regardless of the requested
  1368. // cwd (the api/commands.ts contract), not a cwd conflict.
  1369. if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
  1370. throw new SubagentSessionOwnership(sessionId)
  1371. }
  1372. if (inspected.meta.cwd !== cwd) {
  1373. throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
  1374. }
  1375. // Resolved from the log, not the header: a session that switched
  1376. // while blank ran every turn under the newer composition.
  1377. const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
  1378. assertPresetUnchanged(sessionId, presetId, storedPreset)
  1379. // The stored preset wins over anything the request names: a resumed
  1380. // session's history was produced under that composition, and
  1381. // rebuilding it differently would replay tool calls the model can no
  1382. // longer make.
  1383. return (await ctx.agents.resume({
  1384. resumeSessionId: sessionId,
  1385. agentOptions: agentOptions(),
  1386. setup: (await composeAgent(storedPreset)).setup,
  1387. })).agent
  1388. }
  1389. try {
  1390. await mkdir(cwd, { recursive: true })
  1391. } catch (error: unknown) {
  1392. throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
  1393. }
  1394. const composition = await composeAgent(presetId)
  1395. return (await ctx.agents.create({
  1396. sessionId,
  1397. agentOptions: agentOptions(),
  1398. meta: {
  1399. cwd,
  1400. ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
  1401. },
  1402. setup: composition.setup,
  1403. })).agent
  1404. })().catch((error: unknown) => {
  1405. // Another Host entry path may have published the same identity while
  1406. // this operation crossed an asynchronous persistence/filesystem step.
  1407. const live = ctx.agents.get(sessionId)
  1408. if (live !== undefined) {
  1409. if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId)
  1410. return live
  1411. }
  1412. const attached = ctx.sessions.get(sessionId)
  1413. if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
  1414. throw new SubagentSessionOwnership(sessionId)
  1415. }
  1416. throw error
  1417. }).finally(() => {
  1418. sessionCreations.delete(sessionId)
  1419. })
  1420. sessionCreations.set(sessionId, creation)
  1421. }
  1422. const agent = await creation
  1423. if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
  1424. // Beside the cwd check for the same reason, and after the await so it
  1425. // covers every path that yields a live agent — freshly created, adopted
  1426. // live, resumed from disk, or recovered by the concurrent-creation catch.
  1427. assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
  1428. if (agent.session.header.cwd !== cwd) {
  1429. throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
  1430. }
  1431. return agent
  1432. }
  1433. /** Resolve or create one path while holding the Host's workspace-create chain. */
  1434. function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> {
  1435. const operation = workspaceCreationChain.then(async () => {
  1436. const existing = await ctx.workspace.resolveByPath(path)
  1437. if (existing !== undefined) return { workspace: existing, created: false }
  1438. return { workspace: await ctx.workspace.create(path), created: true }
  1439. })
  1440. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  1441. return operation
  1442. }
  1443. /**
  1444. * Build the session.list baseline shared by listing and search visibility.
  1445. * Attached sessions come from memory; servable cold sessions merge from
  1446. * persistence, and the final order is newest-first.
  1447. */
  1448. async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
  1449. signal?.throwIfAborted()
  1450. const items = ctx.sessions.list().map((session) => {
  1451. const agent = ctx.agents.get(session.id)
  1452. const projections = listProjectionsFor(ctx, session.header, session)
  1453. return {
  1454. ...summarize(session, agent?.status === 'running'),
  1455. ...projections === undefined ? {} : { projections },
  1456. }
  1457. })
  1458. signal?.throwIfAborted()
  1459. const attached = new Set(items.map(item => item.sessionId))
  1460. const persistence = ctx.get('sessionPersistence')
  1461. if (persistence !== undefined) {
  1462. const cold = (await persistence.list(signal))
  1463. .filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
  1464. signal?.throwIfAborted()
  1465. for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
  1466. signal?.throwIfAborted()
  1467. const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
  1468. const settled = await Promise.allSettled(
  1469. batch.map(async (meta) => {
  1470. // Cold rows read the persisted projection cache only — never a
  1471. // log load; a session without a cache row simply has no column.
  1472. const projections = listProjectionsFor(ctx, meta, undefined)
  1473. return {
  1474. ...await summarizeCold(persistence, meta, signal),
  1475. ...projections === undefined ? {} : { projections },
  1476. }
  1477. }),
  1478. )
  1479. const summaries: SessionSummary[] = []
  1480. let rejected = false
  1481. let failure: unknown
  1482. for (const result of settled) {
  1483. if (result.status === 'fulfilled') {
  1484. summaries.push(result.value)
  1485. } else if (!rejected) {
  1486. rejected = true
  1487. failure = result.reason
  1488. }
  1489. }
  1490. if (rejected) throw failure
  1491. signal?.throwIfAborted()
  1492. items.push(...summaries)
  1493. }
  1494. }
  1495. items.sort((a, b) => b.updatedAt - a.updatedAt)
  1496. return items
  1497. }
  1498. /**
  1499. * Resolve the goal service THIS agent runs.
  1500. *
  1501. * The service is per session: an agent preset mounts it behind an `isolate`
  1502. * realm, which no host context resolves. Reading it from the root would
  1503. * answer "absent" for a session whose composition mounts it — so the lookup
  1504. * is keyed by the agent, and only a deployment composing it nowhere is
  1505. * genuinely absent.
  1506. */
  1507. function goalServiceFor(agent: Agent): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
  1508. const presets = ctx.get('agentPresets')
  1509. const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals')
  1510. if (goals === undefined) {
  1511. return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } }
  1512. }
  1513. return goals
  1514. }
  1515. /** Map one goal-domain rejection to the wire error (stable GoalError codes ride in details). */
  1516. function goalError(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> {
  1517. const details = error instanceof GoalError ? { goalCode: error.code } : {}
  1518. return err(request, { code: 'internal', message: String(error), details })
  1519. }
  1520. /** Resolve a session's agent, apply one goal mutation, and acknowledge with the new CAS ref. */
  1521. async function mutateGoal(
  1522. request: RpcRequest<{ sessionId: SessionId }>,
  1523. mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
  1524. ): Promise<RpcResponse<{ ref: GoalRef }>> {
  1525. const found = await agentFor(request.payload.sessionId)
  1526. if ('error' in found) return err(request, found.error)
  1527. const goals = goalServiceFor(found.agent)
  1528. if ('error' in goals) return err(request, goals.error)
  1529. try {
  1530. const ref = mutation(goals, found.agent)
  1531. return ok(request, { ref: { id: ref.id, revision: ref.revision } })
  1532. } catch (error: unknown) {
  1533. return goalError(request, error)
  1534. }
  1535. }
  1536. /**
  1537. * Whether an adapter currently serves this provider, and therefore whether
  1538. * a session selecting it can start a turn. Catalog membership cannot answer
  1539. * it: an adapter may serve a model its own catalog stopped advertising, so
  1540. * a provider missing from the groups is not the same as one nothing serves.
  1541. * A composition with no llm registry at all cannot judge and says yes —
  1542. * the dispatch it would have refused fails on its own terms.
  1543. */
  1544. function routeServed(provider: string): boolean {
  1545. const llm = ctx.get('llm')
  1546. return llm === undefined || llm.listProviders().some(entry => entry.id === provider)
  1547. }
  1548. /**
  1549. * Resolve the addressed agent for a turn-starting method and refuse when no
  1550. * adapter serves its current selection: a provider nothing serves cannot start a
  1551. * turn, and letting it try spends the whole pre-step path to fail inside
  1552. * the adapter with a message about registration. Refusing here names the
  1553. * model the session is pointed at while the draft is still in the composer.
  1554. * This is `session.prompt`'s enforcement boundary: a client that disables
  1555. * its input is an affordance, and the method stays callable regardless.
  1556. */
  1557. async function turnAgentFor<T>(
  1558. request: RpcRequest<unknown>, sessionId: SessionId,
  1559. ): Promise<{ agent: Agent } | { refused: RpcResponse<T> }> {
  1560. const found = await agentFor(sessionId)
  1561. if ('error' in found) return { refused: err(request, found.error) }
  1562. const agent = found.agent
  1563. const selection = selectionFor(agent).current
  1564. if (!routeServed(selection.provider)) {
  1565. return {
  1566. refused: err(request, {
  1567. code: 'model-unavailable',
  1568. message: `no adapter serves provider "${selection.provider}"; select a model for this session`,
  1569. details: { provider: selection.provider, model: selection.model },
  1570. }),
  1571. }
  1572. }
  1573. return { agent }
  1574. }
  1575. /** Missing-service report shared by the settings domain (skills-domain stance). */
  1576. function settingsAbsent(): RpcError {
  1577. return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-local) in its composition', details: {} }
  1578. }
  1579. /** Open one Host-resolved target and map native failures onto the wire vocabulary. */
  1580. async function openTarget(
  1581. request: RpcRequest<unknown>, path: string, signal: AbortSignal,
  1582. open: (path: string, signal: AbortSignal) => Promise<void>,
  1583. ): Promise<RpcResponse<{ opened: true }>> {
  1584. try {
  1585. await open(path, signal)
  1586. return ok(request, { opened: true as const })
  1587. } catch (error: unknown) {
  1588. if (signal.aborted) {
  1589. return err(request, {
  1590. code: 'cancelled',
  1591. message: 'path open was aborted',
  1592. details: {},
  1593. })
  1594. }
  1595. return err(request, {
  1596. code: 'internal',
  1597. message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
  1598. details: {},
  1599. })
  1600. }
  1601. }
  1602. /** Open one Host-resolved path with its default application. */
  1603. function openPath(
  1604. request: RpcRequest<unknown>, path: string, signal: AbortSignal,
  1605. ): Promise<RpcResponse<{ opened: true }>> {
  1606. const open = defaults.openPath
  1607. ?? ((target: string, openSignal: AbortSignal) => openNativePath(target, openSignal))
  1608. return openTarget(request, path, signal, open)
  1609. }
  1610. /** Open one Host-resolved text document in a native editor. */
  1611. function openTextFile(
  1612. request: RpcRequest<unknown>, path: string, signal: AbortSignal,
  1613. ): Promise<RpcResponse<{ opened: true }>> {
  1614. const open = defaults.openTextFile
  1615. ?? ((target: string, openSignal: AbortSignal) => openNativeTextFile(target, openSignal))
  1616. return openTarget(request, path, signal, open)
  1617. }
  1618. /** Whether this deployment can hand a path to a native opener at all. */
  1619. function canOpenPaths(): boolean {
  1620. if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
  1621. // An injected opener is by definition usable; otherwise ask the platform.
  1622. return defaults.openPath !== undefined || canOpenNativePath()
  1623. }
  1624. /** Missing-service report shared by the credentials domain. */
  1625. function credentialsAbsent(): RpcError {
  1626. return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
  1627. }
  1628. /** Map one redacted settings descriptor to its wire view. */
  1629. function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
  1630. return {
  1631. ns: String(descriptor.ns),
  1632. schema: descriptor.schema,
  1633. value: descriptor.value,
  1634. ...descriptor.base === undefined ? {} : { base: descriptor.base },
  1635. ...descriptor.user === undefined ? {} : { user: descriptor.user },
  1636. applies: descriptor.applies,
  1637. secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
  1638. revision: descriptor.revision,
  1639. }
  1640. }
  1641. /** Settings namespaces whose changes can invalidate the model catalog. */
  1642. function modelProviderNamespaces(): Set<string> {
  1643. return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs))
  1644. }
  1645. /**
  1646. * The settings namespaces this proxy serves: configurable model providers
  1647. * plus the small explicit Web preference and product-owned allowlists. The
  1648. * settings seam remains general; a future registration does not become
  1649. * remotely readable or writable by default.
  1650. */
  1651. function exposedNamespaces(): Set<string> {
  1652. const exposed = modelProviderNamespaces()
  1653. for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns)
  1654. for (const ns of PRODUCT_SETTINGS_NAMESPACES) exposed.add(ns)
  1655. return exposed
  1656. }
  1657. /** Refuse a namespace outside the explicit configuration-client boundary. */
  1658. function notExposed(request: RpcRequest<unknown>, ns: string): RpcResponse<SettingsNamespaceView> {
  1659. return err(request, {
  1660. code: 'settings-not-exposed',
  1661. message: `settings namespace "${ns}" is not exposed to configuration clients`,
  1662. details: { ns },
  1663. })
  1664. }
  1665. /**
  1666. * Run one settings write (merge or wholesale replace) and acknowledge with
  1667. * the namespace's new redacted view. A namespace outside the configuration
  1668. * boundary is refused before the seam is touched; every seam refusal —
  1669. * unknown or invalid namespace, read-only provider, schema validation,
  1670. * storage — becomes one `settings-rejected` carrying the seam's own message.
  1671. */
  1672. async function settingsWrite(
  1673. request: RpcRequest<unknown>,
  1674. ns: string,
  1675. mode: 'update' | 'replace' | 'mutate',
  1676. section: object,
  1677. expectedRevision?: number,
  1678. ): Promise<RpcResponse<SettingsNamespaceView>> {
  1679. const settings = ctx.get('settings')
  1680. if (settings === undefined) return err(request, settingsAbsent())
  1681. const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => {
  1682. // A stale writer is its own outcome, not a malformed request: the client
  1683. // must re-read and re-apply rather than treat the write as invalid.
  1684. if (error instanceof SettingsConflictError) {
  1685. return err(request, {
  1686. code: 'settings-conflict',
  1687. message: error.message,
  1688. details: { ns, expected: error.expected, actual: error.actual },
  1689. })
  1690. }
  1691. return err(request, {
  1692. code: 'settings-rejected',
  1693. message: error instanceof Error ? error.message : String(error),
  1694. details: { ns },
  1695. })
  1696. }
  1697. let branded: SettingsNamespace
  1698. try {
  1699. branded = settingsNamespace(ns)
  1700. } catch (error: unknown) {
  1701. // A malformed name is a client bug, reported as such; it could never be
  1702. // in the exposed set either, so naming the real fault costs no ground.
  1703. return rejected(error)
  1704. }
  1705. if (!exposedNamespaces().has(ns)) return notExposed(request, ns)
  1706. try {
  1707. if (mode === 'update') await settings.update(branded, section, expectedRevision)
  1708. else if (mode === 'replace') await settings.replace(branded, section, expectedRevision)
  1709. else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision)
  1710. } catch (error: unknown) {
  1711. return rejected(error)
  1712. }
  1713. const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
  1714. if (descriptor === undefined) {
  1715. // The write committed but the namespace vanished before this read: only
  1716. // a concurrent registrant disposal can produce it.
  1717. return err(request, { code: 'internal', message: `settings namespace "${ns}" was disposed after the ${mode}`, details: {} })
  1718. }
  1719. return ok(request, namespaceView(descriptor))
  1720. }
  1721. return {
  1722. sessions: {
  1723. // Attached sessions summarize from memory; persisted-but-unattached (cold)
  1724. // sessions merge in from the persistence store so history survives restarts.
  1725. // Logs without a cwd are not served; every session records its project
  1726. // at create time.
  1727. async list(request) {
  1728. return ok(request, { items: await listVisibleSessionSummaries() })
  1729. },
  1730. async search(request, signal) {
  1731. const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, {
  1732. code: 'cancelled',
  1733. message: 'session search was aborted',
  1734. details: {},
  1735. })
  1736. if (isAborted(signal)) return cancelled()
  1737. const sessionQuery = ctx.get('sessionQuery')
  1738. if (sessionQuery === undefined) {
  1739. return err(request, {
  1740. code: 'internal',
  1741. message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
  1742. details: {},
  1743. })
  1744. }
  1745. try {
  1746. const visible = await listVisibleSessionSummaries(signal)
  1747. if (isAborted(signal)) return cancelled()
  1748. if (visible.length === 0) return ok(request, { items: [], hasMore: false })
  1749. const visibleIds = new Set(visible.map(item => item.sessionId))
  1750. const authorized: SessionSearchItem[] = []
  1751. const acceptedIds = new Set<SessionId>()
  1752. const seenCursors = new Set<SessionSearchCursor>()
  1753. let cursor: SessionSearchCursor | undefined
  1754. let providerCallCount = 0
  1755. let providerPageLimit = SESSION_SEARCH_RESULT_LIMIT
  1756. while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
  1757. if (isAborted(signal)) return cancelled()
  1758. if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) {
  1759. throw new Error(
  1760. `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`,
  1761. )
  1762. }
  1763. providerCallCount++
  1764. const requestedCursor = cursor
  1765. const requestedPageLimit = providerPageLimit
  1766. let page
  1767. try {
  1768. page = await sessionQuery.searchSessions({
  1769. query: request.payload.query,
  1770. eventFilters: [
  1771. { kind: 'type', values: ['user/message', 'assistant/message'] },
  1772. { kind: 'surface', values: ['current'] },
  1773. ],
  1774. limit: requestedPageLimit,
  1775. ...requestedCursor === undefined ? {} : { cursor: requestedCursor },
  1776. }, { signal })
  1777. } catch (error: unknown) {
  1778. if (isAborted(signal)) return cancelled()
  1779. if (
  1780. requestedCursor === undefined
  1781. && error instanceof SessionQueryError
  1782. && error.code === 'SESSION_QUERY_INVALID_LIMIT'
  1783. && requestedPageLimit > 1
  1784. ) {
  1785. providerPageLimit = Math.max(1, Math.floor(requestedPageLimit / 2))
  1786. continue
  1787. }
  1788. if (
  1789. requestedCursor !== undefined
  1790. && error instanceof SessionQueryError
  1791. && error.code === 'SESSION_QUERY_STALE_CURSOR'
  1792. ) {
  1793. authorized.length = 0
  1794. acceptedIds.clear()
  1795. seenCursors.clear()
  1796. cursor = undefined
  1797. continue
  1798. }
  1799. throw error
  1800. }
  1801. if (isAborted(signal)) return cancelled()
  1802. const providerItemCount = page.items.length
  1803. if (providerItemCount > requestedPageLimit) {
  1804. throw new Error(
  1805. `session search provider returned ${providerItemCount} items; maximum is ${requestedPageLimit}`,
  1806. )
  1807. }
  1808. // Host visibility is the authorization boundary. Consume the
  1809. // provider's globally ranked results rather than binding every
  1810. // visible id into one SQLite statement, then require each hit to
  1811. // name a visible session and a current message from that same
  1812. // session before emitting its snippet.
  1813. for (const hit of page.items) {
  1814. if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
  1815. if (
  1816. !visibleIds.has(hit.header.id)
  1817. || hit.bestMatch.sessionId !== hit.header.id
  1818. || hit.bestMatch.surface !== 'current'
  1819. || !MESSAGE_TYPES.has(hit.bestMatch.type)
  1820. || acceptedIds.has(hit.header.id)
  1821. ) continue
  1822. const snippet = truncateUnicodeCodePoints(
  1823. hit.bestMatch.snippet,
  1824. SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
  1825. )
  1826. acceptedIds.add(hit.header.id)
  1827. authorized.push({
  1828. sessionId: hit.header.id,
  1829. snippet,
  1830. })
  1831. }
  1832. const nextCursor = page.nextCursor
  1833. if (nextCursor !== undefined) {
  1834. if (seenCursors.has(nextCursor)) {
  1835. throw new Error('session search provider repeated a continuation cursor')
  1836. }
  1837. seenCursors.add(nextCursor)
  1838. }
  1839. if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || nextCursor === undefined) break
  1840. cursor = nextCursor
  1841. }
  1842. return ok(request, {
  1843. items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
  1844. hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
  1845. })
  1846. } catch (error: unknown) {
  1847. if (
  1848. isAborted(signal)
  1849. || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')
  1850. ) return cancelled()
  1851. // XXX: Redact provider details before exposing this gateway beyond
  1852. // its current single-user local deployment.
  1853. return err(request, {
  1854. code: 'internal',
  1855. message: `session search failed: ${String(error)}`,
  1856. details: {},
  1857. })
  1858. }
  1859. },
  1860. async create(request) {
  1861. const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
  1862. let workspace: Workspace | undefined
  1863. if (request.payload.workspaceId !== undefined) {
  1864. workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
  1865. if (workspace === undefined) {
  1866. return err(request, {
  1867. code: 'workspace-not-found',
  1868. message: `workspace "${request.payload.workspaceId}" not found`,
  1869. details: { workspaceId: request.payload.workspaceId },
  1870. })
  1871. }
  1872. }
  1873. const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
  1874. const requestedPreset = request.payload.agentPreset
  1875. try {
  1876. await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
  1877. } catch (error: unknown) {
  1878. if (error instanceof AgentPresetConflict) {
  1879. return err(request, {
  1880. code: 'agent-preset-conflict',
  1881. message: error.message,
  1882. details: {
  1883. sessionId: error.sessionId,
  1884. requestedPreset: error.requestedPreset,
  1885. ...error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset },
  1886. },
  1887. })
  1888. }
  1889. const refused = presetFailure(request, error)
  1890. if (refused !== undefined) return refused
  1891. if (error instanceof SessionCwdConflict) {
  1892. return err(request, {
  1893. code: 'session-conflict',
  1894. message: error.message,
  1895. details: {
  1896. sessionId: error.sessionId,
  1897. requestedCwd: error.requestedCwd,
  1898. ...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
  1899. },
  1900. })
  1901. }
  1902. if (error instanceof SubagentSessionOwnership) {
  1903. return err(request, subagentOwnershipError(error.sessionId))
  1904. }
  1905. return err(request, {
  1906. code: 'internal',
  1907. message: `failed to create session "${sessionId}": ${String(error)}`,
  1908. details: {},
  1909. })
  1910. }
  1911. if (workspace !== undefined) {
  1912. try {
  1913. await workspace.attachSession(sessionId)
  1914. } catch (error: unknown) {
  1915. return err(request, {
  1916. code: 'workspace-attach-failed',
  1917. message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
  1918. details: { sessionId, workspaceId: workspace.id },
  1919. })
  1920. }
  1921. }
  1922. // Echo the composition the session RUNS so a client can label it
  1923. // without waiting for the next list refresh — the create is the commit
  1924. // point that knows it (a caller that named none gets the default).
  1925. // Resolved from the log for the same reason `sessionListFields()` is:
  1926. // this handler also adopts an already-live session, and one that
  1927. // switched while blank runs a preset its header no longer names, so
  1928. // echoing the header would contradict both the adoption this call just
  1929. // allowed and the row `session.list` serves for the same session.
  1930. const created = ctx.agents.get(sessionId)
  1931. const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session)
  1932. return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
  1933. },
  1934. async history(request) {
  1935. const { sessionId, beforeSeq, maxMessages } = request.payload
  1936. let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }
  1937. try {
  1938. state = await historyStateFor(sessionId, beforeSeq === undefined)
  1939. } catch (error: unknown) {
  1940. if (error instanceof SessionNotFound) {
  1941. return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
  1942. }
  1943. return err(request, {
  1944. code: 'internal',
  1945. message: `history unavailable for session "${sessionId}": ${String(error)}`,
  1946. details: {},
  1947. })
  1948. }
  1949. const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state))
  1950. return ok(request, {
  1951. events: page.events,
  1952. hasMore: page.hasMore,
  1953. ...state.projections === undefined ? {} : { projections: state.projections },
  1954. })
  1955. },
  1956. async models(request) {
  1957. const { sessionId } = request.payload
  1958. const found = await agentFor(sessionId)
  1959. if ('error' in found) return err(request, found.error)
  1960. const current = selectionFor(found.agent).current
  1961. const { groups, failures } = await buildModelCatalog(ctx)
  1962. const routable = routeServed(current.provider)
  1963. return ok(request, { current: { ...current }, routable, groups, failures })
  1964. },
  1965. async selectModel(request) {
  1966. const { sessionId, provider, model, reasoningEffort } = request.payload
  1967. const found = await agentFor(sessionId)
  1968. if ('error' in found) return err(request, found.error)
  1969. return serializeImageAdmission(found.agent, async () => {
  1970. try {
  1971. const resolved = await ctx.llm.resolveCallConfig({
  1972. provider,
  1973. model,
  1974. ...reasoningEffort === undefined
  1975. ? {}
  1976. : { reasoningEffort: ReasoningEffortId(reasoningEffort) },
  1977. })
  1978. const pendingImage = [...found.agent.inbox.nextTurn, ...found.agent.inbox.nextStep]
  1979. .some(message => contentHasImage(message.content))
  1980. if (pendingImage || messagesHaveImage(found.agent.session.deriveMessages())) {
  1981. const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
  1982. if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
  1983. return err(request, {
  1984. code: 'model-unavailable',
  1985. message: `Model "${resolved.model}" does not accept image input, but this session already contains images; select an image-capable model.`,
  1986. details: { provider, model },
  1987. })
  1988. }
  1989. }
  1990. const selected: ModelSelection = {
  1991. provider: resolved.provider,
  1992. model: resolved.model,
  1993. ...resolved.reasoningEffort === undefined
  1994. ? {}
  1995. : { reasoningEffort: resolved.reasoningEffort },
  1996. }
  1997. selectionFor(found.agent).current = selected
  1998. try {
  1999. await defaults.saveDefaultModelSelection?.(selected)
  2000. } catch (error: unknown) {
  2001. ctx.logger.warn(
  2002. `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
  2003. )
  2004. }
  2005. return ok(request, { selected: { ...selected } })
  2006. } catch (error: unknown) {
  2007. return err(request, {
  2008. code: 'model-unavailable',
  2009. message: error instanceof Error ? error.message : String(error),
  2010. details: { provider, model },
  2011. })
  2012. }
  2013. })
  2014. },
  2015. async rename(request) {
  2016. const { sessionId, title } = request.payload
  2017. const found = await agentFor(sessionId)
  2018. if ('error' in found) return err(request, found.error)
  2019. const titles = ctx.get('sessionTitle')
  2020. if (titles === undefined) {
  2021. return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} })
  2022. }
  2023. try {
  2024. const accepted = titles.rename(found.agent.session, title)
  2025. return ok(request, { title: accepted.title, seq: accepted.eventSeq })
  2026. } catch (error: unknown) {
  2027. // Only the input's fault maps to title-invalid (the message is
  2028. // product-user-visible in the rename dialog); liveness and disposal
  2029. // races are deployment trouble, not a bad title.
  2030. if (error instanceof SessionTitleInvalidError) {
  2031. return err(request, {
  2032. code: 'title-invalid',
  2033. message: error.message,
  2034. details: { sessionId },
  2035. })
  2036. }
  2037. return err(request, {
  2038. code: 'internal',
  2039. message: `failed to rename session "${sessionId}": ${String(error)}`,
  2040. details: {},
  2041. })
  2042. }
  2043. },
  2044. async fork(request) {
  2045. const { sessionId, atSeq } = request.payload
  2046. let source: SessionReadState
  2047. try {
  2048. source = await readSessionState(sessionId)
  2049. } catch (error: unknown) {
  2050. if (error instanceof SessionNotFound) {
  2051. return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
  2052. }
  2053. return err(request, {
  2054. code: 'internal',
  2055. message: `fork source unavailable for session "${sessionId}": ${String(error)}`,
  2056. details: {},
  2057. })
  2058. }
  2059. const events = source.events
  2060. // An in-log anchor belongs to the turn containing it and must never
  2061. // clip backward to an earlier completed turn. Omitted and past-end
  2062. // anchors retain the last-completed-turn shortcut.
  2063. const lastSeq = events.at(-1)?.seq ?? -1
  2064. const anchoredBoundary = atSeq === undefined
  2065. ? undefined
  2066. : events.find(e => e.type === 'turn/end' && e.seq >= atSeq)
  2067. const boundary = anchoredBoundary
  2068. ?? (atSeq === undefined || atSeq > lastSeq
  2069. ? events.findLast(e => e.type === 'turn/end')
  2070. : undefined)
  2071. if (boundary === undefined) {
  2072. return err(request, {
  2073. code: 'fork-unavailable',
  2074. message: atSeq !== undefined && atSeq <= lastSeq
  2075. ? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}`
  2076. : `session "${sessionId}" has no completed turn to fork from`,
  2077. details: { sessionId },
  2078. })
  2079. }
  2080. // Extend the cut through trailing out-of-band appends (session/title,
  2081. // injections) up to the next turn/start: they are standalone events, so
  2082. // the seed stays balanced, and the child inherits a title generated
  2083. // right after the boundary turn.
  2084. let cut = boundary.seq + 1
  2085. while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
  2086. let workspace: Workspace | undefined
  2087. try {
  2088. workspace = await forkWorkspace(source)
  2089. } catch (error: unknown) {
  2090. return err(request, {
  2091. code: 'internal',
  2092. message: `failed to resolve fork workspace for session "${sessionId}": ${String(error)}`,
  2093. details: {},
  2094. })
  2095. }
  2096. const childId = `session-${randomUUID()}` as SessionId
  2097. // The child inherits the parent's composition for the same reason a
  2098. // resumed session keeps its own: the seeded history was produced under
  2099. // those tools, and composing anything else would strand the tool calls
  2100. // it already carries. Now that no model-facing row sits in the host
  2101. // plane, composing nothing would leave the child with no tools at all.
  2102. const forkComposition = await composeAgent(resolveSessionPreset(source))
  2103. try {
  2104. await ctx.agents.create({
  2105. sessionId: childId,
  2106. seed: events.slice(0, cut),
  2107. meta: {
  2108. ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
  2109. parentSession: source.id,
  2110. seedLength: cut,
  2111. ...forkComposition.agentPreset === undefined
  2112. ? {}
  2113. : { agentPreset: forkComposition.agentPreset },
  2114. },
  2115. agentOptions: agentOptions(),
  2116. setup: forkComposition.setup,
  2117. })
  2118. } catch (error: unknown) {
  2119. return err(request, {
  2120. code: 'internal',
  2121. message: `failed to fork session "${sessionId}": ${String(error)}`,
  2122. details: {},
  2123. })
  2124. }
  2125. // An ordinary source keeps its direct Workspace. A subagent source is
  2126. // not listed there, so its ordinary fork joins the nearest owning
  2127. // ancestor instead. The child is already published if attach fails.
  2128. if (workspace !== undefined) {
  2129. try {
  2130. await workspace.attachSession(childId)
  2131. } catch (error: unknown) {
  2132. return err(request, {
  2133. code: 'workspace-attach-failed',
  2134. message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
  2135. details: { sessionId: childId, workspaceId: workspace.id },
  2136. })
  2137. }
  2138. }
  2139. return ok(request, { sessionId: childId })
  2140. },
  2141. async prompt(request) {
  2142. const { sessionId, mode, content } = request.payload
  2143. const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId)
  2144. if ('refused' in resolved) return resolved.refused
  2145. const agent = resolved.agent
  2146. // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
  2147. const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
  2148. const hasImage = content.some(part => part.type === 'image')
  2149. const admit = async (): Promise<RpcResponse<{ accepted: true }>> => {
  2150. try {
  2151. if (hasImage) {
  2152. const current = selectionFor(agent).current
  2153. const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model)
  2154. if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) {
  2155. return err(request, {
  2156. code: 'attachment-error',
  2157. message: `Model "${current.model}" does not support image input.`,
  2158. details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
  2159. })
  2160. }
  2161. }
  2162. const durable = await durablePromptContent(ctx, content)
  2163. const message: UserMessage = createUserMessage({ content: durable, source })
  2164. if (mode === 'steer') agent.steer(message)
  2165. else agent.followup(message)
  2166. } catch (error: unknown) {
  2167. if (error instanceof AttachmentError) {
  2168. return err(request, {
  2169. code: 'attachment-error',
  2170. message: error.message,
  2171. details: { reason: error.code },
  2172. })
  2173. }
  2174. return err(request, {
  2175. code: 'agent-busy',
  2176. message: 'prompt rejected',
  2177. details: { reason: String(error) },
  2178. })
  2179. }
  2180. return ok(request, { accepted: true as const })
  2181. }
  2182. return hasImage ? serializeImageAdmission(agent, admit) : admit()
  2183. },
  2184. async attachment(request) {
  2185. const { sessionId, attachmentId } = request.payload
  2186. let state: SessionReadState
  2187. try {
  2188. state = await readSessionState(sessionId)
  2189. } catch (error: unknown) {
  2190. if (error instanceof SessionNotFound) {
  2191. return err(request, {
  2192. code: 'session-not-found',
  2193. message: error.message,
  2194. details: { sessionId },
  2195. })
  2196. }
  2197. return err(request, {
  2198. code: 'internal',
  2199. message: `attachment authorization unavailable for session "${sessionId}": ${String(error)}`,
  2200. details: {},
  2201. })
  2202. }
  2203. const ref = referencedImage(state.events, String(attachmentId))
  2204. if (ref === undefined) {
  2205. return err(request, {
  2206. code: 'attachment-error',
  2207. message: 'Image is not referenced by this session.',
  2208. details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
  2209. })
  2210. }
  2211. try {
  2212. const stored = await ctx.attachments.readImage(ref)
  2213. return ok(request, {
  2214. attachment: stored.ref,
  2215. data: Buffer.from(stored.data).toString('base64'),
  2216. })
  2217. } catch (error: unknown) {
  2218. if (error instanceof AttachmentError) {
  2219. return err(request, {
  2220. code: 'attachment-error',
  2221. message: error.message,
  2222. details: { reason: error.code },
  2223. })
  2224. }
  2225. return err(request, {
  2226. code: 'internal',
  2227. message: 'Unable to read image attachment.',
  2228. details: {},
  2229. })
  2230. }
  2231. },
  2232. updateQueue(request) {
  2233. const { sessionId, itemId, action } = request.payload
  2234. if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) {
  2235. return Promise.resolve(err(request, {
  2236. code: 'attachment-error',
  2237. message: 'queue edits accept text content only',
  2238. details: { reason: 'QUEUE_EDIT_NON_TEXT' },
  2239. }))
  2240. }
  2241. const agent = ctx.agents.get(sessionId)
  2242. if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
  2243. return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
  2244. }
  2245. if (agent === undefined) {
  2246. return Promise.resolve(err(request, {
  2247. code: 'queue-item-not-found',
  2248. message: 'queued item is no longer pending',
  2249. details: { itemId },
  2250. }))
  2251. }
  2252. const target = agent.inbox.nextTurn.some(message => message.id === itemId)
  2253. ? 'next-turn'
  2254. : agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined
  2255. const message = target === undefined
  2256. ? undefined
  2257. : (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep)
  2258. .find(candidate => candidate.id === itemId)
  2259. if (target === undefined || message === undefined) {
  2260. return Promise.resolve(err(request, {
  2261. code: 'queue-item-not-found',
  2262. message: 'queued item is no longer pending',
  2263. details: { itemId },
  2264. }))
  2265. }
  2266. if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
  2267. return Promise.resolve(err(request, {
  2268. code: 'steer-unavailable',
  2269. message: 'current turn no longer accepts steering',
  2270. details: { itemId },
  2271. }))
  2272. }
  2273. if (action.kind === 'edit') {
  2274. agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content }))
  2275. } else {
  2276. agent.inbox.remove(itemId)
  2277. if (action.kind === 'steer') agent.steer(message)
  2278. }
  2279. return Promise.resolve(ok(request, { accepted: true as const }))
  2280. },
  2281. cancel(request) {
  2282. const { sessionId } = request.payload
  2283. const agent = ctx.agents.get(sessionId)
  2284. if (agent === undefined) {
  2285. return Promise.resolve(err(request, {
  2286. code: 'session-not-found',
  2287. message: `session "${sessionId}" not found (not attached)`,
  2288. details: { sessionId },
  2289. }))
  2290. }
  2291. if (hasSubagentOwner(agent.session, agent)) {
  2292. return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
  2293. }
  2294. agent.cancel({ kind: 'user' }, { keepInbox: true })
  2295. return Promise.resolve(ok(request, { accepted: true as const }))
  2296. },
  2297. },
  2298. subagents: {
  2299. async list(request, signal) {
  2300. try {
  2301. const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
  2302. return ok(request, {
  2303. entries: entries.map(entry => entry.kind === 'child'
  2304. ? {
  2305. ...entry,
  2306. activity: ctx.agents.get(entry.id)?.status === 'running' ? 'running' : 'inactive',
  2307. }
  2308. : entry),
  2309. parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
  2310. })
  2311. } catch (error: unknown) {
  2312. if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
  2313. return err(request, {
  2314. code: 'cancelled',
  2315. message: 'subagent catalog read was cancelled',
  2316. details: {},
  2317. })
  2318. }
  2319. if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
  2320. return err(request, projectionsUnavailableError())
  2321. }
  2322. return err(request, {
  2323. code: 'internal',
  2324. message: 'subagent catalog read failed',
  2325. details: {},
  2326. })
  2327. }
  2328. },
  2329. async history(request, signal) {
  2330. const {
  2331. parentSessionId, childSessionId, mode, beforeSeq, maxMessages,
  2332. } = request.payload
  2333. const verified = await catalogChild(ctx, {
  2334. parentSessionId, childSessionId, mode,
  2335. }, signal)
  2336. if (verified.error !== undefined) return err(request, verified.error)
  2337. // The generic-history data plane: an attached child serves its
  2338. // in-memory snapshot and the registry's live watermark projections; a
  2339. // cold child is one persistence inspection plus a detached fold.
  2340. let header: SessionHeader
  2341. let events: SessionEvent[]
  2342. let projections: SessionProjectionsBlock | undefined
  2343. const attached = ctx.sessions.get(childSessionId)
  2344. if (attached !== undefined) {
  2345. header = attached.header
  2346. events = [...attached.events]
  2347. projections = beforeSeq === undefined
  2348. ? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached))
  2349. : undefined
  2350. } else {
  2351. try {
  2352. const inspected = await inspectServable(childSessionId)
  2353. header = inspected.meta
  2354. events = inspected.events
  2355. projections = beforeSeq === undefined
  2356. ? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
  2357. : undefined
  2358. } catch (error: unknown) {
  2359. if (signal?.aborted) {
  2360. return err(request, {
  2361. code: 'cancelled',
  2362. message: 'subagent history read was cancelled',
  2363. details: {},
  2364. })
  2365. }
  2366. if (error instanceof SessionNotFound) {
  2367. return err(request, {
  2368. code: 'subagent-not-found',
  2369. message: 'subagent disappeared during history read',
  2370. details: { parentSessionId, childSessionId },
  2371. })
  2372. }
  2373. return err(request, {
  2374. code: 'internal',
  2375. message: 'subagent history read failed',
  2376. details: {},
  2377. })
  2378. }
  2379. }
  2380. if (signal?.aborted) {
  2381. return err(request, {
  2382. code: 'cancelled',
  2383. message: 'subagent history read was cancelled',
  2384. details: {},
  2385. })
  2386. }
  2387. if (header.parentSession !== parentSessionId) {
  2388. return err(request, {
  2389. code: 'subagent-unauthorized',
  2390. message: 'subagent parent changed during history read',
  2391. details: { childSessionId },
  2392. })
  2393. }
  2394. const page = historyPage(ctx, events, beforeSeq, maxMessages)
  2395. return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
  2396. },
  2397. async prompt(request, signal) {
  2398. const { parentSessionId, childSessionId, content } = request.payload
  2399. const parent = ctx.agents.get(parentSessionId)
  2400. if (parent === undefined) {
  2401. return err(request, {
  2402. code: 'subagent-parent-unavailable',
  2403. message: `parent session "${parentSessionId}" is not live`,
  2404. details: { parentSessionId },
  2405. })
  2406. }
  2407. const verified = await catalogChild(ctx, {
  2408. parentSessionId, childSessionId, mode: 'continuable',
  2409. }, signal)
  2410. if (verified.error !== undefined) return err(request, verified.error)
  2411. try {
  2412. const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
  2413. source: { kind: 'user', rpcId: request.rpcId },
  2414. signal,
  2415. })
  2416. return ok(request, { messageId })
  2417. } catch (error: unknown) {
  2418. return subagentPromptError(request, error, signal)
  2419. }
  2420. },
  2421. // Deliberately no catalog, history, persistence, or parent Agent lookup:
  2422. // the core primitive alone authorizes the durable address against the
  2423. // live Activation, which is what keeps a live child interruptible while
  2424. // its parent Agent is offline. Absent targets are accepted no-ops there.
  2425. interrupt(request) {
  2426. const { parentSessionId, childSessionId } = request.payload
  2427. try {
  2428. ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId })
  2429. } catch (error: unknown) {
  2430. if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
  2431. return Promise.resolve(err(request, {
  2432. code: 'subagent-unauthorized',
  2433. message: 'subagent does not belong to this parent',
  2434. details: { childSessionId },
  2435. }))
  2436. }
  2437. return Promise.resolve(err(request, {
  2438. code: 'internal',
  2439. message: 'subagent interrupt failed',
  2440. details: {},
  2441. }))
  2442. }
  2443. return Promise.resolve(ok(request, { accepted: true as const }))
  2444. },
  2445. },
  2446. workspace: {
  2447. list(request) {
  2448. return Promise.resolve(ok(request, {
  2449. items: ctx.workspace.list().map(workspaceView),
  2450. archivedSessionIds: [...ctx.workspace.archivedSessionIds],
  2451. }))
  2452. },
  2453. async create(request) {
  2454. const { path } = request.payload
  2455. try {
  2456. const { workspace, created } = await ensureWorkspace(path)
  2457. return ok(request, { workspace: workspaceView(workspace), created })
  2458. } catch (error: unknown) {
  2459. // The registry rejects a path that does not resolve to an existing
  2460. // directory (realpath ENOENT / not-a-directory) — the business
  2461. // error of the typed-path flow, surfaced as a validation failure.
  2462. return err(request, {
  2463. code: 'workspace-invalid-path',
  2464. message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
  2465. details: { path },
  2466. })
  2467. }
  2468. },
  2469. async rename(request) {
  2470. const { payload } = request
  2471. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  2472. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  2473. const title = payload.title.trim()
  2474. // Uniqueness AND the same-title no-op both ride the create chain so
  2475. // they observe the state left by earlier queued renames — checked
  2476. // up front, a queued A→A could report success while an earlier A→B
  2477. // still lands afterwards.
  2478. const operation = workspaceCreationChain.then(async () => {
  2479. if (title === workspace.title) return
  2480. if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
  2481. throw new WorkspaceNameConflictError(title)
  2482. }
  2483. await workspace.setTitle(title)
  2484. })
  2485. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  2486. try {
  2487. await operation
  2488. } catch (error: unknown) {
  2489. if (error instanceof WorkspaceNameConflictError) {
  2490. return err(request, {
  2491. code: 'workspace-name-conflict',
  2492. message: error.message,
  2493. details: { name: error.workspaceName },
  2494. })
  2495. }
  2496. throw error
  2497. }
  2498. return ok(request, { workspace: workspaceView(workspace) })
  2499. },
  2500. async delete(request) {
  2501. const { workspaceId } = request.payload
  2502. const operation = workspaceCreationChain.then(() =>
  2503. ctx.workspace.delete(brandWorkspaceId(workspaceId)))
  2504. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  2505. if (!await operation) return workspaceNotFound(request, workspaceId)
  2506. return ok(request, { deleted: true as const })
  2507. },
  2508. async insertSessionBefore(request) {
  2509. const { payload } = request
  2510. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  2511. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  2512. try {
  2513. await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
  2514. } catch (error: unknown) {
  2515. // Only the entity's unaccounted-id rejection is the business code;
  2516. // storage/durability failures propagate as internal errors.
  2517. if (!(error instanceof WorkspaceMoveInvalidError)) throw error
  2518. return err(request, {
  2519. code: 'workspace-move-invalid',
  2520. message: error.message,
  2521. details: {
  2522. workspaceId: payload.workspaceId,
  2523. sessionId: payload.sessionId,
  2524. ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
  2525. },
  2526. })
  2527. }
  2528. return ok(request, { workspace: workspaceView(workspace) })
  2529. },
  2530. async archiveSession(request) {
  2531. const { sessionId } = request.payload
  2532. try {
  2533. await ctx.workspace.archiveSession(sessionId)
  2534. } catch (error: unknown) {
  2535. // Only the registry's unknown-session rejection is the business
  2536. // code; storage/durability failures propagate as internal errors.
  2537. if (!(error instanceof WorkspaceUnknownSessionError)) throw error
  2538. return err(request, {
  2539. code: 'session-not-found',
  2540. message: error.message,
  2541. details: { sessionId },
  2542. })
  2543. }
  2544. return ok(request, { archivedSessionIds: [...ctx.workspace.archivedSessionIds] })
  2545. },
  2546. },
  2547. host: {
  2548. describe(request) {
  2549. // TODO: version should read apps/cli's package.json; placeholder for now.
  2550. const selection = defaults.defaultModelSelection()
  2551. return Promise.resolve(ok(request, {
  2552. version: '0.0.1',
  2553. // Same source as session.create's fallback: the UI's default project
  2554. // must match where an unspecified-cwd session actually lands.
  2555. cwd: defaults.cwd,
  2556. // Read live for the same reason: this is what the NEXT session will
  2557. // start from, so a saved default has to be what it reports.
  2558. provider: selection.provider,
  2559. model: selection.model,
  2560. attachedSessions: ctx.agents.list().length,
  2561. }))
  2562. },
  2563. async pickDirectory(request, signal) {
  2564. const capability = ctx.directoryPicker.capability()
  2565. if (capability.kind !== 'native') {
  2566. return err(request, {
  2567. code: 'directory-picker-unavailable',
  2568. message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`,
  2569. details: { capability: capability.kind },
  2570. })
  2571. }
  2572. try {
  2573. const path = await capability.pick(signal)
  2574. return ok(request, { path })
  2575. } catch (error: unknown) {
  2576. if (signal.aborted) {
  2577. return err(request, {
  2578. code: 'cancelled',
  2579. message: 'directory picker was aborted',
  2580. details: {},
  2581. })
  2582. }
  2583. return err(request, {
  2584. code: 'internal',
  2585. message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
  2586. details: {},
  2587. })
  2588. }
  2589. },
  2590. async listDirectory(request, signal) {
  2591. const capability = ctx.directoryPicker.capability()
  2592. if (capability.kind !== 'browse') {
  2593. return err(request, {
  2594. code: 'directory-picker-unavailable',
  2595. message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
  2596. details: { capability: capability.kind },
  2597. })
  2598. }
  2599. try {
  2600. // The carrier's signal follows the caller: a disconnect or timeout
  2601. // stops the backend's directory scan instead of outliving it.
  2602. return ok(request, await capability.list(request.payload.path, signal))
  2603. } catch (error: unknown) {
  2604. // An abort is the caller's own timeout/disconnect, not a server
  2605. // failure — same code pickDirectory and command.execute report.
  2606. if (signal.aborted) {
  2607. return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} })
  2608. }
  2609. return err(request, directoryError(error))
  2610. }
  2611. },
  2612. async createDirectory(request) {
  2613. const capability = ctx.directoryPicker.capability()
  2614. if (capability.kind !== 'browse') {
  2615. return err(request, {
  2616. code: 'directory-picker-unavailable',
  2617. message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
  2618. details: { capability: capability.kind },
  2619. })
  2620. }
  2621. try {
  2622. return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) })
  2623. } catch (error: unknown) {
  2624. return err(request, directoryError(error))
  2625. }
  2626. },
  2627. async openPath(request, signal) {
  2628. return openPath(request, request.payload.path, signal)
  2629. },
  2630. },
  2631. commands: {
  2632. // Both methods address one session's agent. agentFor resumes on miss
  2633. // and fences every subagent-owned identity with `agent-busy`; the
  2634. // api/commands.ts module contract owns that fence's wording, so this
  2635. // comment only notes the routing shape: clients send a sessionId for a
  2636. // published session, and resume restores an existing entity.
  2637. async list(request) {
  2638. // Missing service = the deployment omitted dsh-commands from its
  2639. // composition, not an empty catalog: fail loud instead of serving [].
  2640. const commands = ctx.get('commands')
  2641. if (commands === undefined) {
  2642. return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
  2643. }
  2644. const found = await agentFor(request.payload.sessionId)
  2645. if ('error' in found) return err(request, found.error)
  2646. return ok(request, { commands: commands.list(found.agent) })
  2647. },
  2648. async execute(request, signal) {
  2649. const commands = ctx.get('commands')
  2650. if (commands === undefined) {
  2651. return err(request, { code: 'internal', message: 'command registry is absent: this deployment does not mount @deepseek-ai/dsh-commands in its composition (cordis.yml or explicit assembly)', details: {} })
  2652. }
  2653. const { sessionId, line } = request.payload
  2654. const found = await agentFor(sessionId)
  2655. if ('error' in found) return err(request, found.error)
  2656. try {
  2657. // Pure admission: the executor's durable command/run + command/done
  2658. // pair (broadcast on the mux stream) carries the outcome; the
  2659. // response reports whether the line resolved to a handler, plus the
  2660. // minted pairing id so the issuing client can correlate its request
  2661. // with the flow node the lifecycle events produce.
  2662. const execution = await commands.execute(found.agent, line, signal)
  2663. return ok(request, execution === undefined
  2664. ? { matched: false }
  2665. : { matched: true, commandId: execution.commandId })
  2666. } catch (error: unknown) {
  2667. if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
  2668. return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
  2669. }
  2670. },
  2671. },
  2672. goals: {
  2673. // Mutations only — the read side is the 'goal' session projection.
  2674. // Every verb resolves the session's agent (agentFor: implicit cold
  2675. // resume, the command.* precedent) and acknowledges with the new CAS
  2676. // ref; the committed goal/change event carries the whole value to every
  2677. // client through the projection frames.
  2678. async create(request) {
  2679. const { objective, maxGoalRounds } = request.payload
  2680. return mutateGoal(request, (goals, agent) => goals.create(agent, {
  2681. objective,
  2682. ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
  2683. }))
  2684. },
  2685. async edit(request) {
  2686. const { ref, objective, maxGoalRounds } = request.payload
  2687. return mutateGoal(request, (goals, agent) => goals.edit(agent, ref, {
  2688. ...(objective !== undefined ? { objective } : {}),
  2689. ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
  2690. }))
  2691. },
  2692. async pause(request) {
  2693. return mutateGoal(request, (goals, agent) => goals.pause(agent, request.payload.ref))
  2694. },
  2695. async resume(request) {
  2696. return mutateGoal(request, (goals, agent) => goals.resume(agent, request.payload.ref))
  2697. },
  2698. async complete(request) {
  2699. return mutateGoal(request, (goals, agent) => goals.complete(agent, request.payload.ref))
  2700. },
  2701. async clear(request) {
  2702. const found = await agentFor(request.payload.sessionId)
  2703. if ('error' in found) return err(request, found.error)
  2704. const goals = goalServiceFor(found.agent)
  2705. if ('error' in goals) return err(request, goals.error)
  2706. try {
  2707. goals.clear(found.agent, request.payload.ref)
  2708. return ok(request, { cleared: true as const })
  2709. } catch (error: unknown) {
  2710. return goalError(request, error)
  2711. }
  2712. },
  2713. },
  2714. agentPresets: {
  2715. // A deployment with no roster answers with an empty list rather than an
  2716. // error: composing no presets is a valid deployment, and the browser
  2717. // simply offers no choice.
  2718. async list(request) {
  2719. const presets = ctx.get('agentPresets')
  2720. if (presets === undefined) return ok(request, { presets: [], authorable: false, hasDocument: false })
  2721. const defaultId = presets.defaultId
  2722. return ok(request, {
  2723. presets: (await presets.list()).map(preset => ({
  2724. id: preset.id,
  2725. trust: preset.trust,
  2726. isDefault: preset.id === defaultId,
  2727. ...preset.name === undefined ? {} : { name: preset.name },
  2728. ...preset.description === undefined ? {} : { description: preset.description },
  2729. ...preset.broken === undefined ? {} : { broken: preset.broken },
  2730. })),
  2731. authorable: presets.authorable,
  2732. hasDocument: canOpenPaths(),
  2733. })
  2734. },
  2735. // Recomposing is limited to a blank session because a started
  2736. // conversation's history was produced under its preset's tools; the
  2737. // agent and the session survive, only the composition is swapped.
  2738. async select(request) {
  2739. const { sessionId, agentPreset } = request.payload
  2740. const presets = ctx.get('agentPresets')
  2741. if (presets === undefined) {
  2742. return err(request, {
  2743. code: 'agent-preset-not-found',
  2744. message: 'this deployment composes no agent presets',
  2745. details: { agentPreset, available: [] },
  2746. })
  2747. }
  2748. const found = await agentFor(sessionId)
  2749. if ('error' in found) return err(request, found.error)
  2750. const { agent } = found
  2751. const swap = async (): Promise<RpcResponse<{ agentPreset: string }>> => {
  2752. // Re-read inside the queue: an earlier switch may have run, and a
  2753. // conversation may have started, since this request arrived.
  2754. if (!sessionBlank(agent.session)) {
  2755. return err(request, {
  2756. code: 'agent-preset-locked',
  2757. message: `session "${sessionId}" has already started; its agent preset is fixed`,
  2758. details: { sessionId, agentPreset },
  2759. })
  2760. }
  2761. try {
  2762. const preset = await presets.recompose(agent.ctx, agentPreset)
  2763. // Recorded only after the swap committed: the log states what the
  2764. // agent runs, and a rejected mount leaves the previous composition.
  2765. agent.session.append('agent-preset/selected', { agentPreset: preset.id })
  2766. return ok(request, { agentPreset: preset.id })
  2767. } catch (error: unknown) {
  2768. const refused = presetFailure(request, error)
  2769. if (refused !== undefined) return refused
  2770. return err(request, {
  2771. code: 'internal',
  2772. message: `failed to select agent preset "${agentPreset}": ${String(error)}`,
  2773. details: {},
  2774. })
  2775. }
  2776. }
  2777. const queued = presetSwitches.get(sessionId) ?? Promise.resolve()
  2778. const turn = queued.then(swap)
  2779. presetSwitches.set(sessionId, turn.catch(() => undefined))
  2780. try {
  2781. return await turn
  2782. } finally {
  2783. if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId)
  2784. }
  2785. },
  2786. // Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection):
  2787. // a composition names the plugins a session runs, so reading one is
  2788. // reconnaissance, and copy/remove/openDocument manage the roster and
  2789. // drive the host desktop.
  2790. async read(request) {
  2791. const { agentPreset } = request.payload
  2792. const presets = ctx.get('agentPresets')
  2793. if (presets === undefined) return err(request, noRoster(agentPreset))
  2794. try {
  2795. const preset = await presets.resolve(agentPreset)
  2796. return ok(request, {
  2797. agentPreset: preset.id,
  2798. trust: preset.trust,
  2799. content: await presets.read(preset.id),
  2800. ...preset.name === undefined ? {} : { name: preset.name },
  2801. ...preset.description === undefined ? {} : { description: preset.description },
  2802. })
  2803. } catch (error: unknown) {
  2804. return err(request, presetError(agentPreset, error))
  2805. }
  2806. },
  2807. async copy(request) {
  2808. const { from, agentPreset, name } = request.payload
  2809. const presets = ctx.get('agentPresets')
  2810. if (presets === undefined) return err(request, noRoster(agentPreset))
  2811. try {
  2812. await presets.copy(from, agentPreset, name)
  2813. return ok(request, { agentPreset })
  2814. } catch (error: unknown) {
  2815. return err(request, presetError(agentPreset, error))
  2816. }
  2817. },
  2818. async openDocument(request, signal) {
  2819. const { agentPreset } = request.payload
  2820. const presets = ctx.get('agentPresets')
  2821. if (presets === undefined) return err(request, noRoster(agentPreset))
  2822. try {
  2823. const preset = await presets.resolve(agentPreset)
  2824. // Same line as copy/remove draw: the shipped install is not the
  2825. // user's to manage, and pointing an editor into it invites edits an
  2826. // upgrade will silently overwrite.
  2827. if (preset.trust !== 'user') {
  2828. throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
  2829. }
  2830. // The id resolved against the Host's own roots is what selects the
  2831. // directory — no browser payload carries a path in either direction
  2832. // unless the deployment has no opener to hand it to.
  2833. const directory = dirname(preset.path)
  2834. if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory })
  2835. return await openPath(request, directory, signal)
  2836. } catch (error: unknown) {
  2837. return err(request, presetError(agentPreset, error))
  2838. }
  2839. },
  2840. async remove(request) {
  2841. const { agentPreset } = request.payload
  2842. const presets = ctx.get('agentPresets')
  2843. if (presets === undefined) return err(request, noRoster(agentPreset))
  2844. try {
  2845. await presets.remove(agentPreset)
  2846. return ok(request, {})
  2847. } catch (error: unknown) {
  2848. return err(request, presetError(agentPreset, error))
  2849. }
  2850. },
  2851. },
  2852. skills: {
  2853. // Skill lookup never creates or resumes an agent: the session address
  2854. // resolves to a canonical cwd from the host-resident session header, and
  2855. // the view scope is the live agent or the preset's standing key.
  2856. async list(request) {
  2857. const { sessionId } = request.payload
  2858. const session = ctx.sessions.get(sessionId)
  2859. if (session === undefined) {
  2860. return err(request, {
  2861. code: 'session-not-found',
  2862. message: `session "${sessionId}" not found (not attached)`,
  2863. details: { sessionId },
  2864. })
  2865. }
  2866. if (session.header.cwd === undefined) {
  2867. // Every served session records its project at create time; a
  2868. // cwd-less header is a pre-project legacy log (not served).
  2869. return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
  2870. }
  2871. const cwd = session.header.cwd
  2872. // The host registry is layered per scope and serves every session. A
  2873. // composition may still realm-mount its own registry instead; that
  2874. // instance is invisible to host contexts, so address it through the
  2875. // live agent (`agents.get` keeps the no-side-effect stance above).
  2876. const live = ctx.agents.get(sessionId)
  2877. const presets = ctx.get('agentPresets')
  2878. const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
  2879. // Same stance as the commands domain: a missing service means no
  2880. // composition mounts dsh-skill, not an empty catalog. `ctx.get` also
  2881. // keeps this handler independent of the gateway plugin's inject list
  2882. // (an undeclared `ctx.skills` property read fails the reflect proxy).
  2883. const skillRegistry = scoped ?? ctx.get('skills')
  2884. if (skillRegistry === undefined) {
  2885. return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
  2886. }
  2887. // The scope presenters resolve in — the live agent, else the recorded
  2888. // preset's standing key, else the global layer — so a cold session's
  2889. // '/' popup lists the catalog its composition actually serves.
  2890. const scope = await presenterScopeFor(sessionId, session)
  2891. try {
  2892. const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
  2893. return ok(request, {
  2894. skills: skills.map(skill => ({
  2895. name: skill.name,
  2896. description: skill.description,
  2897. ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
  2898. modelInvocable: skill.invocation.modelInvocable,
  2899. })),
  2900. })
  2901. } catch (error: unknown) {
  2902. return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
  2903. }
  2904. },
  2905. },
  2906. settings: {
  2907. describe(request) {
  2908. const settings = ctx.get('settings')
  2909. if (settings === undefined) return Promise.resolve(err(request, settingsAbsent()))
  2910. const exposed = exposedNamespaces()
  2911. return Promise.resolve(ok(request, {
  2912. writable: settings.writable,
  2913. hasDocument: settings.documentPath !== undefined,
  2914. namespaces: settings.describe({ redactSecrets: true })
  2915. .filter(descriptor => exposed.has(String(descriptor.ns)))
  2916. .map(namespaceView),
  2917. }))
  2918. },
  2919. async openDocument(request, signal) {
  2920. const settings = ctx.get('settings')
  2921. if (settings === undefined) return err(request, settingsAbsent())
  2922. if (isAborted(signal)) {
  2923. return err(request, {
  2924. code: 'cancelled',
  2925. message: 'settings document open was aborted',
  2926. details: {},
  2927. })
  2928. }
  2929. let path: string | undefined
  2930. try {
  2931. path = await settings.prepareDocument()
  2932. } catch (error: unknown) {
  2933. if (isAborted(signal)) {
  2934. return err(request, {
  2935. code: 'cancelled',
  2936. message: 'settings document preparation was aborted',
  2937. details: {},
  2938. })
  2939. }
  2940. return err(request, {
  2941. code: 'internal',
  2942. message: `settings document preparation failed: ${error instanceof Error ? error.message : String(error)}`,
  2943. details: {},
  2944. })
  2945. }
  2946. if (path === undefined) {
  2947. return err(request, {
  2948. code: 'internal',
  2949. message: 'settings provider has no local document to open',
  2950. details: {},
  2951. })
  2952. }
  2953. if (isAborted(signal)) {
  2954. return err(request, {
  2955. code: 'cancelled',
  2956. message: 'settings document open was aborted',
  2957. details: {},
  2958. })
  2959. }
  2960. return openTextFile(request, path, signal)
  2961. },
  2962. update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision),
  2963. replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision),
  2964. mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision),
  2965. },
  2966. credentials: {
  2967. async describe(request) {
  2968. const credentials = ctx.get('credentials')
  2969. if (credentials === undefined) return err(request, credentialsAbsent())
  2970. const entries = await Promise.all(request.payload.refs.map(async (ref) => {
  2971. const info = await credentials.describe(credentialRef(ref))
  2972. const view: CredentialView = {
  2973. configured: info.configured,
  2974. ...info.source === undefined ? {} : { source: info.source },
  2975. writable: info.writable,
  2976. }
  2977. return [ref, view] as const
  2978. }))
  2979. return ok(request, { credentials: Object.fromEntries(entries) })
  2980. },
  2981. async set(request) {
  2982. const credentials = ctx.get('credentials')
  2983. if (credentials === undefined) return err(request, credentialsAbsent())
  2984. const { ref, value } = request.payload
  2985. try {
  2986. await credentials.set(credentialRef(ref), value)
  2987. } catch (error: unknown) {
  2988. return err(request, {
  2989. code: 'credential-rejected',
  2990. message: error instanceof Error ? error.message : String(error),
  2991. details: { ref },
  2992. })
  2993. }
  2994. return ok(request, {})
  2995. },
  2996. async unset(request) {
  2997. const credentials = ctx.get('credentials')
  2998. if (credentials === undefined) return err(request, credentialsAbsent())
  2999. const { ref } = request.payload
  3000. try {
  3001. await credentials.unset(credentialRef(ref))
  3002. } catch (error: unknown) {
  3003. return err(request, {
  3004. code: 'credential-rejected',
  3005. message: error instanceof Error ? error.message : String(error),
  3006. details: { ref },
  3007. })
  3008. }
  3009. return ok(request, {})
  3010. },
  3011. },
  3012. llm: {
  3013. providers(request) {
  3014. const registered = ctx.llm.listProviders()
  3015. const active = new Set(registered.map(provider => provider.id))
  3016. const directory = ctx.llm.listConfigurableProviders()
  3017. const declared = new Set(directory.map(entry => entry.provider))
  3018. const views: ConfigurableProviderView[] = directory.map(entry => ({
  3019. provider: entry.provider,
  3020. displayName: entry.displayName,
  3021. settingsNs: entry.settingsNs,
  3022. settingsPath: [...entry.settingsPath],
  3023. active: active.has(entry.provider),
  3024. ...entry.declared === undefined ? {} : { declared: entry.declared },
  3025. }))
  3026. // Routes registered without a directory declaration still appear —
  3027. // they exist and serve models — just with no settings address. No
  3028. // adapter claimed them, so nothing can say whether they are shipped.
  3029. for (const provider of registered) {
  3030. if (declared.has(provider.id)) continue
  3031. views.push({
  3032. provider: provider.id,
  3033. displayName: provider.name,
  3034. settingsNs: '',
  3035. settingsPath: [],
  3036. active: true,
  3037. })
  3038. }
  3039. return Promise.resolve(ok(request, { providers: views }))
  3040. },
  3041. async models(request) {
  3042. return ok(request, await buildModelCatalog(ctx))
  3043. },
  3044. async discoverModels(request, signal) {
  3045. const { settingsNs, provider, baseURL, api, apiKey } = request.payload
  3046. try {
  3047. const models = await ctx.llm.discoverModels(settingsNs, {
  3048. ...provider === undefined ? {} : { provider },
  3049. ...baseURL === undefined ? {} : { baseURL },
  3050. ...api === undefined ? {} : { api },
  3051. ...apiKey === undefined ? {} : { apiKey },
  3052. ...signal === undefined ? {} : { signal },
  3053. })
  3054. return ok(request, { models })
  3055. } catch (error: unknown) {
  3056. // Every failure here is the user's next move, not a transport fault:
  3057. // a wrong endpoint, a rejected key, or a protocol with no listing all
  3058. // end at the same place — fill the models in by hand. The details
  3059. // repeat only what the caller already sent, never the credential.
  3060. return err(request, {
  3061. code: 'model-discovery-failed',
  3062. message: error instanceof Error ? error.message : String(error),
  3063. details: { settingsNs, ...baseURL === undefined ? {} : { baseURL } },
  3064. })
  3065. }
  3066. },
  3067. },
  3068. events: {
  3069. mux(_request, signal) {
  3070. const queue = new FrameQueue<RpcRequest<MuxFrame>>()
  3071. muxQueues.add(queue)
  3072. for (const session of ctx.sessions.list()) {
  3073. subscribeSession(queue, session)
  3074. }
  3075. for (const pending of pendingQuestions.values()) {
  3076. queue.push({
  3077. rpcId: pending.rpcId,
  3078. payload: {
  3079. type: 'question/requested', sessionId: pending.sessionId,
  3080. questions: pending.questions,
  3081. },
  3082. })
  3083. }
  3084. // Refresh recovery: still-pending approval questions replay with their
  3085. // stable rpcId so a reconnecting client can still answer them.
  3086. for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending))
  3087. // Queue snapshot baseline (pendingQuestions precedent): frames replayed
  3088. // in arrival order per session; a reconnecting client rebuilds its
  3089. // queue view from these alone.
  3090. for (const session of ctx.sessions.list()) {
  3091. const agent = ctx.agents.get(session.id)
  3092. if (agent?.session === session && agent.inbox.hasPending) {
  3093. queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
  3094. }
  3095. }
  3096. // Background-task baseline. `ctx.agents.get` is the non-resuming read:
  3097. // a session with no live Agent owns no tasks, so it correctly sees only
  3098. // the unowned ones, and listing never revives a cold session. An empty
  3099. // set sends nothing — absence is how the client reads "no tasks".
  3100. const tasks = ctx.get('tasks')
  3101. if (tasks !== undefined) {
  3102. for (const session of ctx.sessions.list()) {
  3103. const views = taskViews(tasks.list(ctx.agents.get(session.id)))
  3104. if (views.length > 0) {
  3105. queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
  3106. }
  3107. }
  3108. }
  3109. // Per-session open-call table for result-view pairing. Bounded by the
  3110. // per-turn call count: entries clear on turn/end; a table miss (stream
  3111. // opened mid-turn) backscans the session's in-memory events instead.
  3112. const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
  3113. const disposers = [
  3114. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  3115. if (event.type === 'tool/call') {
  3116. const data = event.data as ToolCallData
  3117. try {
  3118. let table = openCalls.get(session.id)
  3119. if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
  3120. table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
  3121. } catch {
  3122. // Unparseable model arguments: leave the table unset; the result view soft-falls.
  3123. }
  3124. } else if (event.type === 'turn/end') {
  3125. openCalls.delete(session.id)
  3126. }
  3127. const view = viewFor(
  3128. ctx, event,
  3129. callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId),
  3130. ctx.agents.get(session.id),
  3131. )
  3132. queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
  3133. }),
  3134. ctx.on('session/created', (session: Session) => {
  3135. subscribeSession(queue, session)
  3136. // The subscribe frame clears the client's task mirror, and a
  3137. // session born after the stream opened missed the baseline loop.
  3138. // Unowned tasks are visible to it from birth, so without this it
  3139. // would show none until the next registry change.
  3140. const views = tasks === undefined ? [] : taskViews(tasks.list(ctx.agents.get(session.id)))
  3141. if (views.length > 0) {
  3142. queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
  3143. }
  3144. }),
  3145. ctx.on('session/disposed', (session: Session) => {
  3146. openCalls.delete(session.id)
  3147. }),
  3148. ...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => {
  3149. if (owner !== undefined) {
  3150. // The exact owner instance the fence compares against, so the
  3151. // push stays correct even while that Agent's scope is tearing
  3152. // down and a lookup by id would already miss.
  3153. queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) }))
  3154. return
  3155. }
  3156. // An unowned task is visible to every caller, so every subscribed
  3157. // session's set changed with it.
  3158. for (const session of ctx.sessions.list()) {
  3159. queue.push(frame({
  3160. type: 'session/tasks',
  3161. sessionId: session.id,
  3162. tasks: taskViews(tasks.list(ctx.agents.get(session.id))),
  3163. }))
  3164. }
  3165. })],
  3166. ]
  3167. return queue.iterate(signal, () => {
  3168. muxQueues.delete(queue)
  3169. for (const dispose of disposers) dispose()
  3170. })
  3171. },
  3172. host(_request, signal) {
  3173. const queue = new FrameQueue<RpcRequest<HostFrame>>()
  3174. const committedWorkspaceIds = new Set(
  3175. ctx.workspace.list().map(workspace => String(workspace.id)),
  3176. )
  3177. // Frame-dedup baseline, same posture as committedWorkspaceIds: the
  3178. // stream opens against the current set; workspace.list re-baselines
  3179. // reconnecting clients, so only later changes need frames.
  3180. let archivedSessionIds = ctx.workspace.archivedSessionIds
  3181. const disposers = [
  3182. ctx.on('session/created', (session: Session) => {
  3183. queue.push(frame({
  3184. type: 'host/session-added',
  3185. sessionId: session.id,
  3186. // Derived at frame time like summarize(); a just-created session
  3187. // has run no turn yet, so this is constantly true in practice.
  3188. blank: sessionBlank(session),
  3189. // Including cwd lets the client group the new session without refreshing the list.
  3190. ...sessionListFields(session.header, session.events),
  3191. }))
  3192. }),
  3193. ctx.on('session/disposed', (session: Session) => {
  3194. queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
  3195. }),
  3196. ctx.on('agent/status', ({ agent, status }: { agent: Agent; status: AgentStatus }) => {
  3197. queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
  3198. }),
  3199. ctx.on('agent/error', ({ agent, error }: { agent: Agent; error: unknown }) => {
  3200. queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) }))
  3201. }),
  3202. ctx.on('domain/changed', (change) => {
  3203. if (change.domain !== 'workspace') return
  3204. if (change.table === '') {
  3205. if (change.operation !== 'put') return
  3206. const state = workspaceDomainState.parse(change.value)
  3207. for (const workspaceId of state.workspaceIds) {
  3208. if (committedWorkspaceIds.has(workspaceId)) continue
  3209. const workspace = ctx.workspace.get(workspaceId)
  3210. if (workspace === undefined) {
  3211. throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
  3212. }
  3213. committedWorkspaceIds.add(workspaceId)
  3214. queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
  3215. }
  3216. if (state.archivedSessionIds.length !== archivedSessionIds.length
  3217. || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) {
  3218. archivedSessionIds = state.archivedSessionIds
  3219. queue.push(frame({
  3220. type: 'host/archived-sessions-changed',
  3221. archivedSessionIds: [...state.archivedSessionIds],
  3222. }))
  3223. }
  3224. return
  3225. }
  3226. if (change.table !== 'workspaces') return
  3227. if (change.operation === 'deleted') {
  3228. if (!committedWorkspaceIds.delete(change.key)) return
  3229. queue.push(frame({
  3230. type: 'host/workspace-removed',
  3231. workspaceId: change.key as WorkspaceId,
  3232. }))
  3233. return
  3234. }
  3235. if (!committedWorkspaceIds.has(change.key)) return
  3236. // Existing-entity table writes are complete attach/touch commits.
  3237. // A new entity's first put waits for the global registry write above.
  3238. queue.push(frame({
  3239. type: 'host/workspace-changed',
  3240. workspace: changedWorkspaceView(change.key, change.value),
  3241. }))
  3242. }),
  3243. ctx.on('commands/change', () => {
  3244. queue.push(frame({ type: 'host/commands-changed' }))
  3245. }),
  3246. // The recompose itself registers nothing (it re-parents the agent's
  3247. // scope onto a standing mount that may already exist), so the
  3248. // logged selection is the only commit point a client can follow.
  3249. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  3250. if (event.type !== 'agent-preset/selected') return
  3251. queue.push(frame({
  3252. type: 'host/session-preset-changed',
  3253. sessionId: session.id,
  3254. agentPreset: event.data.agentPreset,
  3255. }))
  3256. }),
  3257. ctx.on('settings/document-updated', (ns) => {
  3258. // The RAW-section event, not the resolved one: a field going from
  3259. // inherited to overridden leaves the resolved value equal, and a
  3260. // configuration client still has to re-read (its held revision is
  3261. // stale, and the field's meaning changed).
  3262. const name = String(ns)
  3263. queue.push(frame({ type: 'host/settings-changed', ns: name }))
  3264. // A provider's own settings carry its model catalog and endpoint,
  3265. // so a change there invalidates the model list even when the route
  3266. // set is untouched — `llm/adapters-updated` alone misses it. The
  3267. // Agent default section is the other such source: it names the
  3268. // selection every session with no logged one resolves to, so an
  3269. // externally edited default (another tab, a hand-edited
  3270. // settings.yaml) has to reach an open selector too.
  3271. if (modelProviderNamespaces().has(name) || name === String(AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE)) {
  3272. queue.push(frame({ type: 'host/models-changed' }))
  3273. }
  3274. }),
  3275. ctx.on('credentials/updated', (ref) => {
  3276. queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
  3277. }),
  3278. ctx.on('llm/adapters-updated', () => {
  3279. queue.push(frame({ type: 'host/models-changed' }))
  3280. }),
  3281. ]
  3282. return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
  3283. },
  3284. },
  3285. downloads: {
  3286. async sessionLog(request, signal) {
  3287. // Clean error path first: missing services answer 500 and a missing
  3288. // root artifact 404 before any zip byte is produced. The root content
  3289. // read here is reused as the first zip entry, so nothing is read twice.
  3290. const deps = sessionLogExportDeps(ctx)
  3291. if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) {
  3292. return new Response(
  3293. 'session log export is unavailable: missing session-query, session-persistence, or attachments service',
  3294. { status: 500 },
  3295. )
  3296. }
  3297. const ready: SessionLogExportReady = {
  3298. sessionQuery: deps.sessionQuery,
  3299. sessionPersistence: deps.sessionPersistence,
  3300. attachments: deps.attachments,
  3301. }
  3302. let root: SessionRawArtifact | undefined
  3303. try {
  3304. root = await deps.sessionPersistence.readRaw(request.sessionId, signal)
  3305. } catch {
  3306. // Backend read failure: answer 500 without echoing the error, which
  3307. // may carry absolute host paths into the browser error bar.
  3308. return new Response('session log export failed to read the stored artifact', { status: 500 })
  3309. }
  3310. if (root === undefined) {
  3311. return new Response('session not found', { status: 404 })
  3312. }
  3313. return new Response(
  3314. streamSessionLogZip(ready, root, request.sessionId, request.includeDescendants === true, signal),
  3315. {
  3316. headers: {
  3317. 'content-type': 'application/zip',
  3318. 'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`,
  3319. },
  3320. },
  3321. )
  3322. },
  3323. },
  3324. respond(message: ClientResponse): Promise<RpcReceipt> {
  3325. // Route by the echoed rpcId (the wire correlation): approvals first,
  3326. // then questions — the two registries share one id space of UUIDs.
  3327. const approval = pendingApprovals.get(message.rpcId)
  3328. if (approval !== undefined) {
  3329. if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3330. const parsed = approvalResponsePayloadSchema.safeParse(message.result.value)
  3331. // The payload's audit correlation must match the entry the rpcId routed
  3332. // to — a mismatched answer is malformed, not merely late.
  3333. if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) {
  3334. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3335. }
  3336. approval.resolve(parsed.data.outcome)
  3337. return Promise.resolve({ accepted: true })
  3338. }
  3339. const pending = pendingQuestions.get(message.rpcId)
  3340. if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
  3341. if (!message.result.ok) {
  3342. if (message.result.error.code !== 'cancelled') {
  3343. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3344. }
  3345. claimQuestion(pending, 'cancelled')
  3346. pending.reject(new UserInteractionError(
  3347. 'the user cancelled ask_user_question', 'ASK_CANCELLED'))
  3348. return Promise.resolve({ accepted: true })
  3349. }
  3350. const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
  3351. if (!parsed.success) {
  3352. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3353. }
  3354. const payload: QuestionResponsePayload = {
  3355. sessionId: parsed.data.sessionId,
  3356. answer: {
  3357. answers: parsed.data.answer.answers.map(answer => ({
  3358. id: answer.id,
  3359. selected: answer.selected,
  3360. ...(answer.custom === undefined ? {} : { custom: answer.custom }),
  3361. })),
  3362. },
  3363. }
  3364. if (!matchesQuestions(payload, pending)) {
  3365. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3366. }
  3367. claimQuestion(pending, 'answered')
  3368. pending.resolve(payload.answer)
  3369. return Promise.resolve({ accepted: true })
  3370. },
  3371. }
  3372. }