@oar-sh/oar 0.9.13 → 0.9.14

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 (72) hide show
  1. package/.github/extensions/web-relay/extension.mjs +9 -0
  2. package/.github/extensions/web-relay/server-lifecycle/managed-server.mjs +15 -1
  3. package/.github/extensions/web-relay/skills/question-bridge.mjs +4 -1
  4. package/CHANGELOG.md +302 -0
  5. package/README.md +12 -12
  6. package/bin/oar.js +155 -94
  7. package/package.json +5 -5
  8. package/server/README.md +296 -149
  9. package/server/claude-cloud-worker/claude-cloud-session-process.mjs +10 -4
  10. package/server/claude-worker/claude-session-process.mjs +39 -13
  11. package/server/claude-worker/claude-turn-publisher.mjs +20 -1
  12. package/server/claude-worker/subagent-timeline-recorder.mjs +75 -8
  13. package/server/copilot-worker/copilot-ask-user-tool.mjs +1 -1
  14. package/server/copilot-worker/copilot-prompt-context.mjs +3 -1
  15. package/server/copilot-worker/copilot-question-bridge.mjs +11 -6
  16. package/server/copilot-worker/copilot-sdk-adapter.mjs +11 -0
  17. package/server/copilot-worker/copilot-sdk-session-process.mjs +14 -1
  18. package/server/cursor-worker/cursor-ask-user-tool.mjs +1 -1
  19. package/server/cursor-worker/cursor-sdk-adapter.mjs +22 -7
  20. package/server/cursor-worker/cursor-session-worker.mjs +9 -2
  21. package/server/cursor-worker/cursor-turn-runner.mjs +18 -3
  22. package/server/grok-worker/grok-sdk-adapter.mjs +14 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +13 -3
  24. package/server/public/app/api-client.js +3 -1
  25. package/server/public/app/ask-user-view.js +29 -4
  26. package/server/public/app/conversation-provider-indicator.mjs +18 -8
  27. package/server/public/app/question-answer-rule.mjs +15 -0
  28. package/server/public/app/question-multi-select.mjs +9 -2
  29. package/server/public/app/relay-event-view.mjs +5 -1
  30. package/server/public/index.html +9 -5
  31. package/server/relay-exit-codes.mjs +6 -0
  32. package/server/relay-self-restart.mjs +6 -1
  33. package/server/relay-tools.md +14 -11
  34. package/server/repositories/message-repository.mjs +29 -28
  35. package/server/repositories/subagent-timeline-statements.mjs +2 -0
  36. package/server/routes/ask-user-routes.mjs +41 -7
  37. package/server/routes/messages-routes.mjs +65 -10
  38. package/server/routes/preview-routes.mjs +73 -7
  39. package/server/routes/sessions-routes.mjs +4 -2
  40. package/server/server-runtime.mjs +156 -94
  41. package/server/services/cursor-session-root-service.mjs +70 -6
  42. package/server/services/data-dir-resolution.mjs +208 -19
  43. package/server/services/earlier-database-service.mjs +631 -0
  44. package/server/services/oar-cli-helpers.mjs +6 -1
  45. package/server/services/oar-state-layout.mjs +107 -0
  46. package/server/services/oar-state-migration-service.mjs +387 -96
  47. package/server/services/package-data-rescue-preinstall.mjs +22 -0
  48. package/server/services/package-data-rescue.mjs +137 -0
  49. package/server/services/preview-registry-service.mjs +3 -2
  50. package/server/services/push-dispatch-service.mjs +33 -6
  51. package/server/services/relay-event-service.mjs +183 -5
  52. package/server/services/relay-restart-queue.mjs +10 -3
  53. package/server/services/relay-singleton-guard.mjs +300 -36
  54. package/server/services/remote-relay-caller-context.mjs +7 -2
  55. package/server/services/remote-relay-dispatcher.mjs +30 -9
  56. package/server/services/session-activity-tracker.mjs +52 -15
  57. package/server/services/session-worker-launch-service.mjs +31 -1
  58. package/server/services/stale-turn-recovery.mjs +99 -0
  59. package/server/services/subagent-timeline-service.mjs +4 -1
  60. package/server/services/windows-autostart-service.mjs +329 -31
  61. package/server/services/windows-boot-autostart-service.mjs +188 -60
  62. package/shared/preview-instructions.mjs +17 -12
  63. package/shared/preview-tool-core.mjs +32 -14
  64. package/shared/question-answer-rule.mjs +63 -0
  65. package/shared/relay-events.mjs +54 -4
  66. package/shared/remote-relay-contract.mjs +20 -10
  67. package/shared/subagent-timeline.mjs +4 -0
  68. package/shared/windows-system-tools.mjs +25 -0
  69. package/shared/worker-runtime/config-loader.mjs +54 -8
  70. package/shared/worker-runtime/prompt-context.mjs +3 -1
  71. package/shared/worker-runtime/working-directory.mjs +65 -0
  72. package/server/uploads/image_input.md +0 -540
