@oar-sh/oar 0.9.20 → 0.9.22

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 (131) hide show
  1. package/CHANGELOG.md +325 -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 +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -18,6 +18,7 @@ import { createClaudeSessionRootResolver } from '../services/claude-session-root
18
18
  import {
19
19
  startClaudeSession,
20
20
  createCanUseTool,
21
+ isPersonOnlyAsk,
21
22
  readContextUsage,
22
23
  readPlanUsage,
23
24
  normalizeClaudeEffort,
@@ -2354,9 +2355,10 @@ export function createClaudeSessionRunner({
2354
2355
  // nothing else is left: the cap exists to bound a retry storm's chatter,
2355
2356
  // while a structured entry carries transcript geometry no later line can
2356
2357
  // restore (a compaction boundary buffered between turns, then 50 task
2357
- // notifications, used to lose its break row silently). The client's
2358
- // capRelayActivityEntries protects structured rows too, but head-caps the
2359
- // prose it keeps; here the newest lines are the informative ones.
2358
+ // notifications, used to lose its break row silently). The relay's
2359
+ // capRelayActivityEntries protects structured rows too and keeps the
2360
+ // newest prose of each thread; here too the newest lines are the
2361
+ // informative ones.
2360
2362
  while (proc.pendingActivities.length > MAX_PENDING_ACTIVITIES) {
2361
2363
  const proseIndex = proc.pendingActivities.findIndex((carried) => typeof carried === 'string');
2362
2364
  proc.pendingActivities.splice(proseIndex === -1 ? 0 : proseIndex, 1);
@@ -3806,8 +3808,9 @@ export function createClaudeSessionRunner({
3806
3808
  // denied. Register a continuation turn first: it is born processing,
3807
3809
  // gives the card a real queue row, and the flow's eventual top-level
3808
3810
  // result (or stream end) closes it like any other continuation.
3811
+ // A person-only safety ask is a question card too.
3809
3812
  if (
3810
- toolName === 'AskUserQuestion'
3813
+ (toolName === 'AskUserQuestion' || isPersonOnlyAsk(options))
3811
3814
  && proc === processRef
3812
3815
  && !processRef.activeCtx
3813
3816
  && !processRef.pendingDelivered.length
@@ -4250,11 +4253,17 @@ export function createClaudeSessionRunner({
4250
4253
  // `releasable`: the relay may replace this worker now (a billing swap):
4251
4254
  // no CLI process and no delivery on its way in, or a process
4252
4255
  // canReleaseForRespawn would let go of.
4256
+ // `cliAlive`: the CLI child is still up. The relay's recycle replaces a
4257
+ // stale worker only once it is releasable AND its CLI is gone; a
4258
+ // releasable worker whose CLI is up sits in the idle window, where a
4259
+ // scheduled job may still wake it, and is left to its own idle-out. A
4260
+ // closing process is not gone yet, so it still counts as alive.
4253
4261
  if (!proc || proc.closing || proc.aborted) {
4254
4262
  const admitting = admittingDeliveries > 0;
4255
4263
  return {
4256
4264
  turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true, autoCompactWindow: null,
4257
4265
  releasable: !admitting, releaseHold: admitting ? 'turn' : null, backgroundTaskCount: 0,
4266
+ cliAlive: Boolean(proc),
4258
4267
  };
4259
4268
  }
4260
4269
  const ctx = proc.activeCtx || null;
@@ -4293,6 +4302,7 @@ export function createClaudeSessionRunner({
4293
4302
  releasable: !hold,
4294
4303
  releaseHold: hold || null,
4295
4304
  backgroundTaskCount: proc.liveTasks.size,
4305
+ cliAlive: true,
4296
4306
  };
4297
4307
  }
4298
4308
 
@@ -4575,6 +4585,8 @@ export function createClaudeSessionRunner({
4575
4585
  backgroundTaskIds,
4576
4586
  resyncBackgroundTasks,
4577
4587
  flushKeptTaskSettles,
4588
+ // Settle notices still kept for the relay: the keepalive stays busy.
4589
+ keptTaskSettleCount: () => keptTaskSettles.size(),
4578
4590
  shutdown,
4579
4591
  // Test seams / observability.
4580
4592
  _getProcess: () => proc,
@@ -14,6 +14,8 @@ 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 { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
18
+ import { resolveBuildId } from '../../shared/build-id.mjs';
17
19
  import { createControlPoller } from '../../shared/control-poller.mjs';
18
20
  import { resolveDeliveredAutoCompactWindow } from '../../shared/auto-compact-window.mjs';
19
21
  import { resolveDeliveredThinking } from '../../shared/claude-thinking.mjs';
@@ -59,6 +61,10 @@ export async function runClaudeSessionWorker({
59
61
 
60
62
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
61
63
  || path.resolve(__dirname, '..', 'config.json');
64
+ // The code this process started on, read once from the repo root and kept
65
+ // for its whole life: the relay recycles a worker whose build differs from
66
+ // its own, and a tree that moves on under us must not change our answer.
67
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
62
68
  const serverUrl = resolveRelayServerUrl({ configPath });
63
69
  const token = loadTokenFromConfig(configPath);
64
70
  const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
@@ -143,6 +149,7 @@ export async function runClaudeSessionWorker({
143
149
  let heartbeatTimer = null;
144
150
  heartbeat = createHeartbeatController({
145
151
  api,
152
+ buildId,
146
153
  // What this worker runs on: the relay hands it only rows of that billing.
147
154
  workerKind: turnRunner.workerKind,
148
155
  pollMs: HEARTBEAT_MS,
@@ -168,10 +175,16 @@ export async function runClaudeSessionWorker({
168
175
  getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
169
176
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
170
177
  onAnswered: () => turnRunner.flushKeptTaskSettles(),
178
+ getKeptPostCount: () => turnRunner.keptTaskSettleCount?.() || 0,
171
179
  });
172
180
 
181
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
182
+ // while working, 30 s while idle on a relay that allows it.
183
+ let keepalive = null;
173
184
  const wsLink = createWorkerWebSocketLink({
174
185
  serverUrl,
186
+ readyRefreshMs: 0,
187
+ onRelayFrame: () => keepalive?.nudge(),
175
188
  token,
176
189
  dbg,
177
190
  getSessionReady: () => true,
@@ -191,6 +204,7 @@ export async function runClaudeSessionWorker({
191
204
  getSessionId: () => sdkSessionId,
192
205
  getPid: () => process.pid,
193
206
  workerKind: turnRunner.workerKind,
207
+ buildId,
194
208
  onDeliver: async (pending, reason) => {
195
209
  dbg('queue.deliver received', `reason=${reason}`, `msgId=${pending?.message?.id || 'none'}`);
196
210
  const deliveredTimeout = Number(pending?.settings?.backgroundTaskTimeoutMs);
@@ -232,6 +246,7 @@ export async function runClaudeSessionWorker({
232
246
  dbg('unknown worker control ignored', type || '(none)');
233
247
  },
234
248
  });
249
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
235
250
  linkBridge.attach(wsLink);
236
251
 
237
252
  let shuttingDown = false;
@@ -240,6 +255,7 @@ export async function runClaudeSessionWorker({
240
255
  shuttingDown = true;
241
256
  dbg(`shutting down (${signal})`);
242
257
  try { wsLink.stop(); } catch {}
258
+ try { keepalive.stop(); } catch {}
243
259
  try { heartbeat.stopHeartbeat(); } catch {}
244
260
  try { controlPoller.stop(); } catch {}
245
261
  // Consumed steers still settling must fail, not be left for dead-worker
@@ -263,7 +279,7 @@ export async function runClaudeSessionWorker({
263
279
  onBeforeExit: () => { try { turnRunner.shutdown({ graceful: false }); } catch {} },
264
280
  });
265
281
 
266
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''}`);
267
- heartbeat.startHeartbeat();
282
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}${backend ? ` backend=${backend.id}` : ''} build=${buildId || 'unknown'}`);
283
+ keepalive.start();
268
284
  wsLink.start();
269
285
  }
@@ -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
@@ -5060,6 +5133,7 @@ export function createCopilotSdkSessionRunner({
5060
5133
  resyncBackgroundTasks,
5061
5134
  // The relay answered a heartbeat: settle notices kept for it go out.
5062
5135
  flushKeptTaskSettles: () => keptTaskSettles.flush(),
5136
+ keptTaskSettleCount: () => keptTaskSettles.size(),
5063
5137
  cancelPushedMessage,
5064
5138
  // The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
5065
5139
  // subagent lane's Stop tries — the runtime tracks sync agents in the same
@@ -20,6 +20,8 @@ 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 { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
24
+ import { resolveBuildId } from '../../shared/build-id.mjs';
23
25
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
24
26
  import {
25
27
  createRunnerLinkBridge,
@@ -52,6 +54,10 @@ async function main() {
52
54
 
53
55
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
54
56
  || path.resolve(__dirname, '..', 'config.json');
57
+ // The code this process started on, read once from the repo root and kept
58
+ // for its whole life: the relay recycles a worker whose build differs from
59
+ // its own, and a tree that moves on under us must not change our answer.
60
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
55
61
  // Two reads of the same small file. Collapsing them to one would mean
56
62
  // re-implementing `resolveRelayServerUrl`'s env override, host
57
63
  // normalisation and port defaulting inside this worker — a live contract
@@ -129,6 +135,7 @@ async function main() {
129
135
  let heartbeatTimer = null;
130
136
  heartbeat = createHeartbeatController({
131
137
  api,
138
+ buildId,
132
139
  pollMs: HEARTBEAT_MS,
133
140
  getSessionReady: () => true,
134
141
  getHeartbeatTimer: () => heartbeatTimer,
@@ -152,12 +159,19 @@ async function main() {
152
159
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
153
160
  // The relay answered: settle notices it missed go out.
154
161
  onAnswered: () => turnRunner.flushKeptTaskSettles?.(),
162
+ getKeptPostCount: () => turnRunner.keptTaskSettleCount?.() || 0,
155
163
  });
156
164
 
165
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
166
+ // while working, 30 s while idle on a relay that allows it.
167
+ let keepalive = null;
157
168
  const wsLink = createWorkerWebSocketLink({
158
169
  serverUrl,
170
+ readyRefreshMs: 0,
171
+ onRelayFrame: () => keepalive?.nudge(),
159
172
  token,
160
173
  dbg,
174
+ buildId,
161
175
  getSessionReady: () => true,
162
176
  getSessionId: () => sdkSessionId,
163
177
  getPid: () => process.pid,
@@ -206,11 +220,13 @@ async function main() {
206
220
  dbg('worker control ignored (unsupported by the SDK worker)', type || '(none)');
207
221
  },
208
222
  });
223
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
209
224
  linkBridge.attach(wsLink);
210
225
 
211
226
  const shutdown = async (signal) => {
212
227
  dbg(`shutting down (${signal})`);
213
228
  try { wsLink.stop(); } catch {}
229
+ try { keepalive.stop(); } catch {}
214
230
  try { heartbeat.stopHeartbeat(); } catch {}
215
231
  try { controlPoller.stop(); } catch {}
216
232
  // Rows whose prompt the runtime consumed and whose settle marker has not
@@ -238,8 +254,8 @@ async function main() {
238
254
  getActiveQueueMessageIds: () => turnRunner.getActiveQueueMessageIds(),
239
255
  });
240
256
 
241
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
242
- heartbeat.startHeartbeat();
257
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
258
+ keepalive.start();
243
259
  wsLink.start();
244
260
  }
245
261
 
@@ -13,10 +13,13 @@ import {
13
13
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
14
14
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
15
15
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
16
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
17
+ import { resolveBuildId } from '../../shared/build-id.mjs';
16
18
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
17
19
  import { createControlPoller } from '../../shared/control-poller.mjs';
18
20
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
19
21
  import { createCursorTurnRunner } from './cursor-turn-runner.mjs';
22
+ import { buildCursorSteeringState } from './cursor-worker-steering.mjs';
20
23
  import { resolveCursorSessionStoreDir } from '../services/cursor-session-root-service.mjs';
21
24
 
22
25
  // Windows console launch: the launcher names the worker log, this copies
@@ -48,6 +51,10 @@ async function main() {
48
51
 
49
52
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
50
53
  || path.resolve(__dirname, '..', 'config.json');
54
+ // The code this process started on, read once from the repo root and kept
55
+ // for its whole life: the relay recycles a worker whose build differs from
56
+ // its own, and a tree that moves on under us must not change our answer.
57
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
51
58
  const serverUrl = resolveRelayServerUrl({ configPath });
52
59
  const token = loadTokenFromConfig(configPath);
53
60
  const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
@@ -93,17 +100,27 @@ async function main() {
93
100
  let heartbeatTimer = null;
94
101
  const heartbeat = createHeartbeatController({
95
102
  api,
103
+ buildId,
96
104
  pollMs: HEARTBEAT_MS,
97
105
  getSessionReady: () => true,
98
106
  getHeartbeatTimer: () => heartbeatTimer,
99
107
  setHeartbeatTimer: (timer) => { heartbeatTimer = timer; },
100
108
  getActiveQueueMessageId: () => turnRunner.getActiveQueueMessageId(),
109
+ // No steering here; the snapshot tells the relay when it may replace
110
+ // this worker: between turns (the agent handle is rebuilt on demand).
111
+ getSteeringState: () => buildCursorSteeringState(turnRunner),
101
112
  });
102
113
 
114
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
115
+ // while working, 30 s while idle on a relay that allows it.
116
+ let keepalive = null;
103
117
  const wsLink = createWorkerWebSocketLink({
104
118
  serverUrl,
119
+ readyRefreshMs: 0,
120
+ onRelayFrame: () => keepalive?.nudge(),
105
121
  token,
106
122
  dbg,
123
+ buildId,
107
124
  getSessionReady: () => true,
108
125
  getSessionId: () => sdkSessionId,
109
126
  getPid: () => process.pid,
@@ -117,10 +134,12 @@ async function main() {
117
134
  }
118
135
  },
119
136
  });
137
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
120
138
 
121
139
  const shutdown = async (signal) => {
122
140
  dbg(`shutting down (${signal})`);
123
141
  try { wsLink.stop(); } catch {}
142
+ try { keepalive.stop(); } catch {}
124
143
  try { heartbeat.stopHeartbeat(); } catch {}
125
144
  try { controlPoller.stop(); } catch {}
126
145
  // The agent handle owns a SQLite store; close it before exiting — but
@@ -146,8 +165,8 @@ async function main() {
146
165
  getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
147
166
  });
148
167
 
149
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
150
- heartbeat.startHeartbeat();
168
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
169
+ keepalive.start();
151
170
  wsLink.start();
152
171
  }
153
172
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The Cursor worker's heartbeat steering snapshot, from its turn runner
3
+ * (cursor-turn-runner.mjs). A module of its own so it can be tested: the
4
+ * worker main runs at import.
5
+ *
6
+ * Only a turn holds the release. The Cursor SDK tells the runner of no work
7
+ * that outlives a run: whether a sub-agent or a background shell can keep
8
+ * running after the run's result is not something its types say, and the
9
+ * runner sees neither of them, so there is no background count to send. The
10
+ * relay's quiet rule (10 minutes without a delivery or a turn end) covers
11
+ * what the SDK does not say.
12
+ */
13
+ import { buildSimpleSteeringState } from '../../shared/worker-runtime/simple-steering-state.mjs';
14
+
15
+ export function buildCursorSteeringState(turnRunner) {
16
+ return buildSimpleSteeringState({
17
+ activeAttempt: turnRunner.getActiveQueueAttempt(),
18
+ turnActive: turnRunner.isTurnActive(),
19
+ });
20
+ }
@@ -42,6 +42,8 @@ import { migrateClaudeApiUsageCheckpoints } from './migrations/0033-claude-api-u
42
42
  import { migrateClaudeApiUsageTotals } from './migrations/0034-claude-api-usage-totals.mjs';
43
43
  import { migrateConversationPlanOnly } from './migrations/0035-conversation-plan-only.mjs';
44
44
  import { migrateTokenUsage } from './migrations/0036-token-usage.mjs';
45
+ import { migrateRelayAsksQueueId } from './migrations/0037-relay-asks-queue-id.mjs';
46
+ import { migrateTurnSeq } from './migrations/0038-turn-seq.mjs';
45
47
  import { migrateImageConversationSchema } from './repositories/image-conversation-repository.mjs';
46
48
 
47
49
  // Mirrors DEFAULT_RELAY_MODE in server-runtime.mjs; used only by the one-time
@@ -1077,6 +1079,23 @@ try {
1077
1079
  console.warn(`[token-usage] migration 0036 failed; retrying on next boot: ${error?.message || error}`);
1078
1080
  }
1079
1081
 
1082
+ // The turn an ask was made in: one column on 0023's table. The ask service
1083
+ // works without it, so a failure is logged and retried next boot like 0023.
1084
+ try {
1085
+ migrateRelayAsksQueueId(db);
1086
+ } catch (error) {
1087
+ console.warn(`[relay-asks] migration 0037 failed; retrying on next boot: ${error?.message || error}`);
1088
+ }
1089
+
1090
+ // One order across a turn's thoughts and tool lines (the stream view). The
1091
+ // repository checks for the column and works without it (no numbers, the
1092
+ // grouped layout), so a failure is logged and retried next boot.
1093
+ try {
1094
+ migrateTurnSeq(db);
1095
+ } catch (error) {
1096
+ console.warn(`[turn-seq] migration 0038 failed; retrying on next boot: ${error?.message || error}`);
1097
+ }
1098
+
1080
1099
  const conversationColumns = db.prepare(`PRAGMA table_info(conversations)`).all().map((c) => c.name);
1081
1100
  if (!conversationColumns.includes('archived')) {
1082
1101
  db.exec(`ALTER TABLE conversations ADD COLUMN archived INTEGER NOT NULL DEFAULT 0`);
@@ -235,10 +235,20 @@ export function createGrokTaskPublisher({
235
235
  stop,
236
236
  /** The ids of the live terminals, for the heartbeat's digest. */
237
237
  backgroundTaskIds: () => liveEntries().map((entry) => entry.taskId),
238
+ /**
239
+ * Terminals whose process runs and that the agent has not released, from
240
+ * the moment they start: a superset of the live ids, which wait out the
241
+ * publish grace first. What may die with the worker, so the relay must
242
+ * not replace it while any is open (the steering snapshot's count); a
243
+ * `sleep 600` started a second before a recycle is as live as one the
244
+ * panel already shows.
245
+ */
246
+ openTerminalCount: () => [...terminals.values()].filter((entry) => !entry.exited && !entry.released).length,
238
247
  /** The relay asked for the set again (heartbeat answer). */
239
248
  resync: () => publish({ resync: true }),
240
249
  /** The relay answered a heartbeat: settle notices kept for it go out. */
241
250
  flushKeptSettles: () => keptSettles.flush(),
251
+ keptSettleCount: () => keptSettles.size(),
242
252
  sweep,
243
253
  close() {
244
254
  clearInterval(sweepTimer);
@@ -12,10 +12,13 @@ import {
12
12
  import { createApiClient } from '../../shared/worker-runtime/api-client.mjs';
13
13
  import { createWorkerWebSocketLink } from '../../shared/worker-runtime/worker-websocket-link.mjs';
14
14
  import { createHeartbeatController } from '../../shared/worker-runtime/heartbeat.mjs';
15
+ import { createWorkerKeepalive } from '../../shared/worker-runtime/worker-keepalive.mjs';
16
+ import { resolveBuildId } from '../../shared/build-id.mjs';
15
17
  import { installWorkerLogFile } from '../../shared/worker-runtime/worker-log-file.mjs';
16
18
  import { createControlPoller } from '../../shared/control-poller.mjs';
17
19
  import { installWorkerCrashGuard } from '../../shared/worker-crash-guard.mjs';
18
20
  import { createGrokTurnRunner } from './grok-turn-runner.mjs';
21
+ import { buildGrokSteeringState } from './grok-worker-steering.mjs';
19
22
  import { createGrokTaskPublisher } from './grok-background-tasks.mjs';
20
23
 
21
24
  // Windows console launch: the launcher names the worker log, this copies
@@ -47,6 +50,10 @@ async function main() {
47
50
 
48
51
  const configPath = String(process.env.COPILOT_WEB_RELAY_CONFIG || '').trim()
49
52
  || path.resolve(__dirname, '..', 'config.json');
53
+ // The code this process started on, read once from the repo root and kept
54
+ // for its whole life: the relay recycles a worker whose build differs from
55
+ // its own, and a tree that moves on under us must not change our answer.
56
+ const buildId = resolveBuildId(path.resolve(__dirname, '..', '..')).id;
50
57
  const serverUrl = resolveRelayServerUrl({ configPath });
51
58
  const token = loadTokenFromConfig(configPath);
52
59
  const cwd = String(process.env.COPILOT_WORKSPACE_ROOT || '').trim() || process.cwd();
@@ -96,6 +103,7 @@ async function main() {
96
103
  let heartbeatTimer = null;
97
104
  heartbeat = createHeartbeatController({
98
105
  api,
106
+ buildId,
99
107
  pollMs: HEARTBEAT_MS,
100
108
  getSessionReady: () => true,
101
109
  getHeartbeatTimer: () => heartbeatTimer,
@@ -106,12 +114,22 @@ async function main() {
106
114
  getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
107
115
  onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
108
116
  onAnswered: () => turnRunner.flushKeptTaskSettles(),
117
+ getKeptPostCount: () => turnRunner.keptTaskSettleCount?.() || 0,
118
+ // No steering here; the snapshot tells the relay when it may replace
119
+ // this worker: no turn, and no open terminal (they end with the worker).
120
+ getSteeringState: () => buildGrokSteeringState(turnRunner),
109
121
  });
110
122
 
123
+ // One timer for the heartbeat and the socket's readiness refresh: 10 s
124
+ // while working, 30 s while idle on a relay that allows it.
125
+ let keepalive = null;
111
126
  const wsLink = createWorkerWebSocketLink({
112
127
  serverUrl,
128
+ readyRefreshMs: 0,
129
+ onRelayFrame: () => keepalive?.nudge(),
113
130
  token,
114
131
  dbg,
132
+ buildId,
115
133
  getSessionReady: () => true,
116
134
  // A relay that just (re)started learns of the terminals from the digest
117
135
  // on the next beat; ask for it now.
@@ -143,10 +161,12 @@ async function main() {
143
161
  dbg('unknown worker control ignored', type || '(none)');
144
162
  },
145
163
  });
164
+ keepalive = createWorkerKeepalive({ heartbeat, link: wsLink, busyMs: HEARTBEAT_MS });
146
165
 
147
166
  const shutdown = async (signal) => {
148
167
  dbg(`shutting down (${signal})`);
149
168
  try { wsLink.stop(); } catch {}
169
+ try { keepalive.stop(); } catch {}
150
170
  try { heartbeat.stopHeartbeat(); } catch {}
151
171
  try { controlPoller.stop(); } catch {}
152
172
  try { taskPublisher.close(); } catch {}
@@ -171,8 +191,8 @@ async function main() {
171
191
  getActiveQueueMessageIds: () => [turnRunner.getActiveQueueAttempt()],
172
192
  });
173
193
 
174
- dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'}`);
175
- heartbeat.startHeartbeat();
194
+ dbg(`starting session=${sdkSessionId.slice(0, 8)} server=${serverUrl} cwd=${cwd} model=${defaultModel || 'default'} build=${buildId || 'unknown'}`);
195
+ keepalive.start();
176
196
  wsLink.start();
177
197
  }
178
198
 
@@ -667,9 +667,13 @@ export function createGrokTurnRunner({
667
667
  // The terminals as background tasks: the heartbeat's digest and resync,
668
668
  // the panel's Stop, kept settle notices.
669
669
  backgroundTaskIds: () => terminalTasks?.backgroundTaskIds() || [],
670
+ // Every terminal that runs and is not released, published or not yet:
671
+ // the steering snapshot's background count (grok-worker-steering.mjs).
672
+ openTerminalCount: () => terminalTasks?.openTerminalCount?.() || 0,
670
673
  resyncBackgroundTasks: () => terminalTasks?.resync(),
671
674
  stopBackgroundTask: (taskId) => terminalTasks?.stop(taskId) || false,
672
675
  flushKeptTaskSettles: () => terminalTasks?.flushKeptSettles(),
676
+ keptTaskSettleCount: () => terminalTasks?.keptSettleCount?.() || 0,
673
677
  setTurnCeilingMs(value) {
674
678
  const ms = Number(value);
675
679
  turnCeilingMs = Number.isFinite(ms) && ms >= 0 ? ms : null;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The Grok worker's heartbeat steering snapshot, from its turn runner
3
+ * (grok-turn-runner.mjs). A module of its own so it can be tested: the worker
4
+ * main runs at import.
5
+ *
6
+ * A turn holds the release, then any open terminal: it dies with the worker.
7
+ * Counted from the terminal's start (`openTerminalCount`), not from when it
8
+ * becomes a panel row (`backgroundTaskIds`, after a 2 s grace), so a command
9
+ * started just before a recycle sweep still holds it.
10
+ */
11
+ import { buildSimpleSteeringState } from '../../shared/worker-runtime/simple-steering-state.mjs';
12
+
13
+ export function buildGrokSteeringState(turnRunner) {
14
+ const open = Number(turnRunner.openTerminalCount?.()) || 0;
15
+ // The live ids are open terminals too; the larger count wins should the two
16
+ // ever disagree.
17
+ const listed = turnRunner.backgroundTaskIds?.().length || 0;
18
+ return buildSimpleSteeringState({
19
+ activeAttempt: turnRunner.getActiveQueueAttempt(),
20
+ turnActive: turnRunner.isTurnActive(),
21
+ backgroundTaskCount: Math.max(open, listed),
22
+ });
23
+ }
@@ -0,0 +1,66 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Migration: the turn an ask was made in.
5
+ *
6
+ * `relay_asks.queue_id`: the queue row of the turn that was live in the
7
+ * asking conversation when its agent asked (services/relay-ask-service.mjs
8
+ * create), so the browser can show the ask in that turn's bubble. NULL for
9
+ * an ask made on behalf of another session, one a paired relay's agent made
10
+ * here, a mirror, and every ask from before this migration. No index: it is
11
+ * read with the row, never looked up by.
12
+ *
13
+ * Not one of RELAY_ASK_COLUMNS (0023): the ask service works without it and
14
+ * then stores nothing.
15
+ *
16
+ * Additive and idempotent. Safe to run on every boot; nothing to do while
17
+ * there is no relay_asks table (0023 not run yet). Rolling back needs no
18
+ * data change: older code ignores the column.
19
+ *
20
+ * Usage: node server/migrations/0037-relay-asks-queue-id.mjs [path/to/oar.db]
21
+ */
22
+
23
+ import { fileURLToPath } from 'url';
24
+ import path from 'path';
25
+ import process from 'process';
26
+ import Database from 'better-sqlite3';
27
+ import { cliDbPath } from '../services/database-file.mjs';
28
+
29
+ function tableExists(db, name) {
30
+ return !!db
31
+ .prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`)
32
+ .get(name);
33
+ }
34
+
35
+ export function migrateRelayAsksQueueId(db) {
36
+ if (!tableExists(db, 'relay_asks')) return { applied: false, columnsAdded: 0 };
37
+ const present = new Set(db.prepare(`PRAGMA table_info(relay_asks)`).all().map((column) => column.name));
38
+ if (present.has('queue_id')) return { applied: false, columnsAdded: 0 };
39
+ db.exec(`ALTER TABLE relay_asks ADD COLUMN queue_id TEXT`);
40
+ return { applied: true, columnsAdded: 1 };
41
+ }
42
+
43
+ export function migrate(dbPath, options = {}) {
44
+ const db = new Database(dbPath, options);
45
+ try {
46
+ return migrateRelayAsksQueueId(db);
47
+ } finally {
48
+ db.close();
49
+ }
50
+ }
51
+
52
+ const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
53
+ if (isMain) {
54
+ try {
55
+ // The given path, else the database in server/data (oar.db, else the
56
+ // name before 0.9.20); never a new one.
57
+ const dbPath = cliDbPath(process.argv[2], new URL('..', import.meta.url));
58
+ const result = migrate(dbPath);
59
+ console.log(`Migration 0037 on ${dbPath}`);
60
+ console.log(result.applied ? ` relay asks: columns added ${result.columnsAdded}` : ' skipped: nothing to change');
61
+ process.exit(0);
62
+ } catch (err) {
63
+ console.error(`Migration 0037 failed: ${err?.message || err}`);
64
+ process.exit(1);
65
+ }
66
+ }