@oar-sh/oar 0.9.5 → 0.9.7

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 (119) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +12 -32
  2. package/.github/extensions/web-relay/skills/question-bridge.mjs +14 -39
  3. package/CHANGELOG.md +340 -0
  4. package/README.md +196 -26
  5. package/package.json +2 -1
  6. package/server/README.md +174 -7
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +340 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +1325 -0
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +155 -0
  10. package/server/claude-worker/claude-remote-relay-tool.mjs +2 -0
  11. package/server/claude-worker/claude-sdk-adapter.mjs +18 -2
  12. package/server/claude-worker/claude-session-process.mjs +151 -1
  13. package/server/claude-worker/claude-session-worker.mjs +6 -0
  14. package/server/claude-worker/claude-turn-failure.mjs +92 -0
  15. package/server/claude-worker/claude-turn-publisher.mjs +52 -3
  16. package/server/claude-worker/sdk-message-normalizer.mjs +13 -1
  17. package/server/copilot-worker/copilot-model-switch.mjs +29 -3
  18. package/server/copilot-worker/copilot-question-bridge.mjs +2 -4
  19. package/server/copilot-worker/copilot-runtime-tree.mjs +91 -0
  20. package/server/copilot-worker/copilot-sdk-adapter.mjs +48 -8
  21. package/server/copilot-worker/copilot-sdk-session-process.mjs +897 -166
  22. package/server/cursor-worker/cursor-sdk-adapter.mjs +80 -13
  23. package/server/cursor-worker/cursor-turn-runner.mjs +5 -2
  24. package/server/db-schema.mjs +24 -0
  25. package/server/grok-worker/acp-client.mjs +68 -12
  26. package/server/grok-worker/grok-sdk-adapter.mjs +4 -0
  27. package/server/grok-worker/grok-turn-runner.mjs +6 -2
  28. package/server/migrations/0006-workspace-root-settings.mjs +67 -0
  29. package/server/migrations/0007-claude-cloud.mjs +82 -0
  30. package/server/public/app/agent-sessions-settings-ui.js +197 -0
  31. package/server/public/app/agent-sessions-settings.mjs +134 -0
  32. package/server/public/app/api-client.js +160 -6
  33. package/server/public/app/attachments-view.js +20 -1
  34. package/server/public/app/attribution-control.mjs +46 -0
  35. package/server/public/app/bootstrap.js +347 -31
  36. package/server/public/app/claude-cloud-conversation-ui.js +110 -0
  37. package/server/public/app/claude-cloud-settings-ui.js +374 -0
  38. package/server/public/app/claude-cloud-ui.mjs +611 -0
  39. package/server/public/app/composer-control-state.mjs +15 -3
  40. package/server/public/app/context-menu.mjs +140 -0
  41. package/server/public/app/context-tier-options.mjs +4 -0
  42. package/server/public/app/conversation-origin-ui.js +27 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +22 -4
  44. package/server/public/app/conversation-view.js +178 -8
  45. package/server/public/app/cwd-picker.js +12 -0
  46. package/server/public/app/journal-view.js +704 -28
  47. package/server/public/app/markdown-strikethrough.mjs +31 -0
  48. package/server/public/app/math-dollars.mjs +89 -0
  49. package/server/public/app/model-selector-options.mjs +1 -1
  50. package/server/public/app/plan-usage-view.mjs +36 -8
  51. package/server/public/app/relay-board-inline.mjs +70 -0
  52. package/server/public/app/relay-board-view.js +157 -61
  53. package/server/public/app/relay-error-ctas.mjs +28 -5
  54. package/server/public/app/remote-relay-origin-view.mjs +120 -4
  55. package/server/public/app/router.js +49 -12
  56. package/server/public/app/settings-modal.js +33 -2
  57. package/server/public/app/settings-tabs.js +1 -1
  58. package/server/public/app/socket-handlers.js +60 -0
  59. package/server/public/app/store.js +4 -1
  60. package/server/public/app/subagent-fold.mjs +61 -0
  61. package/server/public/app/usage-limit-ui.js +225 -0
  62. package/server/public/index.html +471 -8
  63. package/server/relay-tools.md +13 -1
  64. package/server/repositories/message-repository.mjs +10 -1
  65. package/server/repositories/question-repository.mjs +1 -0
  66. package/server/repositories/remote-relay-repository.mjs +72 -1
  67. package/server/repositories/session-repository.mjs +47 -1
  68. package/server/routes/claude-cloud-routes.mjs +140 -0
  69. package/server/routes/git-routes.mjs +37 -0
  70. package/server/routes/messages-routes.mjs +202 -26
  71. package/server/routes/relay-board-routes.mjs +56 -2
  72. package/server/routes/remote-relay-routes.mjs +15 -1
  73. package/server/routes/sessions-routes.mjs +538 -72
  74. package/server/server-runtime.mjs +291 -27
  75. package/server/services/claude-account-usage-service.mjs +176 -0
  76. package/server/services/claude-cloud-repo-service.mjs +408 -0
  77. package/server/services/claude-cloud-session-service.mjs +326 -0
  78. package/server/services/claude-cloud-settings-service.mjs +269 -0
  79. package/server/services/git-remote-service.mjs +115 -0
  80. package/server/services/plan-usage-claude-cloud.mjs +211 -0
  81. package/server/services/plan-usage-claude.mjs +202 -40
  82. package/server/services/plan-usage-contract.mjs +39 -3
  83. package/server/services/plan-usage-service.mjs +82 -2
  84. package/server/services/preview-proxy-server.mjs +64 -36
  85. package/server/services/process-list-service.mjs +122 -0
  86. package/server/services/provider-reasoning-effort.mjs +3 -0
  87. package/server/services/relay-console-log.mjs +199 -0
  88. package/server/services/remote-relay-caller-context.mjs +78 -19
  89. package/server/services/remote-relay-client.mjs +9 -0
  90. package/server/services/remote-relay-dispatcher.mjs +451 -39
  91. package/server/services/remote-relay-inbound.mjs +83 -1
  92. package/server/services/remote-relay-loopback.mjs +245 -0
  93. package/server/services/remote-relay-registry-service.mjs +52 -0
  94. package/server/services/session-worker-launch-service.mjs +100 -15
  95. package/server/services/session-worker-process-service.mjs +410 -138
  96. package/server/services/session-worker-registry-service.mjs +1 -1
  97. package/server/services/session-worker-stop-service.mjs +24 -13
  98. package/server/services/session-worker-supervisor-service.mjs +89 -2
  99. package/server/services/usage-limit-pause-service.mjs +443 -0
  100. package/server/services/worker-launch-note-service.mjs +151 -0
  101. package/shared/ask-user-bridge.mjs +14 -39
  102. package/shared/claude-attribution.mjs +117 -0
  103. package/shared/claude-cloud/api-client.mjs +809 -0
  104. package/shared/claude-cloud/base-url.mjs +47 -0
  105. package/shared/claude-cloud/credentials.mjs +221 -0
  106. package/shared/claude-cloud/repo-url.mjs +111 -0
  107. package/shared/claude-usage-limit.mjs +257 -0
  108. package/shared/control-poller.mjs +69 -20
  109. package/shared/failure-note-text.mjs +38 -0
  110. package/shared/provider-routing.mjs +4 -2
  111. package/shared/question-wait.mjs +108 -0
  112. package/shared/remote-relay-contract.mjs +167 -22
  113. package/shared/remote-relay-tool-core.mjs +11 -8
  114. package/shared/steer-settle-failure.mjs +17 -0
  115. package/shared/steer-settle-markers.mjs +8 -0
  116. package/shared/worker-runtime/process-tree.mjs +483 -0
  117. package/shared/worker-runtime/relay-errors.mjs +31 -0
  118. package/shared/worker-runtime/send-and-wait-errors.mjs +4 -4
  119. package/shared/worker-runtime/turn-liveness.mjs +249 -0
