api-proxy.ts 57 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365
  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, AgentMessage, AgentMessageId, AgentStatus,
  12. } from '@deepseek-ai/dsh-agent'
  13. import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  14. import { errorChain } from '@deepseek-ai/dsh-llm'
  15. import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
  16. import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId, TodoItem } from '@deepseek-ai/dsh-session'
  17. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  18. import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
  19. import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
  20. import {
  21. workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
  22. WorkspaceMoveInvalidError, WorkspaceNameConflictError,
  23. } from '@deepseek-ai/dsh-workspace'
  24. // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
  25. import type {} from '@deepseek-ai/dsh-tools'
  26. import type {
  27. ApiProxy, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning,
  28. MuxFrame, QuestionResponsePayload, SessionSummary, ToolEventView,
  29. WorkspaceId, WorkspaceView,
  30. } from './api/index.ts'
  31. // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
  32. import type {} from '@deepseek-ai/dsh-commands'
  33. import type {} from '@deepseek-ai/dsh-skill'
  34. import { questionResponsePayloadSchema } from './api/questions.schema.ts'
  35. import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
  36. import { RpcId } from './api/rpc.ts'
  37. import type {
  38. AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
  39. } from '@deepseek-ai/dsh-user-interaction'
  40. import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
  41. import { pickNativeDirectory } from './native-directory-picker.ts'
  42. import { affectsSessionMetrics, SessionMetricsProjector } from './session-metrics.ts'
  43. import { openNativePath } from './native-path-opener.ts'
  44. /** Page size when history is called without maxMessages. */
  45. const DEFAULT_MAX_MESSAGES = 50
  46. /** Surface message event types (the pagination counting unit). */
  47. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
  48. /**
  49. * Message-boundary pagination: count maxMessages surface messages backwards from
  50. * the window tail; the cut is the starting seq of the oldest message group
  51. * (chunks group via sourceEventSeqs — never cut mid-message). The tail page
  52. * naturally includes the in-progress partial.
  53. */
  54. function paginate(
  55. events: readonly SessionEvent[],
  56. beforeSeq: number | undefined,
  57. maxMessages: number,
  58. ): { events: SessionEvent[]; hasMore: boolean } {
  59. const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
  60. let count = 0
  61. let cut = 0
  62. for (let i = window.length - 1; i >= 0; i--) {
  63. const event = window[i] as SessionEvent
  64. if (!MESSAGE_TYPES.has(event.type)) continue
  65. count++
  66. const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
  67. const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
  68. if (count >= maxMessages) {
  69. cut = groupStart
  70. break
  71. }
  72. }
  73. const page = window.filter(event => event.seq >= cut)
  74. return { events: page, hasMore: cut > 0 }
  75. }
  76. /** Wrap an ok result echoing the request's rpcId. */
  77. function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
  78. return { rpcId: request.rpcId, result: { ok: true, value } }
  79. }
  80. /** Wrap an error result echoing the request's rpcId. */
  81. function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
  82. return { rpcId: request.rpcId, result: { ok: false, error } }
  83. }
  84. /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
  85. class FrameQueue<F> {
  86. private buffer: F[] = []
  87. private waiter: (() => void) | undefined
  88. private done = false
  89. push(item: F): void {
  90. if (this.done) return
  91. this.buffer.push(item)
  92. this.waiter?.()
  93. }
  94. end(): void {
  95. this.done = true
  96. this.waiter?.()
  97. }
  98. async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
  99. const onAbort = (): void => { this.end() }
  100. signal.addEventListener('abort', onAbort, { once: true })
  101. try {
  102. while (true) {
  103. while (this.buffer.length > 0) yield this.buffer.shift() as F
  104. if (this.done || signal.aborted) return
  105. await new Promise<void>((resolve) => { this.waiter = resolve })
  106. this.waiter = undefined
  107. }
  108. } finally {
  109. signal.removeEventListener('abort', onAbort)
  110. cleanup()
  111. }
  112. }
  113. }
  114. /**
  115. * Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
  116. * for answerable frames belong to the approval/question registry, absent in
  117. * this minimal version).
  118. */
  119. function frame<F>(payload: F): RpcRequest<F> {
  120. return { rpcId: RpcId(randomUUID()), payload }
  121. }
  122. type SessionTitleFrame = Extract<MuxFrame, { type: 'session/title' }>
  123. /** Project the latest durable title without exposing title-generation policy. */
  124. function titleFrame(session: Session): SessionTitleFrame | undefined {
  125. const title = foldSessionTitle(session.events)
  126. if (title === undefined) return undefined
  127. return {
  128. type: 'session/title',
  129. sessionId: session.id,
  130. title: title.title,
  131. eventSeq: title.eventSeq,
  132. updatedAt: title.updatedAt,
  133. }
  134. }
  135. /** Queue the subscription baseline followed by its optional title snapshot. */
  136. function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
  137. queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
  138. const title = titleFrame(session)
  139. if (title !== undefined) queue.push(frame(title))
  140. }
  141. /** SessionSummary projection for attached (in-memory) sessions. */
  142. function summarize(session: Session, running: boolean): SessionSummary {
  143. return {
  144. sessionId: session.id,
  145. updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
  146. running,
  147. blank: session.events.length === 0,
  148. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  149. ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
  150. }
  151. }
  152. /**
  153. * SessionSummary projection for cold (persisted, unattached) sessions.
  154. * updatedAt is the log file's mtime; backends without a per-session file
  155. * (locate() undefined) fall back to the header's createdAt.
  156. */
  157. async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
  158. let updatedAt = meta.createdAt
  159. const location = persistence.locate(meta)
  160. if (location !== undefined) {
  161. try {
  162. updatedAt = (await stat(location.path)).mtimeMs
  163. } catch {
  164. // The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
  165. }
  166. }
  167. return {
  168. sessionId: meta.id,
  169. updatedAt,
  170. running: false,
  171. // Lazy persistence keeps never-appended sessions out of list(): a cold
  172. // session necessarily has events, so blank is constantly false here.
  173. blank: false,
  174. ...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
  175. /* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
  176. filters those out (legacy logs are not served); the conditional mirrors
  177. summarize() shape. */
  178. ...meta.cwd === undefined ? {} : { cwd: meta.cwd },
  179. }
  180. }
  181. /** Resolved Host routing and project-directory defaults consumed by the API implementation. */
  182. export interface ApiProxyDefaults {
  183. provider: string
  184. model: string
  185. /** Default project directory for new sessions whose create request carries no cwd. */
  186. cwd: string
  187. /** Parent directory for name-created workspaces. */
  188. workspaceRoot: string
  189. /** Native single-directory picker; injectable for carrier tests. */
  190. pickDirectory?: (signal: AbortSignal) => Promise<string | null>
  191. /** Native open-with-default-application; injectable for carrier tests. */
  192. openPath?: (path: string, signal: AbortSignal) => Promise<void>
  193. }
  194. /** The tool/call payload fields the presenter path reads. */
  195. interface ToolCallData { callId: string; name: string; arguments: string }
  196. /** The tool/result payload fields the presenter path reads. */
  197. interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
  198. /** One host-owned question wait, addressed by the stable server-request id. */
  199. interface PendingQuestion {
  200. rpcId: RpcId
  201. sessionId: SessionId
  202. questions: AskUserQuestionItem[]
  203. resolve: (answer: AskUserQuestionAnswer) => void
  204. reject: (error: UserInteractionError) => void
  205. signal?: AbortSignal
  206. onAbort?: () => void
  207. }
  208. /** Validate one answer batch against the exact question request it resolves. */
  209. function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
  210. if (payload.sessionId !== pending.sessionId) return false
  211. const answers = payload.answer.answers
  212. if (answers.length !== pending.questions.length) return false
  213. return answers.every((answer, index) => {
  214. const question = pending.questions[index] as AskUserQuestionItem
  215. if (answer.id !== question.id) return false
  216. if (new Set(answer.selected).size !== answer.selected.length) return false
  217. const custom = answer.custom?.trim()
  218. if (custom !== undefined && custom === '') return false
  219. if (custom !== undefined && answer.selected.length > 0) return false
  220. if (question.multiSelect !== true && answer.selected.length > 1) return false
  221. const labels = new Set(question.options?.map(option => option.label) ?? [])
  222. return answer.selected.every(label => labels.has(label))
  223. })
  224. }
  225. /**
  226. * Compute the render intent for a tool/call or tool/result event through the
  227. * presenters registered at this moment; every other event type gets none. A
  228. * result's presenter needs its call's parsed args — `argsFor` supplies them
  229. * (live: the per-session call table; history: an in-page backscan), returning
  230. * undefined when the pairing is unavailable (e.g. the call fell off the page),
  231. * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
  232. * the client's documented default (generic JSON card) covers every miss.
  233. */
  234. function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
  235. try {
  236. if (event.type === 'tool/call') {
  237. const { name, arguments: raw } = event.data as ToolCallData
  238. const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
  239. return view === undefined ? undefined : { for: 'call', view }
  240. }
  241. if (event.type === 'tool/result') {
  242. const { callId, content, isError, meta } = event.data as ToolResultData
  243. const call = argsFor(callId) as { name: string; args: unknown } | undefined
  244. if (call === undefined) return undefined
  245. const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
  246. return view === undefined ? undefined : { for: 'result', view }
  247. }
  248. } catch (error: unknown) {
  249. // A throwing presenter (or unparseable arguments) must not break delivery;
  250. // the event still ships, just without a view.
  251. console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
  252. }
  253. return undefined
  254. }
  255. /**
  256. * Resolve a tool/result's call pairing by scanning a window of events backwards
  257. * for the matching tool/call. Used by the history path (the page is the
  258. * window — a cross-page pairing soft-falls to no view) and by live-path table
  259. * misses after a reconnect-eviction.
  260. */
  261. function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
  262. for (let i = events.length - 1; i >= 0; i--) {
  263. const event = events[i] as SessionEvent
  264. if (event.type !== 'tool/call') continue
  265. const data = event.data as ToolCallData
  266. if (data.callId !== callId) continue
  267. try {
  268. return { name: data.name, args: JSON.parse(data.arguments) }
  269. } catch {
  270. // Unparseable stored arguments: same soft-fall as a live parse failure.
  271. return undefined
  272. }
  273. }
  274. return undefined
  275. }
  276. /** Current todo projection: the latest `todo/write` over the full log (whole-list replace ⇒ last write wins); undefined when none. */
  277. function backscanTodos(events: readonly SessionEvent[]): TodoItem[] | undefined {
  278. for (let i = events.length - 1; i >= 0; i--) {
  279. const event = events[i]
  280. if (event !== undefined && event.type === 'todo/write') return event.data.todos
  281. }
  282. return undefined
  283. }
  284. /**
  285. * Thrown by the cold-resume path when the id names no servable session
  286. * (absent from the store, or a pre-project legacy log without a cwd).
  287. */
  288. class SessionNotFound extends Error {}
  289. /** Requested identity already belongs to a session with another project cwd. */
  290. class SessionCwdConflict extends Error {
  291. constructor(
  292. readonly sessionId: SessionId,
  293. readonly requestedCwd: string,
  294. readonly existingCwd: string | undefined,
  295. ) {
  296. super(
  297. `session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
  298. + `requested ${JSON.stringify(requestedCwd)}`,
  299. )
  300. }
  301. }
  302. /** Host failed before the registry could adopt a name-created directory. */
  303. class WorkspaceDirectoryCreationError extends Error {}
  304. /** Shared workspace-not-found error response of the workspace.* mutation rows. */
  305. function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
  306. return err(request, {
  307. code: 'workspace-not-found',
  308. message: `workspace "${workspaceId}" not found`,
  309. details: { workspaceId },
  310. })
  311. }
  312. /** Wire projection of one workspace entity (the workspace.* value row). */
  313. function workspaceView(workspace: Workspace): WorkspaceView {
  314. return {
  315. workspaceId: workspace.id,
  316. path: workspace.path,
  317. title: workspace.title,
  318. sessionIds: [...workspace.sessionIds],
  319. createdAt: workspace.createdAt,
  320. updatedAt: workspace.updatedAt,
  321. }
  322. }
  323. /** Wire projection of the durable record carried by `domain/changed`. */
  324. function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
  325. const record: WorkspaceRecord = workspaceRecord.parse(value)
  326. return {
  327. workspaceId: workspaceId as WorkspaceId,
  328. path: record.path,
  329. title: record.title,
  330. sessionIds: [...record.sessionIds],
  331. createdAt: record.createdAt,
  332. updatedAt: record.updatedAt,
  333. }
  334. }
  335. /**
  336. * Implement ApiProxy over a composed host context.
  337. * @param ctx - a context with the Host spine and Workspace registry mounted.
  338. * @param defaults - host routing and project-directory defaults.
  339. * @returns the ApiProxy implementation.
  340. */
  341. export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
  342. const agentOptions = { provider: defaults.provider, model: defaults.model }
  343. type WebLlmTargetRef = AgentLlmTargetRef & { current: AgentLlmTarget }
  344. const targets = new WeakMap<Agent, WebLlmTargetRef>()
  345. /** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
  346. const resumes = new Map<SessionId, Promise<Agent>>()
  347. /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
  348. const sessionCreations = new Map<SessionId, Promise<Agent>>()
  349. /** Serializes path ownership checks with record creation across spellings. */
  350. let workspaceCreationChain = Promise.resolve()
  351. const pendingQuestions = new Map<RpcId, PendingQuestion>()
  352. const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
  353. /**
  354. * Install or return the session-local target that prompt assembly snapshots.
  355. * Seed order: latest logged request/header, else the host default routing.
  356. * There is no create-time per-session override tier on this wire — if one
  357. * returns (a create-options contribution), it must fold in between the two.
  358. */
  359. function targetFor(agent: Agent): WebLlmTargetRef {
  360. const installed = targets.get(agent)
  361. if (installed !== undefined) return installed
  362. const logged = agent.session.requestHeader()?.config
  363. const target: WebLlmTargetRef = {
  364. current: logged === undefined
  365. ? { provider: defaults.provider, model: defaults.model }
  366. : {
  367. provider: logged.provider,
  368. model: logged.model,
  369. ...logged.reasoningEffort === undefined
  370. ? {}
  371. : { reasoningEffort: logged.reasoningEffort },
  372. },
  373. assembled: undefined,
  374. }
  375. installAgentLlmTarget(agent.ctx, target)
  376. targets.set(agent, target)
  377. return target
  378. }
  379. /** Pre-publication setup used by both fresh and resumed Web agents. */
  380. function installTarget(agentCtx: Context): void {
  381. const agent = agentCtx.agent
  382. if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
  383. targetFor(agent)
  384. }
  385. /** Send one transient frame to every connected mux consumer. */
  386. function broadcast(payload: MuxFrame): void {
  387. const envelope = frame(payload)
  388. for (const queue of muxQueues) queue.push(envelope)
  389. }
  390. const pendingMetricSessions = new Set<Session>()
  391. let metricFlushScheduled = false
  392. const metricsProjector = new SessionMetricsProjector(ctx)
  393. /** Queue one full-log metrics publication after synchronous session listeners drain. */
  394. function scheduleMetrics(session: Session): void {
  395. if (muxQueues.size === 0) return
  396. pendingMetricSessions.add(session)
  397. if (metricFlushScheduled) return
  398. metricFlushScheduled = true
  399. queueMicrotask(() => {
  400. metricFlushScheduled = false
  401. const sessions = [...pendingMetricSessions]
  402. pendingMetricSessions.clear()
  403. for (const current of sessions) {
  404. broadcast({
  405. type: 'session/metrics',
  406. sessionId: current.id,
  407. metrics: metricsProjector.snapshot(current),
  408. })
  409. }
  410. })
  411. }
  412. ctx.effect(() => {
  413. const disposers = [
  414. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  415. if (affectsSessionMetrics(event)) scheduleMetrics(session)
  416. }),
  417. ctx.on('agent/created', (agent: Agent) => { scheduleMetrics(agent.session) }),
  418. ctx.on('agent/model-request', (agent, turn, step, request) => {
  419. broadcast({
  420. type: 'session/model-request',
  421. sessionId: agent.session.id,
  422. turn,
  423. step,
  424. provider: request.provider,
  425. model: request.model,
  426. ...request.contextWindow === undefined
  427. ? {}
  428. : { contextWindow: request.contextWindow },
  429. })
  430. }),
  431. ctx.on('session/disposed', (session: Session) => { pendingMetricSessions.delete(session) }),
  432. ]
  433. return () => {
  434. pendingMetricSessions.clear()
  435. for (const dispose of disposers) dispose()
  436. }
  437. }, 'api-proxy: session metrics')
  438. /**
  439. * Per-session inbox mirror serving the mux-open queue snapshot (the same
  440. * refresh-recovery baseline as pending questions). Keyed by the stable
  441. * AgentMessageId: every enqueued id receives exactly one terminal
  442. * `agent/inbox/dequeue` OR `agent/inbox/discard` (the inbox contract), so
  443. * the mirror needs no consumption heuristics or sweeps beyond disposal.
  444. */
  445. const queuedMirror = new Map<SessionId, Map<AgentMessageId, { message: AgentMessage; steering: boolean }>>()
  446. ctx.effect(() => {
  447. const retire = (agent: Agent, id: AgentMessageId): void => {
  448. const entries = queuedMirror.get(agent.id)
  449. if (entries === undefined) return
  450. entries.delete(id)
  451. if (entries.size === 0) queuedMirror.delete(agent.id)
  452. }
  453. const disposers = [
  454. ctx.on('agent/inbox/enqueue', (agent: Agent, message: AgentMessage, placement) => {
  455. let entries = queuedMirror.get(agent.id)
  456. if (entries === undefined) {
  457. entries = new Map<AgentMessageId, { message: AgentMessage; steering: boolean }>()
  458. queuedMirror.set(agent.id, entries)
  459. }
  460. const steering = placement === 'steering'
  461. entries.set(message.id, { message, steering })
  462. broadcast({
  463. type: 'session/queued',
  464. sessionId: agent.id,
  465. content: message.content,
  466. source: message.source,
  467. steering,
  468. })
  469. }),
  470. ctx.on('agent/inbox/dequeue', (agent: Agent, message: AgentMessage) => {
  471. retire(agent, message.id)
  472. }),
  473. ctx.on('agent/inbox/discard', (agent: Agent, messages: AgentMessage[]) => {
  474. for (const message of messages) retire(agent, message.id)
  475. }),
  476. ctx.on('session/disposed', (session: Session) => {
  477. queuedMirror.delete(session.id)
  478. }),
  479. ]
  480. return () => { for (const dispose of disposers) dispose() }
  481. }, 'api-proxy: queued mirror')
  482. /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
  483. function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
  484. pendingQuestions.delete(pending.rpcId)
  485. if (pending.signal !== undefined && pending.onAbort !== undefined) {
  486. pending.signal.removeEventListener('abort', pending.onAbort)
  487. }
  488. broadcast({
  489. type: 'question/resolved', sessionId: pending.sessionId,
  490. questionRpcId: pending.rpcId, outcome,
  491. })
  492. }
  493. const disposeProvider = ctx.userInteraction.registerProvider({
  494. ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
  495. const sessionId = request.agent?.id
  496. if (sessionId === undefined) {
  497. return Promise.reject(new UserInteractionError(
  498. 'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
  499. }
  500. return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
  501. const rpcId = RpcId(randomUUID())
  502. const pending: PendingQuestion = {
  503. rpcId, sessionId, questions: request.questions, resolve, reject,
  504. ...(request.signal === undefined ? {} : { signal: request.signal }),
  505. }
  506. const onAbort = (): void => {
  507. claimQuestion(pending, 'cancelled')
  508. reject(new UserInteractionError(
  509. 'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
  510. }
  511. pending.onAbort = onAbort
  512. pendingQuestions.set(rpcId, pending)
  513. request.signal?.addEventListener('abort', onAbort, { once: true })
  514. const envelope: RpcRequest<MuxFrame> = {
  515. rpcId,
  516. payload: { type: 'question/requested', sessionId, questions: request.questions },
  517. }
  518. for (const queue of muxQueues) queue.push(envelope)
  519. })
  520. },
  521. })
  522. ctx.effect(() => () => {
  523. disposeProvider()
  524. for (const pending of [...pendingQuestions.values()]) {
  525. claimQuestion(pending, 'cancelled')
  526. pending.reject(new UserInteractionError(
  527. 'web user-interaction provider was disposed', 'ASK_ABORTED'))
  528. }
  529. }, 'api-proxy: user-interaction provider')
  530. /**
  531. * Gate the cold path on the store: an id absent from it, or naming a legacy
  532. * log without a cwd (pre-release stance: not served, no compatibility), is
  533. * not-found before any resume is attempted. With the gate passed, a later
  534. * resume failure is genuinely internal. No persistence configured skips the
  535. * gate — resume itself then fails loud with its own diagnostic.
  536. */
  537. async function assertServable(sessionId: SessionId): Promise<void> {
  538. const persistence = ctx.get('sessionPersistence')
  539. if (persistence === undefined) return
  540. const meta = (await persistence.list()).find(m => m.id === sessionId)
  541. if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
  542. }
  543. async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
  544. const live = ctx.agents.get(sessionId)
  545. if (live !== undefined) return { agent: live }
  546. let resume = resumes.get(sessionId)
  547. if (resume === undefined) {
  548. resume = (async () => {
  549. try {
  550. await assertServable(sessionId)
  551. const handle = await ctx.agents.resume({
  552. resumeSessionId: sessionId,
  553. agentOptions,
  554. setup: installTarget,
  555. })
  556. return handle.agent
  557. } finally {
  558. resumes.delete(sessionId)
  559. }
  560. })()
  561. resumes.set(sessionId, resume)
  562. }
  563. try {
  564. return { agent: await resume }
  565. } catch (error: unknown) {
  566. if (error instanceof SessionNotFound) {
  567. return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
  568. }
  569. // The internal details slot is contractually {}; the reason rides the message.
  570. return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
  571. }
  572. }
  573. /** Resolve one requested identity to a live agent, creating or resuming it once. */
  574. async function ensureSession(sessionId: SessionId, cwd: string, checkPersistedIdentity: boolean): Promise<Agent> {
  575. let creation = sessionCreations.get(sessionId)
  576. if (creation === undefined) {
  577. creation = (async () => {
  578. const live = ctx.agents.get(sessionId)
  579. if (live !== undefined) return live
  580. const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
  581. const stored = persistence === undefined
  582. ? undefined
  583. : (await persistence.list()).find(header => header.id === sessionId)
  584. if (stored !== undefined) {
  585. if (stored.cwd !== cwd) {
  586. throw new SessionCwdConflict(sessionId, cwd, stored.cwd)
  587. }
  588. return (await ctx.agents.resume({
  589. resumeSessionId: sessionId,
  590. agentOptions,
  591. setup: installTarget,
  592. })).agent
  593. }
  594. try {
  595. await mkdir(cwd, { recursive: true })
  596. } catch (error: unknown) {
  597. throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
  598. }
  599. return (await ctx.agents.create({
  600. sessionId,
  601. agentOptions,
  602. meta: { cwd },
  603. setup: installTarget,
  604. })).agent
  605. })().catch((error: unknown) => {
  606. // Another Host entry path may have published the same identity while
  607. // this operation crossed an asynchronous persistence/filesystem step.
  608. const live = ctx.agents.get(sessionId)
  609. if (live !== undefined) return live
  610. throw error
  611. }).finally(() => {
  612. sessionCreations.delete(sessionId)
  613. })
  614. sessionCreations.set(sessionId, creation)
  615. }
  616. const agent = await creation
  617. if (agent.session.header.cwd !== cwd) {
  618. throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
  619. }
  620. return agent
  621. }
  622. /** Resolve or create one path while holding the Host's workspace-create chain. */
  623. function ensureWorkspace(
  624. path: string,
  625. title: string | undefined,
  626. rejectExistingName = false,
  627. createDirectory = false,
  628. ): Promise<{ workspace: Workspace; created: boolean }> {
  629. const operation = workspaceCreationChain.then(async () => {
  630. if (rejectExistingName && title !== undefined
  631. && ctx.workspace.list().some(workspace => workspace.title === title)) {
  632. throw new WorkspaceNameConflictError(title)
  633. }
  634. if (createDirectory) {
  635. try {
  636. await mkdir(path, { recursive: true })
  637. } catch (error: unknown) {
  638. throw new WorkspaceDirectoryCreationError(
  639. `failed to create workspace directory "${path}": ${String(error)}`,
  640. )
  641. }
  642. }
  643. const existing = await ctx.workspace.resolveByPath(path)
  644. if (existing !== undefined) return { workspace: existing, created: false }
  645. return { workspace: await ctx.workspace.create(path, title), created: true }
  646. })
  647. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  648. return operation
  649. }
  650. return {
  651. sessions: {
  652. // Attached sessions summarize from memory; persisted-but-unattached (cold)
  653. // sessions merge in from the persistence store so history survives restarts.
  654. // Legacy logs without a cwd (pre-project stance) are not served — every
  655. // session now records its project at create time.
  656. async list(request) {
  657. const items = ctx.sessions.list().map((session) => {
  658. const agent = ctx.agents.get(session.id)
  659. return summarize(session, agent?.status === 'running')
  660. })
  661. const attached = new Set(items.map(item => item.sessionId))
  662. const persistence = ctx.get('sessionPersistence')
  663. if (persistence !== undefined) {
  664. const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
  665. items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
  666. }
  667. items.sort((a, b) => b.updatedAt - a.updatedAt)
  668. return ok(request, { items })
  669. },
  670. async create(request) {
  671. const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
  672. let workspace: Workspace | undefined
  673. if (request.payload.workspaceId !== undefined) {
  674. workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
  675. if (workspace === undefined) {
  676. return err(request, {
  677. code: 'workspace-not-found',
  678. message: `workspace "${request.payload.workspaceId}" not found`,
  679. details: { workspaceId: request.payload.workspaceId },
  680. })
  681. }
  682. }
  683. const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
  684. try {
  685. await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined)
  686. } catch (error: unknown) {
  687. if (error instanceof SessionCwdConflict) {
  688. return err(request, {
  689. code: 'session-conflict',
  690. message: error.message,
  691. details: {
  692. sessionId: error.sessionId,
  693. requestedCwd: error.requestedCwd,
  694. ...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
  695. },
  696. })
  697. }
  698. return err(request, {
  699. code: 'internal',
  700. message: `failed to create session "${sessionId}": ${String(error)}`,
  701. details: {},
  702. })
  703. }
  704. if (workspace !== undefined) {
  705. try {
  706. await workspace.attachSession(sessionId)
  707. } catch (error: unknown) {
  708. return err(request, {
  709. code: 'workspace-attach-failed',
  710. message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
  711. details: { sessionId, workspaceId: workspace.id },
  712. })
  713. }
  714. }
  715. return ok(request, { sessionId })
  716. },
  717. async history(request) {
  718. const { sessionId, beforeSeq, maxMessages } = request.payload
  719. const found = await agentFor(sessionId)
  720. if ('error' in found) return err(request, found.error)
  721. const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
  722. // Views are computed against the registry at pagination time; result
  723. // pairing scans within the page only (message-boundary pagination keeps
  724. // a call and its result on one page — a cross-page miss soft-falls).
  725. const entries: HistoryEntry[] = page.events.map((event) => {
  726. const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
  727. return { event, ...view === undefined ? {} : { view } }
  728. })
  729. // Tail page carries the session-level todo projection over the FULL
  730. // log (the page window may not contain the last todo/write; a paged
  731. // client cannot reconstruct session-level state from it).
  732. const todos = beforeSeq === undefined ? backscanTodos(found.agent.session.events) : undefined
  733. const metrics = beforeSeq === undefined
  734. ? metricsProjector.snapshot(found.agent.session)
  735. : undefined
  736. return ok(request, {
  737. events: entries,
  738. hasMore: page.hasMore,
  739. ...todos === undefined ? {} : { todos },
  740. ...metrics === undefined ? {} : { metrics },
  741. })
  742. },
  743. async models(request) {
  744. const { sessionId } = request.payload
  745. const found = await agentFor(sessionId)
  746. if ('error' in found) return err(request, found.error)
  747. const current = targetFor(found.agent).current
  748. const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
  749. try {
  750. const advertised = await ctx.llm.listModels(provider.id)
  751. const models = [...advertised]
  752. if (
  753. provider.id === current.provider
  754. && !models.some(model => model.id === current.model)
  755. ) {
  756. models.push({
  757. provider: provider.id,
  758. id: current.model,
  759. name: current.model,
  760. })
  761. }
  762. const entries = await Promise.all(models.map(async (model) => {
  763. const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
  764. const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
  765. ? undefined
  766. : {
  767. efforts: resolved.reasoning.efforts.map(effort => ({
  768. id: effort.id,
  769. name: effort.name,
  770. ...effort.description === undefined
  771. ? {}
  772. : { description: effort.description },
  773. })),
  774. ...resolved.reasoning.defaultEffort === undefined
  775. ? {}
  776. : { defaultEffort: resolved.reasoning.defaultEffort },
  777. }
  778. return {
  779. id: model.id,
  780. name: model.name,
  781. ...model.description === undefined ? {} : { description: model.description },
  782. ...provider.id === current.provider
  783. && model.id === current.model
  784. && !advertised.some(candidate => candidate.id === current.model)
  785. ? { unlisted: true as const }
  786. : {},
  787. ...reasoning === undefined ? {} : { reasoning },
  788. }
  789. }))
  790. const group: ModelProviderGroup = {
  791. id: provider.id,
  792. name: provider.name,
  793. models: entries,
  794. }
  795. return { kind: 'group' as const, group }
  796. } catch (error: unknown) {
  797. const failure: ModelCatalogFailure = {
  798. id: provider.id,
  799. name: provider.name,
  800. message: error instanceof Error ? error.message : String(error),
  801. }
  802. return { kind: 'failure' as const, failure }
  803. }
  804. }))
  805. const groups = catalog.flatMap(item => item.kind === 'group' ? [item.group] : [])
  806. const failures = catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : [])
  807. return ok(request, {
  808. current: { ...current },
  809. groups: groups.filter(group => group.models.length > 0),
  810. failures,
  811. })
  812. },
  813. async selectModel(request) {
  814. const { sessionId, provider, model, reasoningEffort } = request.payload
  815. const found = await agentFor(sessionId)
  816. if ('error' in found) return err(request, found.error)
  817. try {
  818. const resolved = await ctx.llm.resolveCallConfig({
  819. provider,
  820. model,
  821. ...reasoningEffort === undefined
  822. ? {}
  823. : { reasoningEffort: ReasoningEffortId(reasoningEffort) },
  824. })
  825. const selected: AgentLlmTarget = {
  826. provider: resolved.provider,
  827. model: resolved.model,
  828. ...resolved.reasoningEffort === undefined
  829. ? {}
  830. : { reasoningEffort: resolved.reasoningEffort },
  831. }
  832. targetFor(found.agent).current = selected
  833. return ok(request, { selected: { ...selected } })
  834. } catch (error: unknown) {
  835. return err(request, {
  836. code: 'model-unavailable',
  837. message: error instanceof Error ? error.message : String(error),
  838. details: { provider, model },
  839. })
  840. }
  841. },
  842. async prompt(request) {
  843. const { sessionId, mode, content } = request.payload
  844. const found = await agentFor(sessionId)
  845. if ('error' in found) return err(request, found.error)
  846. const agent = found.agent
  847. // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
  848. const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
  849. try {
  850. if (mode === 'steer') agent.steer({ content, source })
  851. else agent.followup({ content, source })
  852. } catch (error: unknown) {
  853. // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached.
  854. return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
  855. }
  856. return ok(request, { accepted: true as const })
  857. },
  858. cancel(request) {
  859. const { sessionId } = request.payload
  860. const agent = ctx.agents.get(sessionId)
  861. if (agent === undefined) {
  862. return Promise.resolve(err(request, {
  863. code: 'session-not-found',
  864. message: `session "${sessionId}" not found (not attached)`,
  865. details: { sessionId },
  866. }))
  867. }
  868. agent.cancel({ kind: 'user' })
  869. return Promise.resolve(ok(request, { accepted: true as const }))
  870. },
  871. },
  872. workspace: {
  873. list(request) {
  874. return Promise.resolve(ok(request, { items: ctx.workspace.list().map(workspaceView) }))
  875. },
  876. // Exactly one of path/name arrives (schema refine). Existing-folder
  877. // adoption reuses its canonical path; create-by-name rejects a name
  878. // already present in the registry.
  879. async create(request) {
  880. const { payload } = request
  881. let path: string
  882. if (payload.name !== undefined) {
  883. const name = payload.name.trim()
  884. if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) {
  885. return err(request, {
  886. code: 'workspace-invalid-path',
  887. message: `workspace name must be one non-empty path segment, got "${payload.name}"`,
  888. details: { path: payload.name },
  889. })
  890. }
  891. path = join(defaults.workspaceRoot, name)
  892. } else {
  893. path = payload.path as string
  894. }
  895. try {
  896. const name = payload.name?.trim()
  897. const { workspace, created } = await ensureWorkspace(
  898. path,
  899. name,
  900. name !== undefined,
  901. name !== undefined,
  902. )
  903. return ok(request, { workspace: workspaceView(workspace), created })
  904. } catch (error: unknown) {
  905. if (error instanceof WorkspaceNameConflictError) {
  906. return err(request, {
  907. code: 'workspace-name-conflict',
  908. message: error.message,
  909. details: { name: error.workspaceName },
  910. })
  911. }
  912. if (error instanceof WorkspaceDirectoryCreationError) {
  913. return err(request, { code: 'internal', message: error.message, details: {} })
  914. }
  915. // The registry rejects a path that does not resolve to an existing
  916. // directory (realpath ENOENT / not-a-directory) — the business
  917. // error of the typed-path flow, surfaced as a validation failure.
  918. return err(request, {
  919. code: 'workspace-invalid-path',
  920. message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
  921. details: { path },
  922. })
  923. }
  924. },
  925. async rename(request) {
  926. const { payload } = request
  927. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  928. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  929. const title = payload.title.trim()
  930. // Uniqueness AND the same-title no-op both ride the create chain so
  931. // they observe the state left by earlier queued renames — checked
  932. // up front, a queued A→A could report success while an earlier A→B
  933. // still lands afterwards.
  934. const operation = workspaceCreationChain.then(async () => {
  935. if (title === workspace.title) return
  936. if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
  937. throw new WorkspaceNameConflictError(title)
  938. }
  939. await workspace.setTitle(title)
  940. })
  941. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  942. try {
  943. await operation
  944. } catch (error: unknown) {
  945. if (error instanceof WorkspaceNameConflictError) {
  946. return err(request, {
  947. code: 'workspace-name-conflict',
  948. message: error.message,
  949. details: { name: error.workspaceName },
  950. })
  951. }
  952. throw error
  953. }
  954. return ok(request, { workspace: workspaceView(workspace) })
  955. },
  956. async delete(request) {
  957. const { workspaceId } = request.payload
  958. const operation = workspaceCreationChain.then(() =>
  959. ctx.workspace.delete(brandWorkspaceId(workspaceId)))
  960. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  961. if (!await operation) return workspaceNotFound(request, workspaceId)
  962. return ok(request, { deleted: true as const })
  963. },
  964. async insertSessionBefore(request) {
  965. const { payload } = request
  966. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  967. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  968. try {
  969. await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
  970. } catch (error: unknown) {
  971. // Only the entity's unaccounted-id rejection is the business code;
  972. // storage/durability failures propagate as internal errors.
  973. if (!(error instanceof WorkspaceMoveInvalidError)) throw error
  974. return err(request, {
  975. code: 'workspace-move-invalid',
  976. message: error.message,
  977. details: {
  978. workspaceId: payload.workspaceId,
  979. sessionId: payload.sessionId,
  980. ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
  981. },
  982. })
  983. }
  984. return ok(request, { workspace: workspaceView(workspace) })
  985. },
  986. },
  987. host: {
  988. describe(request) {
  989. // TODO(step2): version should read apps/cli's package.json; placeholder for now.
  990. return Promise.resolve(ok(request, {
  991. version: '0.0.1',
  992. // Same source as session.create's fallback: the UI's default project
  993. // must match where an unspecified-cwd session actually lands.
  994. cwd: defaults.cwd,
  995. provider: defaults.provider,
  996. model: defaults.model,
  997. attachedSessions: ctx.agents.list().length,
  998. }))
  999. },
  1000. async pickDirectory(request, signal) {
  1001. try {
  1002. const path = await (defaults.pickDirectory ?? pickNativeDirectory)(signal)
  1003. return ok(request, { path })
  1004. } catch (error: unknown) {
  1005. if (signal.aborted) {
  1006. return err(request, {
  1007. code: 'cancelled',
  1008. message: 'directory picker was aborted',
  1009. details: {},
  1010. })
  1011. }
  1012. return err(request, {
  1013. code: 'internal',
  1014. message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
  1015. details: {},
  1016. })
  1017. }
  1018. },
  1019. async openPath(request, signal) {
  1020. try {
  1021. const open = defaults.openPath
  1022. ?? ((path: string, openSignal: AbortSignal) => openNativePath(path, openSignal))
  1023. await open(request.payload.path, signal)
  1024. return ok(request, { opened: true as const })
  1025. } catch (error: unknown) {
  1026. if (signal.aborted) {
  1027. return err(request, {
  1028. code: 'cancelled',
  1029. message: 'path open was aborted',
  1030. details: {},
  1031. })
  1032. }
  1033. return err(request, {
  1034. code: 'internal',
  1035. message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
  1036. details: {},
  1037. })
  1038. }
  1039. },
  1040. },
  1041. commands: {
  1042. // Both methods address one session's agent (agentFor keeps its
  1043. // resume-on-miss: clients only send a sessionId for a published
  1044. // session, and resume restores an existing entity).
  1045. async list(request) {
  1046. // Missing service = the deployment omitted dsh-commands from its
  1047. // composition, not an empty catalog: fail loud instead of serving [].
  1048. const commands = ctx.get('commands')
  1049. if (commands === undefined) {
  1050. 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: {} })
  1051. }
  1052. const found = await agentFor(request.payload.sessionId)
  1053. if ('error' in found) return err(request, found.error)
  1054. return ok(request, { commands: commands.list(found.agent) })
  1055. },
  1056. async execute(request, signal) {
  1057. const commands = ctx.get('commands')
  1058. if (commands === undefined) {
  1059. 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: {} })
  1060. }
  1061. const { sessionId, line } = request.payload
  1062. const found = await agentFor(sessionId)
  1063. if ('error' in found) return err(request, found.error)
  1064. try {
  1065. const result = await commands.execute(found.agent, line, signal)
  1066. if (result === undefined) return ok(request, { matched: false })
  1067. return ok(request, {
  1068. matched: true,
  1069. result: { kind: result.kind, ...result.text === undefined ? {} : { text: result.text } },
  1070. })
  1071. } catch (error: unknown) {
  1072. if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
  1073. return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
  1074. }
  1075. },
  1076. },
  1077. skills: {
  1078. // Skill lookup never touches the Agent registry: the session address
  1079. // resolves to a canonical cwd from the host-resident session header, so
  1080. // listing skills cannot create or resume an agent as a side effect.
  1081. async list(request) {
  1082. const { sessionId } = request.payload
  1083. const session = ctx.sessions.get(sessionId)
  1084. if (session === undefined) {
  1085. return err(request, {
  1086. code: 'session-not-found',
  1087. message: `session "${sessionId}" not found (not attached)`,
  1088. details: { sessionId },
  1089. })
  1090. }
  1091. if (session.header.cwd === undefined) {
  1092. // Every served session records its project at create time; a
  1093. // cwd-less header is a pre-project legacy log (not served).
  1094. return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
  1095. }
  1096. const cwd = session.header.cwd
  1097. // Same stance as the commands domain: a missing service means the
  1098. // deployment omitted dsh-skill from its composition, not an empty
  1099. // catalog. ctx.get also keeps this handler independent of the gateway
  1100. // plugin's inject list (an undeclared `ctx.skills` property read
  1101. // fails the reflect proxy).
  1102. const skillRegistry = ctx.get('skills')
  1103. if (skillRegistry === undefined) {
  1104. 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: {} })
  1105. }
  1106. try {
  1107. const skills = await skillRegistry.list({ cwd })
  1108. return ok(request, {
  1109. skills: skills.map(skill => ({
  1110. name: skill.name,
  1111. description: skill.description,
  1112. ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
  1113. })),
  1114. })
  1115. } catch (error: unknown) {
  1116. return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
  1117. }
  1118. },
  1119. },
  1120. events: {
  1121. mux(_request, signal) {
  1122. const queue = new FrameQueue<RpcRequest<MuxFrame>>()
  1123. muxQueues.add(queue)
  1124. for (const session of ctx.sessions.list()) {
  1125. subscribeSession(queue, session)
  1126. queue.push(frame({
  1127. type: 'session/metrics',
  1128. sessionId: session.id,
  1129. metrics: metricsProjector.snapshot(session),
  1130. }))
  1131. }
  1132. for (const pending of pendingQuestions.values()) {
  1133. queue.push({
  1134. rpcId: pending.rpcId,
  1135. payload: {
  1136. type: 'question/requested', sessionId: pending.sessionId,
  1137. questions: pending.questions,
  1138. },
  1139. })
  1140. }
  1141. // Queue snapshot baseline (pendingQuestions precedent): frames replayed
  1142. // in arrival order per session; a reconnecting client rebuilds its
  1143. // queue view from these alone.
  1144. for (const [sessionId, entries] of queuedMirror) {
  1145. for (const entry of entries.values()) {
  1146. queue.push(frame({
  1147. type: 'session/queued',
  1148. sessionId,
  1149. content: entry.message.content,
  1150. source: entry.message.source,
  1151. steering: entry.steering,
  1152. }))
  1153. }
  1154. }
  1155. // Per-session open-call table for result-view pairing. Bounded by the
  1156. // per-turn call count: entries clear on turn/end; a table miss (stream
  1157. // opened mid-turn) backscans the session's in-memory events instead.
  1158. const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
  1159. const disposers = [
  1160. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  1161. if (event.type === 'tool/call') {
  1162. const data = event.data as ToolCallData
  1163. try {
  1164. let table = openCalls.get(session.id)
  1165. if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
  1166. table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
  1167. } catch {
  1168. // Unparseable model arguments: leave the table unset; the result view soft-falls.
  1169. }
  1170. } else if (event.type === 'turn/end') {
  1171. openCalls.delete(session.id)
  1172. }
  1173. const view = viewFor(ctx, event, callId =>
  1174. openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
  1175. queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
  1176. if (event.type === 'session/title') {
  1177. // The accepted raw event is already in session.events, so the fold must find it.
  1178. queue.push(frame(titleFrame(session) as SessionTitleFrame))
  1179. }
  1180. }),
  1181. ctx.on('session/created', (session: Session) => {
  1182. subscribeSession(queue, session)
  1183. queue.push(frame({
  1184. type: 'session/metrics',
  1185. sessionId: session.id,
  1186. metrics: metricsProjector.snapshot(session),
  1187. }))
  1188. }),
  1189. ctx.on('session/disposed', (session: Session) => {
  1190. openCalls.delete(session.id)
  1191. }),
  1192. ]
  1193. return queue.iterate(signal, () => {
  1194. muxQueues.delete(queue)
  1195. for (const dispose of disposers) dispose()
  1196. })
  1197. },
  1198. host(_request, signal) {
  1199. const queue = new FrameQueue<RpcRequest<HostFrame>>()
  1200. const committedWorkspaceIds = new Set(
  1201. ctx.workspace.list().map(workspace => String(workspace.id)),
  1202. )
  1203. const disposers = [
  1204. ctx.on('session/created', (session: Session) => {
  1205. queue.push(frame({
  1206. type: 'host/session-added',
  1207. sessionId: session.id,
  1208. // Derived at frame time like summarize(); a just-created session
  1209. // has no events yet, so this is constantly true in practice.
  1210. blank: session.events.length === 0,
  1211. ...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
  1212. // cwd rides the frame so the client list needs no refresh to group the new session.
  1213. ...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
  1214. }))
  1215. }),
  1216. ctx.on('session/disposed', (session: Session) => {
  1217. queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
  1218. }),
  1219. ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
  1220. queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
  1221. }),
  1222. ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: unknown) => {
  1223. queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) }))
  1224. }),
  1225. ctx.on('domain/changed', (change) => {
  1226. if (change.domain !== 'workspace') return
  1227. if (change.table === '') {
  1228. if (change.operation !== 'put') return
  1229. const state = workspaceDomainState.parse(change.value)
  1230. for (const workspaceId of state.workspaceIds) {
  1231. if (committedWorkspaceIds.has(workspaceId)) continue
  1232. const workspace = ctx.workspace.get(workspaceId)
  1233. if (workspace === undefined) {
  1234. throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
  1235. }
  1236. committedWorkspaceIds.add(workspaceId)
  1237. queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
  1238. }
  1239. return
  1240. }
  1241. if (change.table !== 'workspaces') return
  1242. if (change.operation === 'deleted') {
  1243. if (!committedWorkspaceIds.delete(change.key)) return
  1244. queue.push(frame({
  1245. type: 'host/workspace-removed',
  1246. workspaceId: change.key as WorkspaceId,
  1247. }))
  1248. return
  1249. }
  1250. if (!committedWorkspaceIds.has(change.key)) return
  1251. // Existing-entity table writes are complete attach/touch commits.
  1252. // A new entity's first put waits for the global registry write above.
  1253. queue.push(frame({
  1254. type: 'host/workspace-changed',
  1255. workspace: changedWorkspaceView(change.key, change.value),
  1256. }))
  1257. }),
  1258. ctx.on('commands/change', () => {
  1259. queue.push(frame({ type: 'host/commands-changed' }))
  1260. }),
  1261. ]
  1262. return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
  1263. },
  1264. },
  1265. respond(message: ClientResponse): Promise<RpcReceipt> {
  1266. const pending = pendingQuestions.get(message.rpcId)
  1267. if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
  1268. if (!message.result.ok) {
  1269. if (message.result.error.code !== 'cancelled') {
  1270. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1271. }
  1272. claimQuestion(pending, 'cancelled')
  1273. pending.reject(new UserInteractionError(
  1274. 'the user cancelled ask_user_question', 'ASK_CANCELLED'))
  1275. return Promise.resolve({ accepted: true })
  1276. }
  1277. const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
  1278. if (!parsed.success) {
  1279. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1280. }
  1281. const payload: QuestionResponsePayload = {
  1282. sessionId: parsed.data.sessionId,
  1283. answer: {
  1284. answers: parsed.data.answer.answers.map(answer => ({
  1285. id: answer.id,
  1286. selected: answer.selected,
  1287. ...(answer.custom === undefined ? {} : { custom: answer.custom }),
  1288. })),
  1289. },
  1290. }
  1291. if (!matchesQuestions(payload, pending)) {
  1292. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  1293. }
  1294. claimQuestion(pending, 'answered')
  1295. pending.resolve(payload.answer)
  1296. return Promise.resolve({ accepted: true })
  1297. },
  1298. }
  1299. }