api-proxy.ts 158 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653
  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 { dirname } from 'node:path'
  8. import type { Context } from '@deepseek-ai/cordis'
  9. import { installModelSelection } from '@deepseek-ai/dsh-agent'
  10. import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent'
  11. import type {} from '@deepseek-ai/dsh-agent-presets/types'
  12. import { AttachmentError } from '@deepseek-ai/dsh-attachment'
  13. import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
  14. import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
  15. import { errorChain } from '@deepseek-ai/dsh-llm'
  16. import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
  17. import { isAppendSurfaceEvent, isJsonValue, lastActivityTime } from '@deepseek-ai/dsh-session'
  18. import type { JsonValue, Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
  19. import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  20. import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query'
  21. import { SubagentError } from '@deepseek-ai/dsh-subagent'
  22. import type { SubagentListEntry as CatalogSubagentListEntry } from '@deepseek-ai/dsh-subagent'
  23. import { isUserInvocable } from '@deepseek-ai/dsh-skill'
  24. import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
  25. import {
  26. workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
  27. WorkspaceMoveInvalidError, WorkspaceUnknownSessionError,
  28. } from '@deepseek-ai/dsh-workspace'
  29. // Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
  30. import {
  31. InvalidPresetIdError, PresetExistsError, PresetMountError,
  32. PresetNotWritableError, resolveSessionPreset,
  33. SETTINGS_NAMESPACE as AGENT_PRESET_SETTINGS_NAMESPACE, UnknownPresetError,
  34. } from '@deepseek-ai/dsh-agent-presets'
  35. import type { PresetBearingSession } from '@deepseek-ai/dsh-agent-presets'
  36. import type {} from '@deepseek-ai/dsh-tools'
  37. import type {
  38. ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
  39. ModelCatalogFailure, ModelProviderGroup,
  40. ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
  41. QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
  42. WorkspaceId, WorkspaceView,
  43. } from './api/index.ts'
  44. import {
  45. DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
  46. flushLiveSessionLog,
  47. sessionLogExportDeps,
  48. sessionLogZipFilename,
  49. streamSessionLogZip,
  50. type SessionLogExportReady,
  51. type SessionLogCompressionLevel,
  52. } from './session-export.ts'
  53. import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
  54. import {
  55. SESSION_SEARCH_RESULT_LIMIT,
  56. SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
  57. truncateUnicodeCodePoints,
  58. } from './api/session-search.ts'
  59. // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
  60. import type {} from '@deepseek-ai/dsh-session-projection'
  61. // Type-only: resolves `ctx.get('tasks')` to the background task registry.
  62. import type {} from '@deepseek-ai/dsh-tasks'
  63. import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
  64. // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
  65. import type {} from '@deepseek-ai/dsh-session-projection-cache'
  66. // GoalError narrows domain rejections to their stable codes at the wire boundary.
  67. import { GoalError } from '@deepseek-ai/dsh-goal'
  68. import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
  69. // Type-only edges: resolve `ctx.get('commands')`, the `commands/change` event, and `ctx.get('skills')`.
  70. import type {} from '@deepseek-ai/dsh-commands'
  71. import type {} from '@deepseek-ai/dsh-skill'
  72. // The settings/credentials seams: brand guards run at this wire boundary; the
  73. // service reads stay optional (`ctx.get`) so a composition without either
  74. // provider still serves every other domain.
  75. import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
  76. import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings'
  77. import { credentialRef } from '@deepseek-ai/dsh-credentials'
  78. // Value edge: the rename impl narrows the title service's validation failure; the import also resolves `ctx.get('sessionTitle')`.
  79. import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
  80. import type { CallId } from '@deepseek-ai/dsh-llm/brand'
  81. import type { ScopeKey } from '@deepseek-ai/dsh-scope'
  82. import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-approval'
  83. // Side-effect type import: resolves the `approval/request` waterfall and
  84. // `ctx.get('approval')` without a value dependency on the seam (optional composition).
  85. import type {} from '@deepseek-ai/dsh-user-approval'
  86. import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
  87. import { questionResponsePayloadSchema } from './api/questions.schema.ts'
  88. import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
  89. import { RpcId } from './api/rpc.ts'
  90. import type {
  91. AskUserQuestionAnswer, AskUserQuestionItem, AskUserQuestionRequest,
  92. } from '@deepseek-ai/dsh-user-interaction'
  93. import { UserInteractionError } from '@deepseek-ai/dsh-user-interaction'
  94. import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
  95. import {
  96. ApiRemoteSessionNotFound as SessionNotFound,
  97. ApiRemoteSubagentSessionOwnership as SubagentSessionOwnership,
  98. API_REMOTE_FORWARDED_EVENTS,
  99. apiRemoteSubagentOwnershipError,
  100. createApiRemoteAgentResolver,
  101. hasApiRemoteSubagentOwner,
  102. inspectApiRemoteSession,
  103. } from '@deepseek-ai/dsh-api-remotes'
  104. import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
  105. /** Page size when history is called without maxMessages. */
  106. const DEFAULT_MAX_MESSAGES = 50
  107. /**
  108. * Non-model settings namespaces intentionally served to the Web client. The
  109. * plugin-owned entries (`agent-loop`, `bash`, `web-search-deepseek`) are the
  110. * host-plane sections the plugin configuration page edits; a namespace absent
  111. * here answers `settings-not-exposed` even when its owner registered it, so
  112. * adding a section to that page is a decision made here rather than by the
  113. * registering plugin. Moving that declaration to `settings.register()`, so a
  114. * plugin can expose its own configuration without a change in this package,
  115. * is deferred work.
  116. */
  117. const WEB_SETTINGS_NAMESPACES = [
  118. 'agent-loop', 'bash', 'locale', 'permission', 'ui-conversation', 'ui-theme', 'web-search-deepseek',
  119. ] as const
  120. /** Provider work budget: at most 100 calls and 2,000 inspected hits. */
  121. const SESSION_SEARCH_PROVIDER_CALL_LIMIT = 100
  122. /** Bound cold-log stat fan-out and settle each started batch before cancellation returns. */
  123. const COLD_SUMMARY_BATCH_SIZE = 16
  124. /** Conversation message event types (the pagination counting unit). */
  125. const MESSAGE_TYPES = new Set(['user/message', 'assistant/message'])
  126. /** Decode the browser payload while rejecting non-canonical base64 forms. */
  127. function decodeBase64(data: string): Uint8Array {
  128. const decoded = Buffer.from(data, 'base64')
  129. if (data.length === 0 || decoded.toString('base64') !== data) {
  130. throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64')
  131. }
  132. return new Uint8Array(decoded)
  133. }
  134. /** Validate one prompt as a batch before publishing any durable image object. */
  135. async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise<ContentBlock[]> {
  136. if (content.every(part => part.type === 'text')) {
  137. return content.map(part => ({ type: 'text', text: part.text }))
  138. }
  139. const limits = ctx.attachments.imageLimits
  140. if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) {
  141. throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES')
  142. }
  143. const prepared = content.map(part => part.type === 'text'
  144. ? part
  145. : { part, data: decodeBase64(part.data) })
  146. const images = prepared.filter((part): part is Extract<typeof part, { data: Uint8Array }> => 'data' in part)
  147. const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0)
  148. if (totalBytes > limits.maxMessageImageBytes) {
  149. throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE')
  150. }
  151. for (const image of images) {
  152. await ctx.attachments.validateImage({
  153. data: image.data,
  154. mediaType: image.part.mediaType,
  155. ...image.part.name === undefined ? {} : { name: image.part.name },
  156. })
  157. }
  158. const blocks: ContentBlock[] = []
  159. for (const item of prepared) {
  160. if (!('data' in item)) {
  161. blocks.push({ type: 'text', text: item.text })
  162. continue
  163. }
  164. const attachment = await ctx.attachments.saveImage({
  165. data: item.data,
  166. mediaType: item.part.mediaType,
  167. ...item.part.name === undefined ? {} : { name: item.part.name },
  168. })
  169. blocks.push({ type: 'image', attachment })
  170. }
  171. return blocks
  172. }
  173. /** Search durable content for an image reference, including nested tool results. */
  174. function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
  175. if (!Array.isArray(content)) return undefined
  176. for (const value of content) {
  177. if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
  178. const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
  179. if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
  180. const ref = block.attachment as ImageAttachmentRef
  181. if (match(ref)) return ref
  182. }
  183. if (block.type === 'tool-result') {
  184. const nested = imageBlockIn(block.content, match)
  185. if (nested !== undefined) return nested
  186. }
  187. }
  188. return undefined
  189. }
  190. /** Search every durable event carrier that can own model-visible content. */
  191. function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined {
  192. const data = event.data as {
  193. content?: unknown
  194. message?: { content?: unknown }
  195. inserted?: Array<{ content?: unknown }>
  196. chunk?: { type?: unknown; block?: unknown }
  197. }
  198. const direct = imageBlockIn(data.content, match)
  199. if (direct !== undefined) return direct
  200. if (data.message !== undefined) {
  201. const wrapped = imageBlockIn(data.message.content, match)
  202. if (wrapped !== undefined) return wrapped
  203. }
  204. if (data.inserted !== undefined) {
  205. for (const message of data.inserted) {
  206. const inserted = imageBlockIn(message.content, match)
  207. if (inserted !== undefined) return inserted
  208. }
  209. }
  210. if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') {
  211. return imageBlockIn([data.chunk.block], match)
  212. }
  213. return undefined
  214. }
  215. /** True when the current model-visible surface contains an image. */
  216. function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean {
  217. return messages.some(message => contentHasImage(message.content))
  218. }
  219. /** Resolve the first reference matching one opaque id. */
  220. function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined {
  221. for (const event of events) {
  222. const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId)
  223. if (found !== undefined) return found
  224. }
  225. return undefined
  226. }
  227. /**
  228. * Product settings intentionally exposed beside model-provider namespaces.
  229. *
  230. * The agent-preset namespace carries one field — which preset a session with
  231. * no explicit choice is composed from — and both browser surfaces that offer
  232. * that choice write it through `settings.update`, so it has to cross the
  233. * configuration boundary or the pickers silently fail to persist.
  234. */
  235. const PRODUCT_SETTINGS_NAMESPACES = new Set(['ui-onboarding', AGENT_PRESET_SETTINGS_NAMESPACE])
  236. /** Strict browser-zone profile: UTC or an IANA Area/Location-style identifier. */
  237. const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/
  238. /** Validate and canonicalize one browser-supplied IANA zone at the wire boundary. */
  239. function canonicalClientTimeZone(value: string): string | undefined {
  240. if (value.length === 0 || value.trim() !== value
  241. || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined
  242. try {
  243. const canonical = new Intl.DateTimeFormat('en-US', { timeZone: value })
  244. .resolvedOptions().timeZone
  245. /* v8 ignore next -- Intl returns UTC or a canonical IANA Area/Location for accepted input. */
  246. if (canonical !== 'UTC' && !IANA_TIME_ZONE.test(canonical)) return undefined
  247. return canonical
  248. } catch {
  249. // Intl rejects unsupported zone names; the RPC maps that parser rejection below.
  250. return undefined
  251. }
  252. }
  253. /** Read live abort state across awaits without treating it as synchronously immutable. */
  254. function isAborted(signal: AbortSignal): boolean {
  255. return signal.aborted
  256. }
  257. /**
  258. * Message-boundary pagination: count maxMessages append-origin messages
  259. * backwards from the window tail. Replacement copies never entered the
  260. * conversation a reader sees — they restate a shadowed range for the model
  261. * alone — so they consume no quota; the page stays one contiguous raw range,
  262. * which keeps a compaction's log-only `compact/summary` record on the same page as its
  263. * replacement. The cut is the starting seq of the oldest message group (chunks
  264. * group via sourceEventSeqs — never cut mid-message). The tail page naturally
  265. * includes the in-progress partial.
  266. */
  267. function paginate(
  268. events: readonly SessionEvent[],
  269. beforeSeq: number | undefined,
  270. maxMessages: number,
  271. ): { events: SessionEvent[]; hasMore: boolean } {
  272. const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
  273. let count = 0
  274. let cut = 0
  275. for (let i = window.length - 1; i >= 0; i--) {
  276. const event = window[i] as SessionEvent
  277. if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue
  278. count++
  279. const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
  280. const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
  281. if (count >= maxMessages) {
  282. cut = groupStart
  283. break
  284. }
  285. }
  286. const page = window.filter(event => event.seq >= cut)
  287. return { events: page, hasMore: cut > 0 }
  288. }
  289. /** Wrap an ok result echoing the request's rpcId. */
  290. function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
  291. return { rpcId: request.rpcId, result: { ok: true, value } }
  292. }
  293. /**
  294. * Build the provider/model catalog over every registered route. Shared by the
  295. * session-scoped `session.models` and host-scoped `llm.models`. Catalog
  296. * membership stays advisory: an unlisted session selection remains valid for
  297. * provider dispatch, but is not injected back into the selector after its
  298. * owning catalog stops advertising it. Per-provider failures ride `failures`
  299. * without failing the sound groups; groups that advertise nothing are dropped.
  300. */
  301. async function buildModelCatalog(ctx: Context): Promise<{
  302. groups: ModelProviderGroup[]
  303. failures: ModelCatalogFailure[]
  304. }> {
  305. const catalog = await Promise.all(ctx.llm.listProviders().map(async (provider) => {
  306. try {
  307. const models = await ctx.llm.listModels(provider.id)
  308. const entries = await Promise.all(models.map(async (model) => {
  309. const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id)
  310. const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined
  311. ? undefined
  312. : {
  313. efforts: resolved.reasoning.efforts.map(effort => ({
  314. id: effort.id,
  315. name: effort.name,
  316. ...effort.description === undefined
  317. ? {}
  318. : { description: effort.description },
  319. })),
  320. ...resolved.reasoning.defaultEffort === undefined
  321. ? {}
  322. : { defaultEffort: resolved.reasoning.defaultEffort },
  323. }
  324. return {
  325. id: model.id,
  326. name: model.name,
  327. ...model.description === undefined ? {} : { description: model.description },
  328. ...reasoning === undefined ? {} : { reasoning },
  329. }
  330. }))
  331. const group: ModelProviderGroup = {
  332. id: provider.id,
  333. name: provider.name,
  334. models: entries,
  335. }
  336. return { kind: 'group' as const, group }
  337. } catch (error: unknown) {
  338. const failure: ModelCatalogFailure = {
  339. id: provider.id,
  340. name: provider.name,
  341. message: error instanceof Error ? error.message : String(error),
  342. }
  343. return { kind: 'failure' as const, failure }
  344. }
  345. }))
  346. return {
  347. groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []).filter(group => group.models.length > 0),
  348. failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []),
  349. }
  350. }
  351. /** Wrap an error result echoing the request's rpcId. */
  352. function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
  353. return { rpcId: request.rpcId, result: { ok: false, error } }
  354. }
  355. /**
  356. * The RPC refusal a preset failure becomes, or undefined when the failure is
  357. * about something else.
  358. *
  359. * Both the session-create path and the switch path can be handed the same two
  360. * failures, and a client that has to branch on the code needs them worded the
  361. * same from either.
  362. * @param request - the request being answered.
  363. * @param error - the thrown value.
  364. * @returns the refusal, or undefined when the caller should keep handling.
  365. */
  366. function presetFailure(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> | undefined {
  367. if (error instanceof UnknownPresetError) {
  368. return err(request, {
  369. code: 'agent-preset-not-found',
  370. message: error.message,
  371. details: { agentPreset: error.presetId, available: [...error.available] },
  372. })
  373. }
  374. if (error instanceof PresetMountError) {
  375. return err(request, {
  376. code: 'agent-preset-invalid',
  377. message: error.message,
  378. details: { agentPreset: error.presetId, reason: error.reason },
  379. })
  380. }
  381. return undefined
  382. }
  383. /** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
  384. class FrameQueue<F> {
  385. private buffer: F[] = []
  386. private waiter: (() => void) | undefined
  387. private done = false
  388. push(item: F): void {
  389. if (this.done) return
  390. this.buffer.push(item)
  391. this.waiter?.()
  392. }
  393. end(): void {
  394. this.done = true
  395. this.waiter?.()
  396. }
  397. async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
  398. const onAbort = (): void => { this.end() }
  399. signal.addEventListener('abort', onAbort, { once: true })
  400. try {
  401. while (true) {
  402. while (this.buffer.length > 0) yield this.buffer.shift() as F
  403. if (this.done || signal.aborted) return
  404. await new Promise<void>((resolve) => { this.waiter = resolve })
  405. this.waiter = undefined
  406. }
  407. } finally {
  408. signal.removeEventListener('abort', onAbort)
  409. cleanup()
  410. }
  411. }
  412. }
  413. /**
  414. * Server-side frame mint: pure pushes get a fresh rpcId per frame (answerable
  415. * frames — approval/question requested — mint their stable id in their
  416. * pending registries instead).
  417. */
  418. function frame<F>(payload: F): RpcRequest<F> {
  419. return { rpcId: RpcId(randomUUID()), payload }
  420. }
  421. /**
  422. * Narrow one allowlisted host event's argument list to the JSON values the
  423. * wrapper frame carries. A rejected argument is an allowlist mistake (the
  424. * forwarded path applies no projection), not hostile input, so it throws rather
  425. * than degrading to a lossy frame. The throw surfaces where the forwarding
  426. * listener runs, so the emitter's own listener containment logs it and drops
  427. * that frame — loud in the Host log, not at load or at the emit. Exported for
  428. * the test that owns this decision: every currently allowlisted event has a
  429. * statically JSON-safe payload, so a type-legal `ctx.emit` cannot reach the
  430. * rejection branch.
  431. * @param event - forwarded host event name, named in the failure.
  432. * @param args - the emitter's argument list.
  433. * @returns the same arguments typed as JSON values.
  434. */
  435. export function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] {
  436. for (const [index, arg] of args.entries()) {
  437. if (!isJsonValue(arg)) {
  438. throw new Error(`forwarded host event "${event}" argument ${index} is not lossless JSON data`)
  439. }
  440. }
  441. return args as JsonValue[]
  442. }
  443. /** Queue the subscription baseline frame. */
  444. function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Session): void {
  445. queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
  446. }
  447. /**
  448. * Project registry snapshots onto the wire view, dropping the three internal
  449. * fields {@link TaskView} documents as absent.
  450. */
  451. function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] {
  452. return snapshots.map(task => ({
  453. id: task.id,
  454. kind: task.kind,
  455. label: task.label,
  456. status: task.status,
  457. ...task.detail === undefined ? {} : { detail: task.detail },
  458. startedAt: task.startedAt,
  459. ...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt },
  460. }))
  461. }
  462. /**
  463. * Whether the session's conversation has started: no turn has run yet (a
  464. * turn is one model-loop execution). Standalone plugin events — command
  465. * lifecycle records, plan/mode, titles, goals — never open a turn, so
  466. * running `/plan` or `/goal` on a fresh session keeps it blank
  467. * (list-hidden, reusable).
  468. */
  469. function sessionBlank(session: Session): boolean {
  470. return !session.events.some(event => event.type === 'turn/start')
  471. }
  472. /** Shared Session-header projection for list baselines and creation frames. */
  473. function sessionListFields(header: SessionHeader, events: readonly SessionEvent[] = []): {
  474. parentSessionId?: SessionId
  475. origin?: 'subagent'
  476. cwd?: string
  477. agentPreset?: string
  478. } {
  479. // The preset comes from the log, not the header: a session that switched
  480. // while blank ran its turns under the newer composition, and a picker
  481. // showing the creation-time value would contradict what the model saw.
  482. const agentPreset = resolveSessionPreset({ header, events })
  483. return {
  484. ...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
  485. ...header.origin === undefined ? {} : { origin: header.origin },
  486. ...header.cwd === undefined ? {} : { cwd: header.cwd },
  487. ...agentPreset === undefined ? {} : { agentPreset },
  488. }
  489. }
  490. /** SessionSummary projection for attached (in-memory) sessions. */
  491. function summarize(session: Session, running: boolean): SessionSummary {
  492. return {
  493. sessionId: session.id,
  494. // Excludes end-seed: a resumed-but-untouched session
  495. // must not sort as freshly worked in.
  496. updatedAt: lastActivityTime(session.events) ?? session.header.createdAt,
  497. running,
  498. blank: sessionBlank(session),
  499. ...sessionListFields(session.header, session.events),
  500. }
  501. }
  502. /**
  503. * SessionSummary projection for cold (persisted, unattached) sessions.
  504. * updatedAt is the log file's mtime; backends without a per-session file
  505. * (locate() undefined) fall back to the header's createdAt.
  506. */
  507. async function summarizeCold(
  508. persistence: SessionPersistence,
  509. meta: SessionHeader,
  510. signal?: AbortSignal,
  511. ): Promise<SessionSummary> {
  512. signal?.throwIfAborted()
  513. let updatedAt = meta.createdAt
  514. const location = persistence.locate(meta)
  515. signal?.throwIfAborted()
  516. if (location !== undefined) {
  517. try {
  518. updatedAt = (await stat(location.path)).mtimeMs
  519. } catch {
  520. // The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
  521. }
  522. signal?.throwIfAborted()
  523. }
  524. return {
  525. sessionId: meta.id,
  526. updatedAt,
  527. running: false,
  528. // Lazy persistence keeps never-appended sessions out of list(); reading
  529. // a cold log to check for turns would defeat the index read, so a listed
  530. // cold session is served as not-blank (its log holds its conversation).
  531. blank: false,
  532. // Header-only: reading the log for a blank-window preset switch would
  533. // defeat the same index read, and attaching the session replaces this row
  534. // with `summarize()`, which resolves the switch from the events.
  535. ...sessionListFields(meta),
  536. }
  537. }
  538. /** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
  539. function directoryError(error: unknown): RpcError {
  540. if (error instanceof DirectoryPickerError) {
  541. return { code: error.code, message: error.message, details: { path: error.path } }
  542. }
  543. return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }
  544. }
  545. /** Resolved Agent model and project-directory defaults consumed by the API implementation. */
  546. export interface ApiProxyDefaults {
  547. /**
  548. * The model selection a session starts from when its own log names none. Read on
  549. * every access rather than captured, so a default saved during this process
  550. * reaches the sessions that have not run a turn yet.
  551. */
  552. defaultModelSelection: () => ModelSelection
  553. /**
  554. * Record a selection as the new default. Either absent, or a closure that
  555. * may itself decline — the gateway plugin always passes one, and it no-ops
  556. * when the deployment mounts no settings provider or when the write races
  557. * service teardown. A switch then stays process-local. A rejection is
  558. * reported and swallowed: the switch already applies to its own session,
  559. * and undoing it because storage failed would be the worse outcome.
  560. */
  561. saveDefaultModelSelection?: (selection: ModelSelection) => Promise<void>
  562. /** Default project directory for new sessions whose create request carries no cwd. */
  563. cwd: string
  564. /** Native open-with-default-application; injectable for carrier tests. */
  565. openPath?: (path: string, signal: AbortSignal) => Promise<void>
  566. /** Native text-editor handoff; injectable for settings-document tests. */
  567. openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
  568. /** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
  569. sessionExportCompressionLevel?: SessionLogCompressionLevel
  570. /**
  571. * Whether handing a path to the native opener can work at all — the
  572. * `hasDocument` capability the preset roster reports, and the switch
  573. * between opening a preset directory and answering its path as text.
  574. * Absent, an injected `openPath` counts as openable and everything else
  575. * falls back to platform detection ({@link canOpenNativePath}).
  576. */
  577. canOpenPath?: () => boolean
  578. }
  579. /** The tool/call payload fields the presenter path reads. */
  580. interface ToolCallData { callId: string; name: string; arguments: string }
  581. /**
  582. * One outstanding approval question: the stable server-request id, the frame
  583. * material replayed to late mux subscribers, and the resolver that settles the
  584. * answerer's promise back into `ctx.approval`.
  585. */
  586. interface PendingApproval {
  587. rpcId: RpcId
  588. sessionId: SessionId
  589. approvalId: ApprovalRequestId
  590. toolName: string
  591. callId?: CallId
  592. reason?: string
  593. resolve(outcome: ApprovalOutcome): void
  594. }
  595. /** Project a pending entry into its answerable mux frame (initial push and mux-open replay share it). */
  596. function requestedFrame(pending: PendingApproval): RpcRequest<MuxFrame> {
  597. return {
  598. rpcId: pending.rpcId,
  599. payload: {
  600. type: 'approval/requested',
  601. sessionId: pending.sessionId,
  602. approvalId: pending.approvalId,
  603. toolName: pending.toolName,
  604. ...pending.callId === undefined ? {} : { callId: pending.callId },
  605. ...pending.reason === undefined ? {} : { reason: pending.reason },
  606. },
  607. }
  608. }
  609. /** One host-owned question wait, addressed by the stable server-request id. */
  610. interface PendingQuestion {
  611. rpcId: RpcId
  612. sessionId: SessionId
  613. questions: AskUserQuestionItem[]
  614. resolve: (answer: AskUserQuestionAnswer) => void
  615. reject: (error: UserInteractionError) => void
  616. signal?: AbortSignal
  617. onAbort?: () => void
  618. }
  619. /** Validate one answer batch against the exact question request it resolves. */
  620. function matchesQuestions(payload: QuestionResponsePayload, pending: PendingQuestion): boolean {
  621. if (payload.sessionId !== pending.sessionId) return false
  622. const answers = payload.answer.answers
  623. if (answers.length !== pending.questions.length) return false
  624. return answers.every((answer, index) => {
  625. const question = pending.questions[index] as AskUserQuestionItem
  626. if (answer.id !== question.id) return false
  627. if (new Set(answer.selected).size !== answer.selected.length) return false
  628. const custom = answer.custom?.trim()
  629. if (custom !== undefined && custom === '') return false
  630. if (question.multiSelect !== true) {
  631. if (custom !== undefined && answer.selected.length > 0) return false
  632. if (answer.selected.length > 1) return false
  633. }
  634. const labels = new Set(question.options?.map(option => option.label) ?? [])
  635. return answer.selected.every(label => labels.has(label))
  636. })
  637. }
  638. /**
  639. * Compute the render intent for a tool/call or tool/result event through the
  640. * presenters registered at this moment; every other event type gets none. A
  641. * result's presenter needs its call's parsed args — `argsFor` supplies them
  642. * (live: the per-session call table; history: an in-page backscan), returning
  643. * undefined when the pairing is unavailable (e.g. the call fell off the page),
  644. * which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
  645. * the client's documented default (generic JSON card) covers every miss.
  646. */
  647. function viewFor(
  648. ctx: Context,
  649. event: SessionEvent,
  650. argsFor: (callId: string) => unknown,
  651. // Presenters live with the definitions, and definitions live in the scope
  652. // chain: a preset registers its tools into its standing layer. A live agent
  653. // is a scope whose chain passes through its preset; a cold read passes the
  654. // preset's standing key directly — no agent, no resume. An undefined scope
  655. // sees only the global layer, which is the pre-preset deployment shape.
  656. scope?: ScopeKey,
  657. ): ToolEventView | undefined {
  658. try {
  659. if (event.type === 'tool/call') {
  660. const { name, arguments: raw } = event.data as ToolCallData
  661. const view = ctx.tools.get(name, scope)?.presentCall?.(JSON.parse(raw))
  662. return view === undefined ? undefined : { for: 'call', view }
  663. }
  664. if (event.type === 'tool/result') {
  665. const { message, meta } = event.data
  666. const [result] = message.content
  667. const callId = message.source.callId
  668. const call = argsFor(callId) as { name: string; args: unknown } | undefined
  669. if (call === undefined) return undefined
  670. const view = ctx.tools.get(call.name, scope)?.presentResult?.(call.args, {
  671. content: result.content,
  672. isError: result.isError === true,
  673. ...meta === undefined ? {} : { meta },
  674. })
  675. return view === undefined ? undefined : { for: 'result', view }
  676. }
  677. } catch (error: unknown) {
  678. // A throwing presenter (or unparseable arguments) must not break delivery;
  679. // the event still ships, just without a view.
  680. console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
  681. }
  682. return undefined
  683. }
  684. /**
  685. * Resolve a tool/result's call pairing by scanning a window of events backwards
  686. * for the matching tool/call. Used by the history path (the page is the
  687. * window — a cross-page pairing soft-falls to no view) and by live-path table
  688. * misses after a reconnect-eviction.
  689. */
  690. function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
  691. for (let i = events.length - 1; i >= 0; i--) {
  692. const event = events[i] as SessionEvent
  693. if (event.type !== 'tool/call') continue
  694. const data = event.data as ToolCallData
  695. if (data.callId !== callId) continue
  696. try {
  697. return { name: data.name, args: JSON.parse(data.arguments) }
  698. } catch {
  699. // Unparseable stored arguments: same soft-fall as a live parse failure.
  700. return undefined
  701. }
  702. }
  703. return undefined
  704. }
  705. /** Render one detached history page through the same presenter path as ordinary history. */
  706. function historyPage(
  707. ctx: Context,
  708. events: readonly SessionEvent[],
  709. beforeSeq: number | undefined,
  710. maxMessages: number | undefined,
  711. scope?: ScopeKey,
  712. ): { events: HistoryEntry[]; hasMore: boolean } {
  713. const page = paginate(events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
  714. return {
  715. events: page.events.map((event) => {
  716. const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId), scope)
  717. return { event, ...view === undefined ? {} : { view } }
  718. }),
  719. hasMore: page.hasMore,
  720. }
  721. }
  722. /**
  723. * The projection baseline for one history tail page: the registry's
  724. * watermark-cache snapshot — one fully synchronous read (no await between the
  725. * page slice and this), so all values and `asOfSeq` form a single consistent
  726. * cut and `asOfSeq` equals the window tail event seq. The carrier holds zero
  727. * domain knowledge (each value passed its unit's own schema inside the
  728. * registry). An absent registry means the deployment has no projection seam:
  729. * the whole block is absent and clients treat every key as capability-absent.
  730. */
  731. /**
  732. * Which session a transcript read is served from. An attached session is the
  733. * live object and keeps appending, so its events and projection baseline are
  734. * read together in one synchronous step; a detached one is already a frozen
  735. * inspection.
  736. */
  737. type HistorySource =
  738. | { readonly kind: 'attached'; readonly session: Session }
  739. | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] }
  740. function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
  741. const registry = ctx.get('sessionProjections')
  742. if (registry === undefined) return undefined
  743. return registry.snapshot(session)
  744. }
  745. /**
  746. * The projection baseline of one session.list row, fail-soft: attached
  747. * sessions cut the registry's live watermark cache; cold sessions view the
  748. * persisted projection cache's identity-checked stored rows (zero log loads
  749. * either way — the listing use case the cache exists for). The block shape
  750. * (values + asOfSeq) matches the history tail's, so a client seeds its
  751. * value store under the same higher-seq-wins rule. Any failure — and an
  752. * empty value set — yields an absent block: a listing without projections
  753. * is degraded, never broken.
  754. */
  755. function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session | undefined): SessionProjectionsBlock | undefined {
  756. try {
  757. const block = session !== undefined
  758. ? ctx.get('sessionProjections')?.snapshot(session)
  759. : ctx.get('sessionProjectionCache')?.cachedSnapshot(meta)
  760. return block !== undefined && Object.keys(block.values).length > 0 ? block : undefined
  761. } catch (error) {
  762. ctx.logger.warn(`session.list: projection column for "${meta.id}" failed (serving the row without it): ${String(error)}`)
  763. return undefined
  764. }
  765. }
  766. /** Projection baseline for a detached history tail without Agent activation. */
  767. function detachedProjectionsFor(
  768. ctx: Context,
  769. events: readonly SessionEvent[],
  770. ): SessionProjectionsBlock | undefined {
  771. const registry = ctx.get('sessionProjections')
  772. if (registry === undefined) return undefined
  773. return registry.restore({}, events, 0).snapshot
  774. }
  775. /**
  776. * Best-effort projections for one subagent history page, fail-soft like
  777. * {@link listProjectionsFor}: a registered unit throwing on a corrupt payload
  778. * never blocks transcript reading — the page is served without the block.
  779. * @param ctx - context carrying the logger for the degradation warning.
  780. * @param childSessionId - the child whose page is being decorated.
  781. * @param compute - the arm-specific fold (live watermark or detached restore).
  782. * @returns the projections block, or undefined when the fold failed.
  783. */
  784. function subagentHistoryProjections(
  785. ctx: Context,
  786. childSessionId: SessionId,
  787. compute: () => SessionProjectionsBlock | undefined,
  788. ): SessionProjectionsBlock | undefined {
  789. try {
  790. return compute()
  791. } catch (error) {
  792. ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`)
  793. return undefined
  794. }
  795. }
  796. /** Map continuation admission failures without exposing provider details. */
  797. function subagentPromptError(
  798. request: RpcRequest<{ childSessionId: SessionId }>,
  799. error: unknown,
  800. signal: AbortSignal,
  801. ): RpcResponse<never> {
  802. const childSessionId = request.payload.childSessionId
  803. if (signal.aborted) {
  804. return err(request, { code: 'cancelled', message: 'subagent prompt was cancelled', details: {} })
  805. }
  806. if (error instanceof SubagentError) {
  807. switch (error.code) {
  808. case 'NOT_RESUMABLE':
  809. return err(request, {
  810. code: 'subagent-not-resumable',
  811. message: 'subagent cannot be resumed',
  812. details: { childSessionId },
  813. })
  814. case 'UNAUTHORIZED':
  815. return err(request, {
  816. code: 'subagent-unauthorized',
  817. message: 'subagent does not belong to this parent',
  818. details: { childSessionId },
  819. })
  820. case 'DRAINING':
  821. case 'ACTIVATION_CLOSING':
  822. case 'CONTINUATION_UNAVAILABLE':
  823. case 'PERSISTENCE_UNAVAILABLE':
  824. return err(request, {
  825. code: 'subagent-delivery-unavailable',
  826. message: 'subagent follow-up is temporarily unavailable',
  827. details: { childSessionId },
  828. })
  829. default:
  830. break
  831. }
  832. }
  833. return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
  834. }
  835. /** Stable RPC face of the missing projections capability, shared by every catalog read path. */
  836. function projectionsUnavailableError(): RpcError {
  837. return {
  838. code: 'internal',
  839. message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
  840. details: {},
  841. }
  842. }
  843. /** Verify one address and mode against the complete direct-child catalog. */
  844. async function catalogChild(
  845. ctx: Context,
  846. address: SubagentAddress,
  847. signal?: AbortSignal,
  848. ): Promise<{
  849. entry?: Extract<CatalogSubagentListEntry, { kind: 'child' }>
  850. error?: RpcError
  851. }> {
  852. const { parentSessionId, childSessionId, mode } = address
  853. try {
  854. const entries = await ctx.subagents.listChildren(parentSessionId, signal)
  855. const entry = entries.find(candidate => candidate.id === childSessionId)
  856. if (entry === undefined || (entry.kind === 'child' && entry.mode !== mode)) {
  857. return {
  858. error: {
  859. code: 'subagent-not-found',
  860. message: `session "${childSessionId}" is not a ${mode} direct child of "${parentSessionId}"`,
  861. details: { parentSessionId, childSessionId },
  862. },
  863. }
  864. }
  865. if (entry.kind === 'diagnostic') {
  866. return {
  867. error: {
  868. code: 'subagent-catalog-diagnostic',
  869. message: `subagent "${childSessionId}" is ${entry.reason}`,
  870. details: { parentSessionId, childSessionId, reason: entry.reason },
  871. },
  872. }
  873. }
  874. return { entry }
  875. } catch (error: unknown) {
  876. if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
  877. return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
  878. }
  879. if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
  880. return { error: projectionsUnavailableError() }
  881. }
  882. return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
  883. }
  884. }
  885. /**
  886. * The requested preset differs from the one this session already runs.
  887. *
  888. * A session's composition is fixed at creation: its history was produced under
  889. * that preset's tools, so adopting the identity under a different one would
  890. * replay tool calls the rebuilt agent cannot make. Naming a different preset
  891. * is therefore a caller error rather than a switch.
  892. */
  893. /** The roster is absent: this deployment composes no agent presets at all. */
  894. function noRoster(agentPreset: string): RpcError {
  895. return {
  896. code: 'agent-preset-not-found',
  897. message: 'this deployment composes no agent presets',
  898. details: { agentPreset, available: [] },
  899. }
  900. }
  901. /** Map one authoring/roster failure onto its wire code. */
  902. function presetError(agentPreset: string, error: unknown): RpcError {
  903. if (error instanceof UnknownPresetError) {
  904. return {
  905. code: 'agent-preset-not-found',
  906. message: error.message,
  907. details: { agentPreset: error.presetId, available: [...error.available] },
  908. }
  909. }
  910. if (error instanceof PresetNotWritableError) {
  911. return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } }
  912. }
  913. if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
  914. return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
  915. }
  916. return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
  917. }
  918. class AgentPresetConflict extends Error {
  919. constructor(
  920. readonly sessionId: SessionId,
  921. readonly requestedPreset: string,
  922. readonly existingPreset: string | undefined,
  923. ) {
  924. super(
  925. existingPreset === undefined
  926. ? `session "${sessionId}" records no agent preset, so it cannot be adopted under one; `
  927. + 'a deployment composing no roster records none on any session — '
  928. : `session "${sessionId}" already runs agent preset ${JSON.stringify(existingPreset)}; `
  929. + `requested ${JSON.stringify(requestedPreset)}. A session's preset is fixed at creation.`,
  930. )
  931. }
  932. }
  933. /** Requested identity already belongs to a session with another project cwd. */
  934. class SessionCwdConflict extends Error {
  935. constructor(
  936. readonly sessionId: SessionId,
  937. readonly requestedCwd: string,
  938. readonly existingCwd: string | undefined,
  939. ) {
  940. super(
  941. `session "${sessionId}" already exists with cwd ${JSON.stringify(existingCwd)}; `
  942. + `requested ${JSON.stringify(requestedCwd)}`,
  943. )
  944. }
  945. }
  946. /** An explicit Host naming operation would duplicate another Workspace title. */
  947. class WorkspaceNameConflictError extends Error {
  948. constructor(readonly workspaceName: string) {
  949. super(`workspace name '${workspaceName}' is already in use`)
  950. this.name = 'WorkspaceNameConflictError'
  951. }
  952. }
  953. /** Shared workspace-not-found error response of the workspace.* mutation rows. */
  954. function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
  955. return err(request, {
  956. code: 'workspace-not-found',
  957. message: `workspace "${workspaceId}" not found`,
  958. details: { workspaceId },
  959. })
  960. }
  961. /** Wire projection of one workspace entity (the workspace.* value row). */
  962. function workspaceView(workspace: Workspace): WorkspaceView {
  963. return {
  964. workspaceId: workspace.id,
  965. path: workspace.path,
  966. title: workspace.title,
  967. sessionIds: [...workspace.sessionIds],
  968. createdAt: workspace.createdAt,
  969. updatedAt: workspace.updatedAt,
  970. }
  971. }
  972. /** Wire projection of the durable record carried by `domain/changed`. */
  973. function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView {
  974. const record: WorkspaceRecord = workspaceRecord.parse(value)
  975. return {
  976. workspaceId: workspaceId as WorkspaceId,
  977. path: record.path,
  978. title: record.title,
  979. sessionIds: [...record.sessionIds],
  980. createdAt: record.createdAt,
  981. updatedAt: record.updatedAt,
  982. }
  983. }
  984. /**
  985. * Implement ApiProxy over a composed host context.
  986. * @param ctx - a context with the Host spine and Workspace registry mounted.
  987. * @param defaults - Agent model and project-directory defaults.
  988. * @returns the ApiProxy implementation.
  989. */
  990. export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
  991. const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
  992. ?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
  993. /** The seed model each create/resume declares; re-read so it never goes stale. */
  994. const agentOptions = (): AgentOptions => {
  995. const { provider, model } = defaults.defaultModelSelection()
  996. return { provider, model }
  997. }
  998. type WebModelSelectionRef = ModelSelectionRef & { current: ModelSelection }
  999. const selections = new WeakMap<Agent, WebModelSelectionRef>()
  1000. /**
  1001. * Serializes `agentPreset.select` per session. Two concurrent selects both
  1002. * pass the blank check, and the second `unmountPresetFor` then finds nothing
  1003. * to unmount because the first already removed the record — leaving two
  1004. * compositions registered into one agent layer. The client's `busy` flag is
  1005. * not enforcement: the wire is reachable directly.
  1006. */
  1007. const presetSwitches = new Map<SessionId, Promise<unknown>>()
  1008. /** Client-chosen identity creation/resume, deduplicated across concurrent retries. */
  1009. const sessionCreations = new Map<SessionId, Promise<Agent>>()
  1010. /** Serializes path ownership and explicit title checks with Workspace mutations. */
  1011. let workspaceCreationChain = Promise.resolve()
  1012. const pendingQuestions = new Map<RpcId, PendingQuestion>()
  1013. const pendingApprovals = new Map<RpcId, PendingApproval>()
  1014. const muxQueues = new Set<FrameQueue<RpcRequest<MuxFrame>>>()
  1015. const imageAdmissionChains = new WeakMap<Agent, Promise<void>>()
  1016. /** Serialize image admission with model selection for one agent. */
  1017. function serializeImageAdmission<T>(agent: Agent, operation: () => Promise<T>): Promise<T> {
  1018. const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation)
  1019. imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined))
  1020. return result
  1021. }
  1022. /**
  1023. * Install or return the session-local model selection that prompt assembly snapshots.
  1024. *
  1025. * Precedence, resolved on EVERY read rather than seeded once: a selection
  1026. * made in this process, else the session's own latest logged request/header,
  1027. * else the live Agent default. Re-reading keeps the two tiers exact in both
  1028. * directions: a session with a recorded request derives its selection from
  1029. * its log, while a blank session (New Session reuses one rather than minting
  1030. * another) reads any default saved after it was created. There is no create-time
  1031. * per-session override tier on this wire — if one returns (a create-options
  1032. * contribution), it must fold in between the selection and the log.
  1033. */
  1034. function selectionFor(agent: Agent): WebModelSelectionRef {
  1035. const installed = selections.get(agent)
  1036. if (installed !== undefined) return installed
  1037. let picked: ModelSelection | undefined
  1038. const selection: WebModelSelectionRef = {
  1039. get current(): ModelSelection {
  1040. if (picked !== undefined) return picked
  1041. // Incrementally folded by the session, so a per-step read costs
  1042. // O(new events) rather than a rescan.
  1043. const logged = agent.session.requestHeader()?.config
  1044. if (logged === undefined) return defaults.defaultModelSelection()
  1045. return {
  1046. provider: logged.provider,
  1047. model: logged.model,
  1048. ...logged.reasoningEffort === undefined
  1049. ? {}
  1050. : { reasoningEffort: logged.reasoningEffort },
  1051. }
  1052. },
  1053. set current(next: ModelSelection) {
  1054. picked = next
  1055. },
  1056. assembled: undefined,
  1057. }
  1058. installModelSelection(agent.ctx, selection)
  1059. selections.set(agent, selection)
  1060. return selection
  1061. }
  1062. /** Pre-publication setup used by both fresh and resumed Web agents. */
  1063. function installSelection(agentCtx: Context): void {
  1064. const agent = agentCtx.agent
  1065. if (agent === undefined) throw new Error('api-proxy: agent setup has no scoped agent')
  1066. selectionFor(agent)
  1067. }
  1068. /**
  1069. * Reject an attempt to run an existing session under a different preset.
  1070. *
  1071. * A caller that names no preset always adopts the session as it is, so the
  1072. * common paths — reconnecting, resuming, retrying a create — are unaffected.
  1073. * @param sessionId - the identity being adopted.
  1074. * @param requested - the preset the request named, if any.
  1075. * @param existing - the preset the session RUNS, if any; both callers resolve
  1076. * it from the log, which differs from the creation header once a blank
  1077. * session has switched.
  1078. * @throws when both are present and differ.
  1079. */
  1080. function assertPresetUnchanged(
  1081. sessionId: SessionId,
  1082. requested: string | undefined,
  1083. existing: string | undefined,
  1084. ): void {
  1085. if (requested === undefined || requested === existing) return
  1086. throw new AgentPresetConflict(sessionId, requested, existing)
  1087. }
  1088. /**
  1089. * Resolve the preset an agent will be composed from, and the setup that
  1090. * installs it.
  1091. *
  1092. * The id is resolved BEFORE the session exists because the session boundary
  1093. * snapshots `meta` before asynchronous setup begins — a preset discovered
  1094. * during setup could never reach the header. Mounting still happens in
  1095. * setup, where a failure rolls the whole creation back rather than leaving a
  1096. * published session whose capabilities are half-installed.
  1097. *
  1098. * A deployment with no preset roster composes nothing and every session
  1099. * shares the host composition, which is the behavior before presets existed.
  1100. * @param presetId - the requested preset, or `undefined` for the default.
  1101. * @returns the id to record on the header (absent without a roster) and the setup callback.
  1102. * @throws when the roster supplies no such preset.
  1103. */
  1104. async function composeAgent(presetId: string | undefined): Promise<{
  1105. agentPreset?: string
  1106. setup: (agentCtx: Context) => Promise<void>
  1107. }> {
  1108. const presets = ctx.get('agentPresets')
  1109. if (presets === undefined) {
  1110. return {
  1111. setup: (agentCtx: Context) => {
  1112. installSelection(agentCtx)
  1113. return Promise.resolve()
  1114. },
  1115. }
  1116. }
  1117. const resolvedId = (await presets.resolve(presetId)).id
  1118. return {
  1119. agentPreset: resolvedId,
  1120. setup: async (agentCtx: Context) => {
  1121. installSelection(agentCtx)
  1122. await presets.mount(agentCtx, resolvedId)
  1123. },
  1124. }
  1125. }
  1126. const hasSubagentOwner = (
  1127. session: Pick<Session, 'header'>,
  1128. agent: Agent | undefined,
  1129. ): boolean => hasApiRemoteSubagentOwner(ctx, session, agent)
  1130. const subagentOwnershipError = (sessionId: SessionId): RpcError =>
  1131. apiRemoteSubagentOwnershipError(sessionId)
  1132. const inspectServable = (sessionId: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> =>
  1133. inspectApiRemoteSession(ctx, sessionId)
  1134. // Cold resume composes the preset the session recorded, for the same reason
  1135. // `session.create` does: its history was produced under that composition.
  1136. // Every generic entry point — prompt, models, commands — arrives here, so
  1137. // leaving it out meant a session opened after a restart ran on host tools
  1138. // and the deployment persona. Resolved from the LOG, not the header: a
  1139. // session that switched while blank ran its turns under the newer
  1140. // composition, and the header is written once at creation. Reading the
  1141. // header here would silently undo the switch on the next restart and
  1142. // restore that history under the old tool set.
  1143. const agentFor = createApiRemoteAgentResolver(ctx, {
  1144. agentOptions,
  1145. setup: async ({ meta, events }) =>
  1146. (await composeAgent(resolveSessionPreset({ header: meta, events }))).setup,
  1147. })
  1148. /** Send one transient frame to every connected mux consumer. */
  1149. function broadcast(payload: MuxFrame): void {
  1150. const envelope = frame(payload)
  1151. for (const queue of muxQueues) queue.push(envelope)
  1152. }
  1153. // Projection change feed → session/projection push frames. The carrier
  1154. // mints the wire frame (the Service Definition package holds no wire vocabulary); the
  1155. // child activates only when a projection registry is composed, and the
  1156. // subscription unwinds with this gateway's fiber.
  1157. ctx.inject(['sessionProjections'], (projectionCtx) => {
  1158. projectionCtx.sessionProjections.onChanged((session, key, value, seq) => {
  1159. broadcast({ type: 'session/projection', sessionId: session.id, key, value, seq })
  1160. })
  1161. })
  1162. /** Project both durable inbox lists, optionally including the splice currently being emitted. */
  1163. const queueItems = (
  1164. agent: Agent,
  1165. splice?: SessionEventMap['agent/inbox/spliced'],
  1166. ): QueuedInboxItem[] => {
  1167. const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => {
  1168. const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep
  1169. return splice?.target === target
  1170. ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted)
  1171. : messages
  1172. }
  1173. return [
  1174. ...project('next-turn').map(message => ({ id: message.id, placement: 'queued' as const, message })),
  1175. ...project('next-step').map(message => ({
  1176. id: message.id,
  1177. // Only user-origin messages are steering; injected context (approval
  1178. // notices, task completion, attached snapshots) is not a user action
  1179. // and must not render as a pending steering bubble.
  1180. placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const,
  1181. message,
  1182. })),
  1183. ]
  1184. }
  1185. ctx.on('session/event', (session, event) => {
  1186. if (event.type !== 'agent/inbox/spliced') return
  1187. const agent = ctx.agents.get(session.id)
  1188. if (agent?.session !== session) return
  1189. broadcast({ type: 'session/queue', sessionId: session.id, items: queueItems(agent, event.data) })
  1190. })
  1191. /** Remove a wait before settling it: synchronous deletion makes the first claimant win. */
  1192. function claimQuestion(pending: PendingQuestion, outcome: 'answered' | 'cancelled'): void {
  1193. pendingQuestions.delete(pending.rpcId)
  1194. if (pending.signal !== undefined && pending.onAbort !== undefined) {
  1195. pending.signal.removeEventListener('abort', pending.onAbort)
  1196. }
  1197. broadcast({
  1198. type: 'question/resolved', sessionId: pending.sessionId,
  1199. questionRpcId: pending.rpcId, outcome,
  1200. })
  1201. }
  1202. const disposeProvider = ctx.userInteraction.registerProvider({
  1203. ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
  1204. const sessionId = request.agent?.id
  1205. if (sessionId === undefined) {
  1206. return Promise.reject(new UserInteractionError(
  1207. 'web user interaction requires an agent-owned session', 'ASK_MISSING_AGENT'))
  1208. }
  1209. return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
  1210. const rpcId = RpcId(randomUUID())
  1211. const pending: PendingQuestion = {
  1212. rpcId, sessionId, questions: request.questions, resolve, reject,
  1213. ...(request.signal === undefined ? {} : { signal: request.signal }),
  1214. }
  1215. const onAbort = (): void => {
  1216. claimQuestion(pending, 'cancelled')
  1217. reject(new UserInteractionError(
  1218. 'ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
  1219. }
  1220. pending.onAbort = onAbort
  1221. pendingQuestions.set(rpcId, pending)
  1222. request.signal?.addEventListener('abort', onAbort, { once: true })
  1223. const envelope: RpcRequest<MuxFrame> = {
  1224. rpcId,
  1225. payload: { type: 'question/requested', sessionId, questions: request.questions },
  1226. }
  1227. for (const queue of muxQueues) queue.push(envelope)
  1228. })
  1229. },
  1230. })
  1231. ctx.effect(() => () => {
  1232. disposeProvider()
  1233. for (const pending of [...pendingQuestions.values()]) {
  1234. claimQuestion(pending, 'cancelled')
  1235. pending.reject(new UserInteractionError(
  1236. 'web user-interaction provider was disposed', 'ASK_ABORTED'))
  1237. }
  1238. }, 'api-proxy: user-interaction provider')
  1239. // --- Approval pending registry ------------------------------------------
  1240. // The proxy is the approval channel for every agent this host owns: an ask
  1241. // through `ctx.approval` becomes an answerable server-request on the mux
  1242. // stream (stable rpcId), settled by POST /api/respond. The entry survives
  1243. // client disconnects — mux-open replays still-pending requested frames with
  1244. // the same rpcId (the refresh-recovery baseline) — and withdraws on the
  1245. // ask's own abort signal (turn cancel), pushing `cancelled` to subscribers.
  1246. if (ctx.get('approval') !== undefined) {
  1247. // Teardown parity with the question provider above: a gateway disposed
  1248. // while approvals are pending settles every entry as 'cancelled' (the
  1249. // service's fail-closed vocabulary), so no ask promise dangles past the
  1250. // proxy's lifetime and subscribers see the withdrawal.
  1251. ctx.effect(() => () => {
  1252. for (const pending of [...pendingApprovals.values()]) pending.resolve('cancelled')
  1253. }, 'api-proxy: approval registry teardown')
  1254. ctx.on('approval/request', (req, next) => {
  1255. // Dispatch rides a microtask behind the service's own signal check: an
  1256. // abort landing in that window would register the abort listener AFTER
  1257. // the signal fired — never invoked, entry pending forever, zombie frame
  1258. // on every mux replay. Settle synchronously instead of publishing.
  1259. if (req.signal?.aborted === true) return Promise.resolve<ApprovalOutcome>('cancelled')
  1260. // The audit pair `approval/asked` is already appended by the service
  1261. // before dispatch, but dispatch rides a microtask: parallel tool calls
  1262. // can append several asked events before any answerer runs. THIS
  1263. // request's event is therefore the newest asked event that is still
  1264. // undecided, unclaimed by another pending entry, and — when the ask
  1265. // names a call — carries the same callId.
  1266. const events = req.agent.session.events
  1267. const claimed = new Set<ApprovalRequestId>()
  1268. for (const entry of pendingApprovals.values()) claimed.add(entry.approvalId)
  1269. const decided = new Set<ApprovalRequestId>()
  1270. let approvalId: ApprovalRequestId | undefined
  1271. for (let i = events.length - 1; i >= 0; i -= 1) {
  1272. const event = events[i] as SessionEvent
  1273. if (event.type === 'approval/decided') {
  1274. decided.add(event.data.id)
  1275. } else if (event.type === 'approval/asked') {
  1276. if (decided.has(event.data.id) || claimed.has(event.data.id)) continue
  1277. // Symmetric pairing: a callId-bearing ask only takes its own call's
  1278. // record, and a callId-less ask only takes a callId-less record —
  1279. // so neither shape can steal the other's audit id under parallel
  1280. // asks. (Today every producer — the tool executor — passes callId;
  1281. // the callId-less arm guards any future non-tool asker.)
  1282. if ((req.callId ?? null) !== (event.data.callId ?? null)) continue
  1283. approvalId = event.data.id
  1284. break
  1285. }
  1286. }
  1287. // No asked event means the request bypassed the service's audit path —
  1288. // not this channel's question; delegate to the fail-closed default.
  1289. if (approvalId === undefined) return next()
  1290. const id = approvalId
  1291. return new Promise<ApprovalOutcome>((resolve) => {
  1292. const settle = (outcome: ApprovalOutcome): void => {
  1293. /* v8 ignore next 3 -- defensive double-settle guard: respond() routes
  1294. through the pending table (a settled id is not-pending before it can
  1295. re-settle) and the first settle removes the abort listener, so no
  1296. reachable path settles twice; kept against future settle callers. */
  1297. if (!pendingApprovals.delete(pending.rpcId)) return
  1298. req.signal?.removeEventListener('abort', onAbort)
  1299. broadcast({ type: 'approval/resolved', sessionId: pending.sessionId, approvalId: id, outcome })
  1300. // A cancelled ask was already settled by the service's own signal
  1301. // race, which discards this late resolution; resolving is a no-op
  1302. // there and keeps this promise from dangling forever.
  1303. resolve(outcome)
  1304. }
  1305. const onAbort = (): void => { settle('cancelled') }
  1306. const pending: PendingApproval = {
  1307. rpcId: RpcId(randomUUID()),
  1308. sessionId: req.agent.session.id,
  1309. approvalId: id,
  1310. toolName: req.toolName,
  1311. ...req.callId === undefined ? {} : { callId: req.callId },
  1312. ...req.reason === undefined ? {} : { reason: req.reason },
  1313. resolve: settle,
  1314. }
  1315. pendingApprovals.set(pending.rpcId, pending)
  1316. req.signal?.addEventListener('abort', onAbort, { once: true })
  1317. const envelope = requestedFrame(pending)
  1318. for (const queue of muxQueues) queue.push(envelope)
  1319. })
  1320. })
  1321. }
  1322. type SessionReadState = {
  1323. id: SessionId
  1324. header: SessionHeader
  1325. events: SessionEvent[]
  1326. }
  1327. /** Read one stable session prefix without acquiring an Agent owner. */
  1328. async function readSessionState(sessionId: SessionId): Promise<SessionReadState> {
  1329. const attached = ctx.sessions.get(sessionId)
  1330. if (attached !== undefined) {
  1331. return {
  1332. id: attached.id,
  1333. header: attached.header,
  1334. events: [...attached.events],
  1335. }
  1336. }
  1337. const inspected = await inspectServable(sessionId)
  1338. return { id: inspected.meta.id, header: inspected.meta, events: inspected.events }
  1339. }
  1340. /** Resolve the Workspace inherited by a fork without making ordinary loose lineage grouped. */
  1341. async function forkWorkspace(source: Pick<Session, 'id' | 'header'>): Promise<Workspace | undefined> {
  1342. const workspaces = ctx.workspace.list()
  1343. const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id))
  1344. if (direct !== undefined || source.header.origin !== 'subagent') return direct
  1345. const lineage = await ctx.sessionQuery.traceSession(source.id)
  1346. for (const ancestor of lineage.ancestors) {
  1347. const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id))
  1348. if (workspace !== undefined) return workspace
  1349. }
  1350. return undefined
  1351. }
  1352. /**
  1353. * Resolve which session one transcript read is served from, without
  1354. * acquiring an Agent owner. This is the read's only asynchronous step
  1355. * besides ensuring the composition; {@link historyCutOf} takes the cut.
  1356. * @param sessionId - the transcript being read.
  1357. * @returns the attached session, or the inspected detached header and events.
  1358. * @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity.
  1359. */
  1360. async function historySourceFor(sessionId: SessionId): Promise<HistorySource> {
  1361. const attached = ctx.sessions.get(sessionId)
  1362. if (attached !== undefined) return { kind: 'attached', session: attached }
  1363. const inspected = await inspectServable(sessionId)
  1364. return { kind: 'detached', header: inspected.meta, events: inspected.events }
  1365. }
  1366. /**
  1367. * The header and events {@link presenterScopeFor} reads to decide which
  1368. * composition a transcript ran under.
  1369. * @param source - the live or detached session this read is served from.
  1370. * @returns that session's creation header and its events.
  1371. */
  1372. function sourceSession(source: HistorySource): PresetBearingSession {
  1373. if (source.kind === 'detached') return { header: source.header, events: source.events }
  1374. return { header: source.session.header, events: source.session.events }
  1375. }
  1376. /**
  1377. * One transcript cut: the events and the projection baseline that describe
  1378. * the SAME log position.
  1379. *
  1380. * Synchronous, and the two reads sit next to each other, because an attached
  1381. * session keeps appending: an `await` between them would serve events cut at
  1382. * N beside a baseline folded to N+1, which is one response describing two
  1383. * moments. The caller does its awaiting before this call.
  1384. * @param source - the live or detached session this read is served from.
  1385. * @param includeProjections - whether the caller asked for the baseline (a tail page does).
  1386. * @returns the events and, when asked, the baseline for that same position.
  1387. */
  1388. function historyCutOf(
  1389. source: HistorySource,
  1390. includeProjections: boolean,
  1391. ): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
  1392. if (source.kind === 'detached') {
  1393. const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined
  1394. return { events: source.events, ...projections === undefined ? {} : { projections } }
  1395. }
  1396. const events = [...source.session.events]
  1397. const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined
  1398. return { events, ...projections === undefined ? {} : { projections } }
  1399. }
  1400. /**
  1401. * The registry view scope a transcript's presenters resolve in.
  1402. *
  1403. * A live agent is that scope itself (its chain passes through its preset's
  1404. * standing layer). A cold session resolves its preset from the LOG, and the
  1405. * preset's STANDING key serves without resuming anything — ensuring the
  1406. * mount composes plugins but starts no agent, session, or turn. No roster,
  1407. * no recorded preset, or a preset the roster no longer supplies all fall
  1408. * back to the global layer: the transcript still serves, with the generic
  1409. * cards a viewless entry renders.
  1410. *
  1411. * Reading the header alone would render a session that switched while blank
  1412. * through the composition it was CREATED with. Every tool only the newer
  1413. * preset registers resolves to no presenter there, and the transcript
  1414. * silently degrades to generic cards for exactly the calls its history is
  1415. * made of.
  1416. * @param sessionId - the transcript being read.
  1417. * @param session - that session's header and log (attached or inspected).
  1418. * @returns the scope to pass to presenter lookups, or undefined for global.
  1419. */
  1420. async function presenterScopeFor(
  1421. sessionId: SessionId,
  1422. session: PresetBearingSession,
  1423. ): Promise<ScopeKey | undefined> {
  1424. const live = ctx.get('agents')?.get(sessionId)
  1425. if (live !== undefined) return live
  1426. const presets = ctx.get('agentPresets')
  1427. if (presets === undefined) return undefined
  1428. try {
  1429. // An unrecorded preset (a log from before the roster existed) renders
  1430. // through the DEFAULT preset's standing layer: that is the composition
  1431. // an unnamed session composes today, and presenters are pure display,
  1432. // so the worst a mismatch produces is the generic card it had anyway.
  1433. return await presets.standingKeyFor(resolveSessionPreset(session))
  1434. } catch {
  1435. // Swallows only the unknown/unusable-preset rejection from the roster:
  1436. // a deleted or broken preset must degrade this read, never fail it.
  1437. return undefined
  1438. }
  1439. }
  1440. /** Resolve one requested identity to a live agent, creating or resuming it once. */
  1441. async function ensureSession(
  1442. sessionId: SessionId,
  1443. cwd: string,
  1444. checkPersistedIdentity: boolean,
  1445. presetId?: string,
  1446. ): Promise<Agent> {
  1447. let creation = sessionCreations.get(sessionId)
  1448. if (creation === undefined) {
  1449. creation = (async () => {
  1450. const attached = ctx.sessions.get(sessionId)
  1451. const live = ctx.agents.get(sessionId)
  1452. if (attached !== undefined && hasSubagentOwner(attached, live)) {
  1453. throw new SubagentSessionOwnership(sessionId)
  1454. }
  1455. if (live !== undefined) return live
  1456. const persistence = checkPersistedIdentity ? ctx.get('sessionPersistence') : undefined
  1457. const stored = persistence === undefined
  1458. ? undefined
  1459. : (await persistence.list()).find(header => header.id === sessionId)
  1460. if (persistence !== undefined && stored !== undefined) {
  1461. const inspected = await persistence.inspect(sessionId)
  1462. // Ownership first: explicit-id adoption of a session-backed
  1463. // subagent must answer `agent-busy` regardless of the requested
  1464. // cwd (the api/commands.ts contract), not a cwd conflict.
  1465. if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
  1466. throw new SubagentSessionOwnership(sessionId)
  1467. }
  1468. if (inspected.meta.cwd !== cwd) {
  1469. throw new SessionCwdConflict(sessionId, cwd, inspected.meta.cwd)
  1470. }
  1471. // Resolved from the log, not the header: a session that switched
  1472. // while blank ran every turn under the newer composition.
  1473. const storedPreset = resolveSessionPreset({ header: inspected.meta, events: inspected.events })
  1474. assertPresetUnchanged(sessionId, presetId, storedPreset)
  1475. // The stored preset wins over anything the request names: a resumed
  1476. // session's history was produced under that composition, and
  1477. // rebuilding it differently would replay tool calls the model can no
  1478. // longer make.
  1479. return (await ctx.agents.resume({
  1480. resumeSessionId: sessionId,
  1481. agentOptions: agentOptions(),
  1482. setup: (await composeAgent(storedPreset)).setup,
  1483. })).agent
  1484. }
  1485. try {
  1486. await mkdir(cwd, { recursive: true })
  1487. } catch (error: unknown) {
  1488. throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error })
  1489. }
  1490. const composition = await composeAgent(presetId)
  1491. return (await ctx.agents.create({
  1492. sessionId,
  1493. agentOptions: agentOptions(),
  1494. meta: {
  1495. cwd,
  1496. ...composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset },
  1497. },
  1498. setup: composition.setup,
  1499. })).agent
  1500. })().catch((error: unknown) => {
  1501. // Another Host entry path may have published the same identity while
  1502. // this operation crossed an asynchronous persistence/filesystem step.
  1503. const live = ctx.agents.get(sessionId)
  1504. if (live !== undefined) {
  1505. if (hasSubagentOwner(live.session, live)) throw new SubagentSessionOwnership(sessionId)
  1506. return live
  1507. }
  1508. const attached = ctx.sessions.get(sessionId)
  1509. if (attached !== undefined && hasSubagentOwner(attached, undefined)) {
  1510. throw new SubagentSessionOwnership(sessionId)
  1511. }
  1512. throw error
  1513. }).finally(() => {
  1514. sessionCreations.delete(sessionId)
  1515. })
  1516. sessionCreations.set(sessionId, creation)
  1517. }
  1518. const agent = await creation
  1519. if (hasSubagentOwner(agent.session, agent)) throw new SubagentSessionOwnership(sessionId)
  1520. // Beside the cwd check for the same reason, and after the await so it
  1521. // covers every path that yields a live agent — freshly created, adopted
  1522. // live, resumed from disk, or recovered by the concurrent-creation catch.
  1523. assertPresetUnchanged(sessionId, presetId, resolveSessionPreset(agent.session))
  1524. if (agent.session.header.cwd !== cwd) {
  1525. throw new SessionCwdConflict(sessionId, cwd, agent.session.header.cwd)
  1526. }
  1527. return agent
  1528. }
  1529. /** Resolve or create one path while holding the Host's workspace-create chain. */
  1530. function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> {
  1531. const operation = workspaceCreationChain.then(async () => {
  1532. const existing = await ctx.workspace.resolveByPath(path)
  1533. if (existing !== undefined) return { workspace: existing, created: false }
  1534. return { workspace: await ctx.workspace.create(path), created: true }
  1535. })
  1536. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  1537. return operation
  1538. }
  1539. /**
  1540. * Build the session.list baseline shared by listing and search visibility.
  1541. * Attached sessions come from memory; servable cold sessions merge from
  1542. * persistence, and the final order is newest-first.
  1543. */
  1544. async function listVisibleSessionSummaries(signal?: AbortSignal): Promise<SessionSummary[]> {
  1545. signal?.throwIfAborted()
  1546. const items = ctx.sessions.list().map((session) => {
  1547. const agent = ctx.agents.get(session.id)
  1548. const projections = listProjectionsFor(ctx, session.header, session)
  1549. return {
  1550. ...summarize(session, agent?.status === 'running'),
  1551. ...projections === undefined ? {} : { projections },
  1552. }
  1553. })
  1554. signal?.throwIfAborted()
  1555. const attached = new Set(items.map(item => item.sessionId))
  1556. const persistence = ctx.get('sessionPersistence')
  1557. if (persistence !== undefined) {
  1558. const cold = (await persistence.list(signal))
  1559. .filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
  1560. signal?.throwIfAborted()
  1561. for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) {
  1562. signal?.throwIfAborted()
  1563. const batch = cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE)
  1564. const settled = await Promise.allSettled(
  1565. batch.map(async (meta) => {
  1566. // Cold rows read the persisted projection cache only — never a
  1567. // log load; a session without a cache row simply has no column.
  1568. const projections = listProjectionsFor(ctx, meta, undefined)
  1569. return {
  1570. ...await summarizeCold(persistence, meta, signal),
  1571. ...projections === undefined ? {} : { projections },
  1572. }
  1573. }),
  1574. )
  1575. const summaries: SessionSummary[] = []
  1576. let rejected = false
  1577. let failure: unknown
  1578. for (const result of settled) {
  1579. if (result.status === 'fulfilled') {
  1580. summaries.push(result.value)
  1581. } else if (!rejected) {
  1582. rejected = true
  1583. failure = result.reason
  1584. }
  1585. }
  1586. if (rejected) throw failure
  1587. signal?.throwIfAborted()
  1588. items.push(...summaries)
  1589. }
  1590. }
  1591. items.sort((a, b) => b.updatedAt - a.updatedAt)
  1592. return items
  1593. }
  1594. /**
  1595. * Resolve the goal service THIS agent runs.
  1596. *
  1597. * The service is per session: an agent preset mounts it behind an `isolate`
  1598. * realm, which no host context resolves. Reading it from the root would
  1599. * answer "absent" for a session whose composition mounts it — so the lookup
  1600. * is keyed by the agent, and only a deployment composing it nowhere is
  1601. * genuinely absent.
  1602. */
  1603. function goalServiceFor(agent: Agent): NonNullable<ReturnType<typeof ctx.get<'goals'>>> | { error: RpcError } {
  1604. const presets = ctx.get('agentPresets')
  1605. const goals = presets?.serviceFor(agent, 'goals') ?? ctx.get('goals')
  1606. if (goals === undefined) {
  1607. return { error: { code: 'internal', message: 'goal service is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-goal', details: {} } }
  1608. }
  1609. return goals
  1610. }
  1611. /** Map one goal-domain rejection to the wire error (stable GoalError codes ride in details). */
  1612. function goalError(request: RpcRequest<unknown>, error: unknown): RpcResponse<never> {
  1613. const details = error instanceof GoalError ? { goalCode: error.code } : {}
  1614. return err(request, { code: 'internal', message: String(error), details })
  1615. }
  1616. /** Resolve a session's agent, apply one goal mutation, and acknowledge with the new CAS ref. */
  1617. async function mutateGoal(
  1618. request: RpcRequest<{ sessionId: SessionId }>,
  1619. mutation: (goals: NonNullable<ReturnType<typeof ctx.get<'goals'>>>, agent: Agent) => CoreGoalRef,
  1620. ): Promise<RpcResponse<{ ref: GoalRef }>> {
  1621. const found = await agentFor(request.payload.sessionId)
  1622. if ('error' in found) return err(request, found.error)
  1623. const goals = goalServiceFor(found.agent)
  1624. if ('error' in goals) return err(request, goals.error)
  1625. try {
  1626. const ref = mutation(goals, found.agent)
  1627. return ok(request, { ref: { id: ref.id, revision: ref.revision } })
  1628. } catch (error: unknown) {
  1629. return goalError(request, error)
  1630. }
  1631. }
  1632. /**
  1633. * Whether an adapter currently serves this provider, and therefore whether
  1634. * a session selecting it can start a turn. Catalog membership cannot answer
  1635. * it: an adapter may serve a model its own catalog stopped advertising, so
  1636. * a provider missing from the groups is not the same as one nothing serves.
  1637. * A composition with no llm registry at all cannot judge and says yes —
  1638. * the dispatch it would have refused fails on its own terms.
  1639. */
  1640. function routeServed(provider: string): boolean {
  1641. const llm = ctx.get('llm')
  1642. return llm === undefined || llm.listProviders().some(entry => entry.id === provider)
  1643. }
  1644. /**
  1645. * Resolve the addressed agent for a turn-starting method and refuse when no
  1646. * adapter serves its current selection: a provider nothing serves cannot start a
  1647. * turn, and letting it try spends the whole pre-step path to fail inside
  1648. * the adapter with a message about registration. Refusing here names the
  1649. * model the session is pointed at while the draft is still in the composer.
  1650. * This is `session.prompt`'s enforcement boundary: a client that disables
  1651. * its input is an affordance, and the method stays callable regardless.
  1652. */
  1653. async function turnAgentFor<T>(
  1654. request: RpcRequest<unknown>, sessionId: SessionId,
  1655. ): Promise<{ agent: Agent } | { refused: RpcResponse<T> }> {
  1656. const found = await agentFor(sessionId)
  1657. if ('error' in found) return { refused: err(request, found.error) }
  1658. const agent = found.agent
  1659. const selection = selectionFor(agent).current
  1660. if (!routeServed(selection.provider)) {
  1661. return {
  1662. refused: err(request, {
  1663. code: 'model-unavailable',
  1664. message: `no adapter serves provider "${selection.provider}"; select a model for this session`,
  1665. details: { provider: selection.provider, model: selection.model },
  1666. }),
  1667. }
  1668. }
  1669. return { agent }
  1670. }
  1671. /** Missing-service report shared by the settings domain (skills-domain stance). */
  1672. function settingsAbsent(): RpcError {
  1673. 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: {} }
  1674. }
  1675. /** Open one Host-resolved target and map native failures onto the wire vocabulary. */
  1676. async function openTarget(
  1677. request: RpcRequest<unknown>, path: string, signal: AbortSignal,
  1678. open: (path: string, signal: AbortSignal) => Promise<void>,
  1679. ): Promise<RpcResponse<{ opened: true }>> {
  1680. try {
  1681. await open(path, signal)
  1682. return ok(request, { opened: true as const })
  1683. } catch (error: unknown) {
  1684. if (signal.aborted) {
  1685. return err(request, {
  1686. code: 'cancelled',
  1687. message: 'path open was aborted',
  1688. details: {},
  1689. })
  1690. }
  1691. return err(request, {
  1692. code: 'internal',
  1693. message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
  1694. details: {},
  1695. })
  1696. }
  1697. }
  1698. /** Open one Host-resolved path with its default application. */
  1699. function openPath(
  1700. request: RpcRequest<unknown>, path: string, signal: AbortSignal,
  1701. ): Promise<RpcResponse<{ opened: true }>> {
  1702. const open = defaults.openPath
  1703. ?? ((target: string, openSignal: AbortSignal) => openNativePath(target, openSignal))
  1704. return openTarget(request, path, signal, open)
  1705. }
  1706. /** Open one Host-resolved text document in a native editor. */
  1707. function openTextFile(
  1708. request: RpcRequest<unknown>, path: string, signal: AbortSignal,
  1709. ): Promise<RpcResponse<{ opened: true }>> {
  1710. const open = defaults.openTextFile
  1711. ?? ((target: string, openSignal: AbortSignal) => openNativeTextFile(target, openSignal))
  1712. return openTarget(request, path, signal, open)
  1713. }
  1714. /** Whether this deployment can hand a path to a native opener at all. */
  1715. function canOpenPaths(): boolean {
  1716. if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
  1717. // An injected opener is by definition usable; otherwise ask the platform.
  1718. return defaults.openPath !== undefined || canOpenNativePath()
  1719. }
  1720. /** Missing-service report shared by the credentials domain. */
  1721. function credentialsAbsent(): RpcError {
  1722. 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: {} }
  1723. }
  1724. /** Map one redacted settings descriptor to its wire view. */
  1725. function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
  1726. return {
  1727. ns: String(descriptor.ns),
  1728. schema: descriptor.schema,
  1729. value: descriptor.value,
  1730. ...descriptor.base === undefined ? {} : { base: descriptor.base },
  1731. ...descriptor.user === undefined ? {} : { user: descriptor.user },
  1732. applies: descriptor.applies,
  1733. secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
  1734. revision: descriptor.revision,
  1735. }
  1736. }
  1737. /** Settings namespaces whose changes can invalidate the model catalog. */
  1738. function modelProviderNamespaces(): Set<string> {
  1739. return new Set(ctx.llm.listConfigurableProviders().map(entry => entry.settingsNs))
  1740. }
  1741. /**
  1742. * The settings namespaces this proxy serves: configurable model providers
  1743. * plus the small explicit Web preference and product-owned allowlists. The
  1744. * settings seam remains general; a future registration does not become
  1745. * remotely readable or writable by default.
  1746. */
  1747. function exposedNamespaces(): Set<string> {
  1748. const exposed = modelProviderNamespaces()
  1749. for (const ns of WEB_SETTINGS_NAMESPACES) exposed.add(ns)
  1750. for (const ns of PRODUCT_SETTINGS_NAMESPACES) exposed.add(ns)
  1751. return exposed
  1752. }
  1753. /** Refuse a namespace outside the explicit configuration-client boundary. */
  1754. function notExposed(request: RpcRequest<unknown>, ns: string): RpcResponse<SettingsNamespaceView> {
  1755. return err(request, {
  1756. code: 'settings-not-exposed',
  1757. message: `settings namespace "${ns}" is not exposed to configuration clients`,
  1758. details: { ns },
  1759. })
  1760. }
  1761. /**
  1762. * Run one settings write (merge or wholesale replace) and acknowledge with
  1763. * the namespace's new redacted view. A namespace outside the configuration
  1764. * boundary is refused before the seam is touched; every seam refusal —
  1765. * unknown or invalid namespace, read-only provider, schema validation,
  1766. * storage — becomes one `settings-rejected` carrying the seam's own message.
  1767. */
  1768. async function settingsWrite(
  1769. request: RpcRequest<unknown>,
  1770. ns: string,
  1771. mode: 'update' | 'replace' | 'mutate',
  1772. section: object,
  1773. expectedRevision?: number,
  1774. ): Promise<RpcResponse<SettingsNamespaceView>> {
  1775. const settings = ctx.get('settings')
  1776. if (settings === undefined) return err(request, settingsAbsent())
  1777. const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => {
  1778. // A stale writer is its own outcome, not a malformed request: the client
  1779. // must re-read and re-apply rather than treat the write as invalid.
  1780. if (error instanceof SettingsConflictError) {
  1781. return err(request, {
  1782. code: 'settings-conflict',
  1783. message: error.message,
  1784. details: { ns, expected: error.expected, actual: error.actual },
  1785. })
  1786. }
  1787. return err(request, {
  1788. code: 'settings-rejected',
  1789. message: error instanceof Error ? error.message : String(error),
  1790. details: { ns },
  1791. })
  1792. }
  1793. let branded: SettingsNamespace
  1794. try {
  1795. branded = settingsNamespace(ns)
  1796. } catch (error: unknown) {
  1797. // A malformed name is a client bug, reported as such; it could never be
  1798. // in the exposed set either, so naming the real fault costs no ground.
  1799. return rejected(error)
  1800. }
  1801. if (!exposedNamespaces().has(ns)) return notExposed(request, ns)
  1802. try {
  1803. if (mode === 'update') await settings.update(branded, section, expectedRevision)
  1804. else if (mode === 'replace') await settings.replace(branded, section, expectedRevision)
  1805. else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision)
  1806. } catch (error: unknown) {
  1807. return rejected(error)
  1808. }
  1809. const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
  1810. if (descriptor === undefined) {
  1811. // The write committed but the namespace vanished before this read: only
  1812. // a concurrent registrant disposal can produce it.
  1813. return err(request, { code: 'internal', message: `settings namespace "${ns}" was disposed after the ${mode}`, details: {} })
  1814. }
  1815. return ok(request, namespaceView(descriptor))
  1816. }
  1817. return {
  1818. sessions: {
  1819. // Attached sessions summarize from memory; persisted-but-unattached (cold)
  1820. // sessions merge in from the persistence store so history survives restarts.
  1821. // Logs without a cwd are not served; every session records its project
  1822. // at create time.
  1823. async list(request) {
  1824. return ok(request, { items: await listVisibleSessionSummaries() })
  1825. },
  1826. async search(request, signal) {
  1827. const cancelled = () => err<{ items: SessionSearchItem[]; hasMore: boolean }>(request, {
  1828. code: 'cancelled',
  1829. message: 'session search was aborted',
  1830. details: {},
  1831. })
  1832. if (isAborted(signal)) return cancelled()
  1833. const sessionQuery = ctx.get('sessionQuery')
  1834. if (sessionQuery === undefined) {
  1835. return err(request, {
  1836. code: 'internal',
  1837. message: 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query',
  1838. details: {},
  1839. })
  1840. }
  1841. try {
  1842. const visible = await listVisibleSessionSummaries(signal)
  1843. if (isAborted(signal)) return cancelled()
  1844. if (visible.length === 0) return ok(request, { items: [], hasMore: false })
  1845. const visibleIds = new Set(visible.map(item => item.sessionId))
  1846. const authorized: SessionSearchItem[] = []
  1847. const acceptedIds = new Set<SessionId>()
  1848. const seenCursors = new Set<SessionSearchCursor>()
  1849. let cursor: SessionSearchCursor | undefined
  1850. let providerCallCount = 0
  1851. let providerPageLimit = SESSION_SEARCH_RESULT_LIMIT
  1852. while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) {
  1853. if (isAborted(signal)) return cancelled()
  1854. if (providerCallCount >= SESSION_SEARCH_PROVIDER_CALL_LIMIT) {
  1855. throw new Error(
  1856. `session search provider exceeded the ${SESSION_SEARCH_PROVIDER_CALL_LIMIT}-call work budget`,
  1857. )
  1858. }
  1859. providerCallCount++
  1860. const requestedCursor = cursor
  1861. const requestedPageLimit = providerPageLimit
  1862. let page
  1863. try {
  1864. page = await sessionQuery.searchSessions({
  1865. query: request.payload.query,
  1866. eventFilters: [
  1867. { kind: 'type', values: ['user/message', 'assistant/message'] },
  1868. { kind: 'surface', values: ['current'] },
  1869. ],
  1870. limit: requestedPageLimit,
  1871. ...requestedCursor === undefined ? {} : { cursor: requestedCursor },
  1872. }, { signal })
  1873. } catch (error: unknown) {
  1874. if (isAborted(signal)) return cancelled()
  1875. if (
  1876. requestedCursor === undefined
  1877. && error instanceof SessionQueryError
  1878. && error.code === 'SESSION_QUERY_INVALID_LIMIT'
  1879. && requestedPageLimit > 1
  1880. ) {
  1881. providerPageLimit = Math.max(1, Math.floor(requestedPageLimit / 2))
  1882. continue
  1883. }
  1884. if (
  1885. requestedCursor !== undefined
  1886. && error instanceof SessionQueryError
  1887. && error.code === 'SESSION_QUERY_STALE_CURSOR'
  1888. ) {
  1889. authorized.length = 0
  1890. acceptedIds.clear()
  1891. seenCursors.clear()
  1892. cursor = undefined
  1893. continue
  1894. }
  1895. throw error
  1896. }
  1897. if (isAborted(signal)) return cancelled()
  1898. const providerItemCount = page.items.length
  1899. if (providerItemCount > requestedPageLimit) {
  1900. throw new Error(
  1901. `session search provider returned ${providerItemCount} items; maximum is ${requestedPageLimit}`,
  1902. )
  1903. }
  1904. // Host visibility is the authorization boundary. Consume the
  1905. // provider's globally ranked results rather than binding every
  1906. // visible id into one SQLite statement, then require each hit to
  1907. // name a visible session and a current message from that same
  1908. // session before emitting its snippet.
  1909. for (const hit of page.items) {
  1910. if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue
  1911. if (
  1912. !visibleIds.has(hit.header.id)
  1913. || hit.bestMatch.sessionId !== hit.header.id
  1914. || hit.bestMatch.surface !== 'current'
  1915. || !MESSAGE_TYPES.has(hit.bestMatch.type)
  1916. || acceptedIds.has(hit.header.id)
  1917. ) continue
  1918. const snippet = truncateUnicodeCodePoints(
  1919. hit.bestMatch.snippet,
  1920. SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
  1921. )
  1922. acceptedIds.add(hit.header.id)
  1923. authorized.push({
  1924. sessionId: hit.header.id,
  1925. snippet,
  1926. })
  1927. }
  1928. const nextCursor = page.nextCursor
  1929. if (nextCursor !== undefined) {
  1930. if (seenCursors.has(nextCursor)) {
  1931. throw new Error('session search provider repeated a continuation cursor')
  1932. }
  1933. seenCursors.add(nextCursor)
  1934. }
  1935. if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || nextCursor === undefined) break
  1936. cursor = nextCursor
  1937. }
  1938. return ok(request, {
  1939. items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT),
  1940. hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT,
  1941. })
  1942. } catch (error: unknown) {
  1943. if (
  1944. isAborted(signal)
  1945. || (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')
  1946. ) return cancelled()
  1947. // XXX: Redact provider details before exposing this gateway beyond
  1948. // its current single-user local deployment.
  1949. return err(request, {
  1950. code: 'internal',
  1951. message: `session search failed: ${String(error)}`,
  1952. details: {},
  1953. })
  1954. }
  1955. },
  1956. async create(request) {
  1957. const sessionId = request.payload.sessionId ?? `session-${randomUUID()}` as SessionId
  1958. let workspace: Workspace | undefined
  1959. if (request.payload.workspaceId !== undefined) {
  1960. workspace = ctx.workspace.get(brandWorkspaceId(request.payload.workspaceId))
  1961. if (workspace === undefined) {
  1962. return err(request, {
  1963. code: 'workspace-not-found',
  1964. message: `workspace "${request.payload.workspaceId}" not found`,
  1965. details: { workspaceId: request.payload.workspaceId },
  1966. })
  1967. }
  1968. }
  1969. const cwd = workspace?.path ?? request.payload.cwd ?? defaults.cwd
  1970. const requestedPreset = request.payload.agentPreset
  1971. try {
  1972. await ensureSession(sessionId, cwd, request.payload.sessionId !== undefined, requestedPreset)
  1973. } catch (error: unknown) {
  1974. if (error instanceof AgentPresetConflict) {
  1975. return err(request, {
  1976. code: 'agent-preset-conflict',
  1977. message: error.message,
  1978. details: {
  1979. sessionId: error.sessionId,
  1980. requestedPreset: error.requestedPreset,
  1981. ...error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset },
  1982. },
  1983. })
  1984. }
  1985. const refused = presetFailure(request, error)
  1986. if (refused !== undefined) return refused
  1987. if (error instanceof SessionCwdConflict) {
  1988. return err(request, {
  1989. code: 'session-conflict',
  1990. message: error.message,
  1991. details: {
  1992. sessionId: error.sessionId,
  1993. requestedCwd: error.requestedCwd,
  1994. ...error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd },
  1995. },
  1996. })
  1997. }
  1998. if (error instanceof SubagentSessionOwnership) {
  1999. return err(request, subagentOwnershipError(error.sessionId))
  2000. }
  2001. return err(request, {
  2002. code: 'internal',
  2003. message: `failed to create session "${sessionId}": ${String(error)}`,
  2004. details: {},
  2005. })
  2006. }
  2007. if (workspace !== undefined) {
  2008. try {
  2009. await workspace.attachSession(sessionId)
  2010. } catch (error: unknown) {
  2011. return err(request, {
  2012. code: 'workspace-attach-failed',
  2013. message: `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`,
  2014. details: { sessionId, workspaceId: workspace.id },
  2015. })
  2016. }
  2017. }
  2018. // Echo the composition the session RUNS so a client can label it
  2019. // without waiting for the next list refresh — the create is the commit
  2020. // point that knows it (a caller that named none gets the default).
  2021. // Resolved from the log for the same reason `sessionListFields()` is:
  2022. // this handler also adopts an already-live session, and one that
  2023. // switched while blank runs a preset its header no longer names, so
  2024. // echoing the header would contradict both the adoption this call just
  2025. // allowed and the row `session.list` serves for the same session.
  2026. const created = ctx.agents.get(sessionId)
  2027. const createdPreset = created === undefined ? undefined : resolveSessionPreset(created.session)
  2028. return ok(request, { sessionId, ...createdPreset === undefined ? {} : { agentPreset: createdPreset } })
  2029. },
  2030. async history(request) {
  2031. const { sessionId, beforeSeq, maxMessages } = request.payload
  2032. try {
  2033. const source = await historySourceFor(sessionId)
  2034. // Both awaits happen BEFORE the cut. Ensuring the recorded
  2035. // composition's standing mount is what registers its projection
  2036. // units, so a first cold read would otherwise serve a baseline
  2037. // missing every preset-owned key; and an attached session keeps
  2038. // appending, so awaiting between the two reads would pair events cut
  2039. // at N with a baseline folded to N+1.
  2040. const scope = await presenterScopeFor(sessionId, sourceSession(source))
  2041. const cut = historyCutOf(source, beforeSeq === undefined)
  2042. const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope)
  2043. return ok(request, {
  2044. events: page.events,
  2045. hasMore: page.hasMore,
  2046. ...cut.projections === undefined ? {} : { projections: cut.projections },
  2047. })
  2048. } catch (error: unknown) {
  2049. if (error instanceof SessionNotFound) {
  2050. return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
  2051. }
  2052. return err(request, {
  2053. code: 'internal',
  2054. message: `history unavailable for session "${sessionId}": ${String(error)}`,
  2055. details: {},
  2056. })
  2057. }
  2058. },
  2059. async models(request) {
  2060. const { sessionId } = request.payload
  2061. const found = await agentFor(sessionId)
  2062. if ('error' in found) return err(request, found.error)
  2063. const current = selectionFor(found.agent).current
  2064. const { groups, failures } = await buildModelCatalog(ctx)
  2065. const routable = routeServed(current.provider)
  2066. return ok(request, { current: { ...current }, routable, groups, failures })
  2067. },
  2068. async selectModel(request) {
  2069. const { sessionId, provider, model, reasoningEffort } = request.payload
  2070. const found = await agentFor(sessionId)
  2071. if ('error' in found) return err(request, found.error)
  2072. return serializeImageAdmission(found.agent, async () => {
  2073. try {
  2074. const resolved = await ctx.llm.resolveCallConfig({
  2075. provider,
  2076. model,
  2077. ...reasoningEffort === undefined
  2078. ? {}
  2079. : { reasoningEffort: ReasoningEffortId(reasoningEffort) },
  2080. })
  2081. const pendingImage = [...found.agent.inbox.nextTurn, ...found.agent.inbox.nextStep]
  2082. .some(message => contentHasImage(message.content))
  2083. if (pendingImage || messagesHaveImage(found.agent.session.deriveMessages())) {
  2084. const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model)
  2085. if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) {
  2086. return err(request, {
  2087. code: 'model-unavailable',
  2088. message: `Model "${resolved.model}" does not accept image input, but this session already contains images; select an image-capable model.`,
  2089. details: { provider, model },
  2090. })
  2091. }
  2092. }
  2093. const selected: ModelSelection = {
  2094. provider: resolved.provider,
  2095. model: resolved.model,
  2096. ...resolved.reasoningEffort === undefined
  2097. ? {}
  2098. : { reasoningEffort: resolved.reasoningEffort },
  2099. }
  2100. selectionFor(found.agent).current = selected
  2101. try {
  2102. await defaults.saveDefaultModelSelection?.(selected)
  2103. } catch (error: unknown) {
  2104. ctx.logger.warn(
  2105. `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`,
  2106. )
  2107. }
  2108. return ok(request, { selected: { ...selected } })
  2109. } catch (error: unknown) {
  2110. return err(request, {
  2111. code: 'model-unavailable',
  2112. message: error instanceof Error ? error.message : String(error),
  2113. details: { provider, model },
  2114. })
  2115. }
  2116. })
  2117. },
  2118. async rename(request) {
  2119. const { sessionId, title } = request.payload
  2120. const found = await agentFor(sessionId)
  2121. if ('error' in found) return err(request, found.error)
  2122. const titles = ctx.get('sessionTitle')
  2123. if (titles === undefined) {
  2124. return err(request, { code: 'internal', message: 'renaming is unavailable: this deployment mounts no session-title service', details: {} })
  2125. }
  2126. try {
  2127. const accepted = titles.rename(found.agent.session, title)
  2128. return ok(request, { title: accepted.title, seq: accepted.eventSeq })
  2129. } catch (error: unknown) {
  2130. // Only the input's fault maps to title-invalid (the message is
  2131. // product-user-visible in the rename dialog); liveness and disposal
  2132. // races are deployment trouble, not a bad title.
  2133. if (error instanceof SessionTitleInvalidError) {
  2134. return err(request, {
  2135. code: 'title-invalid',
  2136. message: error.message,
  2137. details: { sessionId },
  2138. })
  2139. }
  2140. return err(request, {
  2141. code: 'internal',
  2142. message: `failed to rename session "${sessionId}": ${String(error)}`,
  2143. details: {},
  2144. })
  2145. }
  2146. },
  2147. async fork(request) {
  2148. const { sessionId, atSeq } = request.payload
  2149. let source: SessionReadState
  2150. try {
  2151. source = await readSessionState(sessionId)
  2152. } catch (error: unknown) {
  2153. if (error instanceof SessionNotFound) {
  2154. return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
  2155. }
  2156. return err(request, {
  2157. code: 'internal',
  2158. message: `fork source unavailable for session "${sessionId}": ${String(error)}`,
  2159. details: {},
  2160. })
  2161. }
  2162. const events = source.events
  2163. // An in-log anchor belongs to the turn containing it and must never
  2164. // clip backward to an earlier completed turn. Omitted and past-end
  2165. // anchors retain the last-completed-turn shortcut.
  2166. const lastSeq = events.at(-1)?.seq ?? -1
  2167. const anchoredBoundary = atSeq === undefined
  2168. ? undefined
  2169. : events.find(e => e.type === 'turn/end' && e.seq >= atSeq)
  2170. const boundary = anchoredBoundary
  2171. ?? (atSeq === undefined || atSeq > lastSeq
  2172. ? events.findLast(e => e.type === 'turn/end')
  2173. : undefined)
  2174. if (boundary === undefined) {
  2175. return err(request, {
  2176. code: 'fork-unavailable',
  2177. message: atSeq !== undefined && atSeq <= lastSeq
  2178. ? `session "${sessionId}" has not completed the turn containing event ${String(atSeq)}`
  2179. : `session "${sessionId}" has no completed turn to fork from`,
  2180. details: { sessionId },
  2181. })
  2182. }
  2183. // Extend the cut through trailing out-of-band appends (session/title,
  2184. // injections) up to the next turn/start: they are standalone events, so
  2185. // the seed stays balanced, and the child inherits a title generated
  2186. // right after the boundary turn.
  2187. let cut = boundary.seq + 1
  2188. while (cut < events.length && events[cut]?.type !== 'turn/start') cut++
  2189. let workspace: Workspace | undefined
  2190. try {
  2191. workspace = await forkWorkspace(source)
  2192. } catch (error: unknown) {
  2193. return err(request, {
  2194. code: 'internal',
  2195. message: `failed to resolve fork workspace for session "${sessionId}": ${String(error)}`,
  2196. details: {},
  2197. })
  2198. }
  2199. const childId = `session-${randomUUID()}` as SessionId
  2200. // The child inherits the parent's composition for the same reason a
  2201. // resumed session keeps its own: the seeded history was produced under
  2202. // those tools, and composing anything else would strand the tool calls
  2203. // it already carries. Now that no model-facing row sits in the host
  2204. // plane, composing nothing would leave the child with no tools at all.
  2205. const forkComposition = await composeAgent(resolveSessionPreset(source))
  2206. try {
  2207. await ctx.agents.create({
  2208. sessionId: childId,
  2209. seed: events.slice(0, cut),
  2210. meta: {
  2211. ...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
  2212. parentSession: source.id,
  2213. seedLength: cut,
  2214. ...forkComposition.agentPreset === undefined
  2215. ? {}
  2216. : { agentPreset: forkComposition.agentPreset },
  2217. },
  2218. agentOptions: agentOptions(),
  2219. setup: forkComposition.setup,
  2220. })
  2221. } catch (error: unknown) {
  2222. return err(request, {
  2223. code: 'internal',
  2224. message: `failed to fork session "${sessionId}": ${String(error)}`,
  2225. details: {},
  2226. })
  2227. }
  2228. // An ordinary source keeps its direct Workspace. A subagent source is
  2229. // not listed there, so its ordinary fork joins the nearest owning
  2230. // ancestor instead. The child is already published if attach fails.
  2231. if (workspace !== undefined) {
  2232. try {
  2233. await workspace.attachSession(childId)
  2234. } catch (error: unknown) {
  2235. return err(request, {
  2236. code: 'workspace-attach-failed',
  2237. message: `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`,
  2238. details: { sessionId: childId, workspaceId: workspace.id },
  2239. })
  2240. }
  2241. }
  2242. return ok(request, { sessionId: childId })
  2243. },
  2244. async prompt(request) {
  2245. const { sessionId, mode, content, clientTimeZone } = request.payload
  2246. const canonicalTimeZone = clientTimeZone === undefined
  2247. ? undefined
  2248. : canonicalClientTimeZone(clientTimeZone)
  2249. if (clientTimeZone !== undefined && canonicalTimeZone === undefined) {
  2250. return err(request, {
  2251. code: 'invalid-time-zone',
  2252. message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
  2253. details: { value: clientTimeZone },
  2254. })
  2255. }
  2256. const resolved = await turnAgentFor<{ accepted: true }>(request, sessionId)
  2257. if ('refused' in resolved) return resolved.refused
  2258. const agent = resolved.agent
  2259. // Request identity and optional browser zone ride the exact durable user message.
  2260. const source: MessageSource = {
  2261. kind: 'user',
  2262. rpcId: request.rpcId,
  2263. ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }),
  2264. }
  2265. const hasImage = content.some(part => part.type === 'image')
  2266. const admit = async (): Promise<RpcResponse<{ accepted: true }>> => {
  2267. try {
  2268. if (hasImage) {
  2269. const current = selectionFor(agent).current
  2270. const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model)
  2271. if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) {
  2272. return err(request, {
  2273. code: 'attachment-error',
  2274. message: `Model "${current.model}" does not support image input.`,
  2275. details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' },
  2276. })
  2277. }
  2278. }
  2279. const durable = await durablePromptContent(ctx, content)
  2280. const message: UserMessage = createUserMessage({ content: durable, source })
  2281. if (mode === 'steer') agent.steer(message)
  2282. else agent.followup(message)
  2283. } catch (error: unknown) {
  2284. if (error instanceof AttachmentError) {
  2285. return err(request, {
  2286. code: 'attachment-error',
  2287. message: error.message,
  2288. details: { reason: error.code },
  2289. })
  2290. }
  2291. return err(request, {
  2292. code: 'agent-busy',
  2293. message: 'prompt rejected',
  2294. details: { reason: String(error) },
  2295. })
  2296. }
  2297. return ok(request, { accepted: true as const })
  2298. }
  2299. return hasImage ? serializeImageAdmission(agent, admit) : admit()
  2300. },
  2301. async attachment(request) {
  2302. const { sessionId, attachmentId } = request.payload
  2303. let state: SessionReadState
  2304. try {
  2305. state = await readSessionState(sessionId)
  2306. } catch (error: unknown) {
  2307. if (error instanceof SessionNotFound) {
  2308. return err(request, {
  2309. code: 'session-not-found',
  2310. message: error.message,
  2311. details: { sessionId },
  2312. })
  2313. }
  2314. return err(request, {
  2315. code: 'internal',
  2316. message: `attachment authorization unavailable for session "${sessionId}": ${String(error)}`,
  2317. details: {},
  2318. })
  2319. }
  2320. const ref = referencedImage(state.events, String(attachmentId))
  2321. if (ref === undefined) {
  2322. return err(request, {
  2323. code: 'attachment-error',
  2324. message: 'Image is not referenced by this session.',
  2325. details: { reason: 'ATTACHMENT_NOT_REFERENCED' },
  2326. })
  2327. }
  2328. try {
  2329. const stored = await ctx.attachments.readImage(ref)
  2330. return ok(request, {
  2331. attachment: stored.ref,
  2332. data: Buffer.from(stored.data).toString('base64'),
  2333. })
  2334. } catch (error: unknown) {
  2335. if (error instanceof AttachmentError) {
  2336. return err(request, {
  2337. code: 'attachment-error',
  2338. message: error.message,
  2339. details: { reason: error.code },
  2340. })
  2341. }
  2342. return err(request, {
  2343. code: 'internal',
  2344. message: 'Unable to read image attachment.',
  2345. details: {},
  2346. })
  2347. }
  2348. },
  2349. updateQueue(request) {
  2350. const { sessionId, itemId, action } = request.payload
  2351. if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) {
  2352. return Promise.resolve(err(request, {
  2353. code: 'attachment-error',
  2354. message: 'queue edits accept text content only',
  2355. details: { reason: 'QUEUE_EDIT_NON_TEXT' },
  2356. }))
  2357. }
  2358. const agent = ctx.agents.get(sessionId)
  2359. if (agent !== undefined && hasSubagentOwner(agent.session, agent)) {
  2360. return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
  2361. }
  2362. if (agent === undefined) {
  2363. return Promise.resolve(err(request, {
  2364. code: 'queue-item-not-found',
  2365. message: 'queued item is no longer pending',
  2366. details: { itemId },
  2367. }))
  2368. }
  2369. const target = agent.inbox.nextTurn.some(message => message.id === itemId)
  2370. ? 'next-turn'
  2371. : agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined
  2372. const message = target === undefined
  2373. ? undefined
  2374. : (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep)
  2375. .find(candidate => candidate.id === itemId)
  2376. if (target === undefined || message === undefined) {
  2377. return Promise.resolve(err(request, {
  2378. code: 'queue-item-not-found',
  2379. message: 'queued item is no longer pending',
  2380. details: { itemId },
  2381. }))
  2382. }
  2383. if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
  2384. return Promise.resolve(err(request, {
  2385. code: 'steer-unavailable',
  2386. message: 'current turn no longer accepts steering',
  2387. details: { itemId },
  2388. }))
  2389. }
  2390. if (action.kind === 'edit') {
  2391. agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content }))
  2392. } else {
  2393. agent.inbox.remove(itemId)
  2394. if (action.kind === 'steer') agent.steer(message)
  2395. }
  2396. return Promise.resolve(ok(request, { accepted: true as const }))
  2397. },
  2398. cancel(request) {
  2399. const { sessionId } = request.payload
  2400. const agent = ctx.agents.get(sessionId)
  2401. if (agent === undefined) {
  2402. return Promise.resolve(err(request, {
  2403. code: 'session-not-found',
  2404. message: `session "${sessionId}" not found (not attached)`,
  2405. details: { sessionId },
  2406. }))
  2407. }
  2408. if (hasSubagentOwner(agent.session, agent)) {
  2409. return Promise.resolve(err(request, subagentOwnershipError(sessionId)))
  2410. }
  2411. agent.cancel({ kind: 'user' }, { keepInbox: true })
  2412. return Promise.resolve(ok(request, { accepted: true as const }))
  2413. },
  2414. },
  2415. subagents: {
  2416. async list(request, signal) {
  2417. try {
  2418. const entries = await ctx.subagents.listChildren(request.payload.parentSessionId, signal)
  2419. return ok(request, {
  2420. entries: entries.map(entry => entry.kind === 'child'
  2421. ? {
  2422. ...entry,
  2423. activity: ctx.agents.get(entry.id)?.status === 'running' ? 'running' : 'inactive',
  2424. }
  2425. : entry),
  2426. parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
  2427. })
  2428. } catch (error: unknown) {
  2429. if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
  2430. return err(request, {
  2431. code: 'cancelled',
  2432. message: 'subagent catalog read was cancelled',
  2433. details: {},
  2434. })
  2435. }
  2436. if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
  2437. return err(request, projectionsUnavailableError())
  2438. }
  2439. return err(request, {
  2440. code: 'internal',
  2441. message: 'subagent catalog read failed',
  2442. details: {},
  2443. })
  2444. }
  2445. },
  2446. async history(request, signal) {
  2447. const {
  2448. parentSessionId, childSessionId, mode, beforeSeq, maxMessages,
  2449. } = request.payload
  2450. const verified = await catalogChild(ctx, {
  2451. parentSessionId, childSessionId, mode,
  2452. }, signal)
  2453. if (verified.error !== undefined) return err(request, verified.error)
  2454. // The generic-history data plane: an attached child serves its
  2455. // in-memory snapshot and the registry's live watermark projections; a
  2456. // cold child is one persistence inspection plus a detached fold.
  2457. let header: SessionHeader
  2458. let events: SessionEvent[]
  2459. let projections: SessionProjectionsBlock | undefined
  2460. const attached = ctx.sessions.get(childSessionId)
  2461. if (attached !== undefined) {
  2462. header = attached.header
  2463. events = [...attached.events]
  2464. projections = beforeSeq === undefined
  2465. ? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached))
  2466. : undefined
  2467. } else {
  2468. try {
  2469. const inspected = await inspectServable(childSessionId)
  2470. header = inspected.meta
  2471. events = inspected.events
  2472. projections = beforeSeq === undefined
  2473. ? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
  2474. : undefined
  2475. } catch (error: unknown) {
  2476. if (signal?.aborted) {
  2477. return err(request, {
  2478. code: 'cancelled',
  2479. message: 'subagent history read was cancelled',
  2480. details: {},
  2481. })
  2482. }
  2483. if (error instanceof SessionNotFound) {
  2484. return err(request, {
  2485. code: 'subagent-not-found',
  2486. message: 'subagent disappeared during history read',
  2487. details: { parentSessionId, childSessionId },
  2488. })
  2489. }
  2490. return err(request, {
  2491. code: 'internal',
  2492. message: 'subagent history read failed',
  2493. details: {},
  2494. })
  2495. }
  2496. }
  2497. if (signal?.aborted) {
  2498. return err(request, {
  2499. code: 'cancelled',
  2500. message: 'subagent history read was cancelled',
  2501. details: {},
  2502. })
  2503. }
  2504. if (header.parentSession !== parentSessionId) {
  2505. return err(request, {
  2506. code: 'subagent-unauthorized',
  2507. message: 'subagent parent changed during history read',
  2508. details: { childSessionId },
  2509. })
  2510. }
  2511. const page = historyPage(ctx, events, beforeSeq, maxMessages)
  2512. return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
  2513. },
  2514. async prompt(request, signal) {
  2515. const { parentSessionId, childSessionId, content, clientTimeZone } = request.payload
  2516. const canonicalTimeZone = clientTimeZone === undefined
  2517. ? undefined
  2518. : canonicalClientTimeZone(clientTimeZone)
  2519. if (clientTimeZone !== undefined && canonicalTimeZone === undefined) {
  2520. return err(request, {
  2521. code: 'invalid-time-zone',
  2522. message: 'clientTimeZone must be UTC or a valid IANA Area/Location name',
  2523. details: { value: clientTimeZone },
  2524. })
  2525. }
  2526. const parent = ctx.agents.get(parentSessionId)
  2527. if (parent === undefined) {
  2528. return err(request, {
  2529. code: 'subagent-parent-unavailable',
  2530. message: `parent session "${parentSessionId}" is not live`,
  2531. details: { parentSessionId },
  2532. })
  2533. }
  2534. const verified = await catalogChild(ctx, {
  2535. parentSessionId, childSessionId, mode: 'continuable',
  2536. }, signal)
  2537. if (verified.error !== undefined) return err(request, verified.error)
  2538. try {
  2539. const messageId = await ctx.subagents.followup(parent, childSessionId, content, {
  2540. source: {
  2541. kind: 'user',
  2542. rpcId: request.rpcId,
  2543. ...(canonicalTimeZone === undefined ? {} : { clientTimeZone: canonicalTimeZone }),
  2544. },
  2545. signal,
  2546. })
  2547. return ok(request, { messageId })
  2548. } catch (error: unknown) {
  2549. return subagentPromptError(request, error, signal)
  2550. }
  2551. },
  2552. // Deliberately no catalog, history, persistence, or parent Agent lookup:
  2553. // the core primitive alone authorizes the durable address against the
  2554. // live Activation, which is what keeps a live child interruptible while
  2555. // its parent Agent is offline. Absent targets are accepted no-ops there.
  2556. interrupt(request) {
  2557. const { parentSessionId, childSessionId } = request.payload
  2558. try {
  2559. ctx.subagents.interrupt(childSessionId, { kind: 'user', parentSessionId })
  2560. } catch (error: unknown) {
  2561. if (error instanceof SubagentError && error.code === 'UNAUTHORIZED') {
  2562. return Promise.resolve(err(request, {
  2563. code: 'subagent-unauthorized',
  2564. message: 'subagent does not belong to this parent',
  2565. details: { childSessionId },
  2566. }))
  2567. }
  2568. return Promise.resolve(err(request, {
  2569. code: 'internal',
  2570. message: 'subagent interrupt failed',
  2571. details: {},
  2572. }))
  2573. }
  2574. return Promise.resolve(ok(request, { accepted: true as const }))
  2575. },
  2576. },
  2577. workspace: {
  2578. list(request) {
  2579. return Promise.resolve(ok(request, {
  2580. items: ctx.workspace.list().map(workspaceView),
  2581. archivedSessionIds: [...ctx.workspace.archivedSessionIds],
  2582. }))
  2583. },
  2584. async create(request) {
  2585. const { path } = request.payload
  2586. try {
  2587. const { workspace, created } = await ensureWorkspace(path)
  2588. return ok(request, { workspace: workspaceView(workspace), created })
  2589. } catch (error: unknown) {
  2590. // The registry rejects a path that does not resolve to an existing
  2591. // directory (realpath ENOENT / not-a-directory) — the business
  2592. // error of the typed-path flow, surfaced as a validation failure.
  2593. return err(request, {
  2594. code: 'workspace-invalid-path',
  2595. message: `cannot create a workspace at "${path}": ${error instanceof Error ? error.message : String(error)}`,
  2596. details: { path },
  2597. })
  2598. }
  2599. },
  2600. async rename(request) {
  2601. const { payload } = request
  2602. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  2603. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  2604. const title = payload.title.trim()
  2605. // Uniqueness AND the same-title no-op both ride the create chain so
  2606. // they observe the state left by earlier queued renames — checked
  2607. // up front, a queued A→A could report success while an earlier A→B
  2608. // still lands afterwards.
  2609. const operation = workspaceCreationChain.then(async () => {
  2610. if (title === workspace.title) return
  2611. if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
  2612. throw new WorkspaceNameConflictError(title)
  2613. }
  2614. await workspace.setTitle(title)
  2615. })
  2616. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  2617. try {
  2618. await operation
  2619. } catch (error: unknown) {
  2620. if (error instanceof WorkspaceNameConflictError) {
  2621. return err(request, {
  2622. code: 'workspace-name-conflict',
  2623. message: error.message,
  2624. details: { name: error.workspaceName },
  2625. })
  2626. }
  2627. throw error
  2628. }
  2629. return ok(request, { workspace: workspaceView(workspace) })
  2630. },
  2631. async delete(request) {
  2632. const { workspaceId } = request.payload
  2633. const operation = workspaceCreationChain.then(() =>
  2634. ctx.workspace.delete(brandWorkspaceId(workspaceId)))
  2635. workspaceCreationChain = operation.then(() => undefined, () => undefined)
  2636. if (!await operation) return workspaceNotFound(request, workspaceId)
  2637. return ok(request, { deleted: true as const })
  2638. },
  2639. async insertSessionBefore(request) {
  2640. const { payload } = request
  2641. const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
  2642. if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
  2643. try {
  2644. await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
  2645. } catch (error: unknown) {
  2646. // Only the entity's unaccounted-id rejection is the business code;
  2647. // storage/durability failures propagate as internal errors.
  2648. if (!(error instanceof WorkspaceMoveInvalidError)) throw error
  2649. return err(request, {
  2650. code: 'workspace-move-invalid',
  2651. message: error.message,
  2652. details: {
  2653. workspaceId: payload.workspaceId,
  2654. sessionId: payload.sessionId,
  2655. ...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
  2656. },
  2657. })
  2658. }
  2659. return ok(request, { workspace: workspaceView(workspace) })
  2660. },
  2661. async archiveSession(request) {
  2662. const { sessionId } = request.payload
  2663. try {
  2664. await ctx.workspace.archiveSession(sessionId)
  2665. } catch (error: unknown) {
  2666. // Only the registry's unknown-session rejection is the business
  2667. // code; storage/durability failures propagate as internal errors.
  2668. if (!(error instanceof WorkspaceUnknownSessionError)) throw error
  2669. return err(request, {
  2670. code: 'session-not-found',
  2671. message: error.message,
  2672. details: { sessionId },
  2673. })
  2674. }
  2675. return ok(request, { archivedSessionIds: [...ctx.workspace.archivedSessionIds] })
  2676. },
  2677. },
  2678. host: {
  2679. describe(request) {
  2680. // TODO: version should read apps/cli's package.json; placeholder for now.
  2681. const selection = defaults.defaultModelSelection()
  2682. return Promise.resolve(ok(request, {
  2683. version: '0.0.1',
  2684. // Same source as session.create's fallback: the UI's default project
  2685. // must match where an unspecified-cwd session actually lands.
  2686. cwd: defaults.cwd,
  2687. // Read live for the same reason: this is what the NEXT session will
  2688. // start from, so a saved default has to be what it reports.
  2689. provider: selection.provider,
  2690. model: selection.model,
  2691. attachedSessions: ctx.agents.list().length,
  2692. }))
  2693. },
  2694. async pickDirectory(request, signal) {
  2695. const capability = ctx.directoryPicker.capability()
  2696. if (capability.kind !== 'native') {
  2697. return err(request, {
  2698. code: 'directory-picker-unavailable',
  2699. message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`,
  2700. details: { capability: capability.kind },
  2701. })
  2702. }
  2703. try {
  2704. const path = await capability.pick(signal)
  2705. return ok(request, { path })
  2706. } catch (error: unknown) {
  2707. if (signal.aborted) {
  2708. return err(request, {
  2709. code: 'cancelled',
  2710. message: 'directory picker was aborted',
  2711. details: {},
  2712. })
  2713. }
  2714. return err(request, {
  2715. code: 'internal',
  2716. message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
  2717. details: {},
  2718. })
  2719. }
  2720. },
  2721. async listDirectory(request, signal) {
  2722. const capability = ctx.directoryPicker.capability()
  2723. if (capability.kind !== 'browse') {
  2724. return err(request, {
  2725. code: 'directory-picker-unavailable',
  2726. message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
  2727. details: { capability: capability.kind },
  2728. })
  2729. }
  2730. try {
  2731. // The carrier's signal follows the caller: a disconnect or timeout
  2732. // stops the backend's directory scan instead of outliving it.
  2733. return ok(request, await capability.list(request.payload.path, signal))
  2734. } catch (error: unknown) {
  2735. // An abort is the caller's own timeout/disconnect, not a server
  2736. // failure — same code pickDirectory and command.execute report.
  2737. if (signal.aborted) {
  2738. return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} })
  2739. }
  2740. return err(request, directoryError(error))
  2741. }
  2742. },
  2743. async createDirectory(request) {
  2744. const capability = ctx.directoryPicker.capability()
  2745. if (capability.kind !== 'browse') {
  2746. return err(request, {
  2747. code: 'directory-picker-unavailable',
  2748. message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
  2749. details: { capability: capability.kind },
  2750. })
  2751. }
  2752. try {
  2753. return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) })
  2754. } catch (error: unknown) {
  2755. return err(request, directoryError(error))
  2756. }
  2757. },
  2758. async openPath(request, signal) {
  2759. return openPath(request, request.payload.path, signal)
  2760. },
  2761. },
  2762. commands: {
  2763. // Both methods address one session's agent. agentFor resumes on miss
  2764. // and fences every subagent-owned identity with `agent-busy`; the
  2765. // api/commands.ts module contract owns that fence's wording, so this
  2766. // comment only notes the routing shape: clients send a sessionId for a
  2767. // published session, and resume restores an existing entity.
  2768. async list(request) {
  2769. // Missing service = the deployment omitted dsh-commands from its
  2770. // composition, not an empty catalog: fail loud instead of serving [].
  2771. const commands = ctx.get('commands')
  2772. if (commands === undefined) {
  2773. 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: {} })
  2774. }
  2775. const found = await agentFor(request.payload.sessionId)
  2776. if ('error' in found) return err(request, found.error)
  2777. return ok(request, { commands: commands.list(found.agent) })
  2778. },
  2779. async execute(request, signal) {
  2780. const commands = ctx.get('commands')
  2781. if (commands === undefined) {
  2782. 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: {} })
  2783. }
  2784. const { sessionId, line } = request.payload
  2785. const found = await agentFor(sessionId)
  2786. if ('error' in found) return err(request, found.error)
  2787. try {
  2788. // Pure admission: the executor's durable command/run + command/done
  2789. // pair (broadcast on the mux stream) carries the outcome; the
  2790. // response reports whether the line resolved to a handler, plus the
  2791. // minted pairing id so the issuing client can correlate its request
  2792. // with the flow node the lifecycle events produce.
  2793. const execution = await commands.execute(found.agent, line, signal)
  2794. return ok(request, execution === undefined
  2795. ? { matched: false }
  2796. : { matched: true, commandId: execution.commandId })
  2797. } catch (error: unknown) {
  2798. if (signal.aborted) return err(request, { code: 'cancelled', message: 'command execution was aborted', details: {} })
  2799. return err(request, { code: 'internal', message: `command failed: ${String(error)}`, details: {} })
  2800. }
  2801. },
  2802. },
  2803. goals: {
  2804. // Mutations only — the read side is the 'goal' session projection.
  2805. // Every verb resolves the session's agent (agentFor: implicit cold
  2806. // resume, the command.* precedent) and acknowledges with the new CAS
  2807. // ref; the committed goal/change event carries the whole value to every
  2808. // client through the projection frames.
  2809. async create(request) {
  2810. const { objective, maxGoalRounds } = request.payload
  2811. return mutateGoal(request, (goals, agent) => goals.create(agent, {
  2812. objective,
  2813. ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
  2814. }))
  2815. },
  2816. async edit(request) {
  2817. const { ref, objective, maxGoalRounds } = request.payload
  2818. return mutateGoal(request, (goals, agent) => goals.edit(agent, ref, {
  2819. ...(objective !== undefined ? { objective } : {}),
  2820. ...(maxGoalRounds !== undefined ? { maxGoalRounds } : {}),
  2821. }))
  2822. },
  2823. async pause(request) {
  2824. return mutateGoal(request, (goals, agent) => goals.pause(agent, request.payload.ref))
  2825. },
  2826. async resume(request) {
  2827. return mutateGoal(request, (goals, agent) => goals.resume(agent, request.payload.ref))
  2828. },
  2829. async complete(request) {
  2830. return mutateGoal(request, (goals, agent) => goals.complete(agent, request.payload.ref))
  2831. },
  2832. async clear(request) {
  2833. const found = await agentFor(request.payload.sessionId)
  2834. if ('error' in found) return err(request, found.error)
  2835. const goals = goalServiceFor(found.agent)
  2836. if ('error' in goals) return err(request, goals.error)
  2837. try {
  2838. goals.clear(found.agent, request.payload.ref)
  2839. return ok(request, { cleared: true as const })
  2840. } catch (error: unknown) {
  2841. return goalError(request, error)
  2842. }
  2843. },
  2844. },
  2845. agentPresets: {
  2846. // A deployment with no roster answers with an empty list rather than an
  2847. // error: composing no presets is a valid deployment, and the browser
  2848. // simply offers no choice.
  2849. async list(request) {
  2850. const presets = ctx.get('agentPresets')
  2851. if (presets === undefined) return ok(request, { presets: [], authorable: false, hasDocument: false })
  2852. const defaultId = presets.defaultId
  2853. return ok(request, {
  2854. presets: (await presets.list()).map(preset => ({
  2855. id: preset.id,
  2856. trust: preset.trust,
  2857. isDefault: preset.id === defaultId,
  2858. ...preset.name === undefined ? {} : { name: preset.name },
  2859. ...preset.description === undefined ? {} : { description: preset.description },
  2860. ...preset.broken === undefined ? {} : { broken: preset.broken },
  2861. })),
  2862. authorable: presets.authorable,
  2863. hasDocument: canOpenPaths(),
  2864. })
  2865. },
  2866. // Recomposing is limited to a blank session because a started
  2867. // conversation's history was produced under its preset's tools; the
  2868. // agent and the session survive, only the composition is swapped.
  2869. async select(request) {
  2870. const { sessionId, agentPreset } = request.payload
  2871. const presets = ctx.get('agentPresets')
  2872. if (presets === undefined) {
  2873. return err(request, {
  2874. code: 'agent-preset-not-found',
  2875. message: 'this deployment composes no agent presets',
  2876. details: { agentPreset, available: [] },
  2877. })
  2878. }
  2879. const found = await agentFor(sessionId)
  2880. if ('error' in found) return err(request, found.error)
  2881. const { agent } = found
  2882. const swap = async (): Promise<RpcResponse<{ agentPreset: string }>> => {
  2883. // Re-read inside the queue: an earlier switch may have run, and a
  2884. // conversation may have started, since this request arrived.
  2885. if (!sessionBlank(agent.session)) {
  2886. return err(request, {
  2887. code: 'agent-preset-locked',
  2888. message: `session "${sessionId}" has already started; its agent preset is fixed`,
  2889. details: { sessionId, agentPreset },
  2890. })
  2891. }
  2892. try {
  2893. const preset = await presets.recompose(agent.ctx, agentPreset)
  2894. // Recorded only after the swap committed: the log states what the
  2895. // agent runs, and a rejected mount leaves the previous composition.
  2896. agent.session.append('agent-preset/selected', { agentPreset: preset.id })
  2897. return ok(request, { agentPreset: preset.id })
  2898. } catch (error: unknown) {
  2899. const refused = presetFailure(request, error)
  2900. if (refused !== undefined) return refused
  2901. return err(request, {
  2902. code: 'internal',
  2903. message: `failed to select agent preset "${agentPreset}": ${String(error)}`,
  2904. details: {},
  2905. })
  2906. }
  2907. }
  2908. const queued = presetSwitches.get(sessionId) ?? Promise.resolve()
  2909. const turn = queued.then(swap)
  2910. presetSwitches.set(sessionId, turn.catch(() => undefined))
  2911. try {
  2912. return await turn
  2913. } finally {
  2914. if (presetSwitches.get(sessionId) === turn) presetSwitches.delete(sessionId)
  2915. }
  2916. },
  2917. // Authoring is privileged (see PRIVILEGED_METHODS in dsh-client-connection):
  2918. // a composition names the plugins a session runs, so reading one is
  2919. // reconnaissance, and copy/remove/openDocument manage the roster and
  2920. // drive the host desktop.
  2921. async read(request) {
  2922. const { agentPreset } = request.payload
  2923. const presets = ctx.get('agentPresets')
  2924. if (presets === undefined) return err(request, noRoster(agentPreset))
  2925. try {
  2926. const preset = await presets.resolve(agentPreset)
  2927. return ok(request, {
  2928. agentPreset: preset.id,
  2929. trust: preset.trust,
  2930. content: await presets.read(preset.id),
  2931. ...preset.name === undefined ? {} : { name: preset.name },
  2932. ...preset.description === undefined ? {} : { description: preset.description },
  2933. })
  2934. } catch (error: unknown) {
  2935. return err(request, presetError(agentPreset, error))
  2936. }
  2937. },
  2938. async copy(request) {
  2939. const { from, agentPreset, name } = request.payload
  2940. const presets = ctx.get('agentPresets')
  2941. if (presets === undefined) return err(request, noRoster(agentPreset))
  2942. try {
  2943. await presets.copy(from, agentPreset, name)
  2944. return ok(request, { agentPreset })
  2945. } catch (error: unknown) {
  2946. return err(request, presetError(agentPreset, error))
  2947. }
  2948. },
  2949. async openDocument(request, signal) {
  2950. const { agentPreset } = request.payload
  2951. const presets = ctx.get('agentPresets')
  2952. if (presets === undefined) return err(request, noRoster(agentPreset))
  2953. try {
  2954. const preset = await presets.resolve(agentPreset)
  2955. // Same line as copy/remove draw: the shipped install is not the
  2956. // user's to manage, and pointing an editor into it invites edits an
  2957. // upgrade will silently overwrite.
  2958. if (preset.trust !== 'user') {
  2959. throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
  2960. }
  2961. // The id resolved against the Host's own roots is what selects the
  2962. // directory — no browser payload carries a path in either direction
  2963. // unless the deployment has no opener to hand it to.
  2964. const directory = dirname(preset.path)
  2965. if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory })
  2966. return await openPath(request, directory, signal)
  2967. } catch (error: unknown) {
  2968. return err(request, presetError(agentPreset, error))
  2969. }
  2970. },
  2971. async remove(request) {
  2972. const { agentPreset } = request.payload
  2973. const presets = ctx.get('agentPresets')
  2974. if (presets === undefined) return err(request, noRoster(agentPreset))
  2975. try {
  2976. await presets.remove(agentPreset)
  2977. return ok(request, {})
  2978. } catch (error: unknown) {
  2979. return err(request, presetError(agentPreset, error))
  2980. }
  2981. },
  2982. },
  2983. skills: {
  2984. // Skill lookup never creates or resumes an agent: the session address
  2985. // resolves to a canonical cwd from the host-resident session header, and
  2986. // the view scope is the live agent or the preset's standing key.
  2987. async list(request) {
  2988. const { sessionId } = request.payload
  2989. const session = ctx.sessions.get(sessionId)
  2990. if (session === undefined) {
  2991. return err(request, {
  2992. code: 'session-not-found',
  2993. message: `session "${sessionId}" not found (not attached)`,
  2994. details: { sessionId },
  2995. })
  2996. }
  2997. if (session.header.cwd === undefined) {
  2998. // Every served session records its project at create time; a
  2999. // cwd-less header is a pre-project legacy log (not served).
  3000. return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
  3001. }
  3002. const cwd = session.header.cwd
  3003. // The host registry is layered per scope and serves every session. A
  3004. // composition may still realm-mount its own registry instead; that
  3005. // instance is invisible to host contexts, so address it through the
  3006. // live agent (`agents.get` keeps the no-side-effect stance above).
  3007. const live = ctx.agents.get(sessionId)
  3008. const presets = ctx.get('agentPresets')
  3009. const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
  3010. // Same stance as the commands domain: a missing service means no
  3011. // composition mounts dsh-skill, not an empty catalog. `ctx.get` also
  3012. // keeps this handler independent of the gateway plugin's inject list
  3013. // (an undeclared `ctx.skills` property read fails the reflect proxy).
  3014. const skillRegistry = scoped ?? ctx.get('skills')
  3015. if (skillRegistry === undefined) {
  3016. return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
  3017. }
  3018. // The scope presenters resolve in — the live agent, else the recorded
  3019. // preset's standing key, else the global layer — so a cold session's
  3020. // '/' popup lists the catalog its composition actually serves.
  3021. const scope = await presenterScopeFor(sessionId, session)
  3022. try {
  3023. const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
  3024. return ok(request, {
  3025. skills: skills.map(skill => ({
  3026. name: skill.name,
  3027. description: skill.description,
  3028. ...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
  3029. modelInvocable: skill.invocation.modelInvocable,
  3030. })),
  3031. })
  3032. } catch (error: unknown) {
  3033. return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
  3034. }
  3035. },
  3036. },
  3037. settings: {
  3038. describe(request) {
  3039. const settings = ctx.get('settings')
  3040. if (settings === undefined) return Promise.resolve(err(request, settingsAbsent()))
  3041. const exposed = exposedNamespaces()
  3042. return Promise.resolve(ok(request, {
  3043. writable: settings.writable,
  3044. hasDocument: settings.documentPath !== undefined,
  3045. namespaces: settings.describe({ redactSecrets: true })
  3046. .filter(descriptor => exposed.has(String(descriptor.ns)))
  3047. .map(namespaceView),
  3048. }))
  3049. },
  3050. async openDocument(request, signal) {
  3051. const settings = ctx.get('settings')
  3052. if (settings === undefined) return err(request, settingsAbsent())
  3053. if (isAborted(signal)) {
  3054. return err(request, {
  3055. code: 'cancelled',
  3056. message: 'settings document open was aborted',
  3057. details: {},
  3058. })
  3059. }
  3060. let path: string | undefined
  3061. try {
  3062. path = await settings.prepareDocument()
  3063. } catch (error: unknown) {
  3064. if (isAborted(signal)) {
  3065. return err(request, {
  3066. code: 'cancelled',
  3067. message: 'settings document preparation was aborted',
  3068. details: {},
  3069. })
  3070. }
  3071. return err(request, {
  3072. code: 'internal',
  3073. message: `settings document preparation failed: ${error instanceof Error ? error.message : String(error)}`,
  3074. details: {},
  3075. })
  3076. }
  3077. if (path === undefined) {
  3078. return err(request, {
  3079. code: 'internal',
  3080. message: 'settings provider has no local document to open',
  3081. details: {},
  3082. })
  3083. }
  3084. if (isAborted(signal)) {
  3085. return err(request, {
  3086. code: 'cancelled',
  3087. message: 'settings document open was aborted',
  3088. details: {},
  3089. })
  3090. }
  3091. return openTextFile(request, path, signal)
  3092. },
  3093. update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision),
  3094. replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision),
  3095. mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision),
  3096. },
  3097. credentials: {
  3098. async describe(request) {
  3099. const credentials = ctx.get('credentials')
  3100. if (credentials === undefined) return err(request, credentialsAbsent())
  3101. const entries = await Promise.all(request.payload.refs.map(async (ref) => {
  3102. const info = await credentials.describe(credentialRef(ref))
  3103. const view: CredentialView = {
  3104. configured: info.configured,
  3105. ...info.source === undefined ? {} : { source: info.source },
  3106. writable: info.writable,
  3107. }
  3108. return [ref, view] as const
  3109. }))
  3110. return ok(request, { credentials: Object.fromEntries(entries) })
  3111. },
  3112. async set(request) {
  3113. const credentials = ctx.get('credentials')
  3114. if (credentials === undefined) return err(request, credentialsAbsent())
  3115. const { ref, value } = request.payload
  3116. try {
  3117. await credentials.set(credentialRef(ref), value)
  3118. } catch (error: unknown) {
  3119. return err(request, {
  3120. code: 'credential-rejected',
  3121. message: error instanceof Error ? error.message : String(error),
  3122. details: { ref },
  3123. })
  3124. }
  3125. return ok(request, {})
  3126. },
  3127. async unset(request) {
  3128. const credentials = ctx.get('credentials')
  3129. if (credentials === undefined) return err(request, credentialsAbsent())
  3130. const { ref } = request.payload
  3131. try {
  3132. await credentials.unset(credentialRef(ref))
  3133. } catch (error: unknown) {
  3134. return err(request, {
  3135. code: 'credential-rejected',
  3136. message: error instanceof Error ? error.message : String(error),
  3137. details: { ref },
  3138. })
  3139. }
  3140. return ok(request, {})
  3141. },
  3142. },
  3143. llm: {
  3144. providers(request) {
  3145. const registered = ctx.llm.listProviders()
  3146. const active = new Set(registered.map(provider => provider.id))
  3147. const directory = ctx.llm.listConfigurableProviders()
  3148. const declared = new Set(directory.map(entry => entry.provider))
  3149. const views: ConfigurableProviderView[] = directory.map(entry => ({
  3150. provider: entry.provider,
  3151. displayName: entry.displayName,
  3152. settingsNs: entry.settingsNs,
  3153. settingsPath: [...entry.settingsPath],
  3154. active: active.has(entry.provider),
  3155. ...entry.declared === undefined ? {} : { declared: entry.declared },
  3156. }))
  3157. // Routes registered without a directory declaration still appear —
  3158. // they exist and serve models — just with no settings address. No
  3159. // adapter claimed them, so nothing can say whether they are shipped.
  3160. for (const provider of registered) {
  3161. if (declared.has(provider.id)) continue
  3162. views.push({
  3163. provider: provider.id,
  3164. displayName: provider.name,
  3165. settingsNs: '',
  3166. settingsPath: [],
  3167. active: true,
  3168. })
  3169. }
  3170. return Promise.resolve(ok(request, { providers: views }))
  3171. },
  3172. async models(request) {
  3173. return ok(request, await buildModelCatalog(ctx))
  3174. },
  3175. async discoverModels(request, signal) {
  3176. const { settingsNs, provider, baseURL, api, apiKey } = request.payload
  3177. try {
  3178. const models = await ctx.llm.discoverModels(settingsNs, {
  3179. ...provider === undefined ? {} : { provider },
  3180. ...baseURL === undefined ? {} : { baseURL },
  3181. ...api === undefined ? {} : { api },
  3182. ...apiKey === undefined ? {} : { apiKey },
  3183. ...signal === undefined ? {} : { signal },
  3184. })
  3185. return ok(request, { models })
  3186. } catch (error: unknown) {
  3187. // Every failure here is the user's next move, not a transport fault:
  3188. // a wrong endpoint, a rejected key, or a protocol with no listing all
  3189. // end at the same place — fill the models in by hand. The details
  3190. // repeat only what the caller already sent, never the credential.
  3191. return err(request, {
  3192. code: 'model-discovery-failed',
  3193. message: error instanceof Error ? error.message : String(error),
  3194. details: { settingsNs, ...baseURL === undefined ? {} : { baseURL } },
  3195. })
  3196. }
  3197. },
  3198. },
  3199. events: {
  3200. mux(_request, signal) {
  3201. const queue = new FrameQueue<RpcRequest<MuxFrame>>()
  3202. muxQueues.add(queue)
  3203. for (const session of ctx.sessions.list()) {
  3204. subscribeSession(queue, session)
  3205. }
  3206. for (const pending of pendingQuestions.values()) {
  3207. queue.push({
  3208. rpcId: pending.rpcId,
  3209. payload: {
  3210. type: 'question/requested', sessionId: pending.sessionId,
  3211. questions: pending.questions,
  3212. },
  3213. })
  3214. }
  3215. // Refresh recovery: still-pending approval questions replay with their
  3216. // stable rpcId so a reconnecting client can still answer them.
  3217. for (const pending of pendingApprovals.values()) queue.push(requestedFrame(pending))
  3218. // Queue snapshot baseline (pendingQuestions precedent): frames replayed
  3219. // in arrival order per session; a reconnecting client rebuilds its
  3220. // queue view from these alone.
  3221. for (const session of ctx.sessions.list()) {
  3222. const agent = ctx.agents.get(session.id)
  3223. if (agent?.session === session && agent.inbox.hasPending) {
  3224. queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
  3225. }
  3226. }
  3227. // Background-task baseline. `ctx.agents.get` is the non-resuming read:
  3228. // a session with no live Agent owns no tasks, so it correctly sees only
  3229. // the unowned ones, and listing never revives a cold session. An empty
  3230. // set sends nothing — absence is how the client reads "no tasks".
  3231. const tasks = ctx.get('tasks')
  3232. if (tasks !== undefined) {
  3233. for (const session of ctx.sessions.list()) {
  3234. const views = taskViews(tasks.list(ctx.agents.get(session.id)))
  3235. if (views.length > 0) {
  3236. queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
  3237. }
  3238. }
  3239. }
  3240. // Per-session open-call table for result-view pairing. Bounded by the
  3241. // per-turn call count: entries clear on turn/end; a table miss (stream
  3242. // opened mid-turn) backscans the session's in-memory events instead.
  3243. const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
  3244. const disposers = [
  3245. ctx.on('session/event', (session: Session, event: SessionEvent) => {
  3246. if (event.type === 'tool/call') {
  3247. const data = event.data as ToolCallData
  3248. try {
  3249. let table = openCalls.get(session.id)
  3250. if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
  3251. table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
  3252. } catch {
  3253. // Unparseable model arguments: leave the table unset; the result view soft-falls.
  3254. }
  3255. } else if (event.type === 'turn/end') {
  3256. openCalls.delete(session.id)
  3257. }
  3258. const view = viewFor(
  3259. ctx, event,
  3260. callId => openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId),
  3261. ctx.agents.get(session.id),
  3262. )
  3263. queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
  3264. }),
  3265. ctx.on('session/created', (session: Session) => {
  3266. subscribeSession(queue, session)
  3267. // The subscribe frame clears the client's task mirror, and a
  3268. // session born after the stream opened missed the baseline loop.
  3269. // Unowned tasks are visible to it from birth, so without this it
  3270. // would show none until the next registry change.
  3271. const views = tasks === undefined ? [] : taskViews(tasks.list(ctx.agents.get(session.id)))
  3272. if (views.length > 0) {
  3273. queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
  3274. }
  3275. }),
  3276. ctx.on('session/disposed', (session: Session) => {
  3277. openCalls.delete(session.id)
  3278. }),
  3279. ...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => {
  3280. if (owner !== undefined) {
  3281. // The exact owner instance the fence compares against, so the
  3282. // push stays correct even while that Agent's scope is tearing
  3283. // down and a lookup by id would already miss.
  3284. queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) }))
  3285. return
  3286. }
  3287. // An unowned task is visible to every caller, so every subscribed
  3288. // session's set changed with it.
  3289. for (const session of ctx.sessions.list()) {
  3290. queue.push(frame({
  3291. type: 'session/tasks',
  3292. sessionId: session.id,
  3293. tasks: taskViews(tasks.list(ctx.agents.get(session.id))),
  3294. }))
  3295. }
  3296. })],
  3297. ]
  3298. return queue.iterate(signal, () => {
  3299. muxQueues.delete(queue)
  3300. for (const dispose of disposers) dispose()
  3301. })
  3302. },
  3303. host(_request, signal) {
  3304. const queue = new FrameQueue<RpcRequest<HostFrame>>()
  3305. const committedWorkspaceIds = new Set(
  3306. ctx.workspace.list().map(workspace => String(workspace.id)),
  3307. )
  3308. // Frame-dedup baseline, same posture as committedWorkspaceIds: the
  3309. // stream opens against the current set; workspace.list re-baselines
  3310. // reconnecting clients, so only later changes need frames.
  3311. let archivedSessionIds = ctx.workspace.archivedSessionIds
  3312. const disposers = [
  3313. ctx.on('session/created', (session: Session) => {
  3314. queue.push(frame({
  3315. type: 'host/session-added',
  3316. sessionId: session.id,
  3317. // Derived at frame time like summarize(); a just-created session
  3318. // has run no turn yet, so this is constantly true in practice.
  3319. blank: sessionBlank(session),
  3320. // Including cwd lets the client group the new session without refreshing the list.
  3321. ...sessionListFields(session.header, session.events),
  3322. }))
  3323. }),
  3324. ctx.on('session/disposed', (session: Session) => {
  3325. queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
  3326. }),
  3327. ctx.on('agent/status', ({ agent, status }: { agent: Agent; status: AgentStatus }) => {
  3328. queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
  3329. }),
  3330. ctx.on('agent/error', ({ agent, error }: { agent: Agent; error: unknown }) => {
  3331. queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: errorChain(error) }))
  3332. }),
  3333. ctx.on('domain/changed', (change) => {
  3334. if (change.domain !== 'workspace') return
  3335. if (change.table === '') {
  3336. if (change.operation !== 'put') return
  3337. const state = workspaceDomainState.parse(change.value)
  3338. for (const workspaceId of state.workspaceIds) {
  3339. if (committedWorkspaceIds.has(workspaceId)) continue
  3340. const workspace = ctx.workspace.get(workspaceId)
  3341. if (workspace === undefined) {
  3342. throw new Error(`committed workspace registry references missing workspace "${workspaceId}"`)
  3343. }
  3344. committedWorkspaceIds.add(workspaceId)
  3345. queue.push(frame({ type: 'host/workspace-changed', workspace: workspaceView(workspace) }))
  3346. }
  3347. if (state.archivedSessionIds.length !== archivedSessionIds.length
  3348. || state.archivedSessionIds.some((id, index) => id !== archivedSessionIds[index])) {
  3349. archivedSessionIds = state.archivedSessionIds
  3350. queue.push(frame({
  3351. type: 'host/archived-sessions-changed',
  3352. archivedSessionIds: [...state.archivedSessionIds],
  3353. }))
  3354. }
  3355. return
  3356. }
  3357. if (change.table !== 'workspaces') return
  3358. if (change.operation === 'deleted') {
  3359. if (!committedWorkspaceIds.delete(change.key)) return
  3360. queue.push(frame({
  3361. type: 'host/workspace-removed',
  3362. workspaceId: change.key as WorkspaceId,
  3363. }))
  3364. return
  3365. }
  3366. if (!committedWorkspaceIds.has(change.key)) return
  3367. // Existing-entity table writes are complete attach/touch commits.
  3368. // A new entity's first put waits for the global registry write above.
  3369. queue.push(frame({
  3370. type: 'host/workspace-changed',
  3371. workspace: changedWorkspaceView(change.key, change.value),
  3372. }))
  3373. }),
  3374. // Allowlisted host events ride one verbatim wrapper frame each. The
  3375. // allowlist is api-remotes', and `ctx.remote.$on` is the consumer
  3376. // face; nothing here projects, redacts, or renames.
  3377. ...API_REMOTE_FORWARDED_EVENTS.map(name => ctx.on(
  3378. name,
  3379. // The allowlist's shape assertion proves each name is a real,
  3380. // non-scoped, void-returning event, so the rest-parameter handler
  3381. // satisfies every member of the union `on` accepts here;
  3382. // assertJsonArgs proves the payload is JSON-safe before it queues.
  3383. ((...args: unknown[]) => {
  3384. queue.push(frame({
  3385. type: 'host/remote-event',
  3386. event: name,
  3387. args: assertJsonArgs(name, args),
  3388. }))
  3389. }),
  3390. )),
  3391. ]
  3392. return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
  3393. },
  3394. },
  3395. downloads: {
  3396. async sessionLog(request, signal) {
  3397. // Clean error path first: missing services answer 500 and a missing
  3398. // root artifact 404 before any zip byte is produced. The root content
  3399. // read here is reused as the first zip entry, so nothing is read twice.
  3400. const deps = sessionLogExportDeps(ctx)
  3401. if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) {
  3402. return new Response(
  3403. 'session log export is unavailable: missing session-query, session-persistence, or attachments service',
  3404. { status: 500 },
  3405. )
  3406. }
  3407. if (!deps.sessionPersistence.supportsRawArtifacts) {
  3408. return new Response(
  3409. 'session log export is unavailable: the persistence backend does not expose per-session raw artifacts',
  3410. { status: 501 },
  3411. )
  3412. }
  3413. const ready: SessionLogExportReady = {
  3414. sessionQuery: deps.sessionQuery,
  3415. sessionPersistence: deps.sessionPersistence,
  3416. attachments: deps.attachments,
  3417. sessions: deps.sessions,
  3418. }
  3419. let root: SessionRawArtifact | undefined
  3420. try {
  3421. await flushLiveSessionLog(deps, request.sessionId, signal)
  3422. root = await deps.sessionPersistence.readRaw(request.sessionId, signal)
  3423. signal.throwIfAborted()
  3424. } catch {
  3425. signal.throwIfAborted()
  3426. // Root preparation failure: answer 500 without echoing the error,
  3427. // which may carry absolute host paths into the browser error bar.
  3428. return new Response('session log export failed to prepare the stored artifact', { status: 500 })
  3429. }
  3430. if (root === undefined) {
  3431. return new Response('session not found', { status: 404 })
  3432. }
  3433. return new Response(
  3434. streamSessionLogZip(
  3435. ready,
  3436. root,
  3437. request.sessionId,
  3438. request.includeDescendants === true,
  3439. sessionExportCompressionLevel,
  3440. signal,
  3441. ),
  3442. {
  3443. headers: {
  3444. 'content-type': 'application/zip',
  3445. 'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`,
  3446. },
  3447. },
  3448. )
  3449. },
  3450. },
  3451. respond(message: ClientResponse): Promise<RpcReceipt> {
  3452. // Route by the echoed rpcId (the wire correlation): approvals first,
  3453. // then questions — the two registries share one id space of UUIDs.
  3454. const approval = pendingApprovals.get(message.rpcId)
  3455. if (approval !== undefined) {
  3456. if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3457. const parsed = approvalResponsePayloadSchema.safeParse(message.result.value)
  3458. // The payload's audit correlation must match the entry the rpcId routed
  3459. // to — a mismatched answer is malformed, not merely late.
  3460. if (!parsed.success || parsed.data.approvalId !== approval.approvalId || parsed.data.sessionId !== approval.sessionId) {
  3461. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3462. }
  3463. approval.resolve(parsed.data.outcome)
  3464. return Promise.resolve({ accepted: true })
  3465. }
  3466. const pending = pendingQuestions.get(message.rpcId)
  3467. if (pending === undefined) return Promise.resolve({ accepted: false, reason: 'not-pending' })
  3468. if (!message.result.ok) {
  3469. if (message.result.error.code !== 'cancelled') {
  3470. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3471. }
  3472. claimQuestion(pending, 'cancelled')
  3473. pending.reject(new UserInteractionError(
  3474. 'the user cancelled ask_user_question', 'ASK_CANCELLED'))
  3475. return Promise.resolve({ accepted: true })
  3476. }
  3477. const parsed = questionResponsePayloadSchema.safeParse(message.result.value)
  3478. if (!parsed.success) {
  3479. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3480. }
  3481. const payload: QuestionResponsePayload = {
  3482. sessionId: parsed.data.sessionId,
  3483. answer: {
  3484. answers: parsed.data.answer.answers.map(answer => ({
  3485. id: answer.id,
  3486. selected: answer.selected,
  3487. ...(answer.custom === undefined ? {} : { custom: answer.custom }),
  3488. })),
  3489. },
  3490. }
  3491. if (!matchesQuestions(payload, pending)) {
  3492. return Promise.resolve({ accepted: false, reason: 'bad-response' })
  3493. }
  3494. claimQuestion(pending, 'answered')
  3495. pending.resolve(payload.answer)
  3496. return Promise.resolve({ accepted: true })
  3497. },
  3498. }
  3499. }