@oar-sh/oar 0.9.8 → 0.9.10

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 (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
@@ -0,0 +1,39 @@
1
+ 'use strict';
2
+
3
+ // Pinned messages: the pinned_at column detection and its SQL in one place.
4
+ // The statements are null on a database the 0008 migration has not reached
5
+ // yet; every caller treats that as "no pins".
6
+ export function createMessagePinStatements(db) {
7
+ const messageColumns = new Set(
8
+ db.prepare(`PRAGMA table_info(messages)`).all().map((column) => String(column?.name || '').trim()),
9
+ );
10
+ const messagesSupportPins = messageColumns.has('pinned_at');
11
+ if (!messagesSupportPins) {
12
+ return {
13
+ messagesSupportPins,
14
+ setMessagePinnedAt: null,
15
+ listPinnedMessages: null,
16
+ countPinnedMessages: null,
17
+ };
18
+ }
19
+ return {
20
+ messagesSupportPins,
21
+ setMessagePinnedAt: db.prepare(`
22
+ UPDATE messages
23
+ SET pinned_at = ?
24
+ WHERE id = ? AND conversation_id = ?
25
+ `),
26
+ // Conversation order, not pin order: the list reads like a table of contents.
27
+ listPinnedMessages: db.prepare(`
28
+ SELECT id, role, text, mode, attachments, timestamp, pinned_at, hidden_from_shares
29
+ FROM messages
30
+ WHERE conversation_id = ? AND pinned_at IS NOT NULL
31
+ ORDER BY timestamp ASC
32
+ `),
33
+ countPinnedMessages: db.prepare(`
34
+ SELECT COUNT(*) AS cnt
35
+ FROM messages
36
+ WHERE conversation_id = ? AND pinned_at IS NOT NULL
37
+ `),
38
+ };
39
+ }
@@ -1,10 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
4
+ import { createMessagePinStatements } from './message-pin-statements.mjs';
4
5
  import { sessionWorkerProviderSqlList } from '../../shared/provider-routing.mjs';
5
6
 
6
7
  export function createMessageRepository(db) {
7
8
  const shareVisibility = createShareVisibilityStatements(db);
9
+ const messagePins = createMessagePinStatements(db);
8
10
  const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
9
11
  .some((column) => column.name === 'image_operation_id');
10
12
  // A turn paused at the Claude usage limit (usage-limit-pause-service): a
@@ -27,6 +29,9 @@ export function createMessageRepository(db) {
27
29
  getSharedMessages: shareVisibility.getSharedMessages,
28
30
  getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
29
31
  setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
32
+ setMessagePinnedAt: messagePins.setMessagePinnedAt,
33
+ listPinnedMessages: messagePins.listPinnedMessages,
34
+ countPinnedMessages: messagePins.countPinnedMessages,
30
35
  getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
31
36
  getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
32
37
  insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
@@ -341,6 +346,12 @@ export function createMessageRepository(db) {
341
346
  //
342
347
  // A turn blocked on an unanswered AskUserQuestion is never stale — it is
343
348
  // waiting on the human, and relay_questions carries its own expiry.
349
+ //
350
+ // The ceiling counts from the last human interaction with the turn, not
351
+ // from its start: answering a question restarts the clock. Otherwise an
352
+ // interview that waited hours for its answers would be cut off by the
353
+ // ceiling the moment the last answer arrived (live 2026-10-10: a
354
+ // five-question interview lost its reply that way).
344
355
  listRecoverableProcessing: db.prepare(`
345
356
  SELECT id, conversation_id, kind, relay_mode, model, retry_count, consumed_at
346
357
  FROM queue
@@ -348,7 +359,13 @@ export function createMessageRepository(db) {
348
359
  AND COALESCE(kind, '') != 'continuation'
349
360
  AND (
350
361
  COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
351
- OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
362
+ OR (
363
+ @ceilingBefore IS NOT NULL
364
+ AND MAX(
365
+ COALESCE(processing_at, timestamp),
366
+ COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
367
+ ) < @ceilingBefore
368
+ )
352
369
  )
353
370
  AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
354
371
  `),
@@ -426,15 +443,29 @@ export function createMessageRepository(db) {
426
443
  // A continuation with no live worker can only be torn down — replaying
427
444
  // it as deliverable work would send the CLI's own bookkeeping text back
428
445
  // to the CLI as a user prompt.
446
+ //
447
+ // Same staleness rules as listRecoverableProcessing: a continuation
448
+ // waiting on an unanswered question is never stale, and an answer
449
+ // restarts the ceiling clock. A background-agent interview hangs its
450
+ // question cards on the continuation row, so without this a long
451
+ // interview was torn down by the ceiling while the human was still
452
+ // answering (live 2026-10-10).
429
453
  listStaleProcessingContinuations: db.prepare(`
430
- SELECT id, conversation_id
454
+ SELECT id, conversation_id, timestamp, processing_at, owner_last_claimed_at
431
455
  FROM queue
432
456
  WHERE status = 'processing'
433
457
  AND kind = 'continuation'
434
458
  AND (
435
459
  COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
436
- OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
460
+ OR (
461
+ @ceilingBefore IS NOT NULL
462
+ AND MAX(
463
+ COALESCE(processing_at, timestamp),
464
+ COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
465
+ ) < @ceilingBefore
466
+ )
437
467
  )
468
+ AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
438
469
  `),
439
470
  listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
440
471
  // Authoritative "is a turn in flight" signal for the conversation list.
@@ -1,7 +1,13 @@
1
1
  'use strict';
2
2
 
3
+ import { createSubagentTimelineStatements } from './subagent-timeline-statements.mjs';
4
+
3
5
  export function createQuestionRepository(db) {
6
+ const subagentTimeline = createSubagentTimelineStatements(db);
7
+ const runColumns = subagentTimeline.subagentRunSelectColumns;
8
+ const runColumnsAs = (alias) => runColumns.split(', ').map((column) => `${alias}.${column}`).join(', ');
4
9
  return {
10
+ ...subagentTimeline,
5
11
  // relay questions
6
12
  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, ?, ?)`),
7
13
  getQuestion: db.prepare(`SELECT * FROM relay_questions WHERE id = ?`),
@@ -99,7 +105,7 @@ export function createQuestionRepository(db) {
99
105
  WHERE id = ?
100
106
  `),
101
107
  listSubagentRunsByQueueMessage: db.prepare(`
102
- SELECT id, queue_message_id, conversation_id, parent_subagent_id, display_name, status, started_at, updated_at, completed_at
108
+ SELECT ${runColumns}
103
109
  FROM subagent_runs
104
110
  WHERE queue_message_id = ?
105
111
  ORDER BY started_at ASC, id ASC
@@ -108,7 +114,7 @@ export function createQuestionRepository(db) {
108
114
  // abort) also ends its subagent runs — nothing else ever will, and an
109
115
  // un-reconciled row renders as a bubble stuck "running" forever.
110
116
  listRunningSubagentRunsByQueueMessage: db.prepare(`
111
- SELECT id, conversation_id, parent_subagent_id, display_name
117
+ SELECT ${runColumns}
112
118
  FROM subagent_runs
113
119
  WHERE queue_message_id = ? AND status = 'running'
114
120
  `),
@@ -121,7 +127,7 @@ export function createQuestionRepository(db) {
121
127
  WHERE queue_message_id = ? AND status = 'running'
122
128
  `),
123
129
  listSubagentRunsByResponse: db.prepare(`
124
- SELECT sr.id, sr.queue_message_id, sr.conversation_id, sr.parent_subagent_id, sr.display_name, sr.status, sr.started_at, sr.updated_at, sr.completed_at
130
+ SELECT ${runColumnsAs('sr')}
125
131
  FROM subagent_runs sr
126
132
  WHERE sr.queue_message_id IN (
127
133
  SELECT id FROM queue WHERE response_message_id = ?
@@ -29,7 +29,10 @@ export function createRemoteRelayRepository(db) {
29
29
  const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
30
30
  const listUnlocks = db.prepare(`SELECT remote_relay_id, message_id, created_at FROM conversation_remote_relay_unlocks WHERE conversation_id = ? ORDER BY created_at ASC`);
31
31
  const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
32
- const deleteUnlocksForRelay = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE remote_relay_id = ?`);
32
+ // The relay's unlocks and the approvals stored under its id (`<id>:watch`).
33
+ const deleteUnlocksForRelay = db.prepare(`
34
+ DELETE FROM conversation_remote_relay_unlocks WHERE remote_relay_id = ? OR substr(remote_relay_id, 1, length(?) + 1) = ? || ':'
35
+ `);
33
36
  const deleteUnlocksForConversation = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE conversation_id = ?`);
34
37
  const setMessageOrigin = db.prepare(`UPDATE messages SET origin_json = ? WHERE id = ?`);
35
38
  const getMessageOrigin = db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
@@ -101,7 +104,8 @@ export function createRemoteRelayRepository(db) {
101
104
  .map((row) => String(row.id));
102
105
  },
103
106
  forgetRelay(remoteRelayId) {
104
- return deleteUnlocksForRelay.run(String(remoteRelayId || '')).changes;
107
+ const id = String(remoteRelayId || '');
108
+ return deleteUnlocksForRelay.run(id, id, id).changes;
105
109
  },
106
110
  forgetConversation(conversationId) {
107
111
  return deleteUnlocksForConversation.run(String(conversationId || '')).changes;
@@ -0,0 +1,170 @@
1
+ 'use strict';
2
+
3
+ // Sub-agent viewer: the timeline table, the run header columns and their SQL
4
+ // in one place. On a database the 0009 migration has not reached yet the
5
+ // timeline statements are null and the header columns read as absent; every
6
+ // caller treats that as "no timeline" and falls back to the activity, thought
7
+ // and stream rows a run has always had.
8
+
9
+ const HEADER_COLUMNS = [
10
+ 'model',
11
+ 'model_inherited',
12
+ 'subagent_type',
13
+ 'task_id',
14
+ 'total_tokens',
15
+ 'background',
16
+ 'truncated',
17
+ 'timeline_owner',
18
+ ];
19
+
20
+ const BASE_RUN_COLUMNS = [
21
+ 'id',
22
+ 'queue_message_id',
23
+ 'conversation_id',
24
+ 'parent_subagent_id',
25
+ 'display_name',
26
+ 'status',
27
+ 'started_at',
28
+ 'updated_at',
29
+ 'completed_at',
30
+ ];
31
+
32
+ export function createSubagentTimelineStatements(db) {
33
+ const tableNames = new Set(
34
+ db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table'`).all().map((row) => String(row.name)),
35
+ );
36
+ if (!tableNames.has('subagent_runs')) {
37
+ return { subagentRunsSupportHeader: false, subagentTimelineSupported: false, subagentRunSelectColumns: BASE_RUN_COLUMNS.join(', ') };
38
+ }
39
+ const runColumns = new Set(db.prepare(`PRAGMA table_info(subagent_runs)`).all().map((column) => String(column.name)));
40
+ const subagentRunsSupportHeader = HEADER_COLUMNS.every((name) => runColumns.has(name));
41
+ const subagentTimelineSupported = subagentRunsSupportHeader && tableNames.has('subagent_timeline');
42
+ const subagentRunSelectColumns = [...BASE_RUN_COLUMNS, ...(subagentRunsSupportHeader ? HEADER_COLUMNS : [])].join(', ');
43
+
44
+ const statements = {
45
+ subagentRunsSupportHeader,
46
+ subagentTimelineSupported,
47
+ // The column list every run read uses, so the header travels with the
48
+ // run wherever runs are listed (conversation payload, in-flight snapshot).
49
+ subagentRunSelectColumns,
50
+ updateSubagentRunHeader: null,
51
+ setSubagentRunTruncated: null,
52
+ listChildSubagentRuns: null,
53
+ getSubagentRunByTaskId: null,
54
+ listRunningBackgroundSubagentRunsForSession: null,
55
+ getTimelineEntryByKey: null,
56
+ getMaxTimelineSeq: null,
57
+ countTimelineEntries: null,
58
+ insertTimelineEntry: null,
59
+ updateTimelineEntryByKey: null,
60
+ listTimelineEntries: null,
61
+ deleteConvSubagentTimeline: null,
62
+ listLegacyActivityBySubagentRun: null,
63
+ listLegacyThoughtsBySubagentRun: null,
64
+ listLegacyStreamBySubagentRun: null,
65
+ };
66
+
67
+ // What a run recorded before the timeline existed: its tagged activity
68
+ // lines, thoughts and reply text. The viewer reads these for an old run.
69
+ const hasRunColumn = (table) => tableNames.has(table)
70
+ && db.prepare(`PRAGMA table_info(${table})`).all().some((column) => column.name === 'subagent_run_id');
71
+ if (hasRunColumn('relay_activity')) {
72
+ statements.listLegacyActivityBySubagentRun = db.prepare(`
73
+ SELECT id, text, metadata_json, created_at FROM relay_activity
74
+ WHERE subagent_run_id = ? ORDER BY id ASC LIMIT ?
75
+ `);
76
+ }
77
+ if (hasRunColumn('relay_thought')) {
78
+ statements.listLegacyThoughtsBySubagentRun = db.prepare(`
79
+ SELECT reasoning_id, seq, text, done, created_at FROM relay_thought
80
+ WHERE subagent_run_id = ? ORDER BY seq ASC, id ASC LIMIT ?
81
+ `);
82
+ }
83
+ if (hasRunColumn('relay_stream_events')) {
84
+ statements.listLegacyStreamBySubagentRun = db.prepare(`
85
+ SELECT text, done, created_at FROM relay_stream_events
86
+ WHERE subagent_run_id = ? ORDER BY seq ASC, id ASC LIMIT ?
87
+ `);
88
+ }
89
+ if (!subagentRunsSupportHeader) return statements;
90
+
91
+ // A patch, not a replace: every field the writer did not name (NULL) keeps
92
+ // what the row has. `background` and `truncated` only ever turn on, and the
93
+ // timeline owner is claimed once.
94
+ statements.updateSubagentRunHeader = db.prepare(`
95
+ UPDATE subagent_runs
96
+ SET model = COALESCE(@model, model),
97
+ model_inherited = COALESCE(@modelInherited, model_inherited),
98
+ subagent_type = COALESCE(@subagentType, subagent_type),
99
+ task_id = COALESCE(@taskId, task_id),
100
+ total_tokens = COALESCE(@totalTokens, total_tokens),
101
+ background = MAX(background, COALESCE(@background, 0)),
102
+ truncated = MAX(truncated, COALESCE(@truncated, 0)),
103
+ timeline_owner = COALESCE(timeline_owner, @timelineOwner),
104
+ updated_at = @updatedAt
105
+ WHERE id = @id
106
+ `);
107
+ statements.setSubagentRunTruncated = db.prepare(`UPDATE subagent_runs SET truncated = 1, updated_at = ? WHERE id = ?`);
108
+ statements.listChildSubagentRuns = db.prepare(`
109
+ SELECT ${subagentRunSelectColumns}
110
+ FROM subagent_runs
111
+ WHERE parent_subagent_id = ?
112
+ ORDER BY started_at ASC, id ASC
113
+ `);
114
+ // A worker's live background agents: what dies with it.
115
+ if (tableNames.has('conversations')) {
116
+ statements.listRunningBackgroundSubagentRunsForSession = db.prepare(`
117
+ SELECT ${subagentRunSelectColumns}
118
+ FROM subagent_runs
119
+ WHERE status = 'running' AND background = 1
120
+ AND conversation_id IN (SELECT id FROM conversations WHERE sdk_session_id = ? OR id = ?)
121
+ `);
122
+ }
123
+ statements.getSubagentRunByTaskId = db.prepare(`
124
+ SELECT ${subagentRunSelectColumns}
125
+ FROM subagent_runs
126
+ WHERE conversation_id = ? AND task_id = ?
127
+ ORDER BY started_at DESC
128
+ LIMIT 1
129
+ `);
130
+
131
+ if (!subagentTimelineSupported) return statements;
132
+
133
+ statements.getTimelineEntryByKey = db.prepare(`SELECT id, seq, done FROM subagent_timeline WHERE run_id = ? AND entry_key = ?`);
134
+ statements.getMaxTimelineSeq = db.prepare(`SELECT COALESCE(MAX(seq), 0) AS max_seq FROM subagent_timeline WHERE run_id = ?`);
135
+ statements.countTimelineEntries = db.prepare(`SELECT COUNT(*) AS cnt FROM subagent_timeline WHERE run_id = ?`);
136
+ statements.insertTimelineEntry = db.prepare(`
137
+ INSERT INTO subagent_timeline (
138
+ run_id, conversation_id, queue_message_id, entry_key, seq, kind, text, tool_name, tool_use_id,
139
+ metadata_json, done, created_at, updated_at
140
+ ) VALUES (
141
+ @runId, @conversationId, @queueMessageId, @entryKey, @seq, @kind, @text, @toolName, @toolUseId,
142
+ @metadataJson, @done, @createdAt, @updatedAt
143
+ )
144
+ `);
145
+ // In place: the entry keeps its seq (its place in the order). A closed
146
+ // entry stays closed, though its text may still be corrected (the complete
147
+ // copy of a block that streamed).
148
+ statements.updateTimelineEntryByKey = db.prepare(`
149
+ UPDATE subagent_timeline
150
+ SET text = @text,
151
+ tool_name = COALESCE(@toolName, tool_name),
152
+ tool_use_id = COALESCE(@toolUseId, tool_use_id),
153
+ metadata_json = COALESCE(@metadataJson, metadata_json),
154
+ done = MAX(done, @done),
155
+ updated_at = @updatedAt
156
+ WHERE run_id = @runId AND entry_key = @entryKey
157
+ `);
158
+ // Everything after `afterSeq`, plus whatever is still open before it: an
159
+ // open entry changes in place, so a reader that already has its seq still
160
+ // needs its newer text.
161
+ statements.listTimelineEntries = db.prepare(`
162
+ SELECT seq, kind, text, tool_name, tool_use_id, metadata_json, done, created_at, updated_at
163
+ FROM subagent_timeline
164
+ WHERE run_id = @runId AND (seq > @afterSeq OR done = 0)
165
+ ORDER BY seq ASC
166
+ LIMIT @limit
167
+ `);
168
+ statements.deleteConvSubagentTimeline = db.prepare(`DELETE FROM subagent_timeline WHERE conversation_id = ?`);
169
+ return statements;
170
+ }
@@ -116,6 +116,8 @@ export function registerAskUserRoutes(app, deps) {
116
116
  DEFAULT_RELAY_MODE,
117
117
  sessionWorkerRegistry,
118
118
  pushDispatchService,
119
+ // Relay event subscriptions: a question is an event for its watchers.
120
+ relayEventService = null,
119
121
  // Remote relays: another relay's agent answering a question is refused
120
122
  // while the inbound switch is off. Absent in older wiring (no-op).
121
123
  remoteRelayInbound = null,
@@ -246,6 +248,7 @@ export function registerAskUserRoutes(app, deps) {
246
248
  console.log(`[${ts()}] QUESTION ${questionId.slice(0,8)} conv=${question.conversationId.slice(0,8)} mode=${relayMode} fields=${fieldCount} prompt="${promptText.slice(0,60)}"`);
247
249
  io.emit('relay_question', { question });
248
250
  void pushDispatchService?.notifyQuestion?.(question);
251
+ try { relayEventService?.noteQuestion?.(question); } catch {}
249
252
  res.json({ question });
250
253
  });
251
254