api-proxy.ts 99 KB

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