api-proxy.ts 63 KB

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