@oar-sh/oar 0.9.20 → 0.9.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/CHANGELOG.md +325 -0
  2. package/README.md +13 -13
  3. package/bin/oar.js +2 -2
  4. package/package.json +1 -1
  5. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +231 -1
  6. package/server/claude-cloud-worker/claude-cloud-session-worker.mjs +31 -2
  7. package/server/claude-cloud-worker/claude-cloud-worker-steering.mjs +34 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +149 -4
  9. package/server/claude-worker/claude-session-process.mjs +16 -4
  10. package/server/claude-worker/claude-session-worker-main.mjs +18 -2
  11. package/server/copilot-worker/copilot-sdk-session-process.mjs +75 -1
  12. package/server/copilot-worker/copilot-sdk-session-worker.mjs +18 -2
  13. package/server/cursor-worker/cursor-session-worker.mjs +21 -2
  14. package/server/cursor-worker/cursor-worker-steering.mjs +20 -0
  15. package/server/db-schema.mjs +19 -0
  16. package/server/grok-worker/grok-background-tasks.mjs +10 -0
  17. package/server/grok-worker/grok-session-worker.mjs +22 -2
  18. package/server/grok-worker/grok-turn-runner.mjs +4 -0
  19. package/server/grok-worker/grok-worker-steering.mjs +23 -0
  20. package/server/migrations/0037-relay-asks-queue-id.mjs +66 -0
  21. package/server/migrations/0038-turn-seq.mjs +92 -0
  22. package/server/public/app/about-ui.js +1 -1
  23. package/server/public/app/about-view.mjs +19 -0
  24. package/server/public/app/action-confirmations.js +1 -1
  25. package/server/public/app/activity-replay-state.mjs +160 -42
  26. package/server/public/app/api-client.js +11 -2
  27. package/server/public/app/ask-user-view.js +24 -9
  28. package/server/public/app/attachments-view.js +1 -1
  29. package/server/public/app/back-guard.mjs +31 -5
  30. package/server/public/app/bootstrap.js +618 -72
  31. package/server/public/app/chat-actions-menu-state.mjs +141 -4
  32. package/server/public/app/chat-header-meta.mjs +27 -0
  33. package/server/public/app/claude-billing-toggle-ui.js +2 -1
  34. package/server/public/app/claude-cloud-conversation-ui.js +2 -2
  35. package/server/public/app/composer-settings-label.mjs +90 -0
  36. package/server/public/app/composer-settings-ui.js +442 -0
  37. package/server/public/app/context-menu.mjs +36 -5
  38. package/server/public/app/context-usage-view.mjs +68 -0
  39. package/server/public/app/conversation-deep-link.mjs +15 -11
  40. package/server/public/app/conversation-group-drag.mjs +12 -2
  41. package/server/public/app/conversation-group-order.mjs +9 -2
  42. package/server/public/app/conversation-group-state.mjs +47 -10
  43. package/server/public/app/conversation-origin-ui.js +17 -1
  44. package/server/public/app/conversation-view.js +309 -49
  45. package/server/public/app/cwd-menu-placement.mjs +18 -6
  46. package/server/public/app/cwd-picker.js +36 -6
  47. package/server/public/app/emoji-view.js +1 -1
  48. package/server/public/app/font-scaling.js +26 -0
  49. package/server/public/app/header-priority-actions.mjs +238 -0
  50. package/server/public/app/held-details.mjs +202 -0
  51. package/server/public/app/hidden-messages-view.mjs +4 -0
  52. package/server/public/app/inbox-ui.js +88 -81
  53. package/server/public/app/inbox-view.mjs +35 -19
  54. package/server/public/app/journal-view.js +328 -58
  55. package/server/public/app/live-activity-cap.mjs +103 -0
  56. package/server/public/app/phone-landscape-ui.js +225 -0
  57. package/server/public/app/phone-landscape.mjs +99 -0
  58. package/server/public/app/phone-typing-state.mjs +104 -0
  59. package/server/public/app/pinned-messages-view.mjs +5 -0
  60. package/server/public/app/push-settings.js +2 -2
  61. package/server/public/app/pwa-install.js +24 -3
  62. package/server/public/app/questions-panel-ui.js +634 -0
  63. package/server/public/app/questions-stub-view.mjs +40 -0
  64. package/server/public/app/questions-view.mjs +89 -0
  65. package/server/public/app/relay-board-view.js +12 -2
  66. package/server/public/app/relay-event-subscriptions-ui.js +71 -22
  67. package/server/public/app/relay-event-view.mjs +38 -0
  68. package/server/public/app/relay-restart-ui.js +2 -2
  69. package/server/public/app/relay-schedule-view.mjs +32 -6
  70. package/server/public/app/relay-schedules-ui.js +69 -20
  71. package/server/public/app/relay-thoughts.mjs +15 -1
  72. package/server/public/app/remote-relay-origin-view.mjs +41 -23
  73. package/server/public/app/session-history-ui.js +251 -0
  74. package/server/public/app/session-history.mjs +599 -0
  75. package/server/public/app/settings-modal.js +67 -6
  76. package/server/public/app/settings-tabs.js +12 -0
  77. package/server/public/app/share-state.js +1 -1
  78. package/server/public/app/shared-conversations-settings.js +2 -2
  79. package/server/public/app/socket-handlers.js +47 -13
  80. package/server/public/app/status-view.mjs +18 -2
  81. package/server/public/app/store.js +87 -16
  82. package/server/public/app/stream-view-preference.mjs +60 -0
  83. package/server/public/app/subagent-fold.mjs +6 -3
  84. package/server/public/app/subagent-timeline-view.mjs +12 -5
  85. package/server/public/app/subagent-viewer.mjs +35 -8
  86. package/server/public/app/tab-strip-fade.mjs +101 -0
  87. package/server/public/app/tap-actions.js +66 -0
  88. package/server/public/app/thoughts-preference.mjs +79 -0
  89. package/server/public/app/turn-stream-view.mjs +124 -0
  90. package/server/public/app/usage-limit-ui.js +63 -2
  91. package/server/public/app/viewer-history.mjs +13 -2
  92. package/server/public/index.html +2603 -1767
  93. package/server/relay-tools.md +1 -0
  94. package/server/repositories/message-repository.mjs +33 -0
  95. package/server/repositories/question-repository.mjs +51 -7
  96. package/server/repositories/relay-turn-rows.mjs +56 -0
  97. package/server/repositories/session-repository.mjs +19 -0
  98. package/server/routes/ask-user-routes.mjs +34 -5
  99. package/server/routes/claude-cloud-routes.mjs +19 -0
  100. package/server/routes/messages-routes.mjs +92 -15
  101. package/server/routes/relay-ask-routes.mjs +15 -5
  102. package/server/routes/sessions-routes.mjs +117 -12
  103. package/server/server-runtime.mjs +367 -170
  104. package/server/services/about-service.mjs +22 -53
  105. package/server/services/claude-billing-swap-service.mjs +7 -20
  106. package/server/services/claude-cloud-session-service.mjs +47 -5
  107. package/server/services/cloudflared-tunnel-service.mjs +7 -0
  108. package/server/services/idle-timers.mjs +184 -0
  109. package/server/services/queue-wake-hook.mjs +145 -0
  110. package/server/services/queue-waker.mjs +127 -0
  111. package/server/services/relay-ask-service.mjs +64 -5
  112. package/server/services/relay-event-service.mjs +4 -1
  113. package/server/services/remote-relay-dispatcher.mjs +84 -18
  114. package/server/services/session-activity-tracker.mjs +1 -1
  115. package/server/services/session-history-refresh-service.mjs +4 -0
  116. package/server/services/session-launch-mutex-service.mjs +23 -0
  117. package/server/services/session-worker-registry-service.mjs +36 -0
  118. package/server/services/session-worker-supervisor-service.mjs +36 -0
  119. package/server/services/session-worker-websocket-service.mjs +183 -31
  120. package/server/services/ssh-tunnel-manager-service.mjs +7 -0
  121. package/server/services/worker-recycle-service.mjs +772 -0
  122. package/server/services/worker-replacement.mjs +197 -0
  123. package/shared/ask-user-bridge.mjs +9 -6
  124. package/shared/build-id.mjs +128 -0
  125. package/shared/relay-asks.mjs +10 -0
  126. package/shared/relay-events.mjs +1 -0
  127. package/shared/worker-runtime/heartbeat.mjs +31 -0
  128. package/shared/worker-runtime/simple-steering-state.mjs +38 -0
  129. package/shared/worker-runtime/worker-keepalive.mjs +108 -0
  130. package/shared/worker-runtime/worker-websocket-link.mjs +44 -10
  131. package/shared/worker-runtime/working-directory.mjs +1 -1
