@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.
- package/CHANGELOG.md +478 -0
- package/README.md +129 -39
- package/bin/bat/oar.bat +46 -46
- package/bin/oar.js +814 -228
- package/package.json +5 -6
- package/server/README.md +306 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
- package/server/claude-worker/claude-session-process.mjs +35 -1
- package/server/claude-worker/claude-turn-publisher.mjs +3 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +31 -4
- package/server/claude-worker/subagent-timeline-recorder.mjs +572 -0
- package/server/copilot-worker/copilot-sdk-session-process.mjs +3 -1
- package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
- package/server/db-schema.mjs +44 -0
- package/server/migrations/0008-message-pins.mjs +85 -0
- package/server/migrations/0009-subagent-timeline.mjs +135 -0
- package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
- package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
- package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
- package/server/public/app/agent-exchange-fold-view.mjs +248 -0
- package/server/public/app/api-client.js +91 -0
- package/server/public/app/archive-confirm.mjs +28 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/attachments-view.js +1024 -131
- package/server/public/app/back-guard.mjs +58 -0
- package/server/public/app/background-tasks-view.mjs +18 -26
- package/server/public/app/bootstrap.js +230 -35
- package/server/public/app/chat-actions-menu-state.mjs +116 -0
- package/server/public/app/claude-cloud-conversation-ui.js +10 -22
- package/server/public/app/composer-control-state.mjs +16 -0
- package/server/public/app/composer-keyboard.mjs +54 -0
- package/server/public/app/conversation-view.js +471 -110
- package/server/public/app/explorer-edit-state.mjs +68 -0
- package/server/public/app/external-link-policy.mjs +4 -1
- package/server/public/app/file-version.mjs +58 -0
- package/server/public/app/host-suspend-ui.js +6 -3
- package/server/public/app/journal-view.js +161 -27
- package/server/public/app/no-conversation-state.mjs +58 -0
- package/server/public/app/pinned-messages-view.mjs +253 -0
- package/server/public/app/provider-readiness.mjs +120 -0
- package/server/public/app/pwa-install.js +1 -1
- package/server/public/app/relay-board-view.js +3 -0
- package/server/public/app/relay-dot-state.mjs +8 -1
- package/server/public/app/relay-event-subscriptions-ui.js +184 -0
- package/server/public/app/relay-event-view.mjs +234 -0
- package/server/public/app/router.js +8 -2
- package/server/public/app/settings-modal.js +53 -1
- package/server/public/app/socket-handlers.js +65 -28
- package/server/public/app/store.js +25 -2
- package/server/public/app/subagent-row.mjs +138 -0
- package/server/public/app/subagent-timeline-view.mjs +186 -0
- package/server/public/app/subagent-viewer-watch.mjs +50 -0
- package/server/public/app/subagent-viewer.mjs +534 -0
- package/server/public/app/task-row-format.mjs +29 -0
- package/server/public/app/thinking-follow.js +4 -0
- package/server/public/app/transcript-jump-bottom-view.mjs +290 -0
- package/server/public/app/transcript-jump-bottom.mjs +99 -0
- package/server/public/app/transcript-reload-freshness.mjs +25 -0
- package/server/public/app/update-ui.js +1 -1
- package/server/public/app/viewer-chat-gallery.mjs +59 -0
- package/server/public/app/viewer-controls-state.mjs +59 -0
- package/server/public/app/viewer-gallery.mjs +46 -0
- package/server/public/app/viewer-gestures.mjs +105 -0
- package/server/public/app/viewer-history.mjs +45 -0
- package/server/public/app/viewer-tap.mjs +112 -0
- package/server/public/index.html +700 -59
- package/server/repositories/message-pin-statements.mjs +39 -0
- package/server/repositories/message-repository.mjs +34 -3
- package/server/repositories/question-repository.mjs +9 -3
- package/server/repositories/remote-relay-repository.mjs +6 -2
- package/server/repositories/subagent-timeline-statements.mjs +170 -0
- package/server/routes/ask-user-routes.mjs +3 -0
- package/server/routes/messages-routes.mjs +548 -111
- package/server/routes/relay-event-routes.mjs +157 -0
- package/server/routes/remote-relay-routes.mjs +15 -0
- package/server/routes/sessions-routes.mjs +164 -24
- package/server/server-runtime.mjs +274 -62
- package/server/services/cloudflared-tunnel-service.mjs +139 -41
- package/server/services/explorer-entry-name.mjs +26 -0
- package/server/services/explorer-mutations.mjs +122 -0
- package/server/services/file-serving.mjs +155 -0
- package/server/services/message-pin-list.mjs +52 -0
- package/server/services/oar-cli-helpers.mjs +139 -4
- package/server/services/oar-cli-menu.mjs +240 -0
- package/server/services/oar-cli-wsl.mjs +129 -0
- package/server/services/preview-proxy-server.mjs +41 -23
- package/server/services/push-dispatch-service.mjs +18 -0
- package/server/services/relay-event-link-service.mjs +289 -0
- package/server/services/relay-event-service.mjs +1935 -0
- package/server/services/relay-shutdown-gate.mjs +31 -0
- package/server/services/remote-relay-caller-context.mjs +52 -7
- package/server/services/remote-relay-dispatcher.mjs +494 -2
- package/server/services/remote-relay-registry-service.mjs +11 -1
- package/server/services/safe-served-content.mjs +61 -11
- package/server/services/session-activity-tracker.mjs +487 -0
- package/server/services/session-history-refresh-service.mjs +31 -0
- package/server/services/stale-attempt-log-limiter.mjs +96 -0
- package/server/services/stale-recovery-clock-guard.mjs +57 -0
- package/server/services/stale-subagent-run-sweep.mjs +109 -0
- package/server/services/subagent-record-retention.mjs +164 -0
- package/server/services/subagent-timeline-service.mjs +328 -0
- package/server/services/subagent-viewer-rooms.mjs +119 -0
- package/server/services/upgrade-socket-guard.mjs +16 -0
- package/shared/relay-events.mjs +371 -0
- package/shared/remote-relay-contract.mjs +157 -5
- package/shared/subagent-record-retention.mjs +31 -0
- 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.
|
|
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.
|
|
54
|
-
"@cursor/sdk": "^1.0.
|
|
55
|
-
"@github/copilot-sdk": "^1.0.
|
|
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
|
|
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
|
|
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
|
|
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` |
|
|
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
|
-
|
|
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;
|