@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
@@ -361,6 +361,16 @@ export function createMessageRepository(db) {
361
361
  LIMIT ?
362
362
  `),
363
363
  countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') ${notHeldForUsageLimit()} GROUP BY status`),
364
+ // Stale recovery has nothing to look at while no row is processing.
365
+ hasProcessingRow: db.prepare(`SELECT 1 AS present FROM queue WHERE status = 'processing' LIMIT 1`),
366
+ // The earliest future retry time of a pending row (a backoff, a
367
+ // usage-limit hold): the relay wakes its queue then. A hold until the
368
+ // user resumes (year 9999) is no time.
369
+ nextPendingAttemptAt: db.prepare(`
370
+ SELECT MIN(next_attempt_at) AS due FROM queue
371
+ WHERE status = 'pending' AND next_attempt_at IS NOT NULL
372
+ AND next_attempt_at > ? AND next_attempt_at < '9999'
373
+ `),
364
374
  // Deferred host suspend: which conversations still hold live turns.
365
375
  listActiveQueueCountsByConversation: db.prepare(`
366
376
  SELECT q.conversation_id, COUNT(*) AS cnt, MAX(c.title) AS title
@@ -1,12 +1,48 @@
1
1
  'use strict';
2
2
 
3
3
  import { createSubagentTimelineStatements } from './subagent-timeline-statements.mjs';
4
+ import { hasTurnSeqColumn } from '../migrations/0038-turn-seq.mjs';
5
+
6
+ const nextTurnSeqStatements = new WeakMap();
7
+
8
+ /**
9
+ * The next number of a turn's shared order (migration 0038): one more than
10
+ * the highest turn_seq of its thoughts and tool lines together, 1 for a turn
11
+ * that has none. Call it inside the write transaction that inserts the row
12
+ * (BEGIN IMMEDIATE takes the write lock before this read), so two writers
13
+ * never get the same number.
14
+ */
15
+ export function nextTurnSeq(db, queueMessageId) {
16
+ let statement = nextTurnSeqStatements.get(db);
17
+ if (!statement) {
18
+ statement = db.prepare(`
19
+ SELECT MAX(
20
+ COALESCE((SELECT MAX(turn_seq) FROM relay_activity WHERE queue_message_id = @id AND turn_seq IS NOT NULL), 0),
21
+ COALESCE((SELECT MAX(turn_seq) FROM relay_thought WHERE queue_message_id = @id AND turn_seq IS NOT NULL), 0)
22
+ ) AS max_seq
23
+ `);
24
+ nextTurnSeqStatements.set(db, statement);
25
+ }
26
+ return Math.max(0, Number(statement.get({ id: String(queueMessageId) })?.max_seq || 0)) + 1;
27
+ }
4
28
 
5
29
  export function createQuestionRepository(db) {
6
30
  const subagentTimeline = createSubagentTimelineStatements(db);
7
31
  const runColumns = subagentTimeline.subagentRunSelectColumns;
8
32
  const runColumnsAs = (alias) => runColumns.split(', ').map((column) => `${alias}.${column}`).join(', ');
33
+ // Migration 0038 may have failed (it is retried next boot): without the
34
+ // column, rows are written without a number and read back without one,
35
+ // and every turn keeps the grouped layout.
36
+ const turnSeq = hasTurnSeqColumn(db, 'relay_activity') && hasTurnSeqColumn(db, 'relay_thought');
37
+ // The insert statements take turn_seq as their last value, which a caller
38
+ // may leave out (null); a database without the column drops it.
39
+ const fitValues = (statement, count) => ({
40
+ run: (...values) => statement.run(...values.slice(0, count), ...Array(Math.max(0, count - values.length)).fill(null)),
41
+ });
9
42
  return {
43
+ hasTurnSeq: turnSeq,
44
+ // The next turn_seq of a queue message, or null without the column.
45
+ nextTurnSeq: (queueMessageId) => (turnSeq ? nextTurnSeq(db, queueMessageId) : null),
10
46
  ...subagentTimeline,
11
47
  // relay questions
12
48
  insertQuestion: db.prepare(`INSERT INTO relay_questions (id, queue_id, conversation_id, message_id, relay_mode, prompt, choices, request, request_schema, status, answer, structured_answer, sdk_session_id, owner_worker_id, continuation_id, continuation_question_id, created_at, answered_at, expires_at, attempt_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', NULL, NULL, ?, ?, ?, ?, ?, NULL, ?, ?)`),
@@ -27,10 +63,14 @@ export function createQuestionRepository(db) {
27
63
  expireQuestions: db.prepare(`UPDATE relay_questions SET status = 'timed_out' WHERE status = 'pending' AND expires_at < ? RETURNING id, created_at`),
28
64
 
29
65
  // relay activity
30
- insertActivity: db.prepare(`INSERT INTO relay_activity (queue_message_id, response_message_id, conversation_id, relay_mode, text, created_at, subagent_run_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
66
+ // (queue_message_id, response_message_id, conversation_id, relay_mode,
67
+ // text, created_at, subagent_run_id, metadata_json, turn_seq)
68
+ insertActivity: turnSeq
69
+ ? fitValues(db.prepare(`INSERT INTO relay_activity (queue_message_id, response_message_id, conversation_id, relay_mode, text, created_at, subagent_run_id, metadata_json, turn_seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`), 9)
70
+ : fitValues(db.prepare(`INSERT INTO relay_activity (queue_message_id, response_message_id, conversation_id, relay_mode, text, created_at, subagent_run_id, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`), 8),
31
71
  linkActivityToResponse: db.prepare(`UPDATE relay_activity SET response_message_id = ? WHERE queue_message_id = ? AND response_message_id IS NULL`),
32
- listActivityByResponse: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE response_message_id = ? ORDER BY id ASC`),
33
- listActivityByQueueMessage: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE queue_message_id = ? ORDER BY id ASC`),
72
+ listActivityByResponse: db.prepare(`SELECT text, subagent_run_id, metadata_json${turnSeq ? ', turn_seq' : ''} FROM relay_activity WHERE response_message_id = ? ORDER BY id ASC`),
73
+ listActivityByQueueMessage: db.prepare(`SELECT text, subagent_run_id, metadata_json${turnSeq ? ', turn_seq' : ''} FROM relay_activity WHERE queue_message_id = ? ORDER BY id ASC`),
34
74
  // When a queue row last produced output — its newest stream snapshot
35
75
  // (rows are rewritten in place and re-stamped per update) or activity
36
76
  // line. The live-turn picker compares rows by this, never by the
@@ -73,8 +113,12 @@ export function createQuestionRepository(db) {
73
113
 
74
114
  // relay thoughts (agent reasoning)
75
115
  getLastThoughtSeqByQueueMessage: db.prepare(`SELECT COALESCE(MAX(seq), 0) AS max_seq FROM relay_thought WHERE queue_message_id = ?`),
76
- getThoughtByQueueAndReasoning: db.prepare(`SELECT seq FROM relay_thought WHERE queue_message_id = ? AND reasoning_id = ? LIMIT 1`),
77
- insertThought: db.prepare(`INSERT INTO relay_thought (queue_message_id, response_message_id, conversation_id, relay_mode, reasoning_id, seq, text, done, created_at, subagent_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
116
+ getThoughtByQueueAndReasoning: db.prepare(`SELECT seq${turnSeq ? ', turn_seq' : ''} FROM relay_thought WHERE queue_message_id = ? AND reasoning_id = ? LIMIT 1`),
117
+ // (queue_message_id, response_message_id, conversation_id, relay_mode,
118
+ // reasoning_id, seq, text, done, created_at, subagent_run_id, turn_seq)
119
+ insertThought: turnSeq
120
+ ? fitValues(db.prepare(`INSERT INTO relay_thought (queue_message_id, response_message_id, conversation_id, relay_mode, reasoning_id, seq, text, done, created_at, subagent_run_id, turn_seq) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), 11)
121
+ : fitValues(db.prepare(`INSERT INTO relay_thought (queue_message_id, response_message_id, conversation_id, relay_mode, reasoning_id, seq, text, done, created_at, subagent_run_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`), 10),
78
122
  updateThoughtByQueueAndReasoning: db.prepare(`
79
123
  UPDATE relay_thought
80
124
  SET response_message_id = COALESCE(response_message_id, ?),
@@ -88,8 +132,8 @@ export function createQuestionRepository(db) {
88
132
  AND reasoning_id = ?
89
133
  `),
90
134
  linkThoughtsToResponse: db.prepare(`UPDATE relay_thought SET response_message_id = ? WHERE queue_message_id = ? AND response_message_id IS NULL`),
91
- listThoughtsByResponse: db.prepare(`SELECT reasoning_id, seq, text, done, created_at, subagent_run_id FROM relay_thought WHERE response_message_id = ? ORDER BY seq ASC, id ASC`),
92
- listThoughtsByQueueMessage: db.prepare(`SELECT reasoning_id, seq, text, done, created_at, subagent_run_id FROM relay_thought WHERE queue_message_id = ? ORDER BY seq ASC, id ASC`),
135
+ listThoughtsByResponse: db.prepare(`SELECT reasoning_id, seq, text, done, created_at, subagent_run_id${turnSeq ? ', turn_seq' : ''} FROM relay_thought WHERE response_message_id = ? ORDER BY seq ASC, id ASC`),
136
+ listThoughtsByQueueMessage: db.prepare(`SELECT reasoning_id, seq, text, done, created_at, subagent_run_id${turnSeq ? ', turn_seq' : ''} FROM relay_thought WHERE queue_message_id = ? ORDER BY seq ASC, id ASC`),
93
137
  deleteConvThoughts: db.prepare(`DELETE FROM relay_thought WHERE conversation_id = ?`),
94
138
 
95
139
  // subagent runs
@@ -0,0 +1,56 @@
1
+ // A turn's stored thought and tool-line rows as the browser receives them
2
+ // (the message payload and the in-flight snapshot). Pure, so the payload
3
+ // shape is tested without booting the relay (server-runtime.mjs uses these).
4
+
5
+ export function sanitizeActivityText(value) {
6
+ const text = String(value || '').trim();
7
+ if (!text) return '';
8
+ return text.slice(0, 200);
9
+ }
10
+
11
+ // A row's place in its turn's order (migration 0038), or null: a row from
12
+ // before it, or a database without the column.
13
+ function rowTurnSeq(row) {
14
+ if (row?.turn_seq === null || row?.turn_seq === undefined) return null;
15
+ const value = Number(row.turn_seq);
16
+ return Number.isInteger(value) && value > 0 ? value : null;
17
+ }
18
+
19
+ export function mapRelayActivityRow(row) {
20
+ const text = sanitizeActivityText(row?.text);
21
+ if (!text) return null;
22
+ const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
23
+ // Structured rows (compaction boundaries) carry their payload alongside the
24
+ // prose so the transcript can promote them to a break row; junk JSON is
25
+ // simply dropped back to a prose-only entry.
26
+ let metadata = null;
27
+ if (row?.metadata_json) {
28
+ try {
29
+ const parsed = JSON.parse(String(row.metadata_json));
30
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) metadata = parsed;
31
+ } catch { metadata = null; }
32
+ }
33
+ // Only a numbered row says so: the payload of an unnumbered one is as before.
34
+ const turnSeq = rowTurnSeq(row);
35
+ return {
36
+ text,
37
+ subagentRunId: subagentRunId || null,
38
+ ...(metadata ? { metadata } : {}),
39
+ ...(turnSeq !== null ? { turnSeq } : {}),
40
+ };
41
+ }
42
+
43
+ export function mapRelayThoughtRow(row) {
44
+ const seq = Math.max(0, Math.trunc(Number(row?.seq || 0)));
45
+ const subagentRunId = row?.subagent_run_id ? String(row.subagent_run_id).trim() : null;
46
+ const turnSeq = rowTurnSeq(row);
47
+ return {
48
+ reasoningId: row?.reasoning_id ? String(row.reasoning_id) : null,
49
+ seq,
50
+ text: String(row?.text || ''),
51
+ done: Number(row?.done || 0) === 1,
52
+ timestamp: row?.created_at || null,
53
+ subagentRunId: subagentRunId || null,
54
+ ...(turnSeq !== null ? { turnSeq } : {}),
55
+ };
56
+ }
@@ -3,8 +3,8 @@
3
3
  import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
4
4
  import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
5
5
  import { REMOTE_RELAY_ERROR_CODES, isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
6
- import { isRelayAskId, relayQuestionKind } from '../../shared/relay-asks.mjs';
7
- import { sendRelayAskError } from './relay-ask-routes.mjs';
6
+ import { isPersonOnlyQuestion, isRelayAskId, relayQuestionKind } from '../../shared/relay-asks.mjs';
7
+ import { askForPairedRelay, sendRelayAskError } from './relay-ask-routes.mjs';
8
8
  import { closedQuestionAnswer, questionAcceptsFreeText } from '../../shared/question-answer-rule.mjs';
9
9
  import {
10
10
  extractRequestedSchema,
@@ -56,6 +56,23 @@ function isRemoteRelayApprovalRow(row) {
56
56
  return envelope ? isRemoteRelayApprovalQuestion(envelope) : false;
57
57
  }
58
58
 
59
+ /**
60
+ * A stored question row that is a Claude session's Safety check card: an
61
+ * ask Claude Code marks as person-only. Only the user answers it, never an
62
+ * agent of this relay (the loopback client counts as remote) or another.
63
+ */
64
+ function isPersonOnlyRow(row) {
65
+ const envelope = parseRowJson(row?.request);
66
+ return envelope ? isPersonOnlyQuestion(envelope.context) : false;
67
+ }
68
+
69
+ const PERSON_ONLY_ANSWER_REFUSAL = 'This card is the user\'s to answer: Claude Code marks the call as person-only, so no agent may answer it.';
70
+
71
+ /** A card no agent may list or answer. */
72
+ function isUserOnlyRow(row) {
73
+ return isRemoteRelayApprovalRow(row) || isPersonOnlyRow(row);
74
+ }
75
+
59
76
  /**
60
77
  * The choices of a stored card that takes no typed text (approval and
61
78
  * permission cards, and a card with choices that stored no flag, as the
@@ -167,13 +184,17 @@ export function registerAskUserRoutes(app, deps) {
167
184
  const conversationId = req.query.conversationId ? String(req.query.conversationId) : null;
168
185
  const status = String(req.query.status || 'pending').trim() || 'pending';
169
186
  let rows = stmts.listQuestions.all(status, conversationId, conversationId);
170
- if (fromRemoteRelay(req)) rows = rows.filter((row) => !isRemoteRelayApprovalRow(row));
187
+ if (fromRemoteRelay(req)) rows = rows.filter((row) => !isUserOnlyRow(row));
171
188
  const questions = rows.map(formatQuestionRow).map((question) => ({ ...question, kind: relayQuestionKind(question) }));
172
189
  // Questions that don't block, on request (read_session's pendingQuestions):
173
190
  // the cards' own readers take only what a turn waits on.
174
191
  if (String(req.query.includeAsks || '') === '1' && relayAskService?.isAvailable?.() && status === 'pending') {
175
192
  const asks = conversationId ? relayAskService.listOf(conversationId) : relayAskService.listHere();
176
- questions.push(...asks.filter((ask) => !ask.relayId && ask.status === 'pending' && (!conversationId || ask.conversationId === conversationId)));
193
+ // A paired relay's read_session never learns the asking turn's queue id.
194
+ const shown = fromRemoteRelay(req) ? askForPairedRelay : (ask) => ask;
195
+ questions.push(...asks
196
+ .filter((ask) => !ask.relayId && ask.status === 'pending' && (!conversationId || ask.conversationId === conversationId))
197
+ .map(shown));
177
198
  }
178
199
  res.json({ questions });
179
200
  });
@@ -181,7 +202,8 @@ export function registerAskUserRoutes(app, deps) {
181
202
  app.get('/api/relay-question/:id', auth, (req, res) => {
182
203
  if (isRelayAskId(req.params.id)) {
183
204
  const ask = relayAskService?.get?.(req.params.id) || null;
184
- return ask ? res.json({ question: ask }) : res.status(404).json({ error: 'Not found' });
205
+ if (!ask) return res.status(404).json({ error: 'Not found' });
206
+ return res.json({ question: fromRemoteRelay(req) ? askForPairedRelay(ask) : ask });
185
207
  }
186
208
  const row = stmts.getQuestion.get(req.params.id);
187
209
  if (!row || (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req))) {
@@ -343,6 +365,10 @@ export function registerAskUserRoutes(app, deps) {
343
365
  console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=remote-relay-approval-from-remote`);
344
366
  return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
345
367
  }
368
+ if (isPersonOnlyRow(row) && fromRemoteRelay(req)) {
369
+ console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=person-only-from-agent`);
370
+ return res.status(403).json({ error: PERSON_ONLY_ANSWER_REFUSAL, code: REMOTE_RELAY_ERROR_CODES.forbidden });
371
+ }
346
372
  if (row.status !== 'pending') return res.status(409).json({ error: `Question already ${row.status}` });
347
373
 
348
374
  const schema = parseQuestionRequest(row.request_schema);
@@ -418,6 +444,9 @@ export function registerAskUserRoutes(app, deps) {
418
444
  if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
419
445
  return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
420
446
  }
447
+ if (isPersonOnlyRow(row) && fromRemoteRelay(req)) {
448
+ return res.status(403).json({ error: PERSON_ONLY_ANSWER_REFUSAL, code: REMOTE_RELAY_ERROR_CODES.forbidden });
449
+ }
421
450
  if (row.status !== 'pending') return res.json({ ok: true, question: formatQuestionRow(row) });
422
451
 
423
452
  const result = stmts.timeoutQuestion.run(row.id);
@@ -113,7 +113,9 @@ const OPAQUE_RESPONSE_TEXT_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-
113
113
  const OPAQUE_RESPONSE_RECOVERY_WAIT_MS = 600_000;
114
114
  const OPAQUE_RESPONSE_RECOVERY_POLL_MS = 250;
115
115
  const STRANDED_SESSION_PRIME_COOLDOWN_MS = 10_000;
116
- const STRANDED_SESSION_HEARTBEAT_FRESH_MS = 15_000;
116
+ // An idle worker beats every 30 s (shared/worker-runtime/worker-keepalive.mjs):
117
+ // its last beat may be that old and still prove it alive.
118
+ export const STRANDED_SESSION_HEARTBEAT_FRESH_MS = 45_000;
117
119
  // Hold finalization after a relay question is answered so the SDK has time to fire the next
118
120
  // onUserInputRequest (multi-step ask_user). Without this, the transcript is used prematurely and
119
121
  // the queue row is marked done while additional questions are still pending.
@@ -161,6 +163,13 @@ export function shouldRequireNewOpenAIConversation({
161
163
  );
162
164
  }
163
165
 
166
+ // The path a `{*splat}` route matched: express 5 hands it over as its
167
+ // decoded segments, or not at all when the path ends at the slash.
168
+ export function splatPath(req) {
169
+ const splat = req.params?.splat;
170
+ return Array.isArray(splat) ? splat.join('/') : String(splat || '');
171
+ }
172
+
164
173
  function normalizeRequestedProviderType(value = '') {
165
174
  const normalized = String(value || '').trim().toLowerCase();
166
175
  if (normalized === 'openai' || normalized === 'openai-byok' || normalized === 'openai-image') return 'openai';
@@ -2140,8 +2149,6 @@ export function registerMessagesRoutes(app, deps) {
2140
2149
  deepseekClaudeConfigDir = '',
2141
2150
  } = deps;
2142
2151
 
2143
- const ttyConsoleActive = runtimeState?.ttyConsoleActive === true;
2144
-
2145
2152
  // ─── Sub-agent runs: header, timeline, live events ─────────────────────────
2146
2153
  const subagentTimeline = createSubagentTimelineService({ db, stmts });
2147
2154
 
@@ -2253,18 +2260,6 @@ export function registerMessagesRoutes(app, deps) {
2253
2260
  LIMIT 1
2254
2261
  `);
2255
2262
 
2256
- function isAbnormalWorkerTelemetry(payload = {}, level = 'log') {
2257
- const normalizedLevel = String(level || 'log').trim().toLowerCase();
2258
- if (normalizedLevel === 'warn' || normalizedLevel === 'error') return true;
2259
- const event = String(payload?.event || '').trim().toLowerCase();
2260
- const state = String(payload?.state || '').trim().toLowerCase();
2261
- if (state === 'error') return true;
2262
- return event.includes('error')
2263
- || event.includes('failed')
2264
- || event.includes('failure')
2265
- || event.includes('blocked');
2266
- }
2267
-
2268
2263
  function readBridgeIdentity(req) {
2269
2264
  return relayBridgeOwnerService?.normalizeIdentity?.({
2270
2265
  pid: req.headers['x-relay-process-pid'],
@@ -2338,8 +2333,6 @@ export function registerMessagesRoutes(app, deps) {
2338
2333
  const payload = extra && typeof extra === 'object'
2339
2334
  ? { ...envelope, ...extra }
2340
2335
  : envelope;
2341
- const shouldLog = !ttyConsoleActive || isAbnormalWorkerTelemetry(payload, level);
2342
- if (!shouldLog) return;
2343
2336
  const method = typeof console[level] === 'function' ? level : 'log';
2344
2337
  console[method](`[${ts()}] WORKER ${JSON.stringify(payload)}`);
2345
2338
  }
@@ -4019,8 +4012,8 @@ export function registerMessagesRoutes(app, deps) {
4019
4012
  });
4020
4013
  });
4021
4014
 
4022
- app.get('/api/files/*', auth, (req, res) => {
4023
- const requestedPath = String(req.params?.[0] || '').trim();
4015
+ app.get('/api/files/{*splat}', auth, (req, res) => {
4016
+ const requestedPath = splatPath(req).trim();
4024
4017
  const rootOverride = resolveScopedWorkspaceRootPath(req);
4025
4018
  const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
4026
4019
  if (!filePath) return res.status(400).json({ error: 'Invalid file path' });
@@ -4041,8 +4034,8 @@ export function registerMessagesRoutes(app, deps) {
4041
4034
  });
4042
4035
  });
4043
4036
 
4044
- app.get('/api/files-preview/*', auth, (req, res) => {
4045
- const requestedPath = String(req.params?.[0] || '').trim();
4037
+ app.get('/api/files-preview/{*splat}', auth, (req, res) => {
4038
+ const requestedPath = splatPath(req).trim();
4046
4039
  const normalizedPath = normalizeWorkspaceRelativePath(requestedPath);
4047
4040
  const rootOverride = resolveScopedWorkspaceRootPath(req);
4048
4041
  const filePath = resolveWorkspaceFilePath(requestedPath, rootOverride);
@@ -9179,16 +9172,28 @@ export function registerMessagesRoutes(app, deps) {
9179
9172
  const responseMessageId = q?.response_message_id || null;
9180
9173
  const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
9181
9174
  const normalizedMetadata = sanitizeActivityMetadata(metadata);
9182
- const activityRow = stmts.insertActivity.run(
9183
- messageId,
9184
- responseMessageId,
9185
- conversationId,
9186
- normalizeRelayMode(mode) || DEFAULT_RELAY_MODE,
9187
- activityText,
9188
- new Date().toISOString(),
9189
- normalizedSubagentRunId,
9190
- normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
9191
- );
9175
+ // The row's place in the turn's order (thoughts and tool lines share
9176
+ // it): numbered and written in one IMMEDIATE transaction, so two writers
9177
+ // on the same turn never get the same number.
9178
+ let turnSeq = null;
9179
+ const insertActivityRow = () => {
9180
+ turnSeq = stmts.nextTurnSeq?.(messageId) ?? null;
9181
+ return stmts.insertActivity.run(
9182
+ messageId,
9183
+ responseMessageId,
9184
+ conversationId,
9185
+ normalizeRelayMode(mode) || DEFAULT_RELAY_MODE,
9186
+ activityText,
9187
+ new Date().toISOString(),
9188
+ normalizedSubagentRunId,
9189
+ normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
9190
+ turnSeq,
9191
+ );
9192
+ };
9193
+ const insertActivityTx = typeof db?.transaction === 'function' ? db.transaction(insertActivityRow) : null;
9194
+ const activityRow = typeof insertActivityTx?.immediate === 'function'
9195
+ ? insertActivityTx.immediate()
9196
+ : insertActivityRow();
9192
9197
  if (normalizedSubagentRunId) {
9193
9198
  // The lane keeps the capped line; the timeline copy keeps what came in.
9194
9199
  mirrorSubagentFrame({
@@ -9206,6 +9211,7 @@ export function registerMessagesRoutes(app, deps) {
9206
9211
  timestamp: new Date().toISOString(),
9207
9212
  subagentRunId: normalizedSubagentRunId || undefined,
9208
9213
  ...(normalizedMetadata ? { metadata: normalizedMetadata } : {}),
9214
+ ...(turnSeq !== null ? { turnSeq } : {}),
9209
9215
  });
9210
9216
  res.json({ ok: true });
9211
9217
  });
@@ -9363,6 +9369,9 @@ export function registerMessagesRoutes(app, deps) {
9363
9369
  const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
9364
9370
  const normalizedReasoningId = String(reasoningId || '').trim() || null;
9365
9371
  let seq = null;
9372
+ // The thought's place in the turn's order: given on its first write and
9373
+ // kept by every update, so a thought sits where its first chunk arrived.
9374
+ let turnSeq = null;
9366
9375
  const shouldPersistThought = !!done || !!normalizedReasoningId;
9367
9376
  if (shouldPersistThought) {
9368
9377
  const isThoughtSeqConstraintError = (error) => {
@@ -9397,12 +9406,13 @@ export function registerMessagesRoutes(app, deps) {
9397
9406
  messageId,
9398
9407
  normalizedReasoningId,
9399
9408
  );
9400
- return existingSeq;
9409
+ return { seq: existingSeq, turnSeq: existingThought.turn_seq == null ? null : Number(existingThought.turn_seq) };
9401
9410
  }
9402
9411
  }
9403
9412
  const row = stmts.getLastThoughtSeqByQueueMessage?.get(messageId);
9404
9413
  const maxSeq = Math.max(0, Number(row?.max_seq || 0));
9405
9414
  const nextSeq = maxSeq + 1;
9415
+ const nextTurn = stmts.nextTurnSeq?.(messageId) ?? null;
9406
9416
  stmts.insertThought?.run(
9407
9417
  messageId,
9408
9418
  responseMessageId,
@@ -9414,8 +9424,9 @@ export function registerMessagesRoutes(app, deps) {
9414
9424
  done ? 1 : 0,
9415
9425
  now,
9416
9426
  normalizedSubagentRunId,
9427
+ nextTurn,
9417
9428
  );
9418
- return nextSeq;
9429
+ return { seq: nextSeq, turnSeq: nextTurn };
9419
9430
  });
9420
9431
  const runInsertThought = typeof insertThoughtTx?.immediate === 'function'
9421
9432
  ? insertThoughtTx.immediate.bind(insertThoughtTx)
@@ -9423,7 +9434,7 @@ export function registerMessagesRoutes(app, deps) {
9423
9434
  const maxRetries = 4;
9424
9435
  for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
9425
9436
  try {
9426
- seq = runInsertThought();
9437
+ ({ seq, turnSeq } = runInsertThought());
9427
9438
  break;
9428
9439
  } catch (error) {
9429
9440
  if (error?.code === 'THOUGHT_CONVERSATION_MISMATCH') {
@@ -9458,6 +9469,7 @@ export function registerMessagesRoutes(app, deps) {
9458
9469
  seq,
9459
9470
  timestamp: now,
9460
9471
  subagentRunId: normalizedSubagentRunId || undefined,
9472
+ ...(turnSeq !== null ? { turnSeq } : {}),
9461
9473
  });
9462
9474
  res.json({ ok: true, seq });
9463
9475
  });
@@ -151,6 +151,16 @@ export function inboxItemOfPlanBoard(board, session, { forRemote = false } = {})
151
151
  };
152
152
  }
153
153
 
154
+ /**
155
+ * An ask as a paired relay gets it: without `queueId`, the queue row of the
156
+ * turn that asked here, which means nothing there.
157
+ */
158
+ export function askForPairedRelay(ask) {
159
+ if (!ask || typeof ask !== 'object') return ask;
160
+ const { queueId, ...rest } = ask;
161
+ return rest;
162
+ }
163
+
154
164
  /** One Inbox item of an ask (relay-ask-service.mjs shape). */
155
165
  export function inboxItemOfAsk(ask) {
156
166
  const {
@@ -322,7 +332,7 @@ export function registerRelayAskRoutes(app, deps) {
322
332
  if (!relayAskService?.isAvailable?.()) return res.json({ available: false, asks: [], pending: 0 });
323
333
  const conversationId = toText(req.query.conversationId);
324
334
  const asks = conversationId
325
- ? relayAskService.listOf(conversationId).filter((ask) => !ask.relayId && ask.conversationId === conversationId)
335
+ ? relayAskService.listIn(conversationId).filter((ask) => !ask.relayId && ask.conversationId === conversationId)
326
336
  : relayAskService.listHere();
327
337
  res.json({ available: true, asks, pending: relayAskService.pendingCount() });
328
338
  });
@@ -411,7 +421,7 @@ export function registerRelayAskRoutes(app, deps) {
411
421
  detail: body.detail,
412
422
  urgency: body.urgency,
413
423
  });
414
- res.json({ ok: true, ask });
424
+ res.json({ ok: true, ask: askForPairedRelay(ask) });
415
425
  } catch (error) {
416
426
  sendRelayAskError(res, error, 'The ask');
417
427
  }
@@ -433,7 +443,7 @@ export function registerRelayAskRoutes(app, deps) {
433
443
  const caller = admitPairedRelay(req, res);
434
444
  if (!caller || !available(res)) return;
435
445
  try {
436
- res.json({ ok: true, ask: relayAskService.withdraw(toText(asObject(req.body).id), { byRelayId: caller.relayId }) });
446
+ res.json({ ok: true, ask: askForPairedRelay(relayAskService.withdraw(toText(asObject(req.body).id), { byRelayId: caller.relayId })) });
437
447
  } catch (error) {
438
448
  sendRelayAskError(res, error, 'The withdrawal');
439
449
  }
@@ -447,7 +457,7 @@ export function registerRelayAskRoutes(app, deps) {
447
457
  const caller = admitPairedRelay(req, res, body.via);
448
458
  if (!caller || !available(res)) return;
449
459
  try {
450
- res.json({ ok: true, ask: relayAskService.answer(toText(body.id), { answer: String(body.answer ?? ''), choice: String(body.choice ?? ''), via: caller.name }) });
460
+ res.json({ ok: true, ask: askForPairedRelay(relayAskService.answer(toText(body.id), { answer: String(body.answer ?? ''), choice: String(body.choice ?? ''), via: caller.name })) });
451
461
  } catch (error) {
452
462
  sendRelayAskError(res, error, 'The answer');
453
463
  }
@@ -458,7 +468,7 @@ export function registerRelayAskRoutes(app, deps) {
458
468
  const caller = admitPairedRelay(req, res, body.via);
459
469
  if (!caller || !available(res)) return;
460
470
  try {
461
- res.json({ ok: true, ask: relayAskService.dismiss(toText(body.id), { via: caller.name }) });
471
+ res.json({ ok: true, ask: askForPairedRelay(relayAskService.dismiss(toText(body.id), { via: caller.name })) });
462
472
  } catch (error) {
463
473
  sendRelayAskError(res, error, 'The dismissal');
464
474
  }
@@ -78,6 +78,7 @@ import { createShareRepository } from '../repositories/share-repository.mjs';
78
78
  import { createShareAccessLog, readShareRequestViewer } from '../services/share-access-log-service.mjs';
79
79
  import { GROUP_NAME_MAX, resolveGroupDisplayName } from '../repositories/conversation-group-repository.mjs';
80
80
  import { conversationGroupPosition, conversationGroupRole, groupActivityTimes, pageConversationRows } from '../public/app/conversation-group-order.mjs';
81
+ import { detailsVersion, parseHeldDetailsParam, thoughtFingerprint } from '../public/app/held-details.mjs';
81
82
  import { admitRemoteRelayRequest, publicRemoteRelayOrigin, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
82
83
  import {
83
84
  RELAUNCH_COALESCE_WINDOW_MS,
@@ -160,7 +161,7 @@ export { mapUsageSnapshotRow };
160
161
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
161
162
  const SESSION_WORKER_STATUS_QUEUE_STATES = Object.freeze(['pending', 'processing', 'parked']);
162
163
  // Registry statuses that mean "this worker is currently running" (same triple
163
- // as countActiveCliWorkers in server-runtime.mjs).
164
+ // as ACTIVE_SESSION_CWD_STATUSES in server-runtime.mjs).
164
165
  const SESSION_WORKER_ACTIVE_STATUSES = Object.freeze(['starting', 'ready', 'processing']);
165
166
  // A shared attachment's URL names its bytes (sha256 or a fixed generated-image
166
167
  // path), so a kept answer stays right.
@@ -4706,6 +4707,59 @@ export function registerSessionsRoutes(app, deps) {
4706
4707
  });
4707
4708
 
4708
4709
  // GET /api/conversation/:id — get paginated conversation history
4710
+ // The details version of each finished reply (held-details.mjs), one query
4711
+ // per table for the whole window (the ids go in as one JSON parameter).
4712
+ // Tool rows are only inserted: their count and highest row id. Thought rows
4713
+ // are updated in place as they stream and when done: their rows are read
4714
+ // (id, done, text) and fingerprinted, so any change shows.
4715
+ let detailsVersionStatements = null;
4716
+ function relayDetailsVersions(messageIds) {
4717
+ const ids = [...new Set(messageIds.filter(Boolean))];
4718
+ const versions = new Map();
4719
+ if (!ids.length || typeof db?.prepare !== 'function') return versions;
4720
+ try {
4721
+ detailsVersionStatements ||= {
4722
+ activity: db.prepare(`
4723
+ SELECT response_message_id AS id, COUNT(*) AS n, MAX(rowid) AS max_rowid
4724
+ FROM relay_activity
4725
+ WHERE response_message_id IN (SELECT value FROM json_each(?))
4726
+ GROUP BY response_message_id
4727
+ `),
4728
+ thought: db.prepare(`
4729
+ SELECT response_message_id AS response_id, rowid AS row_id, done, text
4730
+ FROM relay_thought
4731
+ WHERE response_message_id IN (SELECT value FROM json_each(?))
4732
+ `),
4733
+ };
4734
+ const idsJson = JSON.stringify(ids);
4735
+ const activity = new Map();
4736
+ for (const row of detailsVersionStatements.activity.all(idsJson) || []) {
4737
+ activity.set(String(row?.id || ''), { n: Number(row.n) || 0, max: Number(row.max_rowid) || 0 });
4738
+ }
4739
+ const thoughtRows = new Map();
4740
+ for (const row of detailsVersionStatements.thought.all(idsJson) || []) {
4741
+ const id = String(row?.response_id || '');
4742
+ if (!thoughtRows.has(id)) thoughtRows.set(id, []);
4743
+ thoughtRows.get(id).push({ rowid: row.row_id, done: row.done, text: row.text });
4744
+ }
4745
+ for (const id of ids) {
4746
+ const tools = activity.get(id) || { n: 0, max: 0 };
4747
+ const thoughts = thoughtRows.get(id) || [];
4748
+ versions.set(id, detailsVersion({
4749
+ activityCount: tools.n,
4750
+ activityMaxRowId: tools.max,
4751
+ thoughtCount: thoughts.length,
4752
+ thoughtMaxRowId: thoughts.reduce((max, row) => Math.max(max, Number(row.rowid) || 0), 0),
4753
+ thoughtHash: thoughtFingerprint(thoughts),
4754
+ }));
4755
+ }
4756
+ } catch {
4757
+ // No versions: nothing is held, everything goes out in full.
4758
+ return new Map();
4759
+ }
4760
+ return versions;
4761
+ }
4762
+
4709
4763
  app.get('/api/conversation/:id', auth, (req, res) => {
4710
4764
  const requestedId = String(req.params.id || '').trim();
4711
4765
  const limit = normalizeConversationHistoryLimit(req.query.limit, 20);
@@ -4729,7 +4783,9 @@ export function registerSessionsRoutes(app, deps) {
4729
4783
  supportedRelayModes: SUPPORTED_RELAY_MODES,
4730
4784
  defaultRelayMode: DEFAULT_RELAY_MODE,
4731
4785
  });
4732
- const inFlight = inFlightStateForConversation(resolvedConversationId);
4786
+ // The running turn's tool lines: as many per thread as this page shows
4787
+ // (its "Tool lines shown" setting); none given, the relay's default.
4788
+ const inFlight = inFlightStateForConversation(resolvedConversationId, { activityCap: req.query.activityCap });
4733
4789
  const sdkSessionId = String(conv.sdk_session_id || resolvedConversationId || '').trim();
4734
4790
  const sessionUsageSummary = resolveSessionUsageSummaryForSdkSession({
4735
4791
  sdkSessionId,
@@ -4768,14 +4824,23 @@ export function registerSessionsRoutes(app, deps) {
4768
4824
  .map((row) => [String(row?.response_message_id || '').trim(), String(row?.id || '').trim()])
4769
4825
  .filter(([responseMessageId, sourceMessageId]) => !!responseMessageId && !!sourceMessageId),
4770
4826
  );
4827
+ // The live poll names the finished replies whose tool lines and thoughts
4828
+ // it holds (`heldDetails`, held-details.mjs); those whose details have
4829
+ // not changed since (the same `detailsVersion`) go out without them.
4830
+ const assistantIds = dbMessages.filter((m) => m.role === 'assistant').map((m) => String(m.id));
4831
+ const detailsVersions = relayDetailsVersions(assistantIds);
4832
+ const heldParam = parseHeldDetailsParam(req.query.heldDetails);
4833
+ const heldNow = new Set(heldParam
4834
+ ? assistantIds.filter((id) => heldParam.get(id) === detailsVersions.get(id))
4835
+ : []);
4771
4836
  const relayActivitiesByMessageId = new Map(
4772
4837
  dbMessages
4773
- .filter((m) => m.role === 'assistant')
4838
+ .filter((m) => m.role === 'assistant' && !heldNow.has(String(m.id)))
4774
4839
  .map((m) => [m.id, relayActivityForResponse(m.id)]),
4775
4840
  );
4776
4841
  const relayThoughtsByMessageId = new Map(
4777
4842
  dbMessages
4778
- .filter((m) => m.role === 'assistant')
4843
+ .filter((m) => m.role === 'assistant' && !heldNow.has(String(m.id)))
4779
4844
  .map((m) => [m.id, relayThoughtsForResponse ? relayThoughtsForResponse(m.id) : []]),
4780
4845
  );
4781
4846
  const subagentRunsByMessageId = new Map(
@@ -4816,8 +4881,14 @@ export function registerSessionsRoutes(app, deps) {
4816
4881
  });
4817
4882
  messages = messages.map((message) => {
4818
4883
  if (message.role !== 'assistant') return message;
4819
- const sourceMessageId = responseMessageToSourceId.get(String(message.id || '').trim()) || message.sourceMessageId || undefined;
4820
- return sourceMessageId ? { ...message, sourceMessageId } : message;
4884
+ const id = String(message.id || '').trim();
4885
+ const sourceMessageId = responseMessageToSourceId.get(id) || message.sourceMessageId || undefined;
4886
+ // Held: the page fills these from what it has. Otherwise the version
4887
+ // the page keeps with them.
4888
+ const details = heldNow.has(id)
4889
+ ? { activities: [], thoughts: [], detailsHeld: true }
4890
+ : (detailsVersions.has(id) ? { detailsVersion: detailsVersions.get(id) } : {});
4891
+ return { ...message, ...(sourceMessageId ? { sourceMessageId } : {}), ...details };
4821
4892
  });
4822
4893
  const history = sqlPage
4823
4894
  ? { messages, pageInfo: sqlPage.pageInfo }