@oar-sh/oar 0.9.19 → 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 (190) hide show
  1. package/.github/extensions/web-relay/polling/polling-loop.mjs +2 -26
  2. package/.github/extensions/web-relay/skills/question-routing-hooks.mjs +2 -26
  3. package/CHANGELOG.md +529 -0
  4. package/README.md +14 -14
  5. package/bin/oar.js +392 -98
  6. package/package.json +4 -2
  7. package/server/claude-cloud-worker/claude-cloud-event-normalizer.mjs +60 -0
  8. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +258 -1
  9. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +22 -1
  10. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  11. package/server/claude-worker/claude-remote-relay-tool.mjs +1 -0
  12. package/server/claude-worker/claude-session-process.mjs +10 -0
  13. package/server/claude-worker/claude-session-worker-main.mjs +8 -1
  14. package/server/claude-worker/claude-turn-publisher.mjs +163 -8
  15. package/server/claude-worker/claude-usage-totals.mjs +58 -0
  16. package/server/copilot-worker/copilot-plan-board.mjs +4 -7
  17. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +86 -3
  18. package/server/copilot-worker/copilot-sdk-session-process.mjs +131 -5
  19. package/server/copilot-worker/copilot-sdk-session-worker.mjs +8 -1
  20. package/server/cursor-worker/cursor-session-worker.mjs +12 -1
  21. package/server/cursor-worker/cursor-turn-runner.mjs +42 -6
  22. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  23. package/server/db-schema.mjs +18 -0
  24. package/server/deepseek-worker/deepseek-claude-backend.mjs +14 -0
  25. package/server/grok-worker/grok-background-tasks.mjs +9 -0
  26. package/server/grok-worker/grok-session-worker.mjs +12 -1
  27. package/server/grok-worker/grok-turn-runner.mjs +47 -6
  28. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  29. package/server/migrations/0001-add-structured-answer.mjs +5 -7
  30. package/server/migrations/0002-recent-workspace-roots-path-key.mjs +5 -7
  31. package/server/migrations/0003-push-subscriptions.mjs +5 -7
  32. package/server/migrations/0004-steer-settle-markers.mjs +5 -7
  33. package/server/migrations/0005-remote-relays.mjs +5 -7
  34. package/server/migrations/0006-workspace-root-settings.mjs +5 -7
  35. package/server/migrations/0007-claude-cloud.mjs +5 -7
  36. package/server/migrations/0008-message-pins.mjs +5 -7
  37. package/server/migrations/0009-subagent-timeline.mjs +5 -7
  38. package/server/migrations/0010-subagent-timeline-pruning.mjs +5 -7
  39. package/server/migrations/0011-relay-event-subscriptions.mjs +5 -7
  40. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +5 -7
  41. package/server/migrations/0013-previews.mjs +5 -7
  42. package/server/migrations/0014-conversation-groups.mjs +5 -7
  43. package/server/migrations/0015-conversation-group-position.mjs +5 -7
  44. package/server/migrations/0016-share-access-log.mjs +5 -7
  45. package/server/migrations/0017-conversation-history-indexes.mjs +5 -7
  46. package/server/migrations/0018-background-task-rows.mjs +5 -7
  47. package/server/migrations/0019-relay-event-subscription-options.mjs +5 -7
  48. package/server/migrations/0020-conversation-may-create-sessions.mjs +5 -7
  49. package/server/migrations/0021-conversation-group-agent-marks.mjs +5 -7
  50. package/server/migrations/0022-relay-schedules.mjs +5 -7
  51. package/server/migrations/0023-relay-asks.mjs +5 -7
  52. package/server/migrations/0024-deepseek-usage-totals.mjs +5 -7
  53. package/server/migrations/0025-deepseek-usage-requests-daily.mjs +5 -7
  54. package/server/migrations/0026-deepseek-usage-served-models.mjs +5 -7
  55. package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +5 -7
  56. package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +5 -7
  57. package/server/migrations/0029-deepseek-balance-rises.mjs +5 -7
  58. package/server/migrations/0030-deepseek-usage-actuals.mjs +5 -7
  59. package/server/migrations/0031-queue-provider.mjs +5 -7
  60. package/server/migrations/0032-runtime-session-billing-switch.mjs +5 -7
  61. package/server/migrations/0033-claude-api-usage-checkpoints.mjs +5 -7
  62. package/server/migrations/0034-claude-api-usage-totals.mjs +5 -7
  63. package/server/migrations/0035-conversation-plan-only.mjs +87 -0
  64. package/server/migrations/0036-token-usage.mjs +267 -0
  65. package/server/public/app/about-ui.js +1 -1
  66. package/server/public/app/about-view.mjs +106 -3
  67. package/server/public/app/action-confirmations.js +1 -1
  68. package/server/public/app/api-client.js +6 -3
  69. package/server/public/app/ask-user-view.js +20 -6
  70. package/server/public/app/attachments-view.js +1 -1
  71. package/server/public/app/back-guard.mjs +31 -5
  72. package/server/public/app/bootstrap.js +611 -70
  73. package/server/public/app/chat-actions-menu-state.mjs +139 -4
  74. package/server/public/app/chat-header-meta.mjs +27 -0
  75. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  76. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  77. package/server/public/app/composer-control-state.mjs +12 -2
  78. package/server/public/app/composer-settings-label.mjs +90 -0
  79. package/server/public/app/composer-settings-ui.js +442 -0
  80. package/server/public/app/context-menu.mjs +36 -5
  81. package/server/public/app/context-usage-view.mjs +16 -0
  82. package/server/public/app/conversation-deep-link.mjs +15 -11
  83. package/server/public/app/conversation-group-drag.mjs +12 -2
  84. package/server/public/app/conversation-group-order.mjs +9 -2
  85. package/server/public/app/conversation-group-state.mjs +47 -10
  86. package/server/public/app/conversation-origin-ui.js +17 -1
  87. package/server/public/app/conversation-view.js +20 -2
  88. package/server/public/app/cwd-menu-placement.mjs +18 -6
  89. package/server/public/app/cwd-picker.js +36 -6
  90. package/server/public/app/emoji-view.js +1 -1
  91. package/server/public/app/font-scaling.js +26 -0
  92. package/server/public/app/header-priority-actions.mjs +234 -0
  93. package/server/public/app/hidden-messages-view.mjs +4 -0
  94. package/server/public/app/inbox-ui.js +50 -2
  95. package/server/public/app/inbox-view.mjs +141 -5
  96. package/server/public/app/journal-view.js +311 -58
  97. package/server/public/app/phone-landscape-ui.js +225 -0
  98. package/server/public/app/phone-landscape.mjs +99 -0
  99. package/server/public/app/phone-typing-state.mjs +104 -0
  100. package/server/public/app/pinned-messages-view.mjs +5 -0
  101. package/server/public/app/plan-board-shared.mjs +49 -0
  102. package/server/public/app/plan-usage-view.mjs +1 -1
  103. package/server/public/app/push-settings.js +2 -2
  104. package/server/public/app/pwa-install.js +24 -3
  105. package/server/public/app/relay-board-inline.mjs +4 -1
  106. package/server/public/app/relay-board-view.js +38 -10
  107. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  108. package/server/public/app/relay-event-view.mjs +42 -0
  109. package/server/public/app/relay-restart-ui.js +2 -2
  110. package/server/public/app/relay-schedule-view.mjs +32 -6
  111. package/server/public/app/relay-schedules-ui.js +69 -20
  112. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  113. package/server/public/app/session-history-ui.js +250 -0
  114. package/server/public/app/session-history.mjs +599 -0
  115. package/server/public/app/settings-modal.js +61 -6
  116. package/server/public/app/settings-tabs.js +12 -0
  117. package/server/public/app/share-state.js +1 -1
  118. package/server/public/app/shared-conversations-settings.js +2 -2
  119. package/server/public/app/socket-handlers.js +15 -4
  120. package/server/public/app/status-view.mjs +18 -2
  121. package/server/public/app/store.js +87 -16
  122. package/server/public/app/subagent-viewer.mjs +27 -2
  123. package/server/public/app/tab-strip-fade.mjs +101 -0
  124. package/server/public/app/tap-actions.js +66 -0
  125. package/server/public/app/usage-limit-ui.js +63 -2
  126. package/server/public/app/viewer-history.mjs +13 -2
  127. package/server/public/index.html +2258 -1585
  128. package/server/public/sw.js +8 -0
  129. package/server/relay-heap-guard.mjs +14 -1
  130. package/server/relay-self-restart.mjs +83 -2
  131. package/server/relay-tools.md +5 -3
  132. package/server/repositories/message-repository.mjs +54 -1
  133. package/server/repositories/remote-relay-repository.mjs +10 -0
  134. package/server/repositories/session-repository.mjs +25 -1
  135. package/server/routes/claude-cloud-routes.mjs +19 -0
  136. package/server/routes/messages-routes.mjs +144 -1
  137. package/server/routes/relay-ask-routes.mjs +138 -8
  138. package/server/routes/relay-board-routes.mjs +55 -40
  139. package/server/routes/sessions-routes.mjs +144 -14
  140. package/server/server-runtime.mjs +322 -51
  141. package/server/services/about-service.mjs +81 -53
  142. package/server/services/cache-rebuild-service.mjs +7 -0
  143. package/server/services/claude-billing-swap-service.mjs +7 -20
  144. package/server/services/claude-cloud-session-service.mjs +47 -5
  145. package/server/services/conversation-preferences-service.mjs +31 -0
  146. package/server/services/database-file.mjs +413 -0
  147. package/server/services/db-backup-service.mjs +809 -0
  148. package/server/services/deepseek-usage-import.mjs +6 -9
  149. package/server/services/delete-archive-service.mjs +3 -0
  150. package/server/services/earlier-database-service.mjs +69 -126
  151. package/server/services/oar-cli-helpers.mjs +214 -12
  152. package/server/services/oar-cli-menu.mjs +1 -0
  153. package/server/services/oar-state-migration-service.mjs +33 -13
  154. package/server/services/package-data-rescue.mjs +4 -2
  155. package/server/services/plan-usage-claude-api.mjs +14 -15
  156. package/server/services/plan-usage-contract.mjs +6 -2
  157. package/server/services/plan-usage-deepseek.mjs +1 -1
  158. package/server/services/plan-usage-tokens.mjs +248 -0
  159. package/server/services/push-dispatch-service.mjs +134 -9
  160. package/server/services/relay-cli-launcher-service.mjs +3 -0
  161. package/server/services/relay-event-service.mjs +30 -0
  162. package/server/services/relay-question-context.mjs +11 -0
  163. package/server/services/relay-restart-picture.mjs +67 -22
  164. package/server/services/remote-inbox-watch-service.mjs +204 -0
  165. package/server/services/remote-relay-caller-context.mjs +80 -7
  166. package/server/services/remote-relay-dispatcher.mjs +101 -19
  167. package/server/services/restart-resume-service.mjs +5 -2
  168. package/server/services/session-activity-tracker.mjs +1 -1
  169. package/server/services/session-launch-mutex-service.mjs +23 -0
  170. package/server/services/session-worker-launch-service.mjs +12 -3
  171. package/server/services/session-worker-registry-service.mjs +36 -0
  172. package/server/services/session-worker-stop-service.mjs +27 -6
  173. package/server/services/session-worker-websocket-service.mjs +40 -16
  174. package/server/services/token-usage-service.mjs +430 -0
  175. package/server/services/update-install-service.mjs +13 -0
  176. package/server/services/usage-limit-pause-service.mjs +8 -1
  177. package/server/services/worker-recycle-service.mjs +772 -0
  178. package/server/services/worker-replacement.mjs +197 -0
  179. package/server/services/worker-scope.mjs +77 -0
  180. package/shared/build-id.mjs +128 -0
  181. package/shared/plan-board-actions.mjs +130 -0
  182. package/shared/plan-only.mjs +43 -0
  183. package/shared/relay-asks.mjs +3 -0
  184. package/shared/relay-events.mjs +38 -4
  185. package/shared/remote-relay-contract.mjs +66 -11
  186. package/shared/turn-token-usage.mjs +144 -0
  187. package/shared/worker-runtime/heartbeat.mjs +5 -0
  188. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  189. package/shared/worker-runtime/worker-websocket-link.mjs +23 -0
  190. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -31,6 +31,7 @@ import {
31
31
  withRemotePromptHeader,
32
32
  } from '../../shared/remote-relay-contract.mjs';
