@oar-sh/oar 0.9.2 → 0.9.4

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 (138) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +336 -0
  4. package/README.md +455 -423
  5. package/bin/oar.js +25 -10
  6. package/package.json +11 -5
  7. package/server/README.md +44 -4
  8. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  9. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  10. package/server/claude-worker/claude-session-process.mjs +1073 -69
  11. package/server/claude-worker/claude-session-worker.mjs +45 -4
  12. package/server/claude-worker/claude-turn-publisher.mjs +81 -20
  13. package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
  14. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  15. package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
  16. package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
  17. package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
  18. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  19. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +113 -14
  20. package/server/copilot-worker/copilot-sdk-session-process.mjs +2349 -498
  21. package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
  22. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  23. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  24. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  25. package/server/db-schema.mjs +28 -0
  26. package/server/grok-worker/acp-client.mjs +92 -6
  27. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  28. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  29. package/server/mcp/oar-mcp-server.mjs +473 -0
  30. package/server/migrations/0004-steer-settle-markers.mjs +119 -0
  31. package/server/migrations/0005-remote-relays.mjs +90 -0
  32. package/server/public/app/action-confirmations.js +141 -13
  33. package/server/public/app/api-client.js +125 -2
  34. package/server/public/app/ask-user-view.js +102 -13
  35. package/server/public/app/attachments-view.js +37 -2
  36. package/server/public/app/background-task-timeout-options.mjs +9 -0
  37. package/server/public/app/background-tasks-view.mjs +33 -18
  38. package/server/public/app/bootstrap.js +55 -14
  39. package/server/public/app/composer-control-state.mjs +52 -34
  40. package/server/public/app/conversation-deep-link.mjs +42 -0
  41. package/server/public/app/conversation-draft-sync.mjs +187 -0
  42. package/server/public/app/conversation-list-filter.mjs +86 -0
  43. package/server/public/app/conversation-provider-indicator.mjs +8 -0
  44. package/server/public/app/conversation-view.js +1016 -200
  45. package/server/public/app/emoji-view.js +2 -0
  46. package/server/public/app/font-scaling.js +4 -37
  47. package/server/public/app/host-suspend-ui.js +272 -0
  48. package/server/public/app/journal-view.js +149 -6
  49. package/server/public/app/mention-autocomplete.mjs +298 -0
  50. package/server/public/app/model-selector-options.mjs +1 -1
  51. package/server/public/app/push-settings.js +1 -0
  52. package/server/public/app/pwa-install.js +8 -3
  53. package/server/public/app/question-multi-select.mjs +52 -0
  54. package/server/public/app/relay-prompt-sanitizer.mjs +61 -3
  55. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  56. package/server/public/app/remote-relay-shared.mjs +85 -0
  57. package/server/public/app/remote-relays-settings.js +618 -0
  58. package/server/public/app/remote-relays-store.mjs +126 -0
  59. package/server/public/app/settings-modal.js +12 -8
  60. package/server/public/app/settings-tabs.js +1 -1
  61. package/server/public/app/socket-handlers.js +37 -1
  62. package/server/public/app/steered-turn-merge.mjs +100 -0
  63. package/server/public/app/store.js +126 -15
  64. package/server/public/app/sync-outbox.mjs +16 -8
  65. package/server/public/app/thinking-follow.js +260 -0
  66. package/server/public/app/tool-activity-emoji.mjs +40 -0
  67. package/server/public/app/transcript-viewport-anchor.mjs +210 -0
  68. package/server/public/index.html +463 -17
  69. package/server/public/sw.js +7 -0
  70. package/server/relay-tools.md +7 -1
  71. package/server/repositories/message-repository.mjs +68 -18
  72. package/server/repositories/question-repository.mjs +18 -0
  73. package/server/repositories/remote-relay-repository.mjs +75 -0
  74. package/server/repositories/session-repository.mjs +13 -32
  75. package/server/routes/ask-user-routes.mjs +42 -2
  76. package/server/routes/messages-routes.mjs +572 -56
  77. package/server/routes/remote-relay-routes.mjs +133 -0
  78. package/server/routes/sessions-routes.mjs +378 -84
  79. package/server/server-runtime.mjs +303 -25
  80. package/server/services/github-ci-activity-service.mjs +199 -0
  81. package/server/services/host-suspend-command.mjs +38 -0
  82. package/server/services/host-suspend-service.mjs +254 -0
  83. package/server/services/live-turn-picker.mjs +47 -0
  84. package/server/services/push-dispatch-service.mjs +37 -4
  85. package/server/services/relay-prompt-sanitizer.mjs +17 -2
  86. package/server/services/relay-question-context.mjs +46 -0
  87. package/server/services/remote-relay-caller-context.mjs +341 -0
  88. package/server/services/remote-relay-client.mjs +332 -0
  89. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  90. package/server/services/remote-relay-inbound.mjs +187 -0
  91. package/server/services/remote-relay-pairing.mjs +189 -0
  92. package/server/services/remote-relay-registry-service.mjs +581 -0
  93. package/server/services/sdk-session-import-service.mjs +49 -7
  94. package/server/services/session-worker-launch-service.mjs +131 -12
  95. package/server/services/session-worker-registry-service.mjs +75 -0
  96. package/server/services/session-worker-supervisor-service.mjs +7 -1
  97. package/server/services/session-worker-websocket-service.mjs +23 -1
  98. package/server/services/steer-resend.mjs +45 -0
  99. package/shared/background-task-timeout.mjs +12 -4
  100. package/shared/control-poller.mjs +4 -1
  101. package/shared/question-multi-select.mjs +16 -0
  102. package/shared/relay-stop-failure.mjs +15 -0
  103. package/shared/remote-relay-contract.mjs +539 -0
  104. package/shared/remote-relay-mentions.mjs +129 -0
  105. package/shared/remote-relay-tool-core.mjs +314 -0
  106. package/shared/slash-command-guard.mjs +76 -0
  107. package/shared/steer-note.mjs +66 -0
  108. package/shared/steer-settle-failure.mjs +89 -0
  109. package/shared/steer-settle-markers.mjs +18 -0
  110. package/shared/worker-crash-guard.mjs +40 -25
  111. package/shared/worker-runtime/api-client.mjs +112 -22
  112. package/shared/worker-runtime/heartbeat.mjs +55 -1
  113. package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
  114. package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
  115. package/server/claude-worker/claude-session-test-harness.mjs +0 -250
  116. package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
  117. package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
  118. package/server/copilot-worker/fixtures/abort-turn.json +0 -588
  119. package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
  120. package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
  121. package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
  122. package/server/copilot-worker/fixtures/happy-turn.json +0 -566
  123. package/server/copilot-worker/fixtures/quota-turn.json +0 -647
  124. package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
  125. package/server/copilot-worker/fixtures/resume-turn.json +0 -700
  126. package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
  127. package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
  128. package/server/routes/messages-routes-test-harness.mjs +0 -111
  129. package/server/services/fixtures/claude-auth-stub.cmd +0 -1
  130. package/server/services/fixtures/claude-auth-stub.mjs +0 -76
  131. package/server/services/fixtures/claude-auth-stub.sh +0 -4
  132. package/server/services/fixtures/cli-install-stub.cmd +0 -1
  133. package/server/services/fixtures/cli-install-stub.mjs +0 -98
  134. package/server/services/fixtures/cli-install-stub.sh +0 -4
  135. package/server/services/fixtures/grok-stub.cmd +0 -1
  136. package/server/services/fixtures/grok-stub.mjs +0 -89
  137. package/server/services/fixtures/grok-stub.sh +0 -4
  138. package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
