@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
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ // What the relay does when its queue may hold work for a worker: hand rows to
4
+ // the workers that are ready (the socket service's notify), make sure every
5
+ // session with a pending row has a worker (the prime), and keep the prime's
6
+ // maintenance pass armed while rows wait. The wakes come from the queue's
7
+ // own writes (queue-wake-hook.mjs), from a retry time coming due, from gates
8
+ // that lift, and from the backstop poll.
9
+
10
+ /**
11
+ * The prime: every session that owns a pending row gets its worker started
12
+ * (rows in retry backoff included, so the worker is warm when the backoff
13
+ * matures; rows held at the usage limit are not listed until their reset).
14
+ */
15
+ export function createPendingWorkerPrimer({
16
+ isEnabled = () => true,
17
+ listPendingOwnerSessionIds,
18
+ isKillBlocked = () => false,
19
+ // A session whose rows were failed instead of launched (a Claude API chat
20
+ // the key cannot serve): true to skip its launch.
21
+ failRowsWithoutLaunch = () => false,
22
+ ensureWorker,
23
+ // Rows were found: the maintenance pass keeps retrying until none is.
24
+ onRowsWaiting = () => {},
25
+ warn = () => {},
26
+ limit = 25,
27
+ } = {}) {
28
+ return async function prime(reason = 'pending-worker-prime') {
29
+ if (!isEnabled()) return 0;
30
+ const rows = listPendingOwnerSessionIds(limit) || [];
31
+ if (rows.length) onRowsWaiting();
32
+ let requested = 0;
33
+ for (const row of rows) {
34
+ const sdkSessionId = String(row?.sdk_session_id || '').trim();
35
+ if (!sdkSessionId) continue;
36
+ if (isKillBlocked(sdkSessionId) === true) continue;
37
+ try {
38
+ // Per session: a throw here costs this session its launch, never the
39
+ // rest of the pass.
40
+ if (failRowsWithoutLaunch(sdkSessionId)) continue;
41
+ } catch (error) {
42
+ warn(`WORKER PRIME skipped session=${sdkSessionId.slice(0, 8)} err=${error?.message || error}`);
43
+ continue;
44
+ }
45
+ requested += 1;
46
+ try {
47
+ const result = await ensureWorker(sdkSessionId);
48
+ if (!result?.ok) {
49
+ warn(`WORKER PRIME blocked session=${sdkSessionId.slice(0, 8)} reason=${String(result?.error || reason || 'pending-worker-prime')}`);
50
+ }
51
+ } catch (error) {
52
+ warn(`WORKER PRIME failed session=${sdkSessionId.slice(0, 8)} reason=${String(reason || 'pending-worker-prime')} err=${error?.message || error}`);
53
+ }
54
+ }
55
+ return requested;
56
+ };
57
+ }
58
+
59
+ /**
60
+ * The wake. `wake(reason)` hands rows out at once, primes, and arms the
61
+ * maintenance pass; `wake(reason, { prime: false })` leaves the prime to that
62
+ * pass (a queue write: the send route launches its own worker, and a write
63
+ * burst must not run a prime per write). Primes never overlap: one asked for
64
+ * while one runs runs once after it. `primeFromBackstop` is the poll's share:
65
+ * a prime at most every `backstopPrimeGapMs`.
66
+ */
67
+ export function createQueueWaker({
68
+ prime,
69
+ notify = () => {},
70
+ armPrime = () => {},
71
+ isReady = () => true,
72
+ backstopPrimeGapMs = 5_000,
73
+ now = () => Date.now(),
74
+ warn = () => {},
75
+ } = {}) {
76
+ let primeRunning = false;
77
+ let primeAgain = null;
78
+ let lastBackstopPrimeAt = -Infinity;
79
+
80
+ async function runPrime(reason) {
81
+ if (primeRunning) {
82
+ primeAgain = reason;
83
+ return;
84
+ }
85
+ primeRunning = true;
86
+ try {
87
+ let next = reason;
88
+ while (next) {
89
+ primeAgain = null;
90
+ try {
91
+ await prime(next);
92
+ } catch (error) {
93
+ warn(`WORKER PRIME pass failed reason=${next} err=${error?.message || error}`);
94
+ }
95
+ next = primeAgain;
96
+ }
97
+ } finally {
98
+ primeRunning = false;
99
+ }
100
+ }
101
+
102
+ function wake(reason = 'queue-wake', { prime: withPrime = true } = {}) {
103
+ if (!isReady()) return false;
104
+ try { armPrime(); } catch {}
105
+ try { notify(reason); } catch (error) {
106
+ warn(`queue notify failed reason=${reason} err=${error?.message || error}`);
107
+ }
108
+ if (withPrime) void runPrime(reason);
109
+ return true;
110
+ }
111
+
112
+ function primeFromBackstop(reason = 'backstop') {
113
+ if (!isReady()) return false;
114
+ try { armPrime(); } catch {}
115
+ const at = now();
116
+ if (at - lastBackstopPrimeAt < backstopPrimeGapMs) return false;
117
+ lastBackstopPrimeAt = at;
118
+ void runPrime(reason);
119
+ return true;
120
+ }
121
+
122
+ return {
123
+ wake,
124
+ primeFromBackstop,
125
+ status: () => ({ primeRunning, primeQueued: primeAgain !== null }),
126
+ };
127
+ }
@@ -108,6 +108,15 @@ export function createRelayAskService({
108
108
  }
109
109
  })();
