api-proxy.ts 115 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714
  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 { join } from 'node:path'
  8. import type { Context } from 'cordis'
  9. import { installAgentLlmTarget } from '@deepseek-ai/dsh-agent'
  10. import type {
  11. Agent, AgentLlmTarget, AgentLlmTargetRef, AgentStatus, InboxItem, InboxItemId,
  12. } from '@deepseek-ai/dsh-agent'
  13. import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  14. import { errorChain } from '@deepseek-ai/dsh-llm'
  15. import type { MessageSource } from '@deepseek-ai/dsh-llm'
  16. import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session'
  17. import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
  18. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  19. import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
  20. import { SubagentError } from '@deepseek-ai/dsh-subagent'
  21. import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
  22. import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
  23. import {
  24. workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
  25. WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
  26. } from '@deepseek-ai/dsh-workspace'
  27. // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
  28. import type {} from '@deepseek-ai/dsh-tools'
  29. import type {
  30. ApiProxy, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup,
  31. ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
  32. SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
  33. WorkspaceId, WorkspaceView,
  34. } from './api/index.ts'
  35. import {
  36. SESSION_SEARCH_RESULT_LIMIT,
  37. SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
  38. truncateUnicodeCodePoints,
  39. } from './api/session-search.ts'
  40. // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
  41. import type {} from '@deepseek-ai/dsh-session-projection'
  42. // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
  43. import type {} from '@deepseek-ai/dsh-session-projection-cache'
  44. // GoalError narrows domain rejections to their stable codes at the wire boundary.
  45. import { GoalError } from '@deepseek-ai/dsh-goal'
  46. import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
  47. // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
  48. import type {} from '@deepseek-ai/dsh-commands'
  49. import type {} from '@deepseek-ai/dsh-skill'
  50. // The settings/credentials seams: brand guards run at this wire boundary; the
  51. // service reads stay optional (`ctx.get`) so a composition without either
  52. // provider still serves every other domain.
  53. import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
  54. import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings'
  55. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  56. // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
  57. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
  58. import type { CallId } from '@deepseek-ai/dsh-llm/brand'
  59. import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
  60. // Side-effect type import: resolves the `approval/request` waterfall and
  61. // `ctx.get('approval')` without a value dependency on the seam (optional composition).
  62. import type {} from '@deepseek-ai/dsh-user-approval'
  63. import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
  64. import { questionResponsePayloadSchema } from './api/questions.schema.ts'
  65. import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
  66. import { RpcId } from './api/rpc.ts'
  67. import type {
  68. AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
  69. } from '@deepseek-ai/dsh-user-interaction'
  70. import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
  71. import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
  72. import { openNativePath } from './native-path-opener.ts'
  73. /** Page size when history is called without maxMessages. */
  74. const DEFAULT_MAX_MESSAGES = 50
  75. /** Non-model settings namespaces intentionally served to the Web client. */
  76. const WEB_SETTINGS_NAMESPACES = ['permission'] as const
  77. /** Provider work budget: at most 100 calls and 2,000 inspected hits. */
  78. const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
  79. /** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */
  80. const COLD_SUMMARY_BATCH_SIZE = 16
  81. /** Conversation message event types (the pagination counting unit). */
  82. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
  83. /** Product settings intentionally exposed beside model-provider namespaces. */
  84. const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding'])
  85. /** Read live abort state across awaits without treating it as synchronously immutable. */
  86. function isAborted(signal: AbortSignal): boolean {
  87. return signal.aborted
  88. }
  89. /**
  90. * Message-boundary pagination: count maxMessages append-origin messages
  91. * backwards from the window tail. Replacement copies never entered the
  92. * conversation a reader sees — they restate a shadowed range for the model
  93. * alone — so they consume no quota; the page stays one contiguous raw range,
  94. * which keeps a compaction's log-only provenance on the same page as its
  95. * replacement. The cut is the starting seq of the oldest message group (chunks
  96. * group via sourceEventSeqs — never cut mid-message). The tail page naturally
  97. * includes the in-progress partial.
  98. */
  99. function paginate(
  100. events: readonly SessionEvent[],
  101. beforeSeq: number | undefined,
  102. maxMessages: number,
  103. ): { events: SessionEvent[]; hasMore: boolean } {
  104. const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
  105. let count = 0
  106. let cut = 0
  107. for (let i = window.length - 1; i >= 0; i--) {
  108. const event = window[i] as SessionEvent
  109. if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
  110. count++
  111. const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
  112. const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
  113. if (count >= maxMessages) {
  114. cut = groupStart
  115. break
  116. }
  117. }
  118. const page = window.filter(event => event.seq >= cut)
  119. return { events: page, hasMore: cut > 0 }
  120. }
  121. /** Wrap an ok result echoing the request's rpcId. */
  122. function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
  123. return { rpcId: request.rpcId, result: { ok: true, value } }
  124. }
  125. /**
  126. * Build the provider/model catalog over every registered route. Shared by the
  127. * session-scoped `session.models` (which passes the session's current target
  128. * so an unlisted current model still renders selectable) and the host-scoped
  129. * `llm.models` (no current). Per-provider failures ride `failures` without
  130. * failing the sound groups; groups that advertise nothing are dropped.
  131. */
  132. async function buildModelCatalog(
  133. ctx: Context,
  134. current?: { provider: string; model: string },
  135. ): Promise<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }> {
  136. const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
  137. try {
  138. const advertised = await ctx.llm.listModels(provider.id)
  139. const models = [...advertised]
  140. if (
  141. current !== undefined
  142. && provider.id === current.provider
  143. && !models.some(model => model.id === current.model)
  144. ) {
  145. models.push({
  146. provider: provider.id,
  147. id: current.model,
  148. name: current.model,
  149. })
  150. }
  151. const entries = await Promise.all(models.map(async (model) => {
  152. const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
  153. const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
  154. ? undefined
  155. : {
  156. efforts: resolved.reasoning.efforts.map(effort => ({
  157. id: effort.id,
  158. name: effort.name,
  159. ...effort.description === undefined
  160. ? {}
  161. : { description: effort.description },
  162. })),
  163. ...resolved.reasoning.defaultEffort === undefined
  164. ? {}
  165. : { defaultEffort: resolved.reasoning.defaultEffort },
  166. }
  167. return {
  168. id: model.id,
  169. name: model.name,
  170. ...model.description === undefined ? {} : { description: model.description },
  171. ...current !== undefined
  172. && provider.id === current.provider
  173. && model.id === current.model
  174. && !advertised.some(candidate => candidate.id === current.model)
  175. ? { unlisted: true as const }
  176. : {},
  177. ...reasoning === undefined ? {} : { reasoning },
  178. }
  179. }))
  180. const group: ModelProviderGroup = {
  181. id: provider.id,
  182. name: provider.name,
  183. models: entries,
  184. }
  185. return { kind: 'group' as const, group }
  186. } catch (error: unknown) {
  187. const failure: ModelCatalogFailure = {
  188. id: provider.id,
  189. name: provider.name,
  190. message: error instanceof Error ? error.message : String(error),
  191. }
  192. return { kind: 'failure' as const, failure }
  193. }
  194. }))
  195. return {
  196. groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []).filter(group => group.models.length > 0),
  197. failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
  198. }
  199. }
  200. /** Wrap an error result echoing the request's rpcId. */
  201. function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
  202. return { rpcId: request.rpcId, result: { ok: false, error } }
  203. }
  204. /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
  205. class FrameQueue<F> {
  206. private buffer: F[] = []
  207. private waiter: (() => void) | undefined
  208. private done = false
  209. push(item: F): void {
  210. if (this.done) return
  211. this.buffer.push(item)
  212. this.waiter?.()
  213. }
  214. end(): void {
  215. this.done = true
  216. this.waiter?.()
  217. }
  218. async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
  219. const onAbort = (): void => { this.end() }
  220. signal.addEventListener('abort', onAbort, { once: true })
  221. try {
  222. while (true) {
  223. while (this.buffer.length > 0) yield this.buffer.shift() as F
  224. if (this.done || signal.aborted) return
  225. await new Promise<void>((resolve) => { this.waiter = resolve })
  226. this.waiter = undefined
  227. }
  228. } finally {
  229. signal.removeEventListener('abort', onAbort)
  230. cleanup()
  231. }
  232. }
  233. }
  234. /**
  235. * Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable
  236. * frames — approval/question requested — mint their stable id in their
  237. * pending registries instead).
  238. */
  239. function frame<F>(payload: F): RpcRequest<F> {
  240. return { rpcId: RpcId(randomUUID()), payload }
  241. }
  242. /** Queue the subscription baseline frame. */
  243. function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
  244. queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
  245. }
  246. /**
  247. * Whether the session's conversation has started: no turn has run yet (a
  248. * turn is one model-loop execution). Standalone plugin events — command
  249. * lifecycle records, plan/mode, titles, goals — never open a turn, so
  250. * running `/plan` or `/goal` on a fresh session keeps it blank
  251. * (list-hidden, reusable).
  252. */
  253. function sessionBlank(session: Session): boolean {
  254. return !session.events.some(event => event.type === 'turn/start')
  255. }
  256. /** Shared Session-header projection for list baselines and creation frames. */
  257. function sessionListFields(header: SessionHeader): {
  258. parentSessionId?: SessionId
  259. origin?: 'subagent'
  260. cwd?: string
  261. } {
  262. return {
  263. ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
  264. ...header.origin === undefined ? {} : { origin: header.origin },
  265. ...header.cwd === undefined ? {} : { cwd: header.cwd },
  266. }
  267. }
  268. /** SessionSummary projection for attached (in-memory) sessions. */
  269. function summarize(session: Session, running: boolean): SessionSummary {
  270. return {
  271. sessionId: session.id,
  272. // Excludes end-seed: a resumed-but-untouched session
  273. // must not sort as freshly worked in.
  274. updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
  275. running,
  276. blank: sessionBlank(session),
  277. ...sessionListFields(session.header),
  278. }
  279. }
  280. /**
  281. * SessionSummary projection for cold (persisted, unattached) sessions.
  282. * updatedAt is the log file's mtime; backends without a per-session file
  283. * (locate() undefined) fall back to the header's createdAt.
  284. */
  285. async function summarizeCold(
  286. persistence: SessionPersistence,
  287. meta: SessionHeader,
  288. signal?: AbortSignal,
  289. ): Promise<SessionSummary> {
  290. signal?.throwIfAborted()
  291. let updatedAt = meta.createdAt
  292. const location = persistence.locate(meta)
  293. signal?.throwIfAborted()
  294. if (location !== undefined) {
  295. try {
  296. updatedAt = (await stat(location.path)).mtimeMs
  297. } catch {
  298. // The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
  299. }
  300. signal?.throwIfAborted()
  301. }
  302. return {
  303. sessionId: meta.id,
  304. updatedAt,
  305. running: false,
  306. // Lazy persistence keeps never-appended sessions out of list(); reading
  307. // a cold log to check for turns would defeat the index read, so a listed
  308. // cold session is served as not-blank (its log holds its conversation).
  309. blank: false,
  310. ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
  311. ...meta.origin === undefined ? {} : { origin: meta.origin },
  312. /* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
  313. filters those out (legacy logs are not served); the conditional mirrors
  314. summarize() shape. */
  315. ...meta.cwd === undefined ? {} : { cwd: meta.cwd },
  316. }
  317. }
  318. /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
  319. function directoryError(error: unknown): RpcError {
  320. if (error instanceof DirectoryPickerError) {
  321. return { code: error.code, message: error.message, details: { path: error.path } }
  322. }
  323. return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }
  324. }
  325. /** Resolved Host routing and project-directory defaults consumed by the API implementation. */
  326. export interface ApiProxyDefaults {
  327. provider: string
  328. model: string
  329. /** Default project directory for new sessions whose create request carries no cwd. */
  330. cwd: string
  331. /** Parent directory for name-created workspaces. */
  332. workspaceRoot: string
  333. /** Native open-with-default-application; injectable for carrier tests. */
  334. openPath?: (path: string, signal: AbortSignal) => Promise<void>
  335. }
  336. /** The tool/call payload fields the presenter path reads. */
  337. interface ToolCallData { callId: string; name: string; arguments: string }
  338. /**
  339. * One outstanding approval question: the stable server-request id, the frame
  340. * material replayed to late mux subscribers, and the resolver that settles the
  341. * answerer's promise back into `ctx.approval`.
  342. */
  343. interface PendingApproval {
  344. rpcId: RpcId
  345. sessionId: SessionId
  346. approvalId: ApprovalRequestId
  347. toolName: string
  348. callId?: CallId
  349. reason?: string
  350. resolve(outcome: ApprovalOutcome): void
  351. }
  352. /** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */
  353. function requestedFrame(pending: PendingApproval): RpcRequest<MuxFrame> {
  354. return {
  355. rpcId: pending.rpcId,
  356. payload: {
  357. type: 'approval/requested',
  358. sessionId: pending.sessionId,
  359. approvalId: pending.approvalId,
  360. toolName: pending.toolName,
  361. ...pending.callId === undefined ? {} : { callId: pending.callId },
  362. ...pending.reason === undefined ? {} : { reason: pending.reason },
  363. },
  364. }
  365. }
  366. /** One host-owned question wait, addressed by the stable server-request id. */
  367. interface PendingQuestion {
  368. rpcId: RpcId
  369. sessionId: SessionId
  370. questions: AskUserQuestionItem[]
  371. resolve: (answer: AskUserQuestionAnswer) => void
  372. reject: (error: UserInteractionError) => void
  373. signal?: AbortSignal
  374. onAbort?: () => void
  375. }
  376. /** Validate one answer batch against the exact question request it resolves. */
  377. function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
  378. if (payload.sessionId !== pending.sessionId) return false
  379. const answers = payload.answer.answers
  380. if (answers.length !== pending.questions.length) return false
  381. return answers.every((answer, index) => {
  382. const question = pending.questions[index] as AskUserQuestionItem
  383. if (answer.id !== question.id) return false
  384. if (new Set(answer.selected).size !== answer.selected.length) return false
  385. const custom = answer.custom?.trim()
  386. if (custom !== undefined && custom === '') return false
  387. if (custom !== undefined && answer.selected.length > 0) return false
  388. if (question.multiSelect !== true && answer.selected.length > 1) return false
  389. const labels = new Set(question.options?.map(option => option.label) ?? [])
  390. return answer.selected.every(label => labels.has(label))
  391. })
  392. }
  393. /**
  394. * Compute the render intent for a tool/call or tool/result event through the
  395. * presenters registered at this moment; every other event type gets none. A
  396. * result's presenter needs its call's parsed args — `argsFor` supplies them
  397. * (live: the per-session call table; history: an in-page backscan), returning
  398. * undefined when the pairing is unavailable (e.g. the call fell off the page),
  399. * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
  400. * the client's documented default (generic JSON card) covers every miss.
  401. */
  402. function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
  403. try {
  404. if (event.type === 'tool/call') {
  405. const { name, arguments: raw } = event.data as ToolCallData
  406. const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
  407. return view === undefined ? undefined : { for: 'call', view }
  408. }
  409. if (event.type === 'tool/result') {
  410. const { message, meta } = event.data
  411. const [result] = message.content
  412. const callId = message.source.callId
  413. const call = argsFor(callId) as { name: string; args: unknown } | undefined
  414. if (call === undefined) return undefined
  415. const view = ctx.tools.get(call.name)?.presentResult?.(call.args, {
  416. content: result.content,
  417. isError: result.isError === true,
  418. ...meta === undefined ? {} : { meta },
  419. })
  420. return view === undefined ? undefined : { for: 'result', view }
  421. }
  422. } catch (error: unknown) {
  423. // A throwing presenter (or unparseable arguments) must not break delivery;
  424. // the event still ships, just without a view.
  425. console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
  426. }
  427. return undefined
  428. }
  429. /**
  430. * Resolve a tool/result's call pairing by scanning a window of events backwards
  431. * for the matching tool/call. Used by the history path (the page is the
  432. * window — a cross-page pairing soft-falls to no view) and by live-path table
  433. * misses after a reconnect-eviction.
  434. */
  435. function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
  436. for (let i = events.length - 1; i >= 0; i--) {
  437. const event = events[i] as SessionEvent
  438. if (event.type !== 'tool/call') continue
  439. const data = event.data as ToolCallData
  440. if (data.callId !== callId) continue
  441. try {
  442. return { name: data.name, args: JSON.parse(data.arguments) }
  443. } catch {
  444. // Unparseable stored arguments: same soft-fall as a live parse failure.
  445. return undefined
  446. }
  447. }
  448. return undefined
  449. }
  450. /** Render one detached history page through the same presenter path as ordinary history. */
  451. function historyPage(
  452. ctx: Context,
  453. events: readonly SessionEvent[],
  454. beforeSeq: number | undefined,
  455. maxMessages: number | undefined,
  456. ): { events: HistoryEntry[]; hasMore: boolean } {
  457. const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
  458. return {
  459. events: page.events.map((event) => {
  460. const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
  461. return { event, ...view === undefined ? {} : { view } }
  462. }),
  463. hasMore: page.hasMore,
  464. }
  465. }
  466. /**
  467. * The projection baseline for one history tail page: the registry's
  468. * watermark-cache snapshot — one fully synchronous read (no await between the
  469. * page slice and this), so all values and `asOfSeq` form a single consistent
  470. * cut and `asOfSeq` equals the window tail event seq. The carrier holds zero
  471. * domain knowledge (each value passed its unit's own schema inside the
  472. * registry). An absent registry means the deployment has no projection seam:
  473. * the whole block is absent and clients treat every key as capability-absent.
  474. */
  475. function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
  476. const registry = ctx.get('sessionProjections')
  477. if (registry === undefined) return undefined
  478. return registry.snapshot(session)
  479. }
  480. /**
  481. * The projection baseline of one session.list row, fail-soft: attached
  482. * sessions cut the registry's live watermark cache; cold sessions view the
  483. * persisted projection cache's identity-checked stored rows (zero log loads
  484. * either way — the listing use case the cache exists for). The block shape
  485. * (values + asOfSeq) matches the history tail's, so a client seeds its
  486. * value store under the same higher-seq-wins rule. Any failure — and an
  487. * empty value set — yields an absent block: a listing without projections
  488. * is degraded, never broken.
  489. */
  490. function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
  491. try {
  492. const block = session !== undefined
  493. ? ctx.get('sessionProjections')?.snapshot(session)
  494. : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
  495. return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
  496. } catch (error) {
  497. ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
  498. return undefined
  499. }
  500. }
  501. /** Projection baseline for a detached history tail without Agent activation. */
  502. function detachedProjectionsFor(
  503. ctx: Context,
  504. events: readonly SessionEvent[],
  505. ): SessionProjectionsBlock | undefined {
  506. const registry = ctx.get('sessionProjections')
  507. if (registry === undefined) return undefined
  508. return registry.restore({}, events, 0).snapshot
  509. }
  510. /** Map continuation admission failures without exposing provider details. */
  511. function subagentPromptError(
  512. request: RpcRequest<{ childSessionId: SessionId }>,
  513. error: unknown,
  514. signal: AbortSignal,
  515. ): RpcResponse<never> {
  516. const childSessionId = request.payload.childSessionId
  517. if (signal.aborted) {
  518. return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
  519. }
  520. if (error instanceof SubagentError) {
  521. switch (error.code) {
  522. case 'NOT_RESUMABLE':
  523. return err(request, {
  524. code: 'subagent-not-resumable',
  525. message: 'subagent cannot be resumed',
  526. details: { childSessionId },
  527. })
  528. case 'UNAUTHORIZED':
  529. return err(request, {
  530. code: 'subagent-unauthorized',
  531. message: 'subagent does not belong to this parent',
  532. details: { childSessionId },
  533. })
  534. case 'DRAINING':
  535. case 'ACTIVATION_CLOSING':
  536. case 'CONTINUATION_UNAVAILABLE':
  537. case 'PERSISTENCE_UNAVAILABLE':
  538. return err(request, {
  539. code: 'subagent-delivery-unavailable',
  540. message: 'subagent follow-up is temporarily unavailable',
  541. details: { childSessionId },
  542. })
  543. default:
  544. break
  545. }
  546. }
  547. return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
  548. }
  549. /** Verify one address and mode against the complete direct-child catalog. */
  550. async function catalogChild(
  551. ctx: Context,
  552. address: SubagentAddress,
  553. signal?: AbortSignal,
  554. ): Promise<{
  555. entry?: Extract<CatalogSubagentListEntry, { kind: 'child' }>
  556. error?: RpcError
  557. }> {
  558. const { parentSessionId, childSessionId, mode } = address
  559. try {
  560. const entries = await ctx.subagents.listChildren(parentSessionId, signal)
  561. const entry = entries.find(candidate => candidate.id === childSessionId)
  562. if (entry === undefined || (entry.kind === 'child' && entry.mode !== mode)) {
  563. return {
  564. error: {
  565. code: 'subagent-not-found',
  566. message: `session "${childSessionId}" is not a ${mode} direct child of "${parentSessionId}"`,
  567. details: { parentSessionId, childSessionId },
  568. },
  569. }
  570. }
  571. if (entry.kind === 'diagnostic') {
  572. return {
  573. error: {
  574. code: 'subagent-catalog-diagnostic',
  575. message: `subagent "${childSessionId}" is ${entry.reason}`,
  576. details: { parentSessionId, childSessionId, reason: entry.reason },
  577. },
  578. }
  579. }
  580. return { entry }
  581. } catch (error: unknown) {
  582. if (signal?.aborted
  583. || (error instanceof SubagentError && error.code === 'CANCELLED')
  584. || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
  585. return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
  586. }
  587. if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
  588. return {
  589. error: {
  590. code: 'subagent-not-found',
  591. message: `parent session "${parentSessionId}" was not found`,
  592. details: { parentSessionId, childSessionId },
  593. },
  594. }
  595. }
  596. return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
  597. }
  598. }
  599. /**
  600. * Thrown by the cold-resume path when the id names no servable session
  601. * (absent from the store, or a pre-project legacy log without a cwd).
  602. */
  603. class SessionNotFound extends Error {}
  604. /** Session identity whose lifecycle belongs to subagent routing, not generic Host resume. */
  605. class SubagentSessionOwnership extends Error {
  606. constructor(readonly sessionId: SessionId) {
  607. super(`session "${sessionId}" is a subagent session; use subagent delivery`)
  608. }
  609. }
  610. /** Requested identity already belongs to a session with another project cwd. */
  611. class SessionCwdConflict extends Error {
  612. constructor(
  613. readonly sessionId: SessionId,
  614. readonly requestedCwd: string,
  615. readonly existingCwd: string | undefined,
  616. ) {
  617. super(
  618. `session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
  619. + `requested ${JSON.stringify(requestedCwd)}`,
  620. )
  621. }
  622. }
  623. /** Host failed before the registry could adopt a name-created directory. */
  624. class WorkspaceDirectoryCreationError extends Error {}
  625. /** An explicit Host naming operation would duplicate another Workspace title. */
  626. class WorkspaceNameConflictError extends Error {
  627. constructor(readonly workspaceName: string) {
  628. super(`workspace name '${workspaceName}' is already in use`)
  629. this.name = 'WorkspaceNameConflictError'
  630. }
  631. }
  632. /** Shared workspace-not-found error response of the workspace.* mutation rows. */
  633. function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
  634. return err(request, {
  635. code: 'workspace-not-found',
  636. message: `workspace "${workspaceId}" not found`,
  637. details: { workspaceId },
  638. })
  639. }
  640. /** Wire projection of one workspace entity (the workspace.* value row). */
  641. function workspaceView(workspace: Workspace): WorkspaceView {
  642. return {
  643. workspaceId: workspace.id,
  644. path: workspace.path,
  645. title: workspace.title,
  646. sessionIds: [...workspace.sessionIds],
  647. createdAt: workspace.createdAt,
  648. updatedAt: workspace.updatedAt,
  649. }
  650. }
  651. /** Wire projection of the durable record carried by `domain/changed`. */
  652. function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
  653. const record: WorkspaceRecord = workspaceRecord.parse(value)
  654. return {
  655. workspaceId: workspaceId as WorkspaceId,
  656. path: record.path,
  657. title: record.title,
  658. sessionIds: [...record.sessionIds],
  659. createdAt: record.createdAt,
  660. updatedAt: record.updatedAt,
  661. }
  662. }
  663. /**
  664. * Implement ApiProxy over a composed host context.
  665. * @param ctx - a context with the Host spine and Workspace registry mounted.
  666. * @param defaults - host routing and project-directory defaults.
  667. * @returns the ApiProxy implementation.
  668. */
  669. export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
  670. const agentOptions = { provider: defaults.provider, model: defaults.model }
  671. type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
  672. const targets = new WeakMap<Agent, WebLlmTargetRef>()
  673. /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
  674. const resumes = new Map<SessionId, Promise<Agent>>()
  675. /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
  676. const sessionCreations = new Map<SessionId, Promise<Agent>>()
  677. /** Serializes path ownership and explicit title checks with Workspace mutations. */
  678. let workspaceCreationChain = Promise.resolve()
  679. const pendingQuestions = new Map<RpcId, PendingQuestion>()
  680. const pendingApprovals = new Map<RpcId, PendingApproval>()
  681. const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
  682. /**
  683. * Install or return the session-local target that prompt assembly snapshots.
  684. * Seed order: latest logged request/header, else the host default routing.
  685. * There is no create-time per-session override tier on this wire — if one
  686. * returns (a create-options contribution), it must fold in between the two.
  687. */
  688. function targetFor(agent: Agent): WebLlmTargetRef {
  689. const installed = targets.get(agent)
  690. if (installed !== undefined) return installed
  691. const logged = agent.session.requestHeader()?.config
  692. const target: WebLlmTargetRef = {
  693. current: logged === undefined
  694. ? { provider: defaults.provider, model: defaults.model }
  695. : {
  696. provider: logged.provider,
  697. model: logged.model,
  698. ...logged.reasoningEffort === undefined
  699. ? {}
  700. : { reasoningEffort: logged.reasoningEffort },
  701. },
  702. assembled: undefined,
  703. }
  704. installAgentLlmTarget(agent.ctx, target)
  705. targets.set(agent, target)
  706. return target
  707. }
  708. /** Pre-publication setup used by both fresh and resumed Web agents. */
  709. function installTarget(agentCtx: Context): void {
  710. const agent = agentCtx.agent
  711. if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
  712. targetFor(agent)
  713. }
  714. /** Send one transient frame to every connected mux consumer. */
  715. function broadcast(payload: MuxFrame): void {
  716. const envelope = frame(payload)
  717. for (const queue of muxQueues) queue.push(envelope)
  718. }
  719. // Projection change feed → session/projection push frames. The carrier
  720. // mints the wire frame (the seam package holds no wire vocabulary); the
  721. // child activates only when a projection registry is composed, and the
  722. // subscription unwinds with this gateway's fiber.
  723. ctx.inject(['sessionProjections'], (projectionCtx) => {
  724. projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
  725. broadcast({ type: 'session/projection', sessionId: session.id, key, value, seq })
  726. })
  727. })
  728. /**
  729. * Per-session pending-occurrence mirror serving live and mux-open
  730. * `session/queue` snapshots. It carries both queued and steering placements.
  731. * Each terminal inbox event retires one matching occurrence, so repeated
  732. * sends of the same identified message remain visible until every occurrence
  733. * is claimed.
  734. */
  735. const queuedMirror = new Map<SessionId, InboxItem[]>()
  736. type UnseenQueueEvent =
  737. | { readonly kind: 'update'; readonly item: InboxItem }
  738. | { readonly kind: 'terminal' }
  739. const unseenQueueEvents = new Map<SessionId, Map<InboxItemId, UnseenQueueEvent>>()
  740. const rememberUnseen = (sessionId: SessionId, itemId: InboxItemId, event: UnseenQueueEvent): void => {
  741. let events = unseenQueueEvents.get(sessionId)
  742. if (events === undefined) {
  743. events = new Map()
  744. unseenQueueEvents.set(sessionId, events)
  745. }
  746. events.set(itemId, event)
  747. // Only synchronous re-entrancy may deliver a mutation before its outer
  748. // enqueue observer. Drop unmatched protocol-invalid observations instead
  749. // of retaining process-local ids indefinitely.
  750. queueMicrotask(() => {
  751. const current = unseenQueueEvents.get(sessionId)
  752. if (current?.get(itemId) !== event) return
  753. current.delete(itemId)
  754. if (current.size === 0) unseenQueueEvents.delete(sessionId)
  755. })
  756. }
  757. const takeUnseen = (sessionId: SessionId, itemId: InboxItemId): UnseenQueueEvent | undefined => {
  758. const events = unseenQueueEvents.get(sessionId)
  759. const event = events?.get(itemId)
  760. if (event === undefined) return undefined
  761. events?.delete(itemId)
  762. if (events?.size === 0) unseenQueueEvents.delete(sessionId)
  763. return event
  764. }
  765. const publishQueue = (sessionId: SessionId): void => {
  766. const items = queuedMirror.get(sessionId) ?? []
  767. broadcast({
  768. type: 'session/queue',
  769. sessionId,
  770. items: items.map(item => ({
  771. id: item.id,
  772. placement: item.placement,
  773. message: item.message,
  774. })),
  775. })
  776. }
  777. ctx.effect(() => {
  778. const retireKnown = (sessionId: SessionId, itemId: InboxItemId): boolean => {
  779. const entries = queuedMirror.get(sessionId)
  780. if (entries === undefined) return false
  781. const index = entries.findIndex(entry => entry.id === itemId)
  782. if (index === -1) return false
  783. entries.splice(index, 1)
  784. if (entries.length === 0) queuedMirror.delete(sessionId)
  785. return true
  786. }
  787. const retire = (agent: Agent, item: InboxItem): boolean => {
  788. if (retireKnown(agent.id, item.id)) return true
  789. rememberUnseen(agent.id, item.id, { kind: 'terminal' })
  790. return false
  791. }
  792. const disposers = [
  793. ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
  794. const unseen = takeUnseen(agent.id, item.id)
  795. if (unseen?.kind === 'terminal') return
  796. let entries = queuedMirror.get(agent.id)
  797. if (entries === undefined) {
  798. entries = []
  799. queuedMirror.set(agent.id, entries)
  800. }
  801. entries.push(unseen?.kind === 'update' ? unseen.item : item)
  802. publishQueue(agent.id)
  803. }),
  804. ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => {
  805. const entries = queuedMirror.get(agent.id)
  806. if (entries === undefined) {
  807. rememberUnseen(agent.id, item.id, { kind: 'update', item })
  808. return
  809. }
  810. const index = entries.findIndex(entry => entry.id === item.id)
  811. if (index === -1) {
  812. rememberUnseen(agent.id, item.id, { kind: 'update', item })
  813. return
  814. }
  815. entries.splice(index, 1, item)
  816. publishQueue(agent.id)
  817. }),
  818. ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => {
  819. if (item.placement === 'steering') {
  820. // AgentLoop appends the durable steering/message synchronously after
  821. // this claim. Retain and retire the mirror row in the following
  822. // microtask so any re-entrant snapshot and the Host's linear mux
  823. // stream keep it visible until the durable event exists.
  824. const present = queuedMirror.get(agent.id)?.some(entry => entry.id === item.id) === true
  825. if (!present) {
  826. retire(agent, item)
  827. return
  828. }
  829. queueMicrotask(() => {
  830. if (retireKnown(agent.id, item.id)) publishQueue(agent.id)
  831. })
  832. } else if (retire(agent, item)) {
  833. // Queued claims have no durable same-message handoff to order.
  834. // Publish retirement synchronously as before.
  835. publishQueue(agent.id)
  836. }
  837. }),
  838. ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => {
  839. let changed = false
  840. for (const item of items) changed = retire(agent, item) || changed
  841. if (changed) publishQueue(agent.id)
  842. }),
  843. ctx.on('session/disposed', (session: Session) => {
  844. queuedMirror.delete(session.id)
  845. unseenQueueEvents.delete(session.id)
  846. }),
  847. ]
  848. return () => { for (const dispose of disposers) dispose() }
  849. }, 'api-proxy: queued mirror')
  850. /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
  851. function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
  852. pendingQuestions.delete(pending.rpcId)
  853. if (pending.signal !== undefined && pending.onAbort !== undefined) {
  854. pending.signal.removeEventListener('abort', pending.onAbort)
  855. }
  856. broadcast({
  857. type: 'question/resolved', sessionId: pending.sessionId,
  858. questionRpcId: pending.rpcId, outcome,
  859. })
  860. }
  861. const disposeProvider = ctx.userInteraction.registerProvider({
  862. ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
  863. const sessionId = request.agent?.id
  864. if (sessionId === undefined) {
  865. return Promise.reject(new UserInteractionError(
  866. 'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
  867. }
  868. return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
  869. const rpcId = RpcId(randomUUID())
  870. const pending: PendingQuestion = {
  871. rpcId, sessionId, questions: request.questions, resolve, reject,
  872. ...(request.signal === undefined ? {} : { signal: request.signal }),
  873. }
  874. const onAbort = (): void => {
  875. claimQuestion(pending, 'cancelled')
  876. reject(new UserInteractionError(
  877. 'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
  878. }
  879. pending.onAbort = onAbort
  880. pendingQuestions.set(rpcId, pending)
  881. request.signal?.addEventListener('abort', onAbort, { once: true })
  882. const envelope: RpcRequest<MuxFrame> = {
  883. rpcId,
  884. payload: { type: 'question/requested', sessionId, questions: request.questions },
  885. }
  886. for (const queue of muxQueues) queue.push(envelope)
  887. })
  888. },
  889. })
  890. ctx.effect(() => () => {
  891. disposeProvider()
  892. for (const pending of [...pendingQuestions.values()]) {
  893. claimQuestion(pending, 'cancelled')
  894. pending.reject(new UserInteractionError(
  895. 'web user-interaction provider was disposed', 'ASK_ABORTED'))
  896. }
  897. }, 'api-proxy: user-interaction provider')
  898. // --- Approval pending registry ------------------------------------------
  899. // The proxy is the approval channel for every agent this host owns: an ask
  900. // through `ctx.approval` becomes an answerable server-request on the mux
  901. // stream (stable rpcId), settled by POST /api/respond. The entry survives
  902. // client disconnects — mux-open replays still-pending requested frames with
  903. // the same rpcId (the refresh-recovery baseline) — and withdraws on the
  904. // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
  905. if (ctx.get('approval') !== undefined) {
  906. // Teardown parity with the question provider above: a gateway disposed
  907. // while approvals are pending settles every entry as 'cancelled' (the
  908. // service's fail-closed vocabulary), so no ask promise dangles past the
  909. // proxy's lifetime and subscribers see the withdrawal.
  910. ctx.effect(() => () => {
  911. for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled')
  912. }, 'api-proxy: approval registry teardown')
  913. ctx.on('approval/request', (req, next) => {
  914. // Dispatch rides a microtask behind the service's own signal check: an
  915. // abort landing in that window would register the abort listener AFTER
  916. // the signal fired — never invoked, entry pending forever, zombie frame
  917. // on every mux replay. Settle synchronously instead of publishing.
  918. if (req.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
  919. // The audit pair `approval/asked` is already appended by the service
  920. // before dispatch, but dispatch rides a microtask: parallel tool calls
  921. // can append several asked events before any answerer runs. THIS
  922. // request's event is therefore the newest asked event that is still
  923. // undecided, unclaimed by another pending entry, and — when the ask
  924. // names a call — carries the same callId.
  925. const events = req.agent.session.events
  926. const claimed = new Set<ApprovalRequestId>()
  927. for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId)
  928. const decided = new Set<ApprovalRequestId>()
  929. let approvalId: ApprovalRequestId | undefined
  930. for (let i = events.length - 1; i >= 0; i -= 1) {
  931. const event = events[i] as SessionEvent
  932. if (event.type === 'approval/decided') {
  933. decided.add(event.data.id)
  934. } else if (event.type === 'approval/asked') {
  935. if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
  936. // Symmetric pairing: a callId-bearing ask only takes its own call's
  937. // record, and a callId-less ask only takes a callId-less record —
  938. // so neither shape can steal the other's audit id under parallel
  939. // asks. (Today every producer — the tool executor — passes callId;
  940. // the callId-less arm guards any future non-tool asker.)
  941. if ((req.callId ?? null) !== (event.data.callId ?? null)) continue
  942. approvalId = event.data.id
  943. break
  944. }
  945. }
  946. // No asked event means the request bypassed the service's audit path —
  947. // not this channel's question; delegate to the fail-closed default.
  948. if (approvalId === undefined) return next()
  949. const id = approvalId
  950. return new Promise<ApprovalOutcome>((resolve) => {
  951. const settle = (outcome: ApprovalOutcome): void => {
  952. /* v8 ignore next 3 -- defensive double-settle guard: respond() routes
  953. through the pending table (a settled id is not-pending before it can
  954. re-settle) and the first settle removes the abort listener, so no
  955. reachable path settles twice; kept against future settle callers. */
  956. if (!pendingApprovals.delete(pending.rpcId)) return
  957. req.signal?.removeEventListener('abort', onAbort)
  958. broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome })
  959. // A cancelled ask was already settled by the service's own signal
  960. // race, which discards this late resolution; resolving is a no-op
  961. // there and keeps this promise from dangling forever.
  962. resolve(outcome)
  963. }
  964. const onAbort = (): void => { settle('cancelled') }
  965. const pending: PendingApproval = {
  966. rpcId: RpcId(randomUUID()),
  967. sessionId: req.agent.session.id,
  968. approvalId: id,
  969. toolName: req.toolName,
  970. ...req.callId === undefined ? {} : { callId: req.callId },
  971. ...req.reason === undefined ? {} : { reason: req.reason },
  972. resolve: settle,
  973. }
  974. pendingApprovals.set(pending.rpcId, pending)
  975. req.signal?.addEventListener('abort', onAbort, { once: true })
  976. const envelope = requestedFrame(pending)
  977. for (const queue of muxQueues) queue.push(envelope)
  978. })
  979. })
  980. }
  981. /** Whether the session's own suffix carries the durable subagent discriminator. */
  982. function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
  983. const ownStart = session.header.seedLength ?? 0
  984. return session.events.slice(ownStart).some(event => event.type === 'subagent/descriptor')
  985. }
  986. /**
  987. * Generic Host interaction cannot claim a durably classified subagent or an
  988. * Agent created through its live parent. The runtime-owner arm also covers
  989. * descriptor-less child publication windows and older stored headers.
  990. */
  991. function hasSubagentOwner(
  992. session: Pick<Session, 'events' | 'header'>,
  993. agent: Agent | undefined,
  994. ): boolean {
  995. if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
  996. const parentId = session.header.parentSession
  997. if (parentId === undefined || agent === undefined) return false
  998. const parent = ctx.agents.get(parentId)
  999. return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent)
  1000. }
  1001. /** Stable generic-Host error for an identity reserved to subagent routing. */
  1002. function subagentOwnershipError(sessionId: SessionId): RpcError {
  1003. return {
  1004. code: 'agent-busy',
  1005. message: `session "${sessionId}" is owned by subagent routing`,
  1006. details: { reason: 'use subagent delivery for this child session' },
  1007. }
  1008. }
  1009. /** Inspect one cold served session without repairing, resuming, or publishing it. */
  1010. async function inspectServable(sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
  1011. const persistence = ctx.get('sessionPersistence')
  1012. if (persistence === undefined) {
  1013. throw new Error('session persistence is not configured (load a dsh-session-persistence backend)')
  1014. }
  1015. const meta = (await persistence.list()).find(m => m.id === sessionId)
  1016. if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
  1017. const inspected = await persistence.inspect(sessionId)
  1018. if (inspected.meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
  1019. return inspected
  1020. }
  1021. async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
  1022. const attached = ctx.sessions.get(sessionId)
  1023. const live = ctx.agents.get(sessionId)
  1024. if (attached !== undefined && hasSubagentOwner(attached, live)) {
  1025. return { error: subagentOwnershipError(sessionId) }
  1026. }
  1027. if (live !== undefined) return { agent: live }
  1028. let resume = resumes.get(sessionId)
  1029. if (resume === undefined) {
  1030. resume = (async () => {
  1031. try {
  1032. const inspected = await inspectServable(sessionId)
  1033. if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
  1034. throw new SubagentSessionOwnership(sessionId)
  1035. }
  1036. const publishedSession = ctx.sessions.get(sessionId)
  1037. const publishedAgent = ctx.agents.get(sessionId)
  1038. if (publishedSession !== undefined && hasSubagentOwner(publishedSession, publishedAgent)) {
  1039. throw new SubagentSessionOwnership(sessionId)
  1040. }
  1041. const handle = await ctx.agents.resume({
  1042. resumeSessionId: sessionId,
  1043. agentOptions,
  1044. setup: installTarget,
  1045. })
  1046. return handle.agent
  1047. } finally {
  1048. resumes.delete(sessionId)
  1049. }
  1050. })()
  1051. resumes.set(sessionId, resume)
  1052. }
  1053. try {
  1054. return { agent: await resume }
  1055. } catch (error: unknown) {
  1056. if (error instanceof SessionNotFound) {
  1057. return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
  1058. }
  1059. if (error instanceof SubagentSessionOwnership) {
  1060. return { error: subagentOwnershipError(error.sessionId) }
  1061. }
  1062. // The internal details slot is contractually {}; the reason rides the message.
  1063. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
  1064. }
  1065. }
  1066. type SessionReadState = {
  1067. id: SessionId
  1068. header: SessionHeader
  1069. events: SessionEvent[]
  1070. }
  1071. /** Read one stable session prefix without acquiring an Agent owner. */
  1072. async function readSessionState(sessionId: SessionId): Promise<SessionReadState> {
  1073. const attached = ctx.sessions.get(sessionId)
  1074. if (attached !== undefined) {
  1075. return {
  1076. id: attached.id,
  1077. header: attached.header,
  1078. events: [...attached.events],
  1079. }
  1080. }
  1081. const inspected = await inspectServable(sessionId)
  1082. return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
  1083. }
  1084. /** Resolve the Workspace inherited by a fork without making ordinary loose lineage grouped. */
  1085. async function forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
  1086. const workspaces = ctx.workspace.list()
  1087. const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
  1088. if (direct !== undefined || source.header.origin !== 'subagent') return direct
  1089. const lineage = await ctx.sessionQuery.traceSession(source.id)
  1090. for (const ancestor of lineage.ancestors) {
  1091. const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))
  1092. if (workspace !== undefined) return workspace
  1093. }
  1094. return undefined
  1095. }
  1096. /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
  1097. async function historyStateFor(
  1098. sessionId: SessionId,
  1099. includeProjections: boolean,
  1100. ): Promise<{ events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
  1101. const attached = ctx.sessions.get(sessionId)
  1102. if (attached !== undefined) {
  1103. const events = [...attached.events]
  1104. const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
  1105. return { events, ...projections === undefined ? {} : { projections } }
  1106. }
  1107. const inspected = await inspectServable(sessionId)
  1108. const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
  1109. return {
  1110. events: inspected.events,
  1111. ...projections === undefined ? {} : { projections },
  1112. }
  1113. }
  1114. /** Resolve one requested identity to a live agent, creating or resuming it once. */
  1115. async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
  1116. let creation = sessionCreations.get(sessionId)
  1117. if (creation === undefined) {
  1118. creation = (async () => {
  1119. const attached = ctx.sessions.get(sessionId)
  1120. const live = ctx.agents.get(sessionId)
  1121. if (attached !== undefined && hasSubagentOwner(attached, live)) {
  1122. throw new SubagentSessionOwnership(sessionId)
  1123. }
  1124. if (live !== undefined) return live
  1125. const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
  1126. const stored = persistence === undefined
  1127. ? undefined
  1128. : (await persistence.list()).find(header => header.id === sessionId)
  1129. if (persistence !== undefined && stored !== undefined) {
  1130. if (stored.cwd !== cwd) {
  1131. throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
  1132. }
  1133. const inspected = await persistence.inspect(sessionId)
  1134. if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
  1135. throw new SubagentSessionOwnership(sessionId)
  1136. }
  1137. return (await ctx.agents.resume({
  1138. resumeSessionId: sessionId,
  1139. agentOptions,
  1140. setup: installTarget,
  1141. })).agent
  1142. }
  1143. try {
  1144. await mkdir(cwd, { recursive: true })
  1145. } catch (error: unknown) {
  1146. throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
  1147. }
  1148. return (await ctx.agents.create({
  1149. sessionId,
  1150. agentOptions,
  1151. meta: { cwd },
  1152. setup: installTarget,
  1153. })).agent
  1154. })().catch((error: unknown) => {
  1155. // Another Host entry path may have published the same identity while
  1156. // this operation crossed an asynchronous persistence/filesystem step.
  1157. const live = ctx.agents.get(sessionId)
  1158. if (live !== undefined) {
  1159. if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId)
  1160. return live
  1161. }
  1162. const attached = ctx.sessions.get(sessionId)
  1163. if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
  1164. throw new SubagentSessionOwnership(sessionId)
  1165. }
  1166. throw error
  1167. }).finally(() => {
  1168. sessionCreations.delete(sessionId)
  1169. })
  1170. sessionCreations.set(sessionId, creation)
  1171. }
  1172. const agent = await creation
  1173. if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
  1174. if (agent.session.header.cwd !== cwd) {
  1175. throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
  1176. }
  1177. return agent
  1178. }
  1179. /** Resolve or create one path while holding the Host's workspace-create chain. */
  1180. function ensureWorkspace(
  1181. path: string,
  1182. title: string | undefined,
  1183. rejectExistingName = false,
  1184. createDirectory = false,
  1185. ): Promise<{ workspace: Workspace; created: boolean }> {
  1186. const operation = workspaceCreationChain.then(async () => {
  1187. if (rejectExistingName && title !== undefined
  1188. && ctx.workspace.list().some(workspace => workspace.title === title)) {
  1189. throw new WorkspaceNameConflictError(title)
  1190. }
  1191. if (createDirectory) {
  1192. try {
  1193. await mkdir(path, { recursive: true })
  1194. } catch (error: unknown) {
  1195. throw new WorkspaceDirectoryCreationError(
  1196. `failed to create workspace directory "${path}": ${String(error)}`,
  1197. )
  1198. }
  1199. }
  1200. const existing = await ctx.workspace.resolveByPath(path)
  1201. if (existing !== undefined) return { workspace: existing, created: false }
  1202. return { workspace: await ctx.workspace.create(path, title), created: true }
  1203. })
  1204. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  1205. return operation
  1206. }
  1207. /**
  1208. * Build the session.list baseline shared by listing and search visibility.
  1209. * Attached sessions come from memory; servable cold sessions merge from
  1210. * persistence, and the final order is newest-first.
  1211. */
  1212. async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
  1213. signal?.throwIfAborted()
  1214. const items = ctx.sessions.list().map((session) => {
  1215. const agent = ctx.agents.get(session.id)
  1216. const projections = listProjectionsFor(ctx, session.header, session)
  1217. return {
  1218. ...summarize(session, agent?.status === 'running'),
  1219. ...projections === undefined ? {} : { projections },
  1220. }
  1221. })
  1222. signal?.throwIfAborted()
  1223. const attached = new Set(items.map(item => item.sessionId))
  1224. const persistence = ctx.get('sessionPersistence')
  1225. if (persistence !== undefined) {
  1226. const cold = (await persistence.list(signal))
  1227. .filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
  1228. signal?.throwIfAborted()
  1229. for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
  1230. signal?.throwIfAborted()
  1231. const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
  1232. const settled = await Promise.allSettled(
  1233. batch.map(async (meta) => {
  1234. // Cold rows read the persisted projection cache only — never a
  1235. // log load; a session without a cache row simply has no column.
  1236. const projections = listProjectionsFor(ctx, meta, undefined)
  1237. return {
  1238. ...await summarizeCold(persistence, meta, signal),
  1239. ...projections === undefined ? {} : { projections },
  1240. }
  1241. }),
  1242. )
  1243. const summaries: SessionSummary[] = []
  1244. let rejected = false
  1245. let failure: unknown
  1246. for (const result of settled) {
  1247. if (result.status === 'fulfilled') {
  1248. summaries.push(result.value)
  1249. } else if (!rejected) {
  1250. rejected = true
  1251. failure = result.reason
  1252. }
  1253. }
  1254. if (rejected) throw failure
  1255. signal?.throwIfAborted()
  1256. items.push(...summaries)
  1257. }
  1258. }
  1259. items.sort((a, b) => b.updatedAt - a.updatedAt)
  1260. return items
  1261. }
  1262. /** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
  1263. function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
  1264. const goals = ctx.get('goals')
  1265. if (goals === undefined) {
  1266. return { error: { code: 'internal', message: 'goal service is absent: this deployment does not mount @deepseek-ai/dsh-goal in its composition (cordis.yml or explicit assembly)', details: {} } }
  1267. }
  1268. return goals
  1269. }
  1270. /** Map one goal-domain rejection to the wire error (stable GoalError codes ride in details). */
  1271. function goalError(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> {
  1272. const details = error instanceof GoalError ? { goalCode: error.code } : {}
  1273. return err(request, { code: 'internal', message: String(error), details })
  1274. }
  1275. /** Resolve a session's agent, apply one goal mutation, and acknowledge with the new CAS ref. */
  1276. async function mutateGoal(
  1277. request: RpcRequest<{ sessionId: SessionId }>,
  1278. mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
  1279. ): Promise<RpcResponse<{ ref: GoalRef }>> {
  1280. const goals = goalService()
  1281. if ('error' in goals) return err(request, goals.error)
  1282. const found = await agentFor(request.payload.sessionId)
  1283. if ('error' in found) return err(request, found.error)
  1284. try {
  1285. const ref = mutation(goals, found.agent)
  1286. return ok(request, { ref: { id: ref.id, revision: ref.revision } })
  1287. } catch (error: unknown) {
  1288. return goalError(request, error)
  1289. }
  1290. }
  1291. /** Missing-service report shared by the settings domain (skills-domain stance). */
  1292. function settingsAbsent(): RpcError {
  1293. 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: {} }
  1294. }
  1295. /** Missing-service report shared by the credentials domain. */
  1296. function credentialsAbsent(): RpcError {
  1297. 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: {} }
  1298. }
  1299. /** Map one redacted seam descriptor to its wire view. */
  1300. function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
  1301. return {
  1302. ns: String(descriptor.ns),
  1303. schema: descriptor.schema,
  1304. value: descriptor.value,
  1305. ...descriptor.base === undefined ? {} : { base: descriptor.base },
  1306. ...descriptor.user === undefined ? {} : { user: descriptor.user },
  1307. applies: descriptor.applies,
  1308. secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
  1309. revision: descriptor.revision,
  1310. }
  1311. }
  1312. /** Settings namespaces whose changes can invalidate the model catalog. */
  1313. function modelProviderNamespaces(): Set<string> {
  1314. return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs))
  1315. }
  1316. /**
  1317. * The settings namespaces this proxy serves: configurable model providers
  1318. * plus the small explicit Web preference and product-owned allowlists. The
  1319. * settings seam remains general; a future registration does not become
  1320. * remotely readable or writable by default.
  1321. */
  1322. function exposedNamespaces(): Set<string> {
  1323. const exposed = modelProviderNamespaces()
  1324. for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns)
  1325. for (const ns of PRODUCT_SETTINGS_NAMESPACES) exposed.add(ns)
  1326. return exposed
  1327. }
  1328. /** Refuse a namespace outside the explicit configuration-client boundary. */
  1329. function notExposed(request: RpcRequest<unknown>, ns: string): RpcResponse<SettingsNamespaceView> {
  1330. return err(request, {
  1331. code: 'settings-not-exposed',
  1332. message: `settings namespace "${ns}" is not exposed to configuration clients`,
  1333. details: { ns },
  1334. })
  1335. }
  1336. /**
  1337. * Run one settings write (merge or wholesale replace) and acknowledge with
  1338. * the namespace's new redacted view. A namespace outside the configuration
  1339. * boundary is refused before the seam is touched; every seam refusal —
  1340. * unknown or invalid namespace, read-only provider, schema validation,
  1341. * storage — becomes one `settings-rejected` carrying the seam's own message.
  1342. */
  1343. async function settingsWrite(
  1344. request: RpcRequest<unknown>,
  1345. ns: string,
  1346. mode: 'update' | 'replace' | 'mutate',
  1347. section: object,
  1348. expectedRevision?: number,
  1349. ): Promise<RpcResponse<SettingsNamespaceView>> {
  1350. const settings = ctx.get('settings')
  1351. if (settings === undefined) return err(request, settingsAbsent())
  1352. const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => {
  1353. // A stale writer is its own outcome, not a malformed request: the client
  1354. // must re-read and re-apply rather than treat the write as invalid.
  1355. if (error instanceof SettingsConflictError) {
  1356. return err(request, {
  1357. code: 'settings-conflict',
  1358. message: error.message,
  1359. details: { ns, expected: error.expected, actual: error.actual },
  1360. })
  1361. }
  1362. return err(request, {
  1363. code: 'settings-rejected',
  1364. message: error instanceof Error ? error.message : String(error),
  1365. details: { ns },
  1366. })
  1367. }
  1368. let branded: SettingsNamespace
  1369. try {
  1370. branded = settingsNamespace(ns)
  1371. } catch (error: unknown) {
  1372. // A malformed name is a client bug, reported as such; it could never be
  1373. // in the exposed set either, so naming the real fault costs no ground.
  1374. return rejected(error)
  1375. }
  1376. if (!exposedNamespaces().has(ns)) return notExposed(request, ns)
  1377. try {
  1378. if (mode === 'update') await settings.update(branded, section, expectedRevision)
  1379. else if (mode === 'replace') await settings.replace(branded, section, expectedRevision)
  1380. else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision)
  1381. } catch (error: unknown) {
  1382. return rejected(error)
  1383. }
  1384. const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
  1385. if (descriptor === undefined) {
  1386. // The write committed but the namespace vanished before this read: only
  1387. // a concurrent registrant disposal can produce it.
  1388. return err(request, { code: 'internal', message: `settings namespace "${ns}" was disposed after the ${mode}`, details: {} })
  1389. }
  1390. return ok(request, namespaceView(descriptor))
  1391. }
  1392. return {
  1393. sessions: {
  1394. // Attached sessions summarize from memory; persisted-but-unattached (cold)
  1395. // sessions merge in from the persistence store so history survives restarts.
  1396. // Legacy logs without a cwd (pre-project stance) are not served — every
  1397. // session now records its project at create time.
  1398. async list(request) {
  1399. return ok(request, { items: await listVisibleSessionSummaries() })
  1400. },
  1401. async search(request, signal) {
  1402. const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, {
  1403. code: 'cancelled',
  1404. message: 'session search was aborted',
  1405. details: {},
  1406. })
  1407. if (isAborted(signal)) return cancelled()
  1408. const sessionQuery = ctx.get('sessionQuery')
  1409. if (sessionQuery === undefined) {
  1410. return err(request, {
  1411. code: 'internal',
  1412. message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
  1413. details: {},
  1414. })
  1415. }
  1416. try {
  1417. const visible = await listVisibleSessionSummaries(signal)
  1418. if (isAborted(signal)) return cancelled()
  1419. if (visible.length === 0) return ok(request, { items: [], hasMore: false })
  1420. const visibleIds = new Set(visible.map(item => item.sessionId))
  1421. const authorized: SessionSearchItem[] = []
  1422. const acceptedIds = new Set<SessionId>()
  1423. const seenCursors = new Set<SessionSearchCursor>()
  1424. let cursor: SessionSearchCursor | undefined
  1425. let providerCallCount = 0
  1426. let providerPageLimit = SESSION_SEARCH_RESULT_LIMIT
  1427. while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
  1428. if (isAborted(signal)) return cancelled()
  1429. if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) {
  1430. throw new Error(
  1431. `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`,
  1432. )
  1433. }
  1434. providerCallCount++
  1435. const requestedCursor = cursor
  1436. const requestedPageLimit = providerPageLimit
  1437. let page
  1438. try {
  1439. page = await sessionQuery.searchSessions({
  1440. query: request.payload.query,
  1441. eventFilters: [
  1442. { kind: 'type', values: ['user/message', 'assistant/message', 'steering/message'] },
  1443. { kind: 'surface', values: ['current'] },
  1444. ],
  1445. limit: requestedPageLimit,
  1446. ...requestedCursor === undefined ? {} : { cursor: requestedCursor },
  1447. }, { signal })
  1448. } catch (error: unknown) {
  1449. if (isAborted(signal)) return cancelled()
  1450. if (
  1451. requestedCursor === undefined
  1452. && error instanceof SessionQueryError
  1453. && error.code === 'SESSION_QUERY_INVALID_LIMIT'
  1454. && requestedPageLimit > 1
  1455. ) {
  1456. providerPageLimit = Math.max(1, Math.floor(requestedPageLimit / 2))
  1457. continue
  1458. }
  1459. if (
  1460. requestedCursor !== undefined
  1461. && error instanceof SessionQueryError
  1462. && error.code === 'SESSION_QUERY_STALE_CURSOR'
  1463. ) {
  1464. authorized.length = 0
  1465. acceptedIds.clear()
  1466. seenCursors.clear()
  1467. cursor = undefined
  1468. continue
  1469. }
  1470. throw error
  1471. }
  1472. if (isAborted(signal)) return cancelled()
  1473. const providerItemCount = page.items.length
  1474. if (providerItemCount > requestedPageLimit) {
  1475. throw new Error(
  1476. `session search provider returned ${providerItemCount} items; maximum is ${requestedPageLimit}`,
  1477. )
  1478. }
  1479. // Host visibility is the authorization boundary. Consume the
  1480. // provider's globally ranked stream rather than binding every
  1481. // visible id into one SQLite statement, then re-check complete
  1482. // provenance before emitting any snippet.
  1483. for (const hit of page.items) {
  1484. if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
  1485. if (
  1486. !visibleIds.has(hit.header.id)
  1487. || hit.bestMatch.sessionId !== hit.header.id
  1488. || hit.bestMatch.surface !== 'current'
  1489. || !MESSAGE_TYPES.has(hit.bestMatch.type)
  1490. || acceptedIds.has(hit.header.id)
  1491. ) continue
  1492. const snippet = truncateUnicodeCodePoints(
  1493. hit.bestMatch.snippet,
  1494. SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
  1495. )
  1496. acceptedIds.add(hit.header.id)
  1497. authorized.push({
  1498. sessionId: hit.header.id,
  1499. snippet,
  1500. })
  1501. }
  1502. const nextCursor = page.nextCursor
  1503. if (nextCursor !== undefined) {
  1504. if (seenCursors.has(nextCursor)) {
  1505. throw new Error('session search provider repeated a continuation cursor')
  1506. }
  1507. seenCursors.add(nextCursor)
  1508. }
  1509. if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || nextCursor === undefined) break
  1510. cursor = nextCursor
  1511. }
  1512. return ok(request, {
  1513. items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
  1514. hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
  1515. })
  1516. } catch (error: unknown) {
  1517. if (
  1518. isAborted(signal)
  1519. || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')
  1520. ) return cancelled()
  1521. // XXX: Redact provider details before exposing this gateway beyond
  1522. // its current single-user local deployment.
  1523. return err(request, {
  1524. code: 'internal',
  1525. message: `session search failed: ${String(error)}`,
  1526. details: {},
  1527. })
  1528. }
  1529. },
  1530. async create(request) {
  1531. const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
  1532. let workspace: Workspace | undefined
  1533. if (request.payload.workspaceId !== undefined) {
  1534. workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
  1535. if (workspace === undefined) {
  1536. return err(request, {
  1537. code: 'workspace-not-found',
  1538. message: `workspace "${request.payload.workspaceId}" not found`,
  1539. details: { workspaceId: request.payload.workspaceId },
  1540. })
  1541. }
  1542. }
  1543. const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
  1544. try {
  1545. await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
  1546. } catch (error: unknown) {
  1547. if (error instanceof SessionCwdConflict) {
  1548. return err(request, {
  1549. code: 'session-conflict',
  1550. message: error.message,
  1551. details: {
  1552. sessionId: error.sessionId,
  1553. requestedCwd: error.requestedCwd,
  1554. ...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
  1555. },
  1556. })
  1557. }
  1558. if (error instanceof SubagentSessionOwnership) {
  1559. return err(request, subagentOwnershipError(error.sessionId))
  1560. }
  1561. return err(request, {
  1562. code: 'internal',
  1563. message: `failed to create session "${sessionId}": ${String(error)}`,
  1564. details: {},
  1565. })
  1566. }
  1567. if (workspace !== undefined) {
  1568. try {
  1569. await workspace.attachSession(sessionId)
  1570. } catch (error: unknown) {
  1571. return err(request, {
  1572. code: 'workspace-attach-failed',
  1573. message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
  1574. details: { sessionId, workspaceId: workspace.id },
  1575. })
  1576. }
  1577. }
  1578. return ok(request, { sessionId })
  1579. },
  1580. async history(request) {
  1581. const { sessionId, beforeSeq, maxMessages } = request.payload
  1582. let state: { events: SessionEvent[]; projections?: SessionProjectionsBlock }
  1583. try {
  1584. state = await historyStateFor(sessionId, beforeSeq === undefined)
  1585. } catch (error: unknown) {
  1586. if (error instanceof SessionNotFound) {
  1587. return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
  1588. }
  1589. return err(request, {
  1590. code: 'internal',
  1591. message: `history unavailable for session "${sessionId}": ${String(error)}`,
  1592. details: {},
  1593. })
  1594. }
  1595. const page = historyPage(ctx, state.events, beforeSeq, maxMessages)
  1596. return ok(request, {
  1597. events: page.events,
  1598. hasMore: page.hasMore,
  1599. ...state.projections === undefined ? {} : { projections: state.projections },
  1600. })
  1601. },
  1602. async models(request) {
  1603. const { sessionId } = request.payload
  1604. const found = await agentFor(sessionId)
  1605. if ('error' in found) return err(request, found.error)
  1606. const current = targetFor(found.agent).current
  1607. const { groups, failures } = await buildModelCatalog(ctx, current)
  1608. return ok(request, { current: { ...current }, groups, failures })
  1609. },
  1610. async selectModel(request) {
  1611. const { sessionId, provider, model, reasoningEffort } = request.payload
  1612. const found = await agentFor(sessionId)
  1613. if ('error' in found) return err(request, found.error)
  1614. try {
  1615. const resolved = await ctx.llm.resolveCallConfig({
  1616. provider,
  1617. model,
  1618. ...reasoningEffort === undefined
  1619. ? {}
  1620. : { reasoningEffort: ReasoningEffortId(reasoningEffort) },
  1621. })
  1622. const selected: AgentLlmTarget = {
  1623. provider: resolved.provider,
  1624. model: resolved.model,
  1625. ...resolved.reasoningEffort === undefined
  1626. ? {}
  1627. : { reasoningEffort: resolved.reasoningEffort },
  1628. }
  1629. targetFor(found.agent).current = selected
  1630. return ok(request, { selected: { ...selected } })
  1631. } catch (error: unknown) {
  1632. return err(request, {
  1633. code: 'model-unavailable',
  1634. message: error instanceof Error ? error.message : String(error),
  1635. details: { provider, model },
  1636. })
  1637. }
  1638. },
  1639. async rename(request) {
  1640. const { sessionId, title } = request.payload
  1641. const found = await agentFor(sessionId)
  1642. if ('error' in found) return err(request, found.error)
  1643. const titles = ctx.get('sessionTitle')
  1644. if (titles === undefined) {
  1645. return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} })
  1646. }
  1647. try {
  1648. const accepted = titles.rename(found.agent.session, title)
  1649. return ok(request, { title: accepted.title, seq: accepted.eventSeq })
  1650. } catch (error: unknown) {
  1651. // Only the input's fault maps to title-invalid (the message is
  1652. // product-user-visible in the rename dialog); liveness and disposal
  1653. // races are deployment trouble, not a bad title.
  1654. if (error instanceof SessionTitleInvalidError) {
  1655. return err(request, {
  1656. code: 'title-invalid',
  1657. message: error.message,
  1658. details: { sessionId },
  1659. })
  1660. }
  1661. return err(request, {
  1662. code: 'internal',
  1663. message: `failed to rename session "${sessionId}": ${String(error)}`,
  1664. details: {},
  1665. })
  1666. }
  1667. },
  1668. async fork(request) {
  1669. const { sessionId, atSeq } = request.payload
  1670. let source: SessionReadState
  1671. try {
  1672. source = await readSessionState(sessionId)
  1673. } catch (error: unknown) {
  1674. if (error instanceof SessionNotFound) {
  1675. return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
  1676. }
  1677. return err(request, {
  1678. code: 'internal',
  1679. message: `fork source unavailable for session "${sessionId}": ${String(error)}`,
  1680. details: {},
  1681. })
  1682. }
  1683. const events = source.events
  1684. // An in-log anchor belongs to the turn containing it and must never
  1685. // clip backward to an earlier completed turn. Omitted and past-end
  1686. // anchors retain the last-completed-turn shortcut.
  1687. const lastSeq = events.at(-1)?.seq ?? -1
  1688. const anchoredBoundary = atSeq === undefined
  1689. ? undefined
  1690. : events.find(e => e.type === 'turn/end' && e.seq >= atSeq)
  1691. const boundary = anchoredBoundary
  1692. ?? (atSeq === undefined || atSeq > lastSeq
  1693. ? events.findLast(e => e.type === 'turn/end')
  1694. : undefined)
  1695. if (boundary === undefined) {
  1696. return err(request, {
  1697. code: 'fork-unavailable',
  1698. message: atSeq !== undefined && atSeq <= lastSeq
  1699. ? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}`
  1700. : `session "${sessionId}" has no completed turn to fork from`,
  1701. details: { sessionId },
  1702. })
  1703. }
  1704. // Extend the cut through trailing out-of-band appends (session/title,
  1705. // injections) up to the next turn/start: they are standalone events, so
  1706. // the seed stays balanced, and the child inherits a title generated
  1707. // right after the boundary turn.
  1708. let cut = boundary.seq + 1
  1709. while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
  1710. let workspace: Workspace | undefined
  1711. try {
  1712. workspace = await forkWorkspace(source)
  1713. } catch (error: unknown) {
  1714. return err(request, {
  1715. code: 'internal',
  1716. message: `failed to resolve fork workspace for session "${sessionId}": ${String(error)}`,
  1717. details: {},
  1718. })
  1719. }
  1720. const childId = `session-${randomUUID()}` as SessionId
  1721. try {
  1722. await ctx.agents.create({
  1723. sessionId: childId,
  1724. seed: events.slice(0, cut),
  1725. meta: {
  1726. ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
  1727. parentSession: source.id,
  1728. seedLength: cut,
  1729. },
  1730. agentOptions,
  1731. setup: installTarget,
  1732. })
  1733. } catch (error: unknown) {
  1734. return err(request, {
  1735. code: 'internal',
  1736. message: `failed to fork session "${sessionId}": ${String(error)}`,
  1737. details: {},
  1738. })
  1739. }
  1740. // An ordinary source keeps its direct Workspace. A subagent source is
  1741. // not listed there, so its ordinary fork joins the nearest owning
  1742. // ancestor instead. The child is already published if attach fails.
  1743. if (workspace !== undefined) {
  1744. try {
  1745. await workspace.attachSession(childId)
  1746. } catch (error: unknown) {
  1747. return err(request, {
  1748. code: 'workspace-attach-failed',
  1749. message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
  1750. details: { sessionId: childId, workspaceId: workspace.id },
  1751. })
  1752. }
  1753. }
  1754. return ok(request, { sessionId: childId })
  1755. },
  1756. async prompt(request) {
  1757. const { sessionId, mode, content } = request.payload
  1758. const found = await agentFor(sessionId)
  1759. if ('error' in found) return err(request, found.error)
  1760. const agent = found.agent
  1761. // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
  1762. const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
  1763. try {
  1764. const message: UserMessage = createUserMessage({ content, source })
  1765. if (mode === 'steer') agent.steer(message)
  1766. else agent.followup(message)
  1767. } catch (error: unknown) {
  1768. // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
  1769. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
  1770. }
  1771. return ok(request, { accepted: true as const })
  1772. },
  1773. updateQueue(request) {
  1774. const { sessionId, itemId, action } = request.payload
  1775. const agent = ctx.agents.get(sessionId)
  1776. if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
  1777. return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
  1778. }
  1779. if (agent === undefined) {
  1780. return Promise.resolve(err(request, {
  1781. code: 'queue-item-not-found',
  1782. message: 'queued item is no longer pending',
  1783. details: { itemId },
  1784. }))
  1785. }
  1786. const result = agent.updateInbox(itemId, action)
  1787. if (result === 'not-found') {
  1788. return Promise.resolve(err(request, {
  1789. code: 'queue-item-not-found',
  1790. message: 'queued item is no longer pending',
  1791. details: { itemId },
  1792. }))
  1793. }
  1794. if (result === 'steer-unavailable') {
  1795. return Promise.resolve(err(request, {
  1796. code: 'steer-unavailable',
  1797. message: 'current turn no longer accepts steering',
  1798. details: { itemId },
  1799. }))
  1800. }
  1801. return Promise.resolve(ok(request, { accepted: true as const }))
  1802. },
  1803. cancel(request) {
  1804. const { sessionId } = request.payload
  1805. const agent = ctx.agents.get(sessionId)
  1806. if (agent === undefined) {
  1807. return Promise.resolve(err(request, {
  1808. code: 'session-not-found',
  1809. message: `session "${sessionId}" not found (not attached)`,
  1810. details: { sessionId },
  1811. }))
  1812. }
  1813. if (hasSubagentOwner(agent.session, agent)) {
  1814. return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
  1815. }
  1816. agent.cancel({ kind: 'user' }, { keepInbox: true })
  1817. return Promise.resolve(ok(request, { accepted: true as const }))
  1818. },
  1819. },
  1820. subagents: {
  1821. async list(request, signal) {
  1822. try {
  1823. const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
  1824. return ok(request, {
  1825. entries: entries.map(entry => entry.kind === 'child'
  1826. ? {
  1827. ...entry,
  1828. activity: ctx.agents.get(entry.id)?.status === 'running' ? 'running' : 'inactive',
  1829. }
  1830. : entry),
  1831. parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
  1832. })
  1833. } catch (error: unknown) {
  1834. if (signal?.aborted
  1835. || (error instanceof SubagentError && error.code === 'CANCELLED')
  1836. || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
  1837. return err(request, {
  1838. code: 'cancelled',
  1839. message: 'subagent catalog read was cancelled',
  1840. details: {},
  1841. })
  1842. }
  1843. return err(request, {
  1844. code: 'internal',
  1845. message: 'subagent catalog read failed',
  1846. details: {},
  1847. })
  1848. }
  1849. },
  1850. async history(request, signal) {
  1851. const {
  1852. parentSessionId, childSessionId, mode, beforeSeq, maxMessages,
  1853. } = request.payload
  1854. const verified = await catalogChild(ctx, {
  1855. parentSessionId, childSessionId, mode,
  1856. }, signal)
  1857. if (verified.error !== undefined) return err(request, verified.error)
  1858. try {
  1859. const snapshot = await ctx.sessionQuery.readSession(childSessionId)
  1860. signal?.throwIfAborted()
  1861. if (snapshot.session.parentSession !== parentSessionId) {
  1862. return err(request, {
  1863. code: 'subagent-unauthorized',
  1864. message: 'subagent parent changed during history read',
  1865. details: { childSessionId },
  1866. })
  1867. }
  1868. const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
  1869. const projections = beforeSeq === undefined
  1870. ? detachedProjectionsFor(ctx, snapshot.events)
  1871. : undefined
  1872. return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
  1873. } catch (error: unknown) {
  1874. if (signal?.aborted
  1875. || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
  1876. return err(request, {
  1877. code: 'cancelled',
  1878. message: 'subagent history read was cancelled',
  1879. details: {},
  1880. })
  1881. }
  1882. if (error instanceof SessionQueryError
  1883. && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
  1884. return err(request, {
  1885. code: 'subagent-not-found',
  1886. message: 'subagent disappeared during history read',
  1887. details: { parentSessionId, childSessionId },
  1888. })
  1889. }
  1890. return err(request, {
  1891. code: 'internal',
  1892. message: 'subagent history read failed',
  1893. details: {},
  1894. })
  1895. }
  1896. },
  1897. async prompt(request, signal) {
  1898. const { parentSessionId, childSessionId, content } = request.payload
  1899. const parent = ctx.agents.get(parentSessionId)
  1900. if (parent === undefined) {
  1901. return err(request, {
  1902. code: 'subagent-parent-unavailable',
  1903. message: `parent session "${parentSessionId}" is not live`,
  1904. details: { parentSessionId },
  1905. })
  1906. }
  1907. const verified = await catalogChild(ctx, {
  1908. parentSessionId, childSessionId, mode: 'continuable',
  1909. }, signal)
  1910. if (verified.error !== undefined) return err(request, verified.error)
  1911. try {
  1912. const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
  1913. source: { kind: 'user', rpcId: request.rpcId },
  1914. signal,
  1915. })
  1916. return ok(request, { messageId })
  1917. } catch (error: unknown) {
  1918. return subagentPromptError(request, error, signal)
  1919. }
  1920. },
  1921. },
  1922. workspace: {
  1923. list(request) {
  1924. return Promise.resolve(ok(request, {
  1925. items: ctx.workspace.list().map(workspaceView),
  1926. archivedSessionIds: [...ctx.workspace.archivedSessionIds],
  1927. }))
  1928. },
  1929. // Exactly one of path/name arrives (schema refine). Existing-folder
  1930. // adoption reuses its canonical path; create-by-name rejects a name
  1931. // already present in the registry.
  1932. // TODO: the create-by-name branch lost its last product consumer when
  1933. // the Web picker collapsed onto the directory flow
  1934. // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md).
  1935. // Delete it with the wire schema's `name` member, this
  1936. // `defaults.workspaceRoot`, the client seam that carried the name
  1937. // (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm,
  1938. // `intentName`'s name branch, the manager's "name under workspaceRoot"
  1939. // contract), and the `dsh web --workspace-root` flag plus its apps/cli
  1940. // README lines, which exist only to feed it.
  1941. async create(request) {
  1942. const { payload } = request
  1943. let path: string
  1944. if (payload.name !== undefined) {
  1945. const name = payload.name.trim()
  1946. if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
  1947. return err(request, {
  1948. code: 'workspace-invalid-path',
  1949. message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
  1950. details: { path: payload.name },
  1951. })
  1952. }
  1953. path = join(defaults.workspaceRoot, name)
  1954. } else {
  1955. path = payload.path as string
  1956. }
  1957. try {
  1958. const name = payload.name?.trim()
  1959. const { workspace, created } = await ensureWorkspace(
  1960. path,
  1961. name,
  1962. name !== undefined,
  1963. name !== undefined,
  1964. )
  1965. return ok(request, { workspace: workspaceView(workspace), created })
  1966. } catch (error: unknown) {
  1967. if (error instanceof WorkspaceNameConflictError) {
  1968. return err(request, {
  1969. code: 'workspace-name-conflict',
  1970. message: error.message,
  1971. details: { name: error.workspaceName },
  1972. })
  1973. }
  1974. if (error instanceof WorkspaceDirectoryCreationError) {
  1975. return err(request, { code: 'internal', message: error.message, details: {} })
  1976. }
  1977. // The registry rejects a path that does not resolve to an existing
  1978. // directory (realpath ENOENT / not-a-directory) — the business
  1979. // error of the typed-path flow, surfaced as a validation failure.
  1980. return err(request, {
  1981. code: 'workspace-invalid-path',
  1982. message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
  1983. details: { path },
  1984. })
  1985. }
  1986. },
  1987. async rename(request) {
  1988. const { payload } = request
  1989. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  1990. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  1991. const title = payload.title.trim()
  1992. // Uniqueness AND the same-title no-op both ride the create chain so
  1993. // they observe the state left by earlier queued renames — checked
  1994. // up front, a queued A→A could report success while an earlier A→B
  1995. // still lands afterwards.
  1996. const operation = workspaceCreationChain.then(async () => {
  1997. if (title === workspace.title) return
  1998. if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
  1999. throw new WorkspaceNameConflictError(title)
  2000. }
  2001. await workspace.setTitle(title)
  2002. })
  2003. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  2004. try {
  2005. await operation
  2006. } catch (error: unknown) {
  2007. if (error instanceof WorkspaceNameConflictError) {
  2008. return err(request, {
  2009. code: 'workspace-name-conflict',
  2010. message: error.message,
  2011. details: { name: error.workspaceName },
  2012. })
  2013. }
  2014. throw error
  2015. }
  2016. return ok(request, { workspace: workspaceView(workspace) })
  2017. },
  2018. async delete(request) {
  2019. const { workspaceId } = request.payload
  2020. const operation = workspaceCreationChain.then(() =>
  2021. ctx.workspace.delete(brandWorkspaceId(workspaceId)))
  2022. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  2023. if (!await operation) return workspaceNotFound(request, workspaceId)
  2024. return ok(request, { deleted: true as const })
  2025. },
  2026. async insertSessionBefore(request) {
  2027. const { payload } = request
  2028. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  2029. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  2030. try {
  2031. await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
  2032. } catch (error: unknown) {
  2033. // Only the entity's unaccounted-id rejection is the business code;
  2034. // storage/durability failures propagate as internal errors.
  2035. if (!(error instanceof WorkspaceMoveInvalidError)) throw error
  2036. return err(request, {
  2037. code: 'workspace-move-invalid',
  2038. message: error.message,
  2039. details: {
  2040. workspaceId: payload.workspaceId,
  2041. sessionId: payload.sessionId,
  2042. ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
  2043. },
  2044. })
  2045. }
  2046. return ok(request, { workspace: workspaceView(workspace) })
  2047. },
  2048. async archiveSession(request) {
  2049. const { sessionId } = request.payload
  2050. try {
  2051. await ctx.workspace.archiveSession(sessionId)
  2052. } catch (error: unknown) {
  2053. // Only the registry's unknown-session rejection is the business
  2054. // code; storage/durability failures propagate as internal errors.
  2055. if (!(error instanceof WorkspaceUnknownSessionError)) throw error
  2056. return err(request, {
  2057. code: 'session-not-found',
  2058. message: error.message,
  2059. details: { sessionId },
  2060. })
  2061. }
  2062. return ok(request, { archivedSessionIds: [...ctx.workspace.archivedSessionIds] })
  2063. },
  2064. },
  2065. host: {
  2066. describe(request) {
  2067. // TODO(step2): version should read apps/cli's package.json; placeholder for now.
  2068. return Promise.resolve(ok(request, {
  2069. version: '0.0.1',
  2070. // Same source as session.create's fallback: the UI's default project
  2071. // must match where an unspecified-cwd session actually lands.
  2072. cwd: defaults.cwd,
  2073. provider: defaults.provider,
  2074. model: defaults.model,
  2075. attachedSessions: ctx.agents.list().length,
  2076. }))
  2077. },
  2078. async pickDirectory(request, signal) {
  2079. const capability = ctx.directoryPicker.capability()
  2080. if (capability.kind !== 'native') {
  2081. return err(request, {
  2082. code: 'directory-picker-unavailable',
  2083. message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`,
  2084. details: { capability: capability.kind },
  2085. })
  2086. }
  2087. try {
  2088. const path = await capability.pick(signal)
  2089. return ok(request, { path })
  2090. } catch (error: unknown) {
  2091. if (signal.aborted) {
  2092. return err(request, {
  2093. code: 'cancelled',
  2094. message: 'directory picker was aborted',
  2095. details: {},
  2096. })
  2097. }
  2098. return err(request, {
  2099. code: 'internal',
  2100. message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
  2101. details: {},
  2102. })
  2103. }
  2104. },
  2105. async listDirectory(request, signal) {
  2106. const capability = ctx.directoryPicker.capability()
  2107. if (capability.kind !== 'browse') {
  2108. return err(request, {
  2109. code: 'directory-picker-unavailable',
  2110. message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
  2111. details: { capability: capability.kind },
  2112. })
  2113. }
  2114. try {
  2115. // The carrier's signal follows the caller: a disconnect or timeout
  2116. // stops the backend's directory scan instead of outliving it.
  2117. return ok(request, await capability.list(request.payload.path, signal))
  2118. } catch (error: unknown) {
  2119. // An abort is the caller's own timeout/disconnect, not a server
  2120. // failure — same code pickDirectory and command.execute report.
  2121. if (signal.aborted) {
  2122. return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} })
  2123. }
  2124. return err(request, directoryError(error))
  2125. }
  2126. },
  2127. async createDirectory(request) {
  2128. const capability = ctx.directoryPicker.capability()
  2129. if (capability.kind !== 'browse') {
  2130. return err(request, {
  2131. code: 'directory-picker-unavailable',
  2132. message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
  2133. details: { capability: capability.kind },
  2134. })
  2135. }
  2136. try {
  2137. return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) })
  2138. } catch (error: unknown) {
  2139. return err(request, directoryError(error))
  2140. }
  2141. },
  2142. async openPath(request, signal) {
  2143. try {
  2144. const open = defaults.openPath
  2145. ?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal))
  2146. await open(request.payload.path, signal)
  2147. return ok(request, { opened: true as const })
  2148. } catch (error: unknown) {
  2149. if (signal.aborted) {
  2150. return err(request, {
  2151. code: 'cancelled',
  2152. message: 'path open was aborted',
  2153. details: {},
  2154. })
  2155. }
  2156. return err(request, {
  2157. code: 'internal',
  2158. message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
  2159. details: {},
  2160. })
  2161. }
  2162. },
  2163. },
  2164. commands: {
  2165. // Both methods address one session's agent (agentFor keeps its
  2166. // resume-on-miss: clients only send a sessionId for a published
  2167. // session, and resume restores an existing entity).
  2168. async list(request) {
  2169. // Missing service = the deployment omitted dsh-commands from its
  2170. // composition, not an empty catalog: fail loud instead of serving [].
  2171. const commands = ctx.get('commands')
  2172. if (commands === undefined) {
  2173. 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: {} })
  2174. }
  2175. const found = await agentFor(request.payload.sessionId)
  2176. if ('error' in found) return err(request, found.error)
  2177. return ok(request, { commands: commands.list(found.agent) })
  2178. },
  2179. async execute(request, signal) {
  2180. const commands = ctx.get('commands')
  2181. if (commands === undefined) {
  2182. 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: {} })
  2183. }
  2184. const { sessionId, line } = request.payload
  2185. const found = await agentFor(sessionId)
  2186. if ('error' in found) return err(request, found.error)
  2187. try {
  2188. // Pure admission: the executor's durable command/run + command/done
  2189. // pair (broadcast on the mux stream) carries the outcome; the
  2190. // response reports whether the line resolved to a handler, plus the
  2191. // minted pairing id so the issuing client can correlate its request
  2192. // with the flow node the lifecycle events produce.
  2193. const execution = await commands.execute(found.agent, line, signal)
  2194. return ok(request, execution === undefined
  2195. ? { matched: false }
  2196. : { matched: true, commandId: execution.commandId })
  2197. } catch (error: unknown) {
  2198. if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
  2199. return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
  2200. }
  2201. },
  2202. },
  2203. goals: {
  2204. // Mutations only — the read side is the 'goal' session projection.
  2205. // Every verb resolves the session's agent (agentFor: implicit cold
  2206. // resume, the command.* precedent) and acknowledges with the new CAS
  2207. // ref; the committed goal/change event carries the whole value to every
  2208. // client through the projection frames.
  2209. async create(request) {
  2210. const { objective, maxGoalRounds } = request.payload
  2211. return mutateGoal(request, (goals, agent) => goals.create(agent, {
  2212. objective,
  2213. ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
  2214. }))
  2215. },
  2216. async edit(request) {
  2217. const { ref, objective, maxGoalRounds } = request.payload
  2218. return mutateGoal(request, (goals, agent) => goals.edit(agent, ref, {
  2219. ...(objective !== undefined ? { objective } : {}),
  2220. ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
  2221. }))
  2222. },
  2223. async pause(request) {
  2224. return mutateGoal(request, (goals, agent) => goals.pause(agent, request.payload.ref))
  2225. },
  2226. async resume(request) {
  2227. return mutateGoal(request, (goals, agent) => goals.resume(agent, request.payload.ref))
  2228. },
  2229. async complete(request) {
  2230. return mutateGoal(request, (goals, agent) => goals.complete(agent, request.payload.ref))
  2231. },
  2232. async clear(request) {
  2233. const goals = goalService()
  2234. if ('error' in goals) return err(request, goals.error)
  2235. const found = await agentFor(request.payload.sessionId)
  2236. if ('error' in found) return err(request, found.error)
  2237. try {
  2238. goals.clear(found.agent, request.payload.ref)
  2239. return ok(request, { cleared: true as const })
  2240. } catch (error: unknown) {
  2241. return goalError(request, error)
  2242. }
  2243. },
  2244. },
  2245. skills: {
  2246. // Skill lookup never touches the Agent registry: the session address
  2247. // resolves to a canonical cwd from the host-resident session header, so
  2248. // listing skills cannot create or resume an agent as a side effect.
  2249. async list(request) {
  2250. const { sessionId } = request.payload
  2251. const session = ctx.sessions.get(sessionId)
  2252. if (session === undefined) {
  2253. return err(request, {
  2254. code: 'session-not-found',
  2255. message: `session "${sessionId}" not found (not attached)`,
  2256. details: { sessionId },
  2257. })
  2258. }
  2259. if (session.header.cwd === undefined) {
  2260. // Every served session records its project at create time; a
  2261. // cwd-less header is a pre-project legacy log (not served).
  2262. return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
  2263. }
  2264. const cwd = session.header.cwd
  2265. // Same stance as the commands domain: a missing service means the
  2266. // deployment omitted dsh-skill from its composition, not an empty
  2267. // catalog. ctx.get also keeps this handler independent of the gateway
  2268. // plugin's inject list (an undeclared `ctx.skills` property read
  2269. // fails the reflect proxy).
  2270. const skillRegistry = ctx.get('skills')
  2271. if (skillRegistry === undefined) {
  2272. return err(request, { code: 'internal', message: 'skill registry is absent: this deployment does not mount @deepseek-ai/dsh-skill in its composition (cordis.yml or explicit assembly)', details: {} })
  2273. }
  2274. try {
  2275. const skills = (await skillRegistry.list({ cwd }))
  2276. .filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
  2277. return ok(request, {
  2278. skills: skills.map(skill => ({
  2279. name: skill.name,
  2280. description: skill.description,
  2281. ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
  2282. })),
  2283. })
  2284. } catch (error: unknown) {
  2285. return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
  2286. }
  2287. },
  2288. },
  2289. settings: {
  2290. describe(request) {
  2291. const settings = ctx.get('settings')
  2292. if (settings === undefined) return Promise.resolve(err(request, settingsAbsent()))
  2293. const exposed = exposedNamespaces()
  2294. return Promise.resolve(ok(request, {
  2295. writable: settings.writable,
  2296. namespaces: settings.describe({ redactSecrets: true })
  2297. .filter(descriptor => exposed.has(String(descriptor.ns)))
  2298. .map(namespaceView),
  2299. }))
  2300. },
  2301. update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision),
  2302. replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision),
  2303. mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision),
  2304. },
  2305. credentials: {
  2306. async describe(request) {
  2307. const credentials = ctx.get('credentials')
  2308. if (credentials === undefined) return err(request, credentialsAbsent())
  2309. const entries = await Promise.all(request.payload.refs.map(async (ref) => {
  2310. const info = await credentials.describe(credentialRef(ref))
  2311. const view: CredentialView = {
  2312. configured: info.configured,
  2313. ...info.source === undefined ? {} : { source: info.source },
  2314. writable: info.writable,
  2315. }
  2316. return [ref, view] as const
  2317. }))
  2318. return ok(request, { credentials: Object.fromEntries(entries) })
  2319. },
  2320. async set(request) {
  2321. const credentials = ctx.get('credentials')
  2322. if (credentials === undefined) return err(request, credentialsAbsent())
  2323. const { ref, value } = request.payload
  2324. try {
  2325. await credentials.set(credentialRef(ref), value)
  2326. } catch (error: unknown) {
  2327. return err(request, {
  2328. code: 'credential-rejected',
  2329. message: error instanceof Error ? error.message : String(error),
  2330. details: { ref },
  2331. })
  2332. }
  2333. return ok(request, {})
  2334. },
  2335. async unset(request) {
  2336. const credentials = ctx.get('credentials')
  2337. if (credentials === undefined) return err(request, credentialsAbsent())
  2338. const { ref } = request.payload
  2339. try {
  2340. await credentials.unset(credentialRef(ref))
  2341. } catch (error: unknown) {
  2342. return err(request, {
  2343. code: 'credential-rejected',
  2344. message: error instanceof Error ? error.message : String(error),
  2345. details: { ref },
  2346. })
  2347. }
  2348. return ok(request, {})
  2349. },
  2350. },
  2351. llm: {
  2352. providers(request) {
  2353. const registered = ctx.llm.listProviders()
  2354. const active = new Set(registered.map(provider => provider.id))
  2355. const directory = ctx.llm.listConfigurableProviders()
  2356. const declared = new Set(directory.map(entry => entry.provider))
  2357. const views = directory.map(entry => ({
  2358. provider: entry.provider,
  2359. displayName: entry.displayName,
  2360. settingsNs: entry.settingsNs,
  2361. settingsPath: [...entry.settingsPath],
  2362. active: active.has(entry.provider),
  2363. }))
  2364. // Routes registered without a directory declaration still appear —
  2365. // they exist and serve models — just with no settings address.
  2366. for (const provider of registered) {
  2367. if (declared.has(provider.id)) continue
  2368. views.push({
  2369. provider: provider.id,
  2370. displayName: provider.name,
  2371. settingsNs: '',
  2372. settingsPath: [],
  2373. active: true,
  2374. })
  2375. }
  2376. return Promise.resolve(ok(request, { providers: views }))
  2377. },
  2378. async models(request) {
  2379. return ok(request, await buildModelCatalog(ctx))
  2380. },
  2381. },
  2382. events: {
  2383. mux(_request, signal) {
  2384. const queue = new FrameQueue<RpcRequest<MuxFrame>>()
  2385. muxQueues.add(queue)
  2386. for (const session of ctx.sessions.list()) {
  2387. subscribeSession(queue, session)
  2388. }
  2389. for (const pending of pendingQuestions.values()) {
  2390. queue.push({
  2391. rpcId: pending.rpcId,
  2392. payload: {
  2393. type: 'question/requested', sessionId: pending.sessionId,
  2394. questions: pending.questions,
  2395. },
  2396. })
  2397. }
  2398. // Refresh recovery: still-pending approval questions replay with their
  2399. // stable rpcId so a reconnecting client can still answer them.
  2400. for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending))
  2401. // Queue snapshot baseline (pendingQuestions precedent): frames replayed
  2402. // in arrival order per session; a reconnecting client rebuilds its
  2403. // queue view from these alone.
  2404. for (const [sessionId, items] of queuedMirror) {
  2405. queue.push(frame({
  2406. type: 'session/queue',
  2407. sessionId,
  2408. items: items.map(item => ({
  2409. id: item.id,
  2410. placement: item.placement,
  2411. message: item.message,
  2412. })),
  2413. }))
  2414. }
  2415. // Per-session open-call table for result-view pairing. Bounded by the
  2416. // per-turn call count: entries clear on turn/end; a table miss (stream
  2417. // opened mid-turn) backscans the session's in-memory events instead.
  2418. const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
  2419. const disposers = [
  2420. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  2421. if (event.type === 'tool/call') {
  2422. const data = event.data as ToolCallData
  2423. try {
  2424. let table = openCalls.get(session.id)
  2425. if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
  2426. table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
  2427. } catch {
  2428. // Unparseable model arguments: leave the table unset; the result view soft-falls.
  2429. }
  2430. } else if (event.type === 'turn/end') {
  2431. openCalls.delete(session.id)
  2432. }
  2433. const view = viewFor(ctx, event, callId =>
  2434. openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
  2435. queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
  2436. }),
  2437. ctx.on('session/created', (session: Session) => {
  2438. subscribeSession(queue, session)
  2439. }),
  2440. ctx.on('session/disposed', (session: Session) => {
  2441. openCalls.delete(session.id)
  2442. }),
  2443. ]
  2444. return queue.iterate(signal, () => {
  2445. muxQueues.delete(queue)
  2446. for (const dispose of disposers) dispose()
  2447. })
  2448. },
  2449. host(_request, signal) {
  2450. const queue = new FrameQueue<RpcRequest<HostFrame>>()
  2451. const committedWorkspaceIds = new Set(
  2452. ctx.workspace.list().map(workspace => String(workspace.id)),
  2453. )
  2454. // Frame-dedup baseline, same posture as committedWorkspaceIds: the
  2455. // stream opens against the current set; workspace.list re-baselines
  2456. // reconnecting clients, so only later changes need frames.
  2457. let archivedSessionIds = ctx.workspace.archivedSessionIds
  2458. const disposers = [
  2459. ctx.on('session/created', (session: Session) => {
  2460. queue.push(frame({
  2461. type: 'host/session-added',
  2462. sessionId: session.id,
  2463. // Derived at frame time like summarize(); a just-created session
  2464. // has run no turn yet, so this is constantly true in practice.
  2465. blank: sessionBlank(session),
  2466. // Including cwd lets the client group the new session without refreshing the list.
  2467. ...sessionListFields(session.header),
  2468. }))
  2469. }),
  2470. ctx.on('session/disposed', (session: Session) => {
  2471. queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
  2472. }),
  2473. ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
  2474. queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
  2475. }),
  2476. ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => {
  2477. queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) }))
  2478. }),
  2479. ctx.on('domain/changed', (change) => {
  2480. if (change.domain !== 'workspace') return
  2481. if (change.table === '') {
  2482. if (change.operation !== 'put') return
  2483. const state = workspaceDomainState.parse(change.value)
  2484. for (const workspaceId of state.workspaceIds) {
  2485. if (committedWorkspaceIds.has(workspaceId)) continue
  2486. const workspace = ctx.workspace.get(workspaceId)
  2487. if (workspace === undefined) {
  2488. throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
  2489. }
  2490. committedWorkspaceIds.add(workspaceId)
  2491. queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
  2492. }
  2493. if (state.archivedSessionIds.length !== archivedSessionIds.length
  2494. || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) {
  2495. archivedSessionIds = state.archivedSessionIds
  2496. queue.push(frame({
  2497. type: 'host/archived-sessions-changed',
  2498. archivedSessionIds: [...state.archivedSessionIds],
  2499. }))
  2500. }
  2501. return
  2502. }
  2503. if (change.table !== 'workspaces') return
  2504. if (change.operation === 'deleted') {
  2505. if (!committedWorkspaceIds.delete(change.key)) return
  2506. queue.push(frame({
  2507. type: 'host/workspace-removed',
  2508. workspaceId: change.key as WorkspaceId,
  2509. }))
  2510. return
  2511. }
  2512. if (!committedWorkspaceIds.has(change.key)) return
  2513. // Existing-entity table writes are complete attach/touch commits.
  2514. // A new entity's first put waits for the global registry write above.
  2515. queue.push(frame({
  2516. type: 'host/workspace-changed',
  2517. workspace: changedWorkspaceView(change.key, change.value),
  2518. }))
  2519. }),
  2520. ctx.on('commands/change', () => {
  2521. queue.push(frame({ type: 'host/commands-changed' }))
  2522. }),
  2523. ctx.on('settings/document-updated', (ns) => {
  2524. // The RAW-section event, not the resolved one: a field going from
  2525. // inherited to overridden leaves the resolved value equal, and a
  2526. // configuration client still has to re-read (its held revision is
  2527. // stale, and the field's meaning changed).
  2528. const name = String(ns)
  2529. queue.push(frame({ type: 'host/settings-changed', ns: name }))
  2530. // A provider's own settings carry its model catalog and endpoint,
  2531. // so a change there invalidates the model list even when the route
  2532. // set is untouched — `llm/adapters-updated` alone misses it.
  2533. if (modelProviderNamespaces().has(name)) queue.push(frame({ type: 'host/models-changed' }))
  2534. }),
  2535. ctx.on('credentials/updated', (ref) => {
  2536. queue.push(frame({ type: 'host/credentials-changed', ref: String(ref) }))
  2537. }),
  2538. ctx.on('llm/adapters-updated', () => {
  2539. queue.push(frame({ type: 'host/models-changed' }))
  2540. }),
  2541. ]
  2542. return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
  2543. },
  2544. },
  2545. respond(message: ClientResponse): Promise<RpcReceipt> {
  2546. // Route by the echoed rpcId (the wire correlation): approvals first,
  2547. // then questions — the two registries share one id space of UUIDs.
  2548. const approval = pendingApprovals.get(message.rpcId)
  2549. if (approval !== undefined) {
  2550. if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
  2551. const parsed = approvalResponsePayloadSchema.safeParse(message.result.value)
  2552. // The payload's audit correlation must match the entry the rpcId routed
  2553. // to — a mismatched answer is malformed, not merely late.
  2554. if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) {
  2555. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  2556. }
  2557. approval.resolve(parsed.data.outcome)
  2558. return Promise.resolve({ accepted: true })
  2559. }
  2560. const pending = pendingQuestions.get(message.rpcId)
  2561. if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
  2562. if (!message.result.ok) {
  2563. if (message.result.error.code !== 'cancelled') {
  2564. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  2565. }
  2566. claimQuestion(pending, 'cancelled')
  2567. pending.reject(new UserInteractionError(
  2568. 'the user cancelled ask_user_question', 'ASK_CANCELLED'))
  2569. return Promise.resolve({ accepted: true })
  2570. }
  2571. const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
  2572. if (!parsed.success) {
  2573. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  2574. }
  2575. const payload: QuestionResponsePayload = {
  2576. sessionId: parsed.data.sessionId,
  2577. answer: {
  2578. answers: parsed.data.answer.answers.map(answer => ({
  2579. id: answer.id,
  2580. selected: answer.selected,
  2581. ...(answer.custom === undefined ? {} : { custom: answer.custom }),
  2582. })),
  2583. },
  2584. }
  2585. if (!matchesQuestions(payload, pending)) {
  2586. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  2587. }
  2588. claimQuestion(pending, 'answered')
  2589. pending.resolve(payload.answer)
  2590. return Promise.resolve({ accepted: true })
  2591. },
  2592. }
  2593. }