@@ -205,6 +205,9 @@ const SYNC_DB_VERSION = 1;
205
205
  const SYNC_STORE_NAME = 'outbox';
206
206
  const SYNC_TAG = 'copilot-outbox';
207
207
  const SYNC_MAX_REPLAY_ATTEMPTS = 10;
208
+ // Mirrors DRAFT_FLUSH_MAX_AGE_MS in app/conversation-draft-sync.mjs: a draft
209
+ // flush replayed later than this would overwrite whatever the draft became.
210
+ const SYNC_DRAFT_FLUSH_MAX_AGE_MS = 5 * 60 * 1000;
208
211
 
209
212
  function openSyncDb() {
210
213
  return new Promise((resolve, reject) => {
@@ -271,6 +274,10 @@ async function replaySyncOutbox() {
271
274
  try {
272
275
  const entries = await readSyncEntries(db);
273
276
  for (const { key, value } of entries) {
277
+ if (value?.kind === 'draft-flush' && !(Date.now() - Number(value.createdAt || 0) <= SYNC_DRAFT_FLUSH_MAX_AGE_MS)) {
278
+ await deleteSyncEntry(db, key);
279
+ continue;
280
+ }
274
281
  let response = null;
275
282
  try {
276
283
  response = await fetch(scopeUrl(String(value.path || '').replace(/^\//, '')), {
@@ -12,6 +12,8 @@ When using ask_user, ALWAYS include a `choices` array with 2-6 answer options so
12
12
  At runtime, inject the question and choices from a random item in your quiz pool.
13
13
  Only omit choices when the question genuinely requires freeform text input (e.g., "What is your name?").
14
14
 
15
+ When the user may pick several of the choices (they are not mutually exclusive), set `"multi_select": true` if your ask_user tool offers that field; otherwise begin the question with "Select all that apply:". Either way, expect an answer that lists several choices separated by ", ".
16
+
15
17
  In autopilot, still call ask_user when user input is truly blocking, because the relay bridge can surface the question even when the direct SDK question hook is bypassed.
16
18
 
17
19
  For relay restarts in extension-managed mode, require explicit user permission first, then use the authenticated localhost API `POST /api/relay/shutdown`. Do not restart by killing processes or using respawn scripts.
@@ -24,4 +26,8 @@ Use `restart: true` in the request body when the user wants a real relay restart
24
26
 
25
27
  Publish a local web server or a static directory on a public preview URL so the user can open it from any device, or list/close existing previews. Use this whenever the user wants to see, try, or share something with a web UI — never ask them to open localhost or forward a port. For a dev server: call this FIRST with {action:"create", port} to get the basePath, then start the server configured to serve under that basePath (Vite --base, Next basePath, Express mount prefix); the proxy forwards X-Forwarded-Prefix but does not rewrite bodies, so root-absolute asset paths will not work. For plain files or a build output: pass {action:"create", dir} instead and the relay serves the directory itself — no dev server needed. Always tell the user the returned URL and that the link is public: anyone who has it can reach the app without logging in. Previews never expire on their own; close them with {action:"close"} when the user is done.
26
28
 
27
- There is no `preview` tool on this provider — use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews` lists, `DELETE /api/previews/:token` closes the link without touching the dev server behind it. A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
29
+ If you have a `preview` tool, use it. Otherwise use the authenticated localhost API (same auth as `POST /api/relay/shutdown`): `POST /api/previews` with `{ "conversationId": "<conv>", "port": 5173, "label": "web app" }` (or `"dir": "./dist"` instead of the port) publishes, `GET /api/previews` lists, `DELETE /api/previews/:token` closes the link without touching the dev server behind it. A 503 with `details` means the preview lane is disabled or misconfigured — surface those details, they are the operator's fix. See `docs/preview-servers.md`.
30
+
31
+ ## Remote relays
32
+
33
+ If you have a `remote_relay` tool, this relay is paired with other OAR relays and the tool works on them: list and read their sessions, prompt a session, start a new one and wait for its reply. Use it only when the user asks for work on another relay or mentions one, and start with `{"action":"list_relays"}`. A relay stays locked until the user mentions it in this conversation (`@name` or its plain name); a locked relay refuses everything except `list_relays`, so ask the user to mention it rather than retrying. Write actions may first wait for the user's approval. If a result lists `pendingQuestions`, the remote agent is waiting for an answer: ask the user (unless they told you to decide), then pass it on with `answer_question`. Always tell the user which relay and session you worked on.
@@ -7,6 +7,9 @@ export function createMessageRepository(db) {
7
7
  const shareVisibility = createShareVisibilityStatements(db);
8
8
  const queueHasImageOperationId = db.prepare(`PRAGMA table_info(queue)`).all()
9
9
  .some((column) => column.name === 'image_operation_id');
10
+ const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
11
+ const messagesHaveSourceId = messageColumns.has('source_message_id');
12
+ const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
10
13
  const insertQueueSql = queueHasImageOperationId
11
14
  ? `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at, image_operation_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?, ?)`
12
15
  : `INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, context_tier, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at, owner_sdk_session_id, owner_assigned_at, owner_lease_expires_at, owner_last_claimed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL, ?, ?, ?, ?)`;
@@ -17,7 +20,7 @@ export function createMessageRepository(db) {
17
20
  getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
18
21
  setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
19
22
  getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
20
- getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
23
+ getRecentMessagesDesc: db.prepare(`SELECT id, role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
21
24
  insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
22
25
  // Provenance is derived from the authenticated responder identity at
23
26
  // /api/response time, never from the response payload.
@@ -117,10 +120,28 @@ export function createMessageRepository(db) {
117
120
  // response resolves to the row its operation id already created.
118
121
  findQByContinuationOp: db.prepare(`SELECT * FROM queue WHERE continuation_op_id = ?`),
119
122
  setMessageKind: db.prepare(`UPDATE messages SET kind = ? WHERE id = ?`),
123
+ // The durable answer→prompt link (the queue's copy is pruned), so a
124
+ // reload still anchors the reply under the message it answers.
125
+ setMessageSourceId: messagesHaveSourceId
126
+ ? db.prepare(`UPDATE messages SET source_message_id = ? WHERE id = ?`)
127
+ : null,
128
+ setMessageResendOf: messagesHaveResendOf
129
+ ? db.prepare(`UPDATE messages SET resend_of_message_id = ? WHERE id = ?`)
130
+ : null,
120
131
  // Quiet teardown for a continuation whose worker died: there is no
121
132
  // user to answer, so it fails without the terminal-failure ceremony.
122
133
  dropStaleContinuation: db.prepare(`UPDATE queue SET status = 'failed', processing_at = NULL, next_attempt_at = NULL, owner_lease_expires_at = NULL WHERE id = ? AND status = 'processing' AND kind = 'continuation'`),
123
- findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY retry_count ASC, CASE WHEN next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(next_attempt_at, timestamp) ASC, timestamp ASC LIMIT 1`),
134
+ // Claim ordering (all findPending* variants and the owner-transfer
135
+ // scan): strict send order, rowid breaking same-millisecond ties.
136
+ // Neither retry_count nor next_attempt_at may rank rows: dead-worker
137
+ // recovery and /api/requeue bump retry_count, so ranking by it ran a
138
+ // recovered OLDER message after newer ones in the same conversation,
139
+ // and ranking expired-backoff rows behind fresh arrivals starved them
140
+ // for as long as new messages kept coming (live 2026-09-19: a
141
+ // steer-recovered row waited 5 minutes while newer messages jumped
142
+ // it). Head-of-line protection is the WHERE clause's next_attempt_at
143
+ // backoff plus MAX_REQUEUE_RETRIES, which fails a row for good.
144
+ findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND COALESCE(kind, '') != 'continuation' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY timestamp ASC, rowid ASC LIMIT 1`),
124
145
  // Global fallback for requesters without a session identity (the legacy
125
146
  // Copilot relay CLI). Conversations bound to a session-worker provider
126
147
  // (claude/cursor/grok) are excluded outright: handing one of their turns
@@ -152,10 +173,8 @@ export function createMessageRepository(db) {
152
173
  'github'
153
174
  )) NOT IN (${sessionWorkerProviderSqlList()})
154
175
  ORDER BY
155
- q.retry_count ASC,
156
- CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC,
157
- COALESCE(q.next_attempt_at, q.timestamp) ASC,
158
- q.timestamp ASC
176
+ q.timestamp ASC,
177
+ q.rowid ASC
159
178
  LIMIT 1
160
179
  `),
161
180
  findPendingForWorker: db.prepare(`
@@ -198,10 +217,8 @@ export function createMessageRepository(db) {
198
217
  ) = ? THEN 0
199
218
  ELSE 1
200
219
  END ASC,
201
- q.retry_count ASC,
202
- CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC,
203
- COALESCE(q.next_attempt_at, q.timestamp) ASC,
204
- q.timestamp ASC
220
+ q.timestamp ASC,
221
+ q.rowid ASC
205
222
  LIMIT 1
206
223
  `),
207
224
  findPendingForSessionAffinity: db.prepare(`
@@ -220,10 +237,8 @@ export function createMessageRepository(db) {
220
237
  NULLIF(c.sdk_session_id, '')
221
238
  ) = ?
222
239
  ORDER BY
223
- q.retry_count ASC,
224
- CASE WHEN q.next_attempt_at IS NULL THEN 0 ELSE 1 END ASC,
225
- COALESCE(q.next_attempt_at, q.timestamp) ASC,
226
- q.timestamp ASC
240
+ q.timestamp ASC,
241
+ q.rowid ASC
227
242
  LIMIT 1
228
243
  `),
229
244
  countQueueWorkForSessionAffinity: db.prepare(`
@@ -269,6 +284,13 @@ export function createMessageRepository(db) {
269
284
  LIMIT ?
270
285
  `),
271
286
  countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
287
+ // Deferred host suspend: which conversations still hold live turns.
288
+ listActiveQueueCountsByConversation: db.prepare(`
289
+ SELECT q.conversation_id, COUNT(*) AS cnt, MAX(c.title) AS title
290
+ FROM queue q LEFT JOIN conversations c ON c.id = q.conversation_id
291
+ WHERE q.status IN ('pending','processing','parked')
292
+ GROUP BY q.conversation_id
293
+ `),
272
294
  countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
273
295
  setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ?, attempt_id = ? WHERE id = ?`),
274
296
  setProcessingWithWorkerLease: db.prepare(`
@@ -301,7 +323,7 @@ export function createMessageRepository(db) {
301
323
  // Recovery clears only the lease, never the owner: a recovered row must
302
324
  // stay routed to its provider worker so the primer respawns that worker
303
325
  // instead of the row becoming claimable by the global relay poll.
304
- recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND processing_at < ?`),
326
+ recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, attempt_id = NULL, next_attempt_at = ?, owner_lease_expires_at = NULL, owner_last_claimed_at = NULL WHERE status = 'processing' AND COALESCE(kind, '') != 'continuation' AND consumed_at IS NULL AND processing_at < ?`),
305
327
  // Staleness is inactivity, not elapsed turn time: owner_last_claimed_at is
306
328
  // refreshed by every worker heartbeat for the message it is working on, so a
307
329
  // long-but-alive turn keeps moving the cutoff. processing_at is only the
@@ -311,7 +333,7 @@ export function createMessageRepository(db) {
311
333
  // A turn blocked on an unanswered AskUserQuestion is never stale — it is
312
334
  // waiting on the human, and relay_questions carries its own expiry.
313
335
  listRecoverableProcessing: db.prepare(`
314
- SELECT id, conversation_id, kind, relay_mode, model, retry_count
336
+ SELECT id, conversation_id, kind, relay_mode, model, retry_count, consumed_at
315
337
  FROM queue
316
338
  WHERE status = 'processing'
317
339
  AND COALESCE(kind, '') != 'continuation'
@@ -332,6 +354,7 @@ export function createMessageRepository(db) {
332
354
  retry_count = retry_count + 1
333
355
  WHERE status = 'processing'
334
356
  AND COALESCE(kind, '') != 'continuation'
357
+ AND consumed_at IS NULL
335
358
  AND (
336
359
  COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
337
360
  OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
@@ -347,7 +370,7 @@ export function createMessageRepository(db) {
347
370
  // worker can never collect its answer, so holding the row would leak
348
371
  // both the row and the question.
349
372
  listProcessingRowsForOwner: db.prepare(`
350
- SELECT id, conversation_id, kind, retry_count
373
+ SELECT id, conversation_id, kind, retry_count, consumed_at, relay_mode, model
351
374
  FROM queue
352
375
  WHERE status = 'processing'
353
376
  AND NULLIF(owner_sdk_session_id, '') = ?
@@ -361,7 +384,28 @@ export function createMessageRepository(db) {
361
384
  owner_lease_expires_at = NULL,
362
385
  owner_last_claimed_at = NULL,
363
386
  retry_count = retry_count + 1
364
- WHERE id = ? AND status = 'processing'
387
+ WHERE id = ? AND status = 'processing' AND consumed_at IS NULL
388
+ `),
389
+ // See the queue.consumed_at migration: at-most-once rows.
390
+ // Fenced like every other worker write: the row must be processing in
391
+ // this conversation, owned by the same worker session (NULL-safe, for
392
+ // routing-disabled installs), and — when the caller names one — still
393
+ // on the attempt it was pushed under.
394
+ markQueueConsumed: db.prepare(`
395
+ UPDATE queue
396
+ SET consumed_at = COALESCE(consumed_at, @at)
397
+ WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
398
+ AND NULLIF(owner_sdk_session_id, '') IS @owner
399
+ AND (@attemptId IS NULL OR attempt_id = @attemptId)
400
+ `),
401
+ // A consumed steer the CLI then replayed as a turn of its own is an
402
+ // ordinary in-flight row again.
403
+ clearQueueConsumed: db.prepare(`
404
+ UPDATE queue
405
+ SET consumed_at = NULL
406
+ WHERE id = @id AND conversation_id = @conversationId AND status = 'processing'
407
+ AND NULLIF(owner_sdk_session_id, '') IS @owner
408
+ AND (@attemptId IS NULL OR attempt_id = @attemptId)
365
409
  `),
366
410
  listProcessingOwnerSessionIds: db.prepare(`
367
411
  SELECT DISTINCT NULLIF(owner_sdk_session_id, '') AS sdk_session_id
@@ -390,6 +434,12 @@ export function createMessageRepository(db) {
390
434
  listConversationIdsWithActiveQueue: db.prepare(`SELECT DISTINCT conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
391
435
  deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
392
436
  getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
437
+ // All processing rows, oldest first. Mid-turn steering means a
438
+ // conversation can have more than one processing row at once (the live
439
+ // turn plus a steered message folded into it), so the single-row query
440
+ // above is ambiguous — callers that need "the live turn" resolve it
441
+ // from this list by which row is actually producing output.
442
+ listProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) ASC`),
393
443
  parkPendingQueueForRestart: db.prepare(`
394
444
  UPDATE queue
395
445
  SET
@@ -25,6 +25,21 @@ export function createQuestionRepository(db) {
25
25
  linkActivityToResponse: db.prepare(`UPDATE relay_activity SET response_message_id = ? WHERE queue_message_id = ? AND response_message_id IS NULL`),
26
26
  listActivityByResponse: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE response_message_id = ? ORDER BY id ASC`),
27
27
  listActivityByQueueMessage: db.prepare(`SELECT text, subagent_run_id, metadata_json FROM relay_activity WHERE queue_message_id = ? ORDER BY id ASC`),
28
+ // When a queue row last produced output — its newest stream snapshot
29
+ // (rows are rewritten in place and re-stamped per update) or activity
30
+ // line. The live-turn picker compares rows by this, never by the
31
+ // per-row stream seq. `stream_done` is 1 once the row's main-thread
32
+ // snapshot is final: a row settling after a handoff re-stamps its
33
+ // snapshot with done=1, which must not read as live output.
34
+ getLastOutputAtByQueueMessage: db.prepare(`
35
+ SELECT
36
+ (SELECT MAX(at) FROM (
37
+ SELECT MAX(created_at) AS at FROM relay_stream_events WHERE queue_message_id = @id
38
+ UNION ALL
39
+ SELECT MAX(created_at) AS at FROM relay_activity WHERE queue_message_id = @id
40
+ )) AS last_output_at,
41
+ (SELECT MAX(done) FROM relay_stream_events WHERE queue_message_id = @id AND subagent_run_id IS NULL) AS stream_done
42
+ `),
28
43
  deleteConvActivity: db.prepare(`DELETE FROM relay_activity WHERE conversation_id = ?`),
29
44
 
30
45
  // relay stream events — every update carries the full text-so-far
@@ -97,6 +112,9 @@ export function createQuestionRepository(db) {
97
112
  FROM subagent_runs
98
113
  WHERE queue_message_id = ? AND status = 'running'
99
114
  `),
115
+ countRunningSubagentRuns: db.prepare(`
116
+ SELECT COUNT(*) AS cnt FROM subagent_runs WHERE status = 'running'
117
+ `),
100
118
  closeRunningSubagentRunsByQueueMessage: db.prepare(`
101
119
  UPDATE subagent_runs
102
120
  SET status = ?, updated_at = ?, completed_at = COALESCE(completed_at, ?)
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ import { normalizeRemoteRelayOrigin } from '../../shared/remote-relay-contract.mjs';
4
+
5
+ // Storage for remote relays that is not app settings: which remote relays a
6
+ // conversation has unlocked (the user mentioned them), and the provenance of
7
+ // messages and conversations another relay's agent created. Tables and
8
+ // columns come from migrations/0005-remote-relays.mjs.
9
+
10
+ function parseOrigin(json) {
11
+ if (!json) return null;
12
+ try {
13
+ return normalizeRemoteRelayOrigin(JSON.parse(json));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function createRemoteRelayRepository(db) {
20
+ const hasUnlock = db.prepare(`SELECT 1 FROM conversation_remote_relay_unlocks WHERE conversation_id = ? AND remote_relay_id = ?`);
21
+ 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`);
22
+ const insertUnlock = db.prepare(`INSERT OR IGNORE INTO conversation_remote_relay_unlocks (conversation_id, remote_relay_id, message_id, created_at) VALUES (?, ?, ?, ?)`);
23
+ const deleteUnlocksForRelay = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE remote_relay_id = ?`);
24
+ const deleteUnlocksForConversation = db.prepare(`DELETE FROM conversation_remote_relay_unlocks WHERE conversation_id = ?`);
25
+ const setMessageOrigin = db.prepare(`UPDATE messages SET origin_json = ? WHERE id = ?`);
26
+ const getMessageOrigin = db.prepare(`SELECT origin_json FROM messages WHERE id = ?`);
27
+ const setConversationOrigin = db.prepare(`UPDATE conversations SET origin_json = ? WHERE id = ?`);
28
+ const getConversationOrigin = db.prepare(`SELECT origin_json FROM conversations WHERE id = ?`);
29
+
30
+ return {
31
+ hasUnlock(conversationId, remoteRelayId) {
32
+ return !!hasUnlock.get(String(conversationId || ''), String(remoteRelayId || ''));
33
+ },
34
+ /** `[{ remoteRelayId, messageId, createdAt }]`, oldest first. */
35
+ listUnlocks(conversationId) {
36
+ return listUnlocks.all(String(conversationId || '')).map((row) => ({
37
+ remoteRelayId: row.remote_relay_id,
38
+ messageId: row.message_id || null,
39
+ createdAt: row.created_at,
40
+ }));
41
+ },
42
+ /** True when this call created the unlock (false when it already existed). */
43
+ recordUnlock(conversationId, remoteRelayId, messageId = null, nowIso = new Date().toISOString()) {
44
+ const conversation = String(conversationId || '').trim();
45
+ const relay = String(remoteRelayId || '').trim();
46
+ if (!conversation || !relay) return false;
47
+ return insertUnlock.run(conversation, relay, messageId ? String(messageId) : null, nowIso).changes > 0;
48
+ },
49
+ forgetRelay(remoteRelayId) {
50
+ return deleteUnlocksForRelay.run(String(remoteRelayId || '')).changes;
51
+ },
52
+ forgetConversation(conversationId) {
53
+ return deleteUnlocksForConversation.run(String(conversationId || '')).changes;
54
+ },
55
+ setMessageOrigin(messageId, origin) {
56
+ const normalized = normalizeRemoteRelayOrigin(origin);
57
+ return setMessageOrigin.run(normalized ? JSON.stringify(normalized) : null, String(messageId || '')).changes > 0;
58
+ },
59
+ getMessageOrigin(messageId) {
60
+ return parseOrigin(getMessageOrigin.get(String(messageId || ''))?.origin_json);
61
+ },
62
+ setConversationOrigin(conversationId, origin) {
63
+ const normalized = normalizeRemoteRelayOrigin(origin);
64
+ return setConversationOrigin.run(normalized ? JSON.stringify(normalized) : null, String(conversationId || '')).changes > 0;
65
+ },
66
+ getConversationOrigin(conversationId) {
67
+ return parseOrigin(getConversationOrigin.get(String(conversationId || ''))?.origin_json);
68
+ },
69
+ };
70
+ }
71
+
72
+ /** Parses an `origin_json` column value read by other queries. */
73
+ export function parseRemoteRelayOriginJson(json) {
74
+ return parseOrigin(json);
75
+ }
@@ -1,9 +1,6 @@
1
1
  'use strict';
2
2
 
3
- import { createShareVisibilityStatements } from './share-visibility-statements.mjs';
4
-
5
3
  export function createSessionRepository(db) {
6
- const shareVisibility = createShareVisibilityStatements(db);
7
4
  const runtimeSessionColumns = new Set(
8
5
  db.prepare(`PRAGMA table_info(runtime_sessions)`).all().map((column) => String(column?.name || '').trim()),
9
6
  );
@@ -12,6 +9,11 @@ export function createSessionRepository(db) {
12
9
  const runtimeProviderSelect = runtimeSessionsSupportProviders
13
10
  ? 'rs.model AS runtime_model, rs.provider_type AS runtime_provider_type, rs.provider_model AS runtime_provider_model'
14
11
  : 'rs.model AS runtime_model, NULL AS runtime_provider_type, NULL AS runtime_provider_model';
12
+ // Provenance of conversations another relay's agent created (migration
13
+ // 0005); NULL on a schema that predates it.
14
+ const conversationsHaveOrigin = db.prepare(`PRAGMA table_info(conversations)`).all()
15
+ .some((column) => String(column?.name || '').trim() === 'origin_json');
16
+ const conversationOriginSelect = conversationsHaveOrigin ? 'c.origin_json' : 'NULL AS origin_json';
15
17
  return {
16
18
  // conversations
17
19
  getConv: db.prepare(`SELECT * FROM conversations WHERE id = ? AND status != 'deleted'`),
@@ -19,7 +21,7 @@ export function createSessionRepository(db) {
19
21
  getConvBySdkSessionId: db.prepare(`SELECT * FROM conversations WHERE sdk_session_id = ? AND status != 'deleted' ORDER BY updated_at DESC LIMIT 1`),
20
22
  listConvIdsMissingRuntimeSession: db.prepare(`SELECT c.id AS id FROM conversations c LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE rs.id IS NULL AND c.status != 'deleted'`),
21
23
  runtimeSessionsSupportProviders,
22
- listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = 0) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
24
+ listConvs: db.prepare(`SELECT c.id, c.title, c.title_source, c.archived, c.compacted_into, c.compacted_from, c.sdk_session_id, c.preferred_relay_mode, c.preferred_model, c.preferred_reasoning_effort, c.configured_workspace_root_path, c.runtime_workspace_root_path, c.draft_text, c.draft_updated_at, c.draft_updated_by_client_id, c.draft_attachments, ${conversationOriginSelect}, c.created_at, c.updated_at, rs.id AS runtime_session_id, rs.strategy AS runtime_strategy, rs.status AS runtime_status, rs.last_used_at AS runtime_last_used_at, ${runtimeProviderSelect}, COUNT(m.id) as message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id LEFT JOIN runtime_sessions rs ON rs.conversation_id = c.id WHERE c.status != 'deleted' AND (? = 1 OR c.archived = 0) GROUP BY c.id ORDER BY CASE WHEN c.sdk_session_id IS NULL OR c.sdk_session_id = '' THEN 1 ELSE 0 END ASC, c.updated_at DESC`),
23
25
  insertConv: db.prepare(`INSERT OR IGNORE INTO conversations (id, title, created_at, updated_at) VALUES (?, ?, ?, ?)`),
24
26
  updateConvTime: db.prepare(`UPDATE conversations SET updated_at = ? WHERE id = ?`),
25
27
  updateConvTitle: db.prepare(`UPDATE conversations SET title = ?, title_source = 'manual' WHERE id = ?`),
@@ -36,36 +38,15 @@ export function createSessionRepository(db) {
36
38
  archiveConv: db.prepare(`UPDATE conversations SET archived = 1, updated_at = ? WHERE id = ?`),
37
39
  deleteConv: db.prepare(`DELETE FROM conversations WHERE id = ?`),
38
40
 
39
- // messages
40
- getMessages: db.prepare(`SELECT * FROM messages WHERE conversation_id = ? ORDER BY timestamp ASC`),
41
- getSharedMessages: shareVisibility.getSharedMessages,
42
- getMessageByConversation: db.prepare(`SELECT * FROM messages WHERE id = ? AND conversation_id = ? LIMIT 1`),
43
- setMessageShareVisibility: shareVisibility.setMessageShareVisibility,
41
+ // Queue and message statements live in message-repository.mjs — the
42
+ // single source of truth. Copies used to live here too; the runtime's
43
+ // {...sessionRepo, ...messageRepo} spread shadowed all of them, and the
44
+ // dead copies silently fossilized (this one's findPending predated the
45
+ // continuation-row exclusion — claiming through it would have replayed
46
+ // a background continuation as a user prompt). The two counts below are
47
+ // the only message/queue statements this repository actually owns.
44
48
  getConversationMessageCount: db.prepare(`SELECT COUNT(*) AS count FROM messages WHERE conversation_id = ?`),
45
49
  getConversationActiveQueueCount: db.prepare(`SELECT COUNT(*) AS count FROM queue WHERE conversation_id = ? AND status IN ('pending', 'processing', 'parked')`),
46
- getLatestConversationModel: db.prepare(`SELECT model FROM messages WHERE conversation_id = ? AND model IS NOT NULL AND model != '' ORDER BY timestamp DESC LIMIT 1`),
47
- getRecentMessagesDesc: db.prepare(`SELECT role, text, timestamp FROM messages WHERE conversation_id = ? ORDER BY timestamp DESC LIMIT ?`),
48
- insertMsg: db.prepare(`INSERT INTO messages (id, conversation_id, role, text, model, mode, attachments, timestamp, model_requested, model_actual, model_origin) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
49
-
50
- // queue
51
- insertQ: db.prepare(`INSERT INTO queue (id, conversation_id, runtime_session_id, is_new_conversation, model, model_variant_id, reasoning_effort, relay_mode, text, attachments, status, timestamp, retry_count, next_attempt_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, 0, NULL)`),
52
- findPending: db.prepare(`SELECT * FROM queue WHERE status = 'pending' AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY retry_count ASC, CASE WHEN next_attempt_at IS NULL THEN 0 ELSE 1 END ASC, COALESCE(next_attempt_at, timestamp) ASC, timestamp ASC LIMIT 1`),
53
- countStatus: db.prepare(`SELECT status, COUNT(*) as cnt FROM queue WHERE status IN ('pending','processing','parked') GROUP BY status`),
54
- countRuntimeSessions: db.prepare(`SELECT COUNT(*) AS cnt FROM runtime_sessions WHERE status = 'active'`),
55
- setProcessing: db.prepare(`UPDATE queue SET status = 'processing', processing_at = ? WHERE id = ?`),
56
- setQueueRuntimeSession: db.prepare(`UPDATE queue SET runtime_session_id = ? WHERE id = ?`),
57
- setQueueResponseMessageId: db.prepare(`UPDATE queue SET response_message_id = ? WHERE id = ?`),
58
- setDone: db.prepare(`UPDATE queue SET status = 'done', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ? AND status IN ('processing', 'pending')`),
59
- setFailed: db.prepare(`UPDATE queue SET status = 'failed', response = ?, processing_at = NULL, next_attempt_at = NULL WHERE id = ?`),
60
- deleteConvQ: db.prepare(`DELETE FROM queue WHERE conversation_id = ?`),
61
- findQById: db.prepare(`SELECT * FROM queue WHERE id = ?`),
62
- pruneQueue: db.prepare(`DELETE FROM queue WHERE status = 'done' AND id NOT IN (SELECT id FROM queue WHERE status = 'done' ORDER BY timestamp DESC LIMIT 200)`),
63
- recoverStale: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
64
- listRecoverableProcessing: db.prepare(`SELECT id, conversation_id FROM queue WHERE status = 'processing' AND processing_at < ?`),
65
- recoverProcessingBefore: db.prepare(`UPDATE queue SET status = 'pending', processing_at = NULL, next_attempt_at = ? WHERE status = 'processing' AND processing_at < ?`),
66
- listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
67
- deleteQueueById: db.prepare(`DELETE FROM queue WHERE id = ?`),
68
- getLatestProcessingQueueByConversation: db.prepare(`SELECT id, relay_mode, timestamp, processing_at FROM queue WHERE conversation_id = ? AND status = 'processing' ORDER BY COALESCE(processing_at, timestamp) DESC LIMIT 1`),
69
50
 
70
51
  // runtime sessions
71
52
  getRuntimeSessionByConversation: db.prepare(`SELECT * FROM runtime_sessions WHERE conversation_id = ?`),
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  import { createAskUserRoutingService } from '../services/ask-user-routing-service.mjs';
4
+ import { admitRemoteRelayRequest, readRemoteRelayRequest } from '../services/remote-relay-inbound.mjs';
5
+ import { isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
4
6
  import {
5
7
  extractRequestedSchema,
6
8
  normalizeSchema,
@@ -33,6 +35,21 @@ function firstKnownValue(source, keyPaths = []) {
33
35
  return null;
34
36
  }
35
37
 
38
+ /**
39
+ * A stored question row that is this relay's Allow / Deny card for one of its
40
+ * own agent's calls to another relay (remote-relay-caller-context.mjs). The
41
+ * context lives in the row's request envelope.
42
+ */
43
+ function isRemoteRelayApprovalRow(row) {
44
+ let envelope = null;
45
+ try {
46
+ envelope = JSON.parse(String(row?.request || ''));
47
+ } catch {
48
+ return false;
49
+ }
50
+ return isRemoteRelayApprovalQuestion(envelope);
51
+ }
52
+
36
53
  function extractContinuationOwnership(rawRequest) {
37
54
  const parsed = rawRequest && typeof rawRequest === 'object' ? rawRequest : null;
38
55
  if (!parsed) {
@@ -99,6 +116,9 @@ export function registerAskUserRoutes(app, deps) {
99
116
  DEFAULT_RELAY_MODE,
100
117
  sessionWorkerRegistry,
101
118
  pushDispatchService,
119
+ // Remote relays: another relay's agent answering a question is refused
120
+ // while the inbound switch is off. Absent in older wiring (no-op).
121
+ remoteRelayInbound = null,
102
122
  } = deps;
103
123
 
104
124
  const continuationRoutingEnabled = runtimeState?.featureFlags?.SESSION_WORKER_CONTINUATION_ROUTING_ENABLED === true;
@@ -108,16 +128,26 @@ export function registerAskUserRoutes(app, deps) {
108
128
  sessionWorkerRegistry,
109
129
  });
110
130
 
131
+ // A request from another relay's agent, by the test the inbound switch
132
+ // applies (X-OAR-Remote-Origin or a body origin). Made even without the
133
+ // remote-relay deps: an approval card is only ever this relay's user's.
134
+ function fromRemoteRelay(req) {
135
+ return readRemoteRelayRequest(req, remoteRelayInbound || {}).remote;
136
+ }
137
+
111
138
  app.get('/api/relay-questions', auth, (req, res) => {
112
139
  const conversationId = req.query.conversationId ? String(req.query.conversationId) : null;
113
140
  const status = String(req.query.status || 'pending').trim() || 'pending';
114
- const rows = stmts.listQuestions.all(status, conversationId, conversationId);
141
+ let rows = stmts.listQuestions.all(status, conversationId, conversationId);
142
+ if (fromRemoteRelay(req)) rows = rows.filter((row) => !isRemoteRelayApprovalRow(row));
115
143
  res.json({ questions: rows.map(formatQuestionRow) });
116
144
  });
117
145
 
118
146
  app.get('/api/relay-question/:id', auth, (req, res) => {
119
147
  const row = stmts.getQuestion.get(req.params.id);
120
- if (!row) return res.status(404).json({ error: 'Not found' });
148
+ if (!row || (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req))) {
149
+ return res.status(404).json({ error: 'Not found' });
150
+ }
121
151
  res.json({ question: formatQuestionRow(row) });
122
152
  });
123
153
 
@@ -220,6 +250,7 @@ export function registerAskUserRoutes(app, deps) {
220
250
  });
221
251
 
222
252
  app.post('/api/relay-question/:id/answer', auth, (req, res) => {
253
+ if (!admitRemoteRelayRequest(req, res, remoteRelayInbound)) return;
223
254
  const id = String(req.params.id || '').trim();
224
255
  const { answer, structuredAnswer, sdk_session_id, continuation_id, continuation_question_id } = req.body;
225
256
  const sdkSessionId = normalizeId(sdk_session_id);
@@ -228,6 +259,12 @@ export function registerAskUserRoutes(app, deps) {
228
259
 
229
260
  const row = stmts.getQuestion.get(id);
230
261
  if (!row) return res.status(404).json({ error: 'Not found' });
262
+ // Allow / Deny on this relay's own call to another relay is its user's
263
+ // decision; the relay that call went to must never make it.
264
+ if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
265
+ console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=remote-relay-approval-from-remote`);
266
+ return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
267
+ }
231
268
  if (row.status !== 'pending') return res.status(409).json({ error: `Question already ${row.status}` });
232
269
 
233
270
  const schema = parseQuestionRequest(row.request_schema);
@@ -287,6 +324,9 @@ export function registerAskUserRoutes(app, deps) {
287
324
  app.post('/api/relay-question/:id/timeout', auth, (req, res) => {
288
325
  const row = stmts.getQuestion.get(req.params.id);
289
326
  if (!row) return res.status(404).json({ error: 'Not found' });
327
+ if (isRemoteRelayApprovalRow(row) && fromRemoteRelay(req)) {
328
+ return res.status(403).json({ error: 'Only this relay\'s user can answer a remote relay approval' });
329
+ }
290
330
  if (row.status !== 'pending') return res.json({ ok: true, question: formatQuestionRow(row) });
291
331
 
292
332
  const result = stmts.timeoutQuestion.run(row.id);