110
110
  const available = !problem;
111
+ // The turn an ask was made in (migration 0037); without the column asks
112
+ // still work and store none.
113
+ const hasQueueId = available && (() => {
114
+ try {
115
+ return db.prepare(`PRAGMA table_info(relay_asks)`).all().some((column) => column.name === 'queue_id');
116
+ } catch {
117
+ return false;
118
+ }
119
+ })();
111
120
 
112
121
  const sql = available ? {
113
122
  insert: db.prepare(`
@@ -116,6 +125,12 @@ export function createRelayAskService({
116
125
  question, options_json, recommendation, detail, urgency, status, created_at, updated_at)
117
126
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)
118
127
  `),
128
+ insertWithQueue: hasQueueId ? db.prepare(`
129
+ INSERT INTO relay_asks
130
+ (id, relay_id, remote_id, conversation_id, conversation_title, created_by, creator_json,
131
+ question, options_json, recommendation, detail, urgency, status, created_at, updated_at, queue_id)
132
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)
133
+ `) : null,
119
134
  get: db.prepare(`SELECT * FROM relay_asks WHERE id = ?`),
120
135
  getMirror: db.prepare(`SELECT * FROM relay_asks WHERE relay_id = ? AND remote_id = ?`),
121
136
  // The Inbox: this relay's asks, pending and closed since.
@@ -131,6 +146,17 @@ export function createRelayAskService({
131
146
  AND (status = 'pending' OR (status <> 'pending' AND updated_at >= ?))
132
147
  ORDER BY created_at DESC, rowid DESC LIMIT ?
133
148
  `),
149
+ // A session's own cards: the asks in it kept here, none it only asked
150
+ // elsewhere and no mirrors.
151
+ listIn: db.prepare(`
152
+ SELECT * FROM relay_asks
153
+ WHERE conversation_id = ? AND relay_id = 'self'
154
+ AND (status = 'pending' OR (status <> 'pending' AND updated_at >= ?))
155
+ ORDER BY created_at DESC, rowid DESC LIMIT ?
156
+ `),
157
+ // What carryIn and carryBy are about to move.
158
+ listCarryIn: db.prepare(`SELECT id FROM relay_asks WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'`),
159
+ listCarryBy: db.prepare(`SELECT id FROM relay_asks WHERE created_by = ? AND status = 'pending'`),
134
160
  countPendingHere: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE relay_id = 'self' AND status = 'pending'`),
135
161
  countPendingIn: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE relay_id = 'self' AND conversation_id = ? AND status = 'pending'`),
136
162
  countPendingBy: db.prepare(`SELECT COUNT(*) AS n FROM relay_asks WHERE created_by = ? AND status = 'pending'`),
@@ -228,6 +254,9 @@ export function createRelayAskService({
228
254
  ? { relay: creator.relayName || null, conversationId: creator.conversationId || null, title: creator.conversationTitle || null }
229
255
  : (toText(row.created_by) ? { conversationId: row.created_by, title: toText(asker?.title) || null } : null),
230
256
  onBehalf: creator ? true : (!!toText(row.created_by) && row.created_by !== row.conversation_id),
257
+ // The queue row of the turn that asked (null: on behalf, a paired
258
+ // relay's agent, or older); a mirror has none.
259
+ ...(mirror ? {} : { queueId: row.queue_id || null }),
231
260
  createdAt: row.created_at,
232
261
  updatedAt: row.updated_at,
233
262
  };
@@ -259,9 +288,10 @@ export function createRelayAskService({
259
288
  /**
260
289
  * Stores an ask about `conversationId` (a session of this relay). The asker
261
290
  * is `createdBy` (a conversation here: the same one, or a lead asking for
262
- * another) or `creator` (a paired relay's agent). Returns the ask.
291
+ * another) or `creator` (a paired relay's agent). `queueId`: the queue row
292
+ * of the turn that asked, when it ran in this session. Returns the ask.
263
293
  */
264
- function create({ conversationId, createdBy = null, creator = null, ...input } = {}) {
294
+ function create({ conversationId, createdBy = null, creator = null, queueId = null, ...input } = {}) {
265
295
  requireAvailable();
266
296
  const session = conversation(conversationId);
267
297
  if (!isLive(session)) {
@@ -281,7 +311,7 @@ export function createRelayAskService({
281
311
  const { question, options, recommendation, detail, urgency } = checked.value;
282
312
  const id = newId();
283
313
  const at = isoNow();
284
- sql.insert.run(
314
+ const values = [
285
315
  id,
286
316
  SELF,
287
317
  null,
@@ -296,7 +326,9 @@ export function createRelayAskService({
296
326
  urgency,
297
327
  at,
298
328
  at,
299
- );
329
+ ];
330
+ if (sql.insertWithQueue) sql.insertWithQueue.run(...values, clip(queueId, 200) || null);
331
+ else sql.insert.run(...values);
300
332
  const row = sql.get.get(id);
301
333
  const ask = shape(row);
302
334
  log(`${id} in ${shortId(session.id)} by ${remoteCreator ? `relay ${shortId(remoteCreator.relayId)}` : shortId(asker)}${urgency === 'now' ? ' (now)' : ''}`);
@@ -335,6 +367,20 @@ export function createRelayAskService({
335
367
  return [...rows.filter((row) => row.status === 'pending'), ...rows.filter((row) => row.status !== 'pending')].map(shape);
336
368
  }
337
369
 
370
+ /**
371
+ * A session's cards: the asks in it kept on this relay (not those it only
372
+ * asked elsewhere, no mirrors), pending first, then those closed in the
373
+ * last answeredKeepMs.
374
+ */
375
+ function listIn(conversationId) {
376
+ if (!available) return [];
377
+ const id = toText(conversationId);
378
+ if (!id) return [];
379
+ const since = new Date(now() - RELAY_ASK_LIMITS.answeredKeepMs).toISOString();
380
+ const rows = sql.listIn.all(id, since, RELAY_ASK_LIMITS.listMax);
381
+ return [...rows.filter((row) => row.status === 'pending'), ...rows.filter((row) => row.status !== 'pending')].map(shape);
382
+ }
383
+
338
384
  /** Who is told the outcome of a row here: its session and the conversation that asked. */
339
385
  function tell(row) {
340
386
  const ask = shape(row);
@@ -429,7 +475,19 @@ export function createRelayAskService({
429
475
  const from = toText(sourceId);
430
476
  const to = toText(targetId);
431
477
  if (!from || !to || from === to) return 0;
432
- const moved = db.transaction(() => sql.carryIn.run(to, from).changes + sql.carryBy.run(to, from).changes)();
478
+ // The rows both updates touch, each once (a self-ask in and by the
479
+ // source is in both), so their cards follow to the successor.
480
+ const touched = new Set();
481
+ const moved = db.transaction(() => {
482
+ for (const row of sql.listCarryIn.all(from)) touched.add(row.id);
483
+ for (const row of sql.listCarryBy.all(from)) touched.add(row.id);
484
+ return sql.carryIn.run(to, from).changes + sql.carryBy.run(to, from).changes;
485
+ })();
486
+ for (const id of touched) {
487
+ const row = sql.get.get(id);
488
+ // A mirror's card lives on the paired relay: nothing to show here.
489
+ if (row && row.relay_id === SELF) changed(row);
490
+ }
433
491
  if (moved) log(`conv=${shortId(from)} compacted into ${shortId(to)}: ${moved} question${moved === 1 ? '' : 's'} moved`);
434
492
  return moved;
435
493
  }
@@ -586,6 +644,7 @@ export function createRelayAskService({
586
644
  get,
587
645
  listHere,
588
646
  listOf,
647
+ listIn,
589
648
  pendingCount,
590
649
  answer,
591
650
  dismiss,
@@ -107,7 +107,7 @@ import {
107
107
  relayEventQuietWindowEnd,
108
108
  relayEventTypesTake,
109
109
  } from '../../shared/relay-events.mjs';
110
- import { REMOTE_RELAY_APPROVAL_SOURCE } from '../../shared/relay-asks.mjs';
110
+ import { REMOTE_RELAY_APPROVAL_SOURCE, isPersonOnlyQuestion } from '../../shared/relay-asks.mjs';
111
111
 
112
112
  export const RELAY_EVENT_SUBSCRIPTIONS_SOCKET_EVENT = 'relay_event_subscriptions';
113
113
  export const RELAY_EVENT_SELF_RELAY_ID = 'self';
@@ -1770,6 +1770,7 @@ export function createRelayEventService({
1770
1770
  prompt: 'text',
1771
1771
  questionId: 'text',
1772
1772
  approvalCard: 'bool',
1773
+ personOnly: 'bool',
1773
1774
  // question_answered
1774
1775
  answer: 'text',
1775
1776
  choice: 'text',
@@ -2316,6 +2317,8 @@ export function createRelayEventService({
2316
2317
  options,
2317
2318
  questionId: toText(question.id),
2318
2319
  ...(toText(context?.source) === REMOTE_RELAY_APPROVAL_SOURCE ? { approvalCard: true } : {}),
2320
+ // A Claude session's Safety check: the user's to answer.
2321
+ ...(isPersonOnlyQuestion(context) ? { personOnly: true } : {}),
2319
2322
  // A question that does not block (relay_tool ask).
2320
2323
  ...(toText(question.kind) === 'ask' ? { kind: 'ask' } : {}),
2321
2324
  });
@@ -44,7 +44,7 @@ import {
44
44
  isMissingRelayEventRoute,
45
45
  relayWithoutCrossRelayEventsText,
46
46
  } from './relay-event-link-service.mjs';
47
- import { RELAY_ASK_KIND, relayQuestionKind } from '../../shared/relay-asks.mjs';
47
+ import { RELAY_ASK_KIND, isPersonOnlyQuestion, relayQuestionKind } from '../../shared/relay-asks.mjs';
48
48
  import { RELAY_ASK_REMOTE_PATHS, isMissingRelayAskRoute, relayWithoutAsksText } from './relay-ask-link-service.mjs';
49
49
 
50
50
  // The one place a `relay_tool` tool call is decided and carried out (plan
@@ -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;
@@ -457,11 +465,13 @@ function relayTarget(relay) {
457
465
  /**
458
466
  * The question card's view of a relay question: what the agent needs to answer
459
467
  * it. Null for the remote's own remote-relay approval cards, which only its
460
- * user may answer (the remote hides them from us as well).
468
+ * user may answer (the remote hides them from us as well), and for a Claude
469
+ * session's Safety check card, which only the user may answer.
461
470
  */
462
471
  function mapPendingQuestion(question) {
463
472
  if (!isPlainObject(question) || !toText(question.id)) return null;
464
473
  if (isRemoteRelayApprovalQuestion(question)) return null;
474
+ if (isPersonOnlyQuestion(question.context)) return null;
465
475
  const kind = relayQuestionKind(question);
466
476
  if (kind === RELAY_ASK_KIND) {
467
477
  // A question that does not block: the user's to answer, the session works on.
@@ -1494,17 +1504,42 @@ export function createRemoteRelayDispatcher({
1494
1504
  };
1495
1505
  }
1496
1506
 
1497
- async function liveWorkerConversationIds(ctx) {
1507
+ /**
1508
+ * The target relay's session workers, from its /api/status: `working`, the
1509
+ * conversations whose worker is starting or running a turn, and
1510
+ * `workerFor(conversationId)`, that conversation's `worker` field (kind,
1511
+ * build id, and whether it runs other code than that relay, which the relay
1512
+ * judges against its own build id and recycles once idle), or null. A relay
1513
+ * that predates build ids gives null build ids and never `stale`; one that
1514
+ * cannot be read gives nothing.
1515
+ */
1516
+ async function sessionWorkers(ctx) {
1517
+ const working = new Set();
1518
+ const byConversation = new Map();
1498
1519
  try {
1499
1520
  const status = await call(ctx, 'GET', '/api/status');
1500
1521
  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
- }
1522
+ for (const worker of workers) {
1523
+ const conversationId = toText(worker?.conversationId);
1524
+ if (!conversationId) continue;
1525
+ const workerStatus = toText(worker?.status).toLowerCase();
1526
+ if (WORKING_WORKER_STATUSES.has(workerStatus)) working.add(conversationId);
1527
+ const known = byConversation.get(conversationId);
1528
+ if (known && (known.live || !LIVE_WORKER_STATUSES.has(workerStatus))) continue;
1529
+ byConversation.set(conversationId, {
1530
+ live: LIVE_WORKER_STATUSES.has(workerStatus),
1531
+ field: {
1532
+ kind: toText(worker?.kind) || null,
1533
+ buildId: toText(worker?.buildId) || null,
1534
+ stale: worker?.stale === true,
1535
+ },
1536
+ });
1537
+ }
1538
+ } catch {}
1539
+ return {
1540
+ working,
1541
+ workerFor: (conversationId) => byConversation.get(toText(conversationId))?.field || null,
1542
+ };
1508
1543
  }
1509
1544
 
1510
1545
  /** The session group of a listed row: id, display name and the row's role. */
@@ -1521,7 +1556,7 @@ export function createRemoteRelayDispatcher({
1521
1556
  };
1522
1557
  }
1523
1558
 
1524
- function mapSession(row, workerIds, ctx = null, groups = null) {
1559
+ function mapSession(row, workers, ctx = null, groups = null) {
1525
1560
  const id = toText(row?.id);
1526
1561
  const via = toText(row?.origin?.relayName);
1527
1562
  // A session an agent of that relay created there, and from which session.
@@ -1531,7 +1566,7 @@ export function createRemoteRelayDispatcher({
1531
1566
  title: toText(row?.title),
1532
1567
  provider: toText(row?.runtimeProviderType) || 'github',
1533
1568
  model: toText(row?.runtimeProviderModel) || toText(row?.runtimeModel) || toText(row?.preferredModel) || null,
1534
- active: row?.activeTurn === true || !!workerIds?.has(id) || isBusyState(row?.sessionState),
1569
+ active: row?.activeTurn === true || !!workers?.working.has(id) || isBusyState(row?.sessionState),
1535
1570
  ...sessionActivityFields(row),
1536
1571
  updatedAt: row?.updatedAt || null,
1537
1572
  messageCount: nonNegativeInt(row?.messageCount),
@@ -1623,10 +1658,12 @@ export function createRemoteRelayDispatcher({
1623
1658
  cursor = decodeCursor(args.cursor, ['beforeConversationId', 'beforeUpdatedAt', 'beforeGroupId']);
1624
1659
  if (!cursor) throw invalidInput('cursor is not a nextCursor from list_sessions');
1625
1660
  }
1626
- const workerIds = scope === 'active' ? await liveWorkerConversationIds(ctx) : null;
1661
+ // The active scope filters on the workers, so it reads them first; the
1662
+ // others read them once the list is known, for the `worker` fields only.
1663
+ const workers = scope === 'active' ? await sessionWorkers(ctx) : null;
1627
1664
  const filtering = !!query || scope === 'active';
1628
1665
  const keep = (row) => (!query || toText(row?.title).toLowerCase().includes(query))
1629
- && (scope !== 'active' || row?.activeTurn === true || workerIds.has(toText(row?.id)) || isBusyState(row?.sessionState));
1666
+ && (scope !== 'active' || row?.activeTurn === true || workers.working.has(toText(row?.id)) || isBusyState(row?.sessionState));
1630
1667
 
1631
1668
  const sessions = [];
1632
1669
  const seen = new Set();
@@ -1649,7 +1686,7 @@ export function createRemoteRelayDispatcher({
1649
1686
  // A group served again whole (its cursor row vanished) repeats rows.
1650
1687
  if (seen.has(toText(row?.id))) continue;
1651
1688
  seen.add(toText(row?.id));
1652
- if (keep(row)) sessions.push(mapSession(row, workerIds, ctx, groups));
1689
+ if (keep(row)) sessions.push(mapSession(row, workers, ctx, groups));
1653
1690
  if (sessions.length >= limit) break;
1654
1691
  }
1655
1692
  scanned += consumed;
@@ -1667,6 +1704,12 @@ export function createRemoteRelayDispatcher({
1667
1704
  hasMore = consumed < rows.length || response?.pageInfo?.hasMore === true;
1668
1705
  if (sessions.length >= limit || !hasMore || !rows.length) break;
1669
1706
  }
1707
+ // Each session's worker process, while the relay has one registered for it.
1708
+ const workerInfo = workers || (sessions.length ? await sessionWorkers(ctx) : null);
1709
+ for (let index = 0; index < sessions.length; index += 1) {
1710
+ const worker = workerInfo?.workerFor(sessions[index].id);
1711
+ if (worker) sessions[index] = { ...sessions[index], ...workerField(worker) };
1712
+ }
1670
1713
 
1671
1714
  const nextCursor = scope === 'all' && hasMore && cursor ? encodeCursor(cursor) : null;
1672
1715
  const note = filtering && hasMore && sessions.length < limit && scope !== 'all'
@@ -1716,7 +1759,9 @@ export function createRemoteRelayDispatcher({
1716
1759
  budget -= text.length;
1717
1760
  const role = toText(row.role) || 'assistant';
1718
1761
  const via = toText(row?.origin?.relayName);
1719
- const activities = includeActivity && role === 'assistant' ? activityTexts(row.activities) : [];
1762
+ // The newest lines, as the progress path reads them: the relay keeps the
1763
+ // newest per thread now, so the first ones are not the start anyway.
1764
+ const activities = includeActivity && role === 'assistant' ? activityTexts(row.activities, { fromEnd: true }) : [];
1720
1765
  out.unshift({
1721
1766
  id: toText(row.id),
1722
1767
  role,
@@ -1749,7 +1794,12 @@ export function createRemoteRelayDispatcher({
1749
1794
  query: { limit: args.last, beforeMessageId: before?.beforeMessageId, beforeTimestamp: before?.beforeTimestamp, groupMembers: 1 },
1750
1795
  });
1751
1796
  const conversationId = toText(conversation?.id) || args.session;
1752
- const pendingQuestions = await loadPendingQuestions(ctx, conversationId, { includeAsks: true });
1797
+ // Neither read rejects: a relay that cannot say leaves the fields out.
1798
+ const [pendingQuestions, workers] = await Promise.all([
1799
+ loadPendingQuestions(ctx, conversationId, { includeAsks: true }),
1800
+ sessionWorkers(ctx),
1801
+ ]);
1802
+ const worker = workers.workerFor(conversationId);
1753
1803
  const { messages, omitted } = shapeMessages(conversation?.messages, {
1754
1804
  maxChars: args.max_chars,
1755
1805
  includeActivity: args.include_activity,
@@ -1798,6 +1848,7 @@ export function createRemoteRelayDispatcher({
1798
1848
  ...(via ? { via } : {}),
1799
1849
  ...(conversation?.mayCreateSessions === true ? { mayCreateSessions: true } : {}),
1800
1850
  ...(conversation?.planOnly === true ? { planOnly: true } : {}),
1851
+ ...workerField(worker),
1801
1852
  ...(group ? { group } : {}),
1802
1853
  openUrl: remoteConversationUrl(ctx.relay.url, conversationId) || null,
1803
1854
  },
@@ -2547,6 +2598,11 @@ export function createRemoteRelayDispatcher({
2547
2598
  `Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
2548
2599
  }
2549
2600
  const session = toText(question.conversationId) || null;
2601
+ if (isPersonOnlyQuestion(question.context)) {
2602
+ throw new DispatchFailure(403, REMOTE_RELAY_ERROR_CODES.forbidden,
2603
+ `Question ${args.question_id} is a Safety check: Claude Code marks the call as person-only, so this card is the user's to answer, never an agent's. Leave it to the user.`,
2604
+ { session });
2605
+ }
2550
2606
  if (ctx.relay.local && session && isOwnConversation(ctx, session)) {
2551
2607
  throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
2552
2608
  `Question ${args.question_id} belongs to this conversation itself; an agent cannot answer its own question. It is the user's to answer.`,
@@ -3415,7 +3471,17 @@ export function createRemoteRelayDispatcher({
3415
3471
  };
3416
3472
  }
3417
3473
  const session = askSession(ctx, args);
3418
- const ask = relayAskCall(() => service.create({ conversationId: session, createdBy: ctx.conversationId, ...askInput(args) }));
3474
+ const ask = relayAskCall(() => service.create({
3475
+ conversationId: session,
3476
+ createdBy: ctx.conversationId,
3477
+ ...askInput(args),
3478
+ // The queue id means "the turn that was live when the ask was made": a
3479
+ // background sub-agent asking after its parent turn ended may record
3480
+ // the user's next, unrelated turn (accepted; there is no worker
3481
+ // attribution). An ask on behalf of another session stores null: the
3482
+ // asking turn lives in another conversation.
3483
+ queueId: session === ctx.conversationId ? (ctx.caller?.processingRowId || null) : null,
3484
+ }));
3419
3485
  const forOther = session !== ctx.conversationId;
3420
3486
  return {
3421
3487
  summary: `ask → ${ctx.relay.name}${forOther ? ` session ${shortId(session)}` : ''}: question ${ask.id} pending${ask.urgency === 'now' ? ' (now)' : ''}`,
@@ -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`).
@@ -367,6 +367,9 @@ export function createSessionHistoryRefreshService({
367
367
  timestamp,
368
368
  activity.subagentRunId,
369
369
  activity.metadata ? JSON.stringify(activity.metadata) : null,
370
+ // turn_seq: an imported turn has no order of its own; it keeps the
371
+ // grouped layout.
372
+ null,
370
373
  );
371
374
  }
372
375
  const thoughts = ensureArray(message?.thoughts)
@@ -386,6 +389,7 @@ export function createSessionHistoryRefreshService({
386
389
  thought.done ? 1 : 0,
387
390
  timestamp,
388
391
  thought.subagentRunId,
392
+ null, // turn_seq, as for the activity rows above
389
393
  );
390
394
  }
391
395
  }
@@ -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);