api-proxy.ts 55 KB

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