@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
@@ -147,6 +147,9 @@ const CATALOG_PROVIDER_LABELS = Object.freeze({
147
147
  });
148
148
  // Worker statuses that mean "working on something right now" (scope active).
149
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']);
150
153
  // Terminal turn failures are stored as ordinary assistant rows; these are the
151
154
  // texts the relay writes for them (buildTerminalFailureTextForChat and the two
152
155
  // recovery-limit paths in messages-routes / server-runtime). Each is the whole
@@ -194,6 +197,11 @@ function shortId(value) {
194
197
  return toText(value).slice(0, 8) || '-';
195
198
  }
196
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
+
197
205
  function nonNegativeInt(value) {
198
206
  const number = Number(value);
199
207
  return Number.isInteger(number) && number >= 0 ? number : 0;
@@ -1494,17 +1502,42 @@ export function createRemoteRelayDispatcher({
1494
1502
  };
1495
1503
  }
1496
1504
 
1497
- 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();
1498
1517
  try {
1499
1518
  const status = await call(ctx, 'GET', '/api/status');
1500
1519
  const workers = Array.isArray(status?.sessionWorker?.workers) ? status.sessionWorker.workers : [];
1501
- return new Set(workers
1502
- .filter((worker) => WORKING_WORKER_STATUSES.has(toText(worker?.status).toLowerCase()))
1503
- .map((worker) => toText(worker?.conversationId))
1504
- .filter(Boolean));
1505
- } catch {
1506
- return new Set();
1507
- }
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
+ };
1508
1541
  }
1509
1542
 
1510
1543
  /** The session group of a listed row: id, display name and the row's role. */
@@ -1521,7 +1554,7 @@ export function createRemoteRelayDispatcher({
1521
1554
  };
1522
1555
  }
1523
1556
 
1524
- function mapSession(row, workerIds, ctx = null, groups = null) {
1557
+ function mapSession(row, workers, ctx = null, groups = null) {
1525
1558
  const id = toText(row?.id);
1526
1559
  const via = toText(row?.origin?.relayName);
1527
1560
  // A session an agent of that relay created there, and from which session.
@@ -1531,7 +1564,7 @@ export function createRemoteRelayDispatcher({
1531
1564
  title: toText(row?.title),
1532
1565
  provider: toText(row?.runtimeProviderType) || 'github',
1533
1566
  model: toText(row?.runtimeProviderModel) || toText(row?.runtimeModel) || toText(row?.preferredModel) || null,
1534
- active: row?.activeTurn === true || !!workerIds?.has(id) || isBusyState(row?.sessionState),
1567
+ active: row?.activeTurn === true || !!workers?.working.has(id) || isBusyState(row?.sessionState),
1535
1568
  ...sessionActivityFields(row),
1536
1569
  updatedAt: row?.updatedAt || null,
1537
1570
  messageCount: nonNegativeInt(row?.messageCount),
@@ -1623,10 +1656,12 @@ export function createRemoteRelayDispatcher({
1623
1656
  cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
1624
1657
  if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
1625
1658
  }
1626
- 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;
1627
1662
  const filtering = !!query || scope === 'active';
1628
1663
  const keep = (row) => (!query || toText(row?.title).toLowerCase().includes(query))
1629
- && (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));
1630
1665
 
1631
1666
  const sessions = [];
1632
1667
  const seen = new Set();
@@ -1649,7 +1684,7 @@ export function createRemoteRelayDispatcher({
1649
1684
  // A group served again whole (its cursor row vanished) repeats rows.
1650
1685
  if (seen.has(toText(row?.id))) continue;
1651
1686
  seen.add(toText(row?.id));
1652
- if (keep(row)) sessions.push(mapSession(row, workerIds, ctx, groups));
1687
+ if (keep(row)) sessions.push(mapSession(row, workers, ctx, groups));
1653
1688
  if (sessions.length >= limit) break;
1654
1689
  }
1655
1690
  scanned += consumed;
@@ -1667,6 +1702,12 @@ export function createRemoteRelayDispatcher({
1667
1702
  hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
1668
1703
  if (sessions.length >= limit || !hasMore || !rows.length) break;
1669
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
+ }
1670
1711
 
1671
1712
  const nextCursor = scope === 'all' && hasMore && cursor ? encodeCursor(cursor) : null;
1672
1713
  const note = filtering && hasMore && sessions.length < limit && scope !== 'all'
@@ -1749,7 +1790,12 @@ export function createRemoteRelayDispatcher({
1749
1790
  query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
1750
1791
  });
1751
1792
  const conversationId = toText(conversation?.id) || args.session;
1752
- 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);
1753
1799
  const { messages, omitted } = shapeMessages(conversation?.messages, {
1754
1800
  maxChars: args.max_chars,
1755
1801
  includeActivity: args.include_activity,
@@ -1798,6 +1844,7 @@ export function createRemoteRelayDispatcher({
1798
1844
  ...(via ? { via } : {}),
1799
1845
  ...(conversation?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
1800
1846
  ...(conversation?.planOnly === true ? { planOnly: true } : {}),
1847
+ ...workerField(worker),
1801
1848
  ...(group ? { group } : {}),
1802
1849
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1803
1850
  },
@@ -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();
@@ -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);
@@ -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'));