@oar-sh/oar 0.9.8 → 0.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +478 -0
  2. package/README.md +129 -39
  3. package/bin/bat/oar.bat +46 -46
  4. package/bin/oar.js +814 -228
  5. package/package.json +5 -6
  6. package/server/README.md +306 -4
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  8. package/server/claude-worker/claude-session-process.mjs +35 -1
  9. package/server/claude-worker/claude-turn-publisher.mjs +3 -0
  10. package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
  11. package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
  12. package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
  13. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  14. package/server/db-schema.mjs +44 -0
  15. package/server/migrations/0008-message-pins.mjs +85 -0
  16. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  17. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  18. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  19. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  20. package/server/public/app/agent-exchange-fold-view.mjs +248 -0
  21. package/server/public/app/api-client.js +91 -0
  22. package/server/public/app/archive-confirm.mjs +28 -0
  23. package/server/public/app/ask-user-view.js +3 -0
  24. package/server/public/app/attachments-view.js +1024 -131
  25. package/server/public/app/back-guard.mjs +58 -0
  26. package/server/public/app/background-tasks-view.mjs +18 -26
  27. package/server/public/app/bootstrap.js +230 -35
  28. package/server/public/app/chat-actions-menu-state.mjs +116 -0
  29. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  30. package/server/public/app/composer-control-state.mjs +16 -0
  31. package/server/public/app/composer-keyboard.mjs +54 -0
  32. package/server/public/app/conversation-view.js +471 -110
  33. package/server/public/app/explorer-edit-state.mjs +68 -0
  34. package/server/public/app/external-link-policy.mjs +4 -1
  35. package/server/public/app/file-version.mjs +58 -0
  36. package/server/public/app/host-suspend-ui.js +6 -3
  37. package/server/public/app/journal-view.js +161 -27
  38. package/server/public/app/no-conversation-state.mjs +58 -0
  39. package/server/public/app/pinned-messages-view.mjs +253 -0
  40. package/server/public/app/provider-readiness.mjs +120 -0
  41. package/server/public/app/pwa-install.js +1 -1
  42. package/server/public/app/relay-board-view.js +3 -0
  43. package/server/public/app/relay-dot-state.mjs +8 -1
  44. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  45. package/server/public/app/relay-event-view.mjs +234 -0
  46. package/server/public/app/router.js +8 -2
  47. package/server/public/app/settings-modal.js +53 -1
  48. package/server/public/app/socket-handlers.js +65 -28
  49. package/server/public/app/store.js +25 -2
  50. package/server/public/app/subagent-row.mjs +138 -0
  51. package/server/public/app/subagent-timeline-view.mjs +186 -0
  52. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  53. package/server/public/app/subagent-viewer.mjs +534 -0
  54. package/server/public/app/task-row-format.mjs +29 -0
  55. package/server/public/app/thinking-follow.js +4 -0
  56. package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
  57. package/server/public/app/transcript-jump-bottom.mjs +99 -0
  58. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  59. package/server/public/app/update-ui.js +1 -1
  60. package/server/public/app/viewer-chat-gallery.mjs +59 -0
  61. package/server/public/app/viewer-controls-state.mjs +59 -0
  62. package/server/public/app/viewer-gallery.mjs +46 -0
  63. package/server/public/app/viewer-gestures.mjs +105 -0
  64. package/server/public/app/viewer-history.mjs +45 -0
  65. package/server/public/app/viewer-tap.mjs +112 -0
  66. package/server/public/index.html +700 -59
  67. package/server/repositories/message-pin-statements.mjs +39 -0
  68. package/server/repositories/message-repository.mjs +34 -3
  69. package/server/repositories/question-repository.mjs +9 -3
  70. package/server/repositories/remote-relay-repository.mjs +6 -2
  71. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  72. package/server/routes/ask-user-routes.mjs +3 -0
  73. package/server/routes/messages-routes.mjs +548 -111
  74. package/server/routes/relay-event-routes.mjs +157 -0
  75. package/server/routes/remote-relay-routes.mjs +15 -0
  76. package/server/routes/sessions-routes.mjs +164 -24
  77. package/server/server-runtime.mjs +274 -62
  78. package/server/services/cloudflared-tunnel-service.mjs +139 -41
  79. package/server/services/explorer-entry-name.mjs +26 -0
  80. package/server/services/explorer-mutations.mjs +122 -0
  81. package/server/services/file-serving.mjs +155 -0
  82. package/server/services/message-pin-list.mjs +52 -0
  83. package/server/services/oar-cli-helpers.mjs +139 -4
  84. package/server/services/oar-cli-menu.mjs +240 -0
  85. package/server/services/oar-cli-wsl.mjs +129 -0
  86. package/server/services/preview-proxy-server.mjs +41 -23
  87. package/server/services/push-dispatch-service.mjs +18 -0
  88. package/server/services/relay-event-link-service.mjs +289 -0
  89. package/server/services/relay-event-service.mjs +1935 -0
  90. package/server/services/relay-shutdown-gate.mjs +31 -0
  91. package/server/services/remote-relay-caller-context.mjs +52 -7
  92. package/server/services/remote-relay-dispatcher.mjs +494 -2
  93. package/server/services/remote-relay-registry-service.mjs +11 -1
  94. package/server/services/safe-served-content.mjs +61 -11
  95. package/server/services/session-activity-tracker.mjs +487 -0
  96. package/server/services/session-history-refresh-service.mjs +31 -0
  97. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  98. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  99. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  100. package/server/services/subagent-record-retention.mjs +164 -0
  101. package/server/services/subagent-timeline-service.mjs +328 -0
  102. package/server/services/subagent-viewer-rooms.mjs +119 -0
  103. package/server/services/upgrade-socket-guard.mjs +16 -0
  104. package/shared/relay-events.mjs +371 -0
  105. package/shared/remote-relay-contract.mjs +157 -5
  106. package/shared/subagent-record-retention.mjs +31 -0
  107. package/shared/subagent-timeline.mjs +79 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -41,7 +41,7 @@
41
41
  "scripts": {
42
42
  "start": "node server/server.js",
43
43
  "copilot:relay": "gh copilot -- --allow-all",
44
- "test": "node --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
44
+ "test": "node --import ./scripts/node-test-keep-alive.mjs --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
45
45
  "test:agents:smoke": "node --test \"tests/agents/**/*.test.mjs\"",
46
46
  "test:e2e": "node tests/run-e2e.mjs",
47
47
  "setup:git": "node scripts/setup-git.mjs",
@@ -50,9 +50,9 @@
50
50
  "test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
51
51
  },