@@ -42,6 +42,7 @@ This relay itself is listed with "self": true when its owner switched agent sess
42
42
  - `create_session` starts a session with any provider that `relay_info` lists: a local one in a folder ("cwd"), or provider "claude-cloud" with "repo" (a GitHub URL or owner/repo) and an optional "branch". The provider ids are the target relay's, as `relay_info` shows them; an id it does not know is refused with the list it has. A session started before a relay update can use providers the update added, without a restart. Without "mode" the new session gets your session's relay mode. At most 4 sessions you started may work at the same time; a fifth is refused until one finishes.
43
43
  - The first `create_session` on this relay shows the user an approval card, once per conversation. A session that an agent created cannot create sessions itself (one level only), unless the owner allowed it for that session: `list_sessions` and `read_session` then show "mayCreateSessions": true. Only the owner sets that.
44
44
  - Sessions you start here are grouped under this session in the user's sidebar. `list_sessions` lists in the sidebar's order (a group sorts by its newest session, head first) and shows the group as a "group" field (id, name, role); `read_session` adds the group's live members with their state. `create_session` takes "group" (a group id, or its name in any case, from `relay_info`'s "groups") to put the new session into an existing group instead, here or on a paired relay; an unknown group is refused with the groups listed and nothing is created, and the result carries the "group". Renaming and dissolving groups is the user's.
