@oar-sh/oar 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.github/extensions/web-relay/extension.mjs +6 -1
  2. package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
  3. package/CHANGELOG.md +67 -0
  4. package/README.md +62 -13
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +92 -25
  10. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  11. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  12. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  13. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  14. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  15. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  16. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  17. package/server/db-schema.mjs +8 -0
  18. package/server/grok-worker/acp-client.mjs +92 -6
  19. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  20. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  21. package/server/mcp/oar-mcp-server.mjs +473 -0
  22. package/server/migrations/0005-remote-relays.mjs +90 -0
  23. package/server/public/app/action-confirmations.js +141 -13
  24. package/server/public/app/api-client.js +103 -0
  25. package/server/public/app/bootstrap.js +21 -6
  26. package/server/public/app/conversation-deep-link.mjs +42 -0
  27. package/server/public/app/conversation-view.js +98 -7
  28. package/server/public/app/host-suspend-ui.js +272 -0
  29. package/server/public/app/journal-view.js +4 -1
  30. package/server/public/app/mention-autocomplete.mjs +298 -0
  31. package/server/public/app/push-settings.js +1 -0
  32. package/server/public/app/pwa-install.js +8 -3
  33. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  34. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  35. package/server/public/app/remote-relay-shared.mjs +85 -0
  36. package/server/public/app/remote-relays-settings.js +618 -0
  37. package/server/public/app/remote-relays-store.mjs +126 -0
  38. package/server/public/app/settings-modal.js +4 -2
  39. package/server/public/app/settings-tabs.js +1 -1
  40. package/server/public/app/socket-handlers.js +18 -1
  41. package/server/public/app/thinking-follow.js +260 -0
  42. package/server/public/app/tool-activity-emoji.mjs +4 -0
  43. package/server/public/index.html +257 -8
  44. package/server/relay-tools.md +5 -1
  45. package/server/repositories/message-repository.mjs +7 -0
  46. package/server/repositories/question-repository.mjs +3 -0
  47. package/server/repositories/remote-relay-repository.mjs +75 -0
  48. package/server/repositories/session-repository.mjs +6 -1
  49. package/server/routes/ask-user-routes.mjs +42 -2
  50. package/server/routes/messages-routes.mjs +90 -16
  51. package/server/routes/remote-relay-routes.mjs +133 -0
  52. package/server/routes/sessions-routes.mjs +80 -29
  53. package/server/server-runtime.mjs +238 -0
  54. package/server/services/github-ci-activity-service.mjs +199 -0
  55. package/server/services/host-suspend-command.mjs +38 -0
  56. package/server/services/host-suspend-service.mjs +254 -0
  57. package/server/services/push-dispatch-service.mjs +37 -4
  58. package/server/services/remote-relay-caller-context.mjs +341 -0
  59. package/server/services/remote-relay-client.mjs +332 -0
  60. package/server/services/remote-relay-dispatcher.mjs +1500 -0
  61. package/server/services/remote-relay-inbound.mjs +187 -0
  62. package/server/services/remote-relay-pairing.mjs +189 -0
  63. package/server/services/remote-relay-registry-service.mjs +581 -0
  64. package/server/services/session-worker-launch-service.mjs +131 -12
  65. package/shared/remote-relay-contract.mjs +539 -0
  66. package/shared/remote-relay-mentions.mjs +129 -0
  67. package/shared/remote-relay-tool-core.mjs +314 -0
  68. package/shared/worker-runtime/api-client.mjs +112 -22