@@ -0,0 +1,1325 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+
4
+ import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
5
+ import { createRelayQuestion } from '../../shared/question-wait.mjs';
6
+ import { DEFAULT_QUESTION_TIMEOUT_MS } from '../../shared/question-timeout.mjs';
7
+ import { EMPTY_TURN_COMPLETION_NOTE } from '../../shared/empty-turn-completion.mjs';
8
+ import { stripModelTierSuffix } from '../../shared/claude-cloud/repo-url.mjs';
9
+ import { resolveDeliveredAttribution, sameClaudeAttribution } from '../../shared/claude-attribution.mjs';
10
+ import { buildUsageLimitFailure, createUsageLimitTracker } from '../../shared/claude-usage-limit.mjs';
11
+ import { buildClaudeAttachmentContent } from '../claude-worker/claude-attachments.mjs';
12
+ import { classifyClaudeResultFailure } from '../claude-worker/claude-turn-failure.mjs';
13
+ import {
14
+ attemptFields,
15
+ createClaudeTurnPublisher,
16
+ isStaleAttemptError,
17
+ } from '../claude-worker/claude-turn-publisher.mjs';
18
+ import { summarizeToolInput } from '../claude-worker/sdk-message-normalizer.mjs';
19
+ import {
20
+ createClaudeCloudEventNormalizer,
21
+ readCloudResult,
22
+ readCloudSessionUsage,
23
+ toCloudSequence,
24
+ } from './claude-cloud-event-normalizer.mjs';
25
+
26
+ const PERMISSION_ALLOW = 'Allow';
27
+ const PERMISSION_DENY = 'Deny';
28
+ const QUESTION_UNANSWERED_MESSAGE = 'The user did not answer. Continue without this, or end the turn and say what you need.';
29
+ const PERMISSION_DENIED_MESSAGE = 'The user denied this tool use.';
30
+ // A catch-up after a worker restart reads the events of one unfinished turn;
31
+ // the cap only stops a cursor that never advances.
32
+ const MAX_CATCH_UP_PAGES = 200;
33
+ const ATTRIBUTION_NOT_APPLIED_NOTE = 'Cloud: the commit attribution setting could not be applied; '
34
+ + 'commits of this turn may carry Claude Code\'s own attribution lines.';
35
+
36
+ // Failures of the event stream that reconnecting cannot cure.
37
+ const FATAL_STREAM_CODES = new Set([
38
+ 'login_missing',
39
+ 'login_expired',
40
+ 'not_found',
41
+ 'bad_request',
42
+ 'github_not_connected',
43
+ 'repo_access_denied',
44
+ 'environment_missing',
45
+ ]);
46
+
47
+ // What the user can do about a failure the cloud client names. `message` says
48
+ // what happened, `guidance` what to do; the relay prints both.
49
+ const KNOWN_ERROR_REPLIES = Object.freeze({
50
+ login_missing: {
51
+ message: 'Claude Cloud needs the Claude login of this relay host, and there is none.',
52
+ guidance: 'Log in on Settings → Providers → Claude, then send the message again.',
53
+ },
54
+ login_expired: {
55
+ message: 'The Claude login of this relay host has expired.',
56
+ guidance: 'Log in again on Settings → Providers → Claude, then send the message again.',
57
+ },
58
+ github_not_connected: {
59
+ message: 'GitHub is not connected to the Claude account of this relay host.',
60
+ guidance: 'Connect it at https://claude.ai/connect-github, then send the message again.',
61
+ },
62
+ repo_access_denied: {
63
+ message: 'The Claude GitHub app has no access to this repository.',
64
+ guidance: 'Add the repository to the Claude GitHub app\'s repository access '
65
+ + '(GitHub → Settings → Applications → Claude → Configure), then send the message again.',
66
+ },
67
+ environment_missing: {
68
+ message: 'The cloud environment of this conversation is missing.',
69
+ guidance: 'Choose an environment on Settings → Providers → Claude Cloud, then send the message again.',
70
+ },
71
+ rate_limited: {
72
+ message: 'Claude Cloud refused the request because of a rate limit.',
73
+ guidance: 'Wait a little, then send the message again.',
74
+ },
75
+ not_found: {
76
+ message: 'The cloud session of this conversation was not found.',
77
+ guidance: 'It may have been deleted on claude.ai, or the relay host is logged in to another Claude account. '
78
+ + 'Start a new Claude Cloud chat.',
79
+ },
80
+ });
81
+
82
+ function sleepDefault(ms) {
83
+ return new Promise((resolve) => setTimeout(resolve, ms));
84
+ }
85
+
86
+ function truncate(text, maxLength) {
87
+ const value = String(text || '').trim();
88
+ if (value.length <= maxLength) return value;
89
+ return `${value.slice(0, Math.max(0, maxLength - 1))}…`;
90
+ }
91
+
92
+ /**
93
+ * The uuid of the user message a queue row becomes. Derived from the row id,
94
+ * not random: a row delivered a second time (requeue, worker restart) names
95
+ * the same message, so the worker finds it in the session's event log and
96
+ * follows the turn it already started instead of sending the prompt again.
97
+ */
98
+ export function claudeCloudMessageUuid(queueMessageId) {
99
+ const hex = crypto.createHash('sha256').update(`oar-claude-cloud-message:${queueMessageId}`).digest('hex');
100
+ const variant = ((parseInt(hex.slice(16, 18), 16) & 0x3f) | 0x80).toString(16);
101
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-${variant}${hex.slice(18, 20)}-${hex.slice(20, 32)}`;
102
+ }
103
+
104
+ /**
105
+ * The content of the cloud user message for a relay message: the text, plus
106
+ * an inline block per image. The sandbox cannot read the relay host's files,
107
+ * so there is no path fallback: every attachment that is not an embeddable
108
+ * image (see claude-attachments.mjs for types and size) is named in `refused`
109
+ * and the caller refuses the turn.
110
+ */
111
+ export function buildClaudeCloudUserContent(message, { fsImpl = fs } = {}) {
112
+ const text = String(message?.text || '').trim();
113
+ const attachments = (Array.isArray(message?.attachments) ? message.attachments : [])
114
+ .filter((attachment) => attachment && typeof attachment === 'object');
115
+ const imageBlocks = [];
116
+ const refused = [];
117
+ for (const attachment of attachments) {
118
+ const { imageBlocks: blocks } = buildClaudeAttachmentContent([attachment], { fsImpl });
119
+ if (blocks.length) imageBlocks.push(...blocks);
120
+ else refused.push(String(attachment.name || 'attachment').trim() || 'attachment');
121
+ }
122
+ if (refused.length) return { content: null, refused };
123
+ if (!imageBlocks.length) return { content: text, refused };
124
+ return { content: [...(text ? [{ type: 'text', text }] : []), ...imageBlocks], refused };
125
+ }
126
+
127
+ /**
128
+ * One Claude Cloud conversation: the cloud session it is bound to, the event
129
+ * stream of that session, and one turn at a time.
130
+ *
131
+ * A delivered message becomes a user event of the cloud session (the first
132
+ * one creates the session). The turn it starts is everything on the session's
133
+ * event stream after that event, up to the first `result`; events are
134
+ * numbered, the stream can be reopened after any number, and the number of
135
+ * the last finished turn is stored on the relay so a restarted worker can
136
+ * find its place again.
137
+ *
138
+ * `cloud` is the client of shared/claude-cloud/api-client.mjs (the worker
139
+ * never sees the token it holds), `api` the relay api client.
140
+ */
141
+ export function createClaudeCloudSessionRunner({
142
+ api,
143
+ cloud,
144
+ sdkSessionId = '',
145
+ defaultModel = '',
146
+ controlPoller = null,
147
+ dbg = () => {},
148
+ now = Date.now,
149
+ sleep = sleepDefault,
150
+ setTimeoutImpl = setTimeout,
151
+ clearTimeoutImpl = clearTimeout,
152
+ fsImpl = fs,
153
+ questionTimeoutMs = DEFAULT_QUESTION_TIMEOUT_MS,
154
+ askUserBridgeOptions = {},
155
+ reconnectMinMs = 1_000,
156
+ reconnectMaxMs = 15_000,
157
+ reconnectGiveUpMs = 5 * 60_000,
158
+ // The event stream is kept open while the process lives (0 = never
159
+ // closed for idleness): the session may take a turn of its own at any
160
+ // time, and the chat should show it. A value closes it after that long
161
+ // without a turn; the next delivery reopens it.
162
+ idleCloseMs = 0,
163
+ interruptGraceMs = 30_000,
164
+ sessionReadTimeoutMs = 10_000,
165
+ // No byte for this long, keepalives included, and the stream is taken for
166
+ // dead and reopened.
167
+ streamIdleTimeoutMs = 120_000,
168
+ // How often a turn the session starts by itself (see followStrayTurn) is
169
+ // registered again when the relay does not answer.
170
+ continuationRetryDelayMs = 1_000,
171
+ // How long a turn stays open for background work of the cloud agent (a
172
+ // subagent, a long command) after the agent ended its own turn; the relay's
173
+ // background-task timeout replaces it when a delivery carries one.
174
+ backgroundHoldMs = 60 * 60_000,
175
+ // Background work is over and nothing has moved for this long: ask the
176
+ // session whether the agent is still going to say something.
177
+ backgroundQuietMs = 45_000,
178
+ } = {}) {
179
+ const publisher = createClaudeTurnPublisher({ api, dbg });
180
+ // The account's usage limit as the sandbox's Claude Code reports it: the
181
+ // same events as a local session's, so the same tracker decides when a
182
+ // failed turn was refused at the limit (the relay then pauses the
183
+ // conversation until the reset instead of failing the turn).
184
+ const usageLimit = createUsageLimitTracker({ now });
185
+ const askUserBridge = createAskUserBridge({
186
+ api,
187
+ getActiveMessage: () => turn?.message || null,
188
+ sdkSessionId,
189
+ sleep,
190
+ questionTimeoutMs,
191
+ questionRationale: 'Claude Cloud requested clarification to continue this turn.',
192
+ dbg,
193
+ ...askUserBridgeOptions,
194
+ });
195
+
196
+ // The cloud session this conversation is bound to:
197
+ // - `lastSeq`: the last event handled; older ones are replays and skipped.
198
+ // - `cursorSeq`: the `result` of the last finished turn, which is what the
199
+ // relay stores. Never a point inside a turn: a restarted worker reads on
200
+ // from it to find the message it already sent.
201
+ // - `attached`: this process has read the session's log up to `lastSeq`.
202
+ // - `sent`: user messages posted whose turn has not ended (uuid → sequence).
203
+ // - `unfinishedTurns`: turns that ended here without their `result` and may
204
+ // still run in the cloud.
205
+ // - `attribution`: the commit attribution this process handed to the
206
+ // sandbox (null: Claude Code's own); undefined while it does not know
207
+ // what the sandbox has, as after a worker restart.
208
+ // - `attributionRequestId`: the request whose answer is still to come.
209
+ // - `model`: the model this process last gave the sandbox (the create's,
210
+ // or a later set_model); undefined while it does not know.
211
+ let session = null;
212
+ let turn = null;
213
+ let stream = null;
214
+ let idleTimer = null;
215
+ // Events are handled one at a time and in order; posting a user message
216
+ // takes a place in the same line, so no event of the turn it starts is
217
+ // looked at before the turn's start is known.
218
+ let chain = Promise.resolve();
219
+
220
+ function enqueue(task) {
221
+ const run = chain.then(task);
222
+ chain = run.catch((error) => dbg('cloud event handling failed', error?.message || String(error)));
223
+ return run;
224
+ }
225
+
226
+ function withTimeout(promise, ms) {
227
+ let timer = null;
228
+ return Promise.race([
229
+ promise,
230
+ new Promise((_, reject) => {
231
+ timer = setTimeoutImpl(() => reject(new Error(`no answer within ${ms} ms`)), ms);
232
+ timer?.unref?.();
233
+ }),
234
+ ]).finally(() => clearTimeoutImpl(timer));
235
+ }
236
+
237
+ function newSession(id, { url = null, cursor = 0, attached = false } = {}) {
238
+ return {
239
+ id,
240
+ url,
241
+ lastSeq: cursor,
242
+ cursorSeq: cursor,
243
+ attached,
244
+ sent: new Map(),
245
+ unfinishedTurns: 0,
246
+ attribution: undefined,
247
+ attributionRequestId: null,
248
+ model: undefined,
249
+ };
250
+ }
251
+
252
+ function resolveModel(message) {
253
+ const requested = String(message?.model || '').trim();
254
+ const perTurn = requested && requested.toLowerCase() !== 'auto' ? requested : '';
255
+ // The delivered message names the conversation's current model; the launch
256
+ // model is what it was when the worker started, and the model may still be
257
+ // changed until the first message creates the cloud session.
258
+ return stripModelTierSuffix(String(message?.providerModel || '').trim() || perTurn || defaultModel);
259
+ }
260
+
261
+ function getActiveQueueMessageId() {
262
+ return turn ? String(turn.message?.id || '') : '';
263
+ }
264
+
265
+ // Crash-guard shape: the attempt id fences the dying worker's requeue.
266
+ function getActiveQueueAttempt() {
267
+ if (!turn?.message?.id) return null;
268
+ return { id: String(turn.message.id), attemptId: turn.message.attemptId || null };
269
+ }
270
+
271
+ function isTurnActive() {
272
+ return Boolean(turn);
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Relay-facing publishing
277
+
278
+ /**
279
+ * Tell the relay which cloud session the conversation is bound to and how
280
+ * far it got. Advisory for the turn: a failed report costs the stored
281
+ * binding (this process keeps its own), never the reply.
282
+ */
283
+ async function reportSession(message, extra = {}) {
284
+ if (!session?.id) return;
285
+ await api('POST', '/api/claude-cloud-session', {
286
+ conversationId: message.conversationId,
287
+ cloudSessionId: session.id,
288
+ ...(session.url ? { sessionUrl: session.url } : {}),
289
+ lastSequence: String(session.cursorSeq),
290
+ ...extra,
291
+ }).catch((error) => dbg('cloud session report failed', error?.message || String(error)));
292
+ }
293
+
294
+ function buildTerminalError(message, { code, text, guidance = null, detail = null }) {
295
+ return {
296
+ kind: 'claude-cloud-turn-failed',
297
+ code,
298
+ stableCode: `claude-cloud.${code}`,
299
+ message: text,
300
+ ...(guidance ? { guidance } : {}),
301
+ ...(detail ? { detail } : {}),
302
+ failedAt: new Date(now()).toISOString(),
303
+ queueMessageId: String(message?.id || '') || null,
304
+ };
305
+ }
306
+
307
+ async function requeue(message) {
308
+ await api('POST', '/api/requeue', { messageId: message.id, ...attemptFields(message) }).catch((error) => {
309
+ if (isStaleAttemptError(error)) dbg('requeue refused as stale_attempt', message.id);
310
+ });
311
+ }
312
+
313
+ async function refuseAttachments(message, refused) {
314
+ const names = refused.map((name) => `"${name}"`).join(', ');
315
+ const text = `Claude Cloud takes images only, sent inline (JPEG, PNG, GIF or WebP, up to 5 MB each). Not sent: ${names}.`;
316
+ await publisher.publishResponse(message, {
317
+ text: `System note: ${text} The message was not sent to the cloud session.`,
318
+ model: null,
319
+ terminalError: buildTerminalError(message, {
320
+ code: 'attachment-unsupported',
321
+ text,
322
+ guidance: 'Remove the attachment, or put what the agent needs into the repository or the message, then send it again.',
323
+ }),
324
+ });
325
+ }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // The turn
329
+
330
+ function openTurn(message) {
331
+ let resolve = null;
332
+ const done = new Promise((resolvePromise) => { resolve = resolvePromise; });
333
+ return {
334
+ message,
335
+ uuid: claudeCloudMessageUuid(message.id),
336
+ model: resolveModel(message),
337
+ // What the commits of this turn should say (shared/claude-attribution.mjs):
338
+ // the settings object, null for Claude Code's own lines, undefined when
339
+ // the delivery did not say.
340
+ attribution: undefined,
341
+ // The sequence number the turn starts after (create: 0, send: the
342
+ // number of the user event); null until the message is in the session.
343
+ startSeq: null,
344
+ // A turn the session started by itself (openStrayTurn): nothing was
345
+ // delivered for it, and it is published under a continuation row.
346
+ stray: false,
347
+ sent: false,
348
+ settled: false,
349
+ interrupted: false,
350
+ interruptSent: false,
351
+ graceTimer: null,
352
+ // Background work the cloud agent left running (see holdForBackground).
353
+ backgroundTasks: 0,
354
+ interim: null,
355
+ // The sandbox's line about the usage limit, when a turn is refused at it.
356
+ usageLimitLine: '',
357
+ holdMs: backgroundHoldMs,
358
+ holdTimer: null,
359
+ quietTimer: null,
360
+ controlState: null,
361
+ normalizer: createClaudeCloudEventNormalizer(),
362
+ // The Claude worker's publisher reads and writes this.
363
+ state: {
364
+ responseModel: '',
365
+ lastStreamedText: '',
366
+ result: null,
367
+ modelUsage: null,
368
+ resultTexts: [],
369
+ contextUsage: null,
370
+ planUsage: null,
371
+ },
372
+ permissions: new Map(), // request id -> { controller, silent, responded }
373
+ done,
374
+ resolve,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * End the wait of a turn: `result` (the cloud finished it), `stopped` (Stop
380
+ * before anything was sent), `stop-unconfirmed` (Stop, and no `result`
381
+ * within the grace period), `gave-up` (the event stream stayed away) or
382
+ * `error`.
383
+ */
384
+ function settleTurn(t, outcome) {
385
+ if (t.settled) return;
386
+ t.settled = true;
387
+ if (t.graceTimer) clearTimeoutImpl(t.graceTimer);
388
+ t.graceTimer = null;
389
+ clearBackgroundTimers(t);
390
+ // The cloud cannot be told to forget a turn: one that ends here without
391
+ // its `result` may still write, and its `result` still comes. A turn
392
+ // given up for a lost stream is not one of them — it is delivered again
393
+ // and followed from where it started.
394
+ if (outcome.kind !== 'result' && outcome.kind !== 'gave-up' && t.sent && session) {
395
+ session.unfinishedTurns += 1;
396
+ }
397
+ t.resolve(outcome);
398
+ }
399
+
400
+ function clearBackgroundTimers(t) {
401
+ if (t.holdTimer) clearTimeoutImpl(t.holdTimer);
402
+ if (t.quietTimer) clearTimeoutImpl(t.quietTimer);
403
+ t.holdTimer = null;
404
+ t.quietTimer = null;
405
+ }
406
+
407
+ /**
408
+ * The cloud agent ended its turn while work it started in the background
409
+ * (a subagent, a long command) still runs. When that work finishes the agent
410
+ * takes another turn of its own, with a `result` of its own, and that turn
411
+ * carries the real answer. The relay turn therefore stays open: ending it on
412
+ * the first `result` would publish "still working on it" as the reply and
413
+ * lose everything the agent says afterwards, because nothing listens between
414
+ * relay turns.
415
+ */
416
+ async function holdForBackground(t, result) {
417
+ const first = !t.interim;
418
+ t.interim = { result };
419
+ if (!t.holdTimer) {
420
+ t.holdTimer = setTimeoutImpl(() => {
421
+ t.holdTimer = null;
422
+ enqueue(() => endBackgroundHold(t, 'ceiling')).catch(() => {});
423
+ }, t.holdMs);
424
+ t.holdTimer?.unref?.();
425
+ }
426
+ if (first) {
427
+ const count = t.backgroundTasks;
428
+ await publisher.postActivity(
429
+ t.message,
430
+ `Cloud: the agent paused while ${count} background task${count === 1 ? '' : 's'} `
431
+ + `${count === 1 ? 'is' : 'are'} still running; waiting for the result.`,
432
+ );
433
+ }
434
+ }
435
+
436
+ /**
437
+ * The background work is over. Normally the agent now takes its closing
438
+ * turn; a background command that simply ends may leave it silent. Nothing
439
+ * new for a while and an idle session mean the interim reply was the last
440
+ * word.
441
+ */
442
+ function scheduleQuietCheck(t) {
443
+ if (t.quietTimer) clearTimeoutImpl(t.quietTimer);
444
+ const seenAt = session?.lastSeq ?? 0;
445
+ t.quietTimer = setTimeoutImpl(() => {
446
+ t.quietTimer = null;
447
+ enqueue(async () => {
448
+ if (t.settled || !t.interim || t.backgroundTasks > 0 || !session) return;
449
+ if (session.lastSeq !== seenAt) {
450
+ scheduleQuietCheck(t);
451
+ return;
452
+ }
453
+ let workerStatus = '';
454
+ try {
455
+ ({ workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs)));
456
+ } catch (error) {
457
+ dbg('cloud session read failed', error?.message || String(error));
458
+ }
459
+ if (t.settled) return;
460
+ if (workerStatus === 'idle' && session.lastSeq === seenAt) await endBackgroundHold(t, 'quiet');
461
+ else scheduleQuietCheck(t);
462
+ }).catch(() => {});
463
+ }, backgroundQuietMs);
464
+ t.quietTimer?.unref?.();
465
+ }
466
+
467
+ /** End a held turn on the reply the agent gave before it paused. */
468
+ async function endBackgroundHold(t, reason) {
469
+ if (t.settled || !t.interim || !session) return;
470
+ if (reason === 'ceiling') {
471
+ // The background work still runs and its closing turn is still to come:
472
+ // that `result` belongs to this turn, not to the next message.
473
+ session.unfinishedTurns += 1;
474
+ await publisher.postActivity(
475
+ t.message,
476
+ `Cloud: background work is still running after ${Math.round(t.holdMs / 60_000)} min. The turn ends here; `
477
+ + `send a message to see how it went${session.url ? ` (${session.url})` : ''}.`,
478
+ );
479
+ }
480
+ t.state.result = t.interim.result;
481
+ t.state.modelUsage = t.interim.result.modelUsage;
482
+ session.cursorSeq = Math.max(session.cursorSeq, session.lastSeq);
483
+ settleTurn(t, { kind: 'result' });
484
+ }
485
+
486
+ function inTurn(t, sequence) {
487
+ return Boolean(t) && t.startSeq !== null && (sequence === null || sequence > t.startSeq);
488
+ }
489
+
490
+ /**
491
+ * Whether an event opens a turn nobody here asked for: a user message from
492
+ * another client (claude.ai), or the agent speaking (a closing turn after
493
+ * background work). Tool results, keepalive-ish system events and the
494
+ * clients' own control traffic do not.
495
+ */
496
+ function startsStrayTurn(event, payload, type) {
497
+ if (type === 'user') {
498
+ const content = payload?.message?.content;
499
+ const blocks = Array.isArray(content) ? content : [];
500
+ return typeof content === 'string' || blocks.some((block) => block?.type === 'text');
501
+ }
502
+ if (type === 'assistant') return true;
503
+ if (type === 'system') return ['init', 'background_tasks_changed'].includes(String(payload?.subtype || ''));
504
+ return false;
505
+ }
506
+
507
+ /**
508
+ * Register a turn the session started by itself with the relay (a
509
+ * continuation row, like the local workers') and follow it from this
510
+ * event on. Null when the relay gives no row: the turn is then skipped
511
+ * until its `result`, which the next delivery's bookkeeping accounts for.
512
+ */
513
+ async function openStrayTurn(sequence, payload, type) {
514
+ const operationId = crypto.randomUUID();
515
+ let response = null;
516
+ for (let attempt = 0; attempt < 3 && !response?.messageId; attempt += 1) {
517
+ response = await api('POST', '/api/continuation-turn', {
518
+ conversationId: sdkSessionId,
519
+ sdkSessionId,
520
+ relayMode: 'agent',
521
+ trigger: type === 'user' ? 'cloud_client_message' : 'cloud_turn',
522
+ operationId,
523
+ }).catch((error) => {
524
+ dbg('stray turn registration failed', error?.message || String(error));
525
+ return null;
526
+ });
527
+ if (!response?.messageId) await sleep(continuationRetryDelayMs);
528
+ }
529
+ if (!response?.messageId) {
530
+ dbg('stray cloud turn not registered; skipping it until its result');
531
+ session.unfinishedTurns += 1;
532
+ return null;
533
+ }
534
+ const message = {
535
+ id: String(response.messageId),
536
+ attemptId: response.attemptId ? String(response.attemptId) : null,
537
+ conversationId: sdkSessionId,
538
+ relayMode: 'agent',
539
+ kind: 'continuation',
540
+ text: '',
541
+ };
542
+ const t = openTurn(message);
543
+ t.stray = true;
544
+ t.startSeq = sequence - 1;
545
+ t.sent = true;
546
+ turn = t;
547
+ clearIdleClose();
548
+ t.controlState = controlPoller?.start?.({ queueMessageId: message.id, onAbortTurn: () => abortTurn(t) });
549
+ if (type === 'user') {
550
+ const content = payload?.message?.content;
551
+ const text = typeof content === 'string'
552
+ ? content
553
+ : (Array.isArray(content) ? content : []).filter((block) => block?.type === 'text').map((block) => String(block.text || '')).join('');
554
+ await publisher.postActivity(message, `Cloud: message sent on claude.ai: ${truncate(text, 400)}`);
555
+ } else {
556
+ await publisher.postActivity(message, 'Cloud: the agent continued on its own.');
557
+ }
558
+ // Runs beside the event line: the turn ends on its `result` like any other.
559
+ (async () => {
560
+ try {
561
+ await finishTurn(t, await t.done);
562
+ } catch (error) {
563
+ settleTurn(t, { kind: 'error', error });
564
+ await failTurn(t, error).catch(() => {});
565
+ } finally {
566
+ endTurn(t);
567
+ if (turn === t) turn = null;
568
+ scheduleIdleClose();
569
+ }
570
+ })();
571
+ return t;
572
+ }
573
+
574
+ async function handleEvent(event, sequence, { answered = null } = {}) {
575
+ if (!session) return;
576
+ if (sequence !== null) {
577
+ // The stream replays from the start when opened without a position,
578
+ // and a reconnect may repeat what was already seen.
579
+ if (sequence <= session.lastSeq) return;
580
+ session.lastSeq = sequence;
581
+ }
582
+ const payload = event?.payload && typeof event.payload === 'object' ? event.payload : {};
583
+ const type = String(payload.type || event?.event_type || '');
584
+ let t = turn && !turn.settled ? turn : null;
585
+
586
+ // No turn of ours, and the session is working: a turn the session
587
+ // started by itself (the agent's closing turn after its background work
588
+ // outlived the hold, or a message sent on claude.ai). It is followed as a
589
+ // continuation of its own, so the chat shows it.
590
+ if (!t && session.attached && sequence !== null && session.unfinishedTurns === 0
591
+ && sequence > session.cursorSeq && startsStrayTurn(event, payload, type)) {
592
+ t = await openStrayTurn(sequence, payload, type);
593
+ if (!t) return;
594
+ }
595
+
596
+ if (type === 'control_response' && session.attributionRequestId
597
+ && String(payload.response?.request_id || '') === session.attributionRequestId) {
598
+ session.attributionRequestId = null;
599
+ if (String(payload.response?.subtype || '') !== 'success') {
600
+ // Not taken: the next message tries again.
601
+ session.attribution = undefined;
602
+ dbg('cloud attribution refused by the sandbox', String(payload.response?.error || ''));
603
+ if (t) await publisher.postActivity(t.message, ATTRIBUTION_NOT_APPLIED_NOTE);
604
+ }
605
+ return;
606
+ }
607
+
608
+ if (type === 'user' && t && t.startSeq === null && sequence !== null
609
+ && (String(payload.uuid || '') === t.uuid || String(event?.event_id || '') === t.uuid)) {
610
+ // The delivered message is in the session already (this row was
611
+ // delivered before): its turn starts here.
612
+ t.startSeq = sequence;
613
+ return;
614
+ }
615
+
616
+ if (inTurn(t, sequence) && session.unfinishedTurns === 0) {
617
+ const report = usageLimit.observe(payload);
618
+ if (report) publisher.publishUsageLimitReport({ conversationId: t.message.conversationId, sdkSessionId, report });
619
+ // The sandbox's own line about the limit arrives as an assistant
620
+ // message (the result carries no text): kept apart, so the relay's
621
+ // note does not repeat it under what the turn had written.
622
+ if (type === 'assistant' && String(payload.error || '') === 'rate_limit') {
623
+ t.usageLimitLine = (Array.isArray(payload.message?.content) ? payload.message.content : [])
624
+ .filter((block) => block?.type === 'text').map((block) => String(block.text || '')).join('').trim();
625
+ }
626
+ }
627
+
628
+ if (type === 'result') {
629
+ // One `result` per user message, in the order the messages were sent:
630
+ // the first ones belong to turns that ended here without theirs.
631
+ if (session.unfinishedTurns > 0) {
632
+ session.unfinishedTurns -= 1;
633
+ return;
634
+ }
635
+ if (!inTurn(t, sequence)) return;
636
+ const result = readCloudResult(payload);
637
+ result.usageLimit = usageLimit.classifyResult(payload);
638
+ if (t.backgroundTasks > 0 && !t.interrupted && !result.isError && !result.interrupted) {
639
+ await holdForBackground(t, result);
640
+ return;
641
+ }
642
+ t.state.result = result;
643
+ t.state.modelUsage = result.modelUsage;
644
+ session.cursorSeq = Math.max(session.cursorSeq, sequence ?? session.lastSeq);
645
+ settleTurn(t, { kind: 'result' });
646
+ return;
647
+ }
648
+
649
+ if (type === 'system' && String(payload.subtype || '') === 'background_tasks_changed'
650
+ && inTurn(t, sequence) && session.unfinishedTurns === 0) {
651
+ t.backgroundTasks = Array.isArray(payload.tasks) ? payload.tasks.length : 0;
652
+ if (t.interim) {
653
+ if (t.backgroundTasks === 0) scheduleQuietCheck(t);
654
+ else if (t.quietTimer) {
655
+ clearTimeoutImpl(t.quietTimer);
656
+ t.quietTimer = null;
657
+ }
658
+ }
659
+ }
660
+
661
+ // What an unfinished earlier turn still writes is not this turn's output.
662
+ if (!inTurn(t, sequence) || session.unfinishedTurns > 0) return;
663
+ for (const action of t.normalizer.normalize(event, { sequence })) {
664
+ await dispatchAction(t, action, { answered });
665
+ }
666
+ }
667
+
668
+ async function dispatchAction(t, action, { answered = null } = {}) {
669
+ const { channel, payload } = action;
670
+ if (channel === 'push') {
671
+ await reportSession(t.message, { pushedBranch: payload.branch });
672
+ return;
673
+ }
674
+ if (channel === 'permission') {
675
+ // Read back from the log after a restart: a request answered further
676
+ // down must not open a card.
677
+ if (!answered?.has(payload.requestId)) startPermissionRequest(t, payload);
678
+ return;
679
+ }
680
+ if (channel === 'permission_settled') {
681
+ const entry = t.permissions.get(payload.requestId);
682
+ if (entry && !entry.responded) {
683
+ // Answered from another client (claude.ai): the card here is moot.
684
+ entry.silent = true;
685
+ entry.controller.abort();
686
+ }
687
+ return;
688
+ }
689
+ await publisher.dispatchAction(t.message, action, t.state);
690
+ }
691
+
692
+ // ---------------------------------------------------------------------------
693
+ // Questions and permission prompts
694
+
695
+ async function askPermission(t, request, signal) {
696
+ const message = t.message;
697
+ const display = String(request.displayName || request.toolName || 'a tool').trim();
698
+ const summary = truncate(summarizeToolInput(request.toolName, request.input), 400);
699
+ const questionId = await createRelayQuestion({
700
+ api,
701
+ sleep,
702
+ signal,
703
+ dbg,
704
+ payload: {
705
+ queueId: message.id,
706
+ messageId: message.id,
707
+ conversationId: message.conversationId,
708
+ mode: message.relayMode || 'agent',
709
+ prompt: [`Claude Cloud asks for permission to use ${display}.`, summary].filter(Boolean).join('\n\n'),
710
+ choices: [PERMISSION_ALLOW, PERMISSION_DENY],
711
+ allowFreeform: false,
712
+ sdk_session_id: sdkSessionId || undefined,
713
+ attemptId: message.attemptId || undefined,
714
+ timeout_ms: questionTimeoutMs,
715
+ context: {
716
+ source: 'ClaudeCloudPermission',
717
+ rationale: 'The cloud agent needs your approval to continue this turn.',
718
+ queueMessageId: message.id || null,
719
+ conversationId: message.conversationId || null,
720
+ relayMode: message.relayMode || 'agent',
721
+ },
722
+ },
723
+ });
724
+ const result = await askUserBridge.waitForRelayQuestionAnswer(questionId, { signal });
725
+ if (result.timedOut || signal.aborted) return { behavior: 'deny', message: QUESTION_UNANSWERED_MESSAGE };
726
+ if (result.answer === PERMISSION_ALLOW) return { behavior: 'allow', updatedInput: request.input };
727
+ return { behavior: 'deny', message: PERMISSION_DENIED_MESSAGE };
728
+ }
729
+
730
+ async function askUser(t, request, signal) {
731
+ if (request.toolName !== 'AskUserQuestion') return askPermission(t, request, signal);
732
+ // Same cards as the Claude worker's: one per question, options as
733
+ // buttons (checkmarks for multiSelect, whose labels the card joins with
734
+ // ", "), answers keyed by the question text.
735
+ const { answers, timedOut } = await askUserBridge.handleAskUserQuestion(request.input, { signal });
736
+ if (timedOut || signal.aborted) return { behavior: 'deny', message: QUESTION_UNANSWERED_MESSAGE };
737
+ return { behavior: 'allow', updatedInput: { ...request.input, answers } };
738
+ }
739
+
740
+ /**
741
+ * The agent waits for the user. Runs beside the event line, not in it: a
742
+ * card may stay open for hours, and the `result` of a turn stopped
743
+ * meanwhile has to get through.
744
+ */
745
+ function startPermissionRequest(t, request) {
746
+ const requestId = String(request.requestId || '').trim();
747
+ if (!requestId || t.permissions.has(requestId)) return;
748
+ const entry = { controller: new AbortController(), silent: false, responded: false };
749
+ t.permissions.set(requestId, entry);
750
+ (async () => {
751
+ let response = null;
752
+ try {
753
+ response = await askUser(t, request, entry.controller.signal);
754
+ } catch (error) {
755
+ dbg('cloud question bridge failed', request.toolName, error?.message || String(error));
756
+ response = {
757
+ behavior: 'deny',
758
+ message: `Relay bridge failed for ${request.toolName || 'the request'}: ${error?.message || error}`,
759
+ };
760
+ }
761
+ if (entry.silent) return;
762
+ entry.responded = true;
763
+ await cloud.sendControlResponse(session.id, { requestId, response });
764
+ })()
765
+ .catch((error) => dbg('cloud control response failed', error?.message || String(error)))
766
+ .finally(() => {
767
+ if (t.permissions.get(requestId) === entry) t.permissions.delete(requestId);
768
+ });
769
+ }
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // Stop
773
+
774
+ async function sendInterruptOnce(t) {
775
+ if (t.interruptSent || t.settled || !t.sent || !session) return;
776
+ t.interruptSent = true;
777
+ try {
778
+ await cloud.sendInterrupt(session.id);
779
+ } catch (error) {
780
+ dbg('cloud interrupt failed', error?.message || String(error));
781
+ }
782
+ if (t.settled) return;
783
+ // The interrupted turn ends with its own `result`, normally at once. The
784
+ // sandbox cannot be stopped from here, so without one the turn ends
785
+ // locally and says so.
786
+ t.graceTimer = setTimeoutImpl(() => settleTurn(t, { kind: 'stop-unconfirmed' }), interruptGraceMs);
787
+ t.graceTimer?.unref?.();
788
+ }
789
+
790
+ async function abortTurn(t) {
791
+ if (t.settled) return;
792
+ t.interrupted = true;
793
+ // Open cards end as unanswered, which the agent is told as a denial.
794
+ for (const entry of t.permissions.values()) entry.controller.abort();
795
+ // Before the message is in the session there is nothing to interrupt
796
+ // yet; startCloudTurn looks at the flag.
797
+ await sendInterruptOnce(t);
798
+ }
799
+
800
+ // ---------------------------------------------------------------------------
801
+ // Event stream
802
+
803
+ async function handleFrame(frame) {
804
+ // session_update, delivery_update and ephemeral_event frames carry
805
+ // nothing a turn needs.
806
+ if (String(frame?.kind || '') !== 'client_event') return;
807
+ const event = frame.data && typeof frame.data === 'object' ? frame.data : null;
808
+ if (!event) return;
809
+ await handleEvent(event, toCloudSequence(frame.id) ?? toCloudSequence(event.sequence_num));
810
+ }
811
+
812
+ /**
813
+ * Follow the session's events for as long as a turn waits on them. The
814
+ * client does not reconnect by itself: it throws when the stream cannot be
815
+ * opened and resolves when an open one ends, whatever the reason. Mid-turn
816
+ * either is a drop, and the stream is reopened after the last event
817
+ * handled, with a growing pause, until it has stayed away for
818
+ * `reconnectGiveUpMs`. Between turns a stream that ends is left closed; the
819
+ * next delivery opens it again.
820
+ */
821
+ async function followStream(entry) {
822
+ const { signal } = entry.controller;
823
+ let delay = reconnectMinMs;
824
+ let downSince = null;
825
+ try {
826
+ while (!signal.aborted) {
827
+ let failure = null;
828
+ let ended = null;
829
+ try {
830
+ ended = await cloud.openEventStream(session.id, {
831
+ lastEventId: session.lastSeq > 0 ? String(session.lastSeq) : null,
832
+ signal,
833
+ idleTimeoutMs: streamIdleTimeoutMs,
834
+ onOpen: () => { downSince = null; },
835
+ // Not awaited by the client: the frame takes its place in the
836
+ // line and the stream is read on.
837
+ onEvent: (frame) => {
838
+ if (signal.aborted) return;
839
+ downSince = null;
840
+ delay = reconnectMinMs;
841
+ enqueue(() => handleFrame(frame)).catch(() => {});
842
+ },
843
+ });
844
+ } catch (error) {
845
+ failure = error;
846
+ }
847
+ // What the stream delivered is handled before the turn is looked at.
848
+ await chain;
849
+ if (signal.aborted) return;
850
+ const t = turn;
851
+ const code = String(failure?.code || '').trim();
852
+ if (failure && FATAL_STREAM_CODES.has(code)) {
853
+ if (t && !t.settled) settleTurn(t, { kind: 'error', error: failure });
854
+ return;
855
+ }
856
+ // Between turns the stream is kept too: the session may take a turn
857
+ // of its own (followStream is left alone by the idle close until the
858
+ // session has been quiet for idleCloseMs).
859
+ if (!t || t.settled) {
860
+ if (downSince === null) downSince = now();
861
+ else if (now() - downSince >= reconnectGiveUpMs) return;
862
+ await sleep(delay);
863
+ delay = Math.min(delay * 2, reconnectMaxMs);
864
+ continue;
865
+ }
866
+ if (downSince === null) {
867
+ downSince = now();
868
+ } else if (now() - downSince >= reconnectGiveUpMs) {
869
+ settleTurn(t, { kind: 'gave-up' });
870
+ return;
871
+ }
872
+ dbg(
873
+ 'cloud event stream dropped mid-turn; reconnecting',
874
+ `reason=${failure ? 'not-opened' : (ended?.reason || 'ended')}`,
875
+ `after=${session.lastSeq}`,
876
+ `in=${delay}ms`,
877
+ String(failure?.message || ended?.error?.message || ''),
878
+ );
879
+ await sleep(delay);
880
+ delay = Math.min(delay * 2, reconnectMaxMs);
881
+ }
882
+ } finally {
883
+ // In the same breath as the decision to stop: a delivery that arrives
884
+ // next must see that there is no stream and open one.
885
+ if (stream === entry) stream = null;
886
+ }
887
+ }
888
+
889
+ function ensureStream() {
890
+ if (stream || !session?.id) return;
891
+ const entry = { controller: new AbortController(), loop: null };
892
+ stream = entry;
893
+ entry.loop = followStream(entry).catch((error) => {
894
+ dbg('cloud event stream loop failed', error?.message || String(error));
895
+ });
896
+ }
897
+
898
+ async function closeStream() {
899
+ const entry = stream;
900
+ stream = null;
901
+ entry?.controller.abort();
902
+ // Frames it had already handed over are still handled, in order.
903
+ await chain;
904
+ }
905
+
906
+ function clearIdleClose() {
907
+ if (idleTimer) clearTimeoutImpl(idleTimer);
908
+ idleTimer = null;
909
+ }
910
+
911
+ function scheduleIdleClose() {
912
+ clearIdleClose();
913
+ if (!stream || !(idleCloseMs > 0)) return;
914
+ idleTimer = setTimeoutImpl(() => {
915
+ idleTimer = null;
916
+ if (turn) return;
917
+ dbg('closing the idle cloud event stream');
918
+ void closeStream();
919
+ }, idleCloseMs);
920
+ idleTimer?.unref?.();
921
+ }
922
+
923
+ /**
924
+ * After a worker restart: read the session's log on from the stored
925
+ * position. If the delivered message is in it, its turn was started by the
926
+ * previous worker, and everything that turn wrote so far is published
927
+ * again under this attempt.
928
+ */
929
+ async function catchUp() {
930
+ const events = [];
931
+ let cursor = session.lastSeq > 0 ? String(session.lastSeq) : null;
932
+ for (let page = 0; page < MAX_CATCH_UP_PAGES; page += 1) {
933
+ const result = await cloud.listEvents(session.id, { ...(cursor ? { cursor } : {}), sortOrder: 'asc' });
934
+ const batch = Array.isArray(result?.events) ? result.events : [];
935
+ events.push(...batch);
936
+ const next = result?.nextCursor ?? null;
937
+ if (!batch.length || next === null || String(next) === String(cursor)) break;
938
+ cursor = String(next);
939
+ }
940
+ const answered = new Set(events
941
+ .filter((event) => String(event?.payload?.type || event?.event_type || '') === 'control_response')
942
+ .map((event) => String(event?.payload?.response?.request_id || '').trim())
943
+ .filter(Boolean));
944
+ await enqueue(async () => {
945
+ for (const event of events) {
946
+ await handleEvent(event, toCloudSequence(event?.sequence_num), { answered });
947
+ }
948
+ });
949
+ }
950
+
951
+ /**
952
+ * A turn that ended here without its `result` is only still to be reckoned
953
+ * with while the cloud is working: an idle session has no `result` left to
954
+ * send, and waiting for one would swallow the next turn's.
955
+ */
956
+ async function confirmUnfinishedTurns() {
957
+ try {
958
+ const { workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs));
959
+ if (workerStatus === 'idle') session.unfinishedTurns = 0;
960
+ } catch (error) {
961
+ dbg('cloud session read failed', error?.message || String(error));
962
+ }
963
+ }
964
+
965
+ /**
966
+ * Before a message goes into a session that exists: hand the sandbox the
967
+ * commit attribution the relay delivered, unless this process already did.
968
+ * The sandbox's Claude Code takes it as a session setting, the way a local
969
+ * one takes `applyFlagSettings`, and null takes it out again. A request the
970
+ * API refuses outright costs the setting, not the turn; every other failure
971
+ * would stop the message too and is left to the turn's own handling.
972
+ */
973
+ async function applyAttribution(t) {
974
+ if (t.attribution === undefined) return;
975
+ if (session.attribution !== undefined && sameClaudeAttribution(session.attribution, t.attribution)) return;
976
+ try {
977
+ const sent = await cloud.applyFlagSettings(session.id, { attribution: t.attribution });
978
+ session.attribution = t.attribution;
979
+ session.attributionRequestId = String(sent?.requestId || '').trim() || null;
980
+ } catch (error) {
981
+ if (String(error?.code || '').trim() !== 'bad_request') throw error;
982
+ dbg('cloud attribution request refused', error?.message || String(error));
983
+ await publisher.postActivity(t.message, ATTRIBUTION_NOT_APPLIED_NOTE);
984
+ }
985
+ }
986
+
987
+ /**
988
+ * Before a message goes into a session that exists: switch the sandbox's
989
+ * model when the conversation's model is not the one this process last
990
+ * gave it (a `set_model` control request; the sandbox re-initialises with
991
+ * the new model and the next turn runs on it). A request the API refuses
992
+ * outright costs the switch, not the turn.
993
+ */
994
+ async function applyModel(t) {
995
+ const model = String(t.model || '').trim();
996
+ if (!model) return;
997
+ if (session.model !== undefined && session.model === model) return;
998
+ try {
999
+ await cloud.setModel(session.id, model);
1000
+ session.model = model;
1001
+ } catch (error) {
1002
+ if (String(error?.code || '').trim() !== 'bad_request') throw error;
1003
+ dbg('cloud set_model refused', error?.message || String(error));
1004
+ await publisher.postActivity(t.message, `Cloud: the model could not be switched to ${model}; the turn runs on the session's model.`);
1005
+ }
1006
+ }
1007
+
1008
+ /** Put the delivered message into the cloud session, unless it is there already. */
1009
+ async function startCloudTurn(t, binding, content) {
1010
+ const message = t.message;
1011
+ const boundId = String(binding.sessionId || '').trim() || session?.id || '';
1012
+
1013
+ if (!boundId) {
1014
+ // The client refuses a create without environment, repository or
1015
+ // model with the code the reply below is built from.
1016
+ const created = await cloud.createSession({
1017
+ title: String(binding.title || '').trim(),
1018
+ environmentId: String(binding.environmentId || '').trim(),
1019
+ model: t.model,
1020
+ repoUrl: String(binding.repoUrl || '').trim(),
1021
+ branch: String(binding.branch || '').trim() || null,
1022
+ content,
1023
+ uuid: t.uuid,
1024
+ // With the message, and before it: the first commit already carries it.
1025
+ ...(t.attribution ? { flagSettings: { attribution: t.attribution } } : {}),
1026
+ });
1027
+ session = newSession(String(created?.id || '').trim(), {
1028
+ url: String(created?.sessionUrl || '').trim() || null,
1029
+ attached: true,
1030
+ });
1031
+ if (!session.id) {
1032
+ session = null;
1033
+ throw new Error('The cloud did not return a session id.');
1034
+ }
1035
+ t.startSeq = 0;
1036
+ t.sent = true;
1037
+ session.sent.set(t.uuid, 0);
1038
+ // A new sandbox without the setting writes Claude Code's own lines.
1039
+ session.attribution = t.attribution ?? null;
1040
+ session.model = t.model;
1041
+ session.attributionRequestId = String(created?.flagSettingsRequestId || '').trim() || null;
1042
+ await reportSession(message, t.model ? { model: t.model } : {});
1043
+ return;
1044
+ }
1045
+
1046
+ if (!session || session.id !== boundId) {
1047
+ await closeStream();
1048
+ session = newSession(boundId, { cursor: toCloudSequence(binding.lastSequence) ?? 0 });
1049
+ }
1050
+
1051
+ const sentAt = session.sent.get(t.uuid);
1052
+ if (sentAt !== undefined) {
1053
+ // The same row again (requeued after the stream stayed away): the cloud
1054
+ // has the message. Replay its turn from the start so this attempt
1055
+ // publishes all of it, instead of sending the prompt twice.
1056
+ await closeStream();
1057
+ session.lastSeq = Math.min(session.lastSeq, sentAt);
1058
+ t.startSeq = sentAt;
1059
+ t.sent = true;
1060
+ return;
1061
+ }
1062
+
1063
+ if (!session.attached) {
1064
+ await catchUp();
1065
+ session.attached = true;
1066
+ if (t.startSeq !== null) {
1067
+ t.sent = true;
1068
+ session.sent.set(t.uuid, t.startSeq);
1069
+ return;
1070
+ }
1071
+ }
1072
+
1073
+ if (t.interrupted) {
1074
+ settleTurn(t, { kind: 'stopped' });
1075
+ return;
1076
+ }
1077
+ if (session.unfinishedTurns > 0) await confirmUnfinishedTurns();
1078
+ let alreadyInSession = false;
1079
+ await enqueue(async () => {
1080
+ await applyAttribution(t);
1081
+ await applyModel(t);
1082
+ const sent = await cloud.sendUserMessage(session.id, content, { uuid: t.uuid });
1083
+ const sequence = toCloudSequence(sent?.sequence);
1084
+ // No event is handled while the message is posted, so a new message is
1085
+ // numbered after everything seen. One that is not was in the session
1086
+ // before (the cloud recognised its uuid).
1087
+ alreadyInSession = sequence !== null && sequence <= session.lastSeq;
1088
+ if (t.startSeq === null) t.startSeq = sequence ?? session.lastSeq;
1089
+ t.sent = true;
1090
+ session.sent.set(t.uuid, t.startSeq);
1091
+ });
1092
+ if (alreadyInSession) {
1093
+ await closeStream();
1094
+ session.lastSeq = Math.min(session.lastSeq, t.startSeq);
1095
+ }
1096
+ }
1097
+
1098
+ async function publishResult(t) {
1099
+ const { message, state } = t;
1100
+ const result = state.result;
1101
+ const responseModel = state.responseModel || t.model || null;
1102
+ const streamed = String(state.lastStreamedText || '').trim();
1103
+
1104
+ if (t.interrupted) {
1105
+ // Stopped from the relay: surface what streamed and let the relay's
1106
+ // abort control own the row's fate, as the other workers do.
1107
+ await publisher.publishFinalStream(message, streamed);
1108
+ return;
1109
+ }
1110
+ if (result.interrupted) {
1111
+ // Stopped from another client. No abort control exists on the relay
1112
+ // for this row, so it needs its answer.
1113
+ const text = streamed || 'System note: the cloud turn was stopped from another client before it wrote a reply.';
1114
+ await publisher.publishFinalStream(message, text);
1115
+ await publisher.publishResponse(message, { text, model: responseModel });
1116
+ return;
1117
+ }
1118
+ if (result.usageLimit) {
1119
+ // Refused at the usage limit: the relay pauses the turn until the reset.
1120
+ // What the turn had written stays above the relay's note, without the
1121
+ // sandbox's own line about the limit.
1122
+ const refusalText = String(result.usageLimit.text || t.usageLimitLine || '').trim();
1123
+ let written = streamed;
1124
+ if (refusalText && written.endsWith(refusalText)) written = written.slice(0, -refusalText.length).trim();
1125
+ await publisher.publishFinalStream(message, streamed);
1126
+ await publisher.publishResponse(message, {
1127
+ text: result.text || refusalText || result.errors[0] || 'Claude usage limit reached.',
1128
+ model: responseModel,
1129
+ terminalError: buildUsageLimitFailure(message, result.usageLimit),
1130
+ partialText: written,
1131
+ });
1132
+ return;
1133
+ }
1134
+ if (result.isError) {
1135
+ const failure = classifyClaudeResultFailure(result);
1136
+ const errorText = result.text || result.errors[0]
1137
+ || `Claude Cloud turn failed (${result.subtype || 'unknown error'}).`;
1138
+ await publisher.publishFinalStream(message, streamed);
1139
+ await publisher.publishResponse(message, {
1140
+ text: errorText,
1141
+ model: responseModel,
1142
+ terminalError: buildTerminalError(message, { code: failure.code, text: errorText, guidance: failure.guidance }),
1143
+ });
1144
+ return;
1145
+ }
1146
+ // A turn that ends on tool activity alone is a completed turn, not a
1147
+ // failed delivery (see empty-turn-completion.mjs).
1148
+ const text = result.text || streamed || EMPTY_TURN_COMPLETION_NOTE;
1149
+ await publisher.publishFinalStream(message, text);
1150
+ await publisher.publishResponse(message, { text, model: responseModel });
1151
+ }
1152
+
1153
+ /**
1154
+ * After the reply: store how far the session got, and read the session
1155
+ * once for what the events do not carry (context occupancy, the session's
1156
+ * cost so far). Advisory, and bounded: it runs inside the turn, so the
1157
+ * next delivery waits for it.
1158
+ */
1159
+ async function publishUsage(t) {
1160
+ const { message, state } = t;
1161
+ const model = state.responseModel || t.model || '';
1162
+ let usage = { contextUsage: null, costUsd: null, sessionUrl: null };
1163
+ try {
1164
+ usage = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs), { model });
1165
+ } catch (error) {
1166
+ dbg('cloud session read failed', error?.message || String(error));
1167
+ }
1168
+ if (usage.sessionUrl) session.url = usage.sessionUrl;
1169
+ const costUsd = usage.costUsd ?? state.result?.totalCostUsd ?? null;
1170
+ await reportSession(message, {
1171
+ ...(costUsd !== null ? { costUsd } : {}),
1172
+ ...(model ? { model } : {}),
1173
+ });
1174
+ state.contextUsage = usage.contextUsage;
1175
+ await publisher.publishContextUsage({ message, state, model, sdkSessionId });
1176
+ await publisher.publishPlanUsage({ message, state, sdkSessionId });
1177
+ }
1178
+
1179
+ async function finishTurn(t, outcome) {
1180
+ const { message, state } = t;
1181
+ const streamed = String(state.lastStreamedText || '').trim();
1182
+ if (outcome.kind === 'result') {
1183
+ session.sent.delete(t.uuid);
1184
+ await publishResult(t);
1185
+ await publishUsage(t);
1186
+ return;
1187
+ }
1188
+ if (outcome.kind === 'gave-up') {
1189
+ if (t.stray) {
1190
+ // Nothing to deliver again: the row ends on what was seen.
1191
+ await publisher.publishFinalStream(message, streamed);
1192
+ await publisher.publishResponse(message, { text: streamed || EMPTY_TURN_COMPLETION_NOTE, model: state.responseModel || t.model || null });
1193
+ return;
1194
+ }
1195
+ // The message stays in `session.sent`: delivered again, its turn is
1196
+ // followed from the start instead of being sent a second time.
1197
+ await publisher.postActivity(
1198
+ message,
1199
+ 'Cloud: the connection to the cloud session stayed away; the turn is picked up again.',
1200
+ );
1201
+ await requeue(message);
1202
+ return;
1203
+ }
1204
+ if (outcome.kind === 'error') throw outcome.error;
1205
+ session?.sent.delete(t.uuid);
1206
+ if (outcome.kind === 'stop-unconfirmed') {
1207
+ await publisher.postActivity(
1208
+ message,
1209
+ `Cloud: no confirmation of the stop within ${Math.round(interruptGraceMs / 1000)} s. The turn ends here; `
1210
+ + `the cloud agent may still be working${session?.url ? ` (${session.url})` : ''}.`,
1211
+ );
1212
+ }
1213
+ await publisher.publishFinalStream(message, streamed);
1214
+ }
1215
+
1216
+ async function failTurn(t, error) {
1217
+ const message = t.message;
1218
+ const code = String(error?.code || '').trim();
1219
+ const detailText = String(error?.message || error || 'unknown error');
1220
+ dbg('cloud turn failed', code || 'error', detailText);
1221
+ if (code === 'transient' && !t.state.result) {
1222
+ await requeue(message);
1223
+ return;
1224
+ }
1225
+ session?.sent.delete(t.uuid);
1226
+ const known = KNOWN_ERROR_REPLIES[code] || null;
1227
+ // A 429 may say how long to wait.
1228
+ const waitSeconds = Math.ceil(Number(error?.retryAfterMs) / 1000);
1229
+ const guidance = known && code === 'rate_limited' && waitSeconds > 0
1230
+ ? `Wait about ${waitSeconds} s, then send the message again.`
1231
+ : known?.guidance;
1232
+ await publisher.publishResponse(message, {
1233
+ text: known
1234
+ ? `System note: ${known.message} ${guidance}`
1235
+ : `System note: the Claude Cloud turn failed (${detailText}). Retry or send a new message.`,
1236
+ model: null,
1237
+ terminalError: buildTerminalError(message, known
1238
+ ? { code, text: known.message, guidance, detail: error?.detail ? String(error.detail) : null }
1239
+ : { code: 'turn-error', text: detailText }),
1240
+ });
1241
+ }
1242
+
1243
+ async function runTurn(t) {
1244
+ const message = t.message;
1245
+ const binding = message.claudeCloud && typeof message.claudeCloud === 'object' ? message.claudeCloud : {};
1246
+ const { content, refused } = buildClaudeCloudUserContent(message, { fsImpl });
1247
+ if (refused.length) {
1248
+ await refuseAttachments(message, refused);
1249
+ return true;
1250
+ }
1251
+ t.controlState = controlPoller?.start?.({
1252
+ queueMessageId: message.id,
1253
+ onAbortTurn: () => abortTurn(t),
1254
+ });
1255
+ await startCloudTurn(t, binding, content);
1256
+ if (!t.settled) ensureStream();
1257
+ if (t.interrupted) await sendInterruptOnce(t);
1258
+ await finishTurn(t, await t.done);
1259
+ return true;
1260
+ }
1261
+
1262
+ function endTurn(t) {
1263
+ controlPoller?.stop?.(t.controlState);
1264
+ if (t.graceTimer) clearTimeoutImpl(t.graceTimer);
1265
+ t.graceTimer = null;
1266
+ clearBackgroundTimers(t);
1267
+ for (const entry of t.permissions.values()) {
1268
+ // The turn is over: a card still open is closed without an answer to
1269
+ // the cloud. One the Stop already closed still sends its denial.
1270
+ if (entry.controller.signal.aborted) continue;
1271
+ entry.silent = true;
1272
+ entry.controller.abort();
1273
+ }
1274
+ }
1275
+
1276
+ /**
1277
+ * Run one delivered message as one cloud turn. Single-flight: a delivery
1278
+ * while a turn runs is not accepted (the cloud would queue it as a turn of
1279
+ * its own, but v1 has no steering).
1280
+ */
1281
+ async function handlePendingPayload(pending) {
1282
+ const message = pending?.message || pending;
1283
+ if (!message?.id) return false;
1284
+ if (turn) {
1285
+ dbg('turn already active; rejecting concurrent deliver', message.id);
1286
+ return false;
1287
+ }
1288
+ clearIdleClose();
1289
+ const t = openTurn(message);
1290
+ const relayHoldMs = Number(pending?.settings?.backgroundTaskTimeoutMs);
1291
+ if (Number.isFinite(relayHoldMs) && relayHoldMs > 0) t.holdMs = relayHoldMs;
1292
+ const settings = pending?.settings;
1293
+ if (settings && typeof settings === 'object' && Object.prototype.hasOwnProperty.call(settings, 'attribution')) {
1294
+ t.attribution = resolveDeliveredAttribution(null, settings);
1295
+ }
1296
+ turn = t;
1297
+ try {
1298
+ return await runTurn(t);
1299
+ } catch (error) {
1300
+ settleTurn(t, { kind: 'error', error });
1301
+ await failTurn(t, error);
1302
+ return true;
1303
+ } finally {
1304
+ endTurn(t);
1305
+ turn = null;
1306
+ scheduleIdleClose();
1307
+ }
1308
+ }
1309
+
1310
+ async function dispose() {
1311
+ clearIdleClose();
1312
+ if (turn) endTurn(turn);
1313
+ const entry = stream;
1314
+ stream = null;
1315
+ entry?.controller.abort();
1316
+ }
1317
+
1318
+ return {
1319
+ handlePendingPayload,
1320
+ getActiveQueueMessageId,
1321
+ getActiveQueueAttempt,
1322
+ isTurnActive,
1323
+ dispose,
1324
+ };
1325
+ }