@oar-sh/oar 0.9.21 → 0.9.23

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 (82) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +9 -9
  3. package/bin/oar.js +28 -0
  4. package/package.json +13 -14
  5. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +9 -1
  6. package/server/claude-worker/claude-cli-pinned-env.mjs +4 -1
  7. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  8. package/server/claude-worker/claude-session-process.mjs +9 -4
  9. package/server/claude-worker/claude-session-worker-main.mjs +10 -1
  10. package/server/copilot-worker/copilot-sdk-session-process.mjs +1 -0
  11. package/server/copilot-worker/copilot-sdk-session-worker.mjs +10 -1
  12. package/server/cursor-worker/cursor-session-worker.mjs +9 -1
  13. package/server/db-schema.mjs +19 -0
  14. package/server/grok-worker/grok-background-tasks.mjs +1 -0
  15. package/server/grok-worker/grok-session-worker.mjs +10 -1
  16. package/server/grok-worker/grok-turn-runner.mjs +1 -0
  17. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  18. package/server/migrations/0038-turn-seq.mjs +92 -0
  19. package/server/public/app/activity-replay-state.mjs +160 -42
  20. package/server/public/app/api-client.js +7 -0
  21. package/server/public/app/ask-user-view.js +4 -3
  22. package/server/public/app/bootstrap.js +62 -4
  23. package/server/public/app/chat-actions-menu-state.mjs +2 -0
  24. package/server/public/app/context-usage-view.mjs +68 -0
  25. package/server/public/app/conversation-view.js +306 -49
  26. package/server/public/app/header-priority-actions.mjs +5 -1
  27. package/server/public/app/held-details.mjs +202 -0
  28. package/server/public/app/inbox-ui.js +70 -79
  29. package/server/public/app/inbox-view.mjs +35 -19
  30. package/server/public/app/journal-view.js +17 -0
  31. package/server/public/app/live-activity-cap.mjs +103 -0
  32. package/server/public/app/questions-panel-ui.js +634 -0
  33. package/server/public/app/questions-stub-view.mjs +40 -0
  34. package/server/public/app/questions-view.mjs +89 -0
  35. package/server/public/app/relay-board-view.js +12 -2
  36. package/server/public/app/relay-thoughts.mjs +15 -1
  37. package/server/public/app/session-history-ui.js +1 -0
  38. package/server/public/app/settings-modal.js +6 -0
  39. package/server/public/app/socket-handlers.js +42 -11
  40. package/server/public/app/stream-view-preference.mjs +60 -0
  41. package/server/public/app/subagent-fold.mjs +6 -3
  42. package/server/public/app/subagent-timeline-view.mjs +12 -5
  43. package/server/public/app/subagent-viewer.mjs +8 -6
  44. package/server/public/app/thoughts-preference.mjs +79 -0
  45. package/server/public/app/turn-stream-view.mjs +124 -0
  46. package/server/public/index.html +391 -214
  47. package/server/repositories/message-repository.mjs +10 -0
  48. package/server/repositories/question-repository.mjs +51 -7
  49. package/server/repositories/relay-turn-rows.mjs +56 -0
  50. package/server/routes/ask-user-routes.mjs +34 -5
  51. package/server/routes/messages-routes.mjs +46 -34
  52. package/server/routes/relay-ask-routes.mjs +15 -5
  53. package/server/routes/sessions-routes.mjs +77 -6
  54. package/server/server-runtime.mjs +199 -171
  55. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  56. package/server/services/earlier-database-service.mjs +1 -1
  57. package/server/services/file-serving.mjs +13 -0
  58. package/server/services/idle-timers.mjs +184 -0
  59. package/server/services/native-addon-build-service.mjs +224 -0
  60. package/server/services/oar-cli-helpers.mjs +7 -4
  61. package/server/services/queue-wake-hook.mjs +145 -0
  62. package/server/services/queue-waker.mjs +127 -0
  63. package/server/services/relay-ask-service.mjs +64 -5
  64. package/server/services/relay-console-log.mjs +2 -3
  65. package/server/services/relay-event-service.mjs +4 -1
  66. package/server/services/remote-relay-dispatcher.mjs +23 -4
  67. package/server/services/remote-relay-loopback.mjs +3 -3
  68. package/server/services/sdk-session-sync-service.mjs +1 -1
  69. package/server/services/session-history-refresh-service.mjs +4 -0
  70. package/server/services/session-worker-supervisor-service.mjs +36 -0
  71. package/server/services/session-worker-websocket-service.mjs +143 -15
  72. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  73. package/server/services/update-install-service.mjs +32 -1
  74. package/shared/ask-user-bridge.mjs +9 -6
  75. package/shared/relay-asks.mjs +10 -0
  76. package/shared/relay-events.mjs +1 -0
  77. package/shared/worker-runtime/heartbeat.mjs +26 -0
  78. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  79. package/shared/worker-runtime/worker-websocket-link.mjs +21 -10
  80. package/server/services/package-data-rescue-preinstall.mjs +0 -22
  81. package/server/services/package-data-rescue.mjs +0 -139
  82. package/server/tty-console-bootstrap.mjs +0 -75