@@ -18,6 +18,25 @@ export function createMessageRepository(db) {
18
18
  const notHeldForUsageLimit = (alias = '') => (queueHasUsageLimitPause
19
19
  ? `AND NOT (${alias}usage_limit_pause IS NOT NULL AND ${alias}status = 'pending' AND ${alias}next_attempt_at IS NOT NULL AND ${alias}next_attempt_at > strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`
20
20
  : '');
21
+ // When a processing row is due for the stale sweep; one rule for the
22
+ // listings and the requeue, so the UPDATE can never take a row the
23
+ // listing did not. Inactivity is measured from the last heartbeat, the
24
+ // ceiling (@ceilingBefore, NULL = no limit) from the later of the turn's
25
+ // start and its last answered question, and a turn waiting on an
26
+ // unanswered question is never due. The comments at the statements say why.
27
+ const staleProcessingSql = `(
28
+ (
29
+ COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
30
+ OR (
31
+ @ceilingBefore IS NOT NULL
32
+ AND MAX(
33
+ COALESCE(processing_at, timestamp),
34
+ COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
35
+ ) < @ceilingBefore
36
+ )
37
+ )
38
+ AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
39
+ )`;
21
40
  const messageColumns = new Set(db.prepare(`PRAGMA table_info(messages)`).all().map((column) => column.name));
22
41
  const messagesHaveSourceId = messageColumns.has('source_message_id');
23
42
  const messagesHaveResendOf = messageColumns.has('resend_of_message_id');
@@ -369,18 +388,13 @@ export function createMessageRepository(db) {
369
388
  FROM queue
370
389
  WHERE status = 'processing'
371
390
  AND COALESCE(kind, '') != 'continuation'
372
- AND (
373
- COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
374
- OR (
375
- @ceilingBefore IS NOT NULL
376
- AND MAX(
377
- COALESCE(processing_at, timestamp),
378
- COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
379
- ) < @ceilingBefore
380
- )
381
- )
382
- AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
391
+ AND ${staleProcessingSql}
383
392
  `),
393
+ // Requeues one row listRecoverableProcessing returned, so the caller
394
+ // knows exactly which rows it requeued and announces only those. The
395
+ // sweep runs synchronously, so nothing changes between listing and
396
+ // requeue; re-checking the same rule here is a safeguard that keeps
397
+ // this statement from ever taking a row the listing would not.
384
398
  recoverProcessingBefore: db.prepare(`
385
399
  UPDATE queue
386
400
  SET status = 'pending',
@@ -390,14 +404,11 @@ export function createMessageRepository(db) {
390
404
  owner_lease_expires_at = NULL,
391
405
  owner_last_claimed_at = NULL,
392
406
  retry_count = retry_count + 1
393
- WHERE status = 'processing'
407
+ WHERE id = @id
408
+ AND status = 'processing'
394
409
  AND COALESCE(kind, '') != 'continuation'
395
410
  AND consumed_at IS NULL
396
- AND (
397
- COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
398
- OR (@ceilingBefore IS NOT NULL AND COALESCE(processing_at, timestamp) < @ceilingBefore)
399
- )
400
- AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
411
+ AND ${staleProcessingSql}
401
412
  `),
402
413
  // Dead-worker recovery: the owner's process is provably gone, so its
403
414
  // in-flight rows go back to pending — owner intact (the primer
@@ -467,17 +478,7 @@ export function createMessageRepository(db) {
467
478
  FROM queue
468
479
  WHERE status = 'processing'
469
480
  AND kind = 'continuation'
470
- AND (
471
- COALESCE(owner_last_claimed_at, processing_at, timestamp) < @inactiveBefore
472
- OR (
473
- @ceilingBefore IS NOT NULL
474
- AND MAX(
475
- COALESCE(processing_at, timestamp),
476
- COALESCE((SELECT MAX(answered_at) FROM relay_questions WHERE queue_id = queue.id), '')
477
- ) < @ceilingBefore
478
- )
479
- )
480
- AND id NOT IN (SELECT queue_id FROM relay_questions WHERE status = 'pending')
481
+ AND ${staleProcessingSql}
481
482
  `),
482
483
  listQueueForPauseDrop: db.prepare(`SELECT id, conversation_id FROM queue WHERE status IN ('pending', 'processing', 'parked')`),
483
484
  // Authoritative "is a turn in flight" signal for the conversation list.
@@ -47,6 +47,8 @@ export function createSubagentTimelineStatements(db) {
47
47
  // The column list every run read uses, so the header travels with the
48
48
  // run wherever runs are listed (conversation payload, in-flight snapshot).
49
49
  subagentRunSelectColumns,
50
+ // A run that runs again (a resumed agent): no end time while it runs.
51
+ reopenSubagentRun: db.prepare(`UPDATE subagent_runs SET status = 'running', updated_at = ?, completed_at = NULL WHERE id = ?`),
50
52
  updateSubagentRunHeader: null,
51
53
  setSubagentRunTruncated: null,
52
54
  listChildSubagentRuns: null,
@@ -3,6 +3,7 @@
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 { isRemoteRelayApprovalQuestion } from '../../shared/remote-relay-contract.mjs';
6
+ import { closedQuestionAnswer, questionAcceptsFreeText } from '../../shared/question-answer-rule.mjs';
6
7
  import {
7
8
  extractRequestedSchema,
8
9
  normalizeSchema,
@@ -35,19 +36,39 @@ function firstKnownValue(source, keyPaths = []) {
35
36
  return null;
36
37
  }
37
38
 
39
+ function parseRowJson(value) {
40
+ try {
41
+ return JSON.parse(String(value || ''));
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
38
47
  /**
39
48
  * A stored question row that is this relay's Allow / Deny card for one of its
40
49
  * own agent's calls to another relay (remote-relay-caller-context.mjs). The
41
50
  * context lives in the row's request envelope.
42
51
  */
43
52
  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);
53
+ const envelope = parseRowJson(row?.request);
54
+ return envelope ? isRemoteRelayApprovalQuestion(envelope) : false;
55
+ }
56
+
57
+ /**
58
+ * The choices of a stored card that takes no typed text (approval and
59
+ * permission cards, and a card with choices that stored no flag, as the
60
+ * runtime's formatQuestionRow reads it for the card view), or null for a
61
+ * card that takes any reply.
62
+ */
63
+ function closedQuestionOfRow(row) {
64
+ const envelope = parseRowJson(row?.request);
65
+ const parsedChoices = parseRowJson(row?.choices);
66
+ const choices = Array.isArray(parsedChoices) ? parsedChoices.map((choice) => String(choice ?? '')) : [];
67
+ const allowFreeform = envelope && typeof envelope === 'object' && typeof envelope.allowFreeform === 'boolean'
68
+ ? envelope.allowFreeform
69
+ : null;
70
+ if (questionAcceptsFreeText({ allowFreeform, choices })) return null;
71
+ return { choices, multiSelect: envelope?.context?.multiSelect === true };
51
72
  }
52
73
 
53
74
  function extractContinuationOwnership(rawRequest) {
@@ -297,6 +318,19 @@ export function registerAskUserRoutes(app, deps) {
297
318
  flatText = summarizeStructuredAnswer(schema, structured) || flatText;
298
319
  } else if (!flatText) {
299
320
  return res.status(400).json({ error: 'Empty answer' });
321
+ } else {
322
+ // A card that takes no typed text is answered by its choices only.
323
+ // Anything else is refused and the card stays open: it must not pass
324
+ // for an answer (an approval reader would take it for a denial).
325
+ const closed = closedQuestionOfRow(row);
326
+ if (closed) {
327
+ const accepted = closedQuestionAnswer(closed, flatText);
328
+ if (!accepted) {
329
+ console.warn(`[${ts()}] QUESTION reject id=${id.slice(0, 8)} reason=not-a-choice`);
330
+ return res.status(400).json({ error: 'This question takes one of its choices, not typed text', choices: closed.choices });
331
+ }
332
+ flatText = accepted;
333
+ }
300
334
  }
301
335
 
302
336
  const result = askUserRoutingService.routeAnswer({
@@ -64,7 +64,7 @@ import {
64
64
  sanitizeRunHeaderPatch,
65
65
  TIMELINE_READ_DEFAULT_LIMIT,
66
66
  } from '../services/subagent-timeline-service.mjs';
67
- import { normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
67
+ import { SUBAGENT_TASK_TYPES, isTerminalSubagentRunStatus, normalizeSubagentRunStatus } from '../../shared/subagent-timeline.mjs';
68
68
  import { RELAY_EVENT_MESSAGE_KIND } from '../../shared/relay-events.mjs';
69
69
  import { isRelayShutdownQueued, relayShutdownGateReply } from '../services/relay-shutdown-gate.mjs';
70
70
  import { createStaleAttemptLogLimiter } from '../services/stale-attempt-log-limiter.mjs';
@@ -1431,6 +1431,9 @@ function isOpenAIImageAttachment(raw) {
1431
1431
  export function resolveOpenAIImageEditAttachment(rawAttachments, {
1432
1432
  maxImageBytes = 20 * 1024 * 1024,
1433
1433
  allowedRootPath = '',
1434
+ // Folders of earlier versions that uploads are still read from: only a
1435
+ // sha256-named blob directly inside one counts.
1436
+ allowedBlobRootPaths = [],
1434
1437
  allowedGeneratedImagesRootPath = '',
1435
1438
  } = {}) {
1436
1439
  const attachments = Array.isArray(rawAttachments) ? rawAttachments : [];
@@ -1451,8 +1454,11 @@ export function resolveOpenAIImageEditAttachment(rawAttachments, {
1451
1454
  const filePath = String(firstImage.path || '').trim();
1452
1455
  if (!filePath) throw new Error('Image attachment is missing file path or data URL');
1453
1456
  const allowedRoot = String(allowedRootPath || '').trim();
1457
+ const blobRoots = (Array.isArray(allowedBlobRootPaths) ? allowedBlobRootPaths : [])
1458
+ .map((root) => String(root || '').trim())
1459
+ .filter(Boolean);
1454
1460
  const generatedImagesRoot = String(allowedGeneratedImagesRootPath || '').trim();
1455
- if (allowedRoot || generatedImagesRoot) {
1461
+ if (allowedRoot || blobRoots.length || generatedImagesRoot) {
1456
1462
  const resolvedPath = path.resolve(filePath);
1457
1463
  const isWithinRoot = (rootPath) => {
1458
1464
  if (!rootPath) return false;
@@ -1467,7 +1473,11 @@ export function resolveOpenAIImageEditAttachment(rawAttachments, {
1467
1473
  && relativeParts[0] !== '..'
1468
1474
  && relativeParts[1] === 'generated-images';
1469
1475
  })();
1470
- if (!isWithinRoot(allowedRoot) && !isGeneratedImagePath) {
1476
+ const isOldUploadBlob = blobRoots.some((root) => (
1477
+ path.dirname(resolvedPath) === path.resolve(root)
1478
+ && /^[a-f0-9]{64}$/.test(path.basename(resolvedPath))
1479
+ ));
1480
+ if (!isWithinRoot(allowedRoot) && !isOldUploadBlob && !isGeneratedImagePath) {
1471
1481
  throw new Error('Image attachment path is outside the upload directory');
1472
1482
  }
1473
1483
  }
@@ -1747,6 +1757,7 @@ export function registerMessagesRoutes(app, deps) {
1747
1757
  MAX_UPLOAD_BYTES,
1748
1758
  MAX_UPLOAD_ATTACHMENTS,
1749
1759
  uploadsDir,
1760
+ uploadReadDirs = [uploadsDir],
1750
1761
  MAX_REPO_TREE_NODES,
1751
1762
  MAX_REQUEUE_RETRIES,
1752
1763
  MAX_IMAGE_DATA_URL_LENGTH,
@@ -1896,6 +1907,21 @@ export function registerMessagesRoutes(app, deps) {
1896
1907
  return tasks.find((task) => (task?.toolUseId && task.toolUseId === runId) || (taskId && task?.taskId === taskId)) || null;
1897
1908
  }
1898
1909
 
1910
+ // A conversation's sub-agent run by its task id. A Claude agent resumed by
1911
+ // a new message keeps its task id, and the CLI names the call that resumed
1912
+ // it (SendMessage) as the task's tool use, an id no run row carries: its
1913
+ // run is the one the task id leads to.
1914
+ function subagentRunOfTask(conversationId, taskId) {
1915
+ const conversation = String(conversationId || '').trim();
1916
+ const task = String(taskId || '').trim().slice(0, 200);
1917
+ if (!conversation || !task) return null;
1918
+ try {
1919
+ return stmts.getSubagentRunByTaskId?.get?.(conversation, task) || null;
1920
+ } catch {
1921
+ return null;
1922
+ }
1923
+ }
1924
+
1899
1925
  // Stop is offered while a run is running: a background run through its
1900
1926
  // task, a foreground run through the sub-agent cancel route.
1901
1927
  function isSubagentRunStoppable(run) {
@@ -4791,9 +4817,9 @@ export function registerMessagesRoutes(app, deps) {
4791
4817
  ? (pinnedGrokModel || String(configuredGrok?.model || '').trim())
4792
4818
  : '';
4793
4819
  // Claude Cloud conversations resolve models against the cloud provider's
4794
- // own list (plain ids, no "[1m]" tier). The model is part of the cloud
4795
- // session's configuration: it can still change before the first message
4796
- // creates that session, and is locked afterwards.
4820
+ // own list (plain ids, no "[1m]" tier). The first message creates the
4821
+ // cloud session with the conversation's model; a later change is applied
4822
+ // by the worker before the next message (see below).
4797
4823
  const configuredClaudeCloud = runtimeUsesClaudeCloud ? getClaudeCloudProviderSettings() : null;
4798
4824
  const pinnedClaudeCloudModel = runtimeUsesClaudeCloud
4799
4825
  ? stripModelTierSuffix(existingRuntimeSession?.provider_model)
@@ -6971,6 +6997,7 @@ export function registerMessagesRoutes(app, deps) {
6971
6997
  editAttachment = resolveOpenAIImageEditAttachment(requestBody.attachments, {
6972
6998
  maxImageBytes: MAX_UPLOAD_BYTES,
6973
6999
  allowedRootPath: uploadsDir,
7000
+ allowedBlobRootPaths: uploadReadDirs.slice(1),
6974
7001
  allowedGeneratedImagesRootPath: resolveSessionStateRoot?.() || '',
6975
7002
  });
6976
7003
  } catch (error) {
@@ -7830,6 +7857,8 @@ export function registerMessagesRoutes(app, deps) {
7830
7857
  sessionActivityTracker?.noteTaskSettled?.(conversationId, {
7831
7858
  taskId,
7832
7859
  toolUseId: sanitizeSubagentRunId(req.body?.toolUseId) || null,
7860
+ // Which run ended (a resumed agent runs again under the same task id).
7861
+ ...(req.body?.taskRunId ? { taskRunId: String(req.body.taskRunId).trim().slice(0, 200) } : {}),
7833
7862
  status: String(req.body?.status || '').trim().slice(0, 40) || 'completed',
7834
7863
  summary: cleanTaskText(String(req.body?.summary || ''), 500) || null,
7835
7864
  exitCode: exitCode ?? null,
@@ -7886,6 +7915,9 @@ export function registerMessagesRoutes(app, deps) {
7886
7915
  // The spawning tool_use id: the sub-agent run this task is. The panel
7887
7916
  // opens the run's viewer from it.
7888
7917
  toolUseId: sanitizeSubagentRunId(task?.toolUseId) || undefined,
7918
+ // Which run of the task this is (the SDK's run id): a resumed agent
7919
+ // keeps its task id and runs again under a new one.
7920
+ taskRunId: String(task?.taskRunId || '').trim().slice(0, 200) || undefined,
7889
7921
  // The console (shell and Monitor tasks): the file holding the
7890
7922
  // output, how it is obtained ('none': the provider has none to give).
7891
7923
  ...taskOutputFields(task),
@@ -7894,6 +7926,15 @@ export function registerMessagesRoutes(app, deps) {
7894
7926
  exitCode: wholeNumber(task?.exitCode),
7895
7927
  }))
7896
7928
  .filter((task) => task.taskId);
7929
+ // A resumed agent's row names the call that resumed it, which no run
7930
+ // carries: it opens the run of its task instead (a worker that remembers
7931
+ // the spawn sends that already).
7932
+ const runConversationId = resolveTaskConversationId(conversationId);
7933
+ for (const task of tasks) {
7934
+ if (!task.toolUseId || !SUBAGENT_TASK_TYPES.has(task.taskType) || stmts.getSubagentRun?.get?.(task.toolUseId)) continue;
7935
+ const run = subagentRunOfTask(runConversationId, task.taskId);
7936
+ if (run) task.toolUseId = String(run.id);
7937
+ }
7897
7938
  // The first path a task names wins: a later post cannot point a live
7898
7939
  // task's console at another file.
7899
7940
  for (const task of tasks) {
@@ -8563,12 +8604,14 @@ export function registerMessagesRoutes(app, deps) {
8563
8604
  // Fenced by the run row, not by an open turn: a background agent keeps
8564
8605
  // writing after its turn ended, and its entries belong to that turn. A run
8565
8606
  // the relay does not know yet but whose parent it does (an agent spawned
8566
- // inside another) is created under the parent's turn; anything else is a
8567
- // 404 the worker retries once the spawn has landed.
8607
+ // inside another) is created under the parent's turn; a run of a task the
8608
+ // relay knows by its task id (a resumed agent: same task, new tool use) is
8609
+ // that task's run; anything else is a 404 the worker retries once the spawn
8610
+ // has landed.
8568
8611
  app.post('/api/subagent-timeline', auth, (req, res) => {
8569
8612
  touchCli();
8570
8613
  const conversationId = String(req.body?.conversationId || '').trim();
8571
- const runId = sanitizeSubagentRunId(req.body?.subagentRunId);
8614
+ let runId = sanitizeSubagentRunId(req.body?.subagentRunId);
8572
8615
  if (!conversationId || !runId) {
8573
8616
  return res.status(400).json({ error: 'Missing conversationId or subagentRunId' });
8574
8617
  }
@@ -8577,6 +8620,17 @@ export function registerMessagesRoutes(app, deps) {
8577
8620
  }
8578
8621
  const rawHeader = req.body?.header && typeof req.body.header === 'object' ? req.body.header : {};
8579
8622
  let run = stmts.getSubagentRun?.get?.(runId) || null;
8623
+ // A finished run that a later run of its task (posted under another id)
8624
+ // sets running again: the relay marks where it resumed, once per id.
8625
+ let resumedNote = null;
8626
+ if (!run) {
8627
+ run = subagentRunOfTask(conversationId, req.body?.taskId || rawHeader.taskId);
8628
+ if (run && isTerminalSubagentRunStatus(normalizeSubagentRunStatus(run.status))
8629
+ && rawHeader.status && normalizeSubagentRunStatus(rawHeader.status) === 'running') {
8630
+ resumedNote = { key: `relay:resumed:${runId}`, kind: 'note', text: 'Resumed with a new message', metadata: { resumed: true } };
8631
+ }
8632
+ if (run) runId = String(run.id);
8633
+ }
8580
8634
  let created = false;
8581
8635
  if (!run) {
8582
8636
  const parentId = sanitizeSubagentRunId(rawHeader.parentSubagentId);
@@ -8599,7 +8653,8 @@ export function registerMessagesRoutes(app, deps) {
8599
8653
  if (patch) run = subagentTimeline.applyHeader(runId, patch) || run;
8600
8654
  let stored;
8601
8655
  try {
8602
- stored = subagentTimeline.applyEntries(run, req.body?.entries);
8656
+ const entries = Array.isArray(req.body?.entries) ? req.body.entries : [];
8657
+ stored = subagentTimeline.applyEntries(run, resumedNote ? [resumedNote, ...entries] : entries);
8603
8658
  } catch (error) {
8604
8659
  return res.status(500).json({ error: error?.message || 'Failed to store the timeline' });
8605
8660
  }
@@ -4,9 +4,46 @@
4
4
  // listener itself has no routes and no way to register anything. Creating or
5
5
  // closing a preview therefore always requires the relay token, even though the
6
6
  // resulting URL is public.
7
+ //
8
+ // Conversation scope. A preview's token is its only credential, so an agent's
9
+ // preview tool must not see or close another conversation's. A request that
10
+ // names a conversation — the `conversationId` query, else the
11
+ // `X-Relay-Conversation-Id` header every worker and the OAR MCP server send —
12
+ // is scoped to it: GET lists only its previews, DELETE refuses another
13
+ // conversation's with a 403. Only a request with neither (the web UI's
14
+ // Settings → Previews) gets the relay-wide view. Agent-side callers present the
15
+ // same relay token as the owner, so this is a boundary for the tools, not
16
+ // against a caller that deliberately leaves both out.
7
17
 
8
18
  import { validateStaticRoot } from '../services/preview-static-handler.mjs';
9
19
 
20
+ /**
21
+ * The conversation ids a named scope covers: the id itself, and — when it
22
+ * names a conversation by its id or by its session id — that conversation's id
23
+ * and session id. They differ after a placeholder rekey, and a worker's or
24
+ * tool server's own identity is the session id. A lookup that fails narrows
25
+ * the scope to the named id rather than failing the request.
26
+ */
27
+ export function createPreviewScopeResolver({
28
+ getConversation = () => null,
29
+ getConversationBySdkSessionId = () => null,
30
+ } = {}) {
31
+ return function resolveConversationScope(id) {
32
+ const named = String(id || '').trim();
33
+ if (!named) return [];
34
+ const ids = new Set([named]);
35
+ let conversation = null;
36
+ try {
37
+ conversation = getConversation(named) || getConversationBySdkSessionId(named) || null;
38
+ } catch {}
39
+ for (const value of [conversation?.id, conversation?.sdk_session_id]) {
40
+ const text = String(value || '').trim();
41
+ if (text) ids.add(text);
42
+ }
43
+ return [...ids];
44
+ };
45
+ }
46
+
10
47
  export function registerPreviewRoutes(app, deps) {
11
48
  const {
12
49
  auth,
@@ -20,6 +57,8 @@ export function registerPreviewRoutes(app, deps) {
20
57
  // publishes stay panel-only — the transcript records the conversation, not
21
58
  // panel actions).
22
59
  recordPreviewCard = () => {},
60
+ // (scope id) => the conversation ids it covers; createPreviewScopeResolver.
61
+ resolveConversationScope = (id) => (id ? [id] : []),
23
62
  } = deps;
24
63
 
25
64
  function laneUnavailable(res) {
@@ -36,13 +75,27 @@ export function registerPreviewRoutes(app, deps) {
36
75
  });
37
76
  }
38
77
 
39
- // GET /api/previews — every live preview, or one conversation's when filtered.
40
- // Unfiltered is what the settings "Live previews" list uses: nothing expires
41
- // on its own, so there has to be one place that shows everything.
78
+ // The conversation ids a request is scoped to, or null for the owner's
79
+ // view. The query wins over the header: the header is the worker's own
80
+ // session, while a worker can be handed a row of another conversation (a
81
+ // placeholder rekey, or the shared fallback of the worker's pending lookup)
82
+ // and its tool names the conversation of the turn it is running.
83
+ function requestScope(req) {
84
+ const named = String(req.query?.conversationId || '').trim()
85
+ || String(req.headers?.['x-relay-conversation-id'] || '').trim();
86
+ if (!named) return null;
87
+ const ids = resolveConversationScope(named);
88
+ return Array.isArray(ids) && ids.length ? ids : [named];
89
+ }
90
+
91
+ // GET /api/previews — one conversation's live previews when scoped (see the
92
+ // top of the file), else every one. Unscoped is what the settings "Live
93
+ // previews" list uses: nothing expires on its own, so there has to be one
94
+ // place that shows everything.
42
95
  app.get('/api/previews', auth, (req, res) => {
43
- const conversationId = String(req.query?.conversationId || '').trim();
44
- const previews = conversationId
45
- ? previewRegistry.listForConversation(conversationId)
96
+ const scope = requestScope(req);
97
+ const previews = scope
98
+ ? previewRegistry.list().filter((entry) => scope.includes(entry.conversationId))
46
99
  : previewRegistry.list();
47
100
  res.json({
48
101
  previews,
@@ -107,8 +160,21 @@ export function registerPreviewRoutes(app, deps) {
107
160
  });
108
161
 
109
162
  // DELETE /api/previews/:token — the link stops resolving immediately. The dev
110
- // server behind it is untouched; it is a separate background task.
163
+ // server behind it is untouched; it is a separate background task. A scoped
164
+ // request may close only its own conversation's previews.
111
165
  app.delete('/api/previews/:token', auth, (req, res) => {
166
+ const scope = requestScope(req);
167
+ if (scope) {
168
+ const token = String(req.params?.token || '').trim();
169
+ // list() also holds the stored previews a restart could not check yet.
170
+ const target = previewRegistry.list().find((entry) => entry.token === token);
171
+ if (target && !scope.includes(target.conversationId)) {
172
+ return res.status(403).json({
173
+ error: 'This preview belongs to another conversation; a conversation can close only its own previews. '
174
+ + 'The owner can close it in Settings → Previews.',
175
+ });
176
+ }
177
+ }
112
178
  const closed = previewRegistry.close(req.params?.token);
113
179
  if (!closed.ok) return res.status(closed.status || 404).json({ error: closed.error });
114
180
  res.json({ ok: true, preview: closed.preview });
@@ -4032,8 +4032,10 @@ export function registerSessionsRoutes(app, deps) {
4032
4032
  // then nothing is shared.
4033
4033
  function collectSharePreviewTokens(conversationId) {
4034
4034
  const tokens = new Set();
4035
- // Every preview of the relay, not only this conversation's: the preview
4036
- // tool's list covers them all, and a reply may quote any of its tokens.
4035
+ // Every preview of the relay, not only this conversation's: a reply may
4036
+ // quote any token its agent got hold of — one the user pasted from
4037
+ // Settings → Previews, or a relay-wide list fetched by hand with the relay
4038
+ // token (the preview tool's own list is scoped to its conversation).
4037
4039
  try {
4038
4040
  for (const knownToken of listKnownPreviewTokens?.() || []) tokens.add(String(knownToken));
4039
4041
  } catch {