33
33
  import { schemaFields } from '../../shared/question-schema.mjs';
34
+ import { PLAN_ONLY_ERROR_CODE, planOnlyRefusesMode } from '../../shared/plan-only.mjs';
34
35
  import { matchQuestionChoice } from '../../shared/question-answer-rule.mjs';
35
36
  import { ANSWERED_ELSEWHERE_KIND } from '../../shared/steer-settle-markers.mjs';
36
37
  import { RELAY_EVENT_DEFAULT_TYPES, RELAY_EVENT_RELAY_LEVEL_TYPES } from '../../shared/relay-events.mjs';
@@ -146,6 +147,9 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
146
147
  });
147
148
  // Worker statuses that mean "working on something right now" (scope active).
148
149
  const WORKING_WORKER_STATUSES = new Set(['starting', 'processing']);
150
+ // A worker process that is up; the one a session's `worker` field names when
151
+ // the registry holds more than one entry for its conversation.
152
+ const LIVE_WORKER_STATUSES = new Set(['starting', 'ready', 'processing']);
149
153
  // Terminal turn failures are stored as ordinary assistant rows; these are the
150
154
  // texts the relay writes for them (buildTerminalFailureTextForChat and the two
151
155
  // recovery-limit paths in messages-routes / server-runtime). Each is the whole