45
+ - `list_sessions` and `read_session` show a session's worker process, while it has one, as "worker" (kind, buildId, stale); "stale": true means it runs older code than that relay, which replaces it by itself once the session is idle, so there is nothing to do about it.
45
46
  - `update_session` ("session" plus any of "model", "effort", "mode", "auto_compact_window", "title", "group", "thinking_enabled", "thinking_display", "cwd", "planOnly") changes another session's settings for its next turns without prompting it, here or on a paired relay, and answers the settings and "changed"; a group name that does not exist makes one, "none" takes the session out (a group the user made stays, even empty; one update_session made goes once empty), a new "cwd" restarts its CLI there (refused while it works), "auto_compact_window" 0 is the model default. The model is checked as for `send`. A failed call still lists in "changed" what it wrote before the failing step. A mode set this way lasts until the next `update_session` that names another mode; a `send` that names a mode runs only that turn in it, and one without "mode" leaves the session in its own mode.
46
47
  - "planOnly": true makes a plan-only session: agents can't take it out of plan mode. Set it only when the user asks; plan mode alone never sets it. `create_session` with "planOnly": true and no "mode" starts in plan; with another mode named it is refused and nothing is created. A target relay too old for planOnly creates the session without the lock and says so in "planOnlyWarning". `update_session` with "planOnly": true sets it on an existing session (with "mode": "plan" unless it is in plan already). While it is set, a `send` or `update_session` that names another mode is refused with 409, code "plan_only" ("This session is plan-only; agents can't change its mode. Clear planOnly first, or ask the user."); a `send` without "mode" runs in the session's saved mode. To clear it, pass "planOnly": false in the same `update_session` that names the new mode (`{"planOnly":false,"mode":"agent"}`); alone, or with "mode": "plan", it is ignored, and on a plan-only session the answer notes "planOnly unchanged: clear it in the same call as the new mode." Use `update_session` for all this, not the preferences route (it refuses an agent's mode change with 409 and any planOnly change with 403). `list_sessions` and `read_session` show "planOnly": true. In a plan-only conversation you count as a plan-mode caller, also in the one agent turn after "Approve plan": your `create_session` defaults to plan and your write actions ask the user first.
