@oar-sh/oar 0.9.9 → 0.9.11

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 (154) hide show
  1. package/CHANGELOG.md +561 -1
  2. package/README.md +162 -909
  3. package/bin/bat/oar.bat +46 -46
  4. package/package.json +14 -14
  5. package/server/README.md +369 -13
  6. package/server/claude-worker/claude-remote-relay-tool.mjs +10 -0
  7. package/server/claude-worker/claude-session-process.mjs +121 -9
  8. package/server/claude-worker/claude-session-worker.mjs +9 -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 +79 -12
  13. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  14. package/server/cursor-worker/cursor-sdk-adapter.mjs +56 -2
  15. package/server/db-schema.mjs +95 -2
  16. package/server/grok-worker/acp-client.mjs +2 -1
  17. package/server/grok-worker/acp-host-services.mjs +4 -3
  18. package/server/migrations/0009-subagent-timeline.mjs +135 -0
  19. package/server/migrations/0010-subagent-timeline-pruning.mjs +89 -0
  20. package/server/migrations/0011-relay-event-subscriptions.mjs +125 -0
  21. package/server/migrations/0012-cross-relay-event-subscriptions.mjs +116 -0
  22. package/server/migrations/0013-previews.mjs +130 -0
  23. package/server/migrations/0014-conversation-groups.mjs +89 -0
  24. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  25. package/server/migrations/0016-share-access-log.mjs +110 -0
  26. package/server/public/app/about-ui.js +73 -0
  27. package/server/public/app/about-view.mjs +234 -0
  28. package/server/public/app/action-confirmations.js +31 -14
  29. package/server/public/app/agent-exchange-fold-view.mjs +187 -0
  30. package/server/public/app/api-client.js +312 -16
  31. package/server/public/app/archive-confirm.mjs +28 -0
  32. package/server/public/app/ask-user-view.js +17 -12
  33. package/server/public/app/attachments-view.js +516 -6
  34. package/server/public/app/background-tasks-view.mjs +18 -26
  35. package/server/public/app/bootstrap.js +336 -59
  36. package/server/public/app/chat-actions-menu-state.mjs +126 -0
  37. package/server/public/app/claude-cloud-conversation-ui.js +10 -22
  38. package/server/public/app/composer-control-state.mjs +16 -0
  39. package/server/public/app/composer-keyboard.mjs +54 -0
  40. package/server/public/app/context-menu.mjs +90 -6
  41. package/server/public/app/conversation-group-drag.mjs +191 -0
  42. package/server/public/app/conversation-group-order.mjs +265 -0
  43. package/server/public/app/conversation-group-state.mjs +100 -0
  44. package/server/public/app/conversation-list-drag.mjs +449 -0
  45. package/server/public/app/conversation-turn-state.mjs +81 -0
  46. package/server/public/app/conversation-view.js +436 -117
  47. package/server/public/app/cwd-picker.js +1 -1
  48. package/server/public/app/explorer-edit-state.mjs +68 -0
  49. package/server/public/app/hidden-messages-view.mjs +312 -0
  50. package/server/public/app/host-suspend-ui.js +13 -4
  51. package/server/public/app/journal-view.js +930 -45
  52. package/server/public/app/no-conversation-state.mjs +58 -0
  53. package/server/public/app/preview-cards.mjs +117 -31
  54. package/server/public/app/preview-command.mjs +10 -1
  55. package/server/public/app/provider-readiness.mjs +120 -0
  56. package/server/public/app/relay-board-view.js +19 -3
  57. package/server/public/app/relay-event-subscriptions-ui.js +184 -0
  58. package/server/public/app/relay-event-view.mjs +234 -0
  59. package/server/public/app/relay-restart-error.mjs +16 -0
  60. package/server/public/app/settings-modal.js +93 -9
  61. package/server/public/app/settings-tabs.js +1 -1
  62. package/server/public/app/share-format.mjs +49 -0
  63. package/server/public/app/share-preview-marks.mjs +167 -0
  64. package/server/public/app/share-state.js +109 -0
  65. package/server/public/app/shared-conversations-settings.js +480 -0
  66. package/server/public/app/socket-handlers.js +137 -40
  67. package/server/public/app/store.js +115 -32
  68. package/server/public/app/subagent-row.mjs +138 -0
  69. package/server/public/app/subagent-timeline-view.mjs +186 -0
  70. package/server/public/app/subagent-viewer-watch.mjs +50 -0
  71. package/server/public/app/subagent-viewer.mjs +558 -0
  72. package/server/public/app/task-row-format.mjs +29 -0
  73. package/server/public/app/transcript-jump-bottom-view.mjs +464 -0
  74. package/server/public/app/transcript-jump-bottom.mjs +210 -0
  75. package/server/public/app/transcript-reload-freshness.mjs +25 -0
  76. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  77. package/server/public/app/update-ui.js +1 -1
  78. package/server/public/app/viewer-history.mjs +5 -3
  79. package/server/public/index.html +774 -12
  80. package/server/relay-tools.md +2 -1
  81. package/server/relay.mjs +2 -1
  82. package/server/repositories/conversation-group-repository.mjs +444 -0
  83. package/server/repositories/message-repository.mjs +30 -3
  84. package/server/repositories/question-repository.mjs +9 -3
  85. package/server/repositories/remote-relay-repository.mjs +6 -2
  86. package/server/repositories/session-repository.mjs +12 -37
  87. package/server/repositories/share-repository.mjs +322 -0
  88. package/server/repositories/share-visibility-statements.mjs +10 -0
  89. package/server/repositories/subagent-timeline-statements.mjs +170 -0
  90. package/server/routes/about-routes.mjs +15 -0
  91. package/server/routes/ask-user-routes.mjs +3 -0
  92. package/server/routes/cache-routes.mjs +4 -0
  93. package/server/routes/conversation-group-routes.mjs +171 -0
  94. package/server/routes/messages-routes.mjs +698 -114
  95. package/server/routes/preview-routes.mjs +9 -1
  96. package/server/routes/relay-event-routes.mjs +157 -0
  97. package/server/routes/remote-relay-routes.mjs +15 -0
  98. package/server/routes/sessions-routes.mjs +843 -193
  99. package/server/server-runtime.mjs +559 -160
  100. package/server/services/about-service.mjs +461 -0
  101. package/server/services/background-task-store.mjs +128 -0
  102. package/server/services/background-work-view.mjs +228 -0
  103. package/server/services/cache-rebuild-service.mjs +30 -0
  104. package/server/services/delete-archive-service.mjs +4 -1
  105. package/server/services/detached-worker-watch-service.mjs +150 -0
  106. package/server/services/explorer-entry-name.mjs +26 -0
  107. package/server/services/explorer-mutations.mjs +122 -0
  108. package/server/services/hidden-message-list.mjs +21 -0
  109. package/server/services/host-suspend-command.mjs +2 -1
  110. package/server/services/message-pin-list.mjs +1 -1
  111. package/server/services/preview-health-probe.mjs +8 -1
  112. package/server/services/preview-proxy-server.mjs +49 -27
  113. package/server/services/preview-registry-service.mjs +676 -26
  114. package/server/services/preview-static-handler.mjs +40 -7
  115. package/server/services/preview-store.mjs +274 -0
  116. package/server/services/push-dispatch-service.mjs +18 -0
  117. package/server/services/rate-limit-service.mjs +12 -1
  118. package/server/services/relay-auth.mjs +183 -0
  119. package/server/services/relay-cli-launcher-service.mjs +3 -1
  120. package/server/services/relay-event-link-service.mjs +289 -0
  121. package/server/services/relay-event-service.mjs +1952 -0
  122. package/server/services/relay-shutdown-gate.mjs +31 -0
  123. package/server/services/remote-relay-caller-context.mjs +52 -7
  124. package/server/services/remote-relay-dispatcher.mjs +551 -7
  125. package/server/services/remote-relay-registry-service.mjs +11 -1
  126. package/server/services/request-boundary.mjs +183 -0
  127. package/server/services/session-activity-tracker.mjs +695 -0
  128. package/server/services/session-history-refresh-service.mjs +7 -0
  129. package/server/services/session-worker-launch-service.mjs +190 -6
  130. package/server/services/session-worker-process-service.mjs +10 -6
  131. package/server/services/share-access-log-service.mjs +123 -0
  132. package/server/services/share-path-redaction.mjs +424 -0
  133. package/server/services/stale-attempt-log-limiter.mjs +96 -0
  134. package/server/services/stale-recovery-clock-guard.mjs +57 -0
  135. package/server/services/stale-subagent-run-sweep.mjs +109 -0
  136. package/server/services/status-event-service.mjs +7 -3
  137. package/server/services/subagent-record-retention.mjs +164 -0
  138. package/server/services/subagent-timeline-service.mjs +328 -0
  139. package/server/services/subagent-viewer-rooms.mjs +119 -0
  140. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  141. package/server/services/upgrade-socket-guard.mjs +16 -0
  142. package/server/services/windows-autostart-service.mjs +6 -1
  143. package/server/services/windows-boot-autostart-service.mjs +207 -23
  144. package/shared/preview-tool-core.mjs +15 -5
  145. package/shared/relay-events.mjs +371 -0
  146. package/shared/remote-relay-contract.mjs +160 -6
  147. package/shared/share-path-privacy.mjs +30 -0
  148. package/shared/subagent-record-retention.mjs +31 -0
  149. package/shared/subagent-timeline.mjs +79 -0
  150. package/shared/windows-system-tools.mjs +106 -0
  151. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  152. package/shared/worker-runtime/heartbeat.mjs +24 -0
  153. package/shared/worker-runtime/kept-posts.mjs +83 -0
  154. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