@@ -193,6 +197,11 @@ function shortId(value) {
193
197
  return toText(value).slice(0, 8) || '-';
194
198
  }
195
199
 
200
+ /** A session item's `worker` field, left out when the relay has no worker registered for it. */
201
+ function workerField(worker) {
202
+ return worker ? { worker: { ...worker } } : {};
203
+ }
204
+
196
205
  function nonNegativeInt(value) {
197
206
  const number = Number(value);
198
207
  return Number.isInteger(number) && number >= 0 ? number : 0;
@@ -1493,17 +1502,42 @@ export function createRemoteRelayDispatcher({
1493
1502
  };
1494
1503
  }
1495
1504
 
1496
- async function liveWorkerConversationIds(ctx) {
1505
+ /**
1506
+ * The target relay's session workers, from its /api/status: `working`, the
1507
+ * conversations whose worker is starting or running a turn, and
1508
+ * `workerFor(conversationId)`, that conversation's `worker` field (kind,
1509
+ * build id, and whether it runs other code than that relay, which the relay
1510
+ * judges against its own build id and recycles once idle), or null. A relay
1511
+ * that predates build ids gives null build ids and never `stale`; one that
1512
+ * cannot be read gives nothing.
1513
+ */
1514
+ async function sessionWorkers(ctx) {
1515
+ const working = new Set();
1516
+ const byConversation = new Map();
1497
1517
  try {
1498
1518
  const status = await call(ctx, 'GET', '/api/status');
1499
1519
  const workers = Array.isArray(status?.sessionWorker?.workers) ? status.sessionWorker.workers : [];
1500
- return new Set(workers
1501
- .filter((worker) => WORKING_WORKER_STATUSES.has(toText(worker?.status).toLowerCase()))
1502
- .map((worker) => toText(worker?.conversationId))
1503
- .filter(Boolean));
1504
- } catch {
1505
- return new Set();
1506
- }
1520
+ for (const worker of workers) {
1521
+ const conversationId = toText(worker?.conversationId);
1522
+ if (!conversationId) continue;
1523
+ const workerStatus = toText(worker?.status).toLowerCase();
1524
+ if (WORKING_WORKER_STATUSES.has(workerStatus)) working.add(conversationId);
1525
+ const known = byConversation.get(conversationId);
1526
+ if (known && (known.live || !LIVE_WORKER_STATUSES.has(workerStatus))) continue;
1527
+ byConversation.set(conversationId, {
1528
+ live: LIVE_WORKER_STATUSES.has(workerStatus),
1529
+ field: {
1530
+ kind: toText(worker?.kind) || null,
1531
+ buildId: toText(worker?.buildId) || null,
1532
+ stale: worker?.stale === true,
1533
+ },
1534
+ });
1535
+ }
1536
+ } catch {}
1537
+ return {
1538
+ working,
1539
+ workerFor: (conversationId) => byConversation.get(toText(conversationId))?.field || null,
1540
+ };
1507
1541
  }
1508
1542
 
1509
1543
  /** The session group of a listed row: id, display name and the row's role. */
@@ -1520,7 +1554,7 @@ export function createRemoteRelayDispatcher({
1520
1554
  };
1521
1555
  }
1522
1556
 
1523
- function mapSession(row, workerIds, ctx = null, groups = null) {
1557
+ function mapSession(row, workers, ctx = null, groups = null) {
1524
1558
  const id = toText(row?.id);
1525
1559
  const via = toText(row?.origin?.relayName);
1526
1560
  // A session an agent of that relay created there, and from which session.
@@ -1530,7 +1564,7 @@ export function createRemoteRelayDispatcher({
1530
1564
  title: toText(row?.title),
1531
1565
  provider: toText(row?.runtimeProviderType) || 'github',
1532
1566
  model: toText(row?.runtimeProviderModel) || toText(row?.runtimeModel) || toText(row?.preferredModel) || null,
1533
- active: row?.activeTurn === true || !!workerIds?.has(id) || isBusyState(row?.sessionState),
1567
+ active: row?.activeTurn === true || !!workers?.working.has(id) || isBusyState(row?.sessionState),
1534
1568
  ...sessionActivityFields(row),
1535
1569
  updatedAt: row?.updatedAt || null,
1536
1570
  messageCount: nonNegativeInt(row?.messageCount),
@@ -1540,6 +1574,8 @@ export function createRemoteRelayDispatcher({
1540
1574
  ...(createdBy && ctx?.relay?.local && createdBy === ctx.conversationId ? { startedByYou: true } : {}),
1541
1575
  // The owner let this agent-started session's agents start sessions.
1542
1576
  ...(row?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
1577
+ // Agents cannot take it out of plan mode.
1578
+ ...(row?.planOnly === true ? { planOnly: true } : {}),
1543
1579
  // The session group it sits in (sessions an agent started here are
1544
1580
  // grouped under that agent's session; the owner can group by hand).
1545
1581
  ...sessionGroupField(row, groups),
@@ -1620,10 +1656,12 @@ export function createRemoteRelayDispatcher({
1620
1656
  cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
1621
1657
  if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
1622
1658
  }
1623
- const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
1659
+ // The active scope filters on the workers, so it reads them first; the
1660
+ // others read them once the list is known, for the `worker` fields only.
1661
+ const workers = scope === 'active' ? await sessionWorkers(ctx) : null;
1624
1662
  const filtering = !!query || scope === 'active';
1625
1663
  const keep = (row) => (!query || toText(row?.title).toLowerCase().includes(query))
1626
- && (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)) || isBusyState(row?.sessionState));
1664
+ && (scope !== 'active' || row?.activeTurn === true || workers.working.has(toText(row?.id)) || isBusyState(row?.sessionState));
1627
1665
 
1628
1666
  const sessions = [];
1629
1667
  const seen = new Set();
@@ -1646,7 +1684,7 @@ export function createRemoteRelayDispatcher({
1646
1684
  // A group served again whole (its cursor row vanished) repeats rows.
1647
1685
  if (seen.has(toText(row?.id))) continue;
1648
1686
  seen.add(toText(row?.id));
1649
- if (keep(row)) sessions.push(mapSession(row, workerIds, ctx, groups));
1687
+ if (keep(row)) sessions.push(mapSession(row, workers, ctx, groups));
1650
1688
  if (sessions.length >= limit) break;
1651
1689
  }
1652
1690
  scanned += consumed;
@@ -1664,6 +1702,12 @@ export function createRemoteRelayDispatcher({
1664
1702
  hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
1665
1703
  if (sessions.length >= limit || !hasMore || !rows.length) break;
1666
1704
  }
1705
+ // Each session's worker process, while the relay has one registered for it.
1706
+ const workerInfo = workers || (sessions.length ? await sessionWorkers(ctx) : null);
1707
+ for (let index = 0; index < sessions.length; index += 1) {
1708
+ const worker = workerInfo?.workerFor(sessions[index].id);
1709
+ if (worker) sessions[index] = { ...sessions[index], ...workerField(worker) };
1710
+ }
1667
1711
 
1668
1712
  const nextCursor = scope === 'all' && hasMore && cursor ? encodeCursor(cursor) : null;
1669
1713
  const note = filtering && hasMore && sessions.length < limit && scope !== 'all'
@@ -1746,7 +1790,12 @@ export function createRemoteRelayDispatcher({
1746
1790
  query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
1747
1791
  });
1748
1792
  const conversationId = toText(conversation?.id) || args.session;
1749
- const pendingQuestions = await loadPendingQuestions(ctx, conversationId, { includeAsks: true });
1793
+ // Neither read rejects: a relay that cannot say leaves the fields out.
1794
+ const [pendingQuestions, workers] = await Promise.all([
1795
+ loadPendingQuestions(ctx, conversationId, { includeAsks: true }),
1796
+ sessionWorkers(ctx),
1797
+ ]);
1798
+ const worker = workers.workerFor(conversationId);
1750
1799
  const { messages, omitted } = shapeMessages(conversation?.messages, {
1751
1800
  maxChars: args.max_chars,
1752
1801
  includeActivity: args.include_activity,
@@ -1794,6 +1843,8 @@ export function createRemoteRelayDispatcher({
1794
1843
  ...sessionActivityFields(conversation, { console: true }),
1795
1844
  ...(via ? { via } : {}),
1796
1845
  ...(conversation?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
1846
+ ...(conversation?.planOnly === true ? { planOnly: true } : {}),
1847
+ ...workerField(worker),
1797
1848
  ...(group ? { group } : {}),
1798
1849
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1799
1850
  },
@@ -2148,7 +2199,8 @@ export function createRemoteRelayDispatcher({
2148
2199
  // Grok pins its model at bootstrap and refuses any other, so it gets none.
2149
2200
  const model = requestedModel
2150
2201
  || (provider === 'grok' ? '' : (toText(conversation?.preferredModel) || toText(runtime.providerModel) || toText(runtime.model)));
2151
- const mode = args.mode || ctx.caller.mode || toText(conversation?.preferredRelayMode) || '';
2202
+ // Only a mode the agent names: without one the target runs in its own.
2203
+ const mode = toText(args.mode);
2152
2204
  // The remote runs a prompt that names no effort with its provider's
2153
2205
  // default, not with the one the session is set to: pass that one on, so
2154
2206
  // the session keeps its own. This session's effort is never mirrored here.
@@ -2296,7 +2348,14 @@ export function createRemoteRelayDispatcher({
2296
2348
  note = `${provider} on relay "${ctx.relay.name}" does not offer ${ctx.caller.model}; the session uses its default model ${model || '(none)'}.`;
2297
2349
  }
2298
2350
  }
2299
- const mode = args.mode || ctx.caller.mode || toText(catalog.status.defaultRelayMode) || '';
2351
+ // planOnly starts the session locked in plan mode: with no mode named it
2352
+ // starts in plan; another mode named is refused before anything is made.
2353
+ const planOnly = args.planOnly === true || toText(args.planOnly).toLowerCase() === 'true';
2354
+ if (planOnly && planOnlyRefusesMode(args.mode)) {
2355
+ throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.invalidInput,
2356
+ `planOnly keeps a session in plan mode; it cannot start in ${toText(args.mode)} mode. Leave mode out or name "plan". No session was created.`);
2357
+ }
2358
+ const mode = args.mode || (planOnly ? 'plan' : ctx.caller.mode) || toText(catalog.status.defaultRelayMode) || '';
2300
2359
  const title = args.title || firstLine(args.text, TITLE_MAX) || 'Remote session';
2301
2360
  const origin = await buildOrigin(ctx);
2302
2361
 
@@ -2327,6 +2386,7 @@ export function createRemoteRelayDispatcher({
2327
2386
  providerType: provider,
2328
2387
  ...(model ? { model } : {}),
2329
2388
  ...(mode ? { relayMode: mode } : {}),
2389
+ ...(planOnly ? { planOnly: true } : {}),
2330
2390
  ...(effort ? { reasoningEffort: effort } : {}),
2331
2391
  title,
2332
2392
  ...(args.cwd ? { cwd: args.cwd } : {}),
@@ -2372,6 +2432,11 @@ export function createRemoteRelayDispatcher({
2372
2432
  } else if (args.group && (!group || (ctx.group?.id && group.id !== ctx.group.id))) {
2373
2433
  groupWarning = `The session was created, but not in group "${clipLine(args.group, 80)}": ${groupWhere(ctx)} put it ${group ? `in "${group.name || group.id}" (${group.id})` : 'in no group'}. Tell the user.`;
2374
2434
  }
2435
+ // A relay that knows planOnly echoes it; an older one created the session
2436
+ // without the lock (in the mode asked for).
2437
+ const planOnlyWarning = planOnly && bootstrap?.planOnly !== true
2438
+ ? `The session was created WITHOUT the plan-only lock: ${groupWhere(ctx)} ignored planOnly (its OAR is older and does not take it). It started in ${mode || 'its default'} mode, but agents can switch it to another mode. Tell the user.`
2439
+ : null;
2375
2440
  const boundModel = toText(bootstrap?.selectedModel) || model;
2376
2441
  // A relay that swapped the asked model for another (one that predates
2377
2442
  // these checks does for Copilot) is named, never passed over in silence.
@@ -2436,14 +2501,20 @@ export function createRemoteRelayDispatcher({
2436
2501
  // The session group it went into, as list_sessions shows it.
2437
2502
  group,
2438
2503
  ...(groupWarning ? { groupWarning } : {}),
2504
+ ...(planOnly ? { planOnly: bootstrap?.planOnly === true } : {}),
2505
+ ...(planOnlyWarning ? { planOnlyWarning } : {}),
2439
2506
  },
2440
2507
  });
2441
- if (!groupWarning) return result;
2508
+ if (!groupWarning && !planOnlyWarning) return result;
2442
2509
  // Never a plain success: the summary and the note say what is missing.
2510
+ const missing = [
2511
+ groupWarning ? `no group: ${groupReported ? 'not applied' : 'target too old'}` : '',
2512
+ planOnlyWarning ? 'no plan-only lock: target too old' : '',
2513
+ ].filter(Boolean).join('; ');
2443
2514
  return {
2444
2515
  ...result,
2445
- summary: `${result.summary} (no group: ${groupReported ? 'not applied' : 'target too old'})`,
2446
- note: [groupWarning, result.note].map(toText).filter(Boolean).join(' '),
2516
+ summary: `${result.summary} (${missing})`,
2517
+ note: [groupWarning, planOnlyWarning, result.note].map(toText).filter(Boolean).join(' '),
2447
2518
  };
2448
2519
  }
2449
2520
 
@@ -2639,6 +2710,10 @@ export function createRemoteRelayDispatcher({
2639
2710
  if (remoteCode === SESSION_SETTINGS_CODES.busy) {
2640
2711
  return failed(409, C.busy, text, { reason: toText(body.reason) || null });
2641
2712
  }
2713
+ // A plan-only session: no other mode while the flag stays.
2714
+ if (remoteCode === PLAN_ONLY_ERROR_CODE) {
2715
+ return failed(409, PLAN_ONLY_ERROR_CODE, text, { note: 'Clear the flag in the same call (planOnly: false with the mode), or ask the user.' });
2716
+ }
2642
2717
  if (remoteCode === SESSION_SETTINGS_CODES.groupNotFound || remoteCode === SESSION_SETTINGS_CODES.groupAmbiguous) {
2643
2718
  return failed(400, C.unknownGroup, text, {
2644
2719
  groups: mapGroupList(body).slice(0, REMOTE_RELAY_GROUP_LIMITS.refusalMax).map((group) => ({ id: group.id, name: group.name })),
@@ -3486,6 +3561,13 @@ export function createRemoteRelayDispatcher({
3486
3561
  return failure(404, C.notFound, what ? `${what} was not found on relay "${relay.name}".` : message, extra);
3487
3562
  }
3488
3563
  const remoteCode = toText(remoteBody?.code) || null;
3564
+ // A plan-only session refused a mode the agent named.
3565
+ if (remoteCode === PLAN_ONLY_ERROR_CODE) {
3566
+ const session = args.session ? `Session ${args.session} on relay "${relay.name}"` : `Relay "${relay.name}"`;
3567
+ return failure(409, PLAN_ONLY_ERROR_CODE,
3568
+ `${session} is plan-only: agents can't switch it to another mode. Send without mode (it runs in plan), or clear planOnly with update_session first, or ask the user. Nothing was sent.`,
3569
+ { ...extra, remoteCode });
3570
+ }
3489
3571
  const supportedModels = Array.isArray(remoteBody?.supportedModels) ? remoteBody.supportedModels.slice(0, 50) : null;
3490
3572
  // A refused effort: the ones the remote's model takes, so the agent can pick one.
3491
3573
  const supportedEfforts = isEffortRejection(error) ? supportedEffortsOf(error) : [];
@@ -39,6 +39,8 @@
39
39
  */
40
40
 
41
41
  import { randomUUID } from 'node:crypto';
42
+ import { resumeRelayMode } from '../../shared/plan-only.mjs';
43
+ import { readPlanOnly } from './conversation-preferences-service.mjs';
42
44
 
43
45
  export const RESTART_RESUME_SETTING_KEY = 'restart_resume_notes';
44
46
  export const RESTART_RESUME_KIND = 'relay-restart-resume';
@@ -91,7 +93,7 @@ export function createRestartResumeService({
91
93
  if (!db || !stmts) throw new Error('createRestartResumeService requires db and stmts');
92
94
  const listProcessing = db.prepare(`
93
95
  SELECT id, conversation_id, model, model_variant_id, reasoning_effort, context_tier, relay_mode,
94
- owner_sdk_session_id, runtime_session_id
96
+ owner_sdk_session_id, runtime_session_id, text
95
97
  FROM queue WHERE status = 'processing'
96
98
  `);
97
99
  const statusOf = db.prepare(`SELECT status FROM queue WHERE id = ?`);
@@ -212,7 +214,8 @@ export function createRestartResumeService({
212
214
  // The model and mode the interrupted turn ran with.
213
215
  const model = text(cutRow?.model) || text(latestModel) || text(runtimeSession?.provider_model) || text(runtimeSession?.model) || null;
214
216
  const modelVariant = text(cutRow?.model_variant_id) || model;
215
- const relayMode = text(cutRow?.relay_mode) || 'agent';
217
+ // On a plan-only session only the "Approve plan" turn goes on in agent mode.
218
+ const relayMode = resumeRelayMode({ relayMode: cutRow?.relay_mode, text: cutRow?.text, planOnly: readPlanOnly(conversation) });
216
219
  const owner = text(cutRow?.owner_sdk_session_id) || text(runtimeSession?.sdk_session_id) || null;
217
220
  const write = db.transaction(() => {
218
221
  stmts.insertMsg.run(
@@ -83,7 +83,7 @@ export function resolveBootReportingWindowMs(env = process.env, log = () => {})
83
83
  export const UNREPORTED_BACKGROUND_TASK_ID = 'unreported-background-work';
84
84
  // It stays until the worker reports, and a worker on older code never does
85
85
  // for a task that prints nothing: the way out is the session's Kill session.
86
- export const UNREPORTED_BACKGROUND_TASK_DESCRIPTION = 'background work, details not reported since the relay restarted (Kill session in its ⋯ menu ends it)';
86
+ export const UNREPORTED_BACKGROUND_TASK_DESCRIPTION = 'background work, details not reported since the relay restarted (Kill session in its ︙ menu ends it)';
87
87
  // A `running` background run row that its worker's task set does not list
88
88
  // and nothing has updated for this long is not running work (see
89
89
  // `runListedByWorker`).
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The per-session launch mutex, one for the whole relay process, keyed by
3
+ * sdkSessionId.
4
+ *
5
+ * What runs under it: the Change CWD relaunch, the launch route and the
6
+ * delete and archive routes (sessions-routes.mjs), the worker recycle's
7
+ * recycle, archived stop and launch retry (worker-recycle-service.mjs), and
8
+ * the archived sweep at relay start (sweepArchivedWorkers there). What does
9
+ * not: the Claude billing swap, which keeps the old worker away from the
10
+ * rows by its own delivery fence; the send path's ensureWorker; and the kill
11
+ * route (messages-routes.mjs), so a Kill that lands in a recycle's window
12
+ * between its stop and its launch (about 3 s) can look undone, because the
13
+ * launch starts a fresh idle worker. No turn is cut by that. Taking the
14
+ * mutex in those places is a follow-up.
15
+ *
16
+ * It used to be built inside the sessions routes' registration, where nothing
17
+ * outside the routes could take it, so a recycle could stop a worker while a
18
+ * relaunch was half done. A module singleton is the one place both import.
19
+ */
20
+
21
+ import { createKeyedMutex } from './keyed-mutex-service.mjs';
22
+
23
+ export const sessionLaunchMutex = createKeyedMutex();
@@ -1126,6 +1126,9 @@ export async function launchSessionCli({
1126
1126
  locateExecutableImpl = locateExecutable,
1127
1127
  // Asked after a failed spawn whether its folder is gone (the error, or null).
1128
1128
  checkWorkingDirectoryImpl = missingWorkingDirectoryError,
1129
+ // Under a systemd user service: puts the worker in a scope of its own
1130
+ // (worker-scope.mjs). None: the worker starts as it is.
1131
+ workerScope = null,
1129
1132
  } = {}) {
1130
1133
  const target = String(targetSessionId || '').trim();
1131
1134
  if (!target) throw new Error('missing-target-session-id');
@@ -1237,7 +1240,7 @@ export async function launchSessionCli({
1237
1240
  ? prepareWorkerLogFile(target, launchSessionEnv)
1238
1241
  : null;
1239
1242
  try {
1240
- execFileSyncImpl('tmux', [
1243
+ const tmuxArgs = [
1241
1244
  'new-session',
1242
1245
  '-d',
1243
1246
  '-s',
@@ -1251,7 +1254,10 @@ export async function launchSessionCli({
1251
1254
  workerLogPath: tmuxWorkerLogPath || '',
1252
1255
  extraCliArgs: extensionCliArgs,
1253
1256
  }),
1254
- ], {
1257
+ ];
1258
+ // A tmux server this call starts is in the worker's own scope.
1259
+ const tmuxLaunch = workerScope ? workerScope.wrap('tmux', tmuxArgs) : { command: 'tmux', args: tmuxArgs };
1260
+ execFileSyncImpl(tmuxLaunch.command, tmuxLaunch.args, {
1255
1261
  env: tmuxEnv,
1256
1262
  stdio: ['ignore', 'ignore', 'ignore'],
1257
1263
  });
@@ -1351,7 +1357,10 @@ export async function launchSessionCli({
1351
1357
  }
1352
1358
  }
1353
1359
  }
1354
- const child = spawnImpl(spawnCommand, spawnArgs, {
1360
+ // POSIX: in a scope of its own under a systemd user service; the process
1361
+ // id is still the worker's.
1362
+ const detachedLaunch = platform !== 'win32' && workerScope ? workerScope.wrap(spawnCommand, spawnArgs) : { command: spawnCommand, args: spawnArgs };
1363
+ const child = spawnImpl(detachedLaunch.command, detachedLaunch.args, {
1355
1364
  cwd: launchProcessCwd,
1356
1365
  env: launchSessionEnv,
1357
1366
  detached: true,
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ import { normalizeBuildId } from '../../shared/build-id.mjs';
4
+
3
5
  function normalizeSessionId(value) {
4
6
  const text = String(value || '').trim();
5
7
  return text || null;
@@ -141,6 +143,12 @@ export function normalizeWorkerSteeringSnapshot(raw) {
141
143
  const tasks = Number(raw.backgroundTaskCount);
142
144
  snapshot.backgroundTaskCount = Number.isInteger(tasks) && tasks > 0 ? Math.min(tasks, 999) : 0;
143
145
  }
146
+ // Whether the worker's CLI child still runs. The recycle replaces a worker
147
+ // only once that child is gone, because a live CLI may hold scheduled jobs
148
+ // of its own (CronCreate, ScheduleWakeup) that a stop would lose. Only a
149
+ // worker that reports it carries the key: an absent key means "cannot say",
150
+ // which the recycle treats differently from false.
151
+ if (typeof raw.cliAlive === 'boolean') snapshot.cliAlive = raw.cliAlive;
144
152
  return snapshot;
145
153
  }
146
154
 
@@ -160,6 +168,12 @@ function sanitizeState(raw = null) {
160
168
  lastError: String(base.lastError || '').trim() || null,
161
169
  steering: normalizeWorkerSteeringSnapshot(base.steering),
162
170
  kind: normalizeWorkerKind(base.kind),
171
+ // The build id of the code the worker process started on, as it stated
172
+ // it; null when it did not say or said something malformed.
173
+ buildId: normalizeBuildId(base.buildId),
174
+ // When the relay first heard of the current pid: the process's age as far
175
+ // as the relay can tell, without asking the OS. Null without a pid.
176
+ startedAt: normalizeTimestamp(base.startedAt, null),
163
177
  updatedAt: normalizeTimestamp(base.updatedAt, nowIso),
164
178
  createdAt,
165
179
  };
@@ -266,11 +280,33 @@ export function createSessionWorkerRegistry() {
266
280
  // heartbeated — indefinitely, if none ever did.
267
281
  // The kind is the same: a process's own word. An upsert that does not
268
282
  // state it keeps the known one, unless the process it described is gone.
283
+ // The build id and the start time follow the same rule.
269
284
  const statesKind = Object.prototype.hasOwnProperty.call(rawState, 'kind');
285
+ const statesBuildId = Object.prototype.hasOwnProperty.call(rawState, 'buildId');
286
+ const statesStartedAt = Object.prototype.hasOwnProperty.call(rawState, 'startedAt');
270
287
  if (!statesKind) next.kind = existing?.kind || null;
288
+ if (!statesBuildId) next.buildId = existing?.buildId || null;
289
+ if (!statesStartedAt) next.startedAt = existing?.startedAt || null;
271
290
  if (existing && isWorkerReplacedOrGone(existing, next)) {
272
291
  next.steering = null;
273
292
  if (!statesKind) next.kind = null;
293
+ if (!statesBuildId) next.buildId = null;
294
+ if (!statesStartedAt) next.startedAt = null;
295
+ }
296
+ // A pid the entry did not have before is the first the relay hears of
297
+ // that process, so its start time is now. Callers spread the old entry
298
+ // into the upsert that brings the new pid, so a stated startedAt counts
299
+ // only when it is not the old process's own.
300
+ // The build id is that process's own word as well, and it arrives with its
301
+ // hello or heartbeat, not with the upsert that brings the pid. A build id
302
+ // equal to the old entry's is the old process's, spread in, and would make
303
+ // a recycled worker read as the old code (or an old worker as current)
304
+ // until the new one spoke; it is dropped. A different one was stated for
305
+ // this process and stays.
306
+ if (next.pid && next.pid !== existing?.pid) {
307
+ const statedOwnStart = statesStartedAt && next.startedAt && next.startedAt !== existing?.startedAt;
308
+ if (!statedOwnStart) next.startedAt = new Date().toISOString();
309
+ if (next.buildId && next.buildId === existing?.buildId) next.buildId = null;
274
310
  }
275
311
  clearIndexesForSession(existing);
276
312
  bySession.set(state.sdkSessionId, next);
@@ -22,12 +22,17 @@ function toPidList(rows, extraPid) {
22
22
 
23
23
  // The process list is read off the relay's thread, fresh (a stop must see
24
24
  // the children started a moment ago). Throws when the list could not be
25
- // read: the caller must not pretend it stopped what it could not see.
26
- async function enumeratePids({ sdkSessionId, worker, processInspector, platform }) {
25
+ // read: the caller must not pretend it stopped what it could not see. The
26
+ // registry's pid counts only while that process is alive: a worker that is
27
+ // gone is stopped already (on Windows its stop call alone could fail).
28
+ async function enumeratePids({ sdkSessionId, worker, processInspector, platform, isPidAliveImpl }) {
27
29
  const rows = platform === 'win32'
28
30
  ? await processInspector?.findWindowsProcessTreeForSessionAsync?.(sdkSessionId, { fresh: true })
29
31
  : await processInspector?.findProcessesForSessionAsync?.(sdkSessionId, { fresh: true });
30
- return toPidList(rows || [], worker?.pid);
32
+ const listed = toPidList(rows || []);
33
+ const workerPid = Number(worker?.pid);
34
+ const workerAlive = Number.isInteger(workerPid) && workerPid > 0 && (listed.includes(workerPid) || isPidAliveImpl(workerPid));
35
+ return workerAlive ? toPidList(rows || [], workerPid) : listed;
31
36
  }
32
37
 
33
38
  async function stopWindowsPids(processInspector, pids) {
@@ -36,13 +41,29 @@ async function stopWindowsPids(processInspector, pids) {
36
41
 
37
42
  export const PROCESS_LIST_UNREADABLE = 'process-list-unreadable';
38
43
 
44
+ /**
45
+ * Whether `pid` is a live process: signal 0 reaches it. EPERM means it
46
+ * exists but belongs to someone else: alive. The default for every stop
47
+ * that is given no check of its own.
48
+ */
49
+ export function isProcessAlive(pid, { killImpl = (target, signal) => process.kill(target, signal) } = {}) {
50
+ const value = Number(pid);
51
+ if (!Number.isInteger(value) || value <= 0) return false;
52
+ try {
53
+ killImpl(value, 0);
54
+ return true;
55
+ } catch (error) {
56
+ return String(error?.code || '').toUpperCase() === 'EPERM';
57
+ }
58
+ }
59
+
39
60
  export async function stopSessionWorkerProcesses({
40
61
  sdkSessionId,
41
62
  worker = null,
42
63
  processInspector = null,
43
64
  platform = process.platform,
44
65
  killImpl = (pid, signal) => process.kill(pid, signal),
45
- isPidAliveImpl = () => false,
66
+ isPidAliveImpl = isProcessAlive,
46
67
  killTmuxSessionImpl = null,
47
68
  sleepImpl = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
48
69
  nowMs = Date.now,
@@ -67,7 +88,7 @@ export async function stopSessionWorkerProcesses({
67
88
 
68
89
  let pids;
69
90
  try {
70
- pids = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
91
+ pids = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
71
92
  } catch (error) {
72
93
  // Nothing is stopped blind: the known pid alone would leave the tree
73
94
  // running, and the caller would relaunch next to it.
@@ -115,7 +136,7 @@ export async function stopSessionWorkerProcesses({
115
136
  try {
116
137
  if (platform === 'win32') {
117
138
  // Re-enumerate: children spawned after the first snapshot are invisible to it.
118
- const rediscovered = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform });
139
+ const rediscovered = await enumeratePids({ sdkSessionId: sid, worker, processInspector, platform, isPidAliveImpl });
119
140
  escalationTargets = [...new Set([...remainingPids, ...rediscovered.filter((pid) => isPidAliveImpl(pid))])];
120
141
  await stopWindowsPids(processInspector, escalationTargets);
121
142
  } else {
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  import { URL } from 'url';
4
+ import { normalizeBuildId } from '../../shared/build-id.mjs';
4
5
 
5
6
  function normalizeText(value) {
6
7
  const text = String(value || '').trim();
@@ -111,6 +112,13 @@ export function createSessionWorkerWebSocketService({
111
112
  // A worker stated its kind ("claude", "claude-api", ...) in a hello or a
112
113
  // ready: (sessionId, kind, pid). The registry keeps it.
113
114
  onWorkerKind = () => {},
115
+ // A worker stated the build id of the code it started on, in a hello or a
116
+ // ready: (sessionId, buildId, pid). The registry keeps it, so the relay can
117
+ // tell a worker on older code from one that runs what the relay runs.
118
+ onWorkerBuildId = () => {},
119
+ // The relay's own build id, sent in every server.hello so a worker can log
120
+ // that it runs other code; null leaves the key out.
121
+ buildId = null,
114
122
  // Liveness probe for a socket's worker process. A killed worker's socket
115
123
  // survives its process by an event-loop turn (the kill path blocks the loop,
116
124
  // so this service's timer runs before the socket close lands), and asking for
@@ -136,6 +144,8 @@ export function createSessionWorkerWebSocketService({
136
144
  const logDebug = typeof logger?.debug === 'function' ? logger.debug.bind(logger) : noop;
137
145
  const logWarn = typeof logger?.warn === 'function' ? logger.warn.bind(logger) : noop;
138
146
 
147
+ const relayBuildId = normalizeBuildId(buildId);
148
+
139
149
  let interval = null;
140
150
  let lastSnapshot = null;
141
151
  let attached = false;
@@ -245,6 +255,32 @@ export function createSessionWorkerWebSocketService({
245
255
  try { onWorkerKind(meta.sessionId, kind, meta.pid); } catch {}
246
256
  }
247
257
 
258
+ // The build id a hello or ready states, kept and reported like the kind. A
259
+ // value that is malformed or too long is ignored rather than stored, since
260
+ // the worker side is not trusted to bound what it sends.
261
+ function noteWorkerBuildId(meta, payload) {
262
+ const workerBuildId = normalizeBuildId(payload?.buildId);
263
+ if (!workerBuildId || meta.workerBuildId === workerBuildId) return;
264
+ meta.workerBuildId = workerBuildId;
265
+ try { onWorkerBuildId(meta.sessionId, workerBuildId, meta.pid); } catch {}
266
+ }
267
+
268
+ // Both server.hello frames (on connection and the ack of worker.hello) carry
269
+ // the same fields; an unknown relay build id leaves its key out rather than
270
+ // sending null.
271
+ function serverHello(reason, sessionId) {
272
+ const event = {
273
+ type: 'server.hello',
274
+ reason,
275
+ capabilities: WORKER_PROTOCOL_CAPABILITIES,
276
+ queue: normalizeQueueSnapshot(queueCounts()),
277
+ sessionId,
278
+ timestamp: nowIso(),
279
+ };
280
+ if (relayBuildId) event.buildId = relayBuildId;
281
+ return event;
282
+ }
283
+
248
284
  function noteHeartbeat(meta, reason = 'worker-message') {
249
285
  if (!meta?.sessionId) return;
250
286
  try {
@@ -397,14 +433,7 @@ export function createSessionWorkerWebSocketService({
397
433
  });
398
434
  if (identity.sessionId) supersedePriorSockets(ws, identity.sessionId);
399
435
  touchCli();
400
- emitEvent(ws, {
401
- type: 'server.hello',
402
- reason: 'connected',
403
- capabilities: WORKER_PROTOCOL_CAPABILITIES,
404
- queue: normalizeQueueSnapshot(queueCounts()),
405
- sessionId: identity.sessionId,
406
- timestamp: nowIso(),
407
- });
436
+ emitEvent(ws, serverHello('connected', identity.sessionId));
408
437
 
409
438
  ws.on('message', (raw) => {
410
439
  touchCli();
@@ -420,19 +449,13 @@ export function createSessionWorkerWebSocketService({
420
449
  if (!bindSocketIdentity(ws, meta, payload)) return;
421
450
  meta.lastSeenAtMs = nowMsSafe();
422
451
  noteWorkerKind(meta, payload);
452
+ noteWorkerBuildId(meta, payload);
423
453
  // A worker holding deliveries (open question card, compaction) binds
424
454
  // its identity with hello but must not be handed work.
425
455
  meta.ready = payload.ready !== false;
426
456
  meta.lastHelloAt = nowIso();
427
457
  noteHeartbeat(meta, 'worker-hello');
428
- emitEvent(ws, {
429
- type: 'server.hello',
430
- reason: 'ack',
431
- capabilities: WORKER_PROTOCOL_CAPABILITIES,
432
- queue: normalizeQueueSnapshot(queueCounts()),
433
- sessionId: meta.sessionId,
434
- timestamp: nowIso(),
435
- });
458
+ emitEvent(ws, serverHello('ack', meta.sessionId));
436
459
  void maybeDeliverToSocket(ws, 'worker-hello');
437
460
  return;
438
461
  }
@@ -440,6 +463,7 @@ export function createSessionWorkerWebSocketService({
440
463
  if (!bindSocketIdentity(ws, meta, payload)) return;
441
464
  meta.lastSeenAtMs = nowMsSafe();
442
465
  noteWorkerKind(meta, payload);
466
+ noteWorkerBuildId(meta, payload);
443
467
  meta.ready = true;
444
468
  meta.lastReadyAt = nowIso();
445
469
  noteHeartbeat(meta, String(payload.reason || 'worker-ready'));