api-proxy.ts 56 KB

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