@oar-sh/oar 0.9.10 → 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 (104) hide show
  1. package/CHANGELOG.md +314 -1
  2. package/README.md +156 -972
  3. package/package.json +13 -13
  4. package/server/README.md +95 -22
  5. package/server/claude-worker/claude-session-process.mjs +89 -11
  6. package/server/claude-worker/claude-session-worker.mjs +9 -1
  7. package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
  8. package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
  9. package/server/db-schema.mjs +59 -2
  10. package/server/grok-worker/acp-client.mjs +2 -1
  11. package/server/grok-worker/acp-host-services.mjs +4 -3
  12. package/server/migrations/0013-previews.mjs +130 -0
  13. package/server/migrations/0014-conversation-groups.mjs +89 -0
  14. package/server/migrations/0015-conversation-group-position.mjs +76 -0
  15. package/server/migrations/0016-share-access-log.mjs +110 -0
  16. package/server/public/app/about-ui.js +73 -0
  17. package/server/public/app/about-view.mjs +234 -0
  18. package/server/public/app/action-confirmations.js +31 -14
  19. package/server/public/app/agent-exchange-fold-view.mjs +40 -101
  20. package/server/public/app/api-client.js +251 -16
  21. package/server/public/app/ask-user-view.js +17 -15
  22. package/server/public/app/bootstrap.js +158 -32
  23. package/server/public/app/chat-actions-menu-state.mjs +20 -10
  24. package/server/public/app/context-menu.mjs +90 -6
  25. package/server/public/app/conversation-group-drag.mjs +191 -0
  26. package/server/public/app/conversation-group-order.mjs +265 -0
  27. package/server/public/app/conversation-group-state.mjs +100 -0
  28. package/server/public/app/conversation-list-drag.mjs +449 -0
  29. package/server/public/app/conversation-turn-state.mjs +81 -0
  30. package/server/public/app/conversation-view.js +136 -93
  31. package/server/public/app/cwd-picker.js +1 -1
  32. package/server/public/app/hidden-messages-view.mjs +312 -0
  33. package/server/public/app/host-suspend-ui.js +7 -1
  34. package/server/public/app/journal-view.js +787 -22
  35. package/server/public/app/preview-cards.mjs +117 -31
  36. package/server/public/app/preview-command.mjs +10 -1
  37. package/server/public/app/relay-board-view.js +19 -6
  38. package/server/public/app/relay-restart-error.mjs +16 -0
  39. package/server/public/app/settings-modal.js +40 -8
  40. package/server/public/app/settings-tabs.js +1 -1
  41. package/server/public/app/share-format.mjs +49 -0
  42. package/server/public/app/share-preview-marks.mjs +167 -0
  43. package/server/public/app/share-state.js +109 -0
  44. package/server/public/app/shared-conversations-settings.js +480 -0
  45. package/server/public/app/socket-handlers.js +80 -14
  46. package/server/public/app/store.js +96 -30
  47. package/server/public/app/subagent-row.mjs +3 -3
  48. package/server/public/app/subagent-viewer.mjs +30 -6
  49. package/server/public/app/thinking-follow.js +0 -4
  50. package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
  51. package/server/public/app/transcript-jump-bottom.mjs +132 -21
  52. package/server/public/app/transcript-viewport-anchor.mjs +140 -0
  53. package/server/public/index.html +386 -15
  54. package/server/relay-tools.md +2 -1
  55. package/server/relay.mjs +2 -1
  56. package/server/repositories/conversation-group-repository.mjs +444 -0
  57. package/server/repositories/message-repository.mjs +1 -0
  58. package/server/repositories/session-repository.mjs +12 -37
  59. package/server/repositories/share-repository.mjs +322 -0
  60. package/server/repositories/share-visibility-statements.mjs +10 -0
  61. package/server/routes/about-routes.mjs +15 -0
  62. package/server/routes/cache-routes.mjs +4 -0
  63. package/server/routes/conversation-group-routes.mjs +171 -0
  64. package/server/routes/messages-routes.mjs +184 -65
  65. package/server/routes/preview-routes.mjs +9 -1
  66. package/server/routes/sessions-routes.mjs +771 -193
  67. package/server/server-runtime.mjs +316 -100
  68. package/server/services/about-service.mjs +461 -0
  69. package/server/services/background-task-store.mjs +128 -0
  70. package/server/services/background-work-view.mjs +228 -0
  71. package/server/services/cache-rebuild-service.mjs +30 -0
  72. package/server/services/delete-archive-service.mjs +4 -1
  73. package/server/services/detached-worker-watch-service.mjs +150 -0
  74. package/server/services/hidden-message-list.mjs +21 -0
  75. package/server/services/host-suspend-command.mjs +2 -1
  76. package/server/services/message-pin-list.mjs +1 -1
  77. package/server/services/preview-health-probe.mjs +8 -1
  78. package/server/services/preview-proxy-server.mjs +8 -4
  79. package/server/services/preview-registry-service.mjs +676 -26
  80. package/server/services/preview-static-handler.mjs +40 -7
  81. package/server/services/preview-store.mjs +274 -0
  82. package/server/services/rate-limit-service.mjs +12 -1
  83. package/server/services/relay-auth.mjs +183 -0
  84. package/server/services/relay-cli-launcher-service.mjs +3 -1
  85. package/server/services/relay-event-service.mjs +17 -0
  86. package/server/services/remote-relay-dispatcher.mjs +57 -5
  87. package/server/services/request-boundary.mjs +183 -0
  88. package/server/services/session-activity-tracker.mjs +227 -19
  89. package/server/services/session-worker-launch-service.mjs +190 -6
  90. package/server/services/session-worker-process-service.mjs +10 -6
  91. package/server/services/share-access-log-service.mjs +123 -0
  92. package/server/services/share-path-redaction.mjs +424 -0
  93. package/server/services/status-event-service.mjs +7 -3
  94. package/server/services/tunnel-worker-path-guard.mjs +24 -41
  95. package/server/services/windows-autostart-service.mjs +6 -1
  96. package/server/services/windows-boot-autostart-service.mjs +207 -23
  97. package/shared/preview-tool-core.mjs +15 -5
  98. package/shared/remote-relay-contract.mjs +3 -1
  99. package/shared/share-path-privacy.mjs +30 -0
  100. package/shared/windows-system-tools.mjs +106 -0
  101. package/shared/worker-runtime/background-task-digest.mjs +30 -0
  102. package/shared/worker-runtime/heartbeat.mjs +24 -0
  103. package/shared/worker-runtime/kept-posts.mjs +83 -0
  104. package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.10",