package/server/README.md CHANGED
@@ -212,12 +212,18 @@ at confirm time). While a suspend is queued every connected client shows a
212
212
  banner with the blockers or the countdown and a **Cancel** button, the menu entry
213
213
  reads **💤 Suspend pending…**, and reopening the modal offers to cancel. A relay
214
214
  restart drops the queued suspend; a push notification (event **Host suspend**)
215
- reports both the actual suspend and such a drop. A queued **🌄 Restart web relay**
215
+ reports both the actual suspend and such a drop. For the first 45 seconds after
216
+ a relay start the check lists one more blocker, "the relay just started,
217
+ sessions are still reporting": the workers that outlived the previous runtime
218
+ may not have reported their background work yet. A queued **🌄 Restart web relay**
216
219
  gets the same banner and can be cancelled from it too.
217
220
 
218
221
  API: `GET /api/host/suspend` (state plus current blockers), `POST /api/host/suspend`,
219
222
  `POST /api/host/suspend/cancel`, and `POST /api/relay/shutdown/cancel`
220
- (localhost-only). Set `OAR_HOST_SUSPEND_DRY_RUN=1` to log instead of sleeping.
223
+ (token only, like the restart itself). Set `OAR_HOST_SUSPEND_DRY_RUN=1` to log instead of sleeping;
224
+ `OAR_BOOT_REPORTING_WINDOW_MS` changes the 45 seconds (0 to 600000; test relays
225
+ pin `0`, which switches the wait off; a negative value is ignored); an override in effect is
226
+ logged at start.
221
227
 
222
228
  You can also hide the **💤 Suspend host** action from **⚙️ Settings** with the
223
229
  **Show Suspend host action** checkbox. This only controls the menu entry; the
@@ -371,6 +377,41 @@ that started them. The live task set is posted to `POST /api/background-tasks` (
371
377
  and broadcast as the `background_tasks` socket event; the composer's task panel renders it and can
372
378
  stop an individual task over a `worker.control` push.
373
379
 
380
+ The relay holds the task sets in memory (`services/background-task-store.mjs`), so a relay restart
381
+ starts without them while the workers and their tasks keep running. The worker is the source of
382
+ truth, and the relay asks:
383
+
384
+ - **Digest and resync.** A worker's heartbeat carries `backgroundTasks: { count, digest }`, a digest
385
+ of its sorted task ids (`shared/worker-runtime/background-task-digest.mjs`). When it differs from
386
+ the set held for that worker (a restart, or a lost post either way round), the heartbeat answer
387
+ says `resyncBackgroundTasks: true` and the worker posts its full set with `resync: true`; the
388
+ request repeats on every beat until the two agree. A task restored this way keeps its start time
389
+ and is not announced as started again. Every post of a set carries `order: { epoch, seq }` (the
390
+ worker process and its count of set posts): two posts can overtake each other on the way, and the
391
+ relay drops one that is older than the newest it applied from that process, or that comes from a
392
+ process other than the one whose heartbeat came last for the session. A drop is logged and
393
+ answered `stale: true` with a `reason`; a worker told so for its newest set asks for a heartbeat,
394
+ on which the relay asks for the set again. `epoch` only names the process and is never compared
395
+ as a time. A task that ended only because a set did not list it comes back, without a second
396
+ start, when a later set lists it, unless a settle notice or its run's status has confirmed the
397
+ end meanwhile. A worker that sends no digest (Cursor, Grok, Claude Cloud, or a worker on older
398
+ code) has no opinion and is never asked.
399
+ - **Placeholder.** A live Claude worker on older code cannot answer, but its heartbeat says whether
400
+ background work holds its process (`steering.autoCompactWindow.backgroundWork`). While this
401
+ runtime has not heard from that worker about its tasks, the session reads `background_only` with
402
+ one task "background work, details not reported since the relay restarted (Kill session in its ⋯
403
+ menu ends it)", the panel shows the
404
+ same row without a Stop button, and a host suspend waits. It is derived on read
405
+ (`services/background-work-view.mjs`), raises no event, and goes when the set arrives, the flag
406
+ goes false or the worker is gone.
407
+ - **Ends.** The Claude worker keeps a settle notice whose post failed (the newest 20, for 10
408
+ minutes) and sends it again after the next heartbeat the relay answers; such a post is given up
409
+ after 10 s without an answer.
410
+ - **Run rows left `running`.** A background sub-agent's row whose end was never written would count
411
+ as running work for a day. For a Claude worker that has reported its task set in this runtime, a
412
+ `running` background row that the set does not list and that nothing has updated for 15 minutes
413
+ is left out of the session state, `all_idle` and the suspend blockers.
414
+
374
415
  Tasks of type `local_workflow` carry an extra `workflowProgress` digest, because the SDK exposes no
