@oar-sh/oar 0.9.20 → 0.9.21

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 (95) hide show
  1. package/CHANGELOG.md +212 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-session-process.mjs +7 -0
  9. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +74 -1
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  13. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  15. package/server/grok-worker/grok-session-worker.mjs +12 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +3 -0
  17. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  18. package/server/public/app/about-ui.js +1 -1
  19. package/server/public/app/about-view.mjs +19 -0
  20. package/server/public/app/action-confirmations.js +1 -1
  21. package/server/public/app/api-client.js +4 -2
  22. package/server/public/app/ask-user-view.js +20 -6
  23. package/server/public/app/attachments-view.js +1 -1
  24. package/server/public/app/back-guard.mjs +31 -5
  25. package/server/public/app/bootstrap.js +556 -68
  26. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  27. package/server/public/app/chat-header-meta.mjs +27 -0
  28. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  29. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  30. package/server/public/app/composer-settings-label.mjs +90 -0
  31. package/server/public/app/composer-settings-ui.js +442 -0
  32. package/server/public/app/context-menu.mjs +36 -5
  33. package/server/public/app/conversation-deep-link.mjs +15 -11
  34. package/server/public/app/conversation-group-drag.mjs +12 -2
  35. package/server/public/app/conversation-group-order.mjs +9 -2
  36. package/server/public/app/conversation-group-state.mjs +47 -10
  37. package/server/public/app/conversation-origin-ui.js +17 -1
  38. package/server/public/app/conversation-view.js +3 -0
  39. package/server/public/app/cwd-menu-placement.mjs +18 -6
  40. package/server/public/app/cwd-picker.js +36 -6
  41. package/server/public/app/emoji-view.js +1 -1
  42. package/server/public/app/font-scaling.js +26 -0
  43. package/server/public/app/header-priority-actions.mjs +234 -0
  44. package/server/public/app/hidden-messages-view.mjs +4 -0
  45. package/server/public/app/inbox-ui.js +18 -2
  46. package/server/public/app/journal-view.js +311 -58
  47. package/server/public/app/phone-landscape-ui.js +225 -0
  48. package/server/public/app/phone-landscape.mjs +99 -0
  49. package/server/public/app/phone-typing-state.mjs +104 -0
  50. package/server/public/app/pinned-messages-view.mjs +5 -0
  51. package/server/public/app/push-settings.js +2 -2
  52. package/server/public/app/pwa-install.js +24 -3
  53. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  54. package/server/public/app/relay-event-view.mjs +38 -0
  55. package/server/public/app/relay-restart-ui.js +2 -2
  56. package/server/public/app/relay-schedule-view.mjs +32 -6
  57. package/server/public/app/relay-schedules-ui.js +69 -20
  58. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  59. package/server/public/app/session-history-ui.js +250 -0
  60. package/server/public/app/session-history.mjs +599 -0
  61. package/server/public/app/settings-modal.js +61 -6
  62. package/server/public/app/settings-tabs.js +12 -0
  63. package/server/public/app/share-state.js +1 -1
  64. package/server/public/app/shared-conversations-settings.js +2 -2
  65. package/server/public/app/socket-handlers.js +5 -2
  66. package/server/public/app/status-view.mjs +18 -2
  67. package/server/public/app/store.js +87 -16
  68. package/server/public/app/subagent-viewer.mjs +27 -2
  69. package/server/public/app/tab-strip-fade.mjs +101 -0
  70. package/server/public/app/tap-actions.js +66 -0
  71. package/server/public/app/usage-limit-ui.js +63 -2
  72. package/server/public/app/viewer-history.mjs +13 -2
  73. package/server/public/index.html +2249 -1590
  74. package/server/relay-tools.md +1 -0
  75. package/server/repositories/message-repository.mjs +23 -0
  76. package/server/repositories/session-repository.mjs +19 -0
  77. package/server/routes/claude-cloud-routes.mjs +19 -0
  78. package/server/routes/messages-routes.mjs +57 -1
  79. package/server/routes/sessions-routes.mjs +41 -7
  80. package/server/server-runtime.mjs +190 -40
  81. package/server/services/about-service.mjs +22 -53
  82. package/server/services/claude-billing-swap-service.mjs +7 -20
  83. package/server/services/claude-cloud-session-service.mjs +47 -5
  84. package/server/services/remote-relay-dispatcher.mjs +61 -14
  85. package/server/services/session-activity-tracker.mjs +1 -1
  86. package/server/services/session-launch-mutex-service.mjs +23 -0
  87. package/server/services/session-worker-registry-service.mjs +36 -0
  88. package/server/services/session-worker-websocket-service.mjs +40 -16
  89. package/server/services/worker-recycle-service.mjs +772 -0
  90. package/server/services/worker-replacement.mjs +197 -0
  91. package/shared/build-id.mjs +128 -0
  92. package/shared/worker-runtime/heartbeat.mjs +5 -0
  93. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  94. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  95. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -178,6 +178,18 @@ export function createClaudeCloudSessionRunner({
178
178
  // Background work is over and nothing has moved for this long: ask the
179
179
  // session whether the agent is still going to say something.
180
180
  backgroundQuietMs = 45_000,
181
+ // Between turns, while the cloud may still be working on something no
182
+ // relay turn owns (its last known status was not idle, or a turn ended here
183
+ // without its `result`), the session is read again this often: the relay
184
+ // waits on that answer before it replaces this worker (releaseHold).
185
+ statusRecheckMs = 20_000,
186
+ // A session that stays not idle this long is no longer read (one warning
187
+ // line); the next delivery starts the reads again. The hold stays: the
188
+ // relay does not replace a worker whose session it last saw working.
189
+ statusRecheckGiveUpMs = 2 * 60 * 60_000,
190
+ // How often the stored binding is asked for again at start when the relay
191
+ // does not answer (attachOnStart); three tries in all.
192
+ bindingRetryMs = 5_000,
181
193
  } = {}) {
182
194
  const publisher = createClaudeTurnPublisher({ api, dbg });
183
195
  // The account's usage limit as the sandbox's Claude Code reports it: the
@@ -211,10 +223,23 @@ export function createClaudeCloudSessionRunner({
211
223
  // - `attributionRequestId`: the request whose answer is still to come.
212
224
  // - `model`: the model this process last gave the sandbox (the create's,
213
225
  // or a later set_model); undefined while it does not know.
226
+ // - `workerStatus`: the session's `worker_status` at the last read of it
227
+ // ('idle' when the cloud has nothing running), null while unknown.
214
228
  let session = null;
215
229
  let turn = null;
216
230
  let stream = null;
217
231
  let idleTimer = null;
232
+ let statusTimer = null;
233
+ // A read of the session between turns that may attach it (syncSession):
234
+ // a delivery waits for it, so the two never read the log at once.
235
+ let pendingSync = null;
236
+ // When the current run of status rechecks began (null: none runs), and
237
+ // whether it was given up (statusRecheckGiveUpMs).
238
+ let recheckSince = null;
239
+ let recheckGivenUp = false;
240
+ // A turn the session started by itself is being registered with the relay.
241
+ let openingStrayTurn = false;
242
+ let disposed = false;
218
243
  // Events are handled one at a time and in order; posting a user message
219
244
  // takes a place in the same line, so no event of the turn it starts is
220
245
  // looked at before the turn's start is known.
@@ -249,6 +274,7 @@ export function createClaudeCloudSessionRunner({
249
274
  attribution: undefined,
250
275
  attributionRequestId: null,
251
276
  model: undefined,
277
+ workerStatus: null,
252
278
  };
253
279
  }
254
280
 
@@ -275,6 +301,33 @@ export function createClaudeCloudSessionRunner({
275
301
  return Boolean(turn);
276
302
  }
277
303
 
304
+ /**
305
+ * Why the relay may not stop this worker and launch a fresh one now, or ''
306
+ * when it may (the hold names of the relay's STEERING_RELEASE_HOLDS):
307
+ * - 'turn': a turn runs here, delivered or one the session started itself
308
+ * (from the moment the relay is asked for its row).
309
+ * - 'background': no turn runs here, but the cloud may still be working on
310
+ * one: a turn that ended here without its `result` (a Stop the cloud did
311
+ * not confirm, the background-hold ceiling, a turn the relay would not
312
+ * register), or a session whose last known status is not idle. A worker
313
+ * replaced then would lose what that work still writes: the count lives
314
+ * only in this process, and a fresh one reads on from the stored cursor
315
+ * as if nothing were running.
316
+ * A session this process has not read yet holds nothing.
317
+ */
318
+ function releaseHold() {
319
+ if (turn || openingStrayTurn) return 'turn';
320
+ if (!session) return '';
321
+ if (session.unfinishedTurns > 0) return 'background';
322
+ if (session.workerStatus && session.workerStatus !== 'idle') return 'background';
323
+ return '';
324
+ }
325
+
326
+ /** Remember what a read of the session said about the cloud's work. */
327
+ function noteSessionRead(workerStatus) {
328
+ if (session) session.workerStatus = workerStatus || null;
329
+ }
330
+
278
331
  // ---------------------------------------------------------------------------
279
332
  // Relay-facing publishing
280
333
 
@@ -456,6 +509,7 @@ export function createClaudeCloudSessionRunner({
456
509
  let workerStatus = '';
457
510
  try {
458
511
  ({ workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs)));
512
+ noteSessionRead(workerStatus);
459
513
  } catch (error) {
460
514
  dbg('cloud session read failed', error?.message || String(error));
461
515
  }
@@ -532,6 +586,9 @@ export function createClaudeCloudSessionRunner({
532
586
  if (!response?.messageId) {
533
587
  dbg('stray cloud turn not registered; skipping it until its result');
534
588
  session.unfinishedTurns += 1;
589
+ // No turn here will end the wait on its `result`; the session's
590
+ // status will.
591
+ scheduleStatusRecheck();
535
592
  return null;
536
593
  }
537
594
  const message = {
@@ -569,6 +626,7 @@ export function createClaudeCloudSessionRunner({
569
626
  endTurn(t);
570
627
  if (turn === t) turn = null;
571
628
  scheduleIdleClose();
629
+ scheduleStatusRecheck();
572
630
  }
573
631
  })();
574
632
  return t;
@@ -592,7 +650,14 @@ export function createClaudeCloudSessionRunner({
592
650
  // continuation of its own, so the chat shows it.
593
651
  if (!t && session.attached && sequence !== null && session.unfinishedTurns === 0
594
652
  && sequence > session.cursorSeq && startsStrayTurn(event, payload, type)) {
595
- t = await openStrayTurn(sequence, payload, type);
653
+ // The relay is asked for a row first: the turn runs already, and the
654
+ // release holds meanwhile as it will once the row is there.
655
+ openingStrayTurn = true;
656
+ try {
657
+ t = await openStrayTurn(sequence, payload, type);
658
+ } finally {
659
+ openingStrayTurn = false;
660
+ }
596
661
  if (!t) return;
597
662
  }
598
663
 
@@ -963,12 +1028,159 @@ export function createClaudeCloudSessionRunner({
963
1028
  async function confirmUnfinishedTurns() {
964
1029
  try {
965
1030
  const { workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs));
1031
+ noteSessionRead(workerStatus);
966
1032
  if (workerStatus === 'idle') session.unfinishedTurns = 0;
967
1033
  } catch (error) {
968
1034
  dbg('cloud session read failed', error?.message || String(error));
969
1035
  }
970
1036
  }
971
1037
 
1038
+ // ---------------------------------------------------------------------------
1039
+ // Between turns: the session's status, and the attach at start
1040
+
1041
+ /**
1042
+ * The session's status, read once (bounded); null when the read failed, in
1043
+ * which case what was known before stays.
1044
+ */
1045
+ async function readWorkerStatus() {
1046
+ try {
1047
+ const { workerStatus } = readCloudSessionUsage(await withTimeout(cloud.getSession(session.id), sessionReadTimeoutMs));
1048
+ noteSessionRead(workerStatus);
1049
+ return workerStatus || null;
1050
+ } catch (error) {
1051
+ dbg('cloud session read failed', error?.message || String(error));
1052
+ return null;
1053
+ }
1054
+ }
1055
+
1056
+ /**
1057
+ * Read the session again later while it holds the release with no turn
1058
+ * here to end the wait (a turn's end schedules this itself), or while the
1059
+ * session bound at start is not attached yet.
1060
+ */
1061
+ function scheduleStatusRecheck() {
1062
+ if (statusTimer || disposed || !session || turn) return;
1063
+ if (session.attached && !releaseHold()) {
1064
+ recheckSince = null;
1065
+ return;
1066
+ }
1067
+ if (recheckGivenUp) return;
1068
+ if (recheckSince === null) {
1069
+ recheckSince = now();
1070
+ } else if (now() - recheckSince >= statusRecheckGiveUpMs) {
1071
+ // Not forever: a session stuck in a working state would be read every
1072
+ // statusRecheckMs for the life of the worker.
1073
+ recheckGivenUp = true;
1074
+ dbg(
1075
+ `warning: the cloud session has not been idle for ${Math.round(statusRecheckGiveUpMs / 60_000)} min;`,
1076
+ 'no more status reads until the next delivery, and the relay does not replace this worker meanwhile',
1077
+ );
1078
+ return;
1079
+ }
1080
+ statusTimer = setTimeoutImpl(() => {
1081
+ statusTimer = null;
1082
+ void syncSession();
1083
+ }, statusRecheckMs);
1084
+ statusTimer?.unref?.();
1085
+ }
1086
+
1087
+ function clearStatusRecheck() {
1088
+ if (statusTimer) clearTimeoutImpl(statusTimer);
1089
+ statusTimer = null;
1090
+ }
1091
+
1092
+ /**
1093
+ * Between turns: read the session, and act on an idle one. An idle cloud
1094
+ * has no `result` left to send, so the turns that ended here without one
1095
+ * are done with (as confirmUnfinishedTurns does before a send). An idle
1096
+ * session bound at start but not attached yet is read on from the stored
1097
+ * cursor up to now and its stream opened, so a turn the session starts by
1098
+ * itself from here on is followed as a continuation, as the worker before
1099
+ * this one did. A session that is working is not attached: the turn it
1100
+ * runs may be a delivered one a dead worker left behind, which the relay
1101
+ * delivers again and the delivery follows from the log (catchUp); taken
1102
+ * for a turn of the session's own, it would be published twice.
1103
+ * A delivery waits for this (pendingSync).
1104
+ */
1105
+ function syncSession() {
1106
+ if (pendingSync) return pendingSync;
1107
+ if (disposed || !session || turn || openingStrayTurn) return Promise.resolve(false);
1108
+ const run = (async () => {
1109
+ let attachedNow = false;
1110
+ const status = await readWorkerStatus();
1111
+ if (!disposed && session && !turn && status === 'idle') {
1112
+ session.unfinishedTurns = 0;
1113
+ if (!session.attached) {
1114
+ await catchUp();
1115
+ if (!disposed && session && !session.attached) {
1116
+ session.attached = true;
1117
+ ensureStream();
1118
+ attachedNow = true;
1119
+ dbg('cloud event stream opened between turns', `after=${session.lastSeq}`);
1120
+ }
1121
+ }
1122
+ }
1123
+ return attachedNow;
1124
+ })();
1125
+ pendingSync = run
1126
+ .catch((error) => {
1127
+ dbg('cloud session sync failed', error?.message || String(error));
1128
+ return false;
1129
+ })
1130
+ .finally(() => {
1131
+ pendingSync = null;
1132
+ scheduleStatusRecheck();
1133
+ });
1134
+ return pendingSync;
1135
+ }
1136
+
1137
+ /** The binding the relay stores for this conversation, or null. */
1138
+ async function readStoredBinding() {
1139
+ const routePath = `/api/claude-cloud-session?conversationId=${encodeURIComponent(sdkSessionId)}`;
1140
+ for (let attempt = 1; ; attempt += 1) {
1141
+ try {
1142
+ return await api('GET', routePath);
1143
+ } catch (error) {
1144
+ // A relay without the route answers 404 for good: the first delivery
1145
+ // binds the session, as before.
1146
+ if (Number(error?.status) === 404 || attempt >= 3 || disposed) {
1147
+ dbg('stored cloud session not read', error?.message || String(error));
1148
+ return null;
1149
+ }
1150
+ await sleep(bindingRetryMs);
1151
+ }
1152
+ }
1153
+ }
1154
+
1155
+ /**
1156
+ * At worker start: bind the cloud session the relay stores for this
1157
+ * conversation and open its event stream from the stored cursor, before
1158
+ * any delivery. The worker this one replaces kept its stream open for its
1159
+ * whole life (idleCloseMs = 0) and so mirrored the turns the session
1160
+ * starts by itself (a message sent on claude.ai, the agent's closing turn
1161
+ * after background work); without this a fresh worker would see none of
1162
+ * them until its first delivery. A conversation with no cloud session yet
1163
+ * has nothing to attach: its first delivery creates one. A session that is
1164
+ * working at start is read again every statusRecheckMs and attached once
1165
+ * idle (syncSession says why not before).
1166
+ *
1167
+ * Resolves true when the stream was opened.
1168
+ */
1169
+ async function attachOnStart() {
1170
+ if (session || turn || disposed || pendingSync) return false;
1171
+ const run = (async () => {
1172
+ const binding = await readStoredBinding();
1173
+ const id = String(binding?.sessionId || '').trim();
1174
+ if (!id || session || disposed) return false;
1175
+ session = newSession(id, { cursor: toCloudSequence(binding.lastSequence) ?? 0 });
1176
+ return true;
1177
+ })();
1178
+ // A delivery that arrives meanwhile waits until the binding is known.
1179
+ pendingSync = run.catch(() => false).finally(() => { pendingSync = null; });
1180
+ if (!await pendingSync) return false;
1181
+ return syncSession();
1182
+ }
1183
+
972
1184
  /**
973
1185
  * Before a message goes into a session that exists: hand the sandbox the
974
1186
  * commit attribution the relay delivered, unless this process already did.
@@ -1173,6 +1385,7 @@ export function createClaudeCloudSessionRunner({
1173
1385
  dbg('cloud session read failed', error?.message || String(error));
1174
1386
  }
1175
1387
  if (usage.sessionUrl) session.url = usage.sessionUrl;
1388
+ if (usage.workerStatus !== undefined) noteSessionRead(usage.workerStatus);
1176
1389
  const costUsd = usage.costUsd ?? state.result?.totalCostUsd ?? null;
1177
1390
  await reportSession(message, {
1178
1391
  ...(costUsd !== null ? { costUsd } : {}),
@@ -1318,7 +1531,19 @@ export function createClaudeCloudSessionRunner({
1318
1531
  dbg('turn already active; rejecting concurrent deliver', message.id);
1319
1532
  return false;
1320
1533
  }
1534
+ // The session is being read between turns (the attach at start, or a
1535
+ // status check): this delivery binds and reads it after that, never
1536
+ // beside it. A turn the session started meanwhile takes precedence.
1537
+ while (pendingSync) await pendingSync;
1538
+ if (turn) {
1539
+ dbg('turn already active; rejecting concurrent deliver', message.id);
1540
+ return false;
1541
+ }
1321
1542
  clearIdleClose();
1543
+ clearStatusRecheck();
1544
+ // A delivery starts the status reads afresh, also after they were given up.
1545
+ recheckSince = null;
1546
+ recheckGivenUp = false;
1322
1547
  const t = openTurn(message);
1323
1548
  const relayHoldMs = Number(pending?.settings?.backgroundTaskTimeoutMs);
1324
1549
  if (Number.isFinite(relayHoldMs) && relayHoldMs > 0) t.holdMs = relayHoldMs;
@@ -1337,11 +1562,14 @@ export function createClaudeCloudSessionRunner({
1337
1562
  endTurn(t);
1338
1563
  turn = null;
1339
1564
  scheduleIdleClose();
1565
+ scheduleStatusRecheck();
1340
1566
  }
1341
1567
  }
1342
1568
 
1343
1569
  async function dispose() {
1570
+ disposed = true;
1344
1571
  clearIdleClose();
1572
+ clearStatusRecheck();
1345
1573
  if (turn) endTurn(turn);
1346
1574
  const entry = stream;
1347
1575
  stream = null;
@@ -1353,6 +1581,8 @@ export function createClaudeCloudSessionRunner({
1353
1581
  getActiveQueueMessageId,
1354
1582
  getActiveQueueAttempt,
1355
1583
  isTurnActive,
1584
+ releaseHold,
1585
+ attachOnStart,
1356
1586
  dispose,
1357
1587
  };
1358
1588
  }
@@ -14,6 +14,7 @@ import {
14
14
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
15
15
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
16
16
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
17
+ import { resolveBuildId } from '../../shared/build-id.mjs';
17
18
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
18
19
  import { createControlPoller } from '../../shared/control-poller.mjs';
19
20
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
@@ -21,6 +22,7 @@ import { createClaudeCloudCredentials } from '../../shared/claude-cloud/credenti
21
22
  import { createClaudeCloudClient } from '../../shared/claude-cloud/api-client.mjs';
22
23
  import { resolveClaudeCloudBaseUrl } from '../../shared/claude-cloud/base-url.mjs';
23
24
  import { createClaudeCloudSessionRunner } from './claude-cloud-session-process.mjs';
25
+ import { buildClaudeCloudSteeringState } from './claude-cloud-worker-steering.mjs';
24
26
 
25
27
  // Windows console launch: the launcher names the worker log, this copies
26
28
  // stdout/stderr into it. A no-op wherever the launcher redirects instead.
@@ -51,6 +53,10 @@ async function main() {
51
53
 
52
54
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
53
55
  || path.resolve(__dirname, '..', 'config.json');
56
+ // The code this process started on, read once from the repo root and kept
57
+ // for its whole life: the relay recycles a worker whose build differs from
58
+ // its own, and a tree that moves on under us must not change our answer.
59
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
54
60
  const serverUrl = resolveRelayServerUrl({ configPath });
55
61
  const token = loadTokenFromConfig(configPath);
56
62
  const defaultModel = String(process.env.CLAUDE_CLOUD_RELAY_MODEL || '').trim();
@@ -101,17 +107,28 @@ async function main() {
101
107
  let heartbeatTimer = null;
102
108
  const heartbeat = createHeartbeatController({
103
109
  api,
110
+ buildId,
104
111
  pollMs: HEARTBEAT_MS,
105
112
  getSessionReady: () => true,
106
113
  getHeartbeatTimer: () => heartbeatTimer,
107
114
  setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
108
115
  getActiveQueueMessageId: () => runner.getActiveQueueMessageId(),
116
+ // No steering here; the snapshot tells the relay when it may replace
117
+ // this worker: between turns, with no turn that ended here without its
118
+ // result, and with the cloud session idle at its last read. Anything else
119
+ // is cloud work whose output only this process would still publish. The
120
+ // cloud session lives on in the cloud; the fresh worker binds it at start
121
+ // and re-opens its event stream from the cursor the relay stores
122
+ // (runner.attachOnStart below), so the turns the session starts by
123
+ // itself are still followed.
124
+ getSteeringState: () => buildClaudeCloudSteeringState(runner),
109
125
  });
110
126
 
111
127
  const wsLink = createWorkerWebSocketLink({
112
128
  serverUrl,
113
129
  token,
114
130
  dbg,
131
+ buildId,
115
132
  getSessionReady: () => true,
116
133
  getSessionId: () => sdkSessionId,
117
134
  getPid: () => process.pid,
@@ -144,9 +161,13 @@ async function main() {
144
161
  getActiveQueueMessageIds: () => [runner.getActiveQueueAttempt()],
145
162
  });
146
163
 
147
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'}`);
164
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
148
165
  heartbeat.startHeartbeat();
149
166
  wsLink.start();
167
+ // Not awaited: deliveries wait for it inside the runner, and a relay that
168
+ // cannot say which cloud session this is leaves the binding to the first
169
+ // delivery, as before.
170
+ runner.attachOnStart().catch((error) => dbg('cloud session attach at start failed', error?.message || String(error)));
150
171
  }
151
172
 
152
173
  main().catch((error) => {
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The heartbeat steering snapshot of the Claude Cloud worker. It steers
3
+ * nothing (`supported: false`, so the composer offers no mid-turn steering and
4
+ * reads neither the hold nor the cancellable ids); it is sent for the release
5
+ * fields, which tell the relay whether it may stop this worker and launch a
6
+ * fresh one now (its recycle after a deploy).
7
+ *
8
+ * The rule is the runner's (`releaseHold()` in claude-cloud-session-process.mjs):
9
+ * not while a turn runs here, not while a turn that ended here without its
10
+ * `result` may still run in the cloud, and not while the cloud session's last
11
+ * known status is not idle. The generic snapshot of the other small mains
12
+ * (shared/worker-runtime/simple-steering-state.mjs) knows only the turn; the
13
+ * cloud session can be busy with no relay turn at all.
14
+ *
15
+ * `runner` is the object createClaudeCloudSessionRunner returns.
16
+ */
17
+ export function buildClaudeCloudSteeringState(runner) {
18
+ const attempt = runner?.getActiveQueueAttempt?.() || null;
19
+ const messageId = String(attempt?.id || '').trim() || null;
20
+ const turnActive = runner?.isTurnActive?.() === true || Boolean(messageId);
21
+ // A runner without the hold function still never says yes mid-turn.
22
+ const hold = String(runner?.releaseHold?.() || '').trim() || (turnActive ? 'turn' : '');
23
+ return {
24
+ turnActive,
25
+ canSteer: false,
26
+ holdReason: null,
27
+ messageId,
28
+ supported: false,
29
+ cancellableIds: [],
30
+ releasable: !hold,
31
+ releaseHold: hold || null,
32
+ backgroundTaskCount: 0,
33
+ };
34
+ }
@@ -4250,11 +4250,17 @@ export function createClaudeSessionRunner({
4250
4250
  // `releasable`: the relay may replace this worker now (a billing swap):
4251
4251
  // no CLI process and no delivery on its way in, or a process
4252
4252
  // canReleaseForRespawn would let go of.
4253
+ // `cliAlive`: the CLI child is still up. The relay's recycle replaces a
4254
+ // stale worker only once it is releasable AND its CLI is gone; a
4255
+ // releasable worker whose CLI is up sits in the idle window, where a
4256
+ // scheduled job may still wake it, and is left to its own idle-out. A
4257
+ // closing process is not gone yet, so it still counts as alive.
4253
4258
  if (!proc || proc.closing || proc.aborted) {
4254
4259
  const admitting = admittingDeliveries > 0;
4255
4260
  return {
4256
4261
  turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true, autoCompactWindow: null,
4257
4262
  releasable: !admitting, releaseHold: admitting ? 'turn' : null, backgroundTaskCount: 0,
4263
+ cliAlive: Boolean(proc),
4258
4264
  };
4259
4265
  }
4260
4266
  const ctx = proc.activeCtx || null;
@@ -4293,6 +4299,7 @@ export function createClaudeSessionRunner({
4293
4299
  releasable: !hold,
4294
4300
  releaseHold: hold || null,
4295
4301
  backgroundTaskCount: proc.liveTasks.size,
4302
+ cliAlive: true,
4296
4303
  };
4297
4304
  }
4298
4305
 
@@ -14,6 +14,7 @@ import {
14
14
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
15
15
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
16
16
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
17
+ import { resolveBuildId } from '../../shared/build-id.mjs';
17
18
  import { createControlPoller } from '../../shared/control-poller.mjs';
18
19
  import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
19
20
  import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
@@ -59,6 +60,10 @@ export async function runClaudeSessionWorker({
59
60
 
60
61
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
61
62
  || path.resolve(__dirname, '..', 'config.json');
63
+ // The code this process started on, read once from the repo root and kept
64
+ // for its whole life: the relay recycles a worker whose build differs from
65
+ // its own, and a tree that moves on under us must not change our answer.
66
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
62
67
  const serverUrl = resolveRelayServerUrl({ configPath });
63
68
  const token = loadTokenFromConfig(configPath);
64
69
  const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
@@ -143,6 +148,7 @@ export async function runClaudeSessionWorker({
143
148
  let heartbeatTimer = null;
144
149
  heartbeat = createHeartbeatController({
145
150
  api,
151
+ buildId,
146
152
  // What this worker runs on: the relay hands it only rows of that billing.
147
153
  workerKind: turnRunner.workerKind,
148
154
  pollMs: HEARTBEAT_MS,
@@ -191,6 +197,7 @@ export async function runClaudeSessionWorker({
191
197
  getSessionId: () => sdkSessionId,
192
198
  getPid: () => process.pid,
193
199
  workerKind: turnRunner.workerKind,
200
+ buildId,
194
201
  onDeliver: async (pending, reason) => {
195
202
  dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
196
203
  const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
@@ -263,7 +270,7 @@ export async function runClaudeSessionWorker({
263
270
  onBeforeExit: () => { try { turnRunner.shutdown({ graceful: false }); } catch {} },
264
271
  });
265
272
 
266
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''}`);
273
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''} build=${buildId || 'unknown'}`);
267
274
  heartbeat.startHeartbeat();
268
275
  wsLink.start();
269
276
  }
@@ -709,6 +709,12 @@ export function createCopilotSdkSessionRunner({
709
709
  // card), so they hold the stall watchdog and the idle shutdown too — but
710
710
  // not steering: nobody is answering a card here.
711
711
  let pendingRelayToolCalls = 0;
712
+ // Deliveries `handlePendingPayload` is still working on. Most become (or
713
+ // steer into) `activeTurn` at once, but a steer that waits on the turn's
714
+ // send, its row or the relay context can find the turn gone and open its
715
+ // own afterwards: in that gap no turn exists, and the worker must still
716
+ // not read as replaceable (`releaseHold`).
717
+ let admittingDeliveries = 0;
712
718
  // `relay_tool` is registered only while this relay has paired remotes.
713
719
  // Asked once when the worker starts; a session build waits for that first
714
720
  // answer (it is async anyway), and every runtime stop asks again so the
@@ -4739,7 +4745,62 @@ export function createCopilotSdkSessionRunner({
4739
4745
  const canSteer = canAcceptSteering();
4740
4746
  const holdReason = turnActive && !canSteer ? steeringHoldReason(turn) : null;
4741
4747
  const messageId = String(turn?.currentOwner?.message?.id || turn?.message?.id || '').trim() || null;
4742
- return { turnActive, canSteer, holdReason, messageId, supported: true, cancellableIds: cancellableIds() };
4748
+ // `releasable`: the relay may stop this worker and launch a fresh one now
4749
+ // (its recycle after a deploy), the same fields the Claude worker sends.
4750
+ const hold = releaseHold();
4751
+ return {
4752
+ turnActive,
4753
+ canSteer,
4754
+ holdReason,
4755
+ messageId,
4756
+ supported: true,
4757
+ cancellableIds: cancellableIds(),
4758
+ releasable: !hold,
4759
+ releaseHold: hold || null,
4760
+ // As the task panel counts them: with the registry it is the truth, and
4761
+ // the event-scraped shell set would count its shells a second time.
4762
+ backgroundTaskCount: sessionRpc.tasks ? backgroundTasks.size : backgroundShells.size(),
4763
+ // The runtime process (the CLI child) is up: a client or a session
4764
+ // handle exists until `stopRuntime` clears both. The relay's recycle
4765
+ // replaces a stale worker only once it is releasable AND this is false;
4766
+ // releasable with the runtime up is the idle window, left to the idle
4767
+ // shutdown.
4768
+ cliAlive: Boolean(client || session),
4769
+ };
4770
+ }
4771
+
4772
+ /**
4773
+ * What keeps this worker from being replaced now ('' when nothing does):
4774
+ * the idle shutdown's gates (`evaluateLifecycle`) and the runtime tree's
4775
+ * busy check, named with the relay's hold words. Stopping the worker ends
4776
+ * the runtime, and with it the turn, the card a human is answering, a
4777
+ * compaction, and every background shell and agent.
4778
+ *
4779
+ * - 'question': a question card or an ask-mode approval is open;
4780
+ * - 'compaction': the runtime is compacting the session;
4781
+ * - 'turn': a turn is running or still publishing its rows, a `relay_tool`
4782
+ * call is out, an image row is on the relay's image routes, or a
4783
+ * delivery is on its way in;
4784
+ * - 'background': a background shell or agent is live, a settled shell's
4785
+ * continuation is due (`continuationGraceMs`), a registry read is due
4786
+ * or under way, or the task registry has failed to answer: in the last
4787
+ * two "nothing is running" is not known (`backgroundWorkHoldsRuntime`
4788
+ * holds the runtime on the same grounds).
4789
+ */
4790
+ function releaseHold() {
4791
+ if (pendingHumanRequests > 0) return 'question';
4792
+ if (isCompacting()) return 'compaction';
4793
+ if (
4794
+ activeTurn || publishingTurns.size > 0 || pendingRelayToolCalls > 0
4795
+ || directImageMessages.size > 0 || admittingDeliveries > 0
4796
+ ) return 'turn';
4797
+ if (backgroundShells.size() > 0 || backgroundTasks.size > 0) return 'background';
4798
+ if (continuationDueSince && Date.now() - continuationDueSince < continuationGraceMs) return 'background';
4799
+ // A read of the registry that is debounced, queued or on its way may
4800
+ // still find a live agent or shell the last answer did not have.
4801
+ if (sessionRpc.tasks && (taskRefreshTimer || taskRefreshQueued > 0 || taskRefreshInFlight)) return 'background';
4802
+ if (sessionRpc.tasks && taskRefreshFailures > 0 && taskRefreshFailures < MAX_TASK_REFRESH_FAILURES) return 'background';
4803
+ return '';
4743
4804
  }
4744
4805
 
4745
4806
  /**
@@ -4883,6 +4944,18 @@ export function createCopilotSdkSessionRunner({
4883
4944
  }
4884
4945
 
4885
4946
  async function handlePendingPayload(pending) {
4947
+ // A payload without a message is refused at once, as it always was: it
4948
+ // admits nothing.
4949
+ if (!pending?.message) return false;
4950
+ admittingDeliveries += 1;
4951
+ try {
4952
+ return await admitPendingPayload(pending);
4953
+ } finally {
4954
+ admittingDeliveries -= 1;
4955
+ }
4956
+ }
4957
+
4958
+ async function admitPendingPayload(pending) {
4886
4959
  const message = pending?.message || null;
4887
4960
  if (!message) return false;
4888
4961
  // An image model is no chat model: its row goes to the relay's image
@@ -20,6 +20,7 @@ import {
20
20
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
21
21
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
22
22
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
23
+ import { resolveBuildId } from '../../shared/build-id.mjs';
23
24
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
24
25
  import {
25
26
  createRunnerLinkBridge,
@@ -52,6 +53,10 @@ async function main() {
52
53
 
53
54
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
54
55
  || path.resolve(__dirname, '..', 'config.json');
56
+ // The code this process started on, read once from the repo root and kept
57
+ // for its whole life: the relay recycles a worker whose build differs from
58
+ // its own, and a tree that moves on under us must not change our answer.
59
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
55
60
  // Two reads of the same small file. Collapsing them to one would mean
56
61
  // re-implementing `resolveRelayServerUrl`'s env override, host
57
62
  // normalisation and port defaulting inside this worker — a live contract
@@ -129,6 +134,7 @@ async function main() {
129
134
  let heartbeatTimer = null;
130
135
  heartbeat = createHeartbeatController({
131
136
  api,
137
+ buildId,
132
138
  pollMs: HEARTBEAT_MS,
133
139
  getSessionReady: () => true,
134
140
  getHeartbeatTimer: () => heartbeatTimer,
@@ -158,6 +164,7 @@ async function main() {
158
164
  serverUrl,
159
165
  token,
160
166
  dbg,
167
+ buildId,
161
168
  getSessionReady: () => true,
162
169
  getSessionId: () => sdkSessionId,
163
170
  getPid: () => process.pid,
@@ -238,7 +245,7 @@ async function main() {
238
245
  getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
239
246
  });
240
247
 
241
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
248
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
242
249
  heartbeat.startHeartbeat();
243
250
  wsLink.start();
244
251
  }