api-proxy.ts 86 KB

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