dsh-edge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/LICENSE +21 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +183 -0
  4. package/README.zh.md +183 -0
  5. package/THIRD_PARTY_NOTICES.md +199 -0
  6. package/dist/_headers +3 -0
  7. package/dist/assets/fonts/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  8. package/dist/assets/fonts/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  9. package/dist/assets/fonts/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  10. package/dist/assets/fonts/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  11. package/dist/assets/fonts/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  12. package/dist/assets/fonts/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  13. package/dist/assets/fonts/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  14. package/dist/assets/fonts/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  15. package/dist/assets/fonts/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  16. package/dist/assets/fonts/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  17. package/dist/assets/fonts/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  18. package/dist/assets/fonts/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  19. package/dist/assets/fonts/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  20. package/dist/assets/fonts/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  21. package/dist/assets/fonts/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  22. package/dist/assets/fonts/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  23. package/dist/assets/fonts/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  24. package/dist/assets/fonts/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  25. package/dist/assets/fonts/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  26. package/dist/assets/fonts/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  27. package/dist/assets/fonts/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  28. package/dist/assets/fonts/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  29. package/dist/assets/fonts/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  30. package/dist/assets/fonts/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  31. package/dist/assets/fonts/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  32. package/dist/assets/fonts/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  33. package/dist/assets/fonts/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  34. package/dist/assets/fonts/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  35. package/dist/assets/fonts/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  36. package/dist/assets/fonts/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  37. package/dist/assets/fonts/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  38. package/dist/assets/fonts/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  39. package/dist/assets/fonts/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  40. package/dist/assets/fonts/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  41. package/dist/assets/fonts/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  42. package/dist/assets/fonts/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  43. package/dist/assets/fonts/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  44. package/dist/assets/fonts/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  45. package/dist/assets/fonts/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  46. package/dist/assets/fonts/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  47. package/dist/assets/fonts/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  48. package/dist/assets/fonts/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  49. package/dist/assets/fonts/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  50. package/dist/assets/fonts/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  51. package/dist/assets/fonts/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  52. package/dist/assets/fonts/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  53. package/dist/assets/fonts/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  54. package/dist/assets/fonts/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  55. package/dist/assets/fonts/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  56. package/dist/assets/fonts/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  57. package/dist/assets/fonts/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  58. package/dist/assets/fonts/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  59. package/dist/assets/fonts/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  60. package/dist/assets/fonts/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  61. package/dist/assets/fonts/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  62. package/dist/assets/fonts/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  63. package/dist/assets/fonts/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  64. package/dist/assets/fonts/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  65. package/dist/assets/fonts/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  66. package/dist/assets/index-C-1AiF3k.js +112 -0
  67. package/dist/assets/index-CSGf6Qzd.css +1 -0
  68. package/dist/assets/langs/c-BIGW1oBm.js +2 -0
  69. package/dist/assets/langs/cpp-DIPi6g--.js +2 -0
  70. package/dist/assets/langs/csharp-DSvCPggb.js +2 -0
  71. package/dist/assets/langs/css-CLj8gQPS.js +2 -0
  72. package/dist/assets/langs/go-C27-OAKa.js +2 -0
  73. package/dist/assets/langs/html-CfGypltT.js +2 -0
  74. package/dist/assets/langs/ini-BEwlwnbL.js +2 -0
  75. package/dist/assets/langs/java-CylS5w8V.js +2 -0
  76. package/dist/assets/langs/kotlin-BdnUsdx6.js +2 -0
  77. package/dist/assets/langs/less-B1dDrJ26.js +2 -0
  78. package/dist/assets/langs/lua-BaeVxFsk.js +2 -0
  79. package/dist/assets/langs/markdown-Cvjx9yec.js +2 -0
  80. package/dist/assets/langs/mdx-Cmh6b_Ma.js +2 -0
  81. package/dist/assets/langs/php-D-uCvoST.js +2 -0
  82. package/dist/assets/langs/python-B6aJPvgy.js +2 -0
  83. package/dist/assets/langs/ruby-DEItuUFs.js +2 -0
  84. package/dist/assets/langs/rust-B1yitclQ.js +2 -0
  85. package/dist/assets/langs/scss-D5BDwBP9.js +2 -0
  86. package/dist/assets/langs/sql-CRqJ_cUM.js +2 -0
  87. package/dist/assets/langs/swift-D82vCrfD.js +2 -0
  88. package/dist/assets/langs/toml-vGWfd6FD.js +2 -0
  89. package/dist/assets/langs/xml-sdJ4AIDG.js +2 -0
  90. package/dist/assets/langs/yaml-Buea-lGh.js +2 -0
  91. package/dist/assets/vendor-Cjbwl5VI.js +411 -0
  92. package/dist/assets/vendor-CjyC-hUb.css +1 -0
  93. package/dist/favicon.svg +8 -0
  94. package/dist/index.html +35 -0
  95. package/dist/manifest.webmanifest +16 -0
  96. package/dist/plugins/@deepseek-ai/dsh-api-gateway/client.js +420 -0
  97. package/dist/plugins/@deepseek-ai/dsh-api-remotes/client.js +5937 -0
  98. package/dist/plugins/@deepseek-ai/dsh-client-connection/client.js +10207 -0
  99. package/dist/plugins/@deepseek-ai/dsh-client-locale/client.js +1231 -0
  100. package/dist/plugins/@deepseek-ai/dsh-client-modules/client.js +259 -0
  101. package/dist/plugins/@deepseek-ai/dsh-client-runtime/client.js +10539 -0
  102. package/dist/plugins/@deepseek-ai/dsh-client-ui-agent-preset/client.js +1725 -0
  103. package/dist/plugins/@deepseek-ai/dsh-client-ui-commands/client.js +1116 -0
  104. package/dist/plugins/@deepseek-ai/dsh-client-ui-conversation/client.js +9832 -0
  105. package/dist/plugins/@deepseek-ai/dsh-client-ui-deliverables/client.js +373 -0
  106. package/dist/plugins/@deepseek-ai/dsh-client-ui-edge/client.js +549 -0
  107. package/dist/plugins/@deepseek-ai/dsh-client-ui-input-trigger/client.js +859 -0
  108. package/dist/plugins/@deepseek-ai/dsh-client-ui-jobs/client.js +280 -0
  109. package/dist/plugins/@deepseek-ai/dsh-client-ui-layout/client.js +456 -0
  110. package/dist/plugins/@deepseek-ai/dsh-client-ui-model-selection/client.js +804 -0
  111. package/dist/plugins/@deepseek-ai/dsh-client-ui-permission-presets/client.js +461 -0
  112. package/dist/plugins/@deepseek-ai/dsh-client-ui-settings/client.js +254 -0
  113. package/dist/plugins/@deepseek-ai/dsh-client-ui-settings-general/client.js +609 -0
  114. package/dist/plugins/@deepseek-ai/dsh-client-ui-sidebar/client.js +292 -0
  115. package/dist/plugins/@deepseek-ai/dsh-client-ui-skill/client.js +327 -0
  116. package/dist/plugins/@deepseek-ai/dsh-client-ui-subagent/client.js +710 -0
  117. package/dist/plugins/@deepseek-ai/dsh-client-ui-theme/client.js +1312 -0
  118. package/dist/plugins/@deepseek-ai/dsh-client-ui-tool/client.js +1630 -0
  119. package/dist/plugins/@deepseek-ai/dsh-client-ui-trajectory/client.js +7370 -0
  120. package/dist/plugins/@deepseek-ai/dsh-client-ui-user-questions/client.js +700 -0
  121. package/dist/plugins/@deepseek-ai/dsh-client-ui-workflow-run/client.js +474 -0
  122. package/dist/plugins/@deepseek-ai/dsh-client-ui-workspace/client.js +2426 -0
  123. package/dist/plugins/@deepseek-ai/dsh-typert-registry/client.js +1372 -0
  124. package/package.json +97 -0
  125. package/scripts/assemble-web.mjs +230 -0
  126. package/scripts/bundle-size.d.mts +2 -0
  127. package/scripts/bundle-size.mjs +26 -0
  128. package/scripts/bundle.mjs +53 -0
  129. package/scripts/cli.d.mts +39 -0
  130. package/scripts/cli.mjs +496 -0
  131. package/scripts/install.d.mts +149 -0
  132. package/scripts/install.mjs +989 -0
  133. package/scripts/legal-files.mjs +24 -0
  134. package/scripts/verify-packed.mjs +32 -0
  135. package/scripts/wrangler-config.d.mts +18 -0
  136. package/scripts/wrangler-config.mjs +84 -0
  137. package/src/agent.ts +119 -0
  138. package/src/auth.ts +352 -0
  139. package/src/deepseek.ts +231 -0
  140. package/src/deployment.ts +147 -0
  141. package/src/direct-shell-core-empty.ts +10 -0
  142. package/src/direct-shell-protocol.ts +5 -0
  143. package/src/direct-shell.ts +341 -0
  144. package/src/do-session-persistence.ts +1064 -0
  145. package/src/edge-api.ts +796 -0
  146. package/src/edge-credentials.ts +56 -0
  147. package/src/edge-remotes.ts +41 -0
  148. package/src/edge-workspace-store.ts +339 -0
  149. package/src/http.ts +212 -0
  150. package/src/index.ts +196 -0
  151. package/src/instance.ts +1007 -0
  152. package/src/isolated-direct-shell-unavailable.ts +11 -0
  153. package/src/protocol.ts +39 -0
  154. package/src/release.ts +9 -0
  155. package/src/session-store.ts +1110 -0
  156. package/src/sse.ts +86 -0
  157. package/src/web-search.ts +38 -0
  158. package/src/workspace.ts +270 -0
  159. package/wrangler.jsonc +56 -0