375
416
  workflow-progress surface — the worker reads it off disk:
376
417
 
@@ -400,6 +441,69 @@ tree renderer as the live panel, `buildWorkflowRunCard` in `public/app/backgroun
400
441
  survives a reload. A history rebuild deletes a conversation's `workflow_runs` alongside its
401
442
  `subagent_runs`, because both key on message ids the rebuild replaces.
402
443
 
444
+ ### Sub-agent records (the sub-agent viewer)
445
+
446
+ Every sub-agent run keeps its whole record in `subagent_timeline` (migration 0009): one row per
447
+ thought, reply text, tool call (full input), tool result (cut at 4 KB, the original length kept in
448
+ the entry's metadata), child agent or note, numbered per run by `seq` in the order the relay first
449
+ saw it. A writer names each entry with its own `entry_key`; a second write under the same key
450
+ updates the entry in place and keeps its `seq`, which is how a thought or a reply streams. A run
451
+ keeps at most 2000 entries plus one note saying where it was cut (`subagent_runs.truncated`).
452
+ `subagent_runs` also carries the run header the rows show: model, type, task id, tokens, whether it
453
+ runs in the background, and who writes its timeline.
454
+
455
+ - **Claude**: `claude-worker/subagent-timeline-recorder.mjs` reads every SDK message at process
456
+ level, before any turn takes it, and posts entries to `POST /api/subagent-timeline` keyed by the
457
+ run (coalesced every 500 ms; a run the relay does not know yet is retried). A background agent
458
+ outlives the turn that spawned it, so its frames arrive between turns and during later turns;
459
+ they go to its own record, never to a later turn's live lanes. A background launch's receipt
460
+ (`tool_use_result.status: 'async_launched'`) leaves the run running; its `task_notification`
461
+ ends it. Spawns carry `timelineOwner: 'worker'`.
462
+ - **Copilot and Cursor** publish their sub-agents on the live lanes only (`/api/activity`,
463
+ `/api/thought`, `/api/stream` with a `subagentRunId`); the relay copies each frame into the run's
464
+ timeline, uncut. Fields they do not report (model, tokens) stay blank.
465
+ - `POST /api/background-tasks` copies model, type, task id and tokens onto the run whose id is the
466
+ task's `toolUseId`, so the row keeps them after the in-memory task set is cleared.
467
+ - Every `subagent_status` event carries the whole header and the run's own turn (`messageId`). A
468
+ turn that fails closes its foreground runs; background runs are closed by their worker, or by the
469
+ relay when the worker dies.
470
+
471
+ The browser draws one row per top-level run in the reply bubble (`public/app/subagent-row.mjs`, the
472
+ panel's row grammar); a row's title, like a sub-agent task's title in the composer panel, opens
473
+ `public/app/subagent-viewer.mjs`. The viewer reads `GET /api/conversation/:id/subagent/:runId`, grows
474
+ with the `subagent_timeline` event, and fetches again from the last contiguous `seq` after a missed
475
+ event or a reconnect. A run recorded before the timeline existed is served from its tagged
476
+ activity, thought and stream rows (`legacy: true`).
477
+
478
+ **Live delivery.** `subagent_timeline` goes only to the pages whose viewer shows that run. The viewer
479
+ names its run with the socket message `subagent_viewer_watch { conversationId, subagentRunId }`
480
+ (acknowledged with `{ ok: true }` or `{ ok: false, code }`: `missing-id`, `not-found`,
481
+ `conversation-mismatch`) when it opens, moves to a child or its parent, and after every reconnect,
482
+ and sends `subagent_viewer_unwatch` when it closes. The relay checks the run exists and belongs to
483
+ the conversation and keeps each socket in at most one room, `subagent-viewer:<runId>`
484
+ (`services/subagent-viewer-rooms.mjs`). Batches go to that room as volatile packets, so they are
485
+ never kept for a reconnect replay and a backed-up connection drops them. socket.io also drops a
486
+ volatile packet while the connection is still writing an earlier one, even from the same tick
487
+ (a worker post that changes the header emits `subagent_status` first), so the relay holds batches
488
+ one tick, merged per run, before sending. The viewer covers what is dropped: it waits for the
489
+ watch's answer before its first read, fetches on a `seq` hole, after a reconnect, and once more when
490
+ the run ends. A refused `not-found` watch is sent again on the run's next `subagent_status`. The lane
491
+ events (`relay_activity`, `relay_thought`, `relay_stream`, `subagent_status`) go to every page as
492
+ before.
493
+
494
+ **Retention.** After `subagent_record_retention_days` (app setting, `0` = keep for good, otherwise 7,
495
+ 14, 30 or 90; default 90; Settings → General → *Keep sub-agent tool output*) the relay strips a
496
+ finished run's tool entries: a `tool_result` keeps its row with empty text, a `tool_call` keeps its
497
+ one-line summary (`subagentToolCallSummary` in `shared/subagent-timeline.mjs`, the viewer's rule) in
498
+ place of the input. Both get `pruned_at` (migration 0010) and metadata
499
+ `{ pruned: true, prunedAt, originalChars, retentionDays }`; the viewer shows them as "Input removed
500
+ after N days" / "output removed after N days". Thoughts, reply text, notes, child rows and the runs
501
+ stay, and the age is the entry's `updated_at`; runs still `running` (or `pending`, `processing`) are
502
+ never touched, nor are the lane rows. `services/subagent-record-retention.mjs` sweeps 30 s after
503
+ start, once a day and 5 s after the setting changes, 2000 rows per transaction with the event loop
504
+ free in between, through a partial index over the unstripped tool entries, and logs one
505
+ `SUBAGENT RETENTION …` line. No VACUUM: SQLite reuses the freed pages.
506
+
403
507
  ### Context usage
404
508
 
405
509
  Claude sessions have no Copilot `events.jsonl` to tail. Instead the worker reads the live
@@ -431,6 +535,20 @@ On Windows it opens a `Claude Worker <id>` terminal window.
431
535
  turns. Those replies carry no usage line rather than displaying Copilot premium-request numbers under
432
536
  a reply that never touched Copilot.
433
537
 
538
+ ### Plan usage for agents
539
+
540
+ `remote_relay`'s `usage {relay}` (permission `read`, like `relay_info`; this relay too, as `this`)
541
+ reads `GET /api/usage` and `GET /api/usage-limit` on the target and answers per provider card
542
+ (Copilot, Claude, Claude Cloud, Cursor, Grok): `status` (`not-configured` and `unavailable` say why
543
+ in `message`), `plan`, `source`, `capturedAt`, the meters (`used`, `allowance`, `remaining`,
544
+ `utilization`, `unit`, `resetAt`, `severity`) and the card's detail rows and notes; plus
545
+ `usageLimit` (`account`: the Claude window, its utilization, reset and overage; `pauses`: the turns
546
+ held at a usage limit, by session). The dispatcher copies an allow-list of fields
547
+ (`usageCard` in `services/remote-relay-dispatcher.mjs`), so a field the usage report gains later is
548
+ not passed on; account labels and links are left out, and an e-mail address or a token-shaped
549
+ string in a text is masked. A relay without `/api/usage` per provider answers
550
+ `REMOTE_RELAY_UNSUPPORTED`; one without `/api/usage-limit` answers `usageLimit: null` with a note.
551
+
434
552
  ## Claude Cloud provider
435
553
 
436
554
  Provider type `claude-cloud`: a provider of its own, never an alias of `claude`. Its conversations
@@ -440,7 +558,7 @@ conversation that speaks the usual worker contracts (WebSocket link, heartbeat,
440
558
  crash guard) but runs no agent: it sends the user's messages to the cloud session and follows the
441
559
  session's event stream (`claude-cloud-session-process.mjs`, `claude-cloud-event-normalizer.mjs`).
442
560
  The user-facing description, including what the provider does with the Claude CLI's login, is in
443
- the [README](../README.md#claude-cloud); the tracker is
561
+ the [providers guide](../docs/providers.md#claude-cloud); the tracker is
444
562
  [docs/feature-tracker/claude-cloud.md](../docs/feature-tracker/claude-cloud.md).
445
563
 
446
564
  ### The cloud client and the login
@@ -630,6 +748,189 @@ turn finishing and delivery — leaving a permanent spinner. `activeTurn: false`
630
748
  client's optimistic state. Relay shutdown also waits 300 ms before disconnecting sockets so a terminal
631
749
  `message_status` emitted just before an idle-deferred restart still reaches the browser.
632
750
 
751
+ ## Relay event subscriptions
752
+
753
+ A session (the **watcher**) can ask to be told what happens in other sessions of this relay or of a
754
+ paired relay, through `remote_relay`. The relay tells it by writing a short event message into the
755
+ watcher's conversation; nothing reads transcripts on disk, and it works for every provider.
756
+
757
+ **Actions** (`relay` may be left out and means `this`; on this relay the agent-sessions setting must
758
+ be on, as for every local action; a paired relay: see [Across paired relays](#across-paired-relays)):
759
+
760
+ - `subscribe {sessions: [ids] | ["all"], events?, once?, match?, stalled_minutes?, heartbeat_minutes?}`
761
+ — `all` watches every session, ones created later too. The watcher's own conversation is never
762
+ watched. The first subscribe of a conversation shows the user an approval card naming the
763
+ sessions; a subscription to `all` is always named on a card (asked again after an Allow that did
764
+ not cover it). The Allow is remembered as unlock rows `self:watch` / `self:watch-all`.
765
+ - `unsubscribe {subscription_id | "all"}`, `list_subscriptions {watcher?}` (another conversation's
766
+ subscriptions, to inspect before a transfer).
767
+ - `transfer_subscriptions {from}` — moves every active subscription of another conversation, with
768
+ its settings and its undelivered events, to the caller: a watcher is replaced without a moment
769
+ where events go nowhere. Always an approval card (from where, how many, whether `all` is among
770
+ them), also after the caller's first-subscribe Allow. The new watcher is dropped from moved
771
+ session lists.
772
+
773
+ **Events** (`shared/relay-events.mjs`): `question_asked` (prompt, options, question id; an approval
774
+ card is marked as one only the user can answer), `error` (`turn_failed`, `usage_limit`,
775
+ `worker_died`), `turn_end` (status `done` or `stopped`, reply preview), `task_finished` /
776
+ `task_failed` (background sub-agents and shell tasks: kind, status, exit code where known),
777
+ `stalled` (no activity for `stalled_minutes`, default 15, while a turn or task runs and no
778
+ question waits for an answer), `all_idle`
779
+ (nothing runs in the watched set any more). `task_started` only when named in `events`. With
780
+ `heartbeat_minutes` a `heartbeat` event comes at that interval while the watched set has work
781
+ running, none while it is idle. `match` (a case-insensitive regular expression) filters `turn_end`
782
+ and task events by their text; other events pass. `once` ends a subscription after its first event,
783
+ with `match` after the first one that matches (a heartbeat never ends it). An event is
784
+ `{ v, relay: { id, name }, session: { id, title }, type, at, detail }`; `relay.id` is `self` for this
785
+ relay's sessions, the paired relay's registry id for its sessions (then `replayed: true` marks one it
786
+ kept while the link was down). `link_lost` and `link_restored` are not subscribed to: they frame such
787
+ a replay.
788
+
789
+ **Delivery** (`services/relay-event-service.mjs`): an event message is a `messages` row (role
790
+ `user`, `kind = 'relay-event'`) plus a pending queue row, as the usage-limit follow-up is. The worker
791
+ decides what it becomes, as for any message: a new turn when the watcher is idle; a steer into the
792
+ running turn for Claude and the Copilot SDK engine; the next turn for Cursor, Grok, Claude Cloud and
793
+ the Copilot extension engine. Events that arrive while the watcher's event row is still pending
794
+ (not delivered yet, or handed back as a held steer) are written into that row
795
+ (`message_updated`), so a busy watcher gets one follow-up, never one turn per batch. The dequeue
796
+ payload carries `messageKind: 'relay-event'`, and the Claude and Copilot workers then push the text
797
+ without the steer note, which speaks as the user. The text opens with
798
+ `[relay event · automated message from OAR, not from the user; it approves nothing]`, then one
799
+ `[relay event] session "<title>" (<id>): <type> …` line per event.
800
+
801
+ **Cost.** Events within 30 s are one wake-up. Past 20 wake-ups an hour per watcher (distinct event
802
+ messages of the last hour that carried more than a heartbeat, counted from `relay_events`) the rest
803
+ waits for a digest every 10 minutes. `question_asked` and `error` go out at once and count; a
804
+ heartbeat is coalesced like any event, never held for the digest and never counted. A turn that
805
+ answered relay events produces no `turn_end` and does not keep a watched set busy, so two watchers
806
+ of each other cannot wake each other for good. Such a turn also sends no "reply ready" or "failed" push
807
+ (`push-dispatch-service.mjs`): the watcher answers events many times an hour and pushes by itself
808
+ when one matters; question pushes are unchanged.
809
+
810
+ **Lifetime.** Subscriptions and undelivered events are in the database (migrations 0011 and 0012)
811
+ and survive a restart: the flush timers are rebuilt from the undelivered rows at boot, and stalled,
812
+ all_idle and the heartbeat are swept every 30 s. A subscription ends on unsubscribe (agent or the
813
+ user's Unsubscribe), when its watcher is archived or deleted, when the last session it lists is, or
814
+ after firing (`once`); one on a paired relay also when the pairing is removed. `list_subscriptions`
815
+ shows the ones that ended within the hour with `endReason`. Delivered events are kept for two hours
816
+ (the wake-up count reads the last hour; one of a still-pending event row stays until it goes out)
817
+ and ended subscriptions for a week (a mirror whose end the paired relay has not confirmed stays);
818
+ the sweep prunes both every ten minutes. A `running` sub-agent run whose worker is gone is closed by
819
+ `services/stale-subagent-run-sweep.mjs` (a minute after boot, then every ten minutes), and one not
820
+ touched for a day is not counted as running meanwhile.
821
+
822
+ **Hook points**: `/api/response` finalize (turn_end, or error at the usage limit), `failQueueMessage`
823
+ (error; a Stop is turn_end `stopped`), `failRecoveredRowTerminally`, `recoverDeadWorkerProcessingRows`
824
+ and the background-only worker death (worker_died), `/api/relay-question` and the approval card's
825
+ notify (question_asked), the activity tracker's task edges, and the archive and delete routes.
826
+
827
+ ### Across paired relays
828
+
829
+ The watching relay (**A**, it holds the watcher) and the watched one (**B**) each keep a row of the
830
+ subscription. Only A calls, over its pairing: a pairing in one direction is enough.
831
+
832
+ - **Gate.** The four actions with `relay: <paired relay>` pass the same checks as any `remote_relay`
833
+ call there: the relay unlocked in the watcher's conversation (the user named it, or its agent wrote
834
+ there), A's permission for it at least `read`, and B's inbound switch on (B refuses with
835
+ `REMOTE_INBOUND_DISABLED`). `subscribe` first asks B with a dry run (sessions, limits, switch,
836
+ version), then shows the watch card on A naming the relay (and `all`), remembered as
837
+ `<registry id>:watch` / `:watch-all` and forgotten with the pairing, then subscribes on B and stores
838
+ the mirror on A. No card on B. A relay that predates these routes answers Express's plain 404,
839
+ which A reports as `REMOTE_RELAY_UNSUPPORTED` ("runs an OAR version without event subscriptions
840
+ across relays").
841
+ - **B's side** (`relay-event-routes.mjs`): `POST /api/relay-events/remote/subscribe` and
842
+ `POST /api/relay-events/remote/pull` take only another relay's request (the `X-OAR-Remote-Origin`
843
+ header names it; this relay's own agents and the browser get `403`) and only while the inbound
844
+ switch is on. The subscription is an ordinary row whose watcher is the key
845
+ `remote:<that relay's id>`, which names no conversation, so nothing a paired relay asks for is ever
846
+ written into a conversation of B's; its events are recorded, swept (stalled, all_idle, heartbeat)
847
+ and filtered like any, and wait for the fetch. Retention: per subscription the newest 500 undelivered
848
+ events (questions and errors kept longest), none older than 7 days; a subscription not fetched for
849
+ 7 days ends (`subscriber-gone`). What was dropped is counted and reported once.
850
+ - **The fetch** (`services/relay-event-link-service.mjs` on A): every 5 s while A holds active
851
+ subscriptions on B or an end B has not confirmed, every 30 s after a minute without an answer,
852
+ nothing once none are left. The body is `{ after, subscriptions: [{ id, watcher }], ended: [ids] }`:
853
+ B deletes what A stored up to `after`, applies the ends, updates the watcher names, and answers
854
+ `{ events, cursor, more, subscriptions: [state], ended, dropped }` (at most 200 events a page).
855
+ A writes the events into its watchers' undelivered events (only for subscriptions it holds, into the
856
+ watcher it stored), ends or updates its mirrors from the states, clears confirmed ends and moves its
857
+ cursor in one transaction, so a restart of either relay loses or repeats nothing. From there
858
+ delivery is the same as for A's own sessions: coalescing, the cap and digest, the steer, the loop
859
+ guard (an event turn on either relay is no `turn_end` for the other).
860
+ - **Gaps.** A link without an answer for 60 s (failing fetches, or A down) is lost from its last
861
+ answer on. The first fetch after that, every page of it, is held and written as one urgent row per
862
+ watcher with subscriptions there: `link_lost` (no answer since …), `Replayed: N events …` with
863
+ questions and errors first, `link_restored` (after how long, how many kept, how many dropped, which
864
+ subscriptions ended there meanwhile). A shorter outage only delays. Nothing wakes the watcher when
865
+ the link drops; `list_subscriptions` shows each paired subscription's `link` (`ok`, `failing`,
866
+ `lost`), and a `heartbeat_minutes` subscription notices the silence.
867
+ - **Ends.** Unsubscribe and an archived watcher end the mirror at once and mark it until B confirms
868
+ (`list_subscriptions` shows `endPending`); `unsubscribe` waits up to 10 s for that and says so when B
869
+ did not answer. An archived session on B, or B's user ending the subscription there, ends the mirror
870
+ with the next fetch (`archived`, `ended-on-relay`). Removing the pairing sends one last fetch with
871
+ every subscription as ended, then ends the mirrors (`pairing-removed`).
872
+ - `unsubscribe "all"` and `transfer_subscriptions` act on the named relay's subscriptions; with `this`,
873
+ `list_subscriptions` lists all of the watcher's (each with `relay`) and `transfer_subscriptions`
874
+ moves all, its card naming the paired relays among them. A transfer reaches B as the new watcher's
875
+ name with the next fetch.
876
+
877
+ `/api/remote-relays/tool` refuses a call that carries another relay's origin: a paired relay never
878
+ acts, or subscribes, for one of this relay's conversations.
879
+
880
+ ### Session state and tasks
881
+
882
+ `services/session-activity-tracker.mjs` knows what each session is doing, from the
883
+ `/api/background-tasks` sets (a new task id is a start, a task gone from the set has ended, a
884
+ changed one is activity), `POST /api/background-task-settled` (how a task ended; the Claude worker
885
+ posts it from `task_notification`), the sub-agent run statuses and the activity, thought, stream and
886
+ timeline posts (the activity clock). A task that leaves the set without word ends after 5 s with
887
+ its run's status, else `completed` with `statusReported: false`; an exit code is read from the task
888
+ summary when it names one (the SDK has no field for it). Memory only: a restart forgets the recently
889
+ finished tasks and starts the clock at boot. Running tasks come back when the workers send their
890
+ sets again (see [Background tasks and workflow progress](#background-tasks-and-workflow-progress)):
891
+ a task that started before this runtime did is not announced as started a second time, and the end
892
+ of a task this runtime never knew is still an event when the settle notice describes the task
893
+ (`description`, `taskType`, `startedAt`). `all_idle` waits out the first 45 seconds after a relay
894
+ start, like the suspend check.
895
+
896
+ `GET /api/conversations` and `GET /api/conversation/:id` carry `sessionState` (`running_turn`: a
897
+ turn processing, or queued and not held by a usage-limit pause; `background_only`: tasks running;
898
+ `idle`), `sessionTasks` (running tasks, then up to five that ended within the hour: `kind`
899
+ `subagent` | `shell`, `description`, `startedAt`, `lastActivityAt`, `status` `running` |
900
+ `completed` | `failed` | `stopped`, `exitCode`, `summary`, `endedAt`) and `lastActivityAt`.
901
+ `remote_relay`'s `list_sessions` and `read_session` pass them on as `state`, `tasks` and
902
+ `lastActivityAt`; `active` stays and is true for background work too.
903
+
904
+ ### Browser
905
+
906
+ An event message renders as a compact row (`relay-event-view.mjs`) folded to its summary
907
+ (`2 events · question, reply`); a row with a question or an error starts open, a replay carries a
908
+ **replayed** tag, and the user's fold is kept across re-renders. A watcher's header shows **👁 watching
909
+ N sessions** (or all; sessions on paired relays count, the chip's title names those relays), which
910
+ opens its subscriptions with an **Unsubscribe** button each (`lab-relay · Report builder`); a watched
911
+ session shows a **watched** badge in its header and, when watched by id, in the sidebar (a
912
+ subscription to all would badge every row; one on a paired relay badges none of this relay's
913
+ sessions). On the watched relay the badge names a paired relay's watcher with its relay.
914
+ The list comes from `GET /api/relay-events/subscriptions` and the `relay_event_subscriptions`
915
+ socket event (the whole active list after every change).
916
+
917
+ ### Folded agent exchanges
918
+
919
+ A prompt another agent sent the session (a paired relay's, or one of this relay's own: a user row
920
+ with `origin`) folds into one row, `note from <relay> · <session> · answered below` (`working…`,
921
+ `failed`, `stopped`, `no reply yet`), which opens to the prompt as it renders otherwise.
922
+ Presentation only (`agent-exchange-fold-view.mjs` decides, `syncAgentExchangeFolds` in
923
+ `conversation-view.js` applies it as one more idempotent pass after every render and update, beside
924
+ the separator and steering passes); the transcript data is unchanged. Nothing addressed to the user
925
+ folds away: the fold covers the agent's prompt alone. The session's reply to it can hold a line for
926
+ the user, so the reply, the live bubble of the running turn and the turn's question, approval and
927
+ plan cards always show, as for a message of the user's own; they are read for the row's status
928
+ only. One prompt is left unfolded: an agent's prompt that was itself steered into a running turn,
929
+ which renders as part of that turn's flow. A folded prompt shows open while its turn is `working…`
930
+ and closes by itself once it is replied, failed or stopped (`agentExchangeFoldOpen`); the user's
931
+ own toggle, kept by prompt id, always wins over that, and a jump to the prompt (search, pins, image
932
+ parent) opens it.
933
+
633
934
  ## Image conversations (OpenAI BYOK)
634
935
 
635
936
  Choosing **OpenAI Image (BYOK)** in New Chat creates a conversation whose replies are generated
@@ -657,6 +958,24 @@ images, calling the OpenAI Images API directly rather than going through a CLI w
657
958
  - The control appears on message hover; already-hidden messages keep their label visible so the state
658
959
  is never silently lost.
659
960
  - `POST /api/shared/:token/presence` tracks shared-viewer presence, surfaced as a watcher count.
961
+ `sharedPresenceMaxPerConversation` and `sharedPresenceMaxGlobal` cap how many viewers that count
962
+ tracks; they do not limit who can read a link.
963
+ - A revoked or renewed token answers a plain 404 on every shared route, the `/shared/:token` page
964
+ included, exactly like a token that never existed. An open shared page stops at its next poll.
965
+ - Settings → Sharing lists the active shares (`GET /api/shares`) with **Copy link**, **Renew link**
966
+ and **Stop sharing**, and each share's access log. Renewing adds a token to the share's lineage
967
+ (`conversation_shares.lineage_id`, migration 0016) and revokes the old one, so the log, keyed to
968
+ the lineage, carries over. Stopping a share or deleting the conversation deletes its log; nothing
969
+ else prunes it.
970
+ - The access log (`conversation_share_opens`) has one row per open, not per poll: the shared page's
971
+ first fetch carries `open=1` and counts unless the same viewer opened it in the last 10 s; a
972
+ fetch without the flag counts only when that viewer has not fetched the share for 30 minutes
973
+ (a page back from the background, a script). A row keeps the time, the IP address (unless
974
+ **Store viewer IP addresses** is off), the user agent, the country from `cf-ipcountry` (only when
975
+ the request came through a proxy the `trustProxy` setting trusts) and a viewer key: an HMAC of
976
+ address and user agent under a per-relay secret, which counts distinct viewers without the
977
+ address. The address is the one Express derives through `trustProxy`; a forwarded header from an
978
+ untrusted peer is ignored.
660
979
 
661
980
  ## Pinned messages
662
981
 
@@ -715,7 +1034,7 @@ When a newer version is available, Settings shows a dismissible-per-version
715
1034
  card (not dismissible if the release is marked critical — it still never
716
1035
  auto-applies). On npm-global installs, **Update** runs
717
1036
  `npm i -g @oar-sh/oar@<version>` with the log streamed into the card, then the
718
- relay restarts once the message queue is idle. After the restart the relay
1037
+ relay restarts once the running turns finish (queued messages wait for it). After the restart the relay
719
1038
  verifies the running version actually changed; if not, the npm log is surfaced
720
1039
  as a failure. Git checkouts are detect-only — the card shows a "pull to
721
1040
  update" hint instead of the button. `oar update [--beta] [--to X.Y.Z]` does
@@ -934,6 +1253,12 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
934
1253
  | 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 |
935
1254
  | 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`) |
936
1255
  | 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 |
1256
+ | 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 |
1257
+ | POST | `/api/repo/rename` | Rename a workspace entry in place: `{ path, newName, conversationId? }`. The root cannot be renamed; symlinks are refused. Answers `{ ok, path }` |
1258
+ | 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 |
1259
+ | 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 |
1260
+ | POST | `/api/drives/rename` | As `/api/repo/rename` for an absolute path: `{ path, newName }`. `/` and drive roots are refused |
1261
+ | POST | `/api/drives/delete` | As `/api/repo/delete` for an absolute path: `{ path, dryRun? }`. `/` and drive roots are refused |
937
1262
  | GET | `/api/drives/file` | Stream a file by path — Windows drive path or Linux absolute path depending on server platform |
938
1263
  | GET | `/api/drives/files-preview` | Return structured preview JSON for a file — Windows drive path or Linux absolute path depending on server platform |
939
1264
  | 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 |
@@ -959,14 +1284,15 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
959
1284
  | POST | `/api/stream` | (CLI) Push in-flight assistant text stream for current pending message |
960
1285
  | POST | `/api/relay/pause` | Pause dequeueing and drop currently queued messages |
961
1286
  | POST | `/api/relay/resume` | Resume dequeueing after pause |
962
- | POST | `/api/relay/shutdown` | Queue a localhost-only authenticated relay shutdown or self-restart |
1287
+ | POST | `/api/relay/shutdown` | Queue an authenticated relay shutdown or self-restart |
963
1288
  | GET | `/api/relay-questions` | (CLI/UI) List relay questions by `status` (for example `pending` or `answered`) |
964
1289
  | GET | `/api/relay-question/:id` | (CLI) Fetch a single relay question |
965
1290
  | POST | `/api/relay-question` | (CLI) Create a relay question for the browser |
966
1291
  | POST | `/api/relay-question/:id/answer` | (UI) Submit an answer for a relay question |
967
1292
  | POST | `/api/relay-question/:id/timeout` | (CLI/UI) Mark a relay question timed out |
968
- | POST | `/api/heartbeat` | (CLI) Keep CLI status alive |
1293
+ | POST | `/api/heartbeat` | (CLI) Keep CLI status alive. A session worker may send `backgroundTasks: { count, digest }`; the answer carries `resyncBackgroundTasks: true` when the relay's set for that worker differs |
969
1294
  | GET | `/api/status` | Overall status |
1295
+ | GET | `/api/about` | The About OAR modal: `version`, `commit` (the checkout's HEAD read from `.git`, else a `gitHead` stamp in package.json, else `null`), `startedAt` and `uptimeSeconds` of the relay process; `providers[]` (enabled ones only: `cliVersion` from the CLI probe cache, never a new probe, `sdkVersion` from node_modules, `state` `online`/`offline` for the relay CLI that runs Copilot's extension engine and OpenAI, `online`/`idle` with `runningWorkers` for the others); `stats` (conversations, messages, turns = replies, `turnsByProvider`, `firstConversationAt`); `usage` (only what is stored: Copilot premium requests, Cursor and Grok tokens and cost, Claude Cloud cost); `relay` (`dbBytes` = database + WAL, `runningWorkers`); `update` (latest version only while the opt-in check is on). The totals are read in rowid ranges of 10 000 messages with the event loop free in between, and kept for 60 s |
970
1296
  | GET | `/api/models` | Live/cached model catalog used by the UI picker |
971
1297
  | POST | `/api/models/snapshot` | (CLI/relay) Publish discovered model snapshot |
972
1298
  | POST | `/api/conversation/:id/refresh-history` | Re-import an existing conversation's history through the local Copilot SDK |
@@ -983,18 +1309,27 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
983
1309
  | POST | `/api/settings/cursor` | Set/remove the Cursor API key, default model, or enabled model subset; key changes reset discovered models and effort tiers |
984
1310
  | GET | `/api/settings/turn-ceiling` | Read the max turn duration plus slider bounds (`minMinutes`, `maxMinutes`, `stepMinutes`, `defaultMinutes`) |
985
1311
  | POST | `/api/settings/turn-ceiling` | Set the max turn duration in minutes (`0` = no limit) |
1312
+ | 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 |
1313
+ | POST | `/api/settings/subagent-record-retention` | `{ retentionDays }` → the GET body; anything but one of `choices` is `400`. Runs a retention sweep 5 s later |
986
1314
  | POST | `/api/claude-native-session` | (Claude worker) Persist the native Agent SDK session id for resume |
987
1315
  | 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 |
988
1316
  | 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` |
989
1317
  | 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 |
990
1318
  | 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 |
991
- | POST | `/api/subagent-run` | (Worker) Register/update a subagent run for the active turn |
1319
+ | 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 |
1320
+ | 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`) |
1321
+ | 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 |
992
1322
  | POST | `/api/conversation/:conversationId/subagent/:subagentRunId/cancel` | Request cancellation of one subagent run (unsupported by the Claude runtime) |
993
1323
  | PATCH | `/api/conversation/:id/message/:messageId/share-visibility` | Hide or unhide a single message from shared views |
994
1324
  | 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` |
995
- | POST | `/api/conversation/:id/share` | Create or update the conversation's read-only share token |
996
- | GET | `/api/shared/:token` | Read-only shared conversation view (hidden messages filtered out) |
997
- | POST | `/api/shared/:token/presence` | Report shared-viewer presence for the watcher count |
1325
+ | POST | `/api/conversation/:id/share` | The conversation's read-only share link, made if it has none (one active share per conversation): `{ token, shareUrl, path, created }`; a new one sends `share_created` |
1326
+ | POST | `/api/conversation/:id/share/renew` | A new token for the active share; the old one answers 404 from then on and the access log carries over. Sends `share_renewed`; 404 without an active share |
1327
+ | DELETE | `/api/conversation/:id/share` | Stop sharing: revokes every token of the conversation and deletes its access log → `{ revokedAt, revokedCount, opensDeleted }`. Sends `share_revoked`; 404 without an active share |
1328
+ | GET | `/api/shares` | Every active share: `{ shares: [{ conversationId, title, archived, token, shareUrl, path, sharedSince, renewedAt, lastOpenedAt, openCount, viewerCount }], storeViewerIp, accessLog }` |
1329
+ | GET | `/api/conversation/:id/share/opens` | The active share's access log, newest first: `?limit=<n>` (default 200, max 1000) → `{ opens: [{ openedAt, ip, country, userAgent, viewer }], total, limit }` |
1330
+ | GET/POST | `/api/settings/shares` | `{ storeViewerIp }`: whether new access log entries (and the status events of shared reads) keep the viewer's IP address; on by default. A change sends `share_settings_changed` |
1331
+ | GET | `/api/shared/:token` | (No auth) Read-only shared conversation view (hidden messages filtered out). `?open=1` marks the page's first fetch, which the access log counts as an open |
1332
+ | POST | `/api/shared/:token/presence` | (No auth) Report shared-viewer presence for the watcher count |
998
1333
  | POST | `/api/openai/images/generate` | Generate images for an OpenAI Image conversation |
999
1334
  | POST | `/api/image-operations/:operationId/execute` | Execute a queued image generation/edit operation |
1000
1335
  | GET | `/api/generated-image/:conversationId/:messageId/:imageId/content` | Stream a generated image blob |
@@ -1007,6 +1342,11 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1007
1342
  | POST | `/api/remote-relays/pair` | (Relay to relay) Another relay introduces itself after the user paired it there |
1008
1343
  | 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` |
1009
1344
  | 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 |
1345
+ | GET | `/api/relay-events/subscriptions` | Every active [relay event subscription](#relay-event-subscriptions) (`{ available, subscriptions }`); refused to other relays' agents |
1346
+ | 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 |
1347
+ | 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) |
1348
+ | 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 }` |
1349
+ | POST | `/api/background-task-settled` | (Worker) How a background task ended: `{ conversationId, taskId, toolUseId?, status, summary?, reason?, description?, taskType?, startedAt? }` (Claude worker, from `task_notification`; the last three describe a task that was in its published set) |
1010
1350
  | GET | `/api/remote-relays/inflight` | (Worker) Number of `remote_relay` calls running for `conversationId` |
1011
1351
  | GET/POST | `/api/settings/remote-relays` | This relay's `publicUrl` and `inboundEnabled` (accept prompts from other relays' agents) |
1012
1352
  | 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 |
@@ -1014,7 +1354,8 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1014
1354
 
1015
1355
  ### Manual relay shutdown / self-restart
1016
1356
 
1017
- - `POST /api/relay/shutdown` is localhost-only even when the relay is otherwise reachable on the network.
1357
+ - `POST /api/relay/shutdown`, `POST /api/relay/shutdown/cancel` and `POST /api/queue/empty` need the
1358
+ token and nothing else: the browser app calls them from a phone, over a tunnel and over the LAN.
1018
1359
  - The request body accepts:
1019
1360
  - `reason` (optional string)
1020
1361
  - `requestedBy` (optional string)
@@ -1035,7 +1376,7 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1035
1376
  - `restart`: boolean mirror of the action
1036
1377
  - `requestedAt`, `reason`, `requestedBy`
1037
1378
  - `queue`: current `pendingCount` / `processingCount` / `parkedCount`
1038
- - The relay waits until the queue is idle before acting; this API is not an interrupt or cancel mechanism.
1379
+ - 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.
1039
1380
  - Ownership after an intentional restart follows the argv role, so exactly one supervisor acts on exit 75:
1040
1381
  - `server.js --supervised` exits 75 and relaunches under `.github/extensions/web-relay/server-lifecycle/managed-server.mjs`
1041
1382
  - bare `node server/server.js` keeps its supervisor attached, respawns the `--relay-runtime` child, and keeps the same terminal session alive
@@ -1402,7 +1743,8 @@ service).
1402
1743
  | `cloudflaredTunnel.token` | — | Tunnel token from the router panel (never logged) |
1403
1744
  | `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 |
1404
1745
  | `cloudflaredTunnel.extraArgs` | `[]` | Extra arguments appended to `cloudflared tunnel run` |
1405
- | `tunnelMarkerHeaders` | `[]` | Extra edge-injected header names that mark tunnel traffic for the session-worker path guard |
1746
+ | `tunnelMarkerHeaders` | `[]` | Extra edge-injected header names that mark tunnel traffic; merged with `COPILOT_TUNNEL_MARKER_HEADERS` and `cf-ray` (an empty list does not switch the env value off). A request carrying one is never "from this machine" (session-worker path guard, image-operation run, sign-in lockout exemption) |
1747
+ | `allowedOrigins` | `[]` | Extra browser origins the Socket.IO channel accepts besides the relay's own origin |
1406
1748
 
1407
1749
  ### SDK auto-detection behavior
1408
1750
 
@@ -1584,6 +1926,17 @@ created stores an origin with `local: true` that names the conversation it was c
1584
1926
  the user's **Allow** for a conversation's agent to start sessions here is a row of that table
1585
1927
  under the relay id `self` (`remote-relay-repository.mjs`).
1586
1928
 
1929
+ `0011-relay-event-subscriptions.mjs` adds `relay_event_subscriptions` (what each watcher asked for,
1930
+ kept after it ended with `ended_at` / `end_reason`, for a week) and `relay_events` (events per watcher, with
1931
+ `delivered_at` and the message they went into, kept for two hours once delivered). See
1932
+ [Relay event subscriptions](#relay-event-subscriptions).
1933
+
1934
+ `0012-cross-relay-event-subscriptions.mjs` adds the columns both sides of a subscription across
1935
+ relays need (on the watching relay `remote_subscription_id`, `remote_meta_json`, `remote_sync`; on
1936
+ the watched one `subscriber_relay_id`, `subscriber_relay_name`, `subscriber_watcher_id`,
1937
+ `subscriber_watcher_title`, `last_pull_at`, `dropped_count`) and `relay_event_links` (per paired
1938
+ relay the fetch cursor and the link's health). See [Across paired relays](#across-paired-relays).
1939
+
1587
1940
  `0007-claude-cloud.mjs` adds the storage of [Claude Cloud](#claude-cloud-provider) conversations.
1588
1941
  It is additive and idempotent, runs on every boot, and a failure is logged and retried on the next
1589
1942
  one:
@@ -1634,6 +1987,9 @@ working with the corresponding feature inert rather than crashing at startup.
1634
1987
  | `services/claude-cloud-session-service.mjs` | What the relay keeps per cloud conversation: worker reports, the `cloud` payload field, archiving |
1635
1988
  | `services/claude-account-usage-service.mjs` | Live Claude account usage for Check Usage (only while Claude Cloud is on) |
1636
1989
  | `services/git-remote-service.mjs` | GitHub remote and branch of a folder, for the New Chat modal |
1990
+ | `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 |
1991
+ | `services/session-activity-tracker.mjs` | What each session is doing: `state`, tasks, task edges and the activity clock |
1992
+ | `../shared/relay-events.mjs` | Relay event types, limits and the text of an event message |
1637
1993
  | `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 |
1638
1994
  | `services/claude-session-root-service.mjs` | Resolves the browsable session folder for Claude conversations |
1639
1995
  | `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 = () => {} } = {}) {