api-proxy.ts 75 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738
  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 type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
  20. import {
  21. workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
  22. WorkspaceMoveInvalidError, WorkspaceNameConflictError,
  23. } from '@deepseek-ai/dsh-workspace'
  24. // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
  25. import type {} from '@deepseek-ai/dsh-tools'
  26. import type {
  27. ApiProxy, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
  28. MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSummary, ToolEventView,
  29. WorkspaceId, WorkspaceView,
  30. } from './api/index.ts'
  31. // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
  32. import type {} from '@deepseek-ai/dsh-session-projection'
  33. // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
  34. import type {} from '@deepseek-ai/dsh-session-projection-cache'
  35. // GoalError narrows domain rejections to their stable codes at the wire boundary.
  36. import { GoalError } from '@deepseek-ai/dsh-goal'
  37. import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
  38. // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
  39. import type {} from '@deepseek-ai/dsh-commands'
  40. import type {} from '@deepseek-ai/dsh-skill'
  41. // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
  42. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
  43. import type { CallId } from '@deepseek-ai/dsh-llm/brand'
  44. import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
  45. // Side-effect type import: resolves the `approval/request` waterfall and
  46. // `ctx.get('approval')` without a value dependency on the seam (optional composition).
  47. import type {} from '@deepseek-ai/dsh-user-approval'
  48. import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
  49. import { questionResponsePayloadSchema } from './api/questions.schema.ts'
  50. import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
  51. import { RpcId } from './api/rpc.ts'
  52. import type {
  53. AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
  54. } from '@deepseek-ai/dsh-user-interaction'
  55. import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
  56. import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
  57. import { openNativePath } from './native-path-opener.ts'
  58. /** Page size when history is called without maxMessages. */
  59. const DEFAULT_MAX_MESSAGES = 50
  60. /** Conversation message event types (the pagination counting unit). */
  61. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
  62. /**
  63. * Message-boundary pagination: count maxMessages append-origin messages
  64. * backwards from the window tail. Replacement copies never entered the
  65. * conversation a reader sees — they restate a shadowed range for the model
  66. * alone — so they consume no quota; the page stays one contiguous raw range,
  67. * which keeps a compaction's log-only provenance on the same page as its
  68. * replacement. The cut is the starting seq of the oldest message group (chunks
  69. * group via sourceEventSeqs — never cut mid-message). The tail page naturally
  70. * includes the in-progress partial.
  71. */
  72. function paginate(
  73. events: readonly SessionEvent[],
  74. beforeSeq: number | undefined,
  75. maxMessages: number,
  76. ): { events: SessionEvent[]; hasMore: boolean } {
  77. const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
  78. let count = 0
  79. let cut = 0
  80. for (let i = window.length - 1; i >= 0; i--) {
  81. const event = window[i] as SessionEvent
  82. if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
  83. count++
  84. const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
  85. const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
  86. if (count >= maxMessages) {
  87. cut = groupStart
  88. break
  89. }
  90. }
  91. const page = window.filter(event => event.seq >= cut)
  92. return { events: page, hasMore: cut > 0 }
  93. }
  94. /** Wrap an ok result echoing the request's rpcId. */
  95. function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
  96. return { rpcId: request.rpcId, result: { ok: true, value } }
  97. }
  98. /** Wrap an error result echoing the request's rpcId. */
  99. function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
  100. return { rpcId: request.rpcId, result: { ok: false, error } }
  101. }
  102. /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
  103. class FrameQueue<F> {
  104. private buffer: F[] = []
  105. private waiter: (() => void) | undefined
  106. private done = false
  107. push(item: F): void {
  108. if (this.done) return
  109. this.buffer.push(item)
  110. this.waiter?.()
  111. }
  112. end(): void {
  113. this.done = true
  114. this.waiter?.()
  115. }
  116. async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
  117. const onAbort = (): void => { this.end() }
  118. signal.addEventListener('abort', onAbort, { once: true })
  119. try {
  120. while (true) {
  121. while (this.buffer.length > 0) yield this.buffer.shift() as F
  122. if (this.done || signal.aborted) return
  123. await new Promise<void>((resolve) => { this.waiter = resolve })
  124. this.waiter = undefined
  125. }
  126. } finally {
  127. signal.removeEventListener('abort', onAbort)
  128. cleanup()
  129. }
  130. }
  131. }
  132. /**
  133. * Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable
  134. * frames — approval/question requested — mint their stable id in their
  135. * pending registries instead).
  136. */
  137. function frame<F>(payload: F): RpcRequest<F> {
  138. return { rpcId: RpcId(randomUUID()), payload }
  139. }
  140. /** Queue the subscription baseline frame. */
  141. function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
  142. queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
  143. }
  144. /**
  145. * Whether the session's conversation has started: no turn has run yet (a
  146. * turn is one model-loop execution). Standalone plugin events — command
  147. * lifecycle records, plan/mode, titles, goals — never open a turn, so
  148. * running `/plan` or `/goal` on a fresh session keeps it blank
  149. * (list-hidden, reusable).
  150. */
  151. function sessionBlank(session: Session): boolean {
  152. return !session.events.some(event => event.type === 'turn/start')
  153. }
  154. /** SessionSummary projection for attached (in-memory) sessions. */
  155. function summarize(session: Session, running: boolean): SessionSummary {
  156. return {
  157. sessionId: session.id,
  158. // Excludes end-seed: a resumed-but-untouched session
  159. // must not sort as freshly worked in.
  160. updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
  161. running,
  162. blank: sessionBlank(session),
  163. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  164. ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
  165. }
  166. }
  167. /**
  168. * SessionSummary projection for cold (persisted, unattached) sessions.
  169. * updatedAt is the log file's mtime; backends without a per-session file
  170. * (locate() undefined) fall back to the header's createdAt.
  171. */
  172. async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
  173. let updatedAt = meta.createdAt
  174. const location = persistence.locate(meta)
  175. if (location !== undefined) {
  176. try {
  177. updatedAt = (await stat(location.path)).mtimeMs
  178. } catch {
  179. // The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
  180. }
  181. }
  182. return {
  183. sessionId: meta.id,
  184. updatedAt,
  185. running: false,
  186. // Lazy persistence keeps never-appended sessions out of list(); reading
  187. // a cold log to check for turns would defeat the index read, so a listed
  188. // cold session is served as not-blank (its log holds its conversation).
  189. blank: false,
  190. ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
  191. /* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
  192. filters those out (legacy logs are not served); the conditional mirrors
  193. summarize() shape. */
  194. ...meta.cwd === undefined ? {} : { cwd: meta.cwd },
  195. }
  196. }
  197. /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
  198. function directoryError(error: unknown): RpcError {
  199. if (error instanceof DirectoryPickerError) {
  200. return { code: error.code, message: error.message, details: { path: error.path } }
  201. }
  202. return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }
  203. }
  204. /** Resolved Host routing and project-directory defaults consumed by the API implementation. */
  205. export interface ApiProxyDefaults {
  206. provider: string
  207. model: string
  208. /** Default project directory for new sessions whose create request carries no cwd. */
  209. cwd: string
  210. /** Parent directory for name-created workspaces. */
  211. workspaceRoot: string
  212. /** Native open-with-default-application; injectable for carrier tests. */
  213. openPath?: (path: string, signal: AbortSignal) => Promise<void>
  214. }
  215. /** The tool/call payload fields the presenter path reads. */
  216. interface ToolCallData { callId: string; name: string; arguments: string }
  217. /**
  218. * One outstanding approval question: the stable server-request id, the frame
  219. * material replayed to late mux subscribers, and the resolver that settles the
  220. * answerer's promise back into `ctx.approval`.
  221. */
  222. interface PendingApproval {
  223. rpcId: RpcId
  224. sessionId: SessionId
  225. approvalId: ApprovalRequestId
  226. toolName: string
  227. callId?: CallId
  228. reason?: string
  229. resolve(outcome: ApprovalOutcome): void
  230. }
  231. /** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */
  232. function requestedFrame(pending: PendingApproval): RpcRequest<MuxFrame> {
  233. return {
  234. rpcId: pending.rpcId,
  235. payload: {
  236. type: 'approval/requested',
  237. sessionId: pending.sessionId,
  238. approvalId: pending.approvalId,
  239. toolName: pending.toolName,
  240. ...pending.callId === undefined ? {} : { callId: pending.callId },
  241. ...pending.reason === undefined ? {} : { reason: pending.reason },
  242. },
  243. }
  244. }
  245. /** One host-owned question wait, addressed by the stable server-request id. */
  246. interface PendingQuestion {
  247. rpcId: RpcId
  248. sessionId: SessionId
  249. questions: AskUserQuestionItem[]
  250. resolve: (answer: AskUserQuestionAnswer) => void
  251. reject: (error: UserInteractionError) => void
  252. signal?: AbortSignal
  253. onAbort?: () => void
  254. }
  255. /** Validate one answer batch against the exact question request it resolves. */
  256. function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
  257. if (payload.sessionId !== pending.sessionId) return false
  258. const answers = payload.answer.answers
  259. if (answers.length !== pending.questions.length) return false
  260. return answers.every((answer, index) => {
  261. const question = pending.questions[index] as AskUserQuestionItem
  262. if (answer.id !== question.id) return false
  263. if (new Set(answer.selected).size !== answer.selected.length) return false
  264. const custom = answer.custom?.trim()
  265. if (custom !== undefined && custom === '') return false
  266. if (custom !== undefined && answer.selected.length > 0) return false
  267. if (question.multiSelect !== true && answer.selected.length > 1) return false
  268. const labels = new Set(question.options?.map(option => option.label) ?? [])
  269. return answer.selected.every(label => labels.has(label))
  270. })
  271. }
  272. /**
  273. * Compute the render intent for a tool/call or tool/result event through the
  274. * presenters registered at this moment; every other event type gets none. A
  275. * result's presenter needs its call's parsed args — `argsFor` supplies them
  276. * (live: the per-session call table; history: an in-page backscan), returning
  277. * undefined when the pairing is unavailable (e.g. the call fell off the page),
  278. * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
  279. * the client's documented default (generic JSON card) covers every miss.
  280. */
  281. function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
  282. try {
  283. if (event.type === 'tool/call') {
  284. const { name, arguments: raw } = event.data as ToolCallData
  285. const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
  286. return view === undefined ? undefined : { for: 'call', view }
  287. }
  288. if (event.type === 'tool/result') {
  289. const { message, meta } = event.data
  290. const [result] = message.content
  291. const callId = message.source.callId
  292. const call = argsFor(callId) as { name: string; args: unknown } | undefined
  293. if (call === undefined) return undefined
  294. const view = ctx.tools.get(call.name)?.presentResult?.(call.args, {
  295. content: result.content,
  296. isError: result.isError === true,
  297. ...meta === undefined ? {} : { meta },
  298. })
  299. return view === undefined ? undefined : { for: 'result', view }
  300. }
  301. } catch (error: unknown) {
  302. // A throwing presenter (or unparseable arguments) must not break delivery;
  303. // the event still ships, just without a view.
  304. console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
  305. }
  306. return undefined
  307. }
  308. /**
  309. * Resolve a tool/result's call pairing by scanning a window of events backwards
  310. * for the matching tool/call. Used by the history path (the page is the
  311. * window — a cross-page pairing soft-falls to no view) and by live-path table
  312. * misses after a reconnect-eviction.
  313. */
  314. function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
  315. for (let i = events.length - 1; i >= 0; i--) {
  316. const event = events[i] as SessionEvent
  317. if (event.type !== 'tool/call') continue
  318. const data = event.data as ToolCallData
  319. if (data.callId !== callId) continue
  320. try {
  321. return { name: data.name, args: JSON.parse(data.arguments) }
  322. } catch {
  323. // Unparseable stored arguments: same soft-fall as a live parse failure.
  324. return undefined
  325. }
  326. }
  327. return undefined
  328. }
  329. /**
  330. * The projection baseline for one history tail page: the registry's
  331. * watermark-cache snapshot — one fully synchronous read (no await between the
  332. * page slice and this), so all values and `asOfSeq` form a single consistent
  333. * cut and `asOfSeq` equals the window tail event seq. The carrier holds zero
  334. * domain knowledge (each value passed its unit's own schema inside the
  335. * registry). An absent registry means the deployment has no projection seam:
  336. * the whole block is absent and clients treat every key as capability-absent.
  337. */
  338. function projectionsFor(ctx: Context, agent: Agent): SessionProjectionsBlock | undefined {
  339. const registry = ctx.get('sessionProjections')
  340. if (registry === undefined) return undefined
  341. return registry.snapshot(agent.session)
  342. }
  343. /**
  344. * The projection baseline of one session.list row, fail-soft: attached
  345. * sessions cut the registry's live watermark cache; cold sessions view the
  346. * persisted projection cache's identity-checked stored rows (zero log loads
  347. * either way — the listing use case the cache exists for). The block shape
  348. * (values + asOfSeq) matches the history tail's, so a client seeds its
  349. * value store under the same higher-seq-wins rule. Any failure — and an
  350. * empty value set — yields an absent block: a listing without projections
  351. * is degraded, never broken.
  352. */
  353. function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
  354. try {
  355. const block = session !== undefined
  356. ? ctx.get('sessionProjections')?.snapshot(session)
  357. : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
  358. return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
  359. } catch (error) {
  360. ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
  361. return undefined
  362. }
  363. }
  364. /**
  365. * Thrown by the cold-resume path when the id names no servable session
  366. * (absent from the store, or a pre-project legacy log without a cwd).
  367. */
  368. class SessionNotFound extends Error {}
  369. /** Requested identity already belongs to a session with another project cwd. */
  370. class SessionCwdConflict extends Error {
  371. constructor(
  372. readonly sessionId: SessionId,
  373. readonly requestedCwd: string,
  374. readonly existingCwd: string | undefined,
  375. ) {
  376. super(
  377. `session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
  378. + `requested ${JSON.stringify(requestedCwd)}`,
  379. )
  380. }
  381. }
  382. /** Host failed before the registry could adopt a name-created directory. */
  383. class WorkspaceDirectoryCreationError extends Error {}
  384. /** Shared workspace-not-found error response of the workspace.* mutation rows. */
  385. function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
  386. return err(request, {
  387. code: 'workspace-not-found',
  388. message: `workspace "${workspaceId}" not found`,
  389. details: { workspaceId },
  390. })
  391. }
  392. /** Wire projection of one workspace entity (the workspace.* value row). */
  393. function workspaceView(workspace: Workspace): WorkspaceView {
  394. return {
  395. workspaceId: workspace.id,
  396. path: workspace.path,
  397. title: workspace.title,
  398. sessionIds: [...workspace.sessionIds],
  399. createdAt: workspace.createdAt,
  400. updatedAt: workspace.updatedAt,
  401. }
  402. }
  403. /** Wire projection of the durable record carried by `domain/changed`. */
  404. function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
  405. const record: WorkspaceRecord = workspaceRecord.parse(value)
  406. return {
  407. workspaceId: workspaceId as WorkspaceId,
  408. path: record.path,
  409. title: record.title,
  410. sessionIds: [...record.sessionIds],
  411. createdAt: record.createdAt,
  412. updatedAt: record.updatedAt,
  413. }
  414. }
  415. /**
  416. * Implement ApiProxy over a composed host context.
  417. * @param ctx - a context with the Host spine and Workspace registry mounted.
  418. * @param defaults - host routing and project-directory defaults.
  419. * @returns the ApiProxy implementation.
  420. */
  421. export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
  422. const agentOptions = { provider: defaults.provider, model: defaults.model }
  423. type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
  424. const targets = new WeakMap<Agent, WebLlmTargetRef>()
  425. /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
  426. const resumes = new Map<SessionId, Promise<Agent>>()
  427. /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
  428. const sessionCreations = new Map<SessionId, Promise<Agent>>()
  429. /** Serializes path ownership checks with record creation across spellings. */
  430. let workspaceCreationChain = Promise.resolve()
  431. const pendingQuestions = new Map<RpcId, PendingQuestion>()
  432. const pendingApprovals = new Map<RpcId, PendingApproval>()
  433. const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
  434. /**
  435. * Install or return the session-local target that prompt assembly snapshots.
  436. * Seed order: latest logged request/header, else the host default routing.
  437. * There is no create-time per-session override tier on this wire — if one
  438. * returns (a create-options contribution), it must fold in between the two.
  439. */
  440. function targetFor(agent: Agent): WebLlmTargetRef {
  441. const installed = targets.get(agent)
  442. if (installed !== undefined) return installed
  443. const logged = agent.session.requestHeader()?.config
  444. const target: WebLlmTargetRef = {
  445. current: logged === undefined
  446. ? { provider: defaults.provider, model: defaults.model }
  447. : {
  448. provider: logged.provider,
  449. model: logged.model,
  450. ...logged.reasoningEffort === undefined
  451. ? {}
  452. : { reasoningEffort: logged.reasoningEffort },
  453. },
  454. assembled: undefined,
  455. }
  456. installAgentLlmTarget(agent.ctx, target)
  457. targets.set(agent, target)
  458. return target
  459. }
  460. /** Pre-publication setup used by both fresh and resumed Web agents. */
  461. function installTarget(agentCtx: Context): void {
  462. const agent = agentCtx.agent
  463. if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
  464. targetFor(agent)
  465. }
  466. /** Send one transient frame to every connected mux consumer. */
  467. function broadcast(payload: MuxFrame): void {
  468. const envelope = frame(payload)
  469. for (const queue of muxQueues) queue.push(envelope)
  470. }
  471. // Projection change feed → session/projection push frames. The carrier
  472. // mints the wire frame (the seam package holds no wire vocabulary); the
  473. // child activates only when a projection registry is composed, and the
  474. // subscription unwinds with this gateway's fiber.
  475. ctx.inject(['sessionProjections'], (projectionCtx) => {
  476. projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
  477. broadcast({ type: 'session/projection', sessionId: session.id, key, value, seq })
  478. })
  479. })
  480. /**
  481. * Per-session queued-occurrence mirror serving the mux-open queue snapshot
  482. * (the same refresh-recovery baseline as pending questions). Each terminal
  483. * queue event retires one matching occurrence, so repeated sends of the same
  484. * identified message remain visible until every occurrence is claimed.
  485. */
  486. const queuedMirror = new Map<SessionId, InboxItem[]>()
  487. type UnseenQueueEvent =
  488. | { readonly kind: 'update'; readonly item: InboxItem }
  489. | { readonly kind: 'terminal' }
  490. const unseenQueueEvents = new Map<SessionId, Map<InboxItemId, UnseenQueueEvent>>()
  491. const rememberUnseen = (sessionId: SessionId, itemId: InboxItemId, event: UnseenQueueEvent): void => {
  492. let events = unseenQueueEvents.get(sessionId)
  493. if (events === undefined) {
  494. events = new Map()
  495. unseenQueueEvents.set(sessionId, events)
  496. }
  497. events.set(itemId, event)
  498. // Only synchronous re-entrancy may deliver a mutation before its outer
  499. // enqueue observer. Drop unmatched protocol-invalid observations instead
  500. // of retaining process-local ids indefinitely.
  501. queueMicrotask(() => {
  502. const current = unseenQueueEvents.get(sessionId)
  503. if (current?.get(itemId) !== event) return
  504. current.delete(itemId)
  505. if (current.size === 0) unseenQueueEvents.delete(sessionId)
  506. })
  507. }
  508. const takeUnseen = (sessionId: SessionId, itemId: InboxItemId): UnseenQueueEvent | undefined => {
  509. const events = unseenQueueEvents.get(sessionId)
  510. const event = events?.get(itemId)
  511. if (event === undefined) return undefined
  512. events?.delete(itemId)
  513. if (events?.size === 0) unseenQueueEvents.delete(sessionId)
  514. return event
  515. }
  516. const publishQueue = (sessionId: SessionId): void => {
  517. const items = queuedMirror.get(sessionId) ?? []
  518. broadcast({
  519. type: 'session/queue',
  520. sessionId,
  521. items: items.map(item => ({
  522. id: item.id,
  523. message: item.message,
  524. })),
  525. })
  526. }
  527. ctx.effect(() => {
  528. const retire = (agent: Agent, item: InboxItem): boolean => {
  529. const entries = queuedMirror.get(agent.id)
  530. if (entries === undefined) {
  531. rememberUnseen(agent.id, item.id, { kind: 'terminal' })
  532. return false
  533. }
  534. const index = entries.findIndex(entry => entry.id === item.id)
  535. if (index === -1) {
  536. rememberUnseen(agent.id, item.id, { kind: 'terminal' })
  537. return false
  538. }
  539. entries.splice(index, 1)
  540. if (entries.length === 0) queuedMirror.delete(agent.id)
  541. return true
  542. }
  543. const disposers = [
  544. ctx.on('agent/inbox/enqueue', (agent: Agent, item: InboxItem) => {
  545. if (item.placement !== 'queued') return
  546. const unseen = takeUnseen(agent.id, item.id)
  547. if (unseen?.kind === 'terminal') return
  548. let entries = queuedMirror.get(agent.id)
  549. if (entries === undefined) {
  550. entries = []
  551. queuedMirror.set(agent.id, entries)
  552. }
  553. entries.push(unseen?.kind === 'update' ? unseen.item : item)
  554. publishQueue(agent.id)
  555. }),
  556. ctx.on('agent/inbox/update', (agent: Agent, item: InboxItem) => {
  557. const entries = queuedMirror.get(agent.id)
  558. if (entries === undefined) {
  559. rememberUnseen(agent.id, item.id, { kind: 'update', item })
  560. return
  561. }
  562. const index = entries.findIndex(entry => entry.id === item.id)
  563. if (index === -1) {
  564. rememberUnseen(agent.id, item.id, { kind: 'update', item })
  565. return
  566. }
  567. entries.splice(index, 1, item)
  568. publishQueue(agent.id)
  569. }),
  570. ctx.on('agent/inbox/dequeue', (agent: Agent, item: InboxItem) => {
  571. if (retire(agent, item)) publishQueue(agent.id)
  572. }),
  573. ctx.on('agent/inbox/discard', (agent: Agent, items: InboxItem[]) => {
  574. let changed = false
  575. for (const item of items) changed = retire(agent, item) || changed
  576. if (changed) publishQueue(agent.id)
  577. }),
  578. ctx.on('session/disposed', (session: Session) => {
  579. queuedMirror.delete(session.id)
  580. unseenQueueEvents.delete(session.id)
  581. }),
  582. ]
  583. return () => { for (const dispose of disposers) dispose() }
  584. }, 'api-proxy: queued mirror')
  585. /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
  586. function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
  587. pendingQuestions.delete(pending.rpcId)
  588. if (pending.signal !== undefined && pending.onAbort !== undefined) {
  589. pending.signal.removeEventListener('abort', pending.onAbort)
  590. }
  591. broadcast({
  592. type: 'question/resolved', sessionId: pending.sessionId,
  593. questionRpcId: pending.rpcId, outcome,
  594. })
  595. }
  596. const disposeProvider = ctx.userInteraction.registerProvider({
  597. ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
  598. const sessionId = request.agent?.id
  599. if (sessionId === undefined) {
  600. return Promise.reject(new UserInteractionError(
  601. 'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
  602. }
  603. return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
  604. const rpcId = RpcId(randomUUID())
  605. const pending: PendingQuestion = {
  606. rpcId, sessionId, questions: request.questions, resolve, reject,
  607. ...(request.signal === undefined ? {} : { signal: request.signal }),
  608. }
  609. const onAbort = (): void => {
  610. claimQuestion(pending, 'cancelled')
  611. reject(new UserInteractionError(
  612. 'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
  613. }
  614. pending.onAbort = onAbort
  615. pendingQuestions.set(rpcId, pending)
  616. request.signal?.addEventListener('abort', onAbort, { once: true })
  617. const envelope: RpcRequest<MuxFrame> = {
  618. rpcId,
  619. payload: { type: 'question/requested', sessionId, questions: request.questions },
  620. }
  621. for (const queue of muxQueues) queue.push(envelope)
  622. })
  623. },
  624. })
  625. ctx.effect(() => () => {
  626. disposeProvider()
  627. for (const pending of [...pendingQuestions.values()]) {
  628. claimQuestion(pending, 'cancelled')
  629. pending.reject(new UserInteractionError(
  630. 'web user-interaction provider was disposed', 'ASK_ABORTED'))
  631. }
  632. }, 'api-proxy: user-interaction provider')
  633. // --- Approval pending registry ------------------------------------------
  634. // The proxy is the approval channel for every agent this host owns: an ask
  635. // through `ctx.approval` becomes an answerable server-request on the mux
  636. // stream (stable rpcId), settled by POST /api/respond. The entry survives
  637. // client disconnects — mux-open replays still-pending requested frames with
  638. // the same rpcId (the refresh-recovery baseline) — and withdraws on the
  639. // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
  640. if (ctx.get('approval') !== undefined) {
  641. // Teardown parity with the question provider above: a gateway disposed
  642. // while approvals are pending settles every entry as 'cancelled' (the
  643. // service's fail-closed vocabulary), so no ask promise dangles past the
  644. // proxy's lifetime and subscribers see the withdrawal.
  645. ctx.effect(() => () => {
  646. for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled')
  647. }, 'api-proxy: approval registry teardown')
  648. ctx.on('approval/request', (req, next) => {
  649. // Dispatch rides a microtask behind the service's own signal check: an
  650. // abort landing in that window would register the abort listener AFTER
  651. // the signal fired — never invoked, entry pending forever, zombie frame
  652. // on every mux replay. Settle synchronously instead of publishing.
  653. if (req.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
  654. // The audit pair `approval/asked` is already appended by the service
  655. // before dispatch, but dispatch rides a microtask: parallel tool calls
  656. // can append several asked events before any answerer runs. THIS
  657. // request's event is therefore the newest asked event that is still
  658. // undecided, unclaimed by another pending entry, and — when the ask
  659. // names a call — carries the same callId.
  660. const events = req.agent.session.events
  661. const claimed = new Set<ApprovalRequestId>()
  662. for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId)
  663. const decided = new Set<ApprovalRequestId>()
  664. let approvalId: ApprovalRequestId | undefined
  665. for (let i = events.length - 1; i >= 0; i -= 1) {
  666. const event = events[i] as SessionEvent
  667. if (event.type === 'approval/decided') {
  668. decided.add(event.data.id)
  669. } else if (event.type === 'approval/asked') {
  670. if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
  671. // Symmetric pairing: a callId-bearing ask only takes its own call's
  672. // record, and a callId-less ask only takes a callId-less record —
  673. // so neither shape can steal the other's audit id under parallel
  674. // asks. (Today every producer — the tool executor — passes callId;
  675. // the callId-less arm guards any future non-tool asker.)
  676. if ((req.callId ?? null) !== (event.data.callId ?? null)) continue
  677. approvalId = event.data.id
  678. break
  679. }
  680. }
  681. // No asked event means the request bypassed the service's audit path —
  682. // not this channel's question; delegate to the fail-closed default.
  683. if (approvalId === undefined) return next()
  684. const id = approvalId
  685. return new Promise<ApprovalOutcome>((resolve) => {
  686. const settle = (outcome: ApprovalOutcome): void => {
  687. /* v8 ignore next 3 -- defensive double-settle guard: respond() routes
  688. through the pending table (a settled id is not-pending before it can
  689. re-settle) and the first settle removes the abort listener, so no
  690. reachable path settles twice; kept against future settle callers. */
  691. if (!pendingApprovals.delete(pending.rpcId)) return
  692. req.signal?.removeEventListener('abort', onAbort)
  693. broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome })
  694. // A cancelled ask was already settled by the service's own signal
  695. // race, which discards this late resolution; resolving is a no-op
  696. // there and keeps this promise from dangling forever.
  697. resolve(outcome)
  698. }
  699. const onAbort = (): void => { settle('cancelled') }
  700. const pending: PendingApproval = {
  701. rpcId: RpcId(randomUUID()),
  702. sessionId: req.agent.session.id,
  703. approvalId: id,
  704. toolName: req.toolName,
  705. ...req.callId === undefined ? {} : { callId: req.callId },
  706. ...req.reason === undefined ? {} : { reason: req.reason },
  707. resolve: settle,
  708. }
  709. pendingApprovals.set(pending.rpcId, pending)
  710. req.signal?.addEventListener('abort', onAbort, { once: true })
  711. const envelope = requestedFrame(pending)
  712. for (const queue of muxQueues) queue.push(envelope)
  713. })
  714. })
  715. }
  716. /**
  717. * Gate the cold path on the store: an id absent from it, or naming a legacy
  718. * log without a cwd (pre-release stance: not served, no compatibility), is
  719. * not-found before any resume is attempted. With the gate passed, a later
  720. * resume failure is genuinely internal. No persistence configured skips the
  721. * gate — resume itself then fails loud with its own diagnostic.
  722. */
  723. async function assertServable(sessionId: SessionId): Promise<void> {
  724. const persistence = ctx.get('sessionPersistence')
  725. if (persistence === undefined) return
  726. const meta = (await persistence.list()).find(m => m.id === sessionId)
  727. if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
  728. }
  729. async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
  730. const live = ctx.agents.get(sessionId)
  731. if (live !== undefined) return { agent: live }
  732. let resume = resumes.get(sessionId)
  733. if (resume === undefined) {
  734. resume = (async () => {
  735. try {
  736. await assertServable(sessionId)
  737. const handle = await ctx.agents.resume({
  738. resumeSessionId: sessionId,
  739. agentOptions,
  740. setup: installTarget,
  741. })
  742. return handle.agent
  743. } finally {
  744. resumes.delete(sessionId)
  745. }
  746. })()
  747. resumes.set(sessionId, resume)
  748. }
  749. try {
  750. return { agent: await resume }
  751. } catch (error: unknown) {
  752. if (error instanceof SessionNotFound) {
  753. return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
  754. }
  755. // The internal details slot is contractually {}; the reason rides the message.
  756. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
  757. }
  758. }
  759. /** Resolve one requested identity to a live agent, creating or resuming it once. */
  760. async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
  761. let creation = sessionCreations.get(sessionId)
  762. if (creation === undefined) {
  763. creation = (async () => {
  764. const live = ctx.agents.get(sessionId)
  765. if (live !== undefined) return live
  766. const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
  767. const stored = persistence === undefined
  768. ? undefined
  769. : (await persistence.list()).find(header => header.id === sessionId)
  770. if (stored !== undefined) {
  771. if (stored.cwd !== cwd) {
  772. throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
  773. }
  774. return (await ctx.agents.resume({
  775. resumeSessionId: sessionId,
  776. agentOptions,
  777. setup: installTarget,
  778. })).agent
  779. }
  780. try {
  781. await mkdir(cwd, { recursive: true })
  782. } catch (error: unknown) {
  783. throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
  784. }
  785. return (await ctx.agents.create({
  786. sessionId,
  787. agentOptions,
  788. meta: { cwd },
  789. setup: installTarget,
  790. })).agent
  791. })().catch((error: unknown) => {
  792. // Another Host entry path may have published the same identity while
  793. // this operation crossed an asynchronous persistence/filesystem step.
  794. const live = ctx.agents.get(sessionId)
  795. if (live !== undefined) return live
  796. throw error
  797. }).finally(() => {
  798. sessionCreations.delete(sessionId)
  799. })
  800. sessionCreations.set(sessionId, creation)
  801. }
  802. const agent = await creation
  803. if (agent.session.header.cwd !== cwd) {
  804. throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
  805. }
  806. return agent
  807. }
  808. /** Resolve or create one path while holding the Host's workspace-create chain. */
  809. function ensureWorkspace(
  810. path: string,
  811. title: string | undefined,
  812. rejectExistingName = false,
  813. createDirectory = false,
  814. ): Promise<{ workspace: Workspace; created: boolean }> {
  815. const operation = workspaceCreationChain.then(async () => {
  816. if (rejectExistingName && title !== undefined
  817. && ctx.workspace.list().some(workspace => workspace.title === title)) {
  818. throw new WorkspaceNameConflictError(title)
  819. }
  820. if (createDirectory) {
  821. try {
  822. await mkdir(path, { recursive: true })
  823. } catch (error: unknown) {
  824. throw new WorkspaceDirectoryCreationError(
  825. `failed to create workspace directory "${path}": ${String(error)}`,
  826. )
  827. }
  828. }
  829. const existing = await ctx.workspace.resolveByPath(path)
  830. if (existing !== undefined) return { workspace: existing, created: false }
  831. return { workspace: await ctx.workspace.create(path, title), created: true }
  832. })
  833. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  834. return operation
  835. }
  836. /** Resolve the goal service; absent = the deployment did not compose @deepseek-ai/dsh-goal. */
  837. function goalService(): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
  838. const goals = ctx.get('goals')
  839. if (goals === undefined) {
  840. 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: {} } }
  841. }
  842. return goals
  843. }
  844. /** Map one goal-domain rejection to the wire error (stable GoalError codes ride in details). */
  845. function goalError(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> {
  846. const details = error instanceof GoalError ? { goalCode: error.code } : {}
  847. return err(request, { code: 'internal', message: String(error), details })
  848. }
  849. /** Resolve a session's agent, apply one goal mutation, and acknowledge with the new CAS ref. */
  850. async function mutateGoal(
  851. request: RpcRequest<{ sessionId: SessionId }>,
  852. mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
  853. ): Promise<RpcResponse<{ ref: GoalRef }>> {
  854. const goals = goalService()
  855. if ('error' in goals) return err(request, goals.error)
  856. const found = await agentFor(request.payload.sessionId)
  857. if ('error' in found) return err(request, found.error)
  858. try {
  859. const ref = mutation(goals, found.agent)
  860. return ok(request, { ref: { id: ref.id, revision: ref.revision } })
  861. } catch (error: unknown) {
  862. return goalError(request, error)
  863. }
  864. }
  865. return {
  866. sessions: {
  867. // Attached sessions summarize from memory; persisted-but-unattached (cold)
  868. // sessions merge in from the persistence store so history survives restarts.
  869. // Legacy logs without a cwd (pre-project stance) are not served — every
  870. // session now records its project at create time.
  871. async list(request) {
  872. const items = ctx.sessions.list().map((session) => {
  873. const agent = ctx.agents.get(session.id)
  874. const projections = listProjectionsFor(ctx, session.header, session)
  875. return {
  876. ...summarize(session, agent?.status === 'running'),
  877. ...projections === undefined ? {} : { projections },
  878. }
  879. })
  880. const attached = new Set(items.map(item => item.sessionId))
  881. const persistence = ctx.get('sessionPersistence')
  882. if (persistence !== undefined) {
  883. const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
  884. items.push(...await Promise.all(cold.map(async (meta) => {
  885. // Cold rows read the persisted projection cache only — never a
  886. // log load; a session without a cache row simply has no column.
  887. const projections = listProjectionsFor(ctx, meta, undefined)
  888. return {
  889. ...await summarizeCold(persistence, meta),
  890. ...projections === undefined ? {} : { projections },
  891. }
  892. })))
  893. }
  894. items.sort((a, b) => b.updatedAt - a.updatedAt)
  895. return ok(request, { items })
  896. },
  897. async create(request) {
  898. const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
  899. let workspace: Workspace | undefined
  900. if (request.payload.workspaceId !== undefined) {
  901. workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
  902. if (workspace === undefined) {
  903. return err(request, {
  904. code: 'workspace-not-found',
  905. message: `workspace "${request.payload.workspaceId}" not found`,
  906. details: { workspaceId: request.payload.workspaceId },
  907. })
  908. }
  909. }
  910. const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
  911. try {
  912. await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
  913. } catch (error: unknown) {
  914. if (error instanceof SessionCwdConflict) {
  915. return err(request, {
  916. code: 'session-conflict',
  917. message: error.message,
  918. details: {
  919. sessionId: error.sessionId,
  920. requestedCwd: error.requestedCwd,
  921. ...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
  922. },
  923. })
  924. }
  925. return err(request, {
  926. code: 'internal',
  927. message: `failed to create session "${sessionId}": ${String(error)}`,
  928. details: {},
  929. })
  930. }
  931. if (workspace !== undefined) {
  932. try {
  933. await workspace.attachSession(sessionId)
  934. } catch (error: unknown) {
  935. return err(request, {
  936. code: 'workspace-attach-failed',
  937. message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
  938. details: { sessionId, workspaceId: workspace.id },
  939. })
  940. }
  941. }
  942. return ok(request, { sessionId })
  943. },
  944. async history(request) {
  945. const { sessionId, beforeSeq, maxMessages } = request.payload
  946. const found = await agentFor(sessionId)
  947. if ('error' in found) return err(request, found.error)
  948. // Everything below the resume above is synchronous: the page slice,
  949. // the seq read, and the projection walk see one un-torn session state.
  950. const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
  951. // Views are computed against the registry at pagination time; result
  952. // pairing scans within the page only (message-boundary pagination keeps
  953. // a call and its result on one page — a cross-page miss soft-falls).
  954. const entries: HistoryEntry[] = page.events.map((event) => {
  955. const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
  956. return { event, ...view === undefined ? {} : { view } }
  957. })
  958. // Baseline rider: tail page only — loadOlder (beforeSeq present) is
  959. // the one path that never needs a fresh projection baseline.
  960. const projections = beforeSeq === undefined ? projectionsFor(ctx, found.agent) : undefined
  961. return ok(request, {
  962. events: entries,
  963. hasMore: page.hasMore,
  964. ...projections === undefined ? {} : { projections },
  965. })
  966. },
  967. async models(request) {
  968. const { sessionId } = request.payload
  969. const found = await agentFor(sessionId)
  970. if ('error' in found) return err(request, found.error)
  971. const current = targetFor(found.agent).current
  972. const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
  973. try {
  974. const advertised = await ctx.llm.listModels(provider.id)
  975. const models = [...advertised]
  976. if (
  977. provider.id === current.provider
  978. && !models.some(model => model.id === current.model)
  979. ) {
  980. models.push({
  981. provider: provider.id,
  982. id: current.model,
  983. name: current.model,
  984. })
  985. }
  986. const entries = await Promise.all(models.map(async (model) => {
  987. const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
  988. const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
  989. ? undefined
  990. : {
  991. efforts: resolved.reasoning.efforts.map(effort => ({
  992. id: effort.id,
  993. name: effort.name,
  994. ...effort.description === undefined
  995. ? {}
  996. : { description: effort.description },
  997. })),
  998. ...resolved.reasoning.defaultEffort === undefined
  999. ? {}
  1000. : { defaultEffort: resolved.reasoning.defaultEffort },
  1001. }
  1002. return {
  1003. id: model.id,
  1004. name: model.name,
  1005. ...model.description === undefined ? {} : { description: model.description },
  1006. ...provider.id === current.provider
  1007. && model.id === current.model
  1008. && !advertised.some(candidate => candidate.id === current.model)
  1009. ? { unlisted: true as const }
  1010. : {},
  1011. ...reasoning === undefined ? {} : { reasoning },
  1012. }
  1013. }))
  1014. const group: ModelProviderGroup = {
  1015. id: provider.id,
  1016. name: provider.name,
  1017. models: entries,
  1018. }
  1019. return { kind: 'group' as const, group }
  1020. } catch (error: unknown) {
  1021. const failure: ModelCatalogFailure = {
  1022. id: provider.id,
  1023. name: provider.name,
  1024. message: error instanceof Error ? error.message : String(error),
  1025. }
  1026. return { kind: 'failure' as const, failure }
  1027. }
  1028. }))
  1029. const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
  1030. const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
  1031. return ok(request, {
  1032. current: { ...current },
  1033. groups: groups.filter(group => group.models.length > 0),
  1034. failures,
  1035. })
  1036. },
  1037. async selectModel(request) {
  1038. const { sessionId, provider, model, reasoningEffort } = request.payload
  1039. const found = await agentFor(sessionId)
  1040. if ('error' in found) return err(request, found.error)
  1041. try {
  1042. const resolved = await ctx.llm.resolveCallConfig({
  1043. provider,
  1044. model,
  1045. ...reasoningEffort === undefined
  1046. ? {}
  1047. : { reasoningEffort: ReasoningEffortId(reasoningEffort) },
  1048. })
  1049. const selected: AgentLlmTarget = {
  1050. provider: resolved.provider,
  1051. model: resolved.model,
  1052. ...resolved.reasoningEffort === undefined
  1053. ? {}
  1054. : { reasoningEffort: resolved.reasoningEffort },
  1055. }
  1056. targetFor(found.agent).current = selected
  1057. return ok(request, { selected: { ...selected } })
  1058. } catch (error: unknown) {
  1059. return err(request, {
  1060. code: 'model-unavailable',
  1061. message: error instanceof Error ? error.message : String(error),
  1062. details: { provider, model },
  1063. })
  1064. }
  1065. },
  1066. async rename(request) {
  1067. const { sessionId, title } = request.payload
  1068. const found = await agentFor(sessionId)
  1069. if ('error' in found) return err(request, found.error)
  1070. const titles = ctx.get('sessionTitle')
  1071. if (titles === undefined) {
  1072. return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} })
  1073. }
  1074. try {
  1075. const accepted = titles.rename(found.agent.session, title)
  1076. return ok(request, { title: accepted.title, seq: accepted.eventSeq })
  1077. } catch (error: unknown) {
  1078. // Only the input's fault maps to title-invalid (the message is
  1079. // product-user-visible in the rename dialog); liveness and disposal
  1080. // races are deployment trouble, not a bad title.
  1081. if (error instanceof SessionTitleInvalidError) {
  1082. return err(request, {
  1083. code: 'title-invalid',
  1084. message: error.message,
  1085. details: { sessionId },
  1086. })
  1087. }
  1088. return err(request, {
  1089. code: 'internal',
  1090. message: `failed to rename session "${sessionId}": ${String(error)}`,
  1091. details: {},
  1092. })
  1093. }
  1094. },
  1095. async prompt(request) {
  1096. const { sessionId, mode, content } = request.payload
  1097. const found = await agentFor(sessionId)
  1098. if ('error' in found) return err(request, found.error)
  1099. const agent = found.agent
  1100. // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
  1101. const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
  1102. try {
  1103. const message: UserMessage = createUserMessage({ content, source })
  1104. if (mode === 'steer') agent.steer(message)
  1105. else agent.followup(message)
  1106. } catch (error: unknown) {
  1107. // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
  1108. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
  1109. }
  1110. return ok(request, { accepted: true as const })
  1111. },
  1112. updateQueue(request) {
  1113. const { sessionId, itemId, action } = request.payload
  1114. const agent = ctx.agents.get(sessionId)
  1115. if (agent === undefined || agent.updateInbox(itemId, action) === 'not-found') {
  1116. return Promise.resolve(err(request, {
  1117. code: 'queue-item-not-found',
  1118. message: 'queued item is no longer pending',
  1119. details: { itemId },
  1120. }))
  1121. }
  1122. return Promise.resolve(ok(request, { accepted: true as const }))
  1123. },
  1124. cancel(request) {
  1125. const { sessionId } = request.payload
  1126. const agent = ctx.agents.get(sessionId)
  1127. if (agent === undefined) {
  1128. return Promise.resolve(err(request, {
  1129. code: 'session-not-found',
  1130. message: `session "${sessionId}" not found (not attached)`,
  1131. details: { sessionId },
  1132. }))
  1133. }
  1134. agent.cancel({ kind: 'user' })
  1135. return Promise.resolve(ok(request, { accepted: true as const }))
  1136. },
  1137. },
  1138. workspace: {
  1139. list(request) {
  1140. return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) }))
  1141. },
  1142. // Exactly one of path/name arrives (schema refine). Existing-folder
  1143. // adoption reuses its canonical path; create-by-name rejects a name
  1144. // already present in the registry.
  1145. async create(request) {
  1146. const { payload } = request
  1147. let path: string
  1148. if (payload.name !== undefined) {
  1149. const name = payload.name.trim()
  1150. if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
  1151. return err(request, {
  1152. code: 'workspace-invalid-path',
  1153. message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
  1154. details: { path: payload.name },
  1155. })
  1156. }
  1157. path = join(defaults.workspaceRoot, name)
  1158. } else {
  1159. path = payload.path as string
  1160. }
  1161. try {
  1162. const name = payload.name?.trim()
  1163. const { workspace, created } = await ensureWorkspace(
  1164. path,
  1165. name,
  1166. name !== undefined,
  1167. name !== undefined,
  1168. )
  1169. return ok(request, { workspace: workspaceView(workspace), created })
  1170. } catch (error: unknown) {
  1171. if (error instanceof WorkspaceNameConflictError) {
  1172. return err(request, {
  1173. code: 'workspace-name-conflict',
  1174. message: error.message,
  1175. details: { name: error.workspaceName },
  1176. })
  1177. }
  1178. if (error instanceof WorkspaceDirectoryCreationError) {
  1179. return err(request, { code: 'internal', message: error.message, details: {} })
  1180. }
  1181. // The registry rejects a path that does not resolve to an existing
  1182. // directory (realpath ENOENT / not-a-directory) — the business
  1183. // error of the typed-path flow, surfaced as a validation failure.
  1184. return err(request, {
  1185. code: 'workspace-invalid-path',
  1186. message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
  1187. details: { path },
  1188. })
  1189. }
  1190. },
  1191. async rename(request) {
  1192. const { payload } = request
  1193. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  1194. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  1195. const title = payload.title.trim()
  1196. // Uniqueness AND the same-title no-op both ride the create chain so
  1197. // they observe the state left by earlier queued renames — checked
  1198. // up front, a queued A→A could report success while an earlier A→B
  1199. // still lands afterwards.
  1200. const operation = workspaceCreationChain.then(async () => {
  1201. if (title === workspace.title) return
  1202. if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
  1203. throw new WorkspaceNameConflictError(title)
  1204. }
  1205. await workspace.setTitle(title)
  1206. })
  1207. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  1208. try {
  1209. await operation
  1210. } catch (error: unknown) {
  1211. if (error instanceof WorkspaceNameConflictError) {
  1212. return err(request, {
  1213. code: 'workspace-name-conflict',
  1214. message: error.message,
  1215. details: { name: error.workspaceName },
  1216. })
  1217. }
  1218. throw error
  1219. }
  1220. return ok(request, { workspace: workspaceView(workspace) })
  1221. },
  1222. async delete(request) {
  1223. const { workspaceId } = request.payload
  1224. const operation = workspaceCreationChain.then(() =>
  1225. ctx.workspace.delete(brandWorkspaceId(workspaceId)))
  1226. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  1227. if (!await operation) return workspaceNotFound(request, workspaceId)
  1228. return ok(request, { deleted: true as const })
  1229. },
  1230. async insertSessionBefore(request) {
  1231. const { payload } = request
  1232. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  1233. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  1234. try {
  1235. await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
  1236. } catch (error: unknown) {
  1237. // Only the entity's unaccounted-id rejection is the business code;
  1238. // storage/durability failures propagate as internal errors.
  1239. if (!(error instanceof WorkspaceMoveInvalidError)) throw error
  1240. return err(request, {
  1241. code: 'workspace-move-invalid',
  1242. message: error.message,
  1243. details: {
  1244. workspaceId: payload.workspaceId,
  1245. sessionId: payload.sessionId,
  1246. ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
  1247. },
  1248. })
  1249. }
  1250. return ok(request, { workspace: workspaceView(workspace) })
  1251. },
  1252. },
  1253. host: {
  1254. describe(request) {
  1255. // TODO(step2): version should read apps/cli's package.json; placeholder for now.
  1256. return Promise.resolve(ok(request, {
  1257. version: '0.0.1',
  1258. // Same source as session.create's fallback: the UI's default project
  1259. // must match where an unspecified-cwd session actually lands.
  1260. cwd: defaults.cwd,
  1261. provider: defaults.provider,
  1262. model: defaults.model,
  1263. attachedSessions: ctx.agents.list().length,
  1264. }))
  1265. },
  1266. async pickDirectory(request, signal) {
  1267. const capability = ctx.directoryPicker.capability()
  1268. if (capability.kind !== 'native') {
  1269. return err(request, {
  1270. code: 'directory-picker-unavailable',
  1271. message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`,
  1272. details: { capability: capability.kind },
  1273. })
  1274. }
  1275. try {
  1276. const path = await capability.pick(signal)
  1277. return ok(request, { path })
  1278. } catch (error: unknown) {
  1279. if (signal.aborted) {
  1280. return err(request, {
  1281. code: 'cancelled',
  1282. message: 'directory picker was aborted',
  1283. details: {},
  1284. })
  1285. }
  1286. return err(request, {
  1287. code: 'internal',
  1288. message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
  1289. details: {},
  1290. })
  1291. }
  1292. },
  1293. async listDirectory(request, signal) {
  1294. const capability = ctx.directoryPicker.capability()
  1295. if (capability.kind !== 'browse') {
  1296. return err(request, {
  1297. code: 'directory-picker-unavailable',
  1298. message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
  1299. details: { capability: capability.kind },
  1300. })
  1301. }
  1302. try {
  1303. // The carrier's signal follows the caller: a disconnect or timeout
  1304. // stops the backend's directory scan instead of outliving it.
  1305. return ok(request, await capability.list(request.payload.path, signal))
  1306. } catch (error: unknown) {
  1307. // An abort is the caller's own timeout/disconnect, not a server
  1308. // failure — same code pickDirectory and command.execute report.
  1309. if (signal.aborted) {
  1310. return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} })
  1311. }
  1312. return err(request, directoryError(error))
  1313. }
  1314. },
  1315. async createDirectory(request) {
  1316. const capability = ctx.directoryPicker.capability()
  1317. if (capability.kind !== 'browse') {
  1318. return err(request, {
  1319. code: 'directory-picker-unavailable',
  1320. message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
  1321. details: { capability: capability.kind },
  1322. })
  1323. }
  1324. try {
  1325. return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) })
  1326. } catch (error: unknown) {
  1327. return err(request, directoryError(error))
  1328. }
  1329. },
  1330. async openPath(request, signal) {
  1331. try {
  1332. const open = defaults.openPath
  1333. ?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal))
  1334. await open(request.payload.path, signal)
  1335. return ok(request, { opened: true as const })
  1336. } catch (error: unknown) {
  1337. if (signal.aborted) {
  1338. return err(request, {
  1339. code: 'cancelled',
  1340. message: 'path open was aborted',
  1341. details: {},
  1342. })
  1343. }
  1344. return err(request, {
  1345. code: 'internal',
  1346. message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
  1347. details: {},
  1348. })
  1349. }
  1350. },
  1351. },
  1352. commands: {
  1353. // Both methods address one session's agent (agentFor keeps its
  1354. // resume-on-miss: clients only send a sessionId for a published
  1355. // session, and resume restores an existing entity).
  1356. async list(request) {
  1357. // Missing service = the deployment omitted dsh-commands from its
  1358. // composition, not an empty catalog: fail loud instead of serving [].
  1359. const commands = ctx.get('commands')
  1360. if (commands === undefined) {
  1361. 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: {} })
  1362. }
  1363. const found = await agentFor(request.payload.sessionId)
  1364. if ('error' in found) return err(request, found.error)
  1365. return ok(request, { commands: commands.list(found.agent) })
  1366. },
  1367. async execute(request, signal) {
  1368. const commands = ctx.get('commands')
  1369. if (commands === undefined) {
  1370. 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: {} })
  1371. }
  1372. const { sessionId, line } = request.payload
  1373. const found = await agentFor(sessionId)
  1374. if ('error' in found) return err(request, found.error)
  1375. try {
  1376. // Pure admission: the executor's durable command/run + command/done
  1377. // pair (broadcast on the mux stream) carries the outcome; the
  1378. // response reports whether the line resolved to a handler, plus the
  1379. // minted pairing id so the issuing client can correlate its request
  1380. // with the flow node the lifecycle events produce.
  1381. const execution = await commands.execute(found.agent, line, signal)
  1382. return ok(request, execution === undefined
  1383. ? { matched: false }
  1384. : { matched: true, commandId: execution.commandId })
  1385. } catch (error: unknown) {
  1386. if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
  1387. return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
  1388. }
  1389. },
  1390. },
  1391. goals: {
  1392. // Mutations only — the read side is the 'goal' session projection.
  1393. // Every verb resolves the session's agent (agentFor: implicit cold
  1394. // resume, the command.* precedent) and acknowledges with the new CAS
  1395. // ref; the committed goal/change event carries the whole value to every
  1396. // client through the projection frames.
  1397. async create(request) {
  1398. const { objective, maxGoalRounds } = request.payload
  1399. return mutateGoal(request, (goals, agent) => goals.create(agent, {
  1400. objective,
  1401. ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
  1402. }))
  1403. },
  1404. async edit(request) {
  1405. const { ref, objective, maxGoalRounds } = request.payload
  1406. return mutateGoal(request, (goals, agent) => goals.edit(agent, ref, {
  1407. ...(objective !== undefined ? { objective } : {}),
  1408. ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
  1409. }))
  1410. },
  1411. async pause(request) {
  1412. return mutateGoal(request, (goals, agent) => goals.pause(agent, request.payload.ref))
  1413. },
  1414. async resume(request) {
  1415. return mutateGoal(request, (goals, agent) => goals.resume(agent, request.payload.ref))
  1416. },
  1417. async complete(request) {
  1418. return mutateGoal(request, (goals, agent) => goals.complete(agent, request.payload.ref))
  1419. },
  1420. async clear(request) {
  1421. const goals = goalService()
  1422. if ('error' in goals) return err(request, goals.error)
  1423. const found = await agentFor(request.payload.sessionId)
  1424. if ('error' in found) return err(request, found.error)
  1425. try {
  1426. goals.clear(found.agent, request.payload.ref)
  1427. return ok(request, { cleared: true as const })
  1428. } catch (error: unknown) {
  1429. return goalError(request, error)
  1430. }
  1431. },
  1432. },
  1433. skills: {
  1434. // Skill lookup never touches the Agent registry: the session address
  1435. // resolves to a canonical cwd from the host-resident session header, so
  1436. // listing skills cannot create or resume an agent as a side effect.
  1437. async list(request) {
  1438. const { sessionId } = request.payload
  1439. const session = ctx.sessions.get(sessionId)
  1440. if (session === undefined) {
  1441. return err(request, {
  1442. code: 'session-not-found',
  1443. message: `session "${sessionId}" not found (not attached)`,
  1444. details: { sessionId },
  1445. })
  1446. }
  1447. if (session.header.cwd === undefined) {
  1448. // Every served session records its project at create time; a
  1449. // cwd-less header is a pre-project legacy log (not served).
  1450. return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
  1451. }
  1452. const cwd = session.header.cwd
  1453. // Same stance as the commands domain: a missing service means the
  1454. // deployment omitted dsh-skill from its composition, not an empty
  1455. // catalog. ctx.get also keeps this handler independent of the gateway
  1456. // plugin's inject list (an undeclared `ctx.skills` property read
  1457. // fails the reflect proxy).
  1458. const skillRegistry = ctx.get('skills')
  1459. if (skillRegistry === undefined) {
  1460. 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: {} })
  1461. }
  1462. try {
  1463. const skills = (await skillRegistry.list({ cwd }))
  1464. .filter(skill => skill.invocation.modelInvocable && skill.invocation.userInvocable)
  1465. return ok(request, {
  1466. skills: skills.map(skill => ({
  1467. name: skill.name,
  1468. description: skill.description,
  1469. ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
  1470. })),
  1471. })
  1472. } catch (error: unknown) {
  1473. return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
  1474. }
  1475. },
  1476. },
  1477. events: {
  1478. mux(_request, signal) {
  1479. const queue = new FrameQueue<RpcRequest<MuxFrame>>()
  1480. muxQueues.add(queue)
  1481. for (const session of ctx.sessions.list()) {
  1482. subscribeSession(queue, session)
  1483. }
  1484. for (const pending of pendingQuestions.values()) {
  1485. queue.push({
  1486. rpcId: pending.rpcId,
  1487. payload: {
  1488. type: 'question/requested', sessionId: pending.sessionId,
  1489. questions: pending.questions,
  1490. },
  1491. })
  1492. }
  1493. // Refresh recovery: still-pending approval questions replay with their
  1494. // stable rpcId so a reconnecting client can still answer them.
  1495. for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending))
  1496. // Queue snapshot baseline (pendingQuestions precedent): frames replayed
  1497. // in arrival order per session; a reconnecting client rebuilds its
  1498. // queue view from these alone.
  1499. for (const [sessionId, items] of queuedMirror) {
  1500. queue.push(frame({
  1501. type: 'session/queue',
  1502. sessionId,
  1503. items: items.map(item => ({
  1504. id: item.id,
  1505. message: item.message,
  1506. })),
  1507. }))
  1508. }
  1509. // Per-session open-call table for result-view pairing. Bounded by the
  1510. // per-turn call count: entries clear on turn/end; a table miss (stream
  1511. // opened mid-turn) backscans the session's in-memory events instead.
  1512. const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
  1513. const disposers = [
  1514. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  1515. if (event.type === 'tool/call') {
  1516. const data = event.data as ToolCallData
  1517. try {
  1518. let table = openCalls.get(session.id)
  1519. if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
  1520. table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
  1521. } catch {
  1522. // Unparseable model arguments: leave the table unset; the result view soft-falls.
  1523. }
  1524. } else if (event.type === 'turn/end') {
  1525. openCalls.delete(session.id)
  1526. }
  1527. const view = viewFor(ctx, event, callId =>
  1528. openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
  1529. queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
  1530. }),
  1531. ctx.on('session/created', (session: Session) => {
  1532. subscribeSession(queue, session)
  1533. }),
  1534. ctx.on('session/disposed', (session: Session) => {
  1535. openCalls.delete(session.id)
  1536. }),
  1537. ]
  1538. return queue.iterate(signal, () => {
  1539. muxQueues.delete(queue)
  1540. for (const dispose of disposers) dispose()
  1541. })
  1542. },
  1543. host(_request, signal) {
  1544. const queue = new FrameQueue<RpcRequest<HostFrame>>()
  1545. const committedWorkspaceIds = new Set(
  1546. ctx.workspace.list().map(workspace => String(workspace.id)),
  1547. )
  1548. const disposers = [
  1549. ctx.on('session/created', (session: Session) => {
  1550. queue.push(frame({
  1551. type: 'host/session-added',
  1552. sessionId: session.id,
  1553. // Derived at frame time like summarize(); a just-created session
  1554. // has run no turn yet, so this is constantly true in practice.
  1555. blank: sessionBlank(session),
  1556. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  1557. // cwd rides the frame so the client list needs no refresh to group the new session.
  1558. ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
  1559. }))
  1560. }),
  1561. ctx.on('session/disposed', (session: Session) => {
  1562. queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
  1563. }),
  1564. ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
  1565. queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
  1566. }),
  1567. ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => {
  1568. queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) }))
  1569. }),
  1570. ctx.on('domain/changed', (change) => {
  1571. if (change.domain !== 'workspace') return
  1572. if (change.table === '') {
  1573. if (change.operation !== 'put') return
  1574. const state = workspaceDomainState.parse(change.value)
  1575. for (const workspaceId of state.workspaceIds) {
  1576. if (committedWorkspaceIds.has(workspaceId)) continue
  1577. const workspace = ctx.workspace.get(workspaceId)
  1578. if (workspace === undefined) {
  1579. throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
  1580. }
  1581. committedWorkspaceIds.add(workspaceId)
  1582. queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
  1583. }
  1584. return
  1585. }
  1586. if (change.table !== 'workspaces') return
  1587. if (change.operation === 'deleted') {
  1588. if (!committedWorkspaceIds.delete(change.key)) return
  1589. queue.push(frame({
  1590. type: 'host/workspace-removed',
  1591. workspaceId: change.key as WorkspaceId,
  1592. }))
  1593. return
  1594. }
  1595. if (!committedWorkspaceIds.has(change.key)) return
  1596. // Existing-entity table writes are complete attach/touch commits.
  1597. // A new entity's first put waits for the global registry write above.
  1598. queue.push(frame({
  1599. type: 'host/workspace-changed',
  1600. workspace: changedWorkspaceView(change.key, change.value),
  1601. }))
  1602. }),
  1603. ctx.on('commands/change', () => {
  1604. queue.push(frame({ type: 'host/commands-changed' }))
  1605. }),
  1606. ]
  1607. return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
  1608. },
  1609. },
  1610. respond(message: ClientResponse): Promise<RpcReceipt> {
  1611. // Route by the echoed rpcId (the wire correlation): approvals first,
  1612. // then questions — the two registries share one id space of UUIDs.
  1613. const approval = pendingApprovals.get(message.rpcId)
  1614. if (approval !== undefined) {
  1615. if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1616. const parsed = approvalResponsePayloadSchema.safeParse(message.result.value)
  1617. // The payload's audit correlation must match the entry the rpcId routed
  1618. // to — a mismatched answer is malformed, not merely late.
  1619. if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) {
  1620. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1621. }
  1622. approval.resolve(parsed.data.outcome)
  1623. return Promise.resolve({ accepted: true })
  1624. }
  1625. const pending = pendingQuestions.get(message.rpcId)
  1626. if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
  1627. if (!message.result.ok) {
  1628. if (message.result.error.code !== 'cancelled') {
  1629. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1630. }
  1631. claimQuestion(pending, 'cancelled')
  1632. pending.reject(new UserInteractionError(
  1633. 'the user cancelled ask_user_question', 'ASK_CANCELLED'))
  1634. return Promise.resolve({ accepted: true })
  1635. }
  1636. const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
  1637. if (!parsed.success) {
  1638. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1639. }
  1640. const payload: QuestionResponsePayload = {
  1641. sessionId: parsed.data.sessionId,
  1642. answer: {
  1643. answers: parsed.data.answer.answers.map(answer => ({
  1644. id: answer.id,
  1645. selected: answer.selected,
  1646. ...(answer.custom === undefined ? {} : { custom: answer.custom }),
  1647. })),
  1648. },
  1649. }
  1650. if (!matchesQuestions(payload, pending)) {
  1651. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1652. }
  1653. claimQuestion(pending, 'answered')
  1654. pending.resolve(payload.answer)
  1655. return Promise.resolve({ accepted: true })
  1656. },
  1657. }
  1658. }