@@ -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,
@@ -8,9 +8,8 @@
8
8
  // read, so the runtime copies it into `<log dir>/relay-console.log`, with a
9
9
  // time stamp per line and a size limit.
10
10
  //
11
- // The copy is taken from the console's methods rather than from stdout: the
12
- // terminal console (tty-console) redraws its prompt on stdout, and none of
13
- // that belongs in a log.
11
+ // The copy is taken from the console's methods rather than from stdout, so
12
+ // what a library writes to stdout directly stays out of it.
14
13
 
15
14
  import fs from 'fs';
16
15
  import path from 'path';
@@ -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
@@ -465,11 +465,13 @@ function relayTarget(relay) {
465
465
  /**
466
466
  * The question card's view of a relay question: what the agent needs to answer
467
467
  * it. Null for the remote's own remote-relay approval cards, which only its
468
- * 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.
469
470
  */
470
471
  function mapPendingQuestion(question) {
471
472
  if (!isPlainObject(question) || !toText(question.id)) return null;
472
473
  if (isRemoteRelayApprovalQuestion(question)) return null;
474
+ if (isPersonOnlyQuestion(question.context)) return null;
473
475
  const kind = relayQuestionKind(question);
474
476
  if (kind === RELAY_ASK_KIND) {
475
477
  // A question that does not block: the user's to answer, the session works on.
@@ -1757,7 +1759,9 @@ export function createRemoteRelayDispatcher({
1757
1759
  budget -= text.length;
1758
1760
  const role = toText(row.role) || 'assistant';
1759
1761
  const via = toText(row?.origin?.relayName);
1760
- 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 }) : [];
1761
1765
  out.unshift({
1762
1766
  id: toText(row.id),
1763
1767
  role,
@@ -2594,6 +2598,11 @@ export function createRemoteRelayDispatcher({
2594
2598
  `Question ${args.question_id} was not found on relay "${ctx.relay.name}".`);
2595
2599
  }
2596
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
+ }
2597
2606
  if (ctx.relay.local && session && isOwnConversation(ctx, session)) {
2598
2607
  throw new DispatchFailure(400, REMOTE_RELAY_ERROR_CODES.ownSession,
2599
2608
  `Question ${args.question_id} belongs to this conversation itself; an agent cannot answer its own question. It is the user's to answer.`,
@@ -3462,7 +3471,17 @@ export function createRemoteRelayDispatcher({
3462
3471
  };
3463
3472
  }
3464
3473
  const session = askSession(ctx, args);
3465
- 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
+ }));
3466
3485
  const forOther = session !== ctx.conversationId;
3467
3486
  return {
3468
3487
  summary: `ask → ${ctx.relay.name}${forOther ? ` session ${shortId(session)}` : ''}: question ${ask.id} pending${ask.urgency === 'now' ? ' (now)' : ''}`,
@@ -100,10 +100,10 @@ export function createRemoteRelayLoopbackClient({
100
100
  req.httpVersionMinor = 1;
101
101
  req.httpVersion = '1.1';
102
102
  if (hasBody) {
103
- // What the wire would deliver: JSON, parsed. `_body` tells the JSON
104
- // body parser the body is already there.
103
+ // What the wire would deliver: JSON, parsed. The headers carry no
104
+ // content-length or transfer-encoding, so the JSON body parser finds
105
+ // no body to read and leaves this one in place.
105
106
  req.body = JSON.parse(JSON.stringify(body));
106
- req._body = true;
107
107
  }
108
108
  req.complete = true;
109
109
  req.push(null);
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- import { v4 as uuidv4 } from 'uuid';
3
+ import { randomUUID as uuidv4 } from 'crypto';
4
4
 
5
5
  function normalizeId(value) {
6
6
  return String(value || '').trim();
@@ -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
  }
@@ -88,6 +88,12 @@ export function createSessionWorkerSupervisor({
88
88
  // short enough that stopping a turn and immediately sending a new message
89
89
  // does not add 30s in front of the worker's boot.
90
90
  killBlockGraceMs = 3_000,
91
+ // A kill's block on new launches and deliveries for the session ended (its
92
+ // grace ran out, or the marker was cleared): (sessionId). The relay hands
93
+ // out what waited for it.
94
+ onKillBlockLifted = null,
95
+ setTimeoutImpl = setTimeout,
96
+ clearTimeoutImpl = clearTimeout,
91
97
  isPidAlive = defaultIsPidAlive,
92
98
  diagnosticPlanReference = null,
93
99
  log = null,
@@ -98,6 +104,8 @@ export function createSessionWorkerSupervisor({
98
104
 
99
105
  const pendingStarts = new Map();
100
106
  const lifecycleBySession = new Map();
107
+ // sessionId → the timer that reports the end of its kill block.
108
+ const killBlockTimers = new Map();
101
109
  const retryLimit = Math.max(0, clampInt(maxRestartRetries, 5));
102
110
  const backoffBaseMs = Math.max(1, clampInt(restartBackoffBaseMs, 1_000));
103
111
  const backoffMaxMs = Math.max(backoffBaseMs, clampInt(restartBackoffMaxMs, 30_000));
@@ -301,9 +309,35 @@ export function createSessionWorkerSupervisor({
301
309
  });
302
310
  }
303
311
 
312
+ function reportKillBlockLifted(sessionId) {
313
+ if (typeof onKillBlockLifted !== 'function') return;
314
+ try { onKillBlockLifted(sessionId); } catch {}
315
+ }
316
+
317
+ function clearKillBlockTimer(sessionId) {
318
+ const timer = killBlockTimers.get(sessionId);
319
+ if (!timer) return false;
320
+ clearTimeoutImpl(timer);
321
+ killBlockTimers.delete(sessionId);
322
+ return true;
323
+ }
324
+
304
325
  function markKilled(sessionId) {
305
326
  if (!normalizeSessionId(sessionId)) return null;
306
327
  const killed = setLifecycle(sessionId, { killedAtMs: nowMs() });
328
+ // The block itself is read off the clock (isKillBlocked); this timer only
329
+ // says when it ends, so the rows that waited go out then.
330
+ if (killGraceMs > 0 && typeof onKillBlockLifted === 'function') {
331
+ clearKillBlockTimer(sessionId);
332
+ const timer = setTimeoutImpl(() => {
333
+ killBlockTimers.delete(sessionId);
334
+ if (!isKillBlocked(sessionId)) reportKillBlockLifted(sessionId);
335
+ // A few ms past the grace: the timer's clock and nowMs() may differ
336
+ // by a tick.
337
+ }, killGraceMs + 5);
338
+ timer?.unref?.();
339
+ killBlockTimers.set(sessionId, timer);
340
+ }
307
341
  return toLifecycleSnapshot(sessionId, killed);
308
342
  }
309
343
 
@@ -489,10 +523,12 @@ export function createSessionWorkerSupervisor({
489
523
  patch.degradedAtMs = null;
490
524
  patch.uiState = 'white';
491
525
  }
526
+ const liftsKillBlock = resetKilledMarker && clearKillBlockTimer(sessionId);
492
527
  if (resetKilledMarker) {
493
528
  patch.killedAtMs = null;
494
529
  }
495
530
  const cleared = setLifecycle(sessionId, patch);
531
+ if (liftsKillBlock) reportKillBlockLifted(sessionId);
496
532
  return toLifecycleSnapshot(sessionId, cleared);
497
533
  }
498
534