@@ -0,0 +1,1007 @@
1
+ /** Workspace Durable Object with persistent sessions and streamed agent turns. */
2
+
3
+ import {
4
+ getWorkspace,
5
+ withWorkspace,
6
+ type DurableObjectStorageLike,
7
+ } from '@cloudflare/computer'
8
+ import {
9
+ WorkerShellBackend,
10
+ type WorkerShellLoader,
11
+ } from '@cloudflare/computer/backends/worker-shell'
12
+ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent'
13
+ import type {
14
+ HostFrame,
15
+ MuxFrame,
16
+ PromptContentPart,
17
+ QueueAction,
18
+ QueuedInboxItem,
19
+ ServerRequest,
20
+ SessionListMetadata,
21
+ WorkspaceId,
22
+ WorkspaceView,
23
+ } from '@deepseek-ai/dsh-host-apiproxy/api'
24
+ import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api'
25
+ import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
26
+ import { freezeMessage, type MessageId } from '@deepseek-ai/dsh-llm'
27
+ import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
28
+ import { normalizeSessionTitle } from '@deepseek-ai/dsh-session-title'
29
+ import { DurableObject } from 'cloudflare:workers'
30
+ import { OWNER_SESSION_EXPIRY_HEADER } from './auth.ts'
31
+ import { DirectShellBackend } from './direct-shell.ts'
32
+ import {
33
+ resolveEdgeModel,
34
+ type EdgeReasoningEffort,
35
+ } from './deepseek.ts'
36
+ import {
37
+ resolveEdgeDeploymentConfig,
38
+ resolveEdgeDeploymentProfile,
39
+ } from './deployment.ts'
40
+ import {
41
+ EdgeHttpError,
42
+ MAX_SESSION_CREATE_BODY_BYTES,
43
+ MAX_TURN_BODY_BYTES,
44
+ corsHeaders,
45
+ discardUnreadRequestBody,
46
+ errorResponse,
47
+ jsonResponse,
48
+ readJsonObject,
49
+ requireBoundedString,
50
+ requireBoundedUtf8String,
51
+ } from './http.ts'
52
+ import {
53
+ EdgeSessionStore,
54
+ EdgeSessionStoreError,
55
+ type EdgeAgentPromptAdmitter,
56
+ type EdgeMuxBaseline,
57
+ } from './session-store.ts'
58
+ import {
59
+ EdgeTurnId,
60
+ type CancelEdgeTurnResponse,
61
+ type EdgeSession,
62
+ } from './protocol.ts'
63
+ import {
64
+ createLiveSessionEventStream,
65
+ edgeEventStreamHeaders,
66
+ encodeSessionEvent,
67
+ } from './sse.ts'
68
+ import {
69
+ executeWorkspaceCommand,
70
+ requireCommand,
71
+ requireWorkspacePath,
72
+ type EdgeCommandTimeoutPolicy,
73
+ } from './workspace.ts'
74
+ import {
75
+ MAX_MESSAGE_TEXT_BYTES,
76
+ attachPublishedSession,
77
+ createEdgeApi,
78
+ messageTextByteLength,
79
+ } from './edge-api.ts'
80
+ import { handleEdgeRemote } from './edge-remotes.ts'
81
+ import type { EdgeApiSessionSummary } from './session-store.ts'
82
+ import { EDGE_WORKSPACE_ID, EdgeWorkspaceStore } from './edge-workspace-store.ts'
83
+ import { DSH_EDGE_VERSION } from './release.ts'
84
+
85
+ const MAX_SESSION_TITLE_LENGTH = 160
86
+ const MAX_SESSION_TITLE_BYTES = 640
87
+ const MAX_SESSION_ID_LENGTH = 128
88
+ const DEFAULT_SESSION_LIST_LIMIT = 50
89
+ const MAX_SESSION_LIST_LIMIT = 100
90
+ const DEFAULT_REPLAY_EVENT_LIMIT = 128
91
+ const MAX_REPLAY_EVENT_LIMIT = 256
92
+ const MAX_REPLAY_RESPONSE_BYTES = 1_048_576
93
+ const INITIAL_SESSION_LIST_METADATA: SessionListMetadata = { blank: true, lastPromptAt: null }
94
+ const OWNER_SESSION_EXPIRED_CLOSE_CODE = 1008
95
+ const OWNER_SESSION_EXPIRED_CLOSE_REASON = 'owner session expired'
96
+
97
+ interface DownlinkAttachment {
98
+ channel: 'mux' | 'host'
99
+ expiresAt: number
100
+ }
101
+
102
+ /** Mirror the upstream session-list projection fold at the Edge transport seam. */
103
+ function applySessionListMetadata(
104
+ state: SessionListMetadata,
105
+ event: SessionEvent,
106
+ ): SessionListMetadata {
107
+ const blank = state.blank && event.type !== 'turn/start'
108
+ const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user'
109
+ ? event.time
110
+ : state.lastPromptAt
111
+ return blank === state.blank && lastPromptAt === state.lastPromptAt
112
+ ? state
113
+ : { blank, lastPromptAt }
114
+ }
115
+
116
+ /** Bindings shared by the entry Worker and each workspace Durable Object. */
117
+ export interface EdgeEnv {
118
+ DSH_EDGE_INSTANCE: DurableObjectNamespace<DshEdgeInstance>
119
+ ASSETS: Fetcher
120
+ LOADER?: WorkerShellLoader
121
+ DSH_EDGE_ACCESS_KEY?: string
122
+ DEEPSEEK_API_KEY?: string
123
+ DEEPSEEK_BASE_URL?: string
124
+ DEEPSEEK_MAX_OUTPUT_TOKENS?: string
125
+ DEEPSEEK_MODEL?: string
126
+ DEEPSEEK_REASONING_EFFORT?: string
127
+ DEEPSEEK_SEARCH_BASE_URL?: string
128
+ DEEPSEEK_STREAM_IDLE_TIMEOUT_MS?: string
129
+ DSH_EDGE_DEFAULT_COMMAND_TIMEOUT_MS?: string
130
+ DSH_EDGE_MAX_COMMAND_TIMEOUT_MS?: string
131
+ }
132
+
133
+ class DshEdgeObjectBase extends DurableObject<EdgeEnv> {
134
+ workspaceOptions() {
135
+ const backend = this.env.LOADER === undefined
136
+ ? new DirectShellBackend()
137
+ : new WorkerShellBackend({
138
+ loader: this.env.LOADER,
139
+ workspace: {
140
+ binding: 'DSH_EDGE_INSTANCE',
141
+ id: this.ctx.id.toString(),
142
+ },
143
+ ctx: this.ctx,
144
+ })
145
+ return {
146
+ // Computer's preview storage facade and Workers' generated SQL generic
147
+ // differ only in their type parameter; both expose the same runtime API.
148
+ storage: this.ctx.storage as unknown as DurableObjectStorageLike,
149
+ backends: [backend],
150
+ }
151
+ }
152
+ }
153
+
154
+ const DshEdgeWorkspace = withWorkspace(
155
+ DshEdgeObjectBase,
156
+ self => self.workspaceOptions(),
157
+ )
158
+
159
+ interface ActiveTurn {
160
+ turnId: EdgeTurnId
161
+ agent?: Agent
162
+ cancelRequested: boolean
163
+ accepting: boolean
164
+ wasAdmitted: boolean
165
+ admit?: EdgeAgentPromptAdmitter
166
+ admissionReady: Promise<void>
167
+ releaseComplete: Promise<void>
168
+ resolveAdmissionReady: () => void
169
+ resolveReleaseComplete: () => void
170
+ }
171
+
172
+ /** One isolated persistent workspace with its conversations and active agent turn. */
173
+ export class DshEdgeInstance extends DshEdgeWorkspace {
174
+ private readonly sessions = new EdgeSessionStore(
175
+ this.ctx.storage,
176
+ {
177
+ readDeepSeekApiKey: () => this.env.DEEPSEEK_API_KEY,
178
+ ...this.env.DEEPSEEK_SEARCH_BASE_URL === undefined
179
+ ? {}
180
+ : { searchBaseURL: this.env.DEEPSEEK_SEARCH_BASE_URL },
181
+ },
182
+ )
183
+ private readonly workspaces = new EdgeWorkspaceStore(this.ctx.storage)
184
+ private readonly model = resolveEdgeModel(this.env.DEEPSEEK_MODEL)
185
+ private readonly activeTurns = new Map<SessionId, ActiveTurn>()
186
+ private readonly sessionListMetadata = new Map<SessionId, SessionListMetadata>()
187
+ private readonly api = createEdgeApi({
188
+ sessions: this.sessions,
189
+ model: this.model,
190
+ version: DSH_EDGE_VERSION,
191
+ deploymentProfile: () => resolveEdgeDeploymentProfile(this.env),
192
+ describeCredential: ref => this.sessions.describeCredential(ref),
193
+ isRunning: sessionId => this.activeTurns.has(sessionId),
194
+ prompt: input => this.startApiPrompt(input),
195
+ updateQueue: (sessionId, itemId, action) => this.updateQueue(sessionId, itemId, action),
196
+ cancel: sessionId => this.requestTurnCancellation(sessionId),
197
+ workspaceList: sessions => this.workspaces.list(sessions),
198
+ workspaceCreate: path => this.createWorkspace(path),
199
+ workspaceRename: (workspaceId, title) => this.renameWorkspace(workspaceId, title),
200
+ workspaceDelete: workspaceId => this.deleteWorkspace(workspaceId),
201
+ workspaceInsertBefore: (workspaceId, beforeWorkspaceId) =>
202
+ this.workspaces.insertBefore(workspaceId, beforeWorkspaceId),
203
+ workspaceInsertSessionBefore: (workspaceId, sessionId, beforeSessionId) =>
204
+ this.insertSessionBefore(workspaceId, sessionId, beforeSessionId),
205
+ archiveSession: sessionId => this.archiveSession(sessionId),
206
+ sessionCreated: (session) => {
207
+ this.publishSessionCreated(session)
208
+ },
209
+ sessionAttached: (session, workspaceId) => this.publishSessionAttached(session, workspaceId),
210
+ workspaceForSession: sessionId => this.workspaceForSession(sessionId),
211
+ sessionEvent: (sessionId, event) => {
212
+ this.publishSessionEvent(sessionId, event)
213
+ },
214
+ })
215
+ private readonly apiFetch = toFetchHandler(this.api).fetch
216
+
217
+ /** Serve session routes forwarded by the entry Worker. */
218
+ override async fetch(request: Request): Promise<Response> {
219
+ try {
220
+ const url = new URL(request.url)
221
+ const edgeRemote = await handleEdgeRemote(request)
222
+ if (edgeRemote !== undefined) return edgeRemote
223
+ if (url.pathname === '/api/events.mux' || url.pathname === '/api/events.host') {
224
+ return await this.openDownlink(request, url.pathname === '/api/events.mux' ? 'mux' : 'host')
225
+ }
226
+ if (url.pathname.startsWith('/api/') && !url.pathname.startsWith('/api/sessions')) {
227
+ return await this.apiFetch(request)
228
+ }
229
+ const route = parseSessionRoute(url.pathname)
230
+
231
+ if (route.sessionId === undefined) {
232
+ if (request.method === 'POST') return await this.createSession(request)
233
+ if (request.method === 'GET') {
234
+ const after = resolveSessionListAfter(url)
235
+ const limit = resolveSessionListLimit(url)
236
+ const page = await this.sessions.listSessions(after, limit)
237
+ if (page === undefined) throw new EdgeHttpError(400, 'Unknown session list cursor.')
238
+ return jsonResponse({
239
+ sessions: page.sessions.map(session => this.presentSession(session)),
240
+ hasMore: page.hasMore,
241
+ ...page.nextAfter === undefined ? {} : { nextAfter: page.nextAfter },
242
+ })
243
+ }
244
+ } else if (route.action === undefined) {
245
+ if (request.method === 'GET') {
246
+ const session = await this.sessions.getSession(route.sessionId)
247
+ if (session === undefined) throw new EdgeHttpError(404, 'Session not found.')
248
+ return jsonResponse({ session: this.presentSession(session) })
249
+ }
250
+ } else if (route.action === 'events' && request.method === 'GET') {
251
+ return await this.replayEvents(request, url, route.sessionId)
252
+ } else if (route.action === 'turn' && request.method === 'POST') {
253
+ return await this.startTurn(request, route.sessionId)
254
+ } else if (route.action === 'cancel' && request.method === 'POST') {
255
+ return this.cancelTurn(route.sessionId)
256
+ }
257
+
258
+ throw new EdgeHttpError(404, 'Session route not found.')
259
+ } catch (error) {
260
+ await discardUnreadRequestBody(request)
261
+ return errorResponse(error)
262
+ }
263
+ }
264
+
265
+ /** Reject client messages because both upstream WebSockets are downlink-only. */
266
+ override webSocketMessage(socket: WebSocket, _message: string | ArrayBuffer): void {
267
+ if (this.closeExpiredDownlink(socket, Date.now())) return
268
+ socket.close(1008, 'downlink only')
269
+ }
270
+
271
+ /** Close a broken downstream without affecting other subscribers. */
272
+ override webSocketError(socket: WebSocket, error: unknown): void {
273
+ console.error('dsh-edge downstream WebSocket failed.', error)
274
+ socket.close(1011, 'downstream failure')
275
+ }
276
+
277
+ /** End hibernating downlinks when the owner session used to open them expires. */
278
+ override async alarm(): Promise<void> {
279
+ const nextExpiry = this.closeExpiredDownlinks()
280
+ if (nextExpiry !== undefined) await this.ctx.storage.setAlarm(nextExpiry)
281
+ }
282
+
283
+ private async openDownlink(request: Request, channel: 'mux' | 'host'): Promise<Response> {
284
+ if (request.headers.get('upgrade')?.toLowerCase() !== 'websocket') {
285
+ throw new EdgeHttpError(426, 'This event endpoint requires a WebSocket upgrade.')
286
+ }
287
+ const expiresAt = requireOwnerSessionExpiry(request)
288
+ await this.scheduleDownlinkExpiry(expiresAt * 1_000)
289
+ const accept = (baseline?: EdgeMuxBaseline) => {
290
+ const pair = new WebSocketPair()
291
+ const client = pair[0]
292
+ const server = pair[1]
293
+ server.serializeAttachment({ channel, expiresAt } satisfies DownlinkAttachment)
294
+ this.ctx.acceptWebSocket(server, [channel])
295
+ if (baseline !== undefined) {
296
+ for (const session of baseline.sessions) {
297
+ this.rememberSessionListMetadata(session)
298
+ if (!this.sendFrame(server, {
299
+ type: 'session/subscribed',
300
+ sessionId: session.id,
301
+ lastSeq: session.lastSeq,
302
+ })) break
303
+ }
304
+ for (const queue of baseline.queues) {
305
+ if (!this.sendFrame(server, {
306
+ type: 'session/queue',
307
+ sessionId: queue.sessionId,
308
+ items: queue.items,
309
+ })) break
310
+ }
311
+ }
312
+ return new Response(null, { status: 101, webSocket: client })
313
+ }
314
+ return channel === 'mux' ? this.sessions.withMuxBaseline(accept) : accept()
315
+ }
316
+
317
+ private publishSessionCreated(session: EdgeApiSessionSummary): void {
318
+ this.rememberSessionListMetadata(session)
319
+ this.broadcast('mux', {
320
+ type: 'session/subscribed',
321
+ sessionId: session.id,
322
+ lastSeq: session.lastSeq,
323
+ })
324
+ this.broadcast('host', {
325
+ type: 'host/session-added',
326
+ sessionId: session.id,
327
+ blank: session.blank,
328
+ ...session.parentSessionId === undefined
329
+ ? {}
330
+ : { parentSessionId: session.parentSessionId },
331
+ ...session.origin === undefined ? {} : { origin: session.origin },
332
+ ...session.cwd === undefined ? {} : { cwd: session.cwd },
333
+ ...session.agentPreset === undefined ? {} : { agentPreset: session.agentPreset },
334
+ })
335
+ }
336
+
337
+ private async publishSessionAttached(
338
+ session: EdgeApiSessionSummary,
339
+ workspaceId: WorkspaceId,
340
+ ): Promise<void> {
341
+ const workspace = await this.workspaces.attachSession(
342
+ workspaceId,
343
+ session.id,
344
+ await this.sessions.listApiSessions(),
345
+ )
346
+ this.broadcast('host', { type: 'host/workspace-changed', workspace })
347
+ }
348
+
349
+ private async workspaceForSession(sessionId: SessionId): Promise<WorkspaceId | undefined> {
350
+ return await this.workspaces.workspaceForSession(
351
+ sessionId,
352
+ await this.sessions.listApiSessions(),
353
+ )
354
+ }
355
+
356
+ private async createWorkspace(path: string): Promise<{ workspace: WorkspaceView; created: boolean }> {
357
+ const result = await this.workspaces.create(path, await this.sessions.listApiSessions())
358
+ if (result.created) {
359
+ this.broadcast('host', { type: 'host/workspace-changed', workspace: result.workspace })
360
+ }
361
+ return result
362
+ }
363
+
364
+ private async renameWorkspace(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
365
+ const result = await this.workspaces.rename(
366
+ workspaceId,
367
+ title,
368
+ await this.sessions.listApiSessions(),
369
+ )
370
+ if (result.changed) {
371
+ this.broadcast('host', { type: 'host/workspace-changed', workspace: result.workspace })
372
+ }
373
+ return result.workspace
374
+ }
375
+
376
+ private async deleteWorkspace(workspaceId: WorkspaceId): Promise<void> {
377
+ await this.workspaces.delete(workspaceId)
378
+ this.broadcast('host', { type: 'host/workspace-removed', workspaceId })
379
+ }
380
+
381
+ private async insertSessionBefore(
382
+ workspaceId: WorkspaceId,
383
+ sessionId: SessionId,
384
+ beforeSessionId?: SessionId,
385
+ ): Promise<WorkspaceView> {
386
+ const result = await this.workspaces.insertSessionBefore(
387
+ workspaceId,
388
+ sessionId,
389
+ beforeSessionId,
390
+ await this.sessions.listApiSessions(),
391
+ )
392
+ if (result.changed) {
393
+ this.broadcast('host', { type: 'host/workspace-changed', workspace: result.workspace })
394
+ }
395
+ return result.workspace
396
+ }
397
+
398
+ /** Archive with the same registry-global snapshot and frame contract as upstream. */
399
+ private async archiveSession(sessionId: SessionId): Promise<SessionId[]> {
400
+ const result = await this.workspaces.archiveSession(
401
+ sessionId,
402
+ () => this.sessions.requireSession(sessionId),
403
+ )
404
+ if (result.changed) {
405
+ this.broadcast('host', {
406
+ type: 'host/archived-sessions-changed',
407
+ archivedSessionIds: result.archivedSessionIds,
408
+ })
409
+ }
410
+ return result.archivedSessionIds
411
+ }
412
+
413
+ private publishSessionEvent(sessionId: SessionId, event: SessionEvent): void {
414
+ this.broadcast('mux', { type: 'session/event', sessionId, event })
415
+ if (event.type === 'session/title') {
416
+ this.broadcast('mux', {
417
+ type: 'session/projection',
418
+ sessionId,
419
+ key: 'title',
420
+ value: event.data.title,
421
+ seq: event.seq,
422
+ })
423
+ }
424
+ const previous = this.sessionListMetadata.get(sessionId) ?? INITIAL_SESSION_LIST_METADATA
425
+ const next = applySessionListMetadata(previous, event)
426
+ if (next === previous) return
427
+ this.sessionListMetadata.set(sessionId, next)
428
+ this.broadcast('mux', {
429
+ type: 'session/projection',
430
+ sessionId,
431
+ key: 'sessionListMetadata',
432
+ value: next,
433
+ seq: event.seq,
434
+ })
435
+ }
436
+
437
+ private rememberSessionListMetadata(session: EdgeApiSessionSummary): void {
438
+ this.sessionListMetadata.set(session.id, {
439
+ blank: session.blank,
440
+ lastPromptAt: session.lastPromptAt,
441
+ })
442
+ }
443
+
444
+ private publishSessionQueue(sessionId: SessionId, items: QueuedInboxItem[]): void {
445
+ this.broadcast('mux', { type: 'session/queue', sessionId, items })
446
+ }
447
+
448
+ private publishRunning(sessionId: SessionId, running: boolean): void {
449
+ this.broadcast('host', { type: 'host/session-status', sessionId, running })
450
+ }
451
+
452
+ private publishAgentError(sessionId: SessionId, error: unknown): void {
453
+ this.broadcast('host', {
454
+ type: 'host/agent-error',
455
+ sessionId,
456
+ message: error instanceof Error ? error.message : String(error),
457
+ })
458
+ }
459
+
460
+ private broadcast(channel: 'mux', frame: MuxFrame): void
461
+ private broadcast(channel: 'host', frame: HostFrame): void
462
+ private broadcast(channel: 'mux' | 'host', frame: MuxFrame | HostFrame): void {
463
+ const now = Date.now()
464
+ for (const socket of this.ctx.getWebSockets(channel)) {
465
+ if (socket.readyState !== WebSocket.OPEN) continue
466
+ if (this.closeExpiredDownlink(socket, now)) continue
467
+ try {
468
+ this.sendFrame(socket, frame)
469
+ } catch (error) {
470
+ console.error(`dsh-edge failed to publish ${frame.type}.`, error)
471
+ socket.close(1011, 'downstream send failed')
472
+ }
473
+ }
474
+ }
475
+
476
+ private sendFrame(socket: WebSocket, frame: MuxFrame | HostFrame): boolean {
477
+ if (this.closeExpiredDownlink(socket, Date.now())) return false
478
+ const message: ServerRequest = {
479
+ type: 'server-request',
480
+ rpcId: RpcId(crypto.randomUUID()),
481
+ method: frame.type,
482
+ payload: frame,
483
+ }
484
+ socket.send(JSON.stringify(message))
485
+ return true
486
+ }
487
+
488
+ private closeExpiredDownlinks(now = Date.now()): number | undefined {
489
+ let nextExpiry: number | undefined
490
+ for (const socket of this.ctx.getWebSockets()) {
491
+ const attachment = readDownlinkAttachment(socket)
492
+ if (attachment === undefined || attachment.expiresAt * 1_000 <= now) {
493
+ socket.close(OWNER_SESSION_EXPIRED_CLOSE_CODE, OWNER_SESSION_EXPIRED_CLOSE_REASON)
494
+ continue
495
+ }
496
+ const expiresAt = attachment.expiresAt * 1_000
497
+ nextExpiry = nextExpiry === undefined ? expiresAt : Math.min(nextExpiry, expiresAt)
498
+ }
499
+ return nextExpiry
500
+ }
501
+
502
+ private closeExpiredDownlink(socket: WebSocket, now: number): boolean {
503
+ const attachment = readDownlinkAttachment(socket)
504
+ if (attachment !== undefined && attachment.expiresAt * 1_000 > now) return false
505
+ socket.close(OWNER_SESSION_EXPIRED_CLOSE_CODE, OWNER_SESSION_EXPIRED_CLOSE_REASON)
506
+ return true
507
+ }
508
+
509
+ private async scheduleDownlinkExpiry(expiresAt: number): Promise<void> {
510
+ const scheduled = await this.ctx.storage.getAlarm()
511
+ if (scheduled === null || expiresAt < scheduled) {
512
+ await this.ctx.storage.setAlarm(expiresAt)
513
+ }
514
+ }
515
+
516
+ private async createSession(request: Request): Promise<Response> {
517
+ const body = await readJsonObject(request, MAX_SESSION_CREATE_BODY_BYTES)
518
+ const title = normalizeSessionTitle(
519
+ requireBoundedString(body.title, 'title', MAX_SESSION_TITLE_LENGTH),
520
+ MAX_SESSION_TITLE_BYTES,
521
+ )
522
+ if (title.length === 0) {
523
+ throw new EdgeHttpError(400, 'Session title must contain visible text.')
524
+ }
525
+ const session = await this.sessions.createSession({ title })
526
+ const presented = this.presentSession(session)
527
+ const attachmentError = await attachPublishedSession(
528
+ session.id,
529
+ EDGE_WORKSPACE_ID,
530
+ 'created',
531
+ async () => {
532
+ const summary = (await this.sessions.listApiSessions())
533
+ .find(item => item.id === session.id)
534
+ if (summary === undefined) {
535
+ throw new Error(`Session "${session.id}" is missing from its post-creation summary.`)
536
+ }
537
+ this.publishSessionCreated(summary)
538
+ const available = await this.workspaces.list(await this.sessions.listApiSessions())
539
+ if (available.items.some(item => item.workspaceId === EDGE_WORKSPACE_ID)) {
540
+ await this.publishSessionAttached(summary, EDGE_WORKSPACE_ID)
541
+ }
542
+ },
543
+ )
544
+ if (attachmentError !== undefined) {
545
+ return jsonResponse({
546
+ ok: false,
547
+ error: attachmentError.message,
548
+ code: attachmentError.code,
549
+ details: attachmentError.details,
550
+ session: presented,
551
+ }, 500)
552
+ }
553
+ return jsonResponse({ session: presented }, 201)
554
+ }
555
+
556
+ private async replayEvents(request: Request, url: URL, sessionId: SessionId): Promise<Response> {
557
+ const after = resolveAfterSequence(request, url)
558
+ const limit = resolveReplayLimit(url)
559
+ const page = await this.sessions.readEventPage(
560
+ sessionId,
561
+ after + 1,
562
+ limit,
563
+ MAX_REPLAY_RESPONSE_BYTES,
564
+ )
565
+ const chunks: Uint8Array[] = []
566
+ let byteLength = 0
567
+ let hasMore = page.hasMore
568
+ if (page.events.length === 0 && page.hasMore) {
569
+ throw new EdgeHttpError(413, 'The next session event exceeds the replay response limit.')
570
+ }
571
+ for (const event of page.events) {
572
+ const chunk = encodeSessionEvent(event)
573
+ if (byteLength + chunk.byteLength > MAX_REPLAY_RESPONSE_BYTES) {
574
+ if (chunks.length === 0) {
575
+ throw new EdgeHttpError(413, 'The next session event exceeds the replay response limit.')
576
+ }
577
+ hasMore = true
578
+ break
579
+ }
580
+ chunks.push(chunk)
581
+ byteLength += chunk.byteLength
582
+ }
583
+ const stream = new ReadableStream<Uint8Array>({
584
+ start(controller) {
585
+ for (const chunk of chunks) controller.enqueue(chunk)
586
+ controller.close()
587
+ },
588
+ })
589
+ const nextAfter = page.events[chunks.length - 1]?.seq ?? after
590
+ const headers = edgeEventStreamHeaders(corsHeaders())
591
+ headers.set('x-dsh-edge-has-more', String(hasMore))
592
+ headers.set('x-dsh-edge-next-after', String(nextAfter))
593
+ return new Response(stream, {
594
+ headers,
595
+ })
596
+ }
597
+
598
+ private async startTurn(request: Request, sessionId: SessionId): Promise<Response> {
599
+ const body = await readJsonObject(request, MAX_TURN_BODY_BYTES)
600
+ const message = requireBoundedUtf8String(body.message, 'message', MAX_MESSAGE_TEXT_BYTES)
601
+ const {
602
+ baseURL,
603
+ maxTokens,
604
+ reasoningEffort,
605
+ commandTimeoutPolicy,
606
+ streamIdleTimeoutMs,
607
+ } = resolveEdgeDeploymentConfig(this.env)
608
+ const claimed = await this.claimTurn(sessionId)
609
+
610
+ const { stream, completion } = createLiveSessionEventStream(
611
+ publish => this.runClaimedTurn({
612
+ baseURL,
613
+ claimed,
614
+ commandTimeoutPolicy,
615
+ mode: 'queue',
616
+ content: [{ type: 'text', text: message }],
617
+ maxTokens,
618
+ reasoningEffort,
619
+ streamIdleTimeoutMs,
620
+ publish,
621
+ }),
622
+ (error) => {
623
+ console.error('dsh-edge turn transport failed.', error)
624
+ },
625
+ )
626
+ this.ctx.waitUntil(completion.catch(() => undefined))
627
+
628
+ return new Response(stream, {
629
+ status: 200,
630
+ headers: edgeEventStreamHeaders(corsHeaders()),
631
+ })
632
+ }
633
+
634
+ private async startApiPrompt(input: {
635
+ sessionId: SessionId
636
+ mode: 'queue' | 'steer'
637
+ content: Extract<PromptContentPart, { type: 'text' }>[]
638
+ rpcId: RpcId
639
+ clientTimeZone?: string
640
+ }): Promise<void> {
641
+ if (messageTextByteLength(input.content) > MAX_MESSAGE_TEXT_BYTES) {
642
+ throw new EdgeHttpError(
643
+ 413,
644
+ `Prompt text is limited to ${MAX_MESSAGE_TEXT_BYTES} UTF-8 bytes.`,
645
+ )
646
+ }
647
+ while (true) {
648
+ const active = this.activeTurns.get(input.sessionId)
649
+ if (active !== undefined) {
650
+ await active.admissionReady
651
+ if (this.activeTurns.get(input.sessionId) === active
652
+ && active.accepting
653
+ && active.admit !== undefined) {
654
+ await active.admit({
655
+ mode: input.mode,
656
+ content: input.content,
657
+ rpcId: input.rpcId,
658
+ ...input.clientTimeZone === undefined
659
+ ? {}
660
+ : { clientTimeZone: input.clientTimeZone },
661
+ })
662
+ return
663
+ }
664
+ await active.releaseComplete
665
+ continue
666
+ }
667
+
668
+ const {
669
+ baseURL,
670
+ maxTokens,
671
+ reasoningEffort,
672
+ streamIdleTimeoutMs,
673
+ commandTimeoutPolicy,
674
+ } = resolveEdgeDeploymentConfig(this.env)
675
+ let claimed: Awaited<ReturnType<DshEdgeInstance['claimTurn']>>
676
+ try {
677
+ claimed = await this.claimTurn(input.sessionId)
678
+ } catch (error) {
679
+ if (error instanceof EdgeSessionStoreError && error.code === 'BUSY') continue
680
+ throw error
681
+ }
682
+ const running = this.runClaimedTurn({
683
+ claimed,
684
+ baseURL,
685
+ maxTokens,
686
+ reasoningEffort,
687
+ streamIdleTimeoutMs,
688
+ commandTimeoutPolicy,
689
+ mode: input.mode,
690
+ content: input.content,
691
+ rpcId: input.rpcId,
692
+ ...input.clientTimeZone === undefined
693
+ ? {}
694
+ : { clientTimeZone: input.clientTimeZone },
695
+ })
696
+ this.ctx.waitUntil(running.catch((error: unknown) => {
697
+ console.error('dsh-edge upstream protocol turn failed.', error)
698
+ }))
699
+ await claimed.turn.admissionReady
700
+ if (!claimed.turn.wasAdmitted) await running
701
+ return
702
+ }
703
+ }
704
+
705
+ private updateQueue(
706
+ sessionId: SessionId,
707
+ itemId: MessageId,
708
+ action: QueueAction,
709
+ ): 'accepted' | 'queue-item-not-found' | 'steer-unavailable' {
710
+ const active = this.activeTurns.get(sessionId)
711
+ const agent = active?.agent
712
+ if (agent === undefined) return 'queue-item-not-found'
713
+ const target = agent.inbox.nextTurn.some(message => message.id === itemId)
714
+ ? 'next-turn'
715
+ : agent.inbox.nextStep.some(message => message.id === itemId) ? 'next-step' : undefined
716
+ const message = target === undefined
717
+ ? undefined
718
+ : (target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep)
719
+ .find(candidate => candidate.id === itemId)
720
+ if (target === undefined || message === undefined) return 'queue-item-not-found'
721
+ if (action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) {
722
+ return 'steer-unavailable'
723
+ }
724
+ if (action.kind === 'edit') {
725
+ agent.inbox.replace(itemId, freezeMessage({ ...message, content: action.content }))
726
+ } else {
727
+ agent.inbox.remove(itemId)
728
+ if (action.kind === 'steer') agent.steer(message)
729
+ }
730
+ return 'accepted'
731
+ }
732
+
733
+ private async claimTurn(sessionId: SessionId): Promise<{
734
+ sessionId: SessionId
735
+ turn: ActiveTurn
736
+ handle: AgentHandle
737
+ }> {
738
+ const summary = await this.sessions.getApiSessionSummary(sessionId)
739
+ if (this.activeTurns.has(sessionId)) {
740
+ throw new EdgeSessionStoreError('BUSY', 'The session already has a running turn.')
741
+ }
742
+ this.rememberSessionListMetadata(summary)
743
+ const turn: ActiveTurn = {
744
+ turnId: EdgeTurnId(crypto.randomUUID()),
745
+ cancelRequested: false,
746
+ accepting: false,
747
+ wasAdmitted: false,
748
+ ...activeTurnSignals(),
749
+ }
750
+ // Claim before opening the Agent so interleaved DO requests cannot own the same session.
751
+ this.activeTurns.set(sessionId, turn)
752
+ try {
753
+ const handle = await this.sessions.openAgentForTurn(sessionId, this.model)
754
+ turn.agent = handle.agent
755
+ return { sessionId, turn, handle }
756
+ } catch (error) {
757
+ this.activeTurns.delete(sessionId)
758
+ turn.resolveAdmissionReady()
759
+ turn.resolveReleaseComplete()
760
+ throw error
761
+ }
762
+ }
763
+
764
+ private async runClaimedTurn(input: {
765
+ claimed: { sessionId: SessionId; turn: ActiveTurn; handle: AgentHandle }
766
+ baseURL: string
767
+ commandTimeoutPolicy: EdgeCommandTimeoutPolicy
768
+ maxTokens: number
769
+ content: Extract<PromptContentPart, { type: 'text' }>[]
770
+ reasoningEffort: EdgeReasoningEffort
771
+ streamIdleTimeoutMs: number
772
+ mode: 'queue' | 'steer'
773
+ rpcId?: RpcId
774
+ clientTimeZone?: string
775
+ publish?: (event: SessionEvent) => void | Promise<void>
776
+ }): Promise<void> {
777
+ const { sessionId, turn, handle } = input.claimed
778
+ try {
779
+ await this.runTurn({
780
+ ...input,
781
+ agent: handle.agent,
782
+ turn,
783
+ onAdmitted: (admit) => {
784
+ turn.admit = admit
785
+ turn.accepting = true
786
+ turn.wasAdmitted = true
787
+ this.publishRunning(sessionId, true)
788
+ turn.resolveAdmissionReady()
789
+ },
790
+ onClosing: () => {
791
+ turn.accepting = false
792
+ },
793
+ publish: async (event) => {
794
+ this.publishSessionEvent(sessionId, event)
795
+ await input.publish?.(event)
796
+ },
797
+ publishQueue: (items) => {
798
+ this.publishSessionQueue(sessionId, items)
799
+ },
800
+ })
801
+ } catch (error) {
802
+ this.publishAgentError(sessionId, error)
803
+ throw error
804
+ } finally {
805
+ turn.accepting = false
806
+ turn.resolveAdmissionReady()
807
+ await handle.dispose().catch((error: unknown) => {
808
+ console.error('dsh-edge failed to release the turn agent.', error)
809
+ })
810
+ const active = this.activeTurns.get(sessionId)
811
+ if (active?.turnId === turn.turnId) this.activeTurns.delete(sessionId)
812
+ turn.resolveReleaseComplete()
813
+ this.publishRunning(sessionId, false)
814
+ }
815
+ }
816
+
817
+ private async runTurn(input: {
818
+ agent: Agent
819
+ baseURL: string
820
+ commandTimeoutPolicy: EdgeCommandTimeoutPolicy
821
+ maxTokens: number
822
+ content: Extract<PromptContentPart, { type: 'text' }>[]
823
+ reasoningEffort: EdgeReasoningEffort
824
+ streamIdleTimeoutMs: number
825
+ mode: 'queue' | 'steer'
826
+ turn: ActiveTurn
827
+ rpcId?: RpcId
828
+ clientTimeZone?: string
829
+ publish: (event: SessionEvent) => void | Promise<void>
830
+ publishQueue: (items: QueuedInboxItem[]) => void | Promise<void>
831
+ onAdmitted?: (admit: EdgeAgentPromptAdmitter) => void
832
+ onClosing?: () => void
833
+ }): Promise<void> {
834
+ using workspace = await getWorkspace(this)
835
+ await this.sessions.runAgentTurn({
836
+ agent: input.agent,
837
+ baseURL: input.baseURL,
838
+ maxTokens: input.maxTokens,
839
+ reasoningEffort: input.reasoningEffort,
840
+ streamIdleTimeoutMs: input.streamIdleTimeoutMs,
841
+ mode: input.mode,
842
+ content: input.content,
843
+ ...input.rpcId === undefined ? {} : { rpcId: input.rpcId },
844
+ ...input.clientTimeZone === undefined
845
+ ? {}
846
+ : { clientTimeZone: input.clientTimeZone },
847
+ shell: {
848
+ exec: async (command, options) => executeWorkspaceCommand(
849
+ workspace,
850
+ requireCommand(command),
851
+ requireWorkspacePath(options.cwd),
852
+ input.commandTimeoutPolicy,
853
+ options.timeoutMs,
854
+ options.signal,
855
+ ),
856
+ },
857
+ afterFollowup: () => {
858
+ if (input.turn.cancelRequested) input.agent.cancel({ kind: 'user' })
859
+ },
860
+ ...input.onAdmitted === undefined ? {} : { onAdmitted: input.onAdmitted },
861
+ ...input.onClosing === undefined ? {} : { onClosing: input.onClosing },
862
+ publish: input.publish,
863
+ publishQueue: input.publishQueue,
864
+ })
865
+ }
866
+
867
+ private cancelTurn(sessionId: SessionId): Response {
868
+ const active = this.activeTurns.get(sessionId)
869
+ if (active === undefined || !this.requestTurnCancellation(sessionId)) {
870
+ throw new EdgeHttpError(409, 'The session has no active turn in this Worker instance.')
871
+ }
872
+ const response: CancelEdgeTurnResponse = {
873
+ ok: true,
874
+ sessionId,
875
+ turnId: active.turnId,
876
+ }
877
+ return jsonResponse(response, 202)
878
+ }
879
+
880
+ private requestTurnCancellation(sessionId: SessionId): boolean {
881
+ const active = this.activeTurns.get(sessionId)
882
+ if (active === undefined) return false
883
+ active.cancelRequested = true
884
+ active.agent?.cancel({ kind: 'user' })
885
+ return true
886
+ }
887
+
888
+ private presentSession(session: EdgeSession) {
889
+ return {
890
+ ...session,
891
+ status: this.activeTurns.has(session.id) ? 'running' as const : 'idle' as const,
892
+ }
893
+ }
894
+ }
895
+
896
+ function requireOwnerSessionExpiry(request: Request): number {
897
+ const source = request.headers.get(OWNER_SESSION_EXPIRY_HEADER)
898
+ if (source === null || !/^\d{10}$/u.test(source)) {
899
+ throw new EdgeHttpError(401, 'Owner authentication required.')
900
+ }
901
+ const expiresAt = Number(source)
902
+ if (!Number.isSafeInteger(expiresAt) || expiresAt * 1_000 <= Date.now()) {
903
+ throw new EdgeHttpError(401, 'Owner authentication required.')
904
+ }
905
+ return expiresAt
906
+ }
907
+
908
+ function readDownlinkAttachment(socket: WebSocket): DownlinkAttachment | undefined {
909
+ try {
910
+ const attachment: unknown = socket.deserializeAttachment()
911
+ if (typeof attachment !== 'object' || attachment === null) return undefined
912
+ const { channel, expiresAt } = attachment as Record<string, unknown>
913
+ if ((channel !== 'mux' && channel !== 'host')
914
+ || typeof expiresAt !== 'number'
915
+ || !Number.isSafeInteger(expiresAt)) {
916
+ return undefined
917
+ }
918
+ return { channel, expiresAt }
919
+ } catch {
920
+ return undefined
921
+ }
922
+ }
923
+
924
+ function activeTurnSignals(): Pick<ActiveTurn,
925
+ 'admissionReady' | 'releaseComplete' | 'resolveAdmissionReady' | 'resolveReleaseComplete'> {
926
+ const admission = Promise.withResolvers<void>()
927
+ const release = Promise.withResolvers<void>()
928
+ return {
929
+ admissionReady: admission.promise,
930
+ releaseComplete: release.promise,
931
+ resolveAdmissionReady: admission.resolve,
932
+ resolveReleaseComplete: release.resolve,
933
+ }
934
+ }
935
+
936
+ interface ParsedSessionRoute {
937
+ sessionId?: SessionId
938
+ action?: 'events' | 'turn' | 'cancel'
939
+ }
940
+
941
+ function parseSessionRoute(pathname: string): ParsedSessionRoute {
942
+ const parts = pathname.split('/').filter(Boolean)
943
+ if (parts[0] !== 'api' || parts[1] !== 'sessions' || parts.length > 4) {
944
+ throw new EdgeHttpError(404, 'Session route not found.')
945
+ }
946
+ if (parts.length === 2) return {}
947
+ let sessionId: string
948
+ try {
949
+ sessionId = decodeURIComponent(parts[2] ?? '')
950
+ } catch {
951
+ throw new EdgeHttpError(400, 'Invalid session id.')
952
+ }
953
+ if (sessionId.length === 0 || sessionId.length > MAX_SESSION_ID_LENGTH) {
954
+ throw new EdgeHttpError(400, 'Invalid session id.')
955
+ }
956
+ const id = SessionId(sessionId)
957
+ if (parts.length === 3) return { sessionId: id }
958
+ const action = parts[3]
959
+ if (action !== 'events' && action !== 'turn' && action !== 'cancel') {
960
+ throw new EdgeHttpError(404, 'Session route not found.')
961
+ }
962
+ return { sessionId: id, action }
963
+ }
964
+
965
+ function resolveAfterSequence(request: Request, url: URL): number {
966
+ const value = url.searchParams.get('after') ?? request.headers.get('last-event-id') ?? '-1'
967
+ if (!/^-?\d+$/.test(value)) throw new EdgeHttpError(400, 'after must be an integer.')
968
+ const sequence = Number(value)
969
+ if (!Number.isSafeInteger(sequence) || sequence < -1 || sequence >= Number.MAX_SAFE_INTEGER) {
970
+ throw new EdgeHttpError(400, 'after exceeds the supported integer range.')
971
+ }
972
+ return sequence
973
+ }
974
+
975
+ function resolveSessionListAfter(url: URL): SessionId | undefined {
976
+ const raw = url.searchParams.get('after')
977
+ if (raw === null) return undefined
978
+ if (raw.length === 0 || raw.length > MAX_SESSION_ID_LENGTH) {
979
+ throw new EdgeHttpError(400, 'Invalid session list cursor.')
980
+ }
981
+ return SessionId(raw)
982
+ }
983
+
984
+ function resolveSessionListLimit(url: URL): number {
985
+ const raw = url.searchParams.get('limit')
986
+ if (raw === null) return DEFAULT_SESSION_LIST_LIMIT
987
+ if (!/^\d+$/.test(raw)) throw new EdgeHttpError(400, 'limit must be a positive integer.')
988
+ const limit = Number(raw)
989
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_SESSION_LIST_LIMIT) {
990
+ throw new EdgeHttpError(400, `limit must be between 1 and ${MAX_SESSION_LIST_LIMIT}.`)
991
+ }
992
+ return limit
993
+ }
994
+
995
+ function resolveReplayLimit(url: URL): number {
996
+ const raw = url.searchParams.get('limit')
997
+ if (raw === null) return DEFAULT_REPLAY_EVENT_LIMIT
998
+ if (!/^\d+$/.test(raw)) throw new EdgeHttpError(400, 'limit must be a positive integer.')
999
+ const limit = Number(raw)
1000
+ if (!Number.isSafeInteger(limit) || limit <= 0 || limit > MAX_REPLAY_EVENT_LIMIT) {
1001
+ throw new EdgeHttpError(
1002
+ 400,
1003
+ `limit must be between 1 and ${MAX_REPLAY_EVENT_LIMIT}.`,
1004
+ )
1005
+ }
1006
+ return limit
1007
+ }