52
52
  "dependencies": {
53
- "@anthropic-ai/claude-agent-sdk": "^0.3.283",
54
- "@cursor/sdk": "^1.0.32",
55
- "@github/copilot-sdk": "^1.0.14",
53
+ "@anthropic-ai/claude-agent-sdk": "^0.3.292",
54
+ "@cursor/sdk": "^1.0.36",
55
+ "@github/copilot-sdk": "^1.0.17",
56
56
  "better-sqlite3": "^12.10.0",
57
57
  "express": "^4.18.2",
58
58
  "qrcode-terminal": "^0.12.0",
@@ -63,7 +63,6 @@
63
63
  "zod": "^4.4.3"
64
64
  },
65
65
  "optionalDependencies": {
66
- "cloudflared": "^0.7.1",
67
66
  "tty-console": "^1.0.4"
68
67
  },
69
68
  "devDependencies": {
package/server/README.md CHANGED
@@ -400,6 +400,69 @@ tree renderer as the live panel, `buildWorkflowRunCard` in `public/app/backgroun
400
400
  survives a reload. A history rebuild deletes a conversation's `workflow_runs` alongside its
401
401
  `subagent_runs`, because both key on message ids the rebuild replaces.
402
402
 
403
+ ### Sub-agent records (the sub-agent viewer)
404
+
405
+ Every sub-agent run keeps its whole record in `subagent_timeline` (migration 0009): one row per
406
+ thought, reply text, tool call (full input), tool result (cut at 4 KB, the original length kept in
407
+ the entry's metadata), child agent or note, numbered per run by `seq` in the order the relay first
408
+ saw it. A writer names each entry with its own `entry_key`; a second write under the same key
409
+ updates the entry in place and keeps its `seq`, which is how a thought or a reply streams. A run
410
+ keeps at most 2000 entries plus one note saying where it was cut (`subagent_runs.truncated`).
411
+ `subagent_runs` also carries the run header the rows show: model, type, task id, tokens, whether it
412
+ runs in the background, and who writes its timeline.
413
+
414
+ - **Claude**: `claude-worker/subagent-timeline-recorder.mjs` reads every SDK message at process
415
+ level, before any turn takes it, and posts entries to `POST /api/subagent-timeline` keyed by the
416
+ run (coalesced every 500 ms; a run the relay does not know yet is retried). A background agent
417
+ outlives the turn that spawned it, so its frames arrive between turns and during later turns;
418
+ they go to its own record, never to a later turn's live lanes. A background launch's receipt
419
+ (`tool_use_result.status: 'async_launched'`) leaves the run running; its `task_notification`
420
+ ends it. Spawns carry `timelineOwner: 'worker'`.
421
+ - **Copilot and Cursor** publish their sub-agents on the live lanes only (`/api/activity`,
422
+ `/api/thought`, `/api/stream` with a `subagentRunId`); the relay copies each frame into the run's
423
+ timeline, uncut. Fields they do not report (model, tokens) stay blank.
424
+ - `POST /api/background-tasks` copies model, type, task id and tokens onto the run whose id is the
425
+ task's `toolUseId`, so the row keeps them after the in-memory task set is cleared.
426
+ - Every `subagent_status` event carries the whole header and the run's own turn (`messageId`). A
427
+ turn that fails closes its foreground runs; background runs are closed by their worker, or by the
428
+ relay when the worker dies.
429
+
430
+ The browser draws one row per top-level run in the reply bubble (`public/app/subagent-row.mjs`, the
431
+ panel's row grammar); a row's title, like a sub-agent task's title in the composer panel, opens
432
+ `public/app/subagent-viewer.mjs`. The viewer reads `GET /api/conversation/:id/subagent/:runId`, grows
433
+ with the `subagent_timeline` event, and fetches again from the last contiguous `seq` after a missed
434
+ event or a reconnect. A run recorded before the timeline existed is served from its tagged
435
+ activity, thought and stream rows (`legacy: true`).
436
+
437
+ **Live delivery.** `subagent_timeline` goes only to the pages whose viewer shows that run. The viewer
438
+ names its run with the socket message `subagent_viewer_watch { conversationId, subagentRunId }`
439
+ (acknowledged with `{ ok: true }` or `{ ok: false, code }`: `missing-id`, `not-found`,
440
+ `conversation-mismatch`) when it opens, moves to a child or its parent, and after every reconnect,
441
+ and sends `subagent_viewer_unwatch` when it closes. The relay checks the run exists and belongs to
442
+ the conversation and keeps each socket in at most one room, `subagent-viewer:<runId>`
443
+ (`services/subagent-viewer-rooms.mjs`). Batches go to that room as volatile packets, so they are
444
+ never kept for a reconnect replay and a backed-up connection drops them. socket.io also drops a
445
+ volatile packet while the connection is still writing an earlier one, even from the same tick
446
+ (a worker post that changes the header emits `subagent_status` first), so the relay holds batches
447
+ one tick, merged per run, before sending. The viewer covers what is dropped: it waits for the
448
+ watch's answer before its first read, fetches on a `seq` hole, after a reconnect, and once more when
449
+ the run ends. A refused `not-found` watch is sent again on the run's next `subagent_status`. The lane
450
+ events (`relay_activity`, `relay_thought`, `relay_stream`, `subagent_status`) go to every page as
451
+ before.
452
+
453
+ **Retention.** After `subagent_record_retention_days` (app setting, `0` = keep for good, otherwise 7,
454
+ 14, 30 or 90; default 90; Settings → General → *Keep sub-agent tool output*) the relay strips a
455
+ finished run's tool entries: a `tool_result` keeps its row with empty text, a `tool_call` keeps its
456
+ one-line summary (`subagentToolCallSummary` in `shared/subagent-timeline.mjs`, the viewer's rule) in
457
+ place of the input. Both get `pruned_at` (migration 0010) and metadata
458
+ `{ pruned: true, prunedAt, originalChars, retentionDays }`; the viewer shows them as "Input removed
459
+ after N days" / "output removed after N days". Thoughts, reply text, notes, child rows and the runs
460
+ stay, and the age is the entry's `updated_at`; runs still `running` (or `pending`, `processing`) are
461
+ never touched, nor are the lane rows. `services/subagent-record-retention.mjs` sweeps 30 s after
462
+ start, once a day and 5 s after the setting changes, 2000 rows per transaction with the event loop
463
+ free in between, through a partial index over the unstripped tool entries, and logs one
464
+ `SUBAGENT RETENTION …` line. No VACUUM: SQLite reuses the freed pages.
465
+
403
466
  ### Context usage
404
467
 
405
468
  Claude sessions have no Copilot `events.jsonl` to tail. Instead the worker reads the live
@@ -431,6 +494,20 @@ On Windows it opens a `Claude Worker <id>` terminal window.
431
494
  turns. Those replies carry no usage line rather than displaying Copilot premium-request numbers under
432
495
  a reply that never touched Copilot.
433
496
 
497
+ ### Plan usage for agents
498
+
499
+ `remote_relay`'s `usage {relay}` (permission `read`, like `relay_info`; this relay too, as `this`)
500
+ reads `GET /api/usage` and `GET /api/usage-limit` on the target and answers per provider card
501
+ (Copilot, Claude, Claude Cloud, Cursor, Grok): `status` (`not-configured` and `unavailable` say why
502
+ in `message`), `plan`, `source`, `capturedAt`, the meters (`used`, `allowance`, `remaining`,
503
+ `utilization`, `unit`, `resetAt`, `severity`) and the card's detail rows and notes; plus
504
+ `usageLimit` (`account`: the Claude window, its utilization, reset and overage; `pauses`: the turns
505
+ held at a usage limit, by session). The dispatcher copies an allow-list of fields
506
+ (`usageCard` in `services/remote-relay-dispatcher.mjs`), so a field the usage report gains later is
507
+ not passed on; account labels and links are left out, and an e-mail address or a token-shaped
508
+ string in a text is masked. A relay without `/api/usage` per provider answers
509
+ `REMOTE_RELAY_UNSUPPORTED`; one without `/api/usage-limit` answers `usageLimit: null` with a note.
510
+
434
511
  ## Claude Cloud provider
435
512
 
436
513
  Provider type `claude-cloud`: a provider of its own, never an alias of `claude`. Its conversations
@@ -630,6 +707,183 @@ turn finishing and delivery — leaving a permanent spinner. `activeTurn: false`
630
707
  client's optimistic state. Relay shutdown also waits 300 ms before disconnecting sockets so a terminal
631
708
  `message_status` emitted just before an idle-deferred restart still reaches the browser.
632
709
 
710
+ ## Relay event subscriptions
711
+
712
+ A session (the **watcher**) can ask to be told what happens in other sessions of this relay or of a
713
+ paired relay, through `remote_relay`. The relay tells it by writing a short event message into the
714
+ watcher's conversation; nothing reads transcripts on disk, and it works for every provider.
715
+
716
+ **Actions** (`relay` may be left out and means `this`; on this relay the agent-sessions setting must
717
+ be on, as for every local action; a paired relay: see [Across paired relays](#across-paired-relays)):
718
+
719
+ - `subscribe {sessions: [ids] | ["all"], events?, once?, match?, stalled_minutes?, heartbeat_minutes?}`
720
+ — `all` watches every session, ones created later too. The watcher's own conversation is never
721
+ watched. The first subscribe of a conversation shows the user an approval card naming the
722
+ sessions; a subscription to `all` is always named on a card (asked again after an Allow that did
723
+ not cover it). The Allow is remembered as unlock rows `self:watch` / `self:watch-all`.
724
+ - `unsubscribe {subscription_id | "all"}`, `list_subscriptions {watcher?}` (another conversation's
725
+ subscriptions, to inspect before a transfer).
726
+ - `transfer_subscriptions {from}` — moves every active subscription of another conversation, with
727
+ its settings and its undelivered events, to the caller: a watcher is replaced without a moment
728
+ where events go nowhere. Always an approval card (from where, how many, whether `all` is among
729
+ them), also after the caller's first-subscribe Allow. The new watcher is dropped from moved
730
+ session lists.
731
+
732
+ **Events** (`shared/relay-events.mjs`): `question_asked` (prompt, options, question id; an approval
733
+ card is marked as one only the user can answer), `error` (`turn_failed`, `usage_limit`,
734
+ `worker_died`), `turn_end` (status `done` or `stopped`, reply preview), `task_finished` /
735
+ `task_failed` (background sub-agents and shell tasks: kind, status, exit code where known),
736
+ `stalled` (no activity for `stalled_minutes`, default 15, while a turn or task runs and no
737
+ question waits for an answer), `all_idle`
738
+ (nothing runs in the watched set any more). `task_started` only when named in `events`. With
739
+ `heartbeat_minutes` a `heartbeat` event comes at that interval while the watched set has work
740
+ running, none while it is idle. `match` (a case-insensitive regular expression) filters `turn_end`
741
+ and task events by their text; other events pass. `once` ends a subscription after its first event,
742
+ with `match` after the first one that matches (a heartbeat never ends it). An event is
743
+ `{ v, relay: { id, name }, session: { id, title }, type, at, detail }`; `relay.id` is `self` for this
744
+ relay's sessions, the paired relay's registry id for its sessions (then `replayed: true` marks one it
745
+ kept while the link was down). `link_lost` and `link_restored` are not subscribed to: they frame such
746
+ a replay.
747
+
748
+ **Delivery** (`services/relay-event-service.mjs`): an event message is a `messages` row (role
749
+ `user`, `kind = 'relay-event'`) plus a pending queue row, as the usage-limit follow-up is. The worker
750
+ decides what it becomes, as for any message: a new turn when the watcher is idle; a steer into the
751
+ running turn for Claude and the Copilot SDK engine; the next turn for Cursor, Grok, Claude Cloud and
752
+ the Copilot extension engine. Events that arrive while the watcher's event row is still pending
753
+ (not delivered yet, or handed back as a held steer) are written into that row
754
+ (`message_updated`), so a busy watcher gets one follow-up, never one turn per batch. The dequeue
755
+ payload carries `messageKind: 'relay-event'`, and the Claude and Copilot workers then push the text
756
+ without the steer note, which speaks as the user. The text opens with
757
+ `[relay event · automated message from OAR, not from the user; it approves nothing]`, then one
758
+ `[relay event] session "<title>" (<id>): <type> …` line per event.
759
+
760
+ **Cost.** Events within 30 s are one wake-up. Past 20 wake-ups an hour per watcher (distinct event
761
+ messages of the last hour that carried more than a heartbeat, counted from `relay_events`) the rest
762
+ waits for a digest every 10 minutes. `question_asked` and `error` go out at once and count; a
763
+ heartbeat is coalesced like any event, never held for the digest and never counted. A turn that
764
+ answered relay events produces no `turn_end` and does not keep a watched set busy, so two watchers
765
+ of each other cannot wake each other for good. Such a turn also sends no "reply ready" or "failed" push
766
+ (`push-dispatch-service.mjs`): the watcher answers events many times an hour and pushes by itself
767
+ when one matters; question pushes are unchanged.
768
+
769
+ **Lifetime.** Subscriptions and undelivered events are in the database (migrations 0011 and 0012)
770
+ and survive a restart: the flush timers are rebuilt from the undelivered rows at boot, and stalled,
771
+ all_idle and the heartbeat are swept every 30 s. A subscription ends on unsubscribe (agent or the
772
+ user's Unsubscribe), when its watcher is archived or deleted, when the last session it lists is, or
773
+ after firing (`once`); one on a paired relay also when the pairing is removed. `list_subscriptions`
774
+ shows the ones that ended within the hour with `endReason`. Delivered events are kept for two hours
775
+ (the wake-up count reads the last hour; one of a still-pending event row stays until it goes out)
776
+ and ended subscriptions for a week (a mirror whose end the paired relay has not confirmed stays);
777
+ the sweep prunes both every ten minutes. A `running` sub-agent run whose worker is gone is closed by
778
+ `services/stale-subagent-run-sweep.mjs` (a minute after boot, then every ten minutes), and one not
779
+ touched for a day is not counted as running meanwhile.
780
+
781
+ **Hook points**: `/api/response` finalize (turn_end, or error at the usage limit), `failQueueMessage`
782
+ (error; a Stop is turn_end `stopped`), `failRecoveredRowTerminally`, `recoverDeadWorkerProcessingRows`
783
+ and the background-only worker death (worker_died), `/api/relay-question` and the approval card's
784
+ notify (question_asked), the activity tracker's task edges, and the archive and delete routes.
785
+
786
+ ### Across paired relays
787
+
788
+ The watching relay (**A**, it holds the watcher) and the watched one (**B**) each keep a row of the
789
+ subscription. Only A calls, over its pairing: a pairing in one direction is enough.
790
+
791
+ - **Gate.** The four actions with `relay: <paired relay>` pass the same checks as any `remote_relay`
792
+ call there: the relay unlocked in the watcher's conversation (the user named it, or its agent wrote
793
+ there), A's permission for it at least `read`, and B's inbound switch on (B refuses with
794
+ `REMOTE_INBOUND_DISABLED`). `subscribe` first asks B with a dry run (sessions, limits, switch,
795
+ version), then shows the watch card on A naming the relay (and `all`), remembered as
796
+ `<registry id>:watch` / `:watch-all` and forgotten with the pairing, then subscribes on B and stores
797
+ the mirror on A. No card on B. A relay that predates these routes answers Express's plain 404,
798
+ which A reports as `REMOTE_RELAY_UNSUPPORTED` ("runs an OAR version without event subscriptions
799
+ across relays").
800
+ - **B's side** (`relay-event-routes.mjs`): `POST /api/relay-events/remote/subscribe` and
801
+ `POST /api/relay-events/remote/pull` take only another relay's request (the `X-OAR-Remote-Origin`
802
+ header names it; this relay's own agents and the browser get `403`) and only while the inbound
803
+ switch is on. The subscription is an ordinary row whose watcher is the key
804
+ `remote:<that relay's id>`, which names no conversation, so nothing a paired relay asks for is ever
805
+ written into a conversation of B's; its events are recorded, swept (stalled, all_idle, heartbeat)
806
+ and filtered like any, and wait for the fetch. Retention: per subscription the newest 500 undelivered
807
+ events (questions and errors kept longest), none older than 7 days; a subscription not fetched for
808
+ 7 days ends (`subscriber-gone`). What was dropped is counted and reported once.
809
+ - **The fetch** (`services/relay-event-link-service.mjs` on A): every 5 s while A holds active
810
+ subscriptions on B or an end B has not confirmed, every 30 s after a minute without an answer,
811
+ nothing once none are left. The body is `{ after, subscriptions: [{ id, watcher }], ended: [ids] }`:
812
+ B deletes what A stored up to `after`, applies the ends, updates the watcher names, and answers
813
+ `{ events, cursor, more, subscriptions: [state], ended, dropped }` (at most 200 events a page).
814
+ A writes the events into its watchers' undelivered events (only for subscriptions it holds, into the
815
+ watcher it stored), ends or updates its mirrors from the states, clears confirmed ends and moves its
816
+ cursor in one transaction, so a restart of either relay loses or repeats nothing. From there
817
+ delivery is the same as for A's own sessions: coalescing, the cap and digest, the steer, the loop
818
+ guard (an event turn on either relay is no `turn_end` for the other).
819
+ - **Gaps.** A link without an answer for 60 s (failing fetches, or A down) is lost from its last
820
+ answer on. The first fetch after that, every page of it, is held and written as one urgent row per
821
+ watcher with subscriptions there: `link_lost` (no answer since …), `Replayed: N events …` with
822
+ questions and errors first, `link_restored` (after how long, how many kept, how many dropped, which
823
+ subscriptions ended there meanwhile). A shorter outage only delays. Nothing wakes the watcher when
824
+ the link drops; `list_subscriptions` shows each paired subscription's `link` (`ok`, `failing`,
825
+ `lost`), and a `heartbeat_minutes` subscription notices the silence.
826
+ - **Ends.** Unsubscribe and an archived watcher end the mirror at once and mark it until B confirms
827
+ (`list_subscriptions` shows `endPending`); `unsubscribe` waits up to 10 s for that and says so when B
828
+ did not answer. An archived session on B, or B's user ending the subscription there, ends the mirror
829
+ with the next fetch (`archived`, `ended-on-relay`). Removing the pairing sends one last fetch with
830
+ every subscription as ended, then ends the mirrors (`pairing-removed`).
831
+ - `unsubscribe "all"` and `transfer_subscriptions` act on the named relay's subscriptions; with `this`,
832
+ `list_subscriptions` lists all of the watcher's (each with `relay`) and `transfer_subscriptions`
833
+ moves all, its card naming the paired relays among them. A transfer reaches B as the new watcher's
834
+ name with the next fetch.
835
+
836
+ `/api/remote-relays/tool` refuses a call that carries another relay's origin: a paired relay never
837
+ acts, or subscribes, for one of this relay's conversations.
838
+
839
+ ### Session state and tasks
840
+
841
+ `services/session-activity-tracker.mjs` knows what each session is doing, from the
842
+ `/api/background-tasks` sets (a new task id is a start, a task gone from the set has ended, a
843
+ changed one is activity), `POST /api/background-task-settled` (how a task ended; the Claude worker
844
+ posts it from `task_notification`), the sub-agent run statuses and the activity, thought, stream and
845
+ timeline posts (the activity clock). A task that leaves the set without word ends after 5 s with
846
+ its run's status, else `completed` with `statusReported: false`; an exit code is read from the task
847
+ summary when it names one (the SDK has no field for it). Memory only: a restart forgets the recently
848
+ finished tasks and starts the clock at boot.
849
+
850
+ `GET /api/conversations` and `GET /api/conversation/:id` carry `sessionState` (`running_turn`: a
851
+ turn processing, or queued and not held by a usage-limit pause; `background_only`: tasks running;
852
+ `idle`), `sessionTasks` (running tasks, then up to five that ended within the hour: `kind`
853
+ `subagent` | `shell`, `description`, `startedAt`, `lastActivityAt`, `status` `running` |
854
+ `completed` | `failed` | `stopped`, `exitCode`, `summary`, `endedAt`) and `lastActivityAt`.
855
+ `remote_relay`'s `list_sessions` and `read_session` pass them on as `state`, `tasks` and
856
+ `lastActivityAt`; `active` stays and is true for background work too.
857
+
858
+ ### Browser
859
+
860
+ An event message renders as a compact row (`relay-event-view.mjs`) folded to its summary
861
+ (`2 events · question, reply`); a row with a question or an error starts open, a replay carries a
862
+ **replayed** tag, and the user's fold is kept across re-renders. A watcher's header shows **👁 watching
863
+ N sessions** (or all; sessions on paired relays count, the chip's title names those relays), which
864
+ opens its subscriptions with an **Unsubscribe** button each (`lab-relay · Report builder`); a watched
865
+ session shows a **watched** badge in its header and, when watched by id, in the sidebar (a
866
+ subscription to all would badge every row; one on a paired relay badges none of this relay's
867
+ sessions). On the watched relay the badge names a paired relay's watcher with its relay.
868
+ The list comes from `GET /api/relay-events/subscriptions` and the `relay_event_subscriptions`
869
+ socket event (the whole active list after every change).
870
+
871
+ ### Folded agent exchanges
872
+
873
+ A prompt another agent sent the session (a paired relay's, or one of this relay's own: a user row
874
+ with `origin`) and the session's reply to it fold into one row, `note from <relay> · <session> ·
875
+ replied` (`working…`, `failed`, `stopped`, `no reply yet`), which opens to both as they render
876
+ otherwise. Presentation only (`agent-exchange-fold-view.mjs` decides, `syncAgentExchangeFolds` in
877
+ `conversation-view.js` applies it as one more idempotent pass after every render and update, beside
878
+ the separator and steering passes); the transcript data is unchanged. Nothing addressed to the user
879
+ folds away: not a turn a message was steered into (the reply `absorbed`, or another message folded
880
+ into or stopped with it), not an agent prompt that was itself steered into a turn, not an answer on
881
+ a continuation row, and, while the page lives, not a turn that had an open question, approval card
882
+ or plan board, or ran while a message of the user's waited. A folded exchange shows open while its
883
+ turn is `working…` and closes by itself once it is replied, failed or stopped
884
+ (`agentExchangeFoldOpen`); the user's own toggle, kept by prompt id, always wins over that, and a
885
+ jump to a message inside (search, pins, image parent) opens it.
886
+
633
887
  ## Image conversations (OpenAI BYOK)
634
888
 
635
889
  Choosing **OpenAI Image (BYOK)** in New Chat creates a conversation whose replies are generated
@@ -658,6 +912,24 @@ images, calling the OpenAI Images API directly rather than going through a CLI w
658
912
  is never silently lost.
659
913
  - `POST /api/shared/:token/presence` tracks shared-viewer presence, surfaced as a watcher count.
660
914
 
915
+ ## Pinned messages
916
+
917
+ - `PATCH /api/conversation/:id/message/:messageId/pin` with `{ pinned: boolean }` sets or clears
918
+ `messages.pinned_at`. A conversation holds at most 100 pins; the next one is refused with `409`
919
+ and `code: "pin-limit"`. The answer is
920
+ `{ ok, conversationId, messageId, pinned, pins, pinsRevision }`.
921
+ - `GET /api/conversation/:id` carries `pins`: every pinned message of the conversation in
922
+ conversation order, also those outside the requested history page, each as
923
+ `{ messageId, role, preview, timestamp, pinnedAt, attachmentCount, hiddenFromShares }`.
924
+ `preview` is plain text of at most 160 characters. Each message also carries `pinned`.
925
+ - Every change is broadcast as the socket event `conversation_pins_updated` with
926
+ `{ conversationId, pins, revision }`. `revision` (`pinsRevision` in the HTTP answers) grows with
927
+ every change. A conversation load that left the relay before a change can reach the browser
928
+ after the change's own event; the browser keeps the list with the higher revision.
929
+ - Shared views carry neither `pins` nor `pinned`: pins are the owner's bookmarks.
930
+ - Pins belong to message rows. `/compact` starts a new conversation without them (they stay with
931
+ the archived one); re-importing an imported session keeps them, matched like hidden marks.
932
+
661
933
  ## Relay Mode Selection
662
934
 
663
935
  The composer also includes a per-message mode picker:
@@ -697,7 +969,7 @@ When a newer version is available, Settings shows a dismissible-per-version
697
969
  card (not dismissible if the release is marked critical — it still never
698
970
  auto-applies). On npm-global installs, **Update** runs
699
971
  `npm i -g @oar-sh/oar@<version>` with the log streamed into the card, then the
700
- relay restarts once the message queue is idle. After the restart the relay
972
+ relay restarts once the running turns finish (queued messages wait for it). After the restart the relay
701
973
  verifies the running version actually changed; if not, the npm log is surfaced
702
974
  as a failure. Git checkouts are detect-only — the card shows a "pull to
703
975
  update" hint instead of the button. `oar update [--beta] [--to X.Y.Z]` does
@@ -916,6 +1188,12 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
916
1188
  | GET | `/api/drives/roots` | Return browsable root(s) for explorer drive mode — on Windows returns fixed/removable drive letters; on Linux returns a single `/` root node |
917
1189
  | GET | `/api/drives/list` | Lazy-load directory entries (`path`, optional `includeHidden`) — on Windows `path` is a drive-letter path (e.g. `C:/foo`); on Linux `path` is an absolute POSIX path (e.g. `/home/user`) |
918
1190
  | GET | `/api/session-root/list` | List the explorer's Session root (`path`, optional `includeHidden`) — like `/api/drives/list`, but a not-yet-created root returns an empty folder (`exists: false`) instead of 404, and the sibling `<path>.jsonl` transcript is appended as a child |
1191
+ | POST | `/api/repo/mkdir` | Create a folder in the workspace: `{ path, name, conversationId? }` where `path` is the repo-relative parent (`''` for the root); the target is resolved like `/api/files/*` (no `..`, no symlink escape). Answers `{ ok, path }`; 409 when the name is taken |
1192
+ | POST | `/api/repo/rename` | Rename a workspace entry in place: `{ path, newName, conversationId? }`. The root cannot be renamed; symlinks are refused. Answers `{ ok, path }` |
1193
+ | POST | `/api/repo/delete` | Delete a workspace entry, folders recursively: `{ path, conversationId?, dryRun? }`. `dryRun: true` answers `{ ok, kind, files, dirs, capped }` (counted below the folder, capped at 10 000) and removes nothing |
1194
+ | POST | `/api/drives/mkdir` | As `/api/repo/mkdir` for an absolute path (also the explorer's Session root), checked like `/api/drives/list`: `{ path, name }`. Missing parents are created with the folder |
1195
+ | POST | `/api/drives/rename` | As `/api/repo/rename` for an absolute path: `{ path, newName }`. `/` and drive roots are refused |
1196
+ | POST | `/api/drives/delete` | As `/api/repo/delete` for an absolute path: `{ path, dryRun? }`. `/` and drive roots are refused |
919
1197
  | GET | `/api/drives/file` | Stream a file by path — Windows drive path or Linux absolute path depending on server platform |
920
1198
  | GET | `/api/drives/files-preview` | Return structured preview JSON for a file — Windows drive path or Linux absolute path depending on server platform |
921
1199
  | GET | `/api/git/status` | Git status for the conversation's workspace root (`branch`, `upstream`, `ahead`, `behind`, `files[]` incl. untracked); a non-repo root returns `isRepo: false` rather than an error |
@@ -965,14 +1243,19 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
965
1243
  | POST | `/api/settings/cursor` | Set/remove the Cursor API key, default model, or enabled model subset; key changes reset discovered models and effort tiers |
966
1244
  | GET | `/api/settings/turn-ceiling` | Read the max turn duration plus slider bounds (`minMinutes`, `maxMinutes`, `stepMinutes`, `defaultMinutes`) |
967
1245
  | POST | `/api/settings/turn-ceiling` | Set the max turn duration in minutes (`0` = no limit) |
1246
+ | GET | `/api/settings/subagent-record-retention` | How long sub-agent records keep tool inputs and output: `{ retentionDays, choices: [0, 7, 14, 30, 90], defaultDays: 90 }`; `0` = keep for good |
1247
+ | POST | `/api/settings/subagent-record-retention` | `{ retentionDays }` → the GET body; anything but one of `choices` is `400`. Runs a retention sweep 5 s later |
968
1248
  | POST | `/api/claude-native-session` | (Claude worker) Persist the native Agent SDK session id for resume |
969
1249
  | POST | `/api/claude-context-usage` | (Claude and Claude Cloud workers) Report the session's context-window breakdown after a turn; `409` for a conversation of any other provider |
970
1250
  | POST | `/api/claude-plan-usage` | (Claude and Claude Cloud workers) Report the session's structured `/usage` data — plan rate-limit windows plus session cost totals — falling back to the stable `modelUsage`/`totalCostUsd` result fields. Stored as the latest Claude snapshot; a Claude Cloud conversation's report is stored under its own snapshot key, `claude-cloud` |
971
1251
  | POST | `/api/claude-cloud-session` | (Claude Cloud worker) Report the cloud session a conversation is bound to: `{ conversationId, cloudSessionId, sessionUrl?, lastSequence, pushedBranch?, costUsd?, model? }` → `{ ok, cloud }`. Sent after the session was created, after every `result` and on a push; emits `claude_cloud_session`. `404` without a runtime session, `409` for a conversation that is not a cloud one |
972
1252
  | POST | `/api/cursor-plan-usage` | (Cursor worker) Report the agent's cumulative billed usage; the relay diffs it against a per-agent checkpoint and books the increase into the current billing cycle under the pool implied by the turn's model |
973
- | POST | `/api/subagent-run` | (Worker) Register/update a subagent run for the active turn |
1253
+ | POST | `/api/subagent-run` | (Worker) Register/update a subagent run. A new run needs the spawning turn's `messageId`; an update to a known run may omit it (a background agent's end arrives between turns). Optional header fields: `subagentType`, `model`, `modelInherited`, `taskId`, `background`, `timelineOwner`; a post without `status` leaves the run's state alone. Emits `subagent_status` with the whole header |
1254
+ | POST | `/api/subagent-timeline` | (Worker) `{ conversationId, subagentRunId, entries: [{ key, kind, text, done?, toolName?, toolUseId?, metadata? }], header? }` → `{ ok, stored, truncated }`. Kinds: `thought`, `tool_call`, `tool_result`, `text`, `child`, `note`. Fenced by the run row, not by an open turn; an unknown run whose `header.parentSubagentId` is known is created under the parent's turn, anything else is `404 unknown_run`. Sends `subagent_timeline` `{ conversationId, messageId, subagentRunId, entries, truncated }`, volatile, only to the pages watching the run (`subagent_viewer_watch`) |
1255
+ | GET | `/api/conversation/:conversationId/subagent/:subagentRunId` | One run's record for the viewer: `?after=<seq>&limit=<n>` (default 500, max 2000) → `{ run, entries, children, nextAfter, complete }`. `entries` are those after `after` plus any still open; `run.legacy` marks a record rebuilt from the lane rows |
974
1256
  | POST | `/api/conversation/:conversationId/subagent/:subagentRunId/cancel` | Request cancellation of one subagent run (unsupported by the Claude runtime) |
975
1257
  | PATCH | `/api/conversation/:id/message/:messageId/share-visibility` | Hide or unhide a single message from shared views |
1258
+ | PATCH | `/api/conversation/:id/message/:messageId/pin` | `{ pinned }` → pin or unpin a single message; answers with the conversation's pin list and broadcasts `conversation_pins_updated` |
976
1259
  | POST | `/api/conversation/:id/share` | Create or update the conversation's read-only share token |
977
1260
  | GET | `/api/shared/:token` | Read-only shared conversation view (hidden messages filtered out) |
978
1261
  | POST | `/api/shared/:token/presence` | Report shared-viewer presence for the watcher count |
@@ -988,6 +1271,11 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
988
1271
  | POST | `/api/remote-relays/pair` | (Relay to relay) Another relay introduces itself after the user paired it there |
989
1272
  | GET | `/api/remote-relays/summary` | (Worker) `{ count, online, localEnabled }`: the number of paired relays, how many of them are online, and whether agent sessions are switched on. Workers register `remote_relay` when `count` is above zero or `localEnabled` is `true` |
990
1273
  | POST | `/api/remote-relays/tool` | (Worker) One `remote_relay` tool call: `{ conversationId, action, args }`. For a paired relay the relay checks the mention unlock, the permission, the hop limit and (ask/plan mode) the user's approval, then forwards the call to the remote. For this relay itself (named by its name or `this`; refused with `REMOTE_RELAY_LOCAL_DISABLED` while agent sessions are off) it checks instead that the call does not aim at the caller's own conversation (`REMOTE_RELAY_OWN_SESSION`) and, for `create_session`, that the caller was not created by an agent (`REMOTE_RELAY_NESTED_SESSION`), that fewer than 4 sessions it created have a turn queued or running (`REMOTE_RELAY_SESSION_LIMIT`) and that the user allowed it once for the conversation; the call then runs against this relay's own routes in-process. `wait_seconds` is cut to the `maxWaitSeconds` setting for every target |
1274
+ | GET | `/api/relay-events/subscriptions` | Every active [relay event subscription](#relay-event-subscriptions) (`{ available, subscriptions }`); refused to other relays' agents |
1275
+ | DELETE | `/api/relay-events/subscriptions/:id` | The user's Unsubscribe (a paired relay's subscription too: it learns with its next fetch); refused to other relays' agents |
1276
+ | POST | `/api/relay-events/remote/subscribe` | (Relay to relay) A paired relay's agent subscribes to sessions here: `{ watcher: { conversationId, title }, subscriber: { name }, scope, sessions, events?, once?, match?, stalled_minutes?, heartbeat_minutes?, dry_run? }` → `{ ok, subscription }`. Another relay's request only, inbound switch on; see [Across paired relays](#across-paired-relays) |
1277
+ | POST | `/api/relay-events/remote/pull` | (Relay to relay) That relay fetches its events: `{ after, subscriptions: [{ id, watcher }], ended: [ids] }` → `{ ok, events, cursor, more, subscriptions, ended, dropped }` |
1278
+ | POST | `/api/background-task-settled` | (Worker) How a background task ended: `{ conversationId, taskId, toolUseId?, status, summary?, reason? }` (Claude worker, from `task_notification`) |
991
1279
  | GET | `/api/remote-relays/inflight` | (Worker) Number of `remote_relay` calls running for `conversationId` |
992
1280
  | GET/POST | `/api/settings/remote-relays` | This relay's `publicUrl` and `inboundEnabled` (accept prompts from other relays' agents) |
993
1281
  | GET | `/api/settings/agent-sessions` | Agent sessions: `{ enabled, maxWaitSeconds, limits: { minWaitSeconds: 120, maxWaitSeconds: 3600, stepSeconds: 60 }, maxActiveSessions: 4 }`. `enabled` (default `false`) lets this relay's agents start and use sessions on it through `remote_relay`; `maxWaitSeconds` (default `600`) is the longest wait of one `remote_relay` call, for this relay and paired ones |
@@ -1016,7 +1304,7 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1016
1304
  - `restart`: boolean mirror of the action
1017
1305
  - `requestedAt`, `reason`, `requestedBy`
1018
1306
  - `queue`: current `pendingCount` / `processingCount` / `parkedCount`
1019
- - The relay waits until the queue is idle before acting; this API is not an interrupt or cancel mechanism.
1307
+ - The relay waits until no turn is processing before acting; this API is not an interrupt or cancel mechanism. While the request is queued no pending message is handed to a worker (a turn started meanwhile would postpone the restart again); pending and parked rows stay in the database and run after the restart.
1020
1308
  - Ownership after an intentional restart follows the argv role, so exactly one supervisor acts on exit 75:
1021
1309
  - `server.js --supervised` exits 75 and relaunches under `.github/extensions/web-relay/server-lifecycle/managed-server.mjs`
1022
1310
  - bare `node server/server.js` keeps its supervisor attached, respawns the `--relay-runtime` child, and keeps the same terminal session alive
@@ -1381,7 +1669,7 @@ service).
1381
1669
  | `cloudflaredTunnel.enabled` | `false` | Legacy alias for mode (`true` => `managed`) |
1382
1670
  | `cloudflaredTunnel.required` | `false` | Pause dequeue when the Cloudflare tunnel is disconnected in managed mode |
1383
1671
  | `cloudflaredTunnel.token` | — | Tunnel token from the router panel (never logged) |
1384
- | `cloudflaredTunnel.binary` | *(auto)* | `cloudflared` path; falls back to the optional npm package, then `PATH` |
1672
+ | `cloudflaredTunnel.binary` | `cloudflared` | Path or command name of the `cloudflared` you installed (OAR ships none); defaults to `cloudflared` on `PATH`. Without one the tunnel stays off and the status says how to install it |
1385
1673
  | `cloudflaredTunnel.extraArgs` | `[]` | Extra arguments appended to `cloudflared tunnel run` |
1386
1674
  | `tunnelMarkerHeaders` | `[]` | Extra edge-injected header names that mark tunnel traffic for the session-worker path guard |
1387
1675
 
@@ -1565,6 +1853,17 @@ created stores an origin with `local: true` that names the conversation it was c
1565
1853
  the user's **Allow** for a conversation's agent to start sessions here is a row of that table
1566
1854
  under the relay id `self` (`remote-relay-repository.mjs`).
1567
1855
 
1856
+ `0011-relay-event-subscriptions.mjs` adds `relay_event_subscriptions` (what each watcher asked for,
1857
+ kept after it ended with `ended_at` / `end_reason`, for a week) and `relay_events` (events per watcher, with
1858
+ `delivered_at` and the message they went into, kept for two hours once delivered). See
1859
+ [Relay event subscriptions](#relay-event-subscriptions).
1860
+
1861
+ `0012-cross-relay-event-subscriptions.mjs` adds the columns both sides of a subscription across
1862
+ relays need (on the watching relay `remote_subscription_id`, `remote_meta_json`, `remote_sync`; on
1863
+ the watched one `subscriber_relay_id`, `subscriber_relay_name`, `subscriber_watcher_id`,
1864
+ `subscriber_watcher_title`, `last_pull_at`, `dropped_count`) and `relay_event_links` (per paired
1865
+ relay the fetch cursor and the link's health). See [Across paired relays](#across-paired-relays).
1866
+
1568
1867
  `0007-claude-cloud.mjs` adds the storage of [Claude Cloud](#claude-cloud-provider) conversations.
1569
1868
  It is additive and idempotent, runs on every boot, and a failure is logged and retried on the next
1570
1869
  one:
@@ -1615,6 +1914,9 @@ working with the corresponding feature inert rather than crashing at startup.
1615
1914
  | `services/claude-cloud-session-service.mjs` | What the relay keeps per cloud conversation: worker reports, the `cloud` payload field, archiving |
1616
1915
  | `services/claude-account-usage-service.mjs` | Live Claude account usage for Check Usage (only while Claude Cloud is on) |
1617
1916
  | `services/git-remote-service.mjs` | GitHub remote and branch of a folder, for the New Chat modal |
1917
+ | `services/relay-event-service.mjs` | Relay event subscriptions: subscribe / transfer, recording, coalescing, the hourly cap and digest, delivery into the watcher's conversation, stalled / all_idle / heartbeat sweeps |
1918
+ | `services/session-activity-tracker.mjs` | What each session is doing: `state`, tasks, task edges and the activity clock |
1919
+ | `../shared/relay-events.mjs` | Relay event types, limits and the text of an event message |
1618
1920
  | `services/remote-relay-loopback.mjs` | The `remote_relay` client for this relay itself (agent sessions): hands the dispatcher's requests to the relay's own HTTP handler in-process instead of opening a connection |
1619
1921
  | `services/claude-session-root-service.mjs` | Resolves the browsable session folder for Claude conversations |
1620
1922
  | `services/context-usage-view.mjs` | Normalizes Copilot and Claude context data into one payload |
@@ -71,6 +71,16 @@ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
71
71
  answer: text('answer'),
72
72
  choices: z.array(z.string()).optional().describe(FIELDS.choices.description),
73
73
  if_busy: choice('if_busy', REMOTE_RELAY_IF_BUSY),
74
+ sessions: z.array(z.string()).optional().describe(FIELDS.sessions.description),
75
+ // Free text like effort: the relay checks the names and says which exist.
76
+ events: z.array(z.string()).optional().describe(FIELDS.events.description),
77
+ once: z.boolean().optional().describe(FIELDS.once.description),
78
+ match: text('match'),
79
+ stalled_minutes: number('stalled_minutes'),
80
+ heartbeat_minutes: number('heartbeat_minutes'),
81
+ subscription_id: text('subscription_id'),
82
+ watcher: text('watcher'),
83
+ from: text('from'),
74
84
  };
75
85
 
76
86
  export function createRemoteRelayToolDefinition({ api, getConversationId = () => '', dbg = () => {} } = {}) {
@@ -38,6 +38,7 @@ import {
38
38
  createClaudeTurnPublisher,
39
39
  isStaleAttemptError,
40
40
  } from './claude-turn-publisher.mjs';
41
+ import { createSubagentTimelineRecorder } from './subagent-timeline-recorder.mjs';
41
42
  import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
42
43
  import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
43
44
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
@@ -49,6 +50,7 @@ import {
49
50
  STEER_STOPPED_TEXT,
50
51
  } from '../../shared/steer-settle-markers.mjs';
51
52
  import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
53
+ import { isRelayEventDelivery } from '../../shared/relay-events.mjs';
52
54
  import {
53
55
  stripSlashCommandGuard,
54
56
  stripSlashCommandGuardContent,
@@ -547,8 +549,12 @@ export function createClaudeSessionRunner({
547
549
  // Whether CLI processes carry the relay's `remote_relay` tool
548
550
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
549
551
  remoteRelayToolGate = null,
552
+ // Records every sub-agent's frames as its timeline, run-scoped (see
553
+ // subagent-timeline-recorder.mjs). Injectable for tests.
554
+ subagentTimelineRecorder = null,
550
555
  dbg = () => {},
551
556
  } = {}) {
557
+ const timelineRecorder = subagentTimelineRecorder || createSubagentTimelineRecorder({ api, conversationId: sdkSessionId, dbg });
552
558
  const publisher = createClaudeTurnPublisher({
553
559
  api,
554
560
  dbg,
@@ -2481,6 +2487,19 @@ export function createClaudeSessionRunner({
2481
2487
  // tree publishes before the row clears (best-effort: the CLI may have
2482
2488
  // already removed the task via background_tasks_changed).
2483
2489
  if (taskId) publishFinalWorkflowDigest(taskId, String(sdkMessage.status || '').trim());
2490
+ // How the task ended, for the relay's task status and the watchers of
2491
+ // this session (the live set only says it is gone). Ambient tasks are
2492
+ // not activity. Advisory: a failed post never disturbs the stream.
2493
+ if (taskId && sdkMessage.ambient !== true) {
2494
+ api('POST', '/api/background-task-settled', {
2495
+ conversationId: sdkSessionId,
2496
+ taskId,
2497
+ toolUseId: String(sdkMessage.tool_use_id || '').trim() || null,
2498
+ status: String(sdkMessage.status || '').trim() || 'completed',
2499
+ summary: String(sdkMessage.summary || '').trim().slice(0, 500) || null,
2500
+ ...(sdkMessage.reason ? { reason: String(sdkMessage.reason).slice(0, 40) } : {}),
2501
+ }).catch((error) => dbg('background task settle publish failed', error?.message || String(error)));
2502
+ }
2484
2503
  // A settled session-level task means the CLI is about to dequeue a
2485
2504
  // continuation turn — the process must not idle out under it. The SDK
2486
2505
  // flags silent notifications explicitly: skip_transcript means no
@@ -2681,6 +2700,9 @@ export function createClaudeSessionRunner({
2681
2700
  }
2682
2701
 
2683
2702
  async function handleSdkMessage(sdkMessage) {
2703
+ // Before any turn takes the frame: a background agent's frames arrive
2704
+ // between turns too, and belong to the run, not to whichever turn is open.
2705
+ timelineRecorder.observe(sdkMessage);
2684
2706
  const usageLimitReport = usageLimit.observe(sdkMessage);
2685
2707
  if (usageLimitReport) publisher.publishUsageLimitReport({ conversationId: sdkSessionId, sdkSessionId, report: usageLimitReport });
2686
2708
  const suppressed = maybeSuppressStopFollowUp(sdkMessage);
@@ -2702,6 +2724,12 @@ export function createClaudeSessionRunner({
2702
2724
  await settling;
2703
2725
  }
2704
2726
  if (!ctx) return;
2727
+ timelineRecorder.claimSpawns(sdkMessage, ctx);
2728
+ // A background agent spawned by an earlier turn: its frames are not this
2729
+ // turn's, and published here they would be filed under it. Its record
2730
+ // goes to its own run through the recorder above.
2731
+ const subagentParent = String(sdkMessage?.parent_tool_use_id || '').trim();
2732
+ if (subagentParent && timelineRecorder.isForeignRun(subagentParent, ctx)) return;
2705
2733
  const actions = ctx.normalizer.normalize(sdkMessage);
2706
2734
  for (const action of actions) {
2707
2735
  // Decided here, while the reports the turn ran under are the latest.
@@ -3201,6 +3229,10 @@ export function createClaudeSessionRunner({
3201
3229
  const wasCurrent = proc === processRef;
3202
3230
  // The next spawn reads the registration decision; refresh it now.
3203
3231
  if (wasCurrent) void remoteRelayGate.refresh();
3232
+ // Agents die with their process: their runs end, what streamed closes.
3233
+ if (wasCurrent) {
3234
+ void timelineRecorder.processEnded().catch((error) => dbg('subagent timeline flush failed', error?.message || String(error)));
3235
+ }
3204
3236
  stopLifecycleTimer(processRef);
3205
3237
  if (processRef.taskPublishTimer) {
3206
3238
  clearTimeout(processRef.taskPublishTimer);
@@ -3913,7 +3945,9 @@ export function createClaudeSessionRunner({
3913
3945
  // noted steer no longer starts with "/" and is pushed as noted. The
3914
3946
  // replay signatures strip the guard too, so expectedText stays the
3915
3947
  // user's own text here as well.
3916
- const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation'
3948
+ // Nor into an event message the relay wrote: the note speaks as the
3949
+ // user, the event must not (shared/relay-events.mjs).
3950
+ const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation' && !isRelayEventDelivery(message)
3917
3951
  ? withSteerNoteContent(content)
3918
3952
  : content);
3919
3953
  procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now(), steered });
@@ -127,6 +127,9 @@ export function createClaudeTurnPublisher({ api, dbg = () => {}, takeWorkflowRun
127
127
  parentSubagentId: payload.parentSubagentId || undefined,
128
128
  displayName: payload.displayName || undefined,
129
129
  status: payload.status,
130
+ ...(payload.subagentType ? { subagentType: payload.subagentType } : {}),
131
+ ...(payload.background ? { background: true } : {}),
132
+ ...(payload.timelineOwner ? { timelineOwner: payload.timelineOwner } : {}),
130
133
  ...attemptFields(message),
131
134
  }).catch(() => {});
132
135
  return;