47
48
  - Only a session your conversation started may be updated freely; any other (the user's, another agent's, the one that started you) needs the user's approval card once per conversation and relay (refused: `REMOTE_RELAY_PERMISSION`); never your own conversation.
@@ -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
@@ -461,6 +471,29 @@ export function createMessageRepository(db) {
461
471
  WHERE status = 'processing'
462
472
  AND NULLIF(owner_sdk_session_id, '') = ?
463
473
  `),
474
+ // The worker recycle's quiet window (worker-recycle-service.mjs): the
475
+ // last time the session had a delivery or a turn end, as an ISO
476
+ // string, or NULL when the queue holds no row of it. Over the rows the
477
+ // session owns: a row is stamped with its owner at enqueue under
478
+ // session-worker routing and at the latest by its claim, and a
479
+ // conversation rebound to another session leaves its old rows with the
480
+ // session that ran them. The queue keeps no completion time
481
+ // (setDone clears processing_at), so a turn's end is its response
482
+ // message's timestamp; the claim and the heartbeat lease
483
+ // (owner_last_claimed_at) and the assignment stand for the delivery.
484
+ // Each column is COALESCEd: SQLite's multi-argument MAX() is NULL as
485
+ // soon as one argument is.
486
+ getOwnerLastActivityAt: db.prepare(`
487
+ SELECT MAX(NULLIF(MAX(
488
+ COALESCE(q.processing_at, ''),
489
+ COALESCE(q.owner_assigned_at, ''),
490
+ COALESCE(q.owner_last_claimed_at, ''),
491
+ COALESCE(m.timestamp, '')
492
+ ), '')) AS last_activity_at
493
+ FROM queue q
494
+ LEFT JOIN messages m ON m.id = q.response_message_id
495
+ WHERE NULLIF(q.owner_sdk_session_id, '') = ?
496
+ `),
464
497
  recoverProcessingRowKeepOwner: db.prepare(`
465
498
  UPDATE queue
466
499
  SET status = 'pending',
@@ -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
+ }
@@ -100,6 +100,25 @@ export function createSessionRepository(db) {
100
100
  getRuntimeSessionBySdkSessionId: db.prepare(`SELECT * FROM runtime_sessions WHERE sdk_session_id = ?`),
101
101
  getRuntimeSessionById: db.prepare(`SELECT * FROM runtime_sessions WHERE id = ?`),
102
102
  listRuntimeSessions: db.prepare(`SELECT rs.*, c.title AS conversation_title, c.updated_at AS conversation_updated_at FROM runtime_sessions rs LEFT JOIN conversations c ON c.id = rs.conversation_id ORDER BY rs.last_used_at DESC`),
103
+ // The session ids of archived conversations: the worker sweep at relay
104
+ // start stops what still runs for them. A runtime session id is unique
105
+ // (idx_runtime_sessions_sdk_session_id), but a conversation's own
106
+ // sdk_session_id is not: an id that a conversation in use (not
107
+ // archived, not deleted) names too is left out, so that
108
+ // conversation's worker is never stopped for an archived one.
109
+ listArchivedRuntimeSessionIds: db.prepare(`
110
+ SELECT DISTINCT rs.sdk_session_id AS sdk_session_id
111
+ FROM runtime_sessions rs
112
+ JOIN conversations c ON c.id = rs.conversation_id
113
+ WHERE c.archived = 1
114
+ AND NULLIF(rs.sdk_session_id, '') IS NOT NULL
115
+ AND NOT EXISTS (
116
+ SELECT 1 FROM conversations live_c
117
+ WHERE live_c.sdk_session_id = rs.sdk_session_id
118
+ AND COALESCE(live_c.archived, 0) = 0
119
+ AND live_c.status != 'deleted'
120
+ )
121
+ `),
103
122
  insertRuntimeSession: db.prepare(runtimeSessionsSupportProviders
104
123
  ? `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id, provider_type, provider_model) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)`
105
124
  : `INSERT INTO runtime_sessions (id, conversation_id, strategy, runtime_key, model, status, created_at, last_used_at, sdk_session_id) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, ?)`),
@@ -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);
@@ -6,6 +6,9 @@
6
6
  // environment, account, login state