3
+ "version": "0.9.11",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -50,20 +50,20 @@
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.292",
54
- "@cursor/sdk": "^1.0.36",
55
- "@github/copilot-sdk": "^1.0.17",
56
- "better-sqlite3": "^12.10.0",
57
- "express": "^4.18.2",
58
- "qrcode-terminal": "^0.12.0",
59
- "socket.io": "^4.7.5",
60
- "uuid": "^14.0.0",
61
- "web-push": "^3.6.7",
62
- "ws": "^8.18.0",
63
- "zod": "^4.4.3"
53
+ "@anthropic-ai/claude-agent-sdk": "0.3.292",
54
+ "@cursor/sdk": "1.0.36",
55
+ "@github/copilot-sdk": "1.0.17",
56
+ "better-sqlite3": "12.11.1",
57
+ "express": "4.22.3",
58
+ "qrcode-terminal": "0.12.0",
59
+ "socket.io": "4.8.3",
60
+ "uuid": "14.0.1",
61
+ "web-push": "3.6.7",
62
+ "ws": "8.21.3",
63
+ "zod": "4.4.3"
64
64
  },
65
65
  "optionalDependencies": {
66
- "tty-console": "^1.0.4"
66
+ "tty-console": "1.0.4"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@playwright/test": "^1.60.0",
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
 
@@ -517,7 +558,7 @@ conversation that speaks the usual worker contracts (WebSocket link, heartbeat,
517
558
  crash guard) but runs no agent: it sends the user's messages to the cloud session and follows the
518
559
  session's event stream (`claude-cloud-session-process.mjs`, `claude-cloud-event-normalizer.mjs`).
519
560
  The user-facing description, including what the provider does with the Claude CLI's login, is in
520
- the [README](../README.md#claude-cloud); the tracker is
561
+ the [providers guide](../docs/providers.md#claude-cloud); the tracker is
521
562
  [docs/feature-tracker/claude-cloud.md](../docs/feature-tracker/claude-cloud.md).
522
563
 
523
564
  ### The cloud client and the login
@@ -845,7 +886,12 @@ posts it from `task_notification`), the sub-agent run statuses and the activity,
845
886
  timeline posts (the activity clock). A task that leaves the set without word ends after 5 s with
846
887
  its run's status, else `completed` with `statusReported: false`; an exit code is read from the task
847
888
  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.
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.
849
895
 
850
896
  `GET /api/conversations` and `GET /api/conversation/:id` carry `sessionState` (`running_turn`: a
851
897
  turn processing, or queued and not held by a usage-limit pause; `background_only`: tasks running;
@@ -871,18 +917,19 @@ socket event (the whole active list after every change).
871
917
  ### Folded agent exchanges
872
918
 
873
919
  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
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
877
923
  `conversation-view.js` applies it as one more idempotent pass after every render and update, beside
878
924
  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.
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.
886
933
 
887
934
  ## Image conversations (OpenAI BYOK)
888
935
 
@@ -911,6 +958,24 @@ images, calling the OpenAI Images API directly rather than going through a CLI w
911
958
  - The control appears on message hover; already-hidden messages keep their label visible so the state
912
959
  is never silently lost.
913
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.
914
979
 
915
980
  ## Pinned messages
916
981
 
@@ -1219,14 +1284,15 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1219
1284
  | POST | `/api/stream` | (CLI) Push in-flight assistant text stream for current pending message |
1220
1285
  | POST | `/api/relay/pause` | Pause dequeueing and drop currently queued messages |
1221
1286
  | POST | `/api/relay/resume` | Resume dequeueing after pause |
1222
- | 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 |
1223
1288
  | GET | `/api/relay-questions` | (CLI/UI) List relay questions by `status` (for example `pending` or `answered`) |
1224
1289
  | GET | `/api/relay-question/:id` | (CLI) Fetch a single relay question |
1225
1290
  | POST | `/api/relay-question` | (CLI) Create a relay question for the browser |
1226
1291
  | POST | `/api/relay-question/:id/answer` | (UI) Submit an answer for a relay question |
1227
1292
  | POST | `/api/relay-question/:id/timeout` | (CLI/UI) Mark a relay question timed out |
1228
- | 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 |
1229
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 |
1230
1296
  | GET | `/api/models` | Live/cached model catalog used by the UI picker |
1231
1297
  | POST | `/api/models/snapshot` | (CLI/relay) Publish discovered model snapshot |
1232
1298
  | POST | `/api/conversation/:id/refresh-history` | Re-import an existing conversation's history through the local Copilot SDK |
@@ -1256,9 +1322,14 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1256
1322
  | POST | `/api/conversation/:conversationId/subagent/:subagentRunId/cancel` | Request cancellation of one subagent run (unsupported by the Claude runtime) |
1257
1323
  | PATCH | `/api/conversation/:id/message/:messageId/share-visibility` | Hide or unhide a single message from shared views |
1258
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` |
1259
- | POST | `/api/conversation/:id/share` | Create or update the conversation's read-only share token |
1260
- | GET | `/api/shared/:token` | Read-only shared conversation view (hidden messages filtered out) |
1261
- | 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 |
1262
1333
  | POST | `/api/openai/images/generate` | Generate images for an OpenAI Image conversation |
1263
1334
  | POST | `/api/image-operations/:operationId/execute` | Execute a queued image generation/edit operation |
1264
1335
  | GET | `/api/generated-image/:conversationId/:messageId/:imageId/content` | Stream a generated image blob |
@@ -1275,7 +1346,7 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1275
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 |
1276
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) |
1277
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 }` |
1278
- | POST | `/api/background-task-settled` | (Worker) How a background task ended: `{ conversationId, taskId, toolUseId?, status, summary?, reason? }` (Claude worker, from `task_notification`) |
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) |
1279
1350
  | GET | `/api/remote-relays/inflight` | (Worker) Number of `remote_relay` calls running for `conversationId` |
1280
1351
  | GET/POST | `/api/settings/remote-relays` | This relay's `publicUrl` and `inboundEnabled` (accept prompts from other relays' agents) |
1281
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 |
@@ -1283,7 +1354,8 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
1283
1354
 
1284
1355
  ### Manual relay shutdown / self-restart
1285
1356
 
1286
- - `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.
1287
1359
  - The request body accepts:
1288
1360
  - `reason` (optional string)
1289
1361
  - `requestedBy` (optional string)
@@ -1671,7 +1743,8 @@ service).
1671
1743
  | `cloudflaredTunnel.token` | — | Tunnel token from the router panel (never logged) |
1672
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 |
1673
1745
  | `cloudflaredTunnel.extraArgs` | `[]` | Extra arguments appended to `cloudflared tunnel run` |
1674
- | `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 |
1675
1748
 
1676
1749
  ### SDK auto-detection behavior
1677
1750
 
@@ -43,6 +43,7 @@ import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
43
43
  import { createRemoteRelayToolGate } from '../../shared/remote-relay-tool-core.mjs';
44
44
  import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
45
45
  import { createTurnLiveness, resolveTurnStallWindows } from '../../shared/worker-runtime/turn-liveness.mjs';
46
+ import { createKeptPosts } from '../../shared/worker-runtime/kept-posts.mjs';
46
47
  import {
47
48
  ANSWERED_ELSEWHERE_KIND,
48
49
  DELIVERY_ANSWERED_ELSEWHERE_TEXT,
@@ -546,6 +547,10 @@ export function createClaudeSessionRunner({
546
547
  // the socket link: a hold withdraws the relay's readiness, and its end
547
548
  // re-arms it at once so a held message steers into the resumed turn.
548
549
  onDeliveryReadinessChange = () => {},
550
+ // The relay turned this worker's newest task set away (`stale`): the
551
+ // worker asks for a heartbeat, on which the relay learns which process
552
+ // holds the session and asks for the set again.
553
+ onBackgroundTaskSetStale = () => {},
549
554
  // Whether CLI processes carry the relay's `remote_relay` tool
550
555
  // (shared/remote-relay-tool-core.mjs). Default: ask the relay.
551
556
  remoteRelayToolGate = null,
@@ -604,6 +609,34 @@ export function createClaudeSessionRunner({
604
609
  // id → terminalError for settling rows the worker gave up posting; reported
605
610
  // in the heartbeat's `settleFailed` until the relay confirms it failed them.
606
611
  const settleFailedTerminals = new Map();
612
+ // Settle notices of background tasks whose post failed (the relay was
613
+ // away): sent again after the next heartbeat it answers.
614
+ // Every post of the task set carries its place in this worker's order:
615
+ // two posts can overtake each other on the way, and the relay applies the
616
+ // newer one only. `epoch` tells this process from one before it.
617
+ const taskSetEpoch = Date.now();
618
+ let taskSetSeq = 0;
619
+ const nextTaskSetOrder = () => ({ epoch: taskSetEpoch, seq: ++taskSetSeq });
620
+ let taskSetStaleNotedAt = 0;
621
+ /**
622
+ * The relay's answer to a set post. `stale` for the newest one means the
623
+ * relay holds no current set of this worker; an older one was simply
624
+ * overtaken. At most one extra heartbeat per ten seconds: the periodic
625
+ * beat repeats the request anyway.
626
+ */
627
+ function noteTaskSetAnswer(answer, order) {
628
+ if (answer?.stale !== true || order.seq !== taskSetSeq) return;
629
+ dbg('the relay dropped the newest background task set', answer.reason || '');
630
+ if (Date.now() - taskSetStaleNotedAt < 10_000) return;
631
+ taskSetStaleNotedAt = Date.now();
632
+ try { onBackgroundTaskSetStale(); } catch {}
633
+ }
634
+ const postTaskSet = (body) => {
635
+ const order = nextTaskSetOrder();
636
+ return api('POST', '/api/background-tasks', { conversationId: sdkSessionId, ...body, order })
637
+ .then((answer) => noteTaskSetAnswer(answer, order));
638
+ };
639
+ const keptTaskSettles = createKeptPosts({ api, dbg: (...args) => dbg('background task settle:', ...args) });
607
640
  // Row id → the latest in-flight /api/queue-consumed POST touching it.
608
641
  const consumedMarkChains = new Map();
609
642
 
@@ -1694,10 +1727,19 @@ export function createClaudeSessionRunner({
1694
1727
  * which renders it as the composer's background-tasks panel. Membership
1695
1728
  * changes post immediately; chatty progress updates are trailing-edge
1696
1729
  * throttled. Advisory: failures must never disturb the stream consumer.
1730
+ *
1731
+ * `resync`: the relay asked for the set again on a heartbeat, because its
1732
+ * own set for this worker differs (it restarted, or a post was lost). The
1733
+ * same set, flagged, and "no tasks" is an answer too. Returns the post.
1697
1734
  */
1698
- function publishBackgroundTasks({ throttled = false } = {}) {
1735
+ function publishBackgroundTasks({ throttled = false, resync = false } = {}) {
1699
1736
  const processRef = proc;
1700
- if (!processRef) return;
1737
+ if (!processRef) {
1738
+ if (!resync) return Promise.resolve();
1739
+ return postTaskSet({ tasks: [], resync: true }).catch((error) => {
1740
+ dbg('background task resync failed', error?.message || String(error));
1741
+ });
1742
+ }
1701
1743
  const send = () => {
1702
1744
  processRef.taskPublishTimer = null;
1703
1745
  const tasks = [...processRef.liveTasks.entries()].map(([taskId, task]) => {
@@ -1729,7 +1771,7 @@ export function createClaudeSessionRunner({
1729
1771
  ...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
1730
1772
  };
1731
1773
  });
1732
- api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks }).catch((error) => {
1774
+ return postTaskSet({ tasks, ...(resync ? { resync: true } : {}) }).catch((error) => {
1733
1775
  dbg('background task publish failed', error?.message || String(error));
1734
1776
  });
1735
1777
  };
@@ -1738,12 +1780,12 @@ export function createClaudeSessionRunner({
1738
1780
  clearTimeout(processRef.taskPublishTimer);
1739
1781
  processRef.taskPublishTimer = null;
1740
1782
  }
1741
- send();
1742
- return;
1783
+ return send();
1743
1784
  }
1744
- if (processRef.taskPublishTimer) return;
1785
+ if (processRef.taskPublishTimer) return Promise.resolve();
1745
1786
  processRef.taskPublishTimer = setTimeout(send, 2_000);
1746
1787
  processRef.taskPublishTimer.unref?.();
1788
+ return Promise.resolve();
1747
1789
  }
1748
1790
 
1749
1791
  // ---------------------------------------------------------------------------
@@ -2441,7 +2483,12 @@ export function createClaudeSessionRunner({
2441
2483
  // capture a settling workflow's final digest for the transcript card
2442
2484
  // (the notification then refines the status when it lands).
2443
2485
  for (const [taskId, task] of previous) {
2444
- if (!proc.liveTasks.has(taskId)) bufferSettledWorkflowRun(taskId, task);
2486
+ if (proc.liveTasks.has(taskId)) continue;
2487
+ bufferSettledWorkflowRun(taskId, task);
2488
+ // What the task was, for its settle notice: the row is gone by the
2489
+ // time the notification says how it ended.
2490
+ proc.departedTasks.set(taskId, task);
2491
+ if (proc.departedTasks.size > 50) proc.departedTasks.delete(proc.departedTasks.keys().next().value);
2445
2492
  }
2446
2493
  syncWorkflowPoller(proc);
2447
2494
  publishBackgroundTasks();
@@ -2491,14 +2538,24 @@ export function createClaudeSessionRunner({
2491
2538
  // this session (the live set only says it is gone). Ambient tasks are
2492
2539
  // not activity. Advisory: a failed post never disturbs the stream.
2493
2540
  if (taskId && sdkMessage.ambient !== true) {
2494
- api('POST', '/api/background-task-settled', {
2541
+ // A task that was in the published set is described too, so that a
2542
+ // relay which no longer knows it (it restarted meanwhile) can still
2543
+ // tell its watchers the end. A failed post is kept and sent again.
2544
+ const published = proc.liveTasks.get(taskId) || proc.departedTasks.get(taskId) || null;
2545
+ proc.departedTasks.delete(taskId);
2546
+ void keptTaskSettles.post('/api/background-task-settled', {
2495
2547
  conversationId: sdkSessionId,
2496
2548
  taskId,
2497
- toolUseId: String(sdkMessage.tool_use_id || '').trim() || null,
2549
+ toolUseId: String(sdkMessage.tool_use_id || published?.toolUseId || '').trim() || null,
2498
2550
  status: String(sdkMessage.status || '').trim() || 'completed',
2499
2551
  summary: String(sdkMessage.summary || '').trim().slice(0, 500) || null,
2500
2552
  ...(sdkMessage.reason ? { reason: String(sdkMessage.reason).slice(0, 40) } : {}),
2501
- }).catch((error) => dbg('background task settle publish failed', error?.message || String(error)));
2553
+ ...(published?.description ? {
2554
+ description: String(published.description).slice(0, 500),
2555
+ taskType: String(published.taskType || ''),
2556
+ startedAt: published.startedAt || null,
2557
+ } : {}),
2558
+ });
2502
2559
  }
2503
2560
  // A settled session-level task means the CLI is about to dequeue a
2504
2561
  // continuation turn — the process must not idle out under it. The SDK
@@ -3249,7 +3306,7 @@ export function createClaudeSessionRunner({
3249
3306
  if (processRef.liveTasks.size) {
3250
3307
  processRef.liveTasks = new Map();
3251
3308
  if (wasCurrent) {
3252
- api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks: [] }).catch(() => {});
3309
+ postTaskSet({ tasks: [] }).catch(() => {});
3253
3310
  }
3254
3311
  }
3255
3312
  // Steers pushed into a turn the user stopped: if the Stop fell back to
@@ -3331,6 +3388,9 @@ export function createClaudeSessionRunner({
3331
3388
  permissionMode: permissionModeForRelayMode(relayMode),
3332
3389
  liveTasks: new Map(),
3333
3390
  knownTasks: new Set(),
3391
+ // taskId → the row of a task that left the live set, until its
3392
+ // notification arrives.
3393
+ departedTasks: new Map(),
3334
3394
  // taskId → { runId, labels, digestJson } for live local_workflow tasks;
3335
3395
  // pruned by syncWorkflowPoller when a task leaves the live set.
3336
3396
  workflowStates: new Map(),
@@ -4034,6 +4094,21 @@ export function createClaudeSessionRunner({
4034
4094
  return false;
4035
4095
  }
4036
4096
 
4097
+ /** The ids of the live background tasks, for the heartbeat's digest. */
4098
+ function backgroundTaskIds() {
4099
+ return proc ? [...proc.liveTasks.keys()] : [];
4100
+ }
4101
+
4102
+ /** The relay asked for the task set again (heartbeat answer). */
4103
+ function resyncBackgroundTasks() {
4104
+ return publishBackgroundTasks({ resync: true });
4105
+ }
4106
+
4107
+ /** The relay answered a heartbeat: settle notices kept for it go out. */
4108
+ function flushKeptTaskSettles() {
4109
+ return keptTaskSettles.flush();
4110
+ }
4111
+
4037
4112
  return {
4038
4113
  handlePendingPayload,
4039
4114
  canAcceptSteering,
@@ -4046,6 +4121,9 @@ export function createClaudeSessionRunner({
4046
4121
  isTurnActive,
4047
4122
  stopBackgroundTask,
4048
4123
  stopBackgroundTaskByToolUseId,
4124
+ backgroundTaskIds,
4125
+ resyncBackgroundTasks,
4126
+ flushKeptTaskSettles,
4049
4127
  shutdown,
4050
4128
  // Test seams / observability.
4051
4129
  _getProcess: () => proc,
@@ -119,6 +119,7 @@ async function main() {
119
119
  // A readiness flip is a hold starting or ending (question card,
120
120
  // compaction, a turn-opening delivery): the steering snapshot changed
121
121
  // with it, so it goes out now rather than on the next 10 s heartbeat.
122
+ onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
122
123
  onDeliveryReadinessChange: (ready) => {
123
124
  linkBridge.onDeliveryReadinessChange(ready);
124
125
  heartbeat?.requestPulse();
@@ -147,6 +148,12 @@ async function main() {
147
148
  // Consumed rows the runner could not fail itself; the relay fails them.
148
149
  getSettleFailed: () => turnRunner.getSettleFailed(),
149
150
  onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
151
+ // The relay holds the task set in memory only: the digest of the live
152
+ // task ids rides every beat, and a relay that is out of step (it
153
+ // restarted, a post was lost) asks for the set again.
154
+ getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
155
+ onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
156
+ onAnswered: () => turnRunner.flushKeptTaskSettles(),
150
157
  });
151
158
 
152
159
  const wsLink = createWorkerWebSocketLink({
@@ -164,7 +171,8 @@ async function main() {
164
171
  // even between deliveries — a delivery drawn into it is only handed back.
165
172
  getDeliveryHeld: () => turnRunner.isDeliveryHeld(),
166
173
  // A relay that just (re)started knows nothing of this worker until it
167
- // heartbeats; the composer's steering snapshot rides that beat.
174
+ // heartbeats; the composer's steering snapshot rides that beat, and so
175
+ // does the digest that makes the relay ask for the background tasks.
168
176
  onServerHello: () => heartbeat.requestPulse(),
169
177
  getSessionId: () => sdkSessionId,
170
178
  getPid: () => process.pid,
@@ -461,6 +461,10 @@ export function createCopilotSdkSessionRunner({
461
461
  // heartbeat snapshot, and a 10 s wait would often outlast the window in
462
462
  // which the runtime still holds the message (live check 2026-09-25).
463
463
  onCancellableChange = () => {},
464
+ // The relay turned this worker's newest task set away (`stale`): the
465
+ // worker asks for a heartbeat, on which the relay learns which process
466
+ // holds the session and asks for the set again.
467
+ onBackgroundTaskSetStale = () => {},
464
468
  dbg = () => {},
465
469
  } = {}) {
466
470
  let client = null;
@@ -628,7 +632,15 @@ export function createCopilotSdkSessionRunner({
628
632
  let taskRefreshQueued = 0;
629
633
  let lastTaskRefreshAt = 0;
630
634
  let taskPublishTimer = null;
635
+ // The last set the relay took, as JSON, and the one on its way to it.
631
636
  let lastPublishedTasksSignature = '';
637
+ let inFlightTasksSignature = null;
638
+ let taskPublishSeq = 0;
639
+ // Every post of the set carries its place in this worker's order
640
+ // (`epoch` tells this process from one before it): posts can overtake
641
+ // each other on the way, and the relay applies the newer one only.
642
+ const taskSetEpoch = Date.now();
643
+ let taskSetStaleNotedAt = 0;
632
644
  // Cap expiry is bounded: a task whose cancel the runtime refuses (or that
633
645
  // keeps failing) this many times is abandoned — dropped from the live set so
634
646
  // it stops pinning the runtime — rather than held forever.
@@ -1570,27 +1582,77 @@ export function createCopilotSdkSessionRunner({
1570
1582
  * semantics, the same route the Claude worker publishes on). Set changes
1571
1583
  * publish at once; enrichment-only changes (a tool call, tokens) ride a
1572
1584
  * trailing throttle. Advisory: a failed post never disturbs events.
1585
+ *
1586
+ * A set equal to the last one the relay TOOK (or to the one on its way)
1587
+ * is not sent again: the signature is recorded when the post succeeds, so
1588
+ * a failed post is retried by the next publish instead of being
1589
+ * remembered as sent.
1590
+ * `resync`: the relay asked for the set again on a heartbeat (it
1591
+ * restarted, or a post was lost); that goes out whatever the signature
1592
+ * says, flagged. Returns the post.
1573
1593
  */
1574
- function publishBackgroundTasks({ immediate = false } = {}) {
1594
+ function publishBackgroundTasks({ immediate = false, resync = false } = {}) {
1575
1595
  const run = () => {
1576
1596
  if (taskPublishTimer) {
1577
1597
  clearTimeout(taskPublishTimer);
1578
1598
  taskPublishTimer = null;
1579
1599
  }
1580
- const tasks = sessionRpc.tasks ? registryCards() : shellTrackerCards();
1600
+ const tasks = currentTaskCards();
1581
1601
  const signature = JSON.stringify(tasks);
1582
- if (signature === lastPublishedTasksSignature) return;
1583
- lastPublishedTasksSignature = signature;
1584
- api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks })
1585
- .catch((error) => dbg('background task publish failed', error?.message || String(error)));
1602
+ const unchanged = inFlightTasksSignature === null
1603
+ ? signature === lastPublishedTasksSignature
1604
+ : signature === inFlightTasksSignature;
1605
+ if (!resync && unchanged) return Promise.resolve();
1606
+ const seq = ++taskPublishSeq;
1607
+ inFlightTasksSignature = signature;
1608
+ return api('POST', '/api/background-tasks', {
1609
+ conversationId: sdkSessionId,
1610
+ tasks,
1611
+ order: { epoch: taskSetEpoch, seq },
1612
+ ...(resync ? { resync: true } : {}),
1613
+ })
1614
+ .then((answer) => {
1615
+ // Only the newest post says what the relay holds now.
1616
+ if (seq !== taskPublishSeq) return;
1617
+ inFlightTasksSignature = null;
1618
+ if (answer?.stale === true) {
1619
+ // Turned away, not taken: nothing is recorded as sent, and a
1620
+ // heartbeat is asked for (at most one per ten seconds; the
1621
+ // periodic beat repeats the relay's request anyway).
1622
+ dbg('the relay dropped the newest background task set', answer.reason || '');
1623
+ if (Date.now() - taskSetStaleNotedAt < 10_000) return;
1624
+ taskSetStaleNotedAt = Date.now();
1625
+ try { onBackgroundTaskSetStale(); } catch {}
1626
+ return;
1627
+ }
1628
+ lastPublishedTasksSignature = signature;
1629
+ })
1630
+ .catch((error) => {
1631
+ // Not taken: the signature stays at what the relay last took, so
1632
+ // the next publish of this set is sent, not skipped.
1633
+ if (seq === taskPublishSeq) inFlightTasksSignature = null;
1634
+ dbg('background task publish failed', error?.message || String(error));
1635
+ });
1586
1636
  };
1587
- if (immediate || !(taskPublishThrottleMs > 0)) {
1588
- run();
1589
- return;
1590
- }
1591
- if (taskPublishTimer) return;
1637
+ if (resync || immediate || !(taskPublishThrottleMs > 0)) return run();
1638
+ if (taskPublishTimer) return Promise.resolve();
1592
1639
  taskPublishTimer = setTimeout(run, taskPublishThrottleMs);
1593
1640
  taskPublishTimer.unref?.();
1641
+ return Promise.resolve();
1642
+ }
1643
+
1644
+ function currentTaskCards() {
1645
+ return sessionRpc.tasks ? registryCards() : shellTrackerCards();
1646
+ }
1647
+
1648
+ /** The ids of the live background tasks, for the heartbeat's digest. */
1649
+ function backgroundTaskIds() {
1650
+ return currentTaskCards().map((task) => task.taskId);
1651
+ }
1652
+
1653
+ /** The relay asked for the task set again (heartbeat answer). */
1654
+ function resyncBackgroundTasks() {
1655
+ return publishBackgroundTasks({ resync: true });
1594
1656
  }
1595
1657
 
1596
1658
  // ------------------------------------------------------- background tasks --
@@ -4700,6 +4762,8 @@ export function createCopilotSdkSessionRunner({
4700
4762
  steeringState,
4701
4763
  getSettleFailed,
4702
4764
  acknowledgeSettleFailed,
4765
+ backgroundTaskIds,
4766
+ resyncBackgroundTasks,
4703
4767
  cancelPushedMessage,
4704
4768
  // The task panel's per-card Stop (`rpc.tasks.cancel`); also what a
4705
4769
  // subagent lane's Stop tries — the runtime tracks sync agents in the same
@@ -4726,6 +4790,7 @@ export function createCopilotSdkSessionRunner({
4726
4790
  ? new Promise((resolve) => { setTimeout(resolve, taskRefreshMs + 5); }).then(() => taskRefreshChain)
4727
4791
  : taskRefreshChain),
4728
4792
  // Test seams / observability.
4793
+ _publishBackgroundTasks: publishBackgroundTasks,
4729
4794
  _getState: () => ({
4730
4795
  hasClient: !!client,
4731
4796
  hasSession: !!session,
@@ -121,6 +121,7 @@ async function main() {
121
121
  // A message became (or stopped being) cancellable: report it now rather
122
122
  // than on the next 10 s heartbeat (coalesced by the controller).
123
123
  onCancellableChange: () => heartbeat?.requestPulse(),
124
+ onBackgroundTaskSetStale: () => heartbeat?.requestPulse(),
124
125
  canHandBackHeldDelivery: () => linkBridge.canHandBackHeldDelivery(),
125
126
  dbg,
126
127
  });
@@ -144,6 +145,11 @@ async function main() {
144
145
  getSteeringState: () => turnRunner.steeringState(),
145
146
  getSettleFailed: () => turnRunner.getSettleFailed(),
146
147
  onSettleFailedHandled: (ids) => turnRunner.acknowledgeSettleFailed(ids),
148
+ // The relay holds the task set in memory only: the digest of the live
149
+ // task ids rides every beat, and a relay that is out of step (it
150
+ // restarted, a post was lost) asks for the set again.
151
+ getBackgroundTaskIds: () => turnRunner.backgroundTaskIds(),
152
+ onResyncBackgroundTasks: () => turnRunner.resyncBackgroundTasks(),
147
153
  });
148
154
 
149
155
  const wsLink = createWorkerWebSocketLink({