@oar-sh/oar 0.9.14 → 0.9.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +179 -0
- package/README.md +9 -9
- package/bin/oar.js +525 -12
- package/package.json +3 -1
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-session-process.mjs +4 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2 -2
- package/server/db-schema.mjs +52 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +61 -3
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/inbox-ui.js +356 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +23 -2
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +17 -3
- package/server/public/app/socket-handlers.js +27 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +159 -2
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +97 -11
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +17 -2
- package/server/routes/messages-routes.mjs +3 -3
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +613 -56
- package/server/server-runtime.mjs +80 -1
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/oar-cli-helpers.mjs +12 -4
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +956 -30
- package/server/services/windows-autostart-service.mjs +88 -16
- package/server/services/windows-boot-autostart-service.mjs +775 -126
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +553 -25
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/.github/extensions/web-relay/README.md +0 -190
- package/server/README.md +0 -2334
package/server/README.md
DELETED
|
@@ -1,2334 +0,0 @@
|
|
|
1
|
-
# Copilot Web Proxy
|
|
2
|
-
|
|
3
|
-
A Node.js web server that lets you chat with your local coding agents from any device via a browser.
|
|
4
|
-
|
|
5
|
-
Three runtimes can serve a conversation, selected when the conversation is created:
|
|
6
|
-
|
|
7
|
-
- **GitHub Copilot CLI** (default) — executed by the `web-relay` CLI extension and its session workers.
|
|
8
|
-
- **OpenAI (BYOK)** — the Copilot CLI running in BYOK mode against an OpenAI-compatible endpoint, plus a direct Images API path for image conversations.
|
|
9
|
-
- **Claude (Agent SDK)** — a dedicated Node worker in `server/claude-worker/` that speaks the same relay contracts.
|
|
10
|
-
|
|
11
|
-
See [Claude Agent SDK provider](#claude-agent-sdk-provider) for the third runtime.
|
|
12
|
-
|
|
13
|
-
## Setup
|
|
14
|
-
|
|
15
|
-
```bash
|
|
16
|
-
npm install
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
This project now runs in package-wide ESM mode (`"type": "module"` in `package.json`).
|
|
20
|
-
|
|
21
|
-
## Starting the Server
|
|
22
|
-
|
|
23
|
-
If you run `gh copilot` and the web-relay extension is loaded (project-local or user-global),
|
|
24
|
-
the extension auto-starts `server.js` when needed and keeps the relay listener singleton
|
|
25
|
-
while letting session-affine CLI workers run in parallel.
|
|
26
|
-
|
|
27
|
-
If `tty-console` is installed as an optional dependency, the server starts it on an interactive
|
|
28
|
-
terminal and stores its config/history files under `server/logs/`.
|
|
29
|
-
When active, worker loop telemetry is summarized in the title bar (`Clients`, `Pen`, `Proc`,
|
|
30
|
-
`parked`, running `CLIs`) and routine worker dequeue events are not printed as repeating logs.
|
|
31
|
-
|
|
32
|
-
On Linux/macOS, session-affine worker launches prefer detached `tmux` sessions when `tmux`
|
|
33
|
-
is available, using the worker SDK session id as the tmux session name so you can attach
|
|
34
|
-
for debugging without changing the Windows launch path. Worker processes now launch from the
|
|
35
|
-
relay repository root so the project-local `web-relay` extension is always discoverable; the
|
|
36
|
-
session's actual target workspace is still passed via `COPILOT_WORKSPACE_ROOT` / `INIT_CWD`.
|
|
37
|
-
|
|
38
|
-
The web client now includes a read-only **Inspect tmux console** action in the conversation
|
|
39
|
-
three-dot menu. It streams only from active relay session workers and only while the viewer
|
|
40
|
-
is actively watching that session.
|
|
41
|
-
|
|
42
|
-
> **Single-owner rule:** Use either extension-managed relay transport **or** standalone `relay.mjs`, never both at the same time.
|
|
43
|
-
> Agent/runtime restart policy is defined in `.github/copilot-instructions.md`.
|
|
44
|
-
|
|
45
|
-
Launch Copilot CLI from the repository root with extensions enabled and no initial user prompt:
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
npm run copilot:relay
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
That runs `gh copilot -- --allow-all` from the repo root, so the
|
|
52
|
-
extension is discovered and the relay starts immediately. If you installed the extension
|
|
53
|
-
globally, plain `gh copilot` from any repository is enough.
|
|
54
|
-
|
|
55
|
-
Or manually:
|
|
56
|
-
|
|
57
|
-
```bash
|
|
58
|
-
node server/server.js
|
|
59
|
-
```
|
|
60
|
-
|
|
61
|
-
This starts the web server and stays attached to the terminal, respawning its
|
|
62
|
-
worker child across restarts. Ctrl+C (or closing the terminal) stops both.
|
|
63
|
-
A Copilot CLI session with the extension attaches separately.
|
|
64
|
-
|
|
65
|
-
If you install this repo locally with `npm link` or `npm install -g .`, the `copilot-remote`
|
|
66
|
-
command starts the web relay server if needed and then launches `gh copilot` in the same shell
|
|
67
|
-
from whatever folder you run it in. If a relay is already live, it reuses it instead of starting
|
|
68
|
-
a second owner.
|
|
69
|
-
|
|
70
|
-
`copilot-remote` also prepares a user-global `web-relay` extension wrapper by default. You can run
|
|
71
|
-
`copilot-remote --install-extension` to install/update only the wrapper and exit.
|
|
72
|
-
|
|
73
|
-
Relay server output is redirected to a logfile under `%LOCALAPPDATA%\copilot-remote\logs` by
|
|
74
|
-
default (or `COPILOT_WEB_RELAY_LOG_DIR` if set), so the terminal stays reserved for the CLI.
|
|
75
|
-
|
|
76
|
-
The global launcher no longer injects a bootstrap prompt; it just starts `gh copilot` directly
|
|
77
|
-
after the relay is ready.
|
|
78
|
-
|
|
79
|
-
If you need a specific `server/config.json` for token or tunnel settings, set
|
|
80
|
-
`COPILOT_WEB_RELAY_CONFIG` to that file before launching. The global npm install does not include
|
|
81
|
-
the repo-local gitignored config file by default.
|
|
82
|
-
|
|
83
|
-
Mode summary — one entry point, role chosen by argv:
|
|
84
|
-
|
|
85
|
-
- `node server/server.js` (= `npm start`): the process stays attached as a supervisor and runs
|
|
86
|
-
`server-runtime.mjs` in a worker child it marks with `--relay-runtime`. Exit 75 relaunches the
|
|
87
|
-
worker; other non-zero exits are retried 3 times before the supervisor gives up.
|
|
88
|
-
- `node server/server.js --supervised`: runs the server in this process and exits 75 instead of
|
|
89
|
-
restarting itself, leaving that to whoever spawned it. This is what the CLI extension passes.
|
|
90
|
-
- `node server/relay.mjs`: the standalone Copilot relay, started by hand after the server is up.
|
|
91
|
-
Manual escape hatch only — never alongside extension-managed transport.
|
|
92
|
-
|
|
93
|
-
Role flags are argv, not env, on purpose: the server's environment is inherited by tmux worker
|
|
94
|
-
sessions and by the Copilot CLI it launches, and the extension spawns the next server from that
|
|
95
|
-
same environment — so an env flag would decide the role of unrelated servers down the tree.
|
|
96
|
-
|
|
97
|
-
On Windows, the visible relay launcher path now targets a stable per-workspace Windows Terminal window name so later foreground launches reuse the same window instead of opening new desktop windows. Keep the hidden/stdio path as a fallback only.
|
|
98
|
-
|
|
99
|
-
### Runtime safety checklist (avoid duplicate relay workers)
|
|
100
|
-
|
|
101
|
-
1. Stop stale detached watchdog/relay processes before restart.
|
|
102
|
-
2. Keep exactly one listener on port `3333`.
|
|
103
|
-
3. The relay singleton lock is stored at `server/data/relay-server.lock` (stale locks are auto-recovered).
|
|
104
|
-
4. In extension-managed mode, do not start a second server or run `node relay.mjs`.
|
|
105
|
-
5. Verify `/api/status` shows `cliOnline: true` and queue counts are moving or zero.
|
|
106
|
-
6. Follow relay restart policy from `.github/copilot-instructions.md`.
|
|
107
|
-
7. In extension-managed mode, use `POST /api/relay/shutdown` for manual restart requests.
|
|
108
|
-
- It is queued until the relay is idle, so it will not stop an in-flight turn immediately.
|
|
109
|
-
- Send `{ "restart": true }` when you want a self-restart instead of a plain shutdown.
|
|
110
|
-
8. Do not run tests that spawn Copilot CLI clients unless the user explicitly permits it.
|
|
111
|
-
|
|
112
|
-
Script necessity note:
|
|
113
|
-
|
|
114
|
-
- Extension-managed relay does not call npm scripts directly; it starts `server.js --supervised` itself.
|
|
115
|
-
- Extension-managed relay supervision includes bounded auto-restart while the CLI session is active,
|
|
116
|
-
and is now the only supervisor in that mode.
|
|
117
|
-
- `npm start` is a plain alias for `node server/server.js`; there is no other start script.
|
|
118
|
-
- Manual restart policy is defined in `.github/copilot-instructions.md`.
|
|
119
|
-
|
|
120
|
-
## Global extension install (user-scoped)
|
|
121
|
-
|
|
122
|
-
Copilot also scans a user extensions directory, so you can make this extension available
|
|
123
|
-
across repositories:
|
|
124
|
-
|
|
125
|
-
```text
|
|
126
|
-
C:\Users\<you>\.copilot\extensions\web-relay\extension.mjs
|
|
127
|
-
```
|
|
128
|
-
|
|
129
|
-
With global install, starting `gh copilot` in any workspace will load this extension and keep
|
|
130
|
-
the relay tied to that workspace CWD instead of forcing a fixed repo launcher.
|
|
131
|
-
|
|
132
|
-
If you also keep a project-local copy, extension management can show two `web-relay` entries.
|
|
133
|
-
Keep only one active to prevent duplicate loading.
|
|
134
|
-
|
|
135
|
-
For global install, use `copilot-remote --install-extension` (recommended) or manually place a
|
|
136
|
-
wrapper `extension.mjs` there that imports your repository extension entrypoint. Then set one of
|
|
137
|
-
these environment variables so it can find your relay server files:
|
|
138
|
-
|
|
139
|
-
- `COPILOT_WEB_RELAY_SERVER_DIR` (recommended) → absolute path to the `server` folder
|
|
140
|
-
- `COPILOT_WEB_RELAY_ROOT` → repo root that contains `server\`
|
|
141
|
-
- Optional overrides: `COPILOT_WEB_RELAY_CONFIG`, `COPILOT_WEB_RELAY_TOOLS`, `COPILOT_WEB_RELAY_LOG_DIR`
|
|
142
|
-
|
|
143
|
-
Project-local extensions still take precedence if the same extension name exists in both locations.
|
|
144
|
-
|
|
145
|
-
The startup banner shows your access URLs and token.
|
|
146
|
-
|
|
147
|
-
## Accessing the UI
|
|
148
|
-
|
|
149
|
-
Open in any browser:
|
|
150
|
-
```
|
|
151
|
-
http://<your-pc-ip>:3333/
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
If `localhostOnly` is enabled in `config.json`, the server listens only on loopback and you must use:
|
|
155
|
-
|
|
156
|
-
```
|
|
157
|
-
http://localhost:3333/
|
|
158
|
-
```
|
|
159
|
-
|
|
160
|
-
`localhostOnly` affects only the local relay listener. SSH reverse tunnel exposure is configured separately with `sshTunnel.remoteBind`. The Cloudflare Tunnel mode (`cloudflaredTunnel`) needs no public bind at all: `cloudflared` connects outbound to Cloudflare and proxies back into `127.0.0.1`.
|
|
161
|
-
|
|
162
|
-
Sign in once with the token prompt; the browser stores the session in an HttpOnly cookie.
|
|
163
|
-
Each CLI session now tracks its own workspace root:
|
|
164
|
-
- the running CLI keeps its learned runtime CWD
|
|
165
|
-
- the **🗂️ Change CWD** menu updates that session's persisted next-launch CWD
|
|
166
|
-
- the workspace browser follows the selected session's effective CWD instead of one relay-global root
|
|
167
|
-
|
|
168
|
-
Startup CWD learning prefers explicit session/launcher hints such as `COPILOT_WORKSPACE_ROOT`,
|
|
169
|
-
then other runtime cwd hints (`INIT_CWD`, `PWD`, session metadata). The extension startup sync
|
|
170
|
-
deliberately skips `process.cwd()` so the relay host directory never silently masquerades as a
|
|
171
|
-
project root. Sessions without an explicit configured CWD launch in the relay's working directory.
|
|
172
|
-
|
|
173
|
-
A plain `cd <path>` chat command persists to that conversation's configured CWD only — it does not
|
|
174
|
-
affect other sessions or the global relay root.
|
|
175
|
-
|
|
176
|
-
Use the chat header **⋯** menu and choose **🗂️ Change CWD** to switch the selected session's next
|
|
177
|
-
launch directory from the list of known directories. The CWD picker always stays available; the
|
|
178
|
-
launch action is only enabled for the selected CLI when it is not running.
|
|
179
|
-
|
|
180
|
-
### Socket events for CWD changes
|
|
181
|
-
|
|
182
|
-
| Event | Emitter | Meaning |
|
|
183
|
-
|---|---|---|
|
|
184
|
-
| `workspace_root_changed` | Admin CWD picker (`POST /api/workspace-root`) | Global relay root changed; affects all sessions that have no per-session root configured |
|
|
185
|
-
| `conversation_workspace_root_updated` | Chat `/cd` command and conversation CWD API | Per-conversation configured root updated; only the referenced conversation is affected |
|
|
186
|
-
|
|
187
|
-
## Install as an app
|
|
188
|
-
|
|
189
|
-
The UI is now a Progressive Web App. On Android Chrome, use the browser menu to choose
|
|
190
|
-
**Install app** or **Add to Home screen**. Installed app mode now prefers fullscreen launch
|
|
191
|
-
where supported (with standalone fallback), and hides install/fullscreen header buttons.
|
|
192
|
-
|
|
193
|
-
When opened in a regular browser tab, the in-app **Install** button remains available
|
|
194
|
-
in the chat header (shown as `⬇` on small screens).
|
|
195
|
-
|
|
196
|
-
You can rename the installed app label from **⚙️ Settings → Relay name** (paired relays know this relay by the same name).
|
|
197
|
-
The name is stored on the relay (app settings) and served in the web app manifest
|
|
198
|
-
to every browser and origin (LAN and tunnel agree), so future installs pick it up
|
|
199
|
-
immediately and already-installed Android apps receive it through Chrome's normal
|
|
200
|
-
manifest-update flow — one confirmation prompt showing the new name, then the
|
|
201
|
-
launcher label updates. A legacy per-browser name is adopted by the relay the
|
|
202
|
-
first time that browser loads the app.
|
|
203
|
-
|
|
204
|
-
**💤 Suspend host** (Windows relay hosts) puts the PC into suspend-to-RAM once
|
|
205
|
-
nothing is running any more. The confirmation modal first lists what is still
|
|
206
|
-
active — sessions with a running turn, sessions with live background agents, and
|
|
207
|
-
open GitHub Actions runs in the workspaces of those sessions (read through the
|
|
208
|
-
host's `gh` CLI; an unreadable CI state blocks for 15 minutes, then is ignored).
|
|
209
|
-
Confirming queues the request: the relay polls every second and suspends after
|
|
210
|
-
everything has been idle for 2 minutes (or 30 seconds when nothing was running
|
|
211
|
-
at confirm time). While a suspend is queued every connected client shows a
|
|
212
|
-
banner with the blockers or the countdown and a **Cancel** button, the menu entry
|
|
213
|
-
reads **💤 Suspend pending…**, and reopening the modal offers to cancel. A relay
|
|
214
|
-
restart drops the queued suspend; a push notification (event **Host suspend**)
|
|
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**
|
|
219
|
-
gets a banner of its own, with the sessions it waits for, **Restart now** and
|
|
220
|
-
**Cancel restart** (see [Manual relay shutdown / self-restart](#manual-relay-shutdown--self-restart)).
|
|
221
|
-
|
|
222
|
-
API: `GET /api/host/suspend` (state plus current blockers), `POST /api/host/suspend`,
|
|
223
|
-
`POST /api/host/suspend/cancel`, and `POST /api/relay/shutdown/cancel`
|
|
224
|
-
(token only, like the restart itself). Set `OAR_HOST_SUSPEND_DRY_RUN=1` to log instead of sleeping;
|
|
225
|
-
`OAR_BOOT_REPORTING_WINDOW_MS` changes the 45 seconds (0 to 600000; test relays
|
|
226
|
-
pin `0`, which switches the wait off; a negative value is ignored); an override in effect is
|
|
227
|
-
logged at start.
|
|
228
|
-
|
|
229
|
-
You can also hide the **💤 Suspend host** action from **⚙️ Settings** with the
|
|
230
|
-
**Show Suspend host action** checkbox. This only controls the menu entry; the
|
|
231
|
-
banner for an already queued suspend still shows on every device.
|
|
232
|
-
|
|
233
|
-
The live thinking bubble's header carries three follow buttons on the right —
|
|
234
|
-
**💭** thoughts, **📃** streamed answer, **🛠️** tool list. Arming one keeps that
|
|
235
|
-
section's last line pinned just above the composer as the reply grows; scrolling
|
|
236
|
-
the transcript by hand (wheel, touch, scroll keys) disarms it, typing in the
|
|
237
|
-
composer does not. The armed mode is remembered per conversation for the life of
|
|
238
|
-
the page. **Stop** now asks for confirmation before cancelling the turn.
|
|
239
|
-
|
|
240
|
-
If you host the relay behind a subpath, set `remotePath` in `server/config.json` to that public path prefix and open the URL with a trailing slash so the PWA scope matches correctly for install prompts. The relay serves a path-relative manifest identity (`id`, `start_url`, `scope`) so each install stays bound to its own URL subtree and avoids cross-app collisions on shared origins.
|
|
241
|
-
|
|
242
|
-
## Model Selection
|
|
243
|
-
|
|
244
|
-
The composer includes a model picker next to the Send button.
|
|
245
|
-
Models are now populated dynamically from the active Copilot CLI runtime using
|
|
246
|
-
raw model IDs (no relay-specific aliases). The selected model ID is sent as-is
|
|
247
|
-
for each message.
|
|
248
|
-
|
|
249
|
-
Behavior notes:
|
|
250
|
-
|
|
251
|
-
- The picker refreshes from live CLI model discovery when relay status changes.
|
|
252
|
-
- Selection is persisted in browser storage and reused if still available.
|
|
253
|
-
- If live discovery is temporarily unavailable, the relay can use cached/current
|
|
254
|
-
model state and shows a warning banner.
|
|
255
|
-
- In extension-managed mode, the relay still switches model per message and
|
|
256
|
-
reports the active model used in the response.
|
|
257
|
-
|
|
258
|
-
### A message without a model, an effort or a mode
|
|
259
|
-
|
|
260
|
-
The composer always sends all three. A client that leaves one out of
|
|
261
|
-
`POST /api/message` (a script, an agent on this relay) gets the conversation's
|
|
262
|
-
own, never the relay-wide default:
|
|
263
|
-
|
|
264
|
-
- **Model**, for an existing Copilot conversation: the conversation's saved
|
|
265
|
-
`preferred_model`, then the runtime session's `provider_model`, then its
|
|
266
|
-
`model`, then the model of the latest user message (`auto` skipped in each);
|
|
267
|
-
only a conversation with none of these runs on the catalog's current model.
|
|
268
|
-
A saved model the catalog no longer lists is refused with
|
|
269
|
-
`409 CONVERSATION_MODEL_UNAVAILABLE` (`model`, `supportedModels`) instead of
|
|
270
|
-
being replaced; the composer shows that text in its banner. An explicit
|
|
271
|
-
`auto` is unchanged (new conversations only). Claude, Cursor, Grok, Claude
|
|
272
|
-
Cloud and OpenAI conversations keep their pinned `provider_model`, as before.
|
|
273
|
-
- **Effort**: for Copilot, an effort the model's variant id carries, else the
|
|
274
|
-
conversation's `preferred_reasoning_effort`, clamped to the nearest level the
|
|
275
|
-
model offers (`nearestSupportedReasoningEffort`, the composer's rule); with
|
|
276
|
-
nothing to keep, the model's lowest level that is not `none`, so `none` is
|
|
277
|
-
chosen only when the conversation asked for it or the model has no other
|
|
278
|
-
level. OpenAI, Claude, Cursor and Grok conversations take the conversation's
|
|
279
|
-
effort the same way; with no effort stored, or one that neither matches nor
|
|
280
|
-
clamps (an `ultracode` on a model without it), they keep the provider's own
|
|
281
|
-
default as before (`none`, `low` for an OpenAI o-series model; for Claude,
|
|
282
|
-
Cursor and Grok `none` means the model's default). A Claude Cloud conversation always runs with `none`: the
|
|
283
|
-
cloud takes no effort. A board action's follow-up takes the conversation's
|
|
284
|
-
effort as well.
|
|
285
|
-
- **Mode**: a send that names no `relayMode`/`mode` runs in the
|
|
286
|
-
conversation's saved `preferred_relay_mode`, not the relay default.
|
|
287
|
-
- **Preferences**: a send to an existing conversation saves only what it named.
|
|
288
|
-
A model it named is saved (with its effort only when that was named too, else
|
|
289
|
-
the stored effort stays); an effort alone is saved against the stored model,
|
|
290
|
-
and a conversation with no saved model runs with that effort but saves
|
|
291
|
-
nothing; a send that named neither writes nothing. The mode saved with them
|
|
292
|
-
is the one the turn runs in, so a send without a mode leaves the stored mode
|
|
293
|
-
as it is. A send from another relay's agent never writes them, as before.
|
|
294
|
-
|
|
295
|
-
A text repeat inside the duplicate window answers `200` with `duplicate: true`
|
|
296
|
-
and `queued: false`, and nothing runs. A message whose turn **failed** no
|
|
297
|
-
longer counts: sending it again queues it.
|
|
298
|
-
|
|
299
|
-
### OpenAI API key
|
|
300
|
-
|
|
301
|
-
Open **Settings → Providers → OpenAI** to save an OpenAI API key, the default model ID (`gpt-4o`
|
|
302
|
-
if none) and an optional **Base URL** (`https://api.openai.com/v1` when empty; any
|
|
303
|
-
OpenAI-compatible endpoint, or the capture proxy `server/tools/capture-proxy.mjs`). **Enable OpenAI
|
|
304
|
-
API key for New Chat model selection** makes OpenAI selectable in New Chat without re-entering the
|
|
305
|
-
key. An OpenAI conversation runs on the Copilot engine the relay has selected, extension or SDK:
|
|
306
|
-
the worker is started with `COPILOT_PROVIDER_TYPE=openai`, `COPILOT_PROVIDER_BASE_URL` (the saved
|
|
307
|
-
base URL), `COPILOT_PROVIDER_API_KEY`, `COPILOT_PROVIDER_WIRE_API` and `COPILOT_MODEL`
|
|
308
|
-
(`applyOpenAIProviderEnvironment`); the CLI reads them on the extension engine, the SDK worker
|
|
309
|
-
turns them into the session's provider (`copilot-worker/copilot-byok-provider.mjs`). Started
|
|
310
|
-
conversations keep their provider and lock the composer to their model.
|
|
311
|
-
|
|
312
|
-
GPT-5, `codex-*` and the o1/o3/o4 models use the Responses API; other OpenAI models use Chat
|
|
313
|
-
Completions (`resolveOpenAIWireApi`). The reasoning efforts follow the model family
|
|
314
|
-
(`shared/openai-reasoning.mjs`): GPT-5 and `codex` offer `none`, `low`, `medium`, `high` and
|
|
315
|
-
`xhigh`, the o-series `low`, `medium` and `high`, other models only `none` (image models take
|
|
316
|
-
`auto`, `low`, `medium` and `high` as their quality). An effort the model does not offer is refused
|
|
317
|
-
with `400`; with none named the conversation's own applies (clamped to the model's levels), else
|
|
318
|
-
`none`, or `low` on a model without `none`. GitHub Copilot's reasoning choices stay separate even when both providers list the
|
|
319
|
-
same model ID.
|
|
320
|
-
|
|
321
|
-
Saving or re-enabling the key refreshes OpenAI's `/v1/models` list and adds
|
|
322
|
-
candidate OpenAI model IDs to the composer picker. The Settings model-variant
|
|
323
|
-
refresh performs the same discovery while OpenAI is enabled and retains the
|
|
324
|
-
cached list if discovery fails. Starting a conversation uses the cached list; it
|
|
325
|
-
does not make another discovery request. The model selected when creating a
|
|
326
|
-
conversation is persisted on that conversation and used as its `COPILOT_MODEL`;
|
|
327
|
-
the Settings model is only the default. Starting a chat from a locked OpenAI
|
|
328
|
-
conversation opens an independent model chooser without changing the active
|
|
329
|
-
conversation.
|
|
330
|
-
|
|
331
|
-
Disabling OpenAI retains the key and cached model list. **Remove saved key** deletes the key and the
|
|
332
|
-
cached list and switches OpenAI off, and it is refused with `409 openai-key-removal-blocked`
|
|
333
|
-
(`activeConversationCount`, `startedConversationCount`, `activeQueueConversationCount`) while any
|
|
334
|
-
OpenAI conversation that is not deleted, image conversations and archived ones included, has a
|
|
335
|
-
message or a pending, processing or parked queue row (`setOpenAIProviderSettings` in
|
|
336
|
-
`server-runtime.mjs`). Disable OpenAI instead, or delete those conversations first. A removal that
|
|
337
|
-
goes through moves the OpenAI conversations without a message to GitHub Copilot.
|
|
338
|
-
|
|
339
|
-
### Claude models
|
|
340
|
-
|
|
341
|
-
When the Claude provider is enabled, discovered `claude-*` IDs join the same catalog. The composer
|
|
342
|
-
only offers models the active conversation's provider can serve, so Claude models are hidden in
|
|
343
|
-
Copilot/OpenAI conversations and vice versa.
|
|
344
|
-
|
|
345
|
-
**Select Models** has one tab per runtime — **Copilot**, **OpenAI**, **Claude SDK**, **Cursor SDK** —
|
|
346
|
-
and each tab lists only rows contributed by that runtime. The Claude and Cursor tabs write their
|
|
347
|
-
selection through `POST /api/settings/claude` / `POST /api/settings/cursor` (`enabledModels`) rather
|
|
348
|
-
than the model-variant catalog; the configured default model is always enabled and cannot be
|
|
349
|
-
deselected, since deselecting it would leave that provider's conversations without a model. An empty
|
|
350
|
-
selection means "all available".
|
|
351
|
-
|
|
352
|
-
`POST /api/model-variants/refresh` refreshes all enabled providers concurrently and reports
|
|
353
|
-
`openAIModelDiscovery` and `claudeModelDiscovery` separately, so one provider's failed discovery does
|
|
354
|
-
not hide another's success — the cached list is kept and a warning is surfaced in the UI.
|
|
355
|
-
|
|
356
|
-
## Claude Agent SDK provider
|
|
357
|
-
|
|
358
|
-
Claude conversations are executed by `server/claude-worker/claude-session-worker.mjs`, a plain Node
|
|
359
|
-
process spawned per conversation. It is not part of the Copilot CLI extension: it connects to the
|
|
360
|
-
same worker WebSocket, sends the same heartbeats, and posts to the same activity/stream/thought/
|
|
361
|
-
response endpoints, but runs turns through `@anthropic-ai/claude-agent-sdk` instead of the Copilot SDK.
|
|
362
|
-
|
|
363
|
-
### Enabling
|
|
364
|
-
|
|
365
|
-
**Settings → Claude (Agent SDK) → Enable Claude for New Chat model selection.**
|
|
366
|
-
|
|
367
|
-
Authentication uses the relay host's own logged-in Claude credentials (`~/.claude`) — the relay never
|
|
368
|
-
stores an Anthropic API key. Run `claude` on the host and complete login before enabling. A turn that
|
|
369
|
-
fails to authenticate is answered with a system note telling you to do exactly that, rather than
|
|
370
|
-
being retried silently.
|
|
371
|
-
|
|
372
|
-
Enabling (or changing the default model) triggers model discovery: a short-lived idle `query()` is
|
|
373
|
-
opened purely to serve a `supportedModels()` control request, with a 20 s timeout. Discovered
|
|
374
|
-
`claude-*` IDs — including bracketed capability variants such as `claude-opus-5[1m]` — are stored and
|
|
375
|
-
added to the model catalog. Bare aliases (`default`, `sonnet`) are dropped in favour of the explicit IDs.
|
|
376
|
-
In the composer, `[1m]` variants are folded into their base model: the base ID is the only dropdown
|
|
377
|
-
entry, and the variant is selected through the context-size dropdown (`long_context` composes the
|
|
378
|
-
`[1m]` ID on send and in the persisted per-conversation model preference).
|
|
379
|
-
|
|
380
|
-
Disabling Claude rebinds conversations that have not yet sent a message back to the default provider,
|
|
381
|
-
mirroring the OpenAI key-removal reconciliation. Conversations that have already started keep their
|
|
382
|
-
provider, and other providers' bindings are never touched.
|
|
383
|
-
|
|
384
|
-
### Per-turn behavior
|
|
385
|
-
|
|
386
|
-
| Relay mode | Claude mapping |
|
|
387
|
-
| ----------- | --------------------------------------------------------------------------- |
|
|
388
|
-
| `plan` | SDK `permissionMode: 'plan'`; `ExitPlanMode` publishes a **plan_ready** board, then the tool call is **denied** so the turn ends for review instead of rolling straight into implementation — the board's choice starts the next turn in the chosen mode |
|
|
389
|
-
| `ask` | System prompt appended to prefer `AskUserQuestion` before implementing |
|
|
390
|
-
| `agent` | Default permission mode, unmodified preset prompt |
|
|
391
|
-
| `autopilot` | System prompt appended to keep moving unless input is truly blocking |
|
|
392
|
-
|
|
393
|
-
- **Model and reasoning effort are per turn.** Each turn is a fresh `query()` with `resume`, so both
|
|
394
|
-
can change between messages. Effort levels are `none` (SDK default) plus whatever the model reports
|
|
395
|
-
(`low`, `medium`, `high`, `xhigh`, `max`), plus **Ultracode** — see below.
|
|
396
|
-
- **Ultracode** is offered on every model whose discovered efforts include `xhigh`. It is not an SDK
|
|
397
|
-
effort level but a session-scoped settings flag, so the relay carries `ultracode` as a sentinel on
|
|
398
|
-
the ordinary effort ladder (`withClaudeUltracodeTier` in `services/provider-reasoning-effort.mjs`,
|
|
399
|
-
the single source for all Claude effort lists) and only the worker translates it: a fresh session
|
|
400
|
-
spawns with `effort: 'xhigh'` and `settings: { ultracode: true, enableWorkflows: true }`, and a
|
|
401
|
-
live session is toggled in or out with `applyFlagSettings` (`claudeUltracodeFlagSettings`).
|
|
402
|
-
`enableWorkflows` is passed explicitly because the worker loads no filesystem settings. It is never
|
|
403
|
-
a silent default — the selectors label the rung "Ultracode" and warn about the token cost.
|
|
404
|
-
- **Tools are auto-allowed**, matching the Copilot workers' `--allow-all` posture. Only two tools are
|
|
405
|
-
intercepted: `AskUserQuestion` (bridged to relay question cards) and `ExitPlanMode` (plan board;
|
|
406
|
-
denied after the board posts — the plan text comes from the tool input, or from the CLI's
|
|
407
|
-
`planFilePath` when no inline plan is sent).
|
|
408
|
-
- **Attachments** — images up to 5 MB are inlined as base64 content blocks; larger images and all
|
|
409
|
-
non-image files are passed as absolute path references for Claude's `Read` tool.
|
|
410
|
-
- **Subagents** — `forwardSubagentText` is on, so subagent text, thoughts, and activity stream into
|
|
411
|
-
their own nested bubbles. `abort_subagent` maps to `query.stopTask()` for a backgrounded subagent;
|
|
412
|
-
one running inside the current turn is still answered "not supported". Full-turn **Stop** works.
|
|
413
|
-
- **Thinking** — the worker requests summarized thinking *display* without passing a `thinking`
|
|
414
|
-
option, so it never switches thinking on for a session that has it off, and never changes a budget.
|
|
415
|
-
|
|
416
|
-
### Session continuity
|
|
417
|
-
|
|
418
|
-
The native Agent SDK session id observed on the first turn is posted to
|
|
419
|
-
`POST /api/claude-native-session` and stored in `runtime_sessions.claude_native_session_id`. Later
|
|
420
|
-
turns pass it back as `resume`, so a Claude conversation survives worker restarts. The id is only
|
|
421
|
-
cached in the worker after the server accepts it, so a failed persist is retried next turn.
|
|
422
|
-
|
|
423
|
-
### Background tasks and workflow progress
|
|
424
|
-
|
|
425
|
-
The worker keeps one persistent CLI process per conversation, so background tasks survive the reply
|
|
426
|
-
that started them. The live task set is posted to `POST /api/background-tasks` (REPLACE semantics)
|
|
427
|
-
and broadcast as the `background_tasks` socket event; the composer's task panel renders it and can
|
|
428
|
-
stop an individual task over a `worker.control` push. The conversation payload carries the set too
|
|
429
|
-
(`backgroundTasks`, `endedBackgroundTasks`); the page's live poll and refresh leave it out when a
|
|
430
|
-
`background_tasks` event for the conversation arrived while their request was on its way, as the
|
|
431
|
-
event's set is at least as new.
|
|
432
|
-
|
|
433
|
-
The relay holds the task sets in memory (`services/background-task-store.mjs`), so a relay restart
|
|
434
|
-
starts without them while the workers and their tasks keep running (the console rows are stored;
|
|
435
|
-
see below). The worker is the source of
|
|
436
|
-
truth, and the relay asks:
|
|
437
|
-
|
|
438
|
-
- **Digest and resync.** A worker's heartbeat carries `backgroundTasks: { count, digest }`, a digest
|
|
439
|
-
of its sorted task ids (`shared/worker-runtime/background-task-digest.mjs`). When it differs from
|
|
440
|
-
the set held for that worker (a restart, or a lost post either way round), the heartbeat answer
|
|
441
|
-
says `resyncBackgroundTasks: true` and the worker posts its full set with `resync: true`; the
|
|
442
|
-
request repeats on every beat until the two agree. A task restored this way keeps its start time
|
|
443
|
-
and is not announced as started again. Every post of a set carries `order: { epoch, seq }` (the
|
|
444
|
-
worker process and its count of set posts): two posts can overtake each other on the way, and the
|
|
445
|
-
relay drops one that is older than the newest it applied from that process, or that comes from a
|
|
446
|
-
process other than the one whose heartbeat came last for the session. A drop is logged and
|
|
447
|
-
answered `stale: true` with a `reason`; a worker told so for its newest set asks for a heartbeat,
|
|
448
|
-
on which the relay asks for the set again. `epoch` only names the process and is never compared
|
|
449
|
-
as a time. A task that ended only because a set did not list it comes back, without a second
|
|
450
|
-
start, when a later set lists it, unless a settle notice or its run's status has confirmed the
|
|
451
|
-
end meanwhile. A worker that sends no digest (Cursor, Grok, Claude Cloud, or a worker on older
|
|
452
|
-
code) has no opinion and is never asked.
|
|
453
|
-
- **Placeholder.** A live Claude worker on older code cannot answer, but its heartbeat says whether
|
|
454
|
-
background work holds its process (`steering.autoCompactWindow.backgroundWork`). While this
|
|
455
|
-
runtime has not heard from that worker about its tasks, the session reads `background_only` with
|
|
456
|
-
one task "background work, details not reported since the relay restarted (Kill session in its ⋯
|
|
457
|
-
menu ends it)", the panel shows the
|
|
458
|
-
same row without a Stop button, and a host suspend waits. It is derived on read
|
|
459
|
-
(`services/background-work-view.mjs`), raises no event, and goes when the set arrives, the flag
|
|
460
|
-
goes false or the worker is gone.
|
|
461
|
-
- **Ends.** The Claude worker keeps a settle notice whose post failed (the newest 20, for 10
|
|
462
|
-
minutes) and sends it again after the next heartbeat the relay answers; such a post is given up
|
|
463
|
-
after 10 s without an answer.
|
|
464
|
-
- **The console rows.** What the task console needs of a shell or Monitor task (its `outputPath`
|
|
465
|
-
and `outputKind`, the ended row with status, exit code and `endedAt`, how a task ended when the
|
|
466
|
-
settle came before the set dropped it) is kept in `background_task_rows` (migration 0018,
|
|
467
|
-
`services/background-task-persistence.mjs`), written only when that part of a set changes, not on
|
|
468
|
-
every progress post. At boot the store restores the ended rows still inside their hour, and holds
|
|
469
|
-
the live ones (`restored`): the output route and the console's live room find a held task (the
|
|
470
|
-
route answers it with `status: 'held'`, `held: true` and `stoppable: false`; the console says it
|
|
471
|
-
waits for its worker), but `get()` and with it the panel, the session state and the suspend check
|
|
472
|
-
do not count it as running, and the stop route only pushes the stop to the worker. The worker's next set settles it (a task in the set is live again, with the
|
|
473
|
-
path it had; one not in it becomes an ended row, with the status the runtime before was told), a
|
|
474
|
-
settle sent again for it ends it, and while a key has held tasks its heartbeat digest never
|
|
475
|
-
counts as in step, so the relay asks for the set. Tasks of a worker that sends no set within twice
|
|
476
|
-
the boot reporting window (at least 15 s; 90 s by default) are ended with status `unknown` and the
|
|
477
|
-
pages get the `background_tasks` event (`endUnconfirmed`); a later set that lists one brings it
|
|
478
|
-
back. A held task's end time is when the runtime before last knew it running: a stored live row's
|
|
479
|
-
`updated_at` is written with the set and confirmed at most every 30 minutes while the worker's
|
|
480
|
-
digest agrees, and a live row not confirmed for 6 hours (a worker that went away in an earlier
|
|
481
|
-
runtime without an empty set) is deleted at boot instead of restored. Health readings are not
|
|
482
|
-
stored: the poller reads the files again within 10 s, the ended rows once. A runner's counts come
|
|
483
|
-
from the last 64 KB when its closing summary is there. A live log that outgrew that window is read
|
|
484
|
-
whole by a per-task runner scan (`services/task-output-service.mjs`): it looks for a runner's
|
|
485
|
-
header (a test run after a long install or build prints its header once) and counts from there,
|
|
486
|
-
reads on from where it stopped as the file grows, and starts over on a new file under the path
|
|
487
|
-
(shorter, another inode, an older mtime). The scans live in memory and share 4 MB per tick, each
|
|
488
|
-
tick starting at another task, so after a restart a long log is caught up over a few ticks (the
|
|
489
|
-
window's reading stands until then) and one busy log cannot keep the others waiting. An ended row
|
|
490
|
-
never starts a scan, so the rows a restart brings back are read in their window only. What a
|
|
491
|
-
worker says about a task (description, summary, last tool call, and a shell row's `command`, which
|
|
492
|
-
the Claude worker takes from the Bash or Monitor call that started the task, Copilot from the
|
|
493
|
-
shell tool's arguments or the task registry, Grok from the terminal) goes through `cleanTaskText`
|
|
494
|
-
on the publish and settle routes: control codes and colours out, credentials scrubbed as in the
|
|
495
|
-
output, then cut (a command at 2,000 characters; a Grok description is its command line). It is
|
|
496
|
-
stored with the console rows, so a held or ended row keeps it; share links carry no task rows. The
|
|
497
|
-
`none` answers of the output route use the page's texts (`TASK_OUTPUT_NONE_MESSAGES` in
|
|
498
|
-
`public/app/task-console-render.mjs`). Rows go with their hour, the 20-per-key limit, a set that
|
|
499
|
-
drops them or the conversation's delete. Without the table (a failed migration) the store works in
|
|
500
|
-
memory as before.
|
|
501
|
-
- **Run rows left `running`.** A background sub-agent's row whose end was never written would count
|
|
502
|
-
as running work for a day. For a Claude worker that has reported its task set in this runtime, a
|
|
503
|
-
`running` background row that the set does not list and that nothing has updated for 15 minutes
|
|
504
|
-
is left out of the session state, `all_idle` and the suspend blockers.
|
|
505
|
-
|
|
506
|
-
Tasks of type `local_workflow` carry an extra `workflowProgress` digest, because the SDK exposes no
|
|
507
|
-
workflow-progress surface — the worker reads it off disk:
|
|
508
|
-
|
|
509
|
-
- While the workflow runs, `subagents/workflows/<runId>/journal.jsonl` under the session directory
|
|
510
|
-
gives the agent set and their running/done state; agent labels come from the first line of each
|
|
511
|
-
`agent-<id>.jsonl`.
|
|
512
|
-
- The run record `workflows/<runId>.json` is written **only at completion**, and takes over as the
|
|
513
|
-
authoritative tree once it exists — it is the only source of phase titles, logs, token totals, and
|
|
514
|
-
per-agent tool counts.
|
|
515
|
-
- Parsing lives in `claude-worker/workflow-progress-digest.mjs` (`digestFromJournal`,
|
|
516
|
-
`digestFromRunRecord`), polling in `claude-session-process.mjs` (`syncWorkflowPoller`, 2 s, only
|
|
517
|
-
while such a task is live, published only when the digest actually changed). Reads are bounded by
|
|
518
|
-
byte caps and a filename pattern, and the digest is clamped (≤100 agents, ≤50 phases, ≤5 log lines,
|
|
519
|
-
per-field length caps). A run is joined to its task by the record's `taskId`, with a start-time
|
|
520
|
-
mtime window as the mid-run fallback.
|
|
521
|
-
- The relay never trusts the worker's JSON: `sanitizeWorkflowProgress` (`routes/messages-routes.mjs`)
|
|
522
|
-
re-applies every clamp before storing or broadcasting, and drops a digest with no phases and no
|
|
523
|
-
agents.
|
|
524
|
-
|
|
525
|
-
When a workflow settles, the worker buffers its final digest (at most five, terminal status never
|
|
526
|
-
downgraded) and attaches it to the next `POST /api/response` as `workflowRuns`. Runs that settled on
|
|
527
|
-
a journal snapshot get one more run-record read at drain time, since the CLI often flushes the record
|
|
528
|
-
a few seconds after the task disappears. The relay writes those rows into `workflow_runs` inside the
|
|
529
|
-
same transaction that finalizes the assistant message, keyed on the response message id, and returns
|
|
530
|
-
them with the message — so the transcript's collapsed **Finished background task** card (the same
|
|
531
|
-
tree renderer as the live panel, `buildWorkflowRunCard` in `public/app/background-tasks-view.mjs`)
|
|
532
|
-
survives a reload. A history rebuild deletes a conversation's `workflow_runs` alongside its
|
|
533
|
-
`subagent_runs`, because both key on message ids the rebuild replaces.
|
|
534
|
-
|
|
535
|
-
### Sub-agent records (the sub-agent viewer)
|
|
536
|
-
|
|
537
|
-
Every sub-agent run keeps its whole record in `subagent_timeline` (migration 0009): one row per
|
|
538
|
-
thought, reply text, tool call (full input), tool result (cut at 4 KB, the original length kept in
|
|
539
|
-
the entry's metadata), child agent or note, numbered per run by `seq` in the order the relay first
|
|
540
|
-
saw it. A writer names each entry with its own `entry_key`; a second write under the same key
|
|
541
|
-
updates the entry in place and keeps its `seq`, which is how a thought or a reply streams. A run
|
|
542
|
-
keeps at most 2000 entries plus one note saying where it was cut (`subagent_runs.truncated`).
|
|
543
|
-
`subagent_runs` also carries the run header the rows show: model, type, task id, tokens, whether it
|
|
544
|
-
runs in the background, and who writes its timeline.
|
|
545
|
-
|
|
546
|
-
- **Claude**: `claude-worker/subagent-timeline-recorder.mjs` reads every SDK message at process
|
|
547
|
-
level, before any turn takes it, and posts entries to `POST /api/subagent-timeline` keyed by the
|
|
548
|
-
run (coalesced every 500 ms; a run the relay does not know yet is retried). A background agent
|
|
549
|
-
outlives the turn that spawned it, so its frames arrive between turns and during later turns;
|
|
550
|
-
they go to its own record, never to a later turn's live lanes. A background launch's receipt
|
|
551
|
-
(`tool_use_result.status: 'async_launched'`) leaves the run running; its `task_notification`
|
|
552
|
-
ends it. Spawns carry `timelineOwner: 'worker'`. A finished background agent that the session
|
|
553
|
-
sends a new message (SendMessage) runs again as the same task: `task_started` names its task id
|
|
554
|
-
again, with a new `run_id` and the SendMessage call as `tool_use_id`. The recorder leads that id
|
|
555
|
-
back to the agent's first run, so the record goes on under the same run (a note marks where it
|
|
556
|
-
resumed, and the run reads `running` again until its next `task_notification`); the task panel
|
|
557
|
-
row, the settle notice and a targeted stop name that run too. Every timeline post also names the
|
|
558
|
-
run's `taskId`, for a worker that did not see the first run (see the route below).
|
|
559
|
-
- **Copilot and Cursor** publish their sub-agents on the live lanes only (`/api/activity`,
|
|
560
|
-
`/api/thought`, `/api/stream` with a `subagentRunId`); the relay copies each frame into the run's
|
|
561
|
-
timeline, uncut. Fields they do not report (model, tokens) stay blank.
|
|
562
|
-
- `POST /api/background-tasks` copies model, type, task id and tokens onto the run whose id is the
|
|
563
|
-
task's `toolUseId`, so the row keeps them after the in-memory task set is cleared. A sub-agent
|
|
564
|
-
task whose `toolUseId` is no run (a resumed agent from a worker that did not see its first run)
|
|
565
|
-
gets the id of the conversation's run that carries its task id, so the row opens that record.
|
|
566
|
-
- Every `subagent_status` event carries the whole header and the run's own turn (`messageId`). A
|
|
567
|
-
turn that fails closes its foreground runs; background runs are closed by their worker, or by the
|
|
568
|
-
relay when the worker dies.
|
|
569
|
-
|
|
570
|
-
The browser draws one row per top-level run in the reply bubble (`public/app/subagent-row.mjs`, the
|
|
571
|
-
panel's row grammar); a row's title, like a sub-agent task's title in the composer panel, opens
|
|
572
|
-
`public/app/subagent-viewer.mjs`. The viewer reads `GET /api/conversation/:id/subagent/:runId`, grows
|
|
573
|
-
with the `subagent_timeline` event, and fetches again from the last contiguous `seq` after a missed
|
|
574
|
-
event or a reconnect. A run recorded before the timeline existed is served from its tagged
|
|
575
|
-
activity, thought and stream rows (`legacy: true`).
|
|
576
|
-
|
|
577
|
-
**Live delivery.** `subagent_timeline` goes only to the pages whose viewer shows that run. The viewer
|
|
578
|
-
names its run with the socket message `subagent_viewer_watch { conversationId, subagentRunId }`
|
|
579
|
-
(acknowledged with `{ ok: true }` or `{ ok: false, code }`: `missing-id`, `not-found`,
|
|
580
|
-
`conversation-mismatch`) when it opens, moves to a child or its parent, and after every reconnect,
|
|
581
|
-
and sends `subagent_viewer_unwatch` when it closes. The relay checks the run exists and belongs to
|
|
582
|
-
the conversation and keeps each socket in at most one room, `subagent-viewer:<runId>`
|
|
583
|
-
(`services/subagent-viewer-rooms.mjs`). Batches go to that room as volatile packets, so they are
|
|
584
|
-
never kept for a reconnect replay and a backed-up connection drops them. socket.io also drops a
|
|
585
|
-
volatile packet while the connection is still writing an earlier one, even from the same tick
|
|
586
|
-
(a worker post that changes the header emits `subagent_status` first), so the relay holds batches
|
|
587
|
-
one tick, merged per run, before sending. The viewer covers what is dropped: it waits for the
|
|
588
|
-
watch's answer before its first read, fetches on a `seq` hole, after a reconnect, and once more when
|
|
589
|
-
the run ends. A refused `not-found` watch is sent again on the run's next `subagent_status`. The lane
|
|
590
|
-
events (`relay_activity`, `relay_thought`, `relay_stream`, `subagent_status`) go to every page as
|
|
591
|
-
before.
|
|
592
|
-
|
|
593
|
-
**Retention.** After `subagent_record_retention_days` (app setting, `0` = keep for good, otherwise 7,
|
|
594
|
-
14, 30 or 90; default 90; Settings → General → *Keep sub-agent tool output*) the relay strips a
|
|
595
|
-
finished run's tool entries: a `tool_result` keeps its row with empty text, a `tool_call` keeps its
|
|
596
|
-
one-line summary (`subagentToolCallSummary` in `shared/subagent-timeline.mjs`, the viewer's rule) in
|
|
597
|
-
place of the input. Both get `pruned_at` (migration 0010) and metadata
|
|
598
|
-
`{ pruned: true, prunedAt, originalChars, retentionDays }`; the viewer shows them as "Input removed
|
|
599
|
-
after N days" / "output removed after N days". Thoughts, reply text, notes, child rows and the runs
|
|
600
|
-
stay, and the age is the entry's `updated_at`; runs still `running` (or `pending`, `processing`) are
|
|
601
|
-
never touched, nor are the lane rows. `services/subagent-record-retention.mjs` sweeps 30 s after
|
|
602
|
-
start, once a day and 5 s after the setting changes, 2000 rows per transaction with the event loop
|
|
603
|
-
free in between, through a partial index over the unstripped tool entries, and logs one
|
|
604
|
-
`SUBAGENT RETENTION …` line. No VACUUM: SQLite reuses the freed pages.
|
|
605
|
-
|
|
606
|
-
### Context usage
|
|
607
|
-
|
|
608
|
-
Claude sessions have no Copilot `events.jsonl` to tail. Instead the worker reads the live
|
|
609
|
-
context-window breakdown with `getContextUsage()` while the turn's `Query` is still open, and posts it
|
|
610
|
-
to `POST /api/claude-context-usage`; the relay stores it in `runtime_sessions.context_usage_json`.
|
|
611
|
-
`GET /api/context/:id` then serves Claude and Copilot sessions through one normalized `contextUsage`
|
|
612
|
-
view so a single renderer handles both. Claude supplies real per-category token counts; Copilot's
|
|
613
|
-
coarse system/tools + messages + buffer split is synthesized into the same shape and flagged
|
|
614
|
-
`isEstimate` when the runtime only reports a lower bound.
|
|
615
|
-
|
|
616
|
-
### Worker environment
|
|
617
|
-
|
|
618
|
-
`applyClaudeProviderEnvironment` sets these on the worker launch environment:
|
|
619
|
-
|
|
620
|
-
- `COPILOT_WEB_RELAY_WORKER_KIND=claude` — selects the Claude worker instead of the Copilot CLI
|
|
621
|
-
- `CLAUDE_RELAY_MODEL` — the conversation's model, used when a turn carries none
|
|
622
|
-
|
|
623
|
-
Also honored by the worker: `COPILOT_WORKSPACE_ROOT` (cwd), `COPILOT_WEB_RELAY_CONFIG` (relay config
|
|
624
|
-
path), `CLAUDE_CODE_EXECUTABLE` (explicit Claude Code binary), and
|
|
625
|
-
`COPILOT_WEB_RELAY_CLAUDE_WORKER_PATH` (override for the worker script location).
|
|
626
|
-
|
|
627
|
-
On Linux/macOS the Claude worker still launches inside a `tmux` session named after the SDK session
|
|
628
|
-
id, but without the `script`-based pseudo-TTY the Copilot workers need — it is a plain Node process.
|
|
629
|
-
On Windows it opens a `Claude Worker <id>` terminal window.
|
|
630
|
-
|
|
631
|
-
### Provider-aware usage snapshots
|
|
632
|
-
|
|
633
|
-
`fetchUsageSummary` reads GitHub Copilot plan quota, so it is skipped entirely for OpenAI and Claude
|
|
634
|
-
turns. Those replies carry no usage line rather than displaying Copilot premium-request numbers under
|
|
635
|
-
a reply that never touched Copilot.
|
|
636
|
-
|
|
637
|
-
### Plan usage for agents
|
|
638
|
-
|
|
639
|
-
`remote_relay`'s `usage {relay}` (permission `read`, like `relay_info`; this relay too, as `this`)
|
|
640
|
-
reads `GET /api/usage` and `GET /api/usage-limit` on the target and answers per provider card
|
|
641
|
-
(Copilot, Claude, Claude Cloud, Cursor, Grok): `status` (`not-configured` and `unavailable` say why
|
|
642
|
-
in `message`), `plan`, `source`, `capturedAt`, the meters (`used`, `allowance`, `remaining`,
|
|
643
|
-
`utilization`, `unit`, `resetAt`, `severity`) and the card's detail rows and notes; plus
|
|
644
|
-
`usageLimit` (`account`: the Claude window, its utilization, reset and overage; `pauses`: the turns
|
|
645
|
-
held at a usage limit, by session). The dispatcher copies an allow-list of fields
|
|
646
|
-
(`usageCard` in `services/remote-relay-dispatcher.mjs`), so a field the usage report gains later is
|
|
647
|
-
not passed on; account labels and links are left out, and an e-mail address or a token-shaped
|
|
648
|
-
string in a text is masked. A relay without `/api/usage` per provider answers
|
|
649
|
-
`REMOTE_RELAY_UNSUPPORTED`; one without `/api/usage-limit` answers `usageLimit: null` with a note.
|
|
650
|
-
|
|
651
|
-
## Claude Cloud provider
|
|
652
|
-
|
|
653
|
-
Provider type `claude-cloud`: a provider of its own, never an alias of `claude`. Its conversations
|
|
654
|
-
run Claude Code in a sandbox at Anthropic, on a clone of a GitHub repository. The worker,
|
|
655
|
-
`server/claude-cloud-worker/claude-cloud-session-worker.mjs`, is a plain Node process per
|
|
656
|
-
conversation that speaks the usual worker contracts (WebSocket link, heartbeat, control poller,
|
|
657
|
-
crash guard) but runs no agent: it sends the user's messages to the cloud session and follows the
|
|
658
|
-
session's event stream (`claude-cloud-session-process.mjs`, `claude-cloud-event-normalizer.mjs`).
|
|
659
|
-
The user-facing description, including what the provider does with the Claude CLI's login, is in
|
|
660
|
-
the [providers guide](../docs/providers.md#claude-cloud); the tracker is
|
|
661
|
-
[docs/feature-tracker/claude-cloud.md](../docs/feature-tracker/claude-cloud.md).
|
|
662
|
-
|
|
663
|
-
### The cloud client and the login
|
|
664
|
-
|
|
665
|
-
`shared/claude-cloud/api-client.mjs` (`createClaudeCloudClient`) is the only code that knows the
|
|
666
|
-
cloud API: paths, headers and bodies were measured from what the Claude CLI sends, not taken from
|
|
667
|
-
a documented API, so every expectation about them lives in that one file. It is SDK-free (Node's
|
|
668
|
-
`fetch`), turns every failure into a `ClaudeCloudError` with a `code` (`login_missing`,
|
|
669
|
-
`login_expired`, `github_not_connected`, `repo_access_denied`, `environment_missing`, `not_found`,
|
|
670
|
-
`rate_limited`, `bad_request`, `transient`), and never reconnects or retries on a timer by itself.
|
|
671
|
-
|
|
672
|
-
`shared/claude-cloud/credentials.mjs` (`createClaudeCloudCredentials`) is the only code that reads
|
|
673
|
-
the login: `CLAUDE_CODE_OAUTH_TOKEN` from the environment, else `claudeAiOauth.accessToken` in
|
|
674
|
-
`<CLAUDE_CONFIG_DIR | ~/.claude>/.credentials.json`. The token is cached until a minute before it
|
|
675
|
-
expires and then read again; the refresh token is never used. The relay process has one reader and
|
|
676
|
-
one client (`server-runtime.mjs`), whose `nudge` lets the CLI look at its own status
|
|
677
|
-
(`claudeAuthService.getStatus({ force: true })`) at most once in five minutes; each worker builds
|
|
678
|
-
its own reader, without a nudge. `applyClaudeCloudProviderEnvironment` adds no credential to the
|
|
679
|
-
launch environment (`COPILOT_WEB_RELAY_WORKER_KIND=claude-cloud` and `CLAUDE_CLOUD_RELAY_MODEL`,
|
|
680
|
-
nothing else). Only a relay that itself runs on `CLAUDE_CODE_OAUTH_TOKEN` passes a login on: the
|
|
681
|
-
variable is one of `WORKER_SECRET_ENV_VARS`, restricted to the `claude-cloud` worker kind
|
|
682
|
-
(`workerSecretEnvVarsFor`), so under tmux it reaches a cloud worker through the owner-only secret
|
|
683
|
-
env file, never as an `export` and never in the environment tmux is started with, and no other
|
|
684
|
-
worker kind gets it that way. `redact()` takes the token out of every error text.
|
|
685
|
-
|
|
686
|
-
The client's base URL is the Anthropic API. `shared/claude-cloud/base-url.mjs`
|
|
687
|
-
(`resolveClaudeCloudBaseUrl`) lets `OAR_CLAUDE_CLOUD_API_BASE_URL` replace it for tests, and only
|
|
688
|
-
with an `http(s)` URL of `127.0.0.1`, `[::1]` or `localhost` without credentials; any other value
|
|
689
|
-
is ignored, because the client sends the login to whatever its base URL is. The relay's client and
|
|
690
|
-
the worker's both resolve it there.
|
|
691
|
-
|
|
692
|
-
### Settings
|
|
693
|
-
|
|
694
|
-
`services/claude-cloud-settings-service.mjs` keeps three `app_settings` rows:
|
|
695
|
-
`claude_cloud_enabled` (default off), `claude_cloud_default_model` (default `claude-sonnet-5-5`)
|
|
696
|
-
and `claude_cloud_environment_id`. Everything else the tab shows is read, not stored: the account
|
|
697
|
-
from `claude auth status`, the login's source and expiry from `credentials.describe()`, the
|
|
698
|
-
environments from the cloud (cached for a minute). **While the provider is off, nothing is asked of
|
|
699
|
-
Anthropic**: the environments are listed only when it is on and a login exists. With no
|
|
700
|
-
environment stored, `resolveEnvironmentId` takes the account's first active one and stores it.
|
|
701
|
-
`models` is the Claude provider's model list with the `[…]` tier suffix dropped, deduplicated, the
|
|
702
|
-
default first (`buildClaudeCloudModelList`). Every change is broadcast as the socket event
|
|
703
|
-
`claude_cloud_settings_updated` with the GET body.
|
|
704
|
-
|
|
705
|
-
Switching the provider off does not rebind anything: there is no reconciliation as for the other
|
|
706
|
-
providers, and an existing cloud conversation still launches its worker.
|
|
707
|
-
|
|
708
|
-
### Creating a conversation
|
|
709
|
-
|
|
710
|
-
`POST /api/conversation/bootstrap` takes `providerType: 'claude-cloud'` with
|
|
711
|
-
`cloudSource: { repoUrl, branch? }`. The provider is only ever asked for by name (its model ids are
|
|
712
|
-
the Claude provider's own, so a model never selects it). Refusals are `400` with a `code`:
|
|
713
|
-
`claude_cloud_disabled`, `claude_cloud_repo_invalid`, `claude_cloud_branch_invalid`,
|
|
714
|
-
`claude_cloud_environment_missing`, and `CLAUDE_CLOUD_MODEL_UNAVAILABLE` for a model outside the
|
|
715
|
-
list. The repository is normalized to `https://github.com/<owner>/<repo>`
|
|
716
|
-
(`shared/claude-cloud/repo-url.mjs` → `normalizeGitHubRepoUrl`; https, ssh and scp forms of
|
|
717
|
-
github.com only), the branch is checked against git's ref rules (`isValidBranchName`; empty = the
|
|
718
|
-
default branch), and the result is stored in `conversations.cloud_source_json` in the same
|
|
719
|
-
transaction as the conversation. Nothing is created at Anthropic here. `POST /api/message` with
|
|
720
|
-
`providerType: 'claude-cloud'` on a conversation of another provider is `409
|
|
721
|
-
CLAUDE_CLOUD_REQUIRES_NEW_CONVERSATION`.
|
|
722
|
-
|
|
723
|
-
`POST /api/message` accepts any model of the list, before and after the first message, and stores
|
|
724
|
-
it on the runtime session; a model outside the list is `400 CLAUDE_CLOUD_MODEL_UNAVAILABLE`. The
|
|
725
|
-
delivery carries the conversation's model as `providerModel`, which the worker's `resolveModel`
|
|
726
|
-
prefers over the message's own model and over `CLAUDE_CLOUD_RELAY_MODEL` (the launch model). The
|
|
727
|
-
first message creates the session with it; before a later message whose model is not the one the
|
|
728
|
-
worker last gave the sandbox, the worker sends a `set_model` control request (`applyModel`; after a
|
|
729
|
-
worker restart once, since it cannot know). A `set_model` the API refuses costs the switch, not the
|
|
730
|
-
turn. Reasoning effort is always `none` and the context tier always `default`, whatever the request
|
|
731
|
-
says.
|
|
732
|
-
|
|
733
|
-
### Delivery, binding and resume
|
|
734
|
-
|
|
735
|
-
`buildDequeuedRelayMessage` adds a `claudeCloud` field to every message delivered to a cloud
|
|
736
|
-
worker (`buildClaudeCloudDelivery`): `{ sessionId, lastSequence, repoUrl, branch, environmentId,
|
|
737
|
-
title }`. A restarted worker therefore needs nothing but the delivery. The worker reports the
|
|
738
|
-
binding with `POST /api/claude-cloud-session` (`{ conversationId, cloudSessionId, sessionUrl?,
|
|
739
|
-
lastSequence, pushedBranch?, costUsd?, model? }`) after it created the session, after every
|
|
740
|
-
`result`, and on a push. `recordWorkerReport` (`services/claude-cloud-session-service.mjs`) stores
|
|
741
|
-
the session id, the sequence (never moved back) and the cost on the runtime session row, keeps the
|
|
742
|
-
session link and the list of pushed branches (the newest 20) in `cloud_source_json`, and emits the
|
|
743
|
-
socket event `claude_cloud_session` with `{ conversationId, cloud }`. A report that names another
|
|
744
|
-
cloud session than the stored one starts sequence, cost and pushed branches again.
|
|
745
|
-
|
|
746
|
-
The conversation list, the conversation payload and the bootstrap answer carry the same `cloud`
|
|
747
|
-
field: `{ repoUrl, slug, branch, sessionUrl, pushedBranches: [{ branch, at }], costUsd }`, or
|
|
748
|
-
`null` for a conversation that is not a cloud one. There is no session root for a cloud
|
|
749
|
-
conversation (`buildConversationSessionRootPayload` returns `null`).
|
|
750
|
-
|
|
751
|
-
### Usage
|
|
752
|
-
|
|
753
|
-
The worker posts to the Claude worker's two usage routes. `POST /api/claude-context-usage` accepts
|
|
754
|
-
a `claude-cloud` conversation and stores the session's context occupancy (used and maximum tokens,
|
|
755
|
-
no categories) in `runtime_sessions.context_usage_json`, which `GET /api/context/:id` serves.
|
|
756
|
-
`POST /api/claude-plan-usage` keeps a cloud worker's report under its own snapshot key
|
|
757
|
-
(`claude-cloud`), so the Claude card stays what the Claude workers wrote.
|
|
758
|
-
|
|
759
|
-
`GET /api/usage` builds two things from the cloud client while the provider is on
|
|
760
|
-
(`services/claude-account-usage-service.mjs`: the usage body, the prepaid credits and the credit
|
|
761
|
-
offer, read without a model turn, cached for 60 s, a failed read for 15 s; the route waits at most
|
|
762
|
-
3 s for them and falls back to the last turn's snapshot. Nothing is read and nothing cached is
|
|
763
|
-
handed out while the provider is off):
|
|
764
|
-
|
|
765
|
-
- the **Claude card's** live windows (`normalizeClaudeAccountUsage` in
|
|
766
|
-
`services/plan-usage-claude.mjs`): the 5-hour, weekly and model-scoped windows with the
|
|
767
|
-
account's own severity word, the week by product, and the organisation's prepaid credits;
|
|
768
|
-
- the **Claude Cloud card** (`services/plan-usage-claude-cloud.mjs` → `buildClaudeCloudPlanCard`):
|
|
769
|
-
one meter per top-level bucket of the usage body that carries a dollar limit (the cloud credit,
|
|
770
|
-
with its expiry), the relay's own cloud spend (`readClaudeCloudSpend`: the sum of
|
|
771
|
-
`runtime_sessions.claude_cloud_cost_usd` over the cloud conversations that still exist, plus the
|
|
772
|
-
cost of the conversation named by `?conversationId=`), the latest cloud snapshot's session
|
|
773
|
-
detail, and a note when a credit is offered but not claimed.
|
|
774
|
-
|
|
775
|
-
### Archive
|
|
776
|
-
|
|
777
|
-
`DELETE /api/conversation/:id` and `POST /api/conversation/:id/archive` call
|
|
778
|
-
`archiveSessionInBackground` for a cloud conversation: the cloud session is archived at Anthropic,
|
|
779
|
-
best effort and not waited for, so neither action ever depends on Anthropic. Nothing is deleted
|
|
780
|
-
there.
|
|
781
|
-
|
|
782
|
-
## Cursor Agent SDK provider
|
|
783
|
-
|
|
784
|
-
Cursor conversations run through `server/cursor-worker/cursor-session-worker.mjs`, a per-conversation
|
|
785
|
-
Node worker like the Claude one, backed by the Cursor Agent SDK. Authentication uses a Cursor API key
|
|
786
|
-
saved via **Settings → Cursor SDK** (`POST /api/settings/cursor`); saving or replacing the key resets
|
|
787
|
-
and re-runs model discovery, which also records each model's supported reasoning-effort tiers
|
|
788
|
-
(`effortsByModel`).
|
|
789
|
-
|
|
790
|
-
Per-turn behavior:
|
|
791
|
-
|
|
792
|
-
| Relay mode | Cursor mapping |
|
|
793
|
-
| ----------- | ---------------------------------------------------------------------------- |
|
|
794
|
-
| `plan` | SDK native plan mode; a plan-shaped final message publishes a **plan_ready** board |
|
|
795
|
-
| `ask` / `autopilot` | The SDK's send options carry no per-turn instruction channel, so a `[Relay mode: …]` nudge is prepended to the user message — injected only when the mode changes, and a change away from a nudged mode sends an explicit cancellation |
|
|
796
|
-
| `agent` | Default behavior, no injection |
|
|
797
|
-
|
|
798
|
-
- **Model and reasoning effort are per turn.** Effort is validated against the model's discovered
|
|
799
|
-
tiers (`none` = model default); for `auto`/undiscovered models the request passes through and the
|
|
800
|
-
worker validates it against the resolved model's live params.
|
|
801
|
-
- **Stale agent handles are retried once.** A cached agent handle whose exchanged auth expired fails
|
|
802
|
-
as a terminal error result even though the API key is still valid, so the worker recreates the
|
|
803
|
-
handle and retries a single time; a second auth failure is treated as a real key problem.
|
|
804
|
-
- **Session root** — the worker keeps its per-session agent store at
|
|
805
|
-
`$CURSOR_AGENT_STORE_DIR/<sdkSessionId>`, else `cursor-agents/<sdkSessionId>` in the relay's data
|
|
806
|
-
dir (`~/.oar/data` on a global install, `server/data` in a checkout), created on the first turn. A
|
|
807
|
-
session with a store at the earlier default `server/data/cursor-agents` keeps using it there when
|
|
808
|
-
it has none in the data dir, or when its `agent.db` there changed last. The explorer's Session root resolves to it via `resolveCursorSessionRoot`.
|
|
809
|
-
- Cursor turns are skipped by `fetchUsageSummary` like OpenAI and Claude turns — no Copilot usage
|
|
810
|
-
line is attached.
|
|
811
|
-
|
|
812
|
-
## Turn recovery and the max turn duration
|
|
813
|
-
|
|
814
|
-
A turn in `processing` is returned to the queue by two independent guards. **Both exempt a turn that
|
|
815
|
-
is blocked on an unanswered relay question** — that turn is waiting on a human, and `relay_questions`
|
|
816
|
-
carries its own expiry.
|
|
817
|
-
|
|
818
|
-
1. **Inactivity (`processingTimeoutMs`, default 10 min).** Measured from `owner_last_claimed_at`,
|
|
819
|
-
which every worker heartbeat refreshes for the message it is working on — *not* from when the turn
|
|
820
|
-
began. A turn that legitimately runs for hours of tool calls keeps pushing the cutoff forward.
|
|
821
|
-
`processing_at` is only the fallback for rows with no owner (session-worker routing disabled).
|
|
822
|
-
While the CLI is offline the window shrinks to 45 s.
|
|
823
|
-
2. **Absolute ceiling (Settings → Max turn duration, default 60 min).** Measured from the later of
|
|
824
|
-
`processing_at` and the turn's last answered question (`relay_questions.answered_at`): answering
|
|
825
|
-
a question restarts the clock, so a long interview is not cut off the moment its last answer
|
|
826
|
-
arrives. `0` disables it; the slider range is 0–600 minutes in 5-minute steps. This is a
|
|
827
|
-
backstop for a worker that has hung while still heartbeating, and is stored in the DB app
|
|
828
|
-
setting `turn_ceiling_minutes` — not in `config.json`. Shared bounds and formatting live in
|
|
829
|
-
`shared/turn-ceiling.mjs`.
|
|
830
|
-
|
|
831
|
-
Both guards are one SQL rule in `message-repository.mjs`, shared by the listing
|
|
832
|
-
(`listRecoverableProcessing`) and the requeue (`recoverProcessingBefore`, one row at a time,
|
|
833
|
-
re-checking the rule as a safeguard; the sweep runs synchronously, so nothing changes between
|
|
834
|
-
the two). The sweep (`services/stale-turn-recovery.mjs`) applies the retry budget to the listed
|
|
835
|
-
rows, requeues the rest and announces each one it requeued with `message_status`.
|
|
836
|
-
|
|
837
|
-
The sweep has no provider filter, so Grok rows are requeued by the rule above too. The Grok worker
|
|
838
|
-
also enforces the ceiling itself (delivered with each queue message, `grok-worker/acp-client.mjs`
|
|
839
|
-
`sessionPrompt`): counted from when it sent the prompt, it cancels the turn and fails it as
|
|
840
|
-
`grok.turn-stalled`. That check does not pause for question cards (on Grok: remote-relay approval
|
|
841
|
-
cards through the OAR MCP server), and an answer does not restart its clock. Whichever trips first
|
|
842
|
-
ends the turn.
|
|
843
|
-
|
|
844
|
-
## Subagent runs and live streaming
|
|
845
|
-
|
|
846
|
-
- `POST /api/stream` and `POST /api/activity` / `POST /api/thought` accept an optional
|
|
847
|
-
`subagentRunId`. Frames carrying one are routed to that subagent's bubble instead of the parent.
|
|
848
|
-
- The browser renders a **live reply preview**: main-thread stream text is markdown-rendered into the
|
|
849
|
-
pending bubble while the turn runs, and replaced by the real message when it completes.
|
|
850
|
-
- Subagent bubbles nest by `parentSubagentId`. A child event arriving before its parent's bubble
|
|
851
|
-
exists builds the parent chain on demand, so nesting is preserved regardless of event order.
|
|
852
|
-
- After the turn finishes, `subagentRuns` are returned with the assistant message and re-rendered as
|
|
853
|
-
collapsible **🤖 <name>** sections containing that run's thoughts and activity.
|
|
854
|
-
- Reloading mid-turn repaints both the reply preview and every subagent bubble from persisted stream
|
|
855
|
-
rows (`deriveInFlightStreamTextByThread`).
|
|
856
|
-
- The Copilot extension propagates the SDK `agentId` onto question/tool activity, so subagent-issued
|
|
857
|
-
`ask_user` calls and tool results land in the right bubble. Re-discovering a known run no longer
|
|
858
|
-
demotes it to a root or renames it.
|
|
859
|
-
|
|
860
|
-
## Conversation list active-turn flag
|
|
861
|
-
|
|
862
|
-
`GET /api/conversations` returns `activeTurn` per conversation, derived from
|
|
863
|
-
`listConversationIdsWithActiveQueue` (`pending` / `processing` / `parked`). Clients previously tracked
|
|
864
|
-
this only from one-shot `message_status` socket events, which are lost if the socket drops between a
|
|
865
|
-
turn finishing and delivery — leaving a permanent spinner. `activeTurn: false` now clears the
|
|
866
|
-
client's optimistic state. Relay shutdown also waits 300 ms before disconnecting sockets so a terminal
|
|
867
|
-
`message_status` emitted just before an idle-deferred restart still reaches the browser.
|
|
868
|
-
|
|
869
|
-
## Relay event subscriptions
|
|
870
|
-
|
|
871
|
-
A session (the **watcher**) can ask to be told what happens in other sessions of this relay or of a
|
|
872
|
-
paired relay, through `remote_relay`. The relay tells it by writing a short event message into the
|
|
873
|
-
watcher's conversation; nothing reads transcripts on disk, and it works for every provider.
|
|
874
|
-
|
|
875
|
-
**Actions** (`relay` may be left out and means `this`; on this relay the agent-sessions setting must
|
|
876
|
-
be on, as for every local action; a paired relay: see [Across paired relays](#across-paired-relays)):
|
|
877
|
-
|
|
878
|
-
- `subscribe {sessions: [ids] | ["all"], events?, once?, match?, stalled_minutes?, heartbeat_minutes?}`
|
|
879
|
-
— `all` watches every session, ones created later too. The watcher's own conversation is never
|
|
880
|
-
watched. The first subscribe of a conversation shows the user an approval card naming the
|
|
881
|
-
sessions; a subscription to `all` is always named on a card (asked again after an Allow that did
|
|
882
|
-
not cover it). The Allow is remembered as unlock rows `self:watch` / `self:watch-all`.
|
|
883
|
-
`events: ["relay_restarted"]` alone needs no sessions, watches none and shows no card (see
|
|
884
|
-
[relay_restarted](#relay_restarted-woken-when-the-relay-runs-again)); on a paired relay it is
|
|
885
|
-
refused, by the tool and by that relay's subscribe route.
|
|
886
|
-
- `unsubscribe {subscription_id | "all"}`, `list_subscriptions {watcher?}` (another conversation's
|
|
887
|
-
subscriptions, to inspect before a transfer).
|
|
888
|
-
- `transfer_subscriptions {from}` — moves every active subscription of another conversation, with
|
|
889
|
-
its settings and its undelivered events, to the caller: a watcher is replaced without a moment
|
|
890
|
-
where events go nowhere. Always an approval card (from where, how many, whether `all` is among
|
|
891
|
-
them), also after the caller's first-subscribe Allow. The new watcher is dropped from moved
|
|
892
|
-
session lists.
|
|
893
|
-
|
|
894
|
-
**Events** (`shared/relay-events.mjs`): `question_asked` (prompt, options, question id; an approval
|
|
895
|
-
card is marked as one only the user can answer), `error` (`turn_failed`, `usage_limit`,
|
|
896
|
-
`worker_died`), `turn_end` (status `done` or `stopped`, reply preview), `task_finished` /
|
|
897
|
-
`task_failed` (background sub-agents and shell tasks: kind, status, exit code where known; a
|
|
898
|
-
non-zero exit is `task_failed`), `stalled` (no activity for `stalled_minutes`, default 15, while a
|
|
899
|
-
turn or task runs and no question waits for an answer), `all_idle`
|
|
900
|
-
(nothing runs in the watched set any more). `task_started` only when named in `events`.
|
|
901
|
-
`subagent_finished` is no event of its own but a narrower `task_finished` a subscription can name
|
|
902
|
-
(`RELAY_EVENT_SUBSCRIBABLE_TYPES`): the end of a task that is not a shell command, delivered as a
|
|
903
|
-
`task_finished` line. The default (`events` left out) is every type but `task_started` and
|
|
904
|
-
`task_finished` (and `relay_restarted`), with `subagent_finished`: a shell command that ended well wakes only a watcher
|
|
905
|
-
that names `task_finished`, which takes every task end. A busy worker ends a shell command every
|
|
906
|
-
minute or so, and each was a wake-up, a model turn on the watcher's whole context. A subscription
|
|
907
|
-
whose stored list is exactly the old default (every type but `task_started`, in any order) named no
|
|
908
|
-
events and is read as the new default (`effectiveRelayEventTypes`, at read time, the row is never
|
|
909
|
-
rewritten, so an older relay reading the same database sees what it stored); any other list,
|
|
910
|
-
including the old default plus more, is the watcher's choice and stays. A subscribe that names
|
|
911
|
-
exactly the old default list stores `subagent_finished` with it, so it is never read as the default. A mirror shows the paired
|
|
912
|
-
relay's list as that relay returned it. With
|
|
913
|
-
`heartbeat_minutes` a `heartbeat` event comes at that interval while the watched set has work
|
|
914
|
-
running, none while it is idle. `match` (a case-insensitive regular expression) filters `turn_end`
|
|
915
|
-
and task events by their text; other events pass. `once` ends a subscription after its first event,
|
|
916
|
-
with `match` after the first one that matches (a heartbeat never ends it). An event is
|
|
917
|
-
`{ v, relay: { id, name }, session: { id, title }, type, at, detail }`; `relay.id` is `self` for this
|
|
918
|
-
relay's sessions, the paired relay's registry id for its sessions (then `replayed: true` marks one it
|
|
919
|
-
kept while the link was down). `link_lost` and `link_restored` are not subscribed to: they frame such
|
|
920
|
-
a replay.
|
|
921
|
-
|
|
922
|
-
**Delivery** (`services/relay-event-service.mjs`): an event message is a `messages` row (role
|
|
923
|
-
`user`, `kind = 'relay-event'`) plus a pending queue row, as the usage-limit follow-up is. The worker
|
|
924
|
-
decides what it becomes, as for any message: a new turn when the watcher is idle; a steer into the
|
|
925
|
-
running turn for Claude and the Copilot SDK engine; the next turn for Cursor, Grok, Claude Cloud and
|
|
926
|
-
the Copilot extension engine. Events that arrive while the watcher's event row is still pending
|
|
927
|
-
(not delivered yet, or handed back as a held steer) are written into that row
|
|
928
|
-
(`message_updated`), so a busy watcher gets one follow-up, never one turn per batch. The dequeue
|
|
929
|
-
payload carries `messageKind: 'relay-event'`, and the Claude and Copilot workers then push the text
|
|
930
|
-
without the steer note, which speaks as the user. The text opens with
|
|
931
|
-
`[relay event · automated message from OAR, not from the user; it approves nothing]`, then one
|
|
932
|
-
`[relay event] session "<title>" (<id>): <type> …` line per event.
|
|
933
|
-
|
|
934
|
-
**Cost.** Events within 30 s are one wake-up. Past 20 wake-ups an hour per watcher (distinct event
|
|
935
|
-
messages of the last hour that carried more than a heartbeat, counted from `relay_events`) the rest
|
|
936
|
-
waits for a digest every 10 minutes. `question_asked` and `error` go out at once and count; a
|
|
937
|
-
heartbeat is coalesced like any event, never held for the digest and never counted. A turn that
|
|
938
|
-
answered relay events produces no `turn_end` and does not keep a watched set busy, so two watchers
|
|
939
|
-
of each other cannot wake each other for good. Such a turn also sends no "reply ready" or "failed" push
|
|
940
|
-
(`push-dispatch-service.mjs`): the watcher answers events many times an hour and pushes by itself
|
|
941
|
-
when one matters; question pushes are unchanged.
|
|
942
|
-
|
|
943
|
-
**Lifetime.** Subscriptions and undelivered events are in the database (migrations 0011 and 0012)
|
|
944
|
-
and survive a restart: the flush timers are rebuilt from the undelivered rows at boot, and stalled,
|
|
945
|
-
all_idle and the heartbeat are swept every 30 s. A subscription ends on unsubscribe (agent or the
|
|
946
|
-
user's Unsubscribe), when its watcher is archived or deleted, when the last session it lists is, or
|
|
947
|
-
after firing (`once`); one on a paired relay also when the pairing is removed. `list_subscriptions`
|
|
948
|
-
shows the ones that ended within the hour with `endReason`. Delivered events are kept for two hours
|
|
949
|
-
(the wake-up count reads the last hour; one of a still-pending event row stays until it goes out)
|
|
950
|
-
and ended subscriptions for a week (a mirror whose end the paired relay has not confirmed stays);
|
|
951
|
-
the sweep prunes both every ten minutes. A `running` sub-agent run whose worker is gone is closed by
|
|
952
|
-
`services/stale-subagent-run-sweep.mjs` (a minute after boot, then every ten minutes), and one not
|
|
953
|
-
touched for a day is not counted as running meanwhile.
|
|
954
|
-
|
|
955
|
-
**Hook points**: `/api/response` finalize (turn_end, or error at the usage limit), `failQueueMessage`
|
|
956
|
-
(error; a Stop is turn_end `stopped`), `failRecoveredRowTerminally`, `recoverDeadWorkerProcessingRows`
|
|
957
|
-
and the background-only worker death (worker_died), `/api/relay-question` and the approval card's
|
|
958
|
-
notify (question_asked), the activity tracker's task edges, and the archive and delete routes.
|
|
959
|
-
|
|
960
|
-
### relay_restarted: woken when the relay runs again
|
|
961
|
-
|
|
962
|
-
`relay_restarted` is an event of this relay, not of a session (`RELAY_EVENT_RELAY_LEVEL_TYPES`):
|
|
963
|
-
a subscription gets it only when it names it, never by default, and one that names nothing else
|
|
964
|
-
needs no sessions. An agent that is about to request a restart subscribes with `once: true` first:
|
|
965
|
-
`POST /api/relay/shutdown` has no caller identity (agents call it with the token, `requestedBy` is
|
|
966
|
-
free text, and agents do not know their conversation id), while the tool knows the calling conversation.
|
|
967
|
-
|
|
968
|
-
- **How the run before ended.** A clean end of the runtime writes one `app_settings` row
|
|
969
|
-
(`relay_last_exit`: `action`, `reason`, `requested`, `requestedBy`, `requestedAt`, `stoppedAt`,
|
|
970
|
-
`noWake`): the queued (or `immediate`) request from the restart queue's `startShutdown`, else
|
|
971
|
-
`shutdownRuntime` (a signal, the owner process gone). The first note of a runtime wins; a runtime
|
|
972
|
-
stopped before it listened writes none, so the record of the run before stays for the next start.
|
|
973
|
-
- **At startup** (`noteRelayStarted`, once the HTTP server listens: a start that fails to bind
|
|
974
|
-
leaves the row for the next one) the relay reads and deletes that row in the same transaction
|
|
975
|
-
that records one `relay_restarted` event for every active subscription of this relay's live
|
|
976
|
-
watchers that names it (not a paired relay's, not a mirror), so each start is told once; a
|
|
977
|
-
`once` subscription ends with it. Without a row the line says the relay did not record how it
|
|
978
|
-
stopped (for example a crash, a forced stop or a reboot). On Windows a stop by the supervisor or
|
|
979
|
-
the CLI extension ends the process without its signal handler, so an ordinary stop there may read
|
|
980
|
-
like that too. A row older than 30 days (`RELAY_LAST_EXIT_MAX_AGE_MS`) is not taken as the run
|
|
981
|
-
just before (an older OAR, which neither writes nor takes it, may have run in between): it is
|
|
982
|
-
dropped, its `noWake` with it. A younger one is used; the line shows its stop time, so a relay
|
|
983
|
-
stopped over a weekend still says how. Delivery is that of every event: coalesced for 30 s (the workers
|
|
984
|
-
report back first), counted toward the hourly cap, written into an event row that is still
|
|
985
|
-
pending.
|
|
986
|
-
- **Loop guard.** On every `POST /api/relay/shutdown` (`noteRestartRequested`) the relay notes the
|
|
987
|
-
conversations whose processing turn answers a `relay_restarted` event (its queue row's
|
|
988
|
-
`relay_events`); they go into `noWake` and are not woken by that restart (logged), so a woken
|
|
989
|
-
turn that restarts again ends the chain. That holds for any restart requested while such a turn
|
|
990
|
-
runs (the button, an update, another agent): the API cannot tell who asked. The line says so up
|
|
991
|
-
front. A cancelled (or never queued) request forgets them. A held-back `once` subscription ends
|
|
992
|
-
(`held-back`, logged): that restart was the one it was for, and a later one would wake it with a
|
|
993
|
-
stale "carry on". If it also watches sessions, that ends with it: their events stop too. One
|
|
994
|
-
without `once` stays, and a later restart requested while no such turn
|
|
995
|
-
runs wakes it again. `heartbeat_minutes` on a subscription without sessions is refused (nothing
|
|
996
|
-
runs in an empty set).
|
|
997
|
-
- The line: `[relay event] relay_restarted: the relay runs again since <time>, after a restart
|
|
998
|
-
requested by "<requestedBy>" at <time> (reason "<reason>"). Timers and jobs your CLI scheduled
|
|
999
|
-
before may be gone: check what you were waiting for and carry on. A restart requested while this
|
|
1000
|
-
turn runs does not wake you again.` A stop by signal reads `after it was stopped (SIGTERM) at
|
|
1001
|
-
<time>` (a process id in the runtime's own reason is left out).
|
|
1002
|
-
|
|
1003
|
-
### Across paired relays
|
|
1004
|
-
|
|
1005
|
-
The watching relay (**A**, it holds the watcher) and the watched one (**B**) each keep a row of the
|
|
1006
|
-
subscription. Only A calls, over its pairing: a pairing in one direction is enough.
|
|
1007
|
-
|
|
1008
|
-
- **Gate.** The four actions with `relay: <paired relay>` pass the same checks as any `remote_relay`
|
|
1009
|
-
call there: the relay unlocked in the watcher's conversation (the user named it, or its agent wrote
|
|
1010
|
-
there), A's permission for it at least `read`, and B's inbound switch on (B refuses with
|
|
1011
|
-
`REMOTE_INBOUND_DISABLED`). `subscribe` first asks B with a dry run (sessions, limits, switch,
|
|
1012
|
-
version), then shows the watch card on A naming the relay (and `all`), remembered as
|
|
1013
|
-
`<registry id>:watch` / `:watch-all` and forgotten with the pairing, then subscribes on B and stores
|
|
1014
|
-
the mirror on A. No card on B. A relay that predates these routes answers Express's plain 404,
|
|
1015
|
-
which A reports as `REMOTE_RELAY_UNSUPPORTED` ("runs an OAR version without event subscriptions
|
|
1016
|
-
across relays").
|
|
1017
|
-
- **B's side** (`relay-event-routes.mjs`): `POST /api/relay-events/remote/subscribe` and
|
|
1018
|
-
`POST /api/relay-events/remote/pull` take only another relay's request (the `X-OAR-Remote-Origin`
|
|
1019
|
-
header names it; this relay's own agents and the browser get `403`) and only while the inbound
|
|
1020
|
-
switch is on. The subscription is an ordinary row whose watcher is the key
|
|
1021
|
-
`remote:<that relay's id>`, which names no conversation, so nothing a paired relay asks for is ever
|
|
1022
|
-
written into a conversation of B's; its events are recorded, swept (stalled, all_idle, heartbeat)
|
|
1023
|
-
and filtered like any, and wait for the fetch. Retention: per subscription the newest 500 undelivered
|
|
1024
|
-
events (questions and errors kept longest), none older than 7 days; a subscription not fetched for
|
|
1025
|
-
7 days ends (`subscriber-gone`). What was dropped is counted and reported once.
|
|
1026
|
-
- **The fetch** (`services/relay-event-link-service.mjs` on A): every 5 s while A holds active
|
|
1027
|
-
subscriptions on B or an end B has not confirmed, every 30 s after a minute without an answer,
|
|
1028
|
-
nothing once none are left. The body is `{ after, subscriptions: [{ id, watcher }], ended: [ids] }`:
|
|
1029
|
-
B deletes what A stored up to `after`, applies the ends, updates the watcher names, and answers
|
|
1030
|
-
`{ events, cursor, more, subscriptions: [state], ended, dropped }` (at most 200 events a page).
|
|
1031
|
-
A writes the events into its watchers' undelivered events (only for subscriptions it holds, into the
|
|
1032
|
-
watcher it stored), ends or updates its mirrors from the states, clears confirmed ends and moves its
|
|
1033
|
-
cursor in one transaction, so a restart of either relay loses or repeats nothing. From there
|
|
1034
|
-
delivery is the same as for A's own sessions: coalescing, the cap and digest, the steer, the loop
|
|
1035
|
-
guard (an event turn on either relay is no `turn_end` for the other).
|
|
1036
|
-
- **Gaps.** A link without an answer for 60 s (failing fetches, or A down) is lost from its last
|
|
1037
|
-
answer on. The first fetch after that, every page of it, is held and written as one urgent row per
|
|
1038
|
-
watcher with subscriptions there: `link_lost` (no answer since …), `Replayed: N events …` with
|
|
1039
|
-
questions and errors first, `link_restored` (after how long, how many kept, how many dropped, which
|
|
1040
|
-
subscriptions ended there meanwhile). A shorter outage only delays. Nothing wakes the watcher when
|
|
1041
|
-
the link drops; `list_subscriptions` shows each paired subscription's `link` (`ok`, `failing`,
|
|
1042
|
-
`lost`), and a `heartbeat_minutes` subscription notices the silence.
|
|
1043
|
-
- **Ends.** Unsubscribe and an archived watcher end the mirror at once and mark it until B confirms
|
|
1044
|
-
(`list_subscriptions` shows `endPending`); `unsubscribe` waits up to 10 s for that and says so when B
|
|
1045
|
-
did not answer. An archived session on B, or B's user ending the subscription there, ends the mirror
|
|
1046
|
-
with the next fetch (`archived`, `ended-on-relay`). Removing the pairing sends one last fetch with
|
|
1047
|
-
every subscription as ended, then ends the mirrors (`pairing-removed`).
|
|
1048
|
-
- `unsubscribe "all"` and `transfer_subscriptions` act on the named relay's subscriptions; with `this`,
|
|
1049
|
-
`list_subscriptions` lists all of the watcher's (each with `relay`) and `transfer_subscriptions`
|
|
1050
|
-
moves all, its card naming the paired relays among them. A transfer reaches B as the new watcher's
|
|
1051
|
-
name with the next fetch.
|
|
1052
|
-
|
|
1053
|
-
`/api/remote-relays/tool` refuses a call that carries another relay's origin: a paired relay never
|
|
1054
|
-
acts, or subscribes, for one of this relay's conversations.
|
|
1055
|
-
|
|
1056
|
-
### Session state and tasks
|
|
1057
|
-
|
|
1058
|
-
`services/session-activity-tracker.mjs` knows what each session is doing, from the
|
|
1059
|
-
`/api/background-tasks` sets (a new task id is a start, a task gone from the set has ended, a
|
|
1060
|
-
changed one is activity), `POST /api/background-task-settled` (how a task ended; the Claude worker
|
|
1061
|
-
posts it from `task_notification`), the sub-agent run statuses and the activity, thought, stream and
|
|
1062
|
-
timeline posts (the activity clock). A task that leaves the set without word ends after 5 s with
|
|
1063
|
-
its run's status, else `completed` with `statusReported: false`; an exit code is read from the task
|
|
1064
|
-
summary when it names one (the SDK has no field for it). Memory only: a restart forgets the recently
|
|
1065
|
-
finished tasks and starts the clock at boot. Running tasks come back when the workers send their
|
|
1066
|
-
sets again (see [Background tasks and workflow progress](#background-tasks-and-workflow-progress)):
|
|
1067
|
-
a task that started before this runtime did is not announced as started a second time, and the end
|
|
1068
|
-
of a task this runtime never knew is still an event when the settle notice describes the task
|
|
1069
|
-
(`description`, `taskType`, `startedAt`). A task that ended stays ended when a later set lists it
|
|
1070
|
-
again, unless the set names another run of it (`taskRunId`, the SDK's `run_id`): a resumed Claude
|
|
1071
|
-
sub-agent keeps its task id, so its new run is a start (and later an end) of its own, and a settle
|
|
1072
|
-
notice of the earlier run that arrives late does not end it. `all_idle` waits out the first 45
|
|
1073
|
-
seconds after a relay start, like the suspend check.
|
|
1074
|
-
|
|
1075
|
-
`GET /api/conversations` and `GET /api/conversation/:id` carry `sessionState` (`running_turn`: a
|
|
1076
|
-
turn processing, or queued and not held by a usage-limit pause; `background_only`: tasks running;
|
|
1077
|
-
`idle`), `sessionTasks` (running tasks, then up to five that ended within the hour: `kind`
|
|
1078
|
-
`subagent` | `shell`, `description`, `startedAt`, `lastActivityAt`, `status` `running` |
|
|
1079
|
-
`completed` | `failed` | `stopped`, `exitCode`, `summary`, `endedAt`) and `lastActivityAt`.
|
|
1080
|
-
`remote_relay`'s `list_sessions` and `read_session` pass them on as `state`, `tasks` and
|
|
1081
|
-
`lastActivityAt`; `active` stays and is true for background work too. `read_session` adds to each
|
|
1082
|
-
task its `taskId`, `output` (whether it has output to read), a shell task's `command` (its command
|
|
1083
|
-
line where the worker reports one, scrubbed of credentials and cut at 2,000 characters by the relay
|
|
1084
|
-
when the worker posts it, shown to at most 500) and, for a task with an output file, `health`
|
|
1085
|
-
(`minutesSinceOutput`, `quiet`, `quietAfterMinutes`, test-run `counts`);
|
|
1086
|
-
`read_task_output {session, task_id, lines?}` (read permission; default 200 lines, at most 500 or
|
|
1087
|
-
32 KB) reads the end of that output through `GET /api/conversation/:id/background-task/:taskId/output`,
|
|
1088
|
-
scrubbed of credentials.
|
|
1089
|
-
|
|
1090
|
-
### Browser
|
|
1091
|
-
|
|
1092
|
-
An event message renders as a compact row (`relay-event-view.mjs`) folded to its summary
|
|
1093
|
-
(`2 events · question, reply`); a row with a question or an error starts open, a replay carries a
|
|
1094
|
-
**replayed** tag, and the user's fold is kept across re-renders. A watcher's header shows **👁 watching
|
|
1095
|
-
N sessions** (or all; sessions on paired relays count, the chip's title names those relays), which
|
|
1096
|
-
opens its subscriptions with an **Unsubscribe** button each (`lab-relay · Report builder`); a watched
|
|
1097
|
-
session shows a **watched** badge in its header and, when watched by id, in the sidebar (a
|
|
1098
|
-
subscription to all would badge every row; one on a paired relay badges none of this relay's
|
|
1099
|
-
sessions). On the watched relay the badge names a paired relay's watcher with its relay.
|
|
1100
|
-
The list comes from `GET /api/relay-events/subscriptions` and the `relay_event_subscriptions`
|
|
1101
|
-
socket event (the whole active list after every change).
|
|
1102
|
-
|
|
1103
|
-
### Folded agent exchanges
|
|
1104
|
-
|
|
1105
|
-
A prompt another agent sent the session (a paired relay's, or one of this relay's own: a user row
|
|
1106
|
-
with `origin`) folds into one row, `note from <relay> · <session> · answered below` (`working…`,
|
|
1107
|
-
`failed`, `stopped`, `no reply yet`), which opens to the prompt as it renders otherwise.
|
|
1108
|
-
Presentation only (`agent-exchange-fold-view.mjs` decides, `syncAgentExchangeFolds` in
|
|
1109
|
-
`conversation-view.js` applies it as one more idempotent pass after every render and update, beside
|
|
1110
|
-
the separator and steering passes); the transcript data is unchanged. Nothing addressed to the user
|
|
1111
|
-
folds away: the fold covers the agent's prompt alone. The session's reply to it can hold a line for
|
|
1112
|
-
the user, so the reply, the live bubble of the running turn and the turn's question, approval and
|
|
1113
|
-
plan cards always show, as for a message of the user's own; they are read for the row's status
|
|
1114
|
-
only. One prompt is left unfolded: an agent's prompt that was itself steered into a running turn,
|
|
1115
|
-
which renders as part of that turn's flow. A folded prompt shows open while its turn is `working…`
|
|
1116
|
-
and closes by itself once it is replied, failed or stopped (`agentExchangeFoldOpen`); the user's
|
|
1117
|
-
own toggle, kept by prompt id, always wins over that, and a jump to the prompt (search, pins, image
|
|
1118
|
-
parent) opens it.
|
|
1119
|
-
|
|
1120
|
-
## Image conversations (OpenAI BYOK)
|
|
1121
|
-
|
|
1122
|
-
Choosing **OpenAI Image (BYOK)** in New Chat creates a conversation whose replies are generated
|
|
1123
|
-
images, calling the OpenAI Images API directly rather than going through a CLI worker.
|
|
1124
|
-
|
|
1125
|
-
- The New Chat dialog swaps **Reasoning effort** for **Quality**, and adds a **Size** picker whose
|
|
1126
|
-
options depend on the model (`dall-e-2`: 256/512/1024 squares; `dall-e-3`: 1024 plus the two
|
|
1127
|
-
1792 orientations; otherwise `auto` plus the 1024/1536 set). The chosen size is remembered per
|
|
1128
|
-
browser and becomes the conversation's context tier.
|
|
1129
|
-
- Generated images are stored per conversation and streamed from
|
|
1130
|
-
`GET /api/generated-image/:conversationId/:messageId/:imageId/content`.
|
|
1131
|
-
- **Edit this image** on a generated image sets it as the composer's image target (shown as an
|
|
1132
|
-
*Editing …* chip); the next message is queued as an edit of that image instead of a fresh
|
|
1133
|
-
generation. Image operations are recorded so a follow-up can continue from the right source image.
|
|
1134
|
-
- Deleting a conversation garbage-collects its generated images.
|
|
1135
|
-
- Continuity is gated by the `IMAGE_CONVERSATION_CONTINUITY_ENABLED` feature flag (on by default).
|
|
1136
|
-
|
|
1137
|
-
## Conversation sharing and per-message visibility
|
|
1138
|
-
|
|
1139
|
-
- **Share** on a conversation issues a read-only token; shared viewers read through
|
|
1140
|
-
`GET /api/shared/:token` and its scoped upload/generated-image content routes.
|
|
1141
|
-
- `PATCH /api/conversation/:id/message/:messageId/share-visibility` toggles a single message's
|
|
1142
|
-
`hidden_from_shares` flag. Hidden messages are filtered out of every shared view while staying
|
|
1143
|
-
fully visible — and visibly marked as hidden — to the owner.
|
|
1144
|
-
- The control appears on message hover; already-hidden messages keep their label visible so the state
|
|
1145
|
-
is never silently lost.
|
|
1146
|
-
- `POST /api/shared/:token/presence` tracks shared-viewer presence, surfaced as a watcher count.
|
|
1147
|
-
`sharedPresenceMaxPerConversation` and `sharedPresenceMaxGlobal` cap how many viewers that count
|
|
1148
|
-
tracks; they do not limit who can read a link.
|
|
1149
|
-
- A revoked or renewed token answers a plain 404 on every shared route, the `/shared/:token` page
|
|
1150
|
-
included, exactly like a token that never existed. An open shared page stops at its next poll.
|
|
1151
|
-
- Settings → Sharing lists the active shares (`GET /api/shares`) with **Copy link**, **Renew link**
|
|
1152
|
-
and **Stop sharing**, and each share's access log. Renewing adds a token to the share's lineage
|
|
1153
|
-
(`conversation_shares.lineage_id`, migration 0016) and revokes the old one, so the log, keyed to
|
|
1154
|
-
the lineage, carries over. Stopping a share or deleting the conversation deletes its log; nothing
|
|
1155
|
-
else prunes it.
|
|
1156
|
-
- The access log (`conversation_share_opens`) has one row per open, not per poll: the shared page's
|
|
1157
|
-
first fetch carries `open=1` and counts unless the same viewer opened it in the last 10 s; a
|
|
1158
|
-
fetch without the flag counts only when that viewer has not fetched the share for 30 minutes
|
|
1159
|
-
(a page back from the background, a script). A row keeps the time, the IP address (unless
|
|
1160
|
-
**Store viewer IP addresses** is off), the user agent, the country from `cf-ipcountry` (only when
|
|
1161
|
-
the request came through a proxy the `trustProxy` setting trusts) and a viewer key: an HMAC of
|
|
1162
|
-
address and user agent under a per-relay secret, which counts distinct viewers without the
|
|
1163
|
-
address. The address is the one Express derives through `trustProxy`; a forwarded header from an
|
|
1164
|
-
untrusted peer is ignored.
|
|
1165
|
-
|
|
1166
|
-
## Pinned messages
|
|
1167
|
-
|
|
1168
|
-
- `PATCH /api/conversation/:id/message/:messageId/pin` with `{ pinned: boolean }` sets or clears
|
|
1169
|
-
`messages.pinned_at`. A conversation holds at most 100 pins; the next one is refused with `409`
|
|
1170
|
-
and `code: "pin-limit"`. The answer is
|
|
1171
|
-
`{ ok, conversationId, messageId, pinned, pins, pinsRevision }`.
|
|
1172
|
-
- `GET /api/conversation/:id` carries `pins`: every pinned message of the conversation in
|
|
1173
|
-
conversation order, also those outside the requested history page, each as
|
|
1174
|
-
`{ messageId, role, preview, timestamp, pinnedAt, attachmentCount, hiddenFromShares }`.
|
|
1175
|
-
`preview` is plain text of at most 160 characters. Each message also carries `pinned`.
|
|
1176
|
-
- Every change is broadcast as the socket event `conversation_pins_updated` with
|
|
1177
|
-
`{ conversationId, pins, revision }`. `revision` (`pinsRevision` in the HTTP answers) grows with
|
|
1178
|
-
every change. A conversation load that left the relay before a change can reach the browser
|
|
1179
|
-
after the change's own event; the browser keeps the list with the higher revision.
|
|
1180
|
-
- Shared views carry neither `pins` nor `pinned`: pins are the owner's bookmarks.
|
|
1181
|
-
- Pins belong to message rows. `/compact` starts a new conversation without them (they stay with
|
|
1182
|
-
the archived one); re-importing an imported session keeps them, matched like hidden marks.
|
|
1183
|
-
|
|
1184
|
-
## Relay Mode Selection
|
|
1185
|
-
|
|
1186
|
-
The composer also includes a per-message mode picker:
|
|
1187
|
-
|
|
1188
|
-
- `plan`
|
|
1189
|
-
- `ask`
|
|
1190
|
-
- `agent`
|
|
1191
|
-
- `autopilot`
|
|
1192
|
-
|
|
1193
|
-
Mode is stored with each queued message so the relay can change behavior per turn.
|
|
1194
|
-
Clarification prompts from the CLI are forwarded back into the browser as question
|
|
1195
|
-
cards with a reply box; a card created with `allowFreeform: false` (approval and
|
|
1196
|
-
permission cards) has none and is answered with its choices.
|
|
1197
|
-
|
|
1198
|
-
`plan` mode behavior:
|
|
1199
|
-
- Draft a concise implementation plan only (no implementation output).
|
|
1200
|
-
- Read-only inspection is allowed when it materially improves plan quality (for example `glob`, `rg`, `view`).
|
|
1201
|
-
- Do not edit repository files or run mutating commands unless implementation is explicitly requested.
|
|
1202
|
-
|
|
1203
|
-
## Conversation titles
|
|
1204
|
-
|
|
1205
|
-
The active conversation header includes a `✍️` button for renaming the conversation inline.
|
|
1206
|
-
Title edits are saved to the database and broadcast to other open clients immediately.
|
|
1207
|
-
|
|
1208
|
-
## Updates
|
|
1209
|
-
|
|
1210
|
-
OAR never phones home by default. Automatic update checking is **opt-in**:
|
|
1211
|
-
enable **Check for updates automatically** in ⚙️ Settings (stored in app
|
|
1212
|
-
settings, off unless you turn it on) and the relay asks `oar.sh/latest.json`
|
|
1213
|
-
for the newest version about twice a day (12 h ± 1 h jitter, ETag-aware, 5 s
|
|
1214
|
-
timeout, failures silent). A manual **Check for updates** button works without
|
|
1215
|
-
opting in — that single request is the only traffic it creates. Set
|
|
1216
|
-
`OAR_NO_UPDATE_CHECK=1` in the relay's environment to disable even manual
|
|
1217
|
-
checks; `OAR_UPDATE_MANIFEST_URL` points the check at a different manifest
|
|
1218
|
-
(used by tests and staging).
|
|
1219
|
-
|
|
1220
|
-
When a newer version is available, Settings shows a dismissible-per-version
|
|
1221
|
-
card (not dismissible if the release is marked critical — it still never
|
|
1222
|
-
auto-applies). On npm-global installs, **Update** runs
|
|
1223
|
-
`npm i -g @oar-sh/oar@<version>` with the log streamed into the card, then the
|
|
1224
|
-
relay restarts once the running turns finish (queued messages wait for it). After the restart the relay
|
|
1225
|
-
verifies the running version actually changed; if not, the npm log is surfaced
|
|
1226
|
-
as a failure. Git checkouts are detect-only — the card shows a "pull to
|
|
1227
|
-
update" hint instead of the button. `oar update [--beta] [--to X.Y.Z]` does
|
|
1228
|
-
the same from a terminal, and rollback is `oar update --to <previous>`.
|
|
1229
|
-
|
|
1230
|
-
## Feature flags
|
|
1231
|
-
|
|
1232
|
-
Feature flags are declared in one registry (`server/features.mjs`) and managed
|
|
1233
|
-
from the web UI's Settings → Features tab, which renders each flag with its
|
|
1234
|
-
user-facing description. Current flags and defaults:
|
|
1235
|
-
|
|
1236
|
-
- `SESSION_WORKER_ROUTING_ENABLED` — ON by default; per-conversation worker
|
|
1237
|
-
processes with ownership and crash recovery (off = legacy shared polling).
|
|
1238
|
-
- `SESSION_WORKER_CONTINUATION_ROUTING_ENABLED` — ON by default; strict
|
|
1239
|
-
ownership validation when routing clarification answers back to the asking
|
|
1240
|
-
worker.
|
|
1241
|
-
- `SESSION_WORKER_FALLBACK_RESTART_ENABLED` — OFF by default; reserved rollout
|
|
1242
|
-
gate, no effect yet.
|
|
1243
|
-
- `IMAGE_CONVERSATION_CONTINUITY_ENABLED` — ON by default; keeps
|
|
1244
|
-
generated-image context across turns so *Edit this image* can continue from
|
|
1245
|
-
the right source image.
|
|
1246
|
-
|
|
1247
|
-
Resolution happens once at relay startup, with this precedence:
|
|
1248
|
-
|
|
1249
|
-
1. Registry defaults
|
|
1250
|
-
2. Stored settings (`app_settings` rows, written by the Features tab via
|
|
1251
|
-
`GET/POST /api/settings/features`)
|
|
1252
|
-
3. Environment override: `COPILOT_REMOTE_{FLAG_NAME}` (`true/false`, `1/0`,
|
|
1253
|
-
`yes/no`, `on/off`)
|
|
1254
|
-
|
|
1255
|
-
Changes saved in the Features tab take effect after a relay restart; the tab
|
|
1256
|
-
shows a restart notice and offers the queue-idle-deferred restart. A legacy
|
|
1257
|
-
`features` key in `config.json` is migrated into `app_settings` once at boot
|
|
1258
|
-
and stripped from the file. Unknown flag names and invalid values are ignored
|
|
1259
|
-
safely.
|
|
1260
|
-
|
|
1261
|
-
Question bridge rule:
|
|
1262
|
-
|
|
1263
|
-
- User-facing questions/clarifications must use `ask_user` so they flow through
|
|
1264
|
-
the relay question bridge and render as web question cards/buttons.
|
|
1265
|
-
- This applies in `autopilot` too: still call `ask_user` for clarification, and
|
|
1266
|
-
the relay layer will surface the question card even if the direct question hook
|
|
1267
|
-
is bypassed.
|
|
1268
|
-
- Plain-text assistant questions are not considered bridge-backed questions, and there is
|
|
1269
|
-
**no fallback that converts them into question cards**. Every relay question originates from an
|
|
1270
|
-
explicit request — `onUserInputRequest`, `onElicitationRequest`, or Claude's `AskUserQuestion`.
|
|
1271
|
-
A turn that merely ends with a plain-text question simply completes, and the user answers it as
|
|
1272
|
-
the next message.
|
|
1273
|
-
|
|
1274
|
-
## Session Worker Management
|
|
1275
|
-
|
|
1276
|
-
Each conversation has its own worker process, keyed by the conversation's SDK session id: one
|
|
1277
|
-
worker per session, and a session's turns never move to another worker. A Copilot conversation on
|
|
1278
|
-
the *Extension* engine runs the `copilot` CLI with the web-relay extension; every other one
|
|
1279
|
-
(Claude, Claude Cloud, Cursor, Grok, and Copilot on the *SDK* engine; OpenAI conversations run on
|
|
1280
|
-
whichever Copilot engine is selected) runs a plain Node worker (`NODE_WORKER_DESCRIPTORS` in
|
|
1281
|
-
`services/session-worker-launch-service.mjs`). The supervisor
|
|
1282
|
-
(`services/session-worker-supervisor-service.mjs`) keeps the lifecycle, health and restart limits,
|
|
1283
|
-
the launch service spawns, `services/session-worker-stop-service.mjs` stops, and the dead-worker
|
|
1284
|
-
recovery lives in `server-runtime.mjs`. Most of it acts only while the
|
|
1285
|
-
`SESSION_WORKER_ROUTING_ENABLED` feature flag is on.
|
|
1286
|
-
|
|
1287
|
-
### Lifecycle and health
|
|
1288
|
-
|
|
1289
|
-
- Registry status: `starting` → `ready` ⇄ `processing`, and `error`. A send, the primer for queued
|
|
1290
|
-
messages and `POST /api/session-worker/:sdkSessionId/launch` call `ensureWorker`, which reuses a
|
|
1291
|
-
live process of the session (found in the process list, or the tmux pane) and launches one
|
|
1292
|
-
otherwise.
|
|
1293
|
-
- Heartbeats: every worker posts `POST /api/heartbeat` every 10 s, and any frame on its worker
|
|
1294
|
-
socket counts as one too. The supervisor uses them only at the start: a launched worker that has
|
|
1295
|
-
sent none within 90 s (`heartbeatTimeoutMs`; a Copilot CLI takes about 35 s to boot) is degraded
|
|
1296
|
-
as `startup-heartbeat-timeout`. After its first heartbeat a worker is judged by its pid, not by
|
|
1297
|
-
missed beats.
|
|
1298
|
-
- Health (`uiState`, in `/api/status` with the worker list): `green` healthy, `red` a question card
|
|
1299
|
-
waits, `yellow` degraded, `white` nothing known. `degradedReason` is `startup-heartbeat-timeout`,
|
|
1300
|
-
`stale-pid` (the pid is gone while a turn or a question waits for the worker), `spawn-failed`,
|
|
1301
|
-
`restart-exhausted` or the worker's own last error. Yellow clears after 10 s of a healthy worker
|
|
1302
|
-
without a new failure (`degradedRecoveryGraceMs`).
|
|
1303
|
-
- An idle (`ready`) worker whose process is gone and that has not heartbeated for 90 s leaves the
|
|
1304
|
-
list; the next message launches a new one. A live idle worker is never evicted by time
|
|
1305
|
-
(`idleEvictionMs` is 0).
|
|
1306
|
-
|
|
1307
|
-
### A worker that dies
|
|
1308
|
-
|
|
1309
|
-
- When a worker's socket closes while its session owns a processing row, holds the composer or has
|
|
1310
|
-
background sub-agents running, the relay checks 15 s later (`WORKER_DEATH_GRACE_MS`) whether the
|
|
1311
|
-
process is still alive. A sweep every 30 s checks the owners of all processing rows the same way.
|
|
1312
|
-
- `recoverDeadWorkerProcessingRows` then drops a continuation row, fails a row the worker had
|
|
1313
|
-
already consumed or that is over `maxRequeueRetries` (5), and puts the others back to `pending`
|
|
1314
|
-
(2 s later, same owner session, one retry more). The session counts a failure (`markError`), the
|
|
1315
|
-
dead worker's sub-agent runs are closed, and the primer launches a new worker for the waiting rows.
|
|
1316
|
-
- A worker that is alive but has had no socket for 60 s while a message waits is replaced (see
|
|
1317
|
-
[Worker link reconnects](#worker-link-reconnects)).
|
|
1318
|
-
|
|
1319
|
-
### Launch failures and backoff
|
|
1320
|
-
|
|
1321
|
-
A launch that throws, a worker that died or a queue row it failed counts a failure: the next launch
|
|
1322
|
-
of that session waits 1 s, doubling per failure (1, 2, 4, 8, 16 s; capped at 30 s), for 5 retries (`maxRestartRetries`).
|
|
1323
|
-
Then the session is `restart-exhausted`: one more launch is allowed 60 s after the last failure,
|
|
1324
|
-
for up to 10 minutes after the first exhaustion, and after that only when a launch is asked for
|
|
1325
|
-
(the conversation's Retry, the launch or kill route, a relay restart). While a session's worker
|
|
1326
|
-
cannot be started the conversation shows a note (`services/worker-launch-note-service.mjs`). A
|
|
1327
|
-
successful launch resets the count.
|
|
1328
|
-
|
|
1329
|
-
### Stopping a worker
|
|
1330
|
-
|
|
1331
|
-
`stopSessionWorkerProcesses` serves **☠️ Kill session** (`POST /api/session-worker/:sdkSessionId/kill`),
|
|
1332
|
-
the CWD relaunch, a provider rebind and the replacement of a worker without a socket. It reads the
|
|
1333
|
-
session's processes from a fresh process list and checks that they are gone: on Linux and macOS
|
|
1334
|
-
SIGTERM to each and `tmux kill-session`, up to 3 s, then SIGKILL to the survivors and up to 2 s
|
|
1335
|
-
more; on Windows `Stop-Process -Force` on the process tree, leaf first; processes still alive after 3 s
|
|
1336
|
-
are stopped once more, with any new ones on a fresh list, and get up to 2 s more. A process list that cannot be read stops nothing (`process-list-unreadable`), and processes
|
|
1337
|
-
that survive are an error (`409` from the kill route). The kill route fails the rows the worker
|
|
1338
|
-
owned and blocks launches of the session for 3 s (`killBlockGraceMs`), so the primer does not
|
|
1339
|
-
start it again at once; the launch route clears that block.
|
|
1340
|
-
|
|
1341
|
-
### Launch modes and environment
|
|
1342
|
-
|
|
1343
|
-
- **Linux/macOS with `tmux`:** a detached tmux session named after the SDK session id
|
|
1344
|
-
(`tmux attach -t <sdk-session-id>`), running `sh -lc`: the extension engine's CLI under `script`
|
|
1345
|
-
for a pseudo-TTY, a Node worker with its output appended to its worker log.
|
|
1346
|
-
- **Linux/macOS without tmux:** a detached child process, a Node worker's output into its log.
|
|
1347
|
-
- **Windows:** `cmd /c start` opens one console window per worker, titled `<Kind> Worker <first 8
|
|
1348
|
-
characters of the session id>`; the extension engine runs `gh copilot -- --allow-all
|
|
1349
|
-
--session-id <id>`.
|
|
1350
|
-
|
|
1351
|
-
The launch environment is the relay's own, taken once at boot (`buildSessionWorkerLaunchEnv`), plus
|
|
1352
|
-
per launch the conversation's working directory as `COPILOT_WORKSPACE_ROOT` and `INIT_CWD`,
|
|
1353
|
-
`SESSION_ID`, `COPILOT_ALLOW_ALL`, and the bound provider's worker kind, model and credentials
|
|
1354
|
-
(`buildSessionWorkerLaunchEnvForSession`). The detached and Windows launches pass all of it. Under
|
|
1355
|
-
tmux the worker's login shell gets only the variables `buildTmuxWorkerShellCommand` exports, and
|
|
1356
|
-
the secrets (`COPILOT_PROVIDER_API_KEY`, `CURSOR_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN` for cloud
|
|
1357
|
-
workers) through an owner-only file it reads and deletes; any other variable, such as
|
|
1358
|
-
`OAR_TURN_STALL_*_MS`, `CLAUDE_CODE_AUTO_COMPACT_WINDOW` or `XAI_API_KEY`, only from the tmux
|
|
1359
|
-
server's environment or the login profile (see [Session workers under
|
|
1360
|
-
tmux](../docs/configuration.md#environment-variables)). `COPILOT_WEB_RELAY_DISABLE_CLI_SPAWN` makes
|
|
1361
|
-
every launch fail (`cli-spawn-disabled`), for test relays.
|
|
1362
|
-
|
|
1363
|
-
## How It Works
|
|
1364
|
-
|
|
1365
|
-
```
|
|
1366
|
-
[Browser] ←── WebSocket ──→ [server.js :3333] ←── WebSocket-first relay ──→ [Copilot CLI session]
|
|
1367
|
-
```
|
|
1368
|
-
|
|
1369
|
-
1. Browser sends a message → stored in server queue
|
|
1370
|
-
2. The relay worker socket delivers queued turns to the bound CLI session immediately
|
|
1371
|
-
3. The CLI processes the turn and posts the response to `POST /api/response`
|
|
1372
|
-
4. Server broadcasts response via Socket.io → appears in browser instantly
|
|
1373
|
-
|
|
1374
|
-
## Monitoring Mode (CLI)
|
|
1375
|
-
|
|
1376
|
-
After launching the server, the CLI opens a worker WebSocket link:
|
|
1377
|
-
- Identifies itself with the bound SDK session id and worker pid
|
|
1378
|
-
- Receives queued turns over `ws(s)://.../api/session-worker/ws`
|
|
1379
|
-
- Reconnects starting at 1 second, with exponential backoff capped at 8 seconds
|
|
1380
|
-
(see [Worker link reconnects](#worker-link-reconnects))
|
|
1381
|
-
- Falls back to `GET /api/pending` only while the worker socket is unavailable
|
|
1382
|
-
- Processes any message with full PC access (file system, commands, etc.)
|
|
1383
|
-
- Posts the response back
|
|
1384
|
-
- CLI appears **online** (green dot) in the web UI while the relay is connected
|
|
1385
|
-
- While working, relay tool activity is streamed into the pending assistant bubble
|
|
1386
|
-
(for example `Search (glob)` and `Search (grep)`).
|
|
1387
|
-
- Assistant reply text is streamed into the pending assistant bubble while the turn is running.
|
|
1388
|
-
- Tool activity is now also kept with the assistant message as a collapsible
|
|
1389
|
-
**Tool activity** section after the response arrives.
|
|
1390
|
-
- Clarification prompts from `ask_user`/user-input requests are forwarded as
|
|
1391
|
-
question cards in the conversation; answering the card resumes the waiting turn.
|
|
1392
|
-
- Answered relay question cards stay visible in the conversation journal, including
|
|
1393
|
-
the answer you submitted.
|
|
1394
|
-
|
|
1395
|
-
### Worker link reconnects
|
|
1396
|
-
|
|
1397
|
-
Every session worker (Copilot SDK, Claude, Claude Cloud, Cursor, Grok) and the Copilot CLI extension
|
|
1398
|
-
use the same link, `shared/worker-runtime/worker-websocket-link.mjs`. Its reconnect rules:
|
|
1399
|
-
|
|
1400
|
-
- A connection counts as made at the relay's first `server.hello`, which the relay sends as soon as it
|
|
1401
|
-
accepts the socket. A socket still connecting, or open without that hello, after 15 seconds
|
|
1402
|
-
(`connectTimeoutMs`) is given up and retried: a relay hung in its shutdown can hold a socket in
|
|
1403
|
-
either state without ever ending it.
|
|
1404
|
-
- The link ends a socket on whichever comes first of an error, a close, that deadline, a stale
|
|
1405
|
-
connection (30 seconds without a server frame) and the relay's `server.draining`, and schedules
|
|
1406
|
-
the next attempt from there; later events from the same socket are ignored. It never waits for a
|
|
1407
|
-
close event, which Node 22 does not fire for a socket closed while connecting, and a socket whose
|
|
1408
|
-
constructor throws is retried too.
|
|
1409
|
-
- There is at most one pending reconnect and one socket at a time. The delay starts at 1 second and
|
|
1410
|
-
doubles up to 8 seconds, plus up to 250 ms of jitter; it starts over only after a completed
|
|
1411
|
-
handshake, so a relay that accepts and drops the socket is not retried every second.
|
|
1412
|
-
- Each failed attempt logs one `worker ws disconnected <reason> attempt=<n> retryInMs=<ms>` line in
|
|
1413
|
-
the worker log for the first three, then at most one a minute, with `notLogged=<n>` counting the
|
|
1414
|
-
ones in between.
|
|
1415
|
-
- `stop()` (worker shutdown) clears the pending reconnect and the deadline and closes the socket
|
|
1416
|
-
without scheduling another attempt.
|
|
1417
|
-
|
|
1418
|
-
The relay's detached-worker watch (`services/detached-worker-watch-service.mjs`) is the safety net
|
|
1419
|
-
behind this: it replaces a worker that has had no socket for 60 seconds while a message waits, but
|
|
1420
|
-
never while a turn or background work of that session runs, so the link has to reconnect by itself.
|
|
1421
|
-
|
|
1422
|
-
### Session activation behavior (important)
|
|
1423
|
-
|
|
1424
|
-
In extension-managed mode, the web server can already be running while the CLI is still
|
|
1425
|
-
shown as offline. The worker socket starts when the Copilot session becomes active
|
|
1426
|
-
(`onSessionStart`), which usually happens after your first prompt in the CLI.
|
|
1427
|
-
|
|
1428
|
-
This means the following startup sequence is expected:
|
|
1429
|
-
1. Open `http://localhost:3333` and briefly see "CLI is offline"
|
|
1430
|
-
2. Send one message in the CLI
|
|
1431
|
-
3. Extension opens the worker socket and queued web messages begin processing
|
|
1432
|
-
|
|
1433
|
-
If a conversation is still unbound when the CLI first sees it, the extension now claims it through `/api/session-sync` before processing so the browser can send back to the same SDK session. A separate startup sync to `/api/session-workspace-root` reports the CLI working directory for the already-bound session; it no longer creates a placeholder conversation on its own.
|
|
1434
|
-
|
|
1435
|
-
| Symptom | Check |
|
|
1436
|
-
|---|---|
|
|
1437
|
-
| UI says "CLI is offline" | Verify `/api/status` works with the auth cookie or `Authorization: Bearer <token>` and shows `cliOnline: true` |
|
|
1438
|
-
| UI flaps online/offline after restart | Ensure you are not mixing extension-managed mode with standalone `relay.mjs`, and confirm only one relay process owns port `3333` |
|
|
1439
|
-
| "Web relay connected" banner repeats too often | Ensure only one extension instance is active; banner dedupe now persists across extension restarts for the same CLI session and reprints only when relay details change |
|
|
1440
|
-
| No response after sending | Tail `server\ext-debug.log` and confirm `onSessionStart fired` + `startPolling called` |
|
|
1441
|
-
| Wrong model used | Check logs for `Model selected: requested=... active=...` |
|
|
1442
|
-
| Question card stuck | Answer in the card UI; logs should show `relay question created` and `relay question answered` |
|
|
1443
|
-
| Tunnel not connecting | Check server console for `[ssh-tunnel]` lines; confirm SSH key auth works without password |
|
|
1444
|
-
| Tunnel keeps reconnecting | VPS `sshd_config` needs `GatewayPorts no` (default) — Caddy handles the public port |
|
|
1445
|
-
|
|
1446
|
-
## Windows-Specific Runtime Behavior
|
|
1447
|
-
|
|
1448
|
-
### Worker Launcher
|
|
1449
|
-
|
|
1450
|
-
- Each worker opens its own console window through `cmd /c start`, titled `<Kind> Worker <first 8
|
|
1451
|
-
characters of the session id>` (`Copilot Worker`, `Claude Worker`, `Cursor Worker`, `Grok Worker`,
|
|
1452
|
-
`Claude Cloud Worker`, `Copilot SDK Worker`); a relaunch opens a new window.
|
|
1453
|
-
- Node workers run `node <kind>-session-worker.mjs --session-id <id>` directly; the extension engine
|
|
1454
|
-
runs `gh copilot -- --allow-all --session-id <id>`.
|
|
1455
|
-
|
|
1456
|
-
### Path Handling
|
|
1457
|
-
|
|
1458
|
-
- **Drives API** — `/api/drives/roots` returns fixed/removable drive letters (`C:`, `D:`, etc.) instead of mount points.
|
|
1459
|
-
- **Path format** — `C:/Users/alice/project/file.txt` (forward slashes, drive letter prefix).
|
|
1460
|
-
- **Directory listing** — Uses PowerShell via `Get-ChildItem` for recursive listings (faster than fs.readdir on large directories).
|
|
1461
|
-
- **Workspace root** — Must be an absolute Windows path; relative paths or UNC paths (`\\server\share`) are handled but may behave unexpectedly.
|
|
1462
|
-
|
|
1463
|
-
### Model Refresh
|
|
1464
|
-
|
|
1465
|
-
- Model discovery on Windows invokes `copilot.ps1` via PowerShell with `-ExecutionPolicy Bypass` to bypass execution policy restrictions.
|
|
1466
|
-
- Command: `powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -Command "copilot help config | Select-String -Pattern 'gpt'"`.
|
|
1467
|
-
- If PowerShell is not available, falls back to direct `copilot help config` (may fail due to execution policy).
|
|
1468
|
-
|
|
1469
|
-
### Environment Variables
|
|
1470
|
-
|
|
1471
|
-
- Workers inherit all relay server environment, including `COPILOT_WORKSPACE_ROOT` and `INIT_CWD`.
|
|
1472
|
-
- On Windows, these paths use backslash or forward-slash format; CLI normalize paths automatically.
|
|
1473
|
-
- `PYTHONUNBUFFERED=1` and `NODE_OPTIONS` should be set before relay startup for worker inheritance.
|
|
1474
|
-
|
|
1475
|
-
### SSH Tunnel on Windows
|
|
1476
|
-
|
|
1477
|
-
- SSH tunnel uses `ssh.exe` (assumes `git-bash` or OpenSSH is in PATH).
|
|
1478
|
-
- If SSH is not available, tunnel mode cannot be "managed"; stays in "disabled" mode.
|
|
1479
|
-
- Windows Firewall may require inbound rule for local relay port (default 3333) if browser is on another machine.
|
|
1480
|
-
|
|
1481
|
-
## API Reference
|
|
1482
|
-
|
|
1483
|
-
All authenticated routes accept an HttpOnly auth cookie or an `Authorization: Bearer <token>` header.
|
|
1484
|
-
|
|
1485
|
-
`GET /api/status` now also includes `readyBanner`, a preformatted relay-info payload used by the CLI extension to print the access window directly in the Copilot CLI client when relay connectivity is established.
|
|
1486
|
-
It also includes `restartOrchestrator` with the current relay-side restart transaction state.
|
|
1487
|
-
It also includes `relayShutdown`, which reports queued manual relay shutdown/restart state separately from the worker restart orchestrator.
|
|
1488
|
-
Queue metrics include `parkedCount` for turns deferred behind restart/rebind gates.
|
|
1489
|
-
|
|
1490
|
-
| Method | Path | Description |
|
|
1491
|
-
|--------|------|-------------|
|
|
1492
|
-
| POST | `/api/message` | Send a message from the browser |
|
|
1493
|
-
| POST | `/api/upload` | Upload binary file content (deduped by SHA-256) |
|
|
1494
|
-
| GET | `/api/upload/:sha256/content` | Stream stored upload content by hash |
|
|
1495
|
-
| PATCH | `/api/conversation/:id/draft` | Save composer draft text and optional `draftAttachments` |
|
|
1496
|
-
| GET | `/api/files/*` | Stream a workspace file by repo-relative path (token required) |
|
|
1497
|
-
| GET | `/api/files-preview/*` | Return structured preview JSON for markdown/code/text/image/video files |
|
|
1498
|
-
| GET | `/api/repo/tree` | Return repository root + first-level entries for lazy workspace browsing |
|
|
1499
|
-
| GET | `/api/repo/list` | Lazy-load workspace directory entries (`path`, `includeHidden`, `includeHeavy`) |
|
|
1500
|
-
| 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 |
|
|
1501
|
-
| 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`) |
|
|
1502
|
-
| 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 |
|
|
1503
|
-
| 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 |
|
|
1504
|
-
| POST | `/api/repo/rename` | Rename a workspace entry in place: `{ path, newName, conversationId? }`. The root cannot be renamed; symlinks are refused. Answers `{ ok, path }` |
|
|
1505
|
-
| 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 |
|
|
1506
|
-
| 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 |
|
|
1507
|
-
| POST | `/api/drives/rename` | As `/api/repo/rename` for an absolute path: `{ path, newName }`. `/` and drive roots are refused |
|
|
1508
|
-
| POST | `/api/drives/delete` | As `/api/repo/delete` for an absolute path: `{ path, dryRun? }`. `/` and drive roots are refused |
|
|
1509
|
-
| GET | `/api/drives/file` | Stream a file by path — Windows drive path or Linux absolute path depending on server platform |
|
|
1510
|
-
| GET | `/api/drives/files-preview` | Return structured preview JSON for a file — Windows drive path or Linux absolute path depending on server platform |
|
|
1511
|
-
| 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 |
|
|
1512
|
-
| GET | `/api/git/diff` | Full-context unified diff for one changed file (`path`, optional `untracked=1`); the client renders both "changes only" and "full file" views from this single patch |
|
|
1513
|
-
| POST | `/api/git/pull` | Run `git pull` in the conversation's workspace root and return the combined output |
|
|
1514
|
-
| GET | `/api/git/remote` | Where a folder lives on GitHub, for the New Chat modal of a Claude Cloud chat: `?root=<absolute path>` → `{ ok, root, hasGit, remoteUrl, repoUrl, slug, branch, upstream, ahead, behind, dirty }`. The one git route that takes a path from the client; it passes the same policy as a requested CWD (`403` outside `workspaceRootAllowList`, `400` otherwise). Read-only (`git status`, `git remote get-url`) |
|
|
1515
|
-
| GET | `/api/conversations` | List all conversations |
|
|
1516
|
-
| GET | `/api/sessions` | List runtime sessions bound 1:1 to conversations |
|
|
1517
|
-
| GET | `/api/conversation/:id` | Get conversation message windows (`before*`, `after*`, or `aroundMessageId`) plus session-root metadata |
|
|
1518
|
-
| GET | `/api/search/messages` | Search message text across all conversations (`q`, `limit`, `offset`) |
|
|
1519
|
-
| PATCH | `/api/conversation/:id` | Update a conversation title |
|
|
1520
|
-
| POST | `/api/conversation/:id/compact` | Compact a conversation into a new one with carry-over summary seed |
|
|
1521
|
-
| DELETE | `/api/conversation/:id` | Delete a conversation: stops its session worker, deletes its CLI session, removes its rows. A Claude Cloud conversation's cloud session is archived at Anthropic in the background (never waited for). A Copilot session the relay only imported and never ran is hidden and tombstoned instead: nothing is stopped and its CLI session is kept. `409 conversation-active` (`reason: turn-running` / `background-tasks`) while it is still working; `409 worker-stop-failed` when its worker survives the stop (nothing deleted) |
|
|
1522
|
-
| GET | `/api/sdk-session-delete/pending` | (CLI relay) Fetch next pending SDK session delete request |
|
|
1523
|
-
| POST | `/api/sdk-session-delete/result` | (CLI relay) Report SDK session delete result |
|
|
1524
|
-
| POST | `/api/session-sync` | (CLI relay) Sync conversation↔SDK binding and optionally confirm orchestrator rebind completion |
|
|
1525
|
-
| POST | `/api/session-workspace-root` | (CLI relay) Report the startup workspace CWD for a session once it is known |
|
|
1526
|
-
| GET | `/api/pending` | (CLI) Fetch next pending message |
|
|
1527
|
-
| POST | `/api/response` | (CLI) Submit response for a message |
|
|
1528
|
-
| GET | `/api/restart-orchestrator` | Read relay restart orchestrator state |
|
|
1529
|
-
| POST | `/api/restart-orchestrator/request` | Queue a restart transaction for a target SDK session |
|
|
1530
|
-
| POST | `/api/activity` | (CLI) Push in-flight tool activity for current message; web search/fetch entries may include bounded queries, URLs, and output previews, while `store_memory`/`vote_memory` entries include available memory metadata |
|
|
1531
|
-
| POST | `/api/stream` | (CLI) Push in-flight assistant text stream for current pending message |
|
|
1532
|
-
| POST | `/api/relay/pause` | Pause dequeueing and drop currently queued messages |
|
|
1533
|
-
| POST | `/api/relay/resume` | Resume dequeueing after pause |
|
|
1534
|
-
| POST | `/api/relay/shutdown` | Queue an authenticated relay shutdown or self-restart |
|
|
1535
|
-
| GET | `/api/relay/shutdown` | The queued request's state (as `relay_shutdown_state`), plus `preview`: what a restart asked now would wait for and leave running (`?action=shutdown` for a shutdown). See [Manual relay shutdown / self-restart](#manual-relay-shutdown--self-restart) |
|
|
1536
|
-
| POST | `/api/relay/shutdown/keep-waiting` | Answer to the max-wait question: the queued request waits another full period, counted from now → `{ ok, extended, …state }` |
|
|
1537
|
-
| POST | `/api/relay/shutdown/cancel` | Withdraw a queued (not yet started) shutdown or restart; held messages go out at once |
|
|
1538
|
-
| GET | `/api/relay-questions` | (CLI/UI) List relay questions by `status` (for example `pending` or `answered`) |
|
|
1539
|
-
| GET | `/api/relay-question/:id` | (CLI) Fetch a single relay question |
|
|
1540
|
-
| POST | `/api/relay-question` | (CLI) Create a relay question for the browser. `allowFreeform` defaults to `false` when `choices` are given (to `true` without): such a card has no reply box and takes only its choices |
|
|
1541
|
-
| POST | `/api/relay-question/:id/answer` | (UI) Submit an answer for a relay question. A card created with `allowFreeform: false` and choices (approval and permission cards) takes only its choices, matched trimmed and in any case and stored as the card's label (several, `", "`-joined, on a multi-select card); anything else is refused with `400` and `choices`, and the card stays open |
|
|
1542
|
-
| POST | `/api/relay-question/:id/timeout` | (CLI/UI) Mark a relay question timed out |
|
|
1543
|
-
| 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 |
|
|
1544
|
-
| GET | `/api/status` | Overall status |
|
|
1545
|
-
| 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 |
|
|
1546
|
-
| GET | `/api/models` | Live/cached model catalog used by the UI picker |
|
|
1547
|
-
| POST | `/api/models/snapshot` | (CLI/relay) Publish discovered model snapshot |
|
|
1548
|
-
| POST | `/api/conversation/:id/refresh-history` | Re-import an existing conversation's history through the local Copilot SDK |
|
|
1549
|
-
| GET | `/api/context/:conversationId` | Context metrics for a conversation or `sdk_session_id`. Copilot sessions are parsed from session-state events (falling back to a labeled lower-bound completion-token estimate when full legacy buckets are missing); Claude sessions are served from the breakdown their worker stored, and so are Cursor, Grok and Claude Cloud sessions. All return the same normalized `contextUsage` view alongside `providerType` and the runtime's own `text` dump |
|
|
1550
|
-
| GET | `/api/context` | Same payload when a `conversationId` query is provided; otherwise returns a missing-selection response |
|
|
1551
|
-
| GET | `/api/usage` | Unified plan usage: one card per configured provider (`providers[]` with `meters`, `details`, `links`) built from the live Copilot quota, the Claude worker's latest stored snapshot, and the derived Cursor cycle totals. A provider that cannot be read yields an unavailable card rather than failing the response, so the modal always renders. While Claude Cloud is on, the Claude card's windows are read live from the account, and a Claude Cloud card is added (credit meters, the relay's cloud spend, and the cost of the conversation named by `?conversationId=` when it is a cloud one); a card may carry `notes[]` (a line of text with an optional https link), and a meter `severity` and `resetKind: 'expiry'`. Legacy top-level Copilot quota fields are spread alongside `providers` for cached clients; `?legacy=1` returns the Copilot-only snapshot on its own |
|
|
1552
|
-
| GET | `/api/settings/cursor-allowance` | Read the manual Cursor plan allowances (`cursorModelsUsd`, `otherModelsUsd`, `resetDay`) |
|
|
1553
|
-
| POST | `/api/settings/cursor-allowance` | Set the Cursor pool allowances and billing reset day; `null` clears an allowance, and `resetAccounting: true` re-baselines the derived spend tracking |
|
|
1554
|
-
| GET | `/api/settings/claude` | Read Claude provider settings (`enabled`, `model`, `models`, `availableModels`, `attributionMode`, `backgroundCommandLimitMinutes` with the slider bounds `backgroundCommandLimitMinMinutes`, `backgroundCommandLimitMaxMinutes`, `backgroundCommandLimitStepMinutes`, `backgroundCommandLimitDefaultMinutes`) |
|
|
1555
|
-
| POST | `/api/settings/claude` | Enable/disable Claude, set the default model, the enabled model subset, the attribution mode or `backgroundCommandLimitMinutes` (clamped to 30…480 and snapped to 30-minute steps; not a number is `400`; read by Claude sessions spawned afterwards); triggers discovery and unstarted-conversation reconciliation |
|
|
1556
|
-
| GET | `/api/settings/claude-cloud` | Read Claude Cloud settings: `{ enabled, defaultModel, environmentId, environments: [{ id, name }] \| null, environmentsError, account: { loggedIn, email, orgName, subscriptionType } \| null, token: { source, hasToken, expiresAt }, models }`. `token` says where the login is read from (`env`, `file`, `none`) and when it runs out, never the token. `environments` is `null` while the provider is off or the host has no login: nothing is asked of Anthropic then |
|
|
1557
|
-
| POST | `/api/settings/claude-cloud` | `{ enabled?, defaultModel?, environmentId? }` → `{ ok: true, …the GET body }`. Enabling without a Claude login on the host is `400` with `code: 'claude_cloud_login_missing'`; an empty `environmentId` clears the stored one. Emits `claude_cloud_settings_updated` |
|
|
1558
|
-
| GET | `/api/settings/cursor` | Read Cursor provider settings — same shape as Claude's: `models` is the enabled subset, `availableModels` the full discovered list, plus per-model `efforts` |
|
|
1559
|
-
| POST | `/api/settings/cursor` | Set/remove the Cursor API key, default model, or enabled model subset; key changes reset discovered models and effort tiers |
|
|
1560
|
-
| GET | `/api/settings/turn-ceiling` | Read the max turn duration plus slider bounds (`minMinutes`, `maxMinutes`, `stepMinutes`, `defaultMinutes`) |
|
|
1561
|
-
| POST | `/api/settings/turn-ceiling` | Set the max turn duration in minutes (`0` = no limit) |
|
|
1562
|
-
| GET | `/api/settings/relay-restart-max-wait` | How long a queued relay restart waits before it asks: `{ maxWaitMinutes, choices: [5, 10, 15, 30, 60, 120, 240, 0], defaultMinutes: 30, overrideMs }`; `0` = no limit; `overrideMs` is `OAR_RELAY_RESTART_MAX_WAIT_MS` when set |
|
|
1563
|
-
| POST | `/api/settings/relay-restart-max-wait` | `{ maxWaitMinutes }` → the GET body; anything but one of `choices` is `400`. A queued request measures its current period against the new value at once |
|
|
1564
|
-
| 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 |
|
|
1565
|
-
| POST | `/api/settings/subagent-record-retention` | `{ retentionDays }` → the GET body; anything but one of `choices` is `400`. Runs a retention sweep 5 s later |
|
|
1566
|
-
| POST | `/api/claude-native-session` | (Claude worker) Persist the native Agent SDK session id for resume |
|
|
1567
|
-
| 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 |
|
|
1568
|
-
| 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` |
|
|
1569
|
-
| 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 |
|
|
1570
|
-
| 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 |
|
|
1571
|
-
| 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 |
|
|
1572
|
-
| POST | `/api/subagent-timeline` | (Worker) `{ conversationId, subagentRunId, taskId?, 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, an unknown run whose `taskId` (or `header.taskId`) a run of the conversation carries is written to that run (a resumed agent; when the post sets that finished run `running` again, the relay adds a note where it resumed), anything else is `404 unknown_run`. `header.status: 'running'` clears the run's end time. Sends `subagent_timeline` `{ conversationId, messageId, subagentRunId, entries, truncated }`, volatile, only to the pages watching the run (`subagent_viewer_watch`) |
|
|
1573
|
-
| 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 |
|
|
1574
|
-
| POST | `/api/conversation/:conversationId/subagent/:subagentRunId/cancel` | Queue an `abort_subagent` control for the session's worker (`{ parentMessageId?, clientId? }` → `{ ok, queued, acknowledgement }`; `not-found`, `message-mismatch`, `already-cancelled` and `already-finished` queue nothing). The Claude worker stops a background sub-agent (`stopTask`), the Copilot SDK worker a background agent (`rpc.tasks.cancel`), the extension engine tries the SDK session's abort methods; any other run, or worker, answers "not supported". Workers read controls only while a turn runs. Whole-turn **Stop** always works |
|
|
1575
|
-
| PATCH | `/api/conversation/:id/message/:messageId/share-visibility` | Hide or unhide a single message from shared views |
|
|
1576
|
-
| 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` |
|
|
1577
|
-
| 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` |
|
|
1578
|
-
| 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 |
|
|
1579
|
-
| 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 |
|
|
1580
|
-
| GET | `/api/shares` | Every active share: `{ shares: [{ conversationId, title, archived, token, shareUrl, path, sharedSince, renewedAt, lastOpenedAt, openCount, viewerCount }], storeViewerIp, accessLog }` |
|
|
1581
|
-
| 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 }` |
|
|
1582
|
-
| 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` |
|
|
1583
|
-
| 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 |
|
|
1584
|
-
| POST | `/api/shared/:token/presence` | (No auth) Report shared-viewer presence for the watcher count |
|
|
1585
|
-
| POST | `/api/openai/images/generate` | Generate images for an OpenAI Image conversation |
|
|
1586
|
-
| POST | `/api/image-operations/:operationId/execute` | Execute a queued image generation/edit operation |
|
|
1587
|
-
| GET | `/api/generated-image/:conversationId/:messageId/:imageId/content` | Stream a generated image blob |
|
|
1588
|
-
| GET | `/api/relay/identity` | This relay's identity for paired relays: `relayId`, `name` (the PWA app name, else the hostname), `version`, `platform`, `publicUrl`, `remoteRelays.{protocol, inbound}` |
|
|
1589
|
-
| GET | `/api/remote-relays` | Paired remote relays with their status (`relays[]`, never a token) plus `self` |
|
|
1590
|
-
| POST | `/api/remote-relays` | Pair a relay from a pasted web address (`url`, optional `token`, `pairBack`, `selfUrl`); `{ needsToken: true }` when neither token works |
|
|
1591
|
-
| PATCH | `/api/remote-relays/:id` | Change a paired relay's `permission` (`read` / `prompt` / `full`), URL or token |
|
|
1592
|
-
| DELETE | `/api/remote-relays/:id` | Remove a paired relay here (the other side keeps its entry) |
|
|
1593
|
-
| POST | `/api/remote-relays/:id/check` | Probe a paired relay now |
|
|
1594
|
-
| POST | `/api/remote-relays/pair` | (Relay to relay) Another relay introduces itself after the user paired it there |
|
|
1595
|
-
| 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` |
|
|
1596
|
-
| 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. A `model` in `create_session` or `send` is checked first, before any approval card, against the list New Chat offers for the provider on the target (`/api/models` for Copilot and OpenAI, the provider settings routes for Claude, Cursor, Grok and Claude Cloud): one it does not list is refused with `REMOTE_RELAY_UNKNOWN_MODEL`, `suggestions` (the close matches) and `models`, and nothing is created or sent. Casing follows the list; a Claude alias (`opus`, `sonnet`, `haiku`, with an optional `[1m]`) resolves to the newest listed model of its family by version number (a date suffix ignored, the undated id first, `[1m]` ids only when the alias asks and they are listed); a `[1m]` id with only its base listed runs as the base, with a note; a Copilot id with its effort (`gpt-5.4-high`) goes to the bootstrap and the first prompt as the listed id plus `reasoningEffort` (an explicit `effort` wins, and a note says so), but only when the catalogue lists that effort for the id (`reasoningByModel`): `gpt-5.1-codex-max` with only `gpt-5.1-codex` listed is unknown, with the base suggested; `auto` passes. `send` also takes the provider's whole discovered list (`availableModels`) and the session's own model, as the message route does. Without a list (discovery has not run, the route did not answer; `auto` alone is no list) the model goes on unchecked and `modelNote` says so, as it does when the bootstrap binds another model than the one asked. A provider not set up there answers `REMOTE_RELAY_PROVIDER_UNAVAILABLE` before its model is looked at. The bootstrap route itself checks a Copilot model from an agent against Copilot's own models: an unknown one is refused (`COPILOT_MODEL_UNAVAILABLE`, with `supportedModels` and `suggestedModels`; another provider's model gets that provider's answer), a listed one is bound in the list's casing, an effort-suffixed one as the listed id plus that effort when `reasoningByModel` lists it; the browser's New Chat keeps its fallback to the current model. `wait_seconds` is cut to the `maxWaitSeconds` setting for every target |
|
|
1597
|
-
| GET | `/api/relay-events/subscriptions` | Every active [relay event subscription](#relay-event-subscriptions) (`{ available, subscriptions }`); refused to other relays' agents |
|
|
1598
|
-
| 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 |
|
|
1599
|
-
| 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) |
|
|
1600
|
-
| 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 }` |
|
|
1601
|
-
| POST | `/api/background-task-settled` | (Worker) How a background task ended: `{ conversationId, taskId, toolUseId?, taskRunId?, status, summary?, reason?, description?, taskType?, startedAt? }` (Claude worker, from `task_notification`; the last three describe a task that was in its published set) |
|
|
1602
|
-
| GET | `/api/remote-relays/inflight` | (Worker) Number of `remote_relay` calls running for `conversationId` |
|
|
1603
|
-
| GET/POST | `/api/settings/remote-relays` | This relay's `publicUrl` and `inboundEnabled` (accept prompts from other relays' agents) |
|
|
1604
|
-
| 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 |
|
|
1605
|
-
| POST | `/api/settings/agent-sessions` | `{ enabled?, maxWaitSeconds? }` → `{ ok: true, …the GET body }`, and the socket event `agent_sessions_settings_updated` with the GET body. `enabled` must be a boolean and `maxWaitSeconds` a whole number of seconds inside `limits` and on its step; anything else is `400` with `{ ok: false, error }` and nothing is stored |
|
|
1606
|
-
|
|
1607
|
-
### Manual relay shutdown / self-restart
|
|
1608
|
-
|
|
1609
|
-
- `POST /api/relay/shutdown`, `POST /api/relay/shutdown/cancel` and `POST /api/queue/empty` need the
|
|
1610
|
-
token and nothing else: the browser app calls them from a phone, over a tunnel and over the LAN.
|
|
1611
|
-
- The request body accepts:
|
|
1612
|
-
- `reason` (optional string)
|
|
1613
|
-
- `requestedBy` (optional string)
|
|
1614
|
-
- `restart` (optional boolean-ish flag; `true` queues self-restart instead of plain shutdown)
|
|
1615
|
-
- `immediate` (optional boolean-ish flag; `true` does not wait for the running turns)
|
|
1616
|
-
- Example restart request:
|
|
1617
|
-
|
|
1618
|
-
```json
|
|
1619
|
-
{
|
|
1620
|
-
"reason": "manual-restart",
|
|
1621
|
-
"requestedBy": "localhost-api",
|
|
1622
|
-
"restart": true
|
|
1623
|
-
}
|
|
1624
|
-
```
|
|
1625
|
-
|
|
1626
|
-
- Response payload includes:
|
|
1627
|
-
- `status`: `queued` or `shutting_down`
|
|
1628
|
-
- `action`: `shutdown` or `restart`
|
|
1629
|
-
- `restart`: boolean mirror of the action
|
|
1630
|
-
- `requestedAt`, `reason`, `requestedBy`
|
|
1631
|
-
- `queue`: current `pendingCount` / `processingCount` / `parkedCount`
|
|
1632
|
-
- `waitingFor`, `held`, `backgroundTasks`, `survival`, `maxWaitMs`, `deadlineAt`, `immediate` (below)
|
|
1633
|
-
- A conversation that subscribed to `relay_restarted` is woken when the relay runs again, with who requested it, when and why ([relay_restarted](#relay_restarted-woken-when-the-relay-runs-again)); one whose turn answered that event when the request came is not woken by it.
|
|
1634
|
-
- The relay waits until no turn is processing before acting. 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.
|
|
1635
|
-
- `immediate: true` (boolean-ish, like `restart`) starts it at once, running turns or not: the banner's **Restart now**. The answer still names what it did not wait for.
|
|
1636
|
-
- Maximum wait (`services/relay-restart-queue.mjs`): after **Ask after a queued relay restart has waited** (`relay_restart_max_wait_minutes`, 30 by default, 5–240 or `0` = no limit; `OAR_RELAY_RESTART_MAX_WAIT_MS` overrides it in ms, for test relays) the request stays queued and sets `question: { askedAt }`; the relay pushes one notification (titled *Relay restart waiting*; switch **Queued relay restart waited its maximum time**, `relayRestart`) and never restarts while turns run; it still goes ahead as soon as the last turn ends, question or not. A raised maximum (or none) that moves the deadline into the future withdraws the question; `/keep-waiting` without a question does nothing (`extended: false`). An `immediate` request may name the queued request it was shown (`expectRequestedAt`, its `requestedAt`); when that request is gone the answer is `accepted: false, stale: true` and nothing starts. Agents are told never to send `immediate` (`relay-tools.md`). The user answers with `immediate`, `/keep-waiting` (another full period from now; counted in `keptWaiting`) or `/cancel`. With no limit the request, and the messages it holds, wait until the turns end or someone decides.
|
|
1637
|
-
- What it waits for, holds and leaves running (`services/relay-restart-picture.mjs`), in the answer, in `GET /api/relay/shutdown`, in `/api/status` → `relayShutdown` and in the `relay_shutdown_state` socket event (sent on every change and on connect):
|
|
1638
|
-
- `waitingFor`: `[{ conversationId, title, turns }]`, the sessions with a processing turn
|
|
1639
|
-
- `held`: `{ count, conversations: [{ conversationId, title, count }] }`, pending and parked messages
|
|
1640
|
-
- `backgroundTasks`: `[{ conversationId, title, count, detail, survives, why }]`, sessions with running background work of a live worker (the task panel's sets and running sub-agent runs, through `background-work-view.mjs`), then sessions with console tasks held since the last restart (`background-task-store.mjs` `listRestored`), which no worker has confirmed yet: `survives: null`
|
|
1641
|
-
- `survival`: `{ action, workersSurvive, summary }`. Workers are separate processes (tmux, detached, a console window) and outlive a restart, which the relay's supervisor or the CLI extension takes; they outlive a shutdown too, except under a systemd service (detected from `INVOCATION_ID` and a `.service` cgroup), whose stop may take the workers with it (`survives: false`, shown as "may stop"; a worker in a tmux session the service did not start survives, which the relay cannot tell). A Claude Cloud session runs at Anthropic and always survives
|
|
1642
|
-
- while queued also `holdsNewTurns`, `maxWaitMs`, `waitingSince` (start of the current period), `deadlineAt` (`null` without a limit), `question`, `keptWaiting`
|
|
1643
|
-
- Ownership after an intentional restart follows the argv role, so exactly one supervisor acts on exit 75:
|
|
1644
|
-
- `server.js --supervised` exits 75 and relaunches under `.github/extensions/web-relay/server-lifecycle/managed-server.mjs`
|
|
1645
|
-
- bare `node server/server.js` keeps its supervisor attached, respawns the `--relay-runtime` child, and keeps the same terminal session alive
|
|
1646
|
-
|
|
1647
|
-
### Interrupted turns after a restart
|
|
1648
|
-
|
|
1649
|
-
`services/restart-resume-service.mjs` implements **After a relay restart, tell interrupted sessions
|
|
1650
|
-
to carry on** (`app_settings` `restart_resume_notes`, `'1'` on, off when absent; `GET/POST
|
|
1651
|
-
/api/settings/restart-resume` `{ enabled }`, a boolean, 400 otherwise). At boot it notes the queue
|
|
1652
|
-
rows that are `processing` (the turns the runtime before left) and follows them through the
|
|
1653
|
-
recovery paths, which report each outcome (`noteRestartTurnOutcome`):
|
|
1654
|
-
|
|
1655
|
-
| Path | `cut` (cannot run again) | `requeued` | `failed` |
|
|
1656
|
-
| --- | --- | --- | --- |
|
|
1657
|
-
| stale sweep (`recoverProcessingOlderThan`) | stale continuation dropped; consumed row failed | requeued | over the retry budget |
|
|
1658
|
-
| dead worker (`recoverDeadWorkerProcessingRows`) | continuation dropped; consumed row failed | requeued | over the retry budget |
|
|
1659
|
-
| owner heartbeat idle (`recoverOwnedProcessingRowsForSession`) | continuation dropped; consumed row failed; a started turn failed as `turn-replay-blocked` | requeued | |
|
|
1660
|
-
|
|
1661
|
-
A heartbeat that lists a row (`activeQueueMessageId(s)`) claims it: its worker lived through the
|
|
1662
|
-
restart and has the turn, so whatever ends it later is not put down to the restart, and the
|
|
1663
|
-
session gets no note. A row that leaves `processing` another way is read from the queue: back in `pending` or `parked` is
|
|
1664
|
-
`requeued`, anything else (a reply from a worker that lived through the restart) is settled. Once
|
|
1665
|
-
none of a conversation's rows of the boot is processing, it gets a note when one was `cut` and none
|
|
1666
|
-
was `requeued`, `claimed` or `failed`; the stale sweep's 15 s tick decides the ones that settled on their own,
|
|
1667
|
-
and the watch ends 30 minutes after the boot. The note is a message of the relay's (role `user`,
|
|
1668
|
-
`kind = 'relay-restart-resume'`) with a pending queue row on the interrupted turn's model, mode and
|
|
1669
|
-
owner, queued at the time it is written (behind a message the user queued meanwhile), as the
|
|
1670
|
-
usage-limit follow-up is; the primer then starts the worker. No note when the setting is off at
|
|
1671
|
-
that moment, for a deleted or archived conversation, while the conversation holds a usage-limit
|
|
1672
|
-
follow-up, or a second time in the same runtime. The browser renders it as a compact relay row
|
|
1673
|
-
(`relay-restart-note-view.mjs`).
|
|
1674
|
-
|
|
1675
|
-
### Upload storage model
|
|
1676
|
-
|
|
1677
|
-
- Physical blobs are stored in `<data dir>/uploads/<sha256>` (content-addressed) when
|
|
1678
|
-
`COPILOT_WEB_RELAY_DATA_DIR` or the config's `dataDir` chose the data dir, else in
|
|
1679
|
-
`server/uploads/<sha256>` (`resolveUploadDir` in `services/data-dir-resolution.mjs`).
|
|
1680
|
-
- Blobs earlier versions left elsewhere are read in place: `server/uploads` (a config
|
|
1681
|
-
`dataDir` did not move uploads before 0.9.14) and `uploads/` in the folder that holds the
|
|
1682
|
-
data dir (state migrations before 0.9.14 copied a checkout's uploads there).
|
|
1683
|
-
`resolveUploadReadDirs` lists them after the upload dir; a blob is read from the first
|
|
1684
|
-
copy whose size is the recorded one (two of that size that are not alike, i.e. differ in
|
|
1685
|
-
their first or last 64 KiB: the one whose content hashes to its name, decided once per blob). New uploads are written to the upload dir only, and
|
|
1686
|
-
the unreferenced-upload sweep and a cache wipe delete only there.
|
|
1687
|
-
- Metadata (`original_name`, `mime_type`, `size_bytes`) is stored in SQLite.
|
|
1688
|
-
- Message/conversation references are tracked in SQLite; when a conversation is deleted,
|
|
1689
|
-
unreferenced blobs are garbage-collected from disk automatically.
|
|
1690
|
-
- Image attachments are forwarded to the Copilot SDK as multimodal attachments
|
|
1691
|
-
(`file` when a disk path is available, otherwise inline `blob`).
|
|
1692
|
-
- Non-image uploads continue to be exposed as file references.
|
|
1693
|
-
- `POST /api/upload` sniffs the payload's magic bytes and reconciles them against the
|
|
1694
|
-
client-supplied `X-File-Type` header. The detected type wins on disagreement, so a
|
|
1695
|
-
disguised executable is stored as what it actually is. Unrecognised bytes (plain text,
|
|
1696
|
-
CSV, source files) keep the claimed type. The response reports `mimeTypeCorrected`.
|
|
1697
|
-
|
|
1698
|
-
### Composer attachments: paste, drag-and-drop and eager upload
|
|
1699
|
-
|
|
1700
|
-
- Files reach the composer through three paths, all sharing one ingestion function:
|
|
1701
|
-
the 📎 picker, `Ctrl+V` paste, and drag-and-drop onto the input area.
|
|
1702
|
-
- **Paste attaches, it never sends.** When the clipboard holds both an image and text,
|
|
1703
|
-
the image wins and the text is discarded. When it holds only text, the handler does
|
|
1704
|
-
nothing so the browser's native paste is untouched.
|
|
1705
|
-
- Clipboard bitmaps arrive unnamed (or as a generic `image.png`) and are renamed
|
|
1706
|
-
`pasted-<ISO-timestamp>.<ext>`.
|
|
1707
|
-
- Images over ~2 MB are downscaled client-side to a 2560 px longest edge and re-encoded
|
|
1708
|
-
to **JPEG** before upload. GIF and SVG are never re-encoded, and a re-encode that fails,
|
|
1709
|
-
grows the file, or yields anything other than JPEG/PNG falls back to the original bytes.
|
|
1710
|
-
- The output format is deliberately restricted to JPEG/PNG (`image-downscale.mjs`): Copilot's
|
|
1711
|
-
image-input docs call these "the most widely supported formats". The composer converts for
|
|
1712
|
-
every provider alike, although the Claude workers, local and cloud, would take a WebP as it is
|
|
1713
|
-
(`claude-attachments.mjs` inlines JPEG, PNG, GIF and WebP).
|
|
1714
|
-
- Re-encoding therefore has two triggers, tracked by `reencodeReason()`:
|
|
1715
|
-
- `'size'` — the image is over the byte threshold. Abandoned if the result is no smaller.
|
|
1716
|
-
- `'format'` — the source is not PNG/JPEG/GIF (e.g. WebP, AVIF, HEIC), so it is converted
|
|
1717
|
-
at its original dimensions **regardless of size**, and kept even if the result is larger:
|
|
1718
|
-
a bigger image the model can read beats a smaller one it cannot.
|
|
1719
|
-
- Without the `'format'` trigger a WebP small enough to skip the size threshold would reach
|
|
1720
|
-
the model in a format outside that pair. An image that does not come through the composer
|
|
1721
|
-
(`POST /api/upload` from a script) is stored as it is.
|
|
1722
|
-
- Attachments upload **eagerly** on selection rather than on Send. Chips show an
|
|
1723
|
-
uploading spinner, and a failed upload becomes a clickable retry.
|
|
1724
|
-
- Send is disabled while any upload is in flight. Failed uploads are deliberately not
|
|
1725
|
-
counted as in-flight, so a failure can never wedge the button.
|
|
1726
|
-
- Exceeding the 6-attachment cap keeps the first 6 and reports the overflow in a toast
|
|
1727
|
-
(previously the overflow was dropped silently).
|
|
1728
|
-
- Shared read-only viewers cannot paste or drop.
|
|
1729
|
-
|
|
1730
|
-
### Composer attachment cache
|
|
1731
|
-
|
|
1732
|
-
- Pending attachments persist **per conversation**, mirroring how text drafts already work.
|
|
1733
|
-
Switching conversations and returning restores them, as does reloading the page.
|
|
1734
|
-
- Storage is `conversations.draft_attachments`: a JSON array of `{sha256, name, type, size}`.
|
|
1735
|
-
Only already-uploaded attachments are persisted — never file bytes.
|
|
1736
|
-
- `PATCH /api/conversation/:id/draft` accepts an optional `draftAttachments` array.
|
|
1737
|
-
Omitting the field leaves the stored attachments untouched; passing `null` or `[]` clears
|
|
1738
|
-
them. Every entry must reference a blob the server already holds, otherwise the request
|
|
1739
|
-
is rejected with a 400.
|
|
1740
|
-
- Removing an attachment is expressed as a draft save carrying the remaining list, so it is
|
|
1741
|
-
ordered against in-flight draft writes rather than racing them. The server diffs the list
|
|
1742
|
-
and releases the reference itself; there is deliberately no delete-by-hash endpoint, since
|
|
1743
|
-
blobs are content-addressed and shared, and such a route would let one caller destroy
|
|
1744
|
-
another conversation's freshly uploaded blob.
|
|
1745
|
-
- Draft blobs are kept alive by an `upload_refs` row using the sentinel
|
|
1746
|
-
`message_id = '__draft__'`. On send, the real message reference is inserted and the
|
|
1747
|
-
sentinel row is dropped. Reference release only reclaims blobs whose sentinel row this
|
|
1748
|
-
conversation actually held.
|
|
1749
|
-
- Conversation deletion needs no special handling: the existing foreign-key cascade and
|
|
1750
|
-
orphan sweep already collect draft blobs.
|
|
1751
|
-
- Because uploads are eager, a blob can exist before anything references it (pasting into a
|
|
1752
|
-
conversation that is then abandoned, or switching conversations mid-upload). A sweep runs
|
|
1753
|
-
at startup and every 6 hours to reclaim unreferenced blobs older than 24 hours; the age
|
|
1754
|
-
floor guarantees an in-flight upload is never collected.
|
|
1755
|
-
- Attachments added before a conversation exists stay in the composer (already uploaded) and
|
|
1756
|
-
are adopted by the conversation created on first send.
|
|
1757
|
-
|
|
1758
|
-
### Chat file reference tokens
|
|
1759
|
-
|
|
1760
|
-
- The web explorer/file preview can copy references as backticked tokens:
|
|
1761
|
-
- ``@file:<path>``
|
|
1762
|
-
- ``@folder:<path>``
|
|
1763
|
-
- Folder tokens use the full folder path.
|
|
1764
|
-
- File tokens use the full file path shown by the preview/browser context.
|
|
1765
|
-
- Tokens are root-agnostic: workspace paths are repo-relative, drive paths keep `C:/...` form.
|
|
1766
|
-
|
|
1767
|
-
### Reference-driven inspection helper
|
|
1768
|
-
|
|
1769
|
-
- Messages containing `@file:` tokens are parsed server-side before queueing.
|
|
1770
|
-
- Text/markdown references stay as plain references (no binary payload attached).
|
|
1771
|
-
- Small image references (up to ~1 MB) are attached to the pending turn so vision-capable
|
|
1772
|
-
models receive real image input.
|
|
1773
|
-
- Oversized image references are left as plain text references to avoid oversized request payloads.
|
|
1774
|
-
|
|
1775
|
-
### Workspace file bridge
|
|
1776
|
-
|
|
1777
|
-
- Use `/api/files/<repo-relative-path>` to open workspace files in a new tab. The browser sends the auth cookie automatically.
|
|
1778
|
-
- Use `/api/files-preview/<repo-relative-path>` for structured preview JSON (`kind`, `language`, `content`, `truncated`, `size`).
|
|
1779
|
-
- Use `/api/repo/tree?includeHidden=0&includeHeavy=0` to load workspace root and first-level entries.
|
|
1780
|
-
- Use `/api/repo/list?path=<repo-relative-dir>&includeHidden=0|1&includeHeavy=0|1` for lazy-loaded workspace directory browsing.
|
|
1781
|
-
- Use `/api/drives/roots` + `/api/drives/list?path=<path>&includeHidden=0|1` for lazy-loaded drive/root browsing (separate from workspace heavy mode).
|
|
1782
|
-
- Use `/api/drives/file?path=<path>` and `/api/drives/files-preview?path=<path>` for drive/root file raw/preview access.
|
|
1783
|
-
- Use `/api/session-root/list?path=<sessionRootPath>&includeHidden=0|1` for the explorer's Session root. Child directories below it lazy-load through `/api/drives/list` as usual. The Claude Agent SDK creates a session's directory lazily — only once the session writes `subagents/` or `tool-results/` files — so the root is served as an empty folder until then rather than 404ing, and the transcript that lives one level up in the project directory is listed as a child of it. Cursor sessions resolve their Session root the same lazy way: the worker's per-session agent store (`$CURSOR_AGENT_STORE_DIR` or `cursor-agents/<sdkSessionId>` in the relay's data dir) exists only after the session's first turn, and the Session button stays disabled until it does.
|
|
1784
|
-
- Requests are auth-protected and restricted to files inside the workspace root.
|
|
1785
|
-
- **Platform split** — the drives API adapts automatically based on the server OS:
|
|
1786
|
-
- **Windows:** drive roots are discovered via `fsutil.exe`; paths use Windows drive-letter format (`C:/foo/bar`); directory listing uses PowerShell.
|
|
1787
|
-
- **Linux:** a single `/` root is returned; paths are absolute POSIX paths (`/home/user/file.txt`); directory listing uses `fs.readdir` + `fs.stat`.
|
|
1788
|
-
- The `/api/status` response includes a `platform` field (`win32`, `linux`, `darwin`, etc.) so the browser UI can adapt path handling accordingly.
|
|
1789
|
-
- Drive browsing is auth-protected; path traversal attacks and non-absolute paths are rejected on both platforms.
|
|
1790
|
-
- Traversal or non-file paths are rejected.
|
|
1791
|
-
- **Linux access scope** — on Linux the drives API currently allows any authenticated user to browse and read any path that the server process has OS-level read access to (i.e. the same permissions as the user running the relay). There is no additional allowlist restriction beyond requiring an absolute path.
|
|
1792
|
-
> **TODO:** add an optional `drivesAllowList` config key (array of absolute path prefixes) so operators can restrict drive access to a set of directories (e.g. home folder or workspace root). When the list is non-empty, requests whose resolved path does not start with one of the listed prefixes should be rejected with `403`.
|
|
1793
|
-
>
|
|
1794
|
-
> When implementing it, reuse `isWithinAllowedPrefix()` from `services/workspace-root-path-policy.mjs` rather than writing a second `startsWith` check — a bare prefix match would let `C:\work` admit `C:\work-secrets`. See `workspaceRootAllowList` below, which already follows this pattern.
|
|
1795
|
-
|
|
1796
|
-
### Git changes modal
|
|
1797
|
-
|
|
1798
|
-
The **🌿 Git changes** entry in the conversation `⋯` menu opens a modal over the conversation's
|
|
1799
|
-
workspace root, backed by `services/git-changes-service.mjs` and `routes/git-routes.mjs`:
|
|
1800
|
-
|
|
1801
|
-
- `GET /api/git/status?conversationId=…` — branch, upstream, ahead/behind, and every changed file
|
|
1802
|
-
(staged, unstaged, and untracked; porcelain v1 parsing). The root is resolved server-side from the
|
|
1803
|
-
conversation scope exactly like the file-preview routes — the client never sends a path — and a
|
|
1804
|
-
workspace root that is not a git repository returns `isRepo: false` rather than an error.
|
|
1805
|
-
- `GET /api/git/diff?path=…&untracked=0|1&conversationId=…` — one full-context unified diff
|
|
1806
|
-
(`-U999999`, untracked files via `git diff --no-index /dev/null`). The client renders both the
|
|
1807
|
-
**Changes only** view (context collapsed to gap markers) and the **Full file** view from this
|
|
1808
|
-
single patch; the parsing lives in `public/app/git-diff-model.mjs`.
|
|
1809
|
-
- `POST /api/git/pull?conversationId=…` — runs `git pull` in the workspace root and returns the
|
|
1810
|
-
combined output.
|
|
1811
|
-
|
|
1812
|
-
File paths passed to `/api/git/diff` go through the same `resolveWorkspaceFilePath` containment
|
|
1813
|
-
check as the file bridge, so traversal outside the workspace root is rejected.
|
|
1814
|
-
|
|
1815
|
-
`GET /api/git/remote?root=…` belongs to the New Chat modal, not to this one: it reads the `origin`
|
|
1816
|
-
remote (else the remote of the upstream branch), the current branch, ahead/behind and whether the
|
|
1817
|
-
checkout is dirty, for the Repository and Branch fields of a Claude Cloud chat
|
|
1818
|
-
(`services/git-remote-service.mjs`). There is no conversation yet at that point, so it is the one
|
|
1819
|
-
git route that takes a path from the client, validated like a requested CWD
|
|
1820
|
-
(`validateRequestedWorkspaceRoot`). Credentials in a remote URL are dropped before it is returned,
|
|
1821
|
-
and a remote that is not a github.com repository comes back with `repoUrl: null`.
|
|
1822
|
-
|
|
1823
|
-
### Changing the launch CWD
|
|
1824
|
-
|
|
1825
|
-
Two endpoints set a conversation's launch directory. Both accept the same aliases —
|
|
1826
|
-
`rootPath`, `workspaceRootPath`, `workspace_root_path`, `cwd` — and both validate the
|
|
1827
|
-
path before touching the database.
|
|
1828
|
-
|
|
1829
|
-
| Endpoint | Effect |
|
|
1830
|
-
| --- | --- |
|
|
1831
|
-
| `POST /api/conversation/:id/workspace-root` | Saves the next-launch CWD. Does not touch the running CLI. |
|
|
1832
|
-
| `POST /api/conversation/:id/relaunch-with-workspace-root` | Saves the CWD, stops the CLI, and relaunches it in the new directory. |
|
|
1833
|
-
|
|
1834
|
-
**Validation** (`services/workspace-root-path-policy.mjs`): the path must be absolute
|
|
1835
|
-
(drive-relative `C:foo` and extended-length `\\?\` forms are rejected), must not contain
|
|
1836
|
-
`; & | \0 \r \n` — the same characters the chat `cd` command already rejects — and must
|
|
1837
|
-
resolve, via `realpath`, to an existing directory. The resolved real path is what gets
|
|
1838
|
-
persisted and spawned, so a symlink cannot smuggle a request past the allow list.
|
|
1839
|
-
Failures return `400` with a `code` of `missing-root-path`, `invalid-root-path`,
|
|
1840
|
-
`relative-root-path`, or `root-path-not-found`.
|
|
1841
|
-
|
|
1842
|
-
**`workspaceRootAllowList`** (optional, `server/config.json`, or the
|
|
1843
|
-
`COPILOT_WORKSPACE_ROOT_ALLOW_LIST` env var using the platform path delimiter): an array of
|
|
1844
|
-
absolute path prefixes. **Absent, `null`, `[]`, or not an array disables the check entirely**,
|
|
1845
|
-
which is the default and matches the historical "any existing directory" behaviour. When it is
|
|
1846
|
-
non-empty, a request outside every prefix is rejected with `403` and
|
|
1847
|
-
`code: "root-path-not-allowed"`. Matching is on segment boundaries and is case-insensitive on
|
|
1848
|
-
Windows. Entries that do not resolve to a directory are logged and dropped rather than failing
|
|
1849
|
-
closed, so a typo cannot brick the relay.
|
|
1850
|
-
|
|
1851
|
-
**Concurrency.** All three endpoints that reach the session-worker launch path — the relaunch
|
|
1852
|
-
route, the save route, and `POST /api/session-worker/:sdkSessionId/launch` — share one mutex
|
|
1853
|
-
keyed by `sdkSessionId`. On top of that, the relaunch route coalesces duplicate requests
|
|
1854
|
-
(a mobile double-tap, a second tab, a retried fetch) within a 5 s window:
|
|
1855
|
-
|
|
1856
|
-
- same request in flight → awaits it and returns the same body plus `coalesced: true`
|
|
1857
|
-
- same request just settled → replays the cached body with `coalesced: true, cached: true`
|
|
1858
|
-
- a *different* request in flight → `409` with `code: "relaunch_in_progress"`
|
|
1859
|
-
|
|
1860
|
-
Request identity is `conversationId` + the normalized path, or an explicit `Idempotency-Key`
|
|
1861
|
-
header / `idempotencyKey` body field, which the web UI mints once per user gesture.
|
|
1862
|
-
Both endpoints are additionally rate limited to 6 requests per 10 s per session and client IP,
|
|
1863
|
-
returning `429` with `Retry-After`.
|
|
1864
|
-
|
|
1865
|
-
**Stop semantics.** The relaunch stops every process in the session's tree, waits for them to
|
|
1866
|
-
exit, and escalates once (`SIGKILL` on POSIX; a re-enumerated forced pass on Windows, which
|
|
1867
|
-
catches children spawned after the first snapshot). If anything survives, the relay **does not
|
|
1868
|
-
launch** — it returns `409` with `code: "worker-stop-timeout"` and `remainingPids`, having still
|
|
1869
|
-
saved the CWD for the next clean launch. Launching on top of a survivor is what previously left
|
|
1870
|
-
two CLIs claiming one session.
|
|
1871
|
-
|
|
1872
|
-
**Response fields.** A successful relaunch reports what actually happened rather than a bare
|
|
1873
|
-
`ok: true`:
|
|
1874
|
-
|
|
1875
|
-
| Field | Meaning |
|
|
1876
|
-
| --- | --- |
|
|
1877
|
-
| `relaunched` | A fresh process was started in the requested directory. |
|
|
1878
|
-
| `workspaceRootApplied` | The running CLI is actually in the requested directory. |
|
|
1879
|
-
| `reusedExistingProcess` | An existing process was reused, so the new CWD did **not** take effect. |
|
|
1880
|
-
| `warning: "cwd-not-applied"` | Present with `activeWorkspaceRootPath` and a human-readable `message` whenever `workspaceRootApplied` is `false`. |
|
|
1881
|
-
| `stoppedPids` / `launchedPid` | Which processes were killed and which was started. |
|
|
1882
|
-
| `coalesced` / `cached` | This response was replayed from a duplicate request. |
|
|
1883
|
-
|
|
1884
|
-
**Recent CWD history.** `recent_workspace_roots` is keyed by `path_key`, a case-normalized form
|
|
1885
|
-
of the path (lower-cased in full on Windows, untouched on POSIX), so `C:\Git\Repo` and
|
|
1886
|
-
`c:\git\repo` occupy one row and the 12-entry cap counts distinct directories. The runtime
|
|
1887
|
-
rebuilds an older table in place at startup; `server/migrations/0002-recent-workspace-roots-path-key.mjs`
|
|
1888
|
-
performs the same rebuild offline.
|
|
1889
|
-
- The web UI opens workspace mentions in an in-app preview dialog with **Preview / Raw** mode buttons.
|
|
1890
|
-
- Markdown preview supports optional embedded-HTML mode with script/event-handler stripping and a visible warning.
|
|
1891
|
-
- The floating **📁 Browse files** button opens the explorer with **Workspace**, **Drives**, and (when available) **Session** roots, tree navigation, list/icon folder views, and image thumbnails.
|
|
1892
|
-
|
|
1893
|
-
### Browser filter persistence and refresh behavior
|
|
1894
|
-
|
|
1895
|
-
- The **Hidden** and **Heavy** toolbar toggles are persisted in `localStorage`, under separate keys
|
|
1896
|
-
for the workspace root and the drives/session roots (the toolbar labels them differently:
|
|
1897
|
-
"Hidden" vs "Hidden/System"). Every filter defaults to off, so a fresh browser profile behaves
|
|
1898
|
-
exactly as it did before persistence existed.
|
|
1899
|
-
- Refreshing the browser — via the Refresh button or a filter toggle — refetches the lazy tree and
|
|
1900
|
-
then re-opens the folders that were expanded, rather than collapsing back to the root. A rapid
|
|
1901
|
-
double-toggle is safe: the restore is applied against the tree that actually arrived last.
|
|
1902
|
-
- Switching root, or a session root changing underneath the browser, discards a parked restore
|
|
1903
|
-
because the saved path belongs to the tree being left.
|
|
1904
|
-
|
|
1905
|
-
### External link handling
|
|
1906
|
-
|
|
1907
|
-
Links in chat bubbles, question cards, and subagent bubbles are opened in a new tab with
|
|
1908
|
-
`noopener`/`noreferrer` applied, including links added to the DOM after page load. Workspace file
|
|
1909
|
-
links (`@file:` mentions and file-preview markdown) are exempt so they continue to open the in-app
|
|
1910
|
-
preview.
|
|
1911
|
-
|
|
1912
|
-
## Structured Answers and Multi-Field Elicitation
|
|
1913
|
-
|
|
1914
|
-
The relay supports **structured answer forms** that extend beyond simple text `ask_user` questions. When a Copilot agent or tool needs multi-field input (e.g., confirmation with multiple checkboxes, form data with validation), the relay bridges the question into a web form.
|
|
1915
|
-
|
|
1916
|
-
### Database Schema
|
|
1917
|
-
|
|
1918
|
-
Structured answers are stored in the `relay_questions` table:
|
|
1919
|
-
|
|
1920
|
-
- `id` (primary key, UUID)
|
|
1921
|
-
- `request_schema` (JSON schema defining form fields: types, validation, constraints)
|
|
1922
|
-
- `structured_answer` (JSON object containing user's multi-field response)
|
|
1923
|
-
|
|
1924
|
-
The migration `server/migrations/0001-add-structured-answer.mjs` creates the `structured_answer` and `request_schema` columns.
|
|
1925
|
-
|
|
1926
|
-
### Elicitation Flow
|
|
1927
|
-
|
|
1928
|
-
1. **Agent sends multi-field question** → Copilot SDK sends `ElicitationRequest` with `requestSchema` (JSON schema).
|
|
1929
|
-
2. **Extension bridges question** → `.github/extensions/web-relay/skills/question-routing-hooks.mjs` detects structured schema and registers it in the relay.
|
|
1930
|
-
3. **Browser renders form** → `server/public/app/question-schema-view.mjs` generates UI from the JSON schema (text inputs, checkboxes, dropdowns, etc.).
|
|
1931
|
-
4. **User submits form** → `ask-user-view.js` validates and POSTs to `/api/ask-user` with `structuredAnswer` payload.
|
|
1932
|
-
5. **Relay stores + returns** → Answer stored in `structured_answer` column; extension retrieves via `/api/pending` and returns `ElicitationResponse` to SDK.
|
|
1933
|
-
|
|
1934
|
-
### Schema Example
|
|
1935
|
-
|
|
1936
|
-
```json
|
|
1937
|
-
{
|
|
1938
|
-
"type": "object",
|
|
1939
|
-
"properties": {
|
|
1940
|
-
"confirmation": {
|
|
1941
|
-
"type": "boolean",
|
|
1942
|
-
"title": "Do you want to proceed?"
|
|
1943
|
-
},
|
|
1944
|
-
"environment": {
|
|
1945
|
-
"type": "string",
|
|
1946
|
-
"enum": ["dev", "staging", "prod"],
|
|
1947
|
-
"title": "Select environment"
|
|
1948
|
-
},
|
|
1949
|
-
"tags": {
|
|
1950
|
-
"type": "array",
|
|
1951
|
-
"items": { "type": "string" },
|
|
1952
|
-
"title": "Enter tags"
|
|
1953
|
-
}
|
|
1954
|
-
},
|
|
1955
|
-
"required": ["confirmation", "environment"]
|
|
1956
|
-
}
|
|
1957
|
-
```
|
|
1958
|
-
|
|
1959
|
-
### Browser Behavior
|
|
1960
|
-
|
|
1961
|
-
- Multi-field forms render form controls for each schema property.
|
|
1962
|
-
- Validation happens client-side (required fields, type checking) and server-side.
|
|
1963
|
-
- Single-field simple `ask_user` (legacy) still renders as a single text input or button group.
|
|
1964
|
-
|
|
1965
|
-
## Relay Tool Guidance
|
|
1966
|
-
|
|
1967
|
-
The CLI extension (`.github/extensions/web-relay/extension.mjs`) and the Copilot SDK worker
|
|
1968
|
-
(`copilot-worker/copilot-prompt-context.mjs`) load `relay-tools.md` (`COPILOT_WEB_RELAY_TOOLS`
|
|
1969
|
-
overrides the path) for shared tool guidance.
|
|
1970
|
-
|
|
1971
|
-
How to embed media in a reply (a markdown image naming the file's absolute host path; the page
|
|
1972
|
-
renders images and turns video and audio into players) is one text,
|
|
1973
|
-
`shared/media-embed-instructions.mjs`. The Claude worker appends it to the system prompt, the
|
|
1974
|
-
Copilot SDK worker to its relay guidance, the Cursor and Grok workers put it in front of the first
|
|
1975
|
-
prompt each worker process sends. The
|
|
1976
|
-
Copilot extension engine's prompt builder (`shared/worker-runtime/prompt-context.mjs`) does not add
|
|
1977
|
-
it and `relay-tools.md` does not contain it, and a Claude Cloud session gets no relay guidance at
|
|
1978
|
-
all, so those two are not told.
|
|
1979
|
-
|
|
1980
|
-
The browser UI keeps the usage button in the sidebar header, and that button continues to
|
|
1981
|
-
call `/api/usage` directly. It now renders the unified plan-usage report — one card per
|
|
1982
|
-
provider with meters, reset countdowns and collapsible cost detail — via
|
|
1983
|
-
`public/app/plan-usage-view.mjs`, falling back to the old Copilot-only text summary when
|
|
1984
|
-
the response carries no `providers` array (an older relay, or one without the plan-usage
|
|
1985
|
-
service).
|
|
1986
|
-
|
|
1987
|
-
## Config (`config.json`)
|
|
1988
|
-
|
|
1989
|
-
```json
|
|
1990
|
-
{
|
|
1991
|
-
"authToken": "<your-token>",
|
|
1992
|
-
"port": 3333,
|
|
1993
|
-
"localhostOnly": true,
|
|
1994
|
-
"processingTimeoutMs": 600000,
|
|
1995
|
-
"conversationSessionMode": "isolated",
|
|
1996
|
-
"contextIndicatorMode": "default",
|
|
1997
|
-
"sdkVersion": "1.0.63",
|
|
1998
|
-
"sdkPath": "/absolute/path/to/copilot-sdk/index.js",
|
|
1999
|
-
"cliPath": "/absolute/path/to/app.js",
|
|
2000
|
-
"sshTunnel": {
|
|
2001
|
-
"mode": "disabled",
|
|
2002
|
-
"required": false,
|
|
2003
|
-
"remoteBind": "loopback",
|
|
2004
|
-
"command": "ssh",
|
|
2005
|
-
"user": "ubuntu",
|
|
2006
|
-
"host": "relay.example.com",
|
|
2007
|
-
"remotePort": 4444,
|
|
2008
|
-
"identityFile": "~/.ssh/id_rsa",
|
|
2009
|
-
"autoReclaimPort": true
|
|
2010
|
-
},
|
|
2011
|
-
"cloudflaredTunnel": {
|
|
2012
|
-
"mode": "disabled",
|
|
2013
|
-
"required": false,
|
|
2014
|
-
"token": "",
|
|
2015
|
-
"binary": "",
|
|
2016
|
-
"extraArgs": []
|
|
2017
|
-
}
|
|
2018
|
-
}
|
|
2019
|
-
```
|
|
2020
|
-
|
|
2021
|
-
| Key | Default | Description |
|
|
2022
|
-
|---|---|---|
|
|
2023
|
-
| `authToken` | generated if empty | Token for all API / UI access. Empty or missing: a random token on every start, printed to the log and not saved (`--token <value>` sets one for the run) |
|
|
2024
|
-
| `port` | `3333` | HTTP/WebSocket listen port (`--port <n>` for the run) |
|
|
2025
|
-
| `localhostOnly` | `true` | Bind only to loopback (`127.0.0.1`) and block LAN/WAN access |
|
|
2026
|
-
| `host` | `127.0.0.1` | Read by the workers only: the address they connect to the relay on (`0.0.0.0`, `::` and `localhost` mean `127.0.0.1`) |
|
|
2027
|
-
| `dataDir` | `data/` beside the server | Database, the relay's lock file, uploads (`uploads/` inside it) and Cursor agent stores (`cursor-agents`); relative to the config file's folder; `COPILOT_WEB_RELAY_DATA_DIR` wins. Without either, a checkout keeps its uploads in `server/uploads`; uploads made there before `dataDir` was set stay there and are still read |
|
|
2028
|
-
| `pollIntervalMs` | — | Read by nothing; new config files still get `3000` |
|
|
2029
|
-
| `processingTimeoutMs` | `600000` | Worker-inactivity window before a turn is treated as stale (ms) — see [Turn recovery](#turn-recovery-and-the-max-turn-duration) |
|
|
2030
|
-
| `maxRequeueRetries` | `5` | Retries of a recovered queue row before it fails |
|
|
2031
|
-
| `conversationSessionMode` | `isolated` | `isolated` or `shared`; recorded as `runtime_sessions.strategy` and reported in `/api/status`, with no effect on behaviour |
|
|
2032
|
-
| `contextIndicatorMode` | `default` | Composer context indicator: `default`, a line across the composer's top edge whose colour runs from green to red as the context fills, or `bar`, a line as long as the share in use; anything else is `default`. Sent to the page in `/api/status` |
|
|
2033
|
-
| `remotePath` | `""` | URL path prefix when the relay is served under a subpath (PWA URLs, socket.io path) |
|
|
2034
|
-
| `trustProxy` | `"loopback"` | Express `trust proxy`: which proxies may set `X-Forwarded-*` |
|
|
2035
|
-
| `workspaceRootAllowList` | unset | Folders conversations may start in; see [Changing the launch CWD](#changing-the-launch-cwd) |
|
|
2036
|
-
| `publicHostnames` | `[]` | The relay's own public hostnames; the preview lane refuses them |
|
|
2037
|
-
| `previews.enabled`, `.port`, `.bindHost`, `.allowPublicBind`, `.publicBaseUrl`, `.allowedTargetHosts`, `.maxLive` | `false`, `port + 1`, `127.0.0.1`, `false`, —, `[]`, `8` | The preview listener (`services/preview-registry-service.mjs`, [docs/preview-servers.md](../docs/preview-servers.md)) |
|
|
2038
|
-
| `pushVapidSubject` | `mailto:copilot-remote@example.com` | Contact URI sent to push services |
|
|
2039
|
-
| `sharedPresenceMaxPerConversation`, `sharedPresenceMaxGlobal` | `200`, `5000` | Caps of the shared-view watcher count |
|
|
2040
|
-
| `cliBinaries` | *(written by the relay)* | Provider CLI paths resolved by an install from Settings, applied at startup |
|
|
2041
|
-
| `cliPort` | `4445` | Port of the standalone `relay.mjs`; the preview lane refuses it |
|
|
2042
|
-
| `features` | — | Legacy: moved into the database once at startup, then the **Features** tab |
|
|
2043
|
-
| `sdkVersion` | *(latest detected)* | Optional semver pin (for example `1.0.63`) used by relay SDK auto-detection |
|
|
2044
|
-
| `sdkPath` | *(auto-detected)* | Optional absolute override for Copilot SDK entry path (`.../copilot-sdk/index.js`) |
|
|
2045
|
-
| `cliPath` | *(auto-detected)* | Optional absolute override for Copilot CLI app path (`.../app.js`) |
|
|
2046
|
-
| `restartGracefulTimeoutMs` | `8000` | Graceful shutdown wait before force fallback |
|
|
2047
|
-
| `restartReadyCooldownMs` | `1000` | How long the worker restart orchestrator still holds dequeue after a restart transaction is ready |
|
|
2048
|
-
| `restartShutdownTimeoutMs` | `45000` | Drain timeout while waiting for active queue jobs |
|
|
2049
|
-
| `restartSpawnTimeoutMs` | `18000` | Max wait for resume process to leave online state |
|
|
2050
|
-
| `restartRebindTimeoutMs` | `20000` | Max wait for session-sync rebind confirmation |
|
|
2051
|
-
| `restartMaxAttempts` | `3` | Max restart attempts before terminal exhaustion |
|
|
2052
|
-
| `restartRetryBackoffMs` | `[1000,3000,7000]` | Deterministic retry backoff schedule |
|
|
2053
|
-
| `sshTunnel.mode` | `disabled` | Tunnel mode (`disabled` or `managed`) |
|
|
2054
|
-
| `sshTunnel.enabled` | `false` | Legacy alias for mode (`true` => `managed`, `false` => `disabled`) |
|
|
2055
|
-
| `sshTunnel.required` | `false` | Pause dequeue when tunnel is disconnected in managed mode |
|
|
2056
|
-
| `sshTunnel.remoteBind` | `loopback` | Remote bind mode for SSH `-R` (`loopback` or `public`) |
|
|
2057
|
-
| `sshTunnel.command` | `ssh` | SSH executable path or command name |
|
|
2058
|
-
| `sshTunnel.user` | — | SSH user on VPS |
|
|
2059
|
-
| `sshTunnel.host` | — | VPS hostname / IP |
|
|
2060
|
-
| `sshTunnel.remotePort` | — | Port opened on the VPS |
|
|
2061
|
-
| `sshTunnel.identityFile` | *(optional)* | SSH private key path (`~` expanded); uses ssh-agent if omitted |
|
|
2062
|
-
| `sshTunnel.autoReclaimPort` | `true` | When remote bind fails, run a remote reclaim step before retrying |
|
|
2063
|
-
| `sshTunnel.reclaimStaleSshSessions` | `false` | Also kill your own childless `@notty` SSH sessions when the port stays held (see caveat below) |
|
|
2064
|
-
| `sshTunnel.remoteCleanupCommand` | *(optional)* | Override reclaim command (`ssh user@host <command>`) for custom VPS cleanup |
|
|
2065
|
-
| `cloudflaredTunnel.mode` | `disabled` | Cloudflare tunnel mode (`disabled` or `managed`) |
|
|
2066
|
-
| `cloudflaredTunnel.enabled` | `false` | Legacy alias for mode (`true` => `managed`) |
|
|
2067
|
-
| `cloudflaredTunnel.required` | `false` | Pause dequeue when the Cloudflare tunnel is disconnected in managed mode |
|
|
2068
|
-
| `cloudflaredTunnel.token` | — | Tunnel token from the router panel (never logged) |
|
|
2069
|
-
| `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 |
|
|
2070
|
-
| `cloudflaredTunnel.extraArgs` | `[]` | Extra arguments appended to `cloudflared tunnel run` |
|
|
2071
|
-
| `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) |
|
|
2072
|
-
| `allowedOrigins` | `[]` | Extra browser origins the Socket.IO channel accepts besides the relay's own origin |
|
|
2073
|
-
|
|
2074
|
-
### SDK auto-detection behavior
|
|
2075
|
-
|
|
2076
|
-
- Relay first honors explicit `sdkPath` + `cliPath` from `config.json` when both are set.
|
|
2077
|
-
- Otherwise it auto-detects the highest available semver under platform-specific install roots:
|
|
2078
|
-
- **Windows:** `%LOCALAPPDATA%\copilot\pkg`
|
|
2079
|
-
- **Linux:** `$XDG_CACHE_HOME/copilot/pkg` (default `~/.cache/copilot/pkg`), then `$XDG_DATA_HOME/copilot/pkg` (default `~/.local/share/copilot/pkg`)
|
|
2080
|
-
- **macOS:** `~/Library/Application Support/copilot/pkg`
|
|
2081
|
-
- Within each root it checks platform subdirs (`<platform>-<arch>`, then `universal`) and picks the highest version containing both:
|
|
2082
|
-
- `copilot-sdk/index.js`
|
|
2083
|
-
- `app.js`
|
|
2084
|
-
- Optional environment override: set `COPILOT_PKG_DIR` to force a specific pkg base directory.
|
|
2085
|
-
- Optional config pin: set `sdkVersion` to require an exact version during auto-detection.
|
|
2086
|
-
|
|
2087
|
-
## SSH Reverse Tunnel
|
|
2088
|
-
|
|
2089
|
-
When `sshTunnel.mode` is `managed` (or legacy `sshTunnel.enabled=true`), `server.js` spawns:
|
|
2090
|
-
|
|
2091
|
-
```
|
|
2092
|
-
ssh -N -o ServerAliveInterval=30 -o ServerAliveCountMax=3 \
|
|
2093
|
-
-o StrictHostKeyChecking=accept-new -o BatchMode=yes \
|
|
2094
|
-
-o ExitOnForwardFailure=yes \
|
|
2095
|
-
-R <remoteSpec> <user>@<host>
|
|
2096
|
-
```
|
|
2097
|
-
|
|
2098
|
-
`sshTunnel.remoteBind` controls `<remoteSpec>`:
|
|
2099
|
-
- `loopback` => `<remotePort>:127.0.0.1:<port>` (loopback bind, best with Caddy `reverse_proxy localhost:<remotePort>`)
|
|
2100
|
-
- `public` => `*:<remotePort>:127.0.0.1:<port>`
|
|
2101
|
-
`ExitOnForwardFailure=yes` ensures the tunnel process exits immediately if remote port forwarding fails, allowing clean auto-retry instead of a false "connected" state.
|
|
2102
|
-
|
|
2103
|
-
**Auto-reconnect** — if the tunnel exits for any reason it is rescheduled with
|
|
2104
|
-
exponential backoff (5 s → 10 s → 20 s → 40 s → 60 s cap, no retry limit).
|
|
2105
|
-
The counter resets after a connection is stable for >30 s.
|
|
2106
|
-
|
|
2107
|
-
**Remote stale-forward reclaim** — on `remote port forwarding failed for listen port`,
|
|
2108
|
-
the relay runs a one-shot remote cleanup over SSH and retries quickly on the same
|
|
2109
|
-
fixed port. Set `sshTunnel.autoReclaimPort` to `false` to disable, or provide
|
|
2110
|
-
`sshTunnel.remoteCleanupCommand` for your own server-specific cleanup command.
|
|
2111
|
-
|
|
2112
|
-
The reclaim reads the port state from `ss` / `/proc/net/tcp` rather than trusting
|
|
2113
|
-
`lsof`. This matters: when the port is held by a stale `ssh -R` forward, the owning
|
|
2114
|
-
`sshd` session has dropped privileges, so `/proc/<pid>/fd` is root-owned and both
|
|
2115
|
-
`lsof` and `fuser` report nothing even though the socket belongs to your own uid.
|
|
2116
|
-
The cleanup exits non-zero while the port is still bound, so a port that cannot be
|
|
2117
|
-
freed falls back to exponential backoff instead of respawning `ssh` every second.
|
|
2118
|
-
|
|
2119
|
-
Killing such a forward requires killing the `sshd` session that owns it. That is
|
|
2120
|
-
off by default because the session cannot be identified precisely without root.
|
|
2121
|
-
Enabling `sshTunnel.reclaimStaleSshSessions` sweeps your own `sshd` sessions that
|
|
2122
|
-
have no child processes — the shape `ssh -N -R` leaves behind. Interactive shells,
|
|
2123
|
-
IDE remote servers (VS Code / Cursor) and the reclaim command itself all have
|
|
2124
|
-
children and are skipped, but any *other* `-N` forward you rely on for a different
|
|
2125
|
-
port would also be killed, so leave it off unless the relay owns the account.
|
|
2126
|
-
|
|
2127
|
-
**Caddy VPS config:**
|
|
2128
|
-
|
|
2129
|
-
```
|
|
2130
|
-
relay.example.com {
|
|
2131
|
-
reverse_proxy localhost:4444
|
|
2132
|
-
}
|
|
2133
|
-
```
|
|
2134
|
-
|
|
2135
|
-
**`/api/status`** now includes:
|
|
2136
|
-
|
|
2137
|
-
```json
|
|
2138
|
-
"contextIndicatorMode": "default",
|
|
2139
|
-
"sshTunnel": {
|
|
2140
|
-
"mode": "managed",
|
|
2141
|
-
"required": false,
|
|
2142
|
-
"enabled": true,
|
|
2143
|
-
"blocking": false,
|
|
2144
|
-
"connected": true,
|
|
2145
|
-
"host": "relay.example.com",
|
|
2146
|
-
"remotePort": 4444,
|
|
2147
|
-
"remoteBindMode": "loopback",
|
|
2148
|
-
"reconnectAttempts": 0,
|
|
2149
|
-
"connectedSince": "2026-05-18T01:00:00.000Z",
|
|
2150
|
-
"lastError": null,
|
|
2151
|
-
"valid": true
|
|
2152
|
-
}
|
|
2153
|
-
```
|
|
2154
|
-
|
|
2155
|
-
### Managed vs. Disabled Mode
|
|
2156
|
-
|
|
2157
|
-
| Setting | Behavior |
|
|
2158
|
-
|---------|----------|
|
|
2159
|
-
| `mode: "disabled"` | No SSH tunnel; relay is only accessible locally (localhost). Suitable for development or internal network access. |
|
|
2160
|
-
| `mode: "managed"` | SSH tunnel enabled; relay opens reverse port forward to VPS. Suitable for remote access or internet-facing deployments. |
|
|
2161
|
-
|
|
2162
|
-
### Tunnel Connection & Queueing
|
|
2163
|
-
|
|
2164
|
-
- **Tunnel required = false** (default): Queue continues to process even if tunnel is disconnected. Browser can still interact via localhost.
|
|
2165
|
-
- **Tunnel required = true**: Queue is paused (returns `paused: true` with reason `ssh_tunnel_required`) when tunnel is disconnected. Used when relay **must** be accessible via VPS (e.g., mobile browser always connects through VPS).
|
|
2166
|
-
|
|
2167
|
-
When `required=true` and tunnel is down:
|
|
2168
|
-
- Relay stays running internally
|
|
2169
|
-
- `/api/pending` returns `{ paused: true, reason: "ssh_tunnel_required" }`
|
|
2170
|
-
- Extension stops dequeuing turns
|
|
2171
|
-
- Browser sees "waiting for tunnel to reconnect..." status
|
|
2172
|
-
- Once tunnel reconnects, queue resumes automatically
|
|
2173
|
-
|
|
2174
|
-
### SSH Tunnel Configuration Precedence
|
|
2175
|
-
|
|
2176
|
-
1. `server/config.json` → `sshTunnel` object
|
|
2177
|
-
2. Environment variables → `COPILOT_REMOTE_SSH_TUNNEL_*` prefixed keys
|
|
2178
|
-
|
|
2179
|
-
Example environment overrides:
|
|
2180
|
-
```bash
|
|
2181
|
-
COPILOT_REMOTE_SSH_TUNNEL_MODE=managed \
|
|
2182
|
-
COPILOT_REMOTE_SSH_TUNNEL_USER=deployer \
|
|
2183
|
-
COPILOT_REMOTE_SSH_TUNNEL_HOST=vps.example.com \
|
|
2184
|
-
COPILOT_REMOTE_SSH_TUNNEL_REMOTE_PORT=4444 \
|
|
2185
|
-
npm start
|
|
2186
|
-
```
|
|
2187
|
-
|
|
2188
|
-
### SSH tunnel migration notes
|
|
2189
|
-
|
|
2190
|
-
- `sshTunnel.mode` is now the canonical switch. Use `disabled` for direct relay deployments and `managed` for reverse SSH.
|
|
2191
|
-
- `sshTunnel.enabled` still works for compatibility and maps to `mode`.
|
|
2192
|
-
- Add `sshTunnel.required: true` only when queue dequeue must stop until the tunnel reconnects.
|
|
2193
|
-
|
|
2194
|
-
`restartOrchestrator` in `/api/status` and `/api/restart-orchestrator` now exposes
|
|
2195
|
-
attempt/retry/timeout fields (`attempts`, `maxAttempts`, `retryAt`, `retryBackoffMs`,
|
|
2196
|
-
`spawnDeadlineAt`, `rebindDeadlineAt`) plus terminal outcomes
|
|
2197
|
-
(`terminalOutcomeCode`, `terminalOutcomeMessage`, `terminalOutcomeAttempts`).
|
|
2198
|
-
Failure classes are deterministic: transient timeouts (`spawn-timeout`, `rebind-timeout`)
|
|
2199
|
-
retry with bounded backoff; session mismatch conflicts (`transaction-mismatch`, `target-mismatch`)
|
|
2200
|
-
are terminal and stop retrying.
|
|
2201
|
-
`POST /api/session-sync` accepts optional orchestrator correlation/target/rebind fields:
|
|
2202
|
-
`orchestrator_correlation_id`, `orchestrator_target_session_id`, and `rebind_completed`
|
|
2203
|
-
(or `rebind_state=completed`). Rebind mismatches return `409` with `retryable`/`terminal`.
|
|
2204
|
-
The extension dequeue/send path treats this restart-orchestrator flow as authoritative and
|
|
2205
|
-
does not attempt in-process runtime session switch calls.
|
|
2206
|
-
|
|
2207
|
-
This worker restart-orchestrator flow is separate from the manual relay self-restart state
|
|
2208
|
-
reported as `relayShutdown`.
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
## Database Migrations
|
|
2213
|
-
|
|
2214
|
-
There is no migration runner and no record of applied migrations. At every start
|
|
2215
|
-
`applySchema(db)` (`server/db-schema.mjs`, the same code the tests build their databases with) runs
|
|
2216
|
-
`SCHEMA_SQL` (`CREATE TABLE IF NOT EXISTS` for a fresh database), then the upgrade sequence:
|
|
2217
|
-
guarded `ALTER TABLE … ADD COLUMN` statements (each runs only when `PRAGMA table_info` lacks the
|
|
2218
|
-
column) and, among them, the functions of `server/migrations/0002-…` to `0018-…` in order. Each of those checks the
|
|
2219
|
-
schema itself and does nothing when its change is already there, so all of them run on every boot.
|
|
2220
|
-
From 0004 on a failure is logged (`migration 00NN failed; retrying on next boot`) and the boot goes
|
|
2221
|
-
on. Every file can also be run by hand against a database: `node server/migrations/<file>.mjs
|
|
2222
|
-
[path/to/copilot.db]`. `0001-add-structured-answer.mjs` (the `structured_answer` and
|
|
2223
|
-
`request_schema` columns of `relay_questions`, see [Structured Answers and Multi-Field
|
|
2224
|
-
Elicitation](#structured-answers-and-multi-field-elicitation)) is only such a script: the boot adds
|
|
2225
|
-
those columns with its own guarded `ALTER TABLE`.
|
|
2226
|
-
|
|
2227
|
-
**In-place column additions:**
|
|
2228
|
-
|
|
2229
|
-
Some columns are added directly at startup with guarded `ALTER TABLE` statements rather than
|
|
2230
|
-
migration files. Recent additions on `runtime_sessions`:
|
|
2231
|
-
|
|
2232
|
-
- `claude_native_session_id` (TEXT) — native Claude Agent SDK session id, replayed as `resume`
|
|
2233
|
-
- `context_usage_json` (TEXT/JSON) — latest context-window breakdown reported by a Claude worker
|
|
2234
|
-
- `context_usage_captured_at` (TEXT) — when that breakdown was captured
|
|
2235
|
-
|
|
2236
|
-
and on `messages`:
|
|
2237
|
-
|
|
2238
|
-
- `hidden_from_shares` / `share_hidden_at` — per-message share visibility (see
|
|
2239
|
-
[Conversation sharing](#conversation-sharing-and-per-message-visibility))
|
|
2240
|
-
|
|
2241
|
-
`0005-remote-relays.mjs` adds `origin_json` to `messages` and `conversations` (provenance of
|
|
2242
|
-
prompts and sessions another relay's agent created) and the table
|
|
2243
|
-
`conversation_remote_relay_unlocks` (the remote relays the user mentioned per conversation).
|
|
2244
|
-
Agent sessions reuse both without a migration of their own: a conversation an agent of this relay
|
|
2245
|
-
created stores an origin with `local: true` that names the conversation it was created from, and
|
|
2246
|
-
the user's **Allow** for a conversation's agent to start sessions here is a row of that table
|
|
2247
|
-
under the relay id `self` (`remote-relay-repository.mjs`).
|
|
2248
|
-
|
|
2249
|
-
`0011-relay-event-subscriptions.mjs` adds `relay_event_subscriptions` (what each watcher asked for,
|
|
2250
|
-
kept after it ended with `ended_at` / `end_reason`, for a week) and `relay_events` (events per watcher, with
|
|
2251
|
-
`delivered_at` and the message they went into, kept for two hours once delivered). See
|
|
2252
|
-
[Relay event subscriptions](#relay-event-subscriptions).
|
|
2253
|
-
|
|
2254
|
-
`0012-cross-relay-event-subscriptions.mjs` adds the columns both sides of a subscription across
|
|
2255
|
-
relays need (on the watching relay `remote_subscription_id`, `remote_meta_json`, `remote_sync`; on
|
|
2256
|
-
the watched one `subscriber_relay_id`, `subscriber_relay_name`, `subscriber_watcher_id`,
|
|
2257
|
-
`subscriber_watcher_title`, `last_pull_at`, `dropped_count`) and `relay_event_links` (per paired
|
|
2258
|
-
relay the fetch cursor and the link's health). See [Across paired relays](#across-paired-relays).
|
|
2259
|
-
|
|
2260
|
-
`0017-conversation-history-indexes.mjs` adds two indexes and nothing else:
|
|
2261
|
-
`queue(response_message_id)` for the sub-agent records of each reply on a history page, and
|
|
2262
|
-
`messages(conversation_id, timestamp, id)` for selecting the page itself. `GET
|
|
2263
|
-
/api/conversation/:id` and the shared view select the requested page in SQL in conversation order
|
|
2264
|
-
(timestamp, then id) and hydrate only its replies, so a page costs the same in a conversation of
|
|
2265
|
-
any length. A conversation with a row whose timestamp is not the relay's ISO form
|
|
2266
|
-
(`YYYY-MM-DDTHH:MM:SS.mmmZ`) or whose id is not lowercase letters, digits and dashes is paged the
|
|
2267
|
-
old way: every row loaded, the page cut afterwards.
|
|
2268
|
-
|
|
2269
|
-
`0018-background-task-rows.mjs` adds `background_task_rows` (`store_key`, `task_id`, `state`
|
|
2270
|
-
`live` | `ended`, `row_json`, `ended_at`, `updated_at`; one row per key and task): the task
|
|
2271
|
-
console's rows across a relay restart (see
|
|
2272
|
-
[Background tasks and workflow progress](#background-tasks-and-workflow-progress)). Additive and
|
|
2273
|
-
idempotent; a failure is logged and retried on the next boot, and the store keeps its rows in
|
|
2274
|
-
memory meanwhile. An older build ignores the table.
|
|
2275
|
-
|
|
2276
|
-
`0007-claude-cloud.mjs` adds the storage of [Claude Cloud](#claude-cloud-provider) conversations.
|
|
2277
|
-
It is additive and idempotent, runs on every boot, and a failure is logged and retried on the next
|
|
2278
|
-
one:
|
|
2279
|
-
|
|
2280
|
-
- `runtime_sessions.claude_cloud_session_id` (TEXT) — the cloud session the conversation runs in,
|
|
2281
|
-
reported by its worker after the first message
|
|
2282
|
-
- `runtime_sessions.claude_cloud_last_sequence` (TEXT) — the event sequence number of the last
|
|
2283
|
-
finished turn's `result`: where a restarted worker reads the session's event log on from
|
|
2284
|
-
- `runtime_sessions.claude_cloud_cost_usd` (REAL) — what the session has cost so far, as the cloud
|
|
2285
|
-
reports it
|
|
2286
|
-
- `conversations.cloud_source_json` (TEXT/JSON) — `{ repoUrl, branch, environmentId, sessionUrl,
|
|
2287
|
-
pushedBranches: [{ branch, at }] }`
|
|
2288
|
-
|
|
2289
|
-
Older tables are created at startup with `CREATE TABLE IF NOT EXISTS` in `SCHEMA_SQL` (`db-schema.mjs`),
|
|
2290
|
-
for example `workflow_runs` (final workflow digest per assistant message,
|
|
2291
|
-
keyed on `response_message_id`, cascade-deleted with the conversation; see
|
|
2292
|
-
[Background tasks and workflow progress](#background-tasks-and-workflow-progress)); newer ones, such as
|
|
2293
|
-
`background_task_rows`, come from a migration only (see below).
|
|
2294
|
-
|
|
2295
|
-
Statements that depend on these columns are prepared conditionally, so an older database keeps
|
|
2296
|
-
working with the corresponding feature inert rather than crashing at startup.
|
|
2297
|
-
|
|
2298
|
-
**Adding a migration:**
|
|
2299
|
-
|
|
2300
|
-
1. Create `server/migrations/00NN-description.mjs` with the next number, exporting a synchronous
|
|
2301
|
-
`migrateSomething(db)` that checks the schema (`sqlite_master`, `PRAGMA table_info`) and changes
|
|
2302
|
-
only what is missing, plus a `migrate(dbPath)` and an `isMain` block for running it by hand
|
|
2303
|
-
(`0018-background-task-rows.mjs` is a small example).
|
|
2304
|
-
2. Call it from `applySchema` after the previous one, inside `try`/`catch` with a
|
|
2305
|
-
`migration 00NN failed; retrying on next boot` warning. It runs on a fresh database too, so
|
|
2306
|
-
what it creates needs no entry in `SCHEMA_SQL`.
|
|
2307
|
-
3. Prepare statements that need the change conditionally, so a database the migration has not
|
|
2308
|
-
reached keeps the feature inert instead of failing at startup.
|
|
2309
|
-
|
|
2310
|
-
## Files
|
|
2311
|
-
|
|
2312
|
-
| File | Description |
|
|
2313
|
-
|------|-------------|
|
|
2314
|
-
| `server.js` | Main server |
|
|
2315
|
-
| `public/index.html` | Web chat UI |
|
|
2316
|
-
| `config.json` | Auth token and settings (gitignored) |
|
|
2317
|
-
| `data/copilot.db` | Persisted conversations, settings, and queue storage (gitignored) |
|
|
2318
|
-
| `relay-tools.md` | Markdown tool guidance loaded by the relay extension |
|
|
2319
|
-
| `claude-worker/` | Claude Agent SDK session worker (turn runner, ask-user bridge, attachments, SDK message normalizer) |
|
|
2320
|
-
| `claude-cloud-worker/` | Claude Cloud session worker (turn runner over the cloud session's event stream, event normalizer) |
|
|
2321
|
-
| `../shared/claude-cloud/` | Claude Cloud API client, the reader of the Claude CLI's login, repository/branch/model string rules |
|
|
2322
|
-
| `services/claude-cloud-settings-service.mjs` | Claude Cloud switch, default model and environment |
|
|
2323
|
-
| `services/claude-cloud-session-service.mjs` | What the relay keeps per cloud conversation: worker reports, the `cloud` payload field, archiving |
|
|
2324
|
-
| `services/claude-account-usage-service.mjs` | Live Claude account usage for Check Usage (only while Claude Cloud is on) |
|
|
2325
|
-
| `services/git-remote-service.mjs` | GitHub remote and branch of a folder, for the New Chat modal |
|
|
2326
|
-
| `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 |
|
|
2327
|
-
| `services/session-activity-tracker.mjs` | What each session is doing: `state`, tasks, task edges and the activity clock |
|
|
2328
|
-
| `../shared/relay-events.mjs` | Relay event types, limits and the text of an event message |
|
|
2329
|
-
| `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 |
|
|
2330
|
-
| `services/claude-session-root-service.mjs` | Resolves the browsable session folder for Claude conversations |
|
|
2331
|
-
| `services/context-usage-view.mjs` | Normalizes Copilot and Claude context data into one payload |
|
|
2332
|
-
| `services/cloudflared-tunnel-service.mjs` | Supervises the `cloudflared` child process for the Cloudflare Tunnel mode |
|
|
2333
|
-
| `services/tunnel-worker-path-guard.mjs` | Rejects session-worker WebSocket paths that arrive through a public tunnel |
|
|
2334
|
-
| `../shared/turn-ceiling.mjs` | Shared bounds/formatting for the max turn duration setting |
|