7
7
  // POST /api/settings/claude-cloud { enabled?, defaultModel?, environmentId? }
8
8
  // POST /api/claude-cloud-session the worker's binding report
9
+ // GET /api/claude-cloud-session `?conversationId=` (or the sdk session
10
+ // id) → the stored binding a starting
11
+ // worker attaches to
9
12
  // GET /api/claude-cloud/repos the repositories New Chat may suggest
10
13
  // (`?refresh=1` reads them afresh)
11
14
  // GET /api/claude-cloud/branches `?repo=<owner/name or URL>` → its branches
@@ -77,6 +80,22 @@ export function registerClaudeCloudRoutes(app, deps = {}) {
77
80
  if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
78
81
  return res.json({ ok: true, cloud: result.cloud });
79
82
  });
83
+
84
+ // GET /api/claude-cloud-session?conversationId=<id> — a starting Claude
85
+ // Cloud worker asks which cloud session it is bound to and from which
86
+ // sequence to read on, so it opens the event stream before any delivery
87
+ // (a relaunched worker follows the turns the session starts by itself
88
+ // from its start). The id may be the conversation id or the sdk session
89
+ // id. `{ ok, sessionId, lastSequence }`, `sessionId: null` when there is
90
+ // no cloud session; 404 only for an unknown conversation.
91
+ app.get('/api/claude-cloud-session', auth, (req, res) => {
92
+ touchCli();
93
+ const raw = req.query?.conversationId ?? req.query?.sdkSessionId;
94
+ const id = Array.isArray(raw) ? raw[0] : raw;
95
+ const result = claudeCloudSessionService.describeStoredBinding(typeof id === 'string' ? id : '');
96
+ if (!result.ok) return res.status(result.statusCode || 400).json({ error: result.error });
97
+ return res.json({ ok: true, sessionId: result.sessionId, lastSequence: result.lastSequence });
98
+ });
80
99
  }
81
100
 