@@ -97,7 +97,12 @@ const bannerStateStore = createBannerStateStore({
97
97
 
98
98
  const buildPromptWithRelayContext = createRelayPromptBuilder({
99
99
  toolInstructions: RELAY_TOOL_INSTRUCTIONS,
100
- getPreviewInstructions: createPreviewInstructionsProvider({ api }),
100
+ // With the OAR MCP server attached (session-worker-launch-service sets the
101
+ // flag), the CLI has a real `preview` tool: the HTTP-API instructions would
102
+ // only contradict it, so the section is dropped.
103
+ getPreviewInstructions: process.env.OAR_MCP_SERVER_ATTACHED === "1"
104
+ ? null
105
+ : createPreviewInstructionsProvider({ api }),
101
106
  });
102
107
 
103
108
  const managedServerLifecycle = createManagedServerLifecycle({
@@ -3,6 +3,8 @@ import {
3
3
  formatToolResultActivity as formatSharedToolResultActivity,
4
4
  formatVoteMemoryActivity,
5
5
  } from "../../../../shared/tool-activity.mjs";
6
+ import { isRemoteRelayToolName } from "../../../../shared/remote-relay-tool-core.mjs";
7
+ import { summarizeRemoteRelayCall } from "../../../../shared/remote-relay-contract.mjs";
6
8
 
7
9
  export function normalizeActivityText(value, maxToolDetailLength = 140) {
8
10
  const text = String(value || "").trim();
@@ -133,6 +135,11 @@ export function formatToolActivity(request, maxToolDetailLength = 140) {
133
135
  if (lower.includes("report_intent")) {
134
136
  return detail ? `● ${detail}` : "● Working…";
135
137
  }
138
+ // The OAR MCP server's tool reaches the CLI as `oar-remote_relay`: show the
139
+ // same line every other engine writes for it.
140
+ if (isRemoteRelayToolName(rawName)) {
141
+ return `Tool (remote_relay): ${summarizeRemoteRelayCall(toolArgsSnapshot(request))}`;
142
+ }
136
143
  return detail ? `Tool (${rawName}): ${detail}` : `Tool (${rawName})`;
137
144
  }
138
145
 
package/CHANGELOG.md CHANGED
@@ -5,6 +5,73 @@ All notable changes to OAR are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.4] — 2026-09-27
9
+
10
+ Highlights: remote relays (pair OAR relays and let an agent on one work on the
11
+ other), follow toggles for the running reply, and a Suspend host that waits for
12
+ agents, background tasks and CI to finish.
13
+
14
+ ### Added
15
+
16
+ - **Remote relays**: pair OAR relays in **Settings → Relays** by pasting the other
17
+ relay's web address; the pairing works both ways. Agents of every provider get a
18
+ `remote_relay` tool to list and read the other relay's sessions, prompt one, start
19
+ a new one, wait for its reply and relay its questions to you. A relay unlocks in a
20
+ conversation only once you mention it (`@` picks it from a list). Per relay, agents
21
+ may read, prompt or do everything; in ask and plan mode each write action asks you
22
+ first. The receiving relay marks such messages with a **↗ from …** badge. Tokens
23
+ stay on the relay; agents never see them.
24
+ - **OAR MCP server** (`server/mcp/oar-mcp-server.mjs`): Grok and the Copilot
25
+ extension engine now get the `preview` and `remote_relay` tools as real tools
26
+ instead of instruction text.
27
+ - **Follow the live reply**: three toggles in the running reply's header keep
28
+ its thoughts, its answer or its tool list pinned just above the composer as
29
+ the reply grows. Scrolling the transcript by hand turns it off; typing in the
30
+ composer does not. The choice is remembered per conversation until the page
31
+ reloads.
32
+ - A queued relay restart shows a banner on every client, with a **Cancel**
33
+ button (a new localhost-only cancel endpoint behind it).
34
+
35
+ ### Changed
36
+
37
+ - **Suspend host waits for the work to finish.** Instead of sleeping the
38
+ machine at once, the relay queues the suspend and fires it after 2 minutes
39
+ with nothing active: no queued, running or parked turn, no background task of
40
+ a live worker, and no open GitHub Actions run in a busy conversation's
41
+ repository (read through `gh`; a repository whose state cannot be read blocks
42
+ for 15 minutes, then is ignored). With nothing running at confirm time it
43
+ fires after a 30-second countdown. The confirmation lists what is still
44
+ active, every client shows a banner with the blockers or the countdown and a
45
+ **Cancel** button, and a push notification reports the suspend, or its drop
46
+ when the relay restarts. Suspend remains Windows-only;
47
+ `OAR_HOST_SUSPEND_DRY_RUN=1` logs instead of suspending.
48
+ - **Stop** asks for confirmation before cancelling the running turn.
49
+ - The **Install app name** setting is now called **Relay name**. Your value is
50
+ kept. It still labels the installed app, and it is the name paired relays
51
+ know this relay by; when it is empty, paired relays see the machine's
52
+ hostname.
53
+ - Dependency updates clear all five `npm audit` findings, one of them rated
54
+ high: express 4.22.3, body-parser 1.20.8, qs 6.16.0, fast-uri 3.1.8 and
55
+ hono 4.13.9. All were inside the declared ranges, so `package.json` is
56
+ unchanged; a git checkout gets them with `npm ci`.
57
+
58
+ ### Fixed
59
+
60
+ - Switching the Claude model in a conversation that already had replies no
61
+ longer strands the next message. The reply to the message that carried the
62
+ switch landed on a *continuation* row while the message itself stayed
63
+ processing, and every later message waited behind it for up to 5 minutes, or
64
+ until the session was killed.
65
+ - **Requests on an idle connection are no longer reset.** The relay closed a
66
+ kept-alive connection after 5 seconds without traffic, so a request sent on
67
+ it a moment later could be reset before the relay read a byte. Behind a
68
+ reverse proxy or tunnel that pools its connections this showed up as an
69
+ occasional 502, and a send was not repeated. Idle connections now stay open
70
+ for 65 seconds.
71
+ - After a send, the transcript stays at the newest message when the composer
72
+ grows (a session note or a warning banner appearing under it). It used to
73
+ slip up until the draft was saved.
74
+
8
75
  ## [0.9.3] — 2026-09-27
9
76
 
10
77
  Highlights: mid-turn steering on Claude and Copilot, Copilot background tasks on
package/README.md CHANGED
@@ -78,7 +78,7 @@ curl -fsSL oar.sh/install | sh
78
78
  The script checks for Node.js 22.13 or newer, runs `npm install -g @oar-sh/oar`, and then hands off to `oar setup` (with `--defaults` when there is no terminal to ask questions in). It refuses to run as root, because OAR installs per user. `OAR_VERSION=x.y.z` pins a version, `OAR_CHANNEL=beta` follows the beta channel, and `OAR_DRY_RUN=1` only prints what it would do:
79
79
 
80
80
  ```bash
81
- curl -fsSL oar.sh/install | OAR_VERSION=0.9.3 sh
81
+ curl -fsSL oar.sh/install | OAR_VERSION=0.9.4 sh
82
82
  ```
83
83
 
84
84
  Read it before you run it: [oar.sh/install](https://oar.sh/install).
@@ -89,7 +89,7 @@ Read it before you run it: [oar.sh/install](https://oar.sh/install).
89
89
  irm oar.sh/install.ps1 | iex
90
90
  ```
91
91
 
92
- The PowerShell installer (PowerShell 5 or newer) does the same: it checks Node.js, runs `npm install -g @oar-sh/oar`, then `oar setup`. It reads the same `OAR_VERSION`, `OAR_CHANNEL`, and `OAR_DRY_RUN` variables, for example `$env:OAR_VERSION = '0.9.3'` before the command.
92
+ The PowerShell installer (PowerShell 5 or newer) does the same: it checks Node.js, runs `npm install -g @oar-sh/oar`, then `oar setup`. It reads the same `OAR_VERSION`, `OAR_CHANNEL`, and `OAR_DRY_RUN` variables, for example `$env:OAR_VERSION = '0.9.4'` before the command.
93
93
 
94
94
  ### npm
95
95
 
@@ -326,15 +326,15 @@ Use **🤗 Select Models** to choose which variants show up in the composer, the
326
326
 
327
327
  ## Highlights
328
328
 
329
- ### New in 0.9.3
329
+ ### New in 0.9.4
330
330
 
331
- - **Mid-turn steering** for Claude, and for Copilot on the SDK engine with every model, hosted or BYOK: a message you send while a turn runs goes into that turn instead of waiting behind it. The send button reads **Steer**; while a question card, plan approval, or compaction is open it reads **Queue**, and the message steers in once that is resolved, so a question card is never bypassed. **Stop** lives on the running reply's bubble, and steered messages that a Stop cut off offer a one-tap **Resend**.
332
- - **Copilot background tasks** (SDK engine): background agents and detached shells appear in the task panel with the model they run on, the command they are running right now, their token count, and a **Stop** that works. The reply no longer waits for them; a background agent's result arrives afterwards as its own *continuation* reply.
333
- - **Multi-select question cards**: a question that allows several answers shows checkmarks and one **Reply with selection** button. Claude cards follow Claude's own flag; Copilot choice cards (SDK engine) also carry a **Select several** switch for when the model forgets to flag it.
334
- - **Drafts sync safely across devices**: an idle device no longer overwrites the draft you are typing on another one, and when two devices edit at once, the text that lost is offered back with **Restore**.
335
- - **Sidebar title filter** above the conversation list. It loads older pages while it is active, so it searches every conversation, not just the loaded ones.
336
- - **Deleting a conversation stops its worker and removes the CLI session OAR created for it** (Copilot's through the runtime, Claude's by deleting that session's transcript). A Copilot session OAR only imported from the host, one you started in a terminal, is hidden instead, and nothing is stopped for it. A conversation that is still working is not deleted; the sidebar tells you why, so you can stop it first.
337
- - The **Background task timeout** defaults to **4 hours** instead of no limit, for Claude and Copilot alike: long-running work still finishes, but a forgotten shell or agent no longer keeps its session alive forever. A value you already chose is kept.
331
+ - **Remote relays**: pair two OAR relays in **Settings → Relays** by pasting the other relay's web address; the pairing works both ways. An agent in any session can then work on the other relay when you ask it to: list and read its sessions, prompt one, start a new one, wait for the reply, and pass a question the remote agent asks back to you. See [Remote relays](#remote-relays).
332
+ - **You decide when a relay is in reach**: a paired relay stays locked in a conversation until you mention it (`@` picks it from a list). Per relay, **Agents may** limits agents to *read only*, *read and prompt*, or *full*, and in ask and plan mode every write action asks you first. The receiving relay marks such messages with a **↗ from …** badge.
333
+ - **Every provider gets the same tools**: Grok and the Copilot extension engine now get `remote_relay` and `preview` as real tools through OAR's MCP server, instead of instruction text.
334
+ - **Follow the live reply**: three toggles in the running reply's header keep its thoughts, its answer, or its tool list pinned just above the composer as the reply grows. Scrolling the transcript by hand turns it off; typing in the composer does not.
335
+ - **Suspend host waits for the work to finish** (Windows): the relay queues the suspend and fires it after 2 minutes with no turn, background task, or GitHub Actions run active, or after a 30-second countdown when nothing was running. Every device shows a banner with what is still active and a **Cancel** button; a queued relay restart shows the same banner.
336
+ - **Stop** asks for confirmation before it cancels the running turn.
337
+ - **Fewer stuck and failed sends**: switching the Claude model mid-conversation no longer strands the next message, and the relay keeps idle connections open for 65 seconds, so a request sent after a short pause is no longer reset (behind a proxy or tunnel: an occasional 502).
338
338
 
339
339
  ### Everything else
340
340
 
@@ -438,7 +438,7 @@ Use **➡️ Share conversation** in the `⋯` menu to publish a read-only link.
438
438
  ### Notifications and the app
439
439
 
440
440
  - **⚙️ Settings → Notifications** turns on push notifications per device: for questions from the agent, completed or failed turns, plan boards, and the CLI going offline. They need a secure context (HTTPS, or `localhost`), are sent only while no device has the app in the foreground, and show generic text unless you opt in to message previews on that device.
441
- - The **⬇** button in the sidebar installs OAR as an app (PWA). **Install app name** in Settings sets its label on every device that connects to this relay.
441
+ - The **⬇** button in the sidebar installs OAR as an app (PWA). **Relay name** in Settings sets its label on every device that connects to this relay, and is the name [paired relays](#remote-relays) know it by.
442
442
 
443
443
  ### The conversation menu
444
444
 
@@ -448,7 +448,7 @@ Besides the entries above, the `⋯` menu holds **✍️ Edit conversation title
448
448
 
449
449
  ### Settings (⚙️ in the web UI)
450
450
 
451
- The modal is organised into five tabs — **General**, **Providers** (with a **Copilot**, **OpenAI**, **Claude**, **Grok**, and **Cursor** sub-tab), **Previews**, **Notifications**, and **Features** — and reopens on the tab you used last. Unless noted as per browser, these settings live in the relay database rather than the config file, and apply to every browser that connects:
451
+ The modal is organised into six tabs — **General**, **Providers** (with a **Copilot**, **OpenAI**, **Claude**, **Grok**, and **Cursor** sub-tab), **Relays**, **Previews**, **Notifications**, and **Features** — and reopens on the tab you used last. Unless noted as per browser, these settings live in the relay database rather than the config file, and apply to every browser that connects:
452
452
 
453
453
  | Tab | Setting | Default | What it does |
454
454
  | --- | ------- | ------- | ------------ |
@@ -457,7 +457,7 @@ The modal is organised into five tabs — **General**, **Providers** (with a **C
457
457
  | General | Max turn duration | `60 min` | Hard cap on how long one turn may run before the relay requeues it (0 = no limit, up to 10 h; see below) |
458
458
  | General | Background task timeout | `4 h` | How long background tasks alone may keep a Claude or Copilot session running after its reply; then they are stopped (0 = no limit, up to 10 h) |
459
459
  | General | Autostart (Windows) | Off | *At sign-in* (a visible terminal after you log on) or *At system startup* (headless, no logon needed, one admin confirmation) |
460
- | General | Install app name | `OAR` | Label of the installed PWA on every device |
460
+ | General | Relay name | `OAR` (the hostname for paired relays) | Label of the installed PWA on every device, and this relay's name for [paired relays](#remote-relays) |
461
461
  | General | Check for updates automatically | off | Opt-in: the relay asks oar.sh for the latest version about twice a day. **Check for updates** works regardless; `OAR_NO_UPDATE_CHECK=1` blocks both |
462
462
  | General | Default CWD for new sessions | relay workspace root | Working directory for newly created sessions that have none of their own |
463
463
  | Providers | Copilot engine | SDK | Extension when this relay cannot run the SDK engine (see [engine choice](#github-copilot--engine-choice)) |
@@ -466,6 +466,8 @@ The modal is organised into five tabs — **General**, **Providers** (with a **C
466
466
  | Providers | Claude account (Relogin / Logout) | host login | Switches the Claude account the relay host's CLI uses, from the browser (see [Claude (Agent SDK)](#claude-agent-sdk)) |
467
467
  | Providers | Cursor API key / model | — / `composer-2.5` | Enables Cursor; plus monthly plan allowances and the optional dashboard session token |
468
468
  | Providers | Grok | off | Enables Grok; default model `grok-4.5`; **Sign in** / **Sign out**; optional monthly allowance |
469
+ | Relays | Public address, Accept prompts from other relays' agents | browser address / on | How paired relays reach this one, and whether their agents may prompt sessions here (see [Remote relays](#remote-relays)) |
470
+ | Relays | Remote relays | — | Pair a relay by pasting its web address; per relay **Agents may** read only, read and prompt, or do everything |
469
471
  | Previews | Live previews | — | Lists the published previews, each of which you can close |
470
472
  | Notifications | Push notifications | off | Per device: which events notify, and whether titles and message previews are included |
471
473
  | Features | Feature flags | see below | **Session worker routing** (on), **Continuation answer routing** (on), **Generated-image continuity** (on), and a reserved **Worker fallback restart** (off, no effect yet). Changes apply after a relay restart; a `COPILOT_REMOTE_<FLAG>` environment variable pins a flag, e.g. `COPILOT_REMOTE_SESSION_WORKER_ROUTING_ENABLED=0` |
@@ -638,6 +640,52 @@ covering `/api/shared/*` — shared views poll for liveness and an edge-cached r
638
640
  would pin viewers to a stale snapshot. Likewise, a Cloudflare Access application over the
639
641
  bound hostname breaks share links unless it bypasses `/shared/*` and `/api/shared/*`.
640
642
 
643
+ ## Remote relays
644
+
645
+ Several OAR relays can be paired, for example the one on your workstation and the one on a
646
+ server. An agent in any session can then work on the other relay when you ask it to: list and
647
+ read its sessions, prompt one, start a new one, wait for the reply, and pass a question the
648
+ remote agent asks back to you.
649
+
650
+ **Pairing.** Open **Settings → Relays** and paste the other relay's web address (the URL you
651
+ open it with in the browser). OAR checks the address with its own token, or with a
652
+ `?token=` the pasted link carries, and asks for a token only when that fails. It reads the
653
+ other relay's name from its API. With **Also add this relay there** checked, it introduces
654
+ itself to the other relay using the address in **Public address**, so the pairing works in both
655
+ directions. When the two relays use different tokens, that introduction hands over this relay's
656
+ token, so the other relay's agents can then work here. Each side can remove the other on its own,
657
+ but removing a relay does not revoke a token it already holds: rotate the token for that. A relay
658
+ that is already paired keeps the address and token stored for it when it introduces itself again;
659
+ change those in **Settings → Relays**.
660
+
661
+ A relay's name is the **Relay name** under **General** (it also names the installed app); when it
662
+ is empty, the machine's hostname is used.
663
+
664
+ **Mention a relay to unlock it.** Agents can see which relays are paired, but a relay stays
665
+ locked in a conversation until you mention it in one of your messages: type `@` and pick it
666
+ from the list, or write its name or its host name. An IP address or `localhost` never counts as a
667
+ host name; a relay whose name is itself an address is unlocked only with a leading `@`. The
668
+ mention unlocks that relay for the rest of
669
+ the conversation. Prompts that arrive from another relay's agent never unlock anything, and a
670
+ chain of relays prompting relays stops after two hops.
671
+
672
+ **What agents can do.** Every provider gets the same `remote_relay` tool (Claude, Cursor and
673
+ Copilot directly; Grok and the Copilot extension engine through OAR's MCP server). Per paired
674
+ relay, **Agents may** limits it to *read only*, *read and prompt* (existing sessions) or *full*
675
+ (also create sessions, answer questions, stop turns, archive). In the *ask* and *plan* relay
676
+ modes, every write action first asks you with a question card.
677
+
678
+ When an agent prompts a session on another relay, that relay shows a **↗ from …** badge with the
679
+ sending relay, session and model on the message, and marks sessions an agent created in the
680
+ conversation list. The remote agent sees a one-line header saying that another agent is talking
681
+ to it. Turns started this way do not send "reply ready" notifications on the remote relay;
682
+ question cards still do. **Accept prompts from other relays' agents** in **Settings → Relays**
683
+ switches incoming agent prompts off.
684
+
685
+ The calls go through your own relay, which holds the other relay's token: agents never see a
686
+ token. Remote addresses must use `https://`; plain `http://` is accepted only for loopback (an
687
+ SSH port forward, for example) and private network ranges.
688
+
641
689
  ## Security notes
642
690
 
643
691
  - The auth token guards the API and the Socket.IO channel. A browser signs in once, with the token or with a URL carrying `?token=` (such as the one `oar setup` prints and puts in its QR code), and then holds an HttpOnly cookie for 30 days, marked `Secure` when the relay is reached over HTTPS. Treat the token, and that URL, like a password.
@@ -645,6 +693,7 @@ bound hostname breaks share links unless it bypasses `/shared/*` and `/api/share
645
693
  - `localhostOnly` (default `true`) keeps the relay on loopback. Beyond your LAN, use HTTPS through one of the [tunnels](#remote-access) rather than an open port.
646
694
  - Agents act with the permissions of the user the relay runs as. OAR adds reach, not a sandbox: whoever holds the token can have them run commands on the host. `workspaceRootAllowList` limits the directories conversations may start in.
647
695
  - Shared conversation links and preview links are public by design: anyone with the URL can read the shared transcript or reach the previewed app, without signing in.
696
+ - A [paired relay](#remote-relays) holds a token that opens the other relay completely. The per-relay "Agents may" limit and the "Accept prompts from other relays' agents" switch are honoured by OAR's own traffic, but anyone holding the token can still use the full API: the token is the real boundary. A token entered for a paired relay is stored like the API keys below and never sent to browsers or agents.
648
697
  - API keys you enter (OpenAI, Cursor) are stored in the relay database on the host and never sent to browsers. Claude and Grok credentials stay with their CLIs on the host.
649
698
  - OAR has no telemetry. Its only request to oar.sh is the update check, which is off until you enable it.
650
699
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oar-sh/oar",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "type": "module",
5
5
  "description": "OAR — Open Agent Relay: drive your local coding agents from any browser",
6
6
  "license": "MIT",
@@ -40,9 +40,12 @@
40
40
  "scripts": {
41
41
  "start": "node server/server.js",
42
42
  "copilot:relay": "gh copilot -- --allow-all",
43
- "test": "node --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\"",
43
+ "test": "node --test \"server/**/*.test.mjs\" \"shared/**/*.test.mjs\" \".github/extensions/web-relay/**/*.test.mjs\" \"scripts/**/*.test.mjs\"",
44
44
  "test:agents:smoke": "node --test \"tests/agents/**/*.test.mjs\"",
45
45
  "test:e2e": "node tests/run-e2e.mjs",
46
+ "setup:git": "node scripts/setup-git.mjs",
47
+ "land": "node scripts/land.mjs",
48
+ "prune:local": "node scripts/prune-local.mjs",
46
49
  "test:e2e:raw": "node ./node_modules/@playwright/test/cli.js test --config tests/playwright.config.mjs"
47
50
  },
48
51
  "dependencies": {
package/server/README.md CHANGED
@@ -193,7 +193,7 @@ where supported (with standalone fallback), and hides install/fullscreen header
193
193
  When opened in a regular browser tab, the in-app **Install** button remains available
194
194
  in the chat header (shown as `⬇` on small screens).
195
195
 
196
- You can rename the installed app label from **⚙️ Settings → Install app name**.
196
+ You can rename the installed app label from **⚙️ Settings → Relay name** (paired relays know this relay by the same name).
197
197
  The name is stored on the relay (app settings) and served in the web app manifest
198
198
  to every browser and origin (LAN and tunnel agree), so future installs pick it up
199
199
  immediately and already-installed Android apps receive it through Chrome's normal
@@ -201,9 +201,34 @@ manifest-update flow — one confirmation prompt showing the new name, then the
201
201
  launcher label updates. A legacy per-browser name is adopted by the relay the
202
202
  first time that browser loads the app.
203
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. A queued **🌄 Restart web relay**
216
+ gets the same banner and can be cancelled from it too.
217
+
218
+ API: `GET /api/host/suspend` (state plus current blockers), `POST /api/host/suspend`,
219
+ `POST /api/host/suspend/cancel`, and `POST /api/relay/shutdown/cancel`
220
+ (localhost-only). Set `OAR_HOST_SUSPEND_DRY_RUN=1` to log instead of sleeping.
221
+
204
222
  You can also hide the **💤 Suspend host** action from **⚙️ Settings** with the
205
- **Show Suspend host action** checkbox. This only controls UI visibility; it does not
206
- change host suspend implementation behavior.
223
+ **Show Suspend host action** checkbox. This only controls the menu entry; the
224
+ banner for an already queued suspend still shows on every device.
225
+
226
+ The live thinking bubble's header carries three follow buttons on the right —
227
+ **💭** thoughts, **📃** streamed answer, **🛠️** tool list. Arming one keeps that
228
+ section's last line pinned just above the composer as the reply grows; scrolling
229
+ the transcript by hand (wheel, touch, scroll keys) disarms it, typing in the
230
+ composer does not. The armed mode is remembered per conversation for the life of
231
+ the page. **Stop** now asks for confirmation before cancelling the turn.
207
232
 
208
233
  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.
209
234
 
@@ -821,6 +846,17 @@ Queue metrics include `parkedCount` for turns deferred behind restart/rebind gat
821
846
  | POST | `/api/openai/images/generate` | Generate images for an OpenAI Image conversation |
822
847
  | POST | `/api/image-operations/:operationId/execute` | Execute a queued image generation/edit operation |
823
848
  | GET | `/api/generated-image/:conversationId/:messageId/:imageId/content` | Stream a generated image blob |
849
+ | 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}` |
850
+ | GET | `/api/remote-relays` | Paired remote relays with their status (`relays[]`, never a token) plus `self` |
851
+ | POST | `/api/remote-relays` | Pair a relay from a pasted web address (`url`, optional `token`, `pairBack`, `selfUrl`); `{ needsToken: true }` when neither token works |
852
+ | PATCH | `/api/remote-relays/:id` | Change a paired relay's `permission` (`read` / `prompt` / `full`), URL or token |
853
+ | DELETE | `/api/remote-relays/:id` | Remove a paired relay here (the other side keeps its entry) |
854
+ | POST | `/api/remote-relays/:id/check` | Probe a paired relay now |
855
+ | POST | `/api/remote-relays/pair` | (Relay to relay) Another relay introduces itself after the user paired it there |
856
+ | GET | `/api/remote-relays/summary` | (Worker) Number of paired relays; workers register `remote_relay` only when it is above zero |
857
+ | POST | `/api/remote-relays/tool` | (Worker) One `remote_relay` tool call: `{ conversationId, action, args }`. 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 |
858
+ | GET | `/api/remote-relays/inflight` | (Worker) Number of `remote_relay` calls running for `conversationId` |
859
+ | GET/POST | `/api/settings/remote-relays` | This relay's `publicUrl` and `inboundEnabled` (accept prompts from other relays' agents) |
824
860
 
825
861
  ### Manual relay shutdown / self-restart
826
862
 
@@ -1378,6 +1414,10 @@ and on `messages`:
1378
1414
  - `hidden_from_shares` / `share_hidden_at` — per-message share visibility (see
1379
1415
  [Conversation sharing](#conversation-sharing-and-per-message-visibility))
1380
1416
 
1417
+ `0005-remote-relays.mjs` adds `origin_json` to `messages` and `conversations` (provenance of
1418
+ prompts and sessions another relay's agent created) and the table
1419
+ `conversation_remote_relay_unlocks` (the remote relays the user mentioned per conversation).
1420
+
1381
1421
  New tables are likewise created at startup with `CREATE TABLE IF NOT EXISTS` in both the fresh-schema
1382
1422
  and migration blocks — most recently `workflow_runs` (final workflow digest per assistant message,
1383
1423
  keyed on `response_message_id`, cascade-deleted with the conversation; see
@@ -0,0 +1,99 @@
1
+ /**
2
+ * The relay's `remote_relay` tool for the Claude worker, shaped as an SDK MCP
3
+ * tool definition (`{ name, description, inputSchema, handler }`) so the
4
+ * adapter can hand it to `createSdkMcpServer` next to the preview tool. On the
5
+ * wire it is `mcp__relay__remote_relay`; the module never imports the SDK.
6
+ */
7
+
8
+ import * as z from 'zod';
9
+
10
+ import {
11
+ REMOTE_RELAY_ACTIONS,
12
+ REMOTE_RELAY_IF_BUSY,
13
+ REMOTE_RELAY_MODES,
14
+ REMOTE_RELAY_PROVIDERS,
15
+ REMOTE_RELAY_SESSION_SCOPES,
16
+ } from '../../shared/remote-relay-contract.mjs';
17
+ import {
18
+ REMOTE_RELAY_CALL_FAILED,
19
+ REMOTE_RELAY_TOOL_DESCRIPTION,
20
+ REMOTE_RELAY_TOOL_INPUT_SCHEMA,
21
+ REMOTE_RELAY_TOOL_NAME,
22
+ executeRemoteRelayTool,
23
+ formatRemoteRelayToolResult,
24
+ } from '../../shared/remote-relay-tool-core.mjs';
25
+
26
+ const FIELDS = REMOTE_RELAY_TOOL_INPUT_SCHEMA.properties;
27
+
28
+ function text(field) {
29
+ return z.string().optional().describe(FIELDS[field].description);
30
+ }
31
+
32
+ function choice(field, values) {
33
+ return z.enum([...values]).optional().describe(FIELDS[field].description);
34
+ }
35
+
36
+ function number(field) {
37
+ return z.number().optional().describe(FIELDS[field].description);
38
+ }
39
+
40
+ /**
41
+ * The contract's JSON schema mirrored as a zod raw shape (the SDK's MCP layer
42
+ * accepts zod only). Descriptions and enum values are read from the contract,
43
+ * so the model sees the same field docs on every provider. Numeric bounds are
44
+ * left off on purpose, as on the preview tool: the contract's validator clamps
45
+ * wait_seconds / limit / last / max_chars instead of failing the call.
46
+ */
47
+ export const REMOTE_RELAY_TOOL_ZOD_SHAPE = {
48
+ action: z.enum([...REMOTE_RELAY_ACTIONS]).describe(FIELDS.action.description),
49
+ relay: text('relay'),
50
+ session: text('session'),
51
+ text: text('text'),
52
+ message_id: text('message_id'),
53
+ wait_seconds: number('wait_seconds'),
54
+ scope: choice('scope', REMOTE_RELAY_SESSION_SCOPES),
55
+ query: text('query'),
56
+ limit: number('limit'),
57
+ cursor: text('cursor'),
58
+ last: number('last'),
59
+ before: text('before'),
60
+ include_activity: z.boolean().optional().describe(FIELDS.include_activity.description),
61
+ max_chars: number('max_chars'),
62
+ provider: choice('provider', REMOTE_RELAY_PROVIDERS),
63
+ model: text('model'),
64
+ cwd: text('cwd'),
65
+ mode: choice('mode', REMOTE_RELAY_MODES),
66
+ title: text('title'),
67
+ question_id: text('question_id'),
68
+ answer: text('answer'),
69
+ choices: z.array(z.string()).optional().describe(FIELDS.choices.description),
70
+ if_busy: choice('if_busy', REMOTE_RELAY_IF_BUSY),
71
+ };
72
+
73
+ export function createRemoteRelayToolDefinition({ api, getConversationId = () => '', dbg = () => {} } = {}) {
74
+ return {
75
+ name: REMOTE_RELAY_TOOL_NAME,
76
+ description: REMOTE_RELAY_TOOL_DESCRIPTION,
77
+ inputSchema: REMOTE_RELAY_TOOL_ZOD_SHAPE,
78
+ // `extra.signal` is the MCP request's: the SDK aborts it when the CLI
79
+ // cancels the call (a Stop interrupting the turn) or the process's MCP
80
+ // transport closes, and the pending relay request goes with it.
81
+ handler: async (args, extra) => {
82
+ let result;
83
+ try {
84
+ result = await executeRemoteRelayTool(args, {
85
+ api,
86
+ conversationId: String(getConversationId() || ''),
87
+ signal: extra?.signal || null,
88
+ });
89
+ } catch (error) {
90
+ // A tool exception would surface as a failed MCP call and can abort
91
+ // the turn; a refusal the model can read cannot.
92
+ const message = error?.message || String(error);
93
+ dbg('remote_relay tool failed', message);
94
+ result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
95
+ }
96
+ return { content: [{ type: 'text', text: formatRemoteRelayToolResult(result) }] };
97
+ },
98
+ };
99
+ }
@@ -2,12 +2,23 @@ import { createSdkMcpServer, query } from '@anthropic-ai/claude-agent-sdk';
2
2
 
3
3
  import { parseThinkingDisplay } from '../../shared/claude-thinking.mjs';
4
4
  import { MEDIA_EMBED_INSTRUCTION_TEXT } from '../../shared/media-embed-instructions.mjs';
5
+ import { REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS } from '../../shared/remote-relay-tool-core.mjs';
5
6
  import { createPreviewToolDefinition } from './claude-preview-tool.mjs';
7
+ import { createRemoteRelayToolDefinition } from './claude-remote-relay-tool.mjs';
6
8
 
7
9
  // The name the in-process MCP server is registered under; it prefixes every
8
- // tool it carries on the wire (`mcp__relay__preview`).
10
+ // tool it carries on the wire (`mcp__relay__preview`, `mcp__relay__remote_relay`).
9
11
  export const RELAY_MCP_SERVER_NAME = 'relay';
10
12
 
13
+ // The per-server tool-call timeout the CLI applies to the relay's tools.
14
+ // Measured against CLI 2.1.283: an SDK server's calls are bounded by this
15
+ // value, else by the MCP_TOOL_TIMEOUT environment variable, else by 1e8 ms —
16
+ // and SDK servers are exempt from the idle timeout. Pinning it here keeps a
17
+ // host-wide MCP_TOOL_TIMEOUT (set for some other server) from cutting off a
18
+ // remote_relay wait: those run up to wait_seconds (≤ 600 s) plus however long
19
+ // an approval card sits unanswered.
20
+ export const RELAY_MCP_TOOL_TIMEOUT_MS = REMOTE_RELAY_TOOL_CALL_TIMEOUT_MS;
21
+
11
22
  const MODE_SYSTEM_PROMPT_APPEND = {
12
23
  ask: 'Prioritize clarification questions (AskUserQuestion) before implementation work; do not make broad assumptions when a question would materially change the result.',
13
24
  autopilot: 'Keep moving unless user input is truly blocking; avoid unnecessary questions.',
@@ -143,11 +154,19 @@ export function systemPromptForRelayMode(relayMode) {
143
154
  * which beats inventing a capability flag to plumb through the delivery
144
155
  * payload. `getConversationId` is called per tool call because a process
145
156
  * outlives the turn it was spawned for.
157
+ *
158
+ * `remote_relay` is the exception: it rides along only when the worker found
159
+ * at least one paired remote (`remoteRelayTool`), because a tool that can do
160
+ * nothing on a relay without remotes would only cost context on every turn.
146
161
  */
147
- export function createRelayMcpServer({ api, getConversationId, dbg } = {}) {
162
+ export function createRelayMcpServer({ api, getConversationId, dbg, remoteRelayTool = false } = {}) {
148
163
  return createSdkMcpServer({
149
164
  name: RELAY_MCP_SERVER_NAME,
150
- tools: [createPreviewToolDefinition({ api, getConversationId, dbg })],
165
+ timeout: RELAY_MCP_TOOL_TIMEOUT_MS,
166
+ tools: [
167
+ createPreviewToolDefinition({ api, getConversationId, dbg }),
168
+ ...(remoteRelayTool ? [createRemoteRelayToolDefinition({ api, getConversationId, dbg })] : []),
169
+ ],
151
170
  });
152
171
  }
153
172
 
@@ -215,6 +234,9 @@ export function startClaudeSession({
215
234
  pathToClaudeCodeExecutable = '',
216
235
  api = null,
217
236
  getConversationId = () => '',
237
+ // Whether this process carries `remote_relay` (the worker's registration
238
+ // decision). Fixed for the process: the tool list is part of the spawn.
239
+ remoteRelayTool = false,
218
240
  queryImpl = query,
219
241
  dbg = () => {},
220
242
  } = {}) {
@@ -230,7 +252,7 @@ export function startClaudeSession({
230
252
  // Relay tools need the worker's authenticated API helper; a caller that
231
253
  // passes none (tests, probes) gets a session without them.
232
254
  const relayMcpServer = typeof api === 'function'
233
- ? createRelayMcpServer({ api, getConversationId, dbg })
255
+ ? createRelayMcpServer({ api, getConversationId, dbg, remoteRelayTool: remoteRelayTool === true })
234
256
  : null;
235
257
  const options = {
236
258
  cwd,