82
101
  if (claudeCloudLoginStatus) {
@@ -45,6 +45,7 @@ import { DEFAULT_CLAUDE_REASONING_EFFORTS } from '../services/provider-reasoning
45
45
  import { claudeBaseModelId, claudeLongContextModelId } from '../../shared/model-id.mjs';
46
46
  import { sanitizeSubagentRunId } from '../../shared/subagent-run-id.mjs';
47
47
  import { normalizeBackgroundTaskDigest } from '../../shared/worker-runtime/background-task-digest.mjs';
48
+ import { normalizeBuildId } from '../../shared/build-id.mjs';
48
49
  import {
49
50
  DEFAULT_TASK_STALL_MINUTES,
50
51
  TASK_OUTPUT_LIMITS,
@@ -112,7 +113,9 @@ const OPAQUE_RESPONSE_TEXT_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-
112
113
  const OPAQUE_RESPONSE_RECOVERY_WAIT_MS = 600_000;
113
114
  const OPAQUE_RESPONSE_RECOVERY_POLL_MS = 250;
114
115
  const STRANDED_SESSION_PRIME_COOLDOWN_MS = 10_000;
115
- 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;
116
119
  // Hold finalization after a relay question is answered so the SDK has time to fire the next
117
120
  // onUserInputRequest (multi-step ask_user). Without this, the transcript is used prematurely and
118
121
  // the queue row is marked done while additional questions are still pending.
@@ -1201,6 +1204,12 @@ export async function dequeuePendingMessageForWorkerLoop({
1201
1204
  workerKind = null,
1202
1205
  onBillingFence = null,
1203
1206
  claudeApiClaimCheck = null,
1207
+ // () → true while the replacement fence (services/worker-replacement.mjs)
1208
+ // is up for the requesting process. The caller asks it before calling; it
1209
+ // is asked again before every claim, since ensureWorker and a transient
1210
+ // error's backoff both wait, and the worker recycle can raise the fence
1211
+ // meanwhile.
1212
+ isReplacementFenced = null,
1204
1213
  } = {}) {
1205
1214
  const requesterSid = normalizeSessionWorkerId(requesterSessionId);
1206
1215
  const routingWithWorker = routingEnabled && requesterSid;
@@ -1275,6 +1284,18 @@ export async function dequeuePendingMessageForWorkerLoop({
1275
1284
 
1276
1285
  let attempt = 0;
1277
1286
  while (attempt <= retryLimit) {
1287
+ // The process asking is being replaced: no row is claimed for it, the
1288
+ // same answer as the caller's own fence check gives.
1289
+ if (routingWithWorker && typeof isReplacementFenced === 'function' && isReplacementFenced() === true) {
1290
+ return {
1291
+ message: null,
1292
+ blockedReason: 'worker-replacement',
1293
+ worker: supervisor?.getWorkerState?.(requesterSid) || null,
1294
+ lifecycle: supervisor?.getLifecycleState?.(requesterSid) || null,
1295
+ fallbackRestart: null,
1296
+ attempts: attempt,
1297
+ };
1298
+ }
1278
1299
  const currentIso = String(nowIso || '').trim() || new Date().toISOString();
1279
1300
  try {
1280
1301
  const message = dequeuePendingMessage({
@@ -2065,6 +2086,9 @@ export function registerMessagesRoutes(app, deps) {
2065
2086
  // killTmuxSessionImpl). Tests must inject these instead of relying on the
2066
2087
  // host OS, so the suite behaves identically on Windows and POSIX.
2067
2088
  sessionWorkerStopOverrides = null,
2089
+ // The replacement fence (services/worker-replacement.mjs): a worker
2090
+ // process the recycle is replacing is handed no work by /api/pending.
2091
+ workerReplacementFence = null,
2068
2092
  resolveSessionStateRoot,
2069
2093
  fetchUsageSummary = null,
2070
2094
  planUsageService = null,
@@ -6245,7 +6269,10 @@ export function registerMessagesRoutes(app, deps) {
6245
6269
  // Whether a billing swap may replace the worker now, and why not.
6246
6270
  || (stored.releasable ?? null) !== (steering.releasable ?? null)
6247
6271
  || (stored.releaseHold || null) !== (steering.releaseHold || null)
6248
- || (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0);
6272
+ || (stored.backgroundTaskCount || 0) !== (steering.backgroundTaskCount || 0)
6273
+ // Whether the worker's CLI child still runs: the worker recycle
6274
+ // waits for it to be gone, so a flip must be stored.
6275
+ || (stored.cliAlive ?? null) !== (steering.cliAlive ?? null);
6249
6276
  if (workerForSteering && changed) {
6250
6277
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForSteering, steering });
6251
6278
  // Pushed rather than left for the clients' status poll: the
@@ -6263,6 +6290,16 @@ export function registerMessagesRoutes(app, deps) {
6263
6290
  sessionWorkerRegistry?.upsertWorker?.({ ...workerForKind, kind: heartbeatKind });
6264
6291
  }
6265
6292
  }
6293
+ // The build id of the code the worker started on, recorded the same way:
6294
+ // only when it says one and the registry has another, so quiet
6295
+ // heartbeats stay write-free.
6296
+ const heartbeatBuildId = normalizeBuildId(req.body?.buildId);
6297
+ if (heartbeatBuildId) {
6298
+ const workerForBuild = sessionWorkerRegistry?.getWorker?.(requesterSessionId) || null;
6299
+ if (workerForBuild && workerForBuild.buildId !== heartbeatBuildId) {
6300
+ sessionWorkerRegistry?.upsertWorker?.({ ...workerForBuild, buildId: heartbeatBuildId });
6301
+ }
6302
+ }
6266
6303
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
6267
6304
  // The worker's digest of its live background-task ids against the set
6268
6305
  // held for it (keyed as the worker posts, by its session id). They
@@ -7009,6 +7046,24 @@ export function registerMessagesRoutes(app, deps) {
7009
7046
  restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
7010
7047
  });
7011
7048
  }
7049
+ // The process asking is being replaced (the worker recycle): no work,
7050
+ // as for a killed session, so no turn starts on the pid being stopped.
7051
+ // Unlike the killed marker, the fence outlives the marker's grace and
7052
+ // every clearRestartSchedule; the new worker has another pid and takes
7053
+ // what waited meanwhile.
7054
+ if (workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true) {
7055
+ return res.json({
7056
+ message: null,
7057
+ routing: {
7058
+ enabled: true,
7059
+ requesterSessionId,
7060
+ blockedReason: 'worker-replacement',
7061
+ lifecycle: sessionWorkerSupervisor?.getLifecycleState?.(requesterSessionId) || null,
7062
+ fallbackRestart: null,
7063
+ },
7064
+ restartOrchestrator: relayRestartOrchestrator?.getState?.() || null,
7065
+ });
7066
+ }
7012
7067
  sessionWorkerSupervisor?.noteSessionHeartbeat?.(requesterSessionId);
7013
7068
  }
7014
7069
  if (runtimeState.relayPaused) return res.json({ message: null, paused: true });
@@ -7103,6 +7158,8 @@ export function registerMessagesRoutes(app, deps) {
7103
7158
  workerKind: httpPendingWorkerKind(requesterSessionId),
7104
7159
  claudeApiClaimCheck,
7105
7160
  onBillingFence: onClaudeBillingFence,
7161
+ // The fence check above, asked again before every claim.
7162
+ isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
7106
7163
  telemetry: (payload = {}) => {
7107
7164
  if (!sessionWorkerRoutingEnabled) return;
7108
7165
  emitSessionWorkerTelemetry(payload.event, {
@@ -7323,6 +7380,7 @@ export function registerMessagesRoutes(app, deps) {
7323
7380
  workerKind: httpPendingWorkerKind(requesterSessionId),
7324
7381
  claudeApiClaimCheck,
7325
7382
  onBillingFence: onClaudeBillingFence,
7383
+ isReplacementFenced: () => workerReplacementFence?.isHeld?.(requesterSessionId, requester?.pid) === true,
7326
7384
  });
7327
7385
  if (recoveryDequeue?.message) {
7328
7386
  msg = recoveryDequeue.message;
@@ -9123,16 +9181,28 @@ export function registerMessagesRoutes(app, deps) {
9123
9181
  const responseMessageId = q?.response_message_id || null;
9124
9182
  const normalizedSubagentRunId = subagentRunId ? String(subagentRunId).trim() : null;
9125
9183
  const normalizedMetadata = sanitizeActivityMetadata(metadata);
9126
- const activityRow = stmts.insertActivity.run(
9127
- messageId,
9128
- responseMessageId,
9129
- conversationId,
9130
- normalizeRelayMode(mode) || DEFAULT_RELAY_MODE,
9131
- activityText,
9132
- new Date().toISOString(),
9133
- normalizedSubagentRunId,
9134
- normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
9135
- );
9184
+ // The row's place in the turn's order (thoughts and tool lines share
9185
+ // it): numbered and written in one IMMEDIATE transaction, so two writers
9186
+ // on the same turn never get the same number.
9187
+ let turnSeq = null;
9188
+ const insertActivityRow = () => {
9189
+ turnSeq = stmts.nextTurnSeq?.(messageId) ?? null;
9190
+ return stmts.insertActivity.run(
9191
+ messageId,
9192
+ responseMessageId,
9193
+ conversationId,
9194
+ normalizeRelayMode(mode) || DEFAULT_RELAY_MODE,
9195
+ activityText,
9196
+ new Date().toISOString(),
9197
+ normalizedSubagentRunId,
9198
+ normalizedMetadata ? JSON.stringify(normalizedMetadata) : null,
9199
+ turnSeq,
9200
+ );
9201
+ };
9202
+ const insertActivityTx = typeof db?.transaction === 'function' ? db.transaction(insertActivityRow) : null;
9203
+ const activityRow = typeof insertActivityTx?.immediate === 'function'
9204
+ ? insertActivityTx.immediate()
9205
+ : insertActivityRow();
9136
9206
  if (normalizedSubagentRunId) {
9137
9207
  // The lane keeps the capped line; the timeline copy keeps what came in.
9138
9208
  mirrorSubagentFrame({
@@ -9150,6 +9220,7 @@ export function registerMessagesRoutes(app, deps) {
9150
9220
  timestamp: new Date().toISOString(),
9151
9221
  subagentRunId: normalizedSubagentRunId || undefined,
9152
9222
  ...(normalizedMetadata ? { metadata: normalizedMetadata } : {}),
9223
+ ...(turnSeq !== null ? { turnSeq } : {}),
9153
9224
  });
9154
9225
  res.json({ ok: true });
9155
9226
  });
@@ -9307,6 +9378,9 @@ export function registerMessagesRoutes(app, deps) {
9307
9378
  const relayMode = normalizeRelayMode(mode) || DEFAULT_RELAY_MODE;
9308
9379
  const normalizedReasoningId = String(reasoningId || '').trim() || null;
9309
9380
  let seq = null;
9381
+ // The thought's place in the turn's order: given on its first write and
9382
+ // kept by every update, so a thought sits where its first chunk arrived.
9383
+ let turnSeq = null;
9310
9384
  const shouldPersistThought = !!done || !!normalizedReasoningId;
9311
9385
  if (shouldPersistThought) {
9312
9386
  const isThoughtSeqConstraintError = (error) => {
@@ -9341,12 +9415,13 @@ export function registerMessagesRoutes(app, deps) {
9341
9415
  messageId,
9342
9416
  normalizedReasoningId,
9343
9417
  );
9344
- return existingSeq;
9418
+ return { seq: existingSeq, turnSeq: existingThought.turn_seq == null ? null : Number(existingThought.turn_seq) };
9345
9419
  }
9346
9420
  }
9347
9421
  const row = stmts.getLastThoughtSeqByQueueMessage?.get(messageId);
9348
9422
  const maxSeq = Math.max(0, Number(row?.max_seq || 0));
9349
9423
  const nextSeq = maxSeq + 1;
9424
+ const nextTurn = stmts.nextTurnSeq?.(messageId) ?? null;
9350
9425
  stmts.insertThought?.run(
9351
9426
  messageId,
9352
9427
  responseMessageId,
@@ -9358,8 +9433,9 @@ export function registerMessagesRoutes(app, deps) {
9358
9433
  done ? 1 : 0,
9359
9434
  now,
9360
9435
  normalizedSubagentRunId,
9436
+ nextTurn,
9361
9437
  );
9362
- return nextSeq;
9438
+ return { seq: nextSeq, turnSeq: nextTurn };
9363
9439
  });
9364
9440
  const runInsertThought = typeof insertThoughtTx?.immediate === 'function'
9365
9441
  ? insertThoughtTx.immediate.bind(insertThoughtTx)
@@ -9367,7 +9443,7 @@ export function registerMessagesRoutes(app, deps) {
9367
9443
  const maxRetries = 4;
9368
9444
  for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
9369
9445
  try {
9370
- seq = runInsertThought();
9446
+ ({ seq, turnSeq } = runInsertThought());
9371
9447
  break;
9372
9448
  } catch (error) {
9373
9449
  if (error?.code === 'THOUGHT_CONVERSATION_MISMATCH') {
@@ -9402,6 +9478,7 @@ export function registerMessagesRoutes(app, deps) {
9402
9478
  seq,
9403
9479
  timestamp: now,
9404
9480
  subagentRunId: normalizedSubagentRunId || undefined,
9481
+ ...(turnSeq !== null ? { turnSeq } : {}),
9405
9482
  });
9406
9483
  res.json({ ok: true, seq });
9407
9484
  });
@@ -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
  }