@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.
- package/.github/extensions/web-relay/extension.mjs +6 -1
- package/.github/extensions/web-relay/skills/tool-activity.mjs +7 -0
- package/CHANGELOG.md +67 -0
- package/README.md +62 -13
- package/package.json +5 -2
- package/server/README.md +43 -3
- package/server/claude-worker/claude-remote-relay-tool.mjs +99 -0
- package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
- package/server/claude-worker/claude-session-process.mjs +92 -25
- package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
- package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
- package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
- package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
- package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
- package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
- package/server/db-schema.mjs +8 -0
- package/server/grok-worker/acp-client.mjs +92 -6
- package/server/grok-worker/grok-turn-runner.mjs +59 -8
- package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
- package/server/mcp/oar-mcp-server.mjs +473 -0
- package/server/migrations/0005-remote-relays.mjs +90 -0
- package/server/public/app/action-confirmations.js +141 -13
- package/server/public/app/api-client.js +103 -0
- package/server/public/app/bootstrap.js +21 -6
- package/server/public/app/conversation-deep-link.mjs +42 -0
- package/server/public/app/conversation-view.js +98 -7
- package/server/public/app/host-suspend-ui.js +272 -0
- package/server/public/app/journal-view.js +4 -1
- package/server/public/app/mention-autocomplete.mjs +298 -0
- package/server/public/app/push-settings.js +1 -0
- package/server/public/app/pwa-install.js +8 -3
- package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
- package/server/public/app/remote-relay-origin-view.mjs +76 -0
- package/server/public/app/remote-relay-shared.mjs +85 -0
- package/server/public/app/remote-relays-settings.js +618 -0
- package/server/public/app/remote-relays-store.mjs +126 -0
- package/server/public/app/settings-modal.js +4 -2
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +18 -1
- package/server/public/app/thinking-follow.js +260 -0
- package/server/public/app/tool-activity-emoji.mjs +4 -0
- package/server/public/index.html +257 -8
- package/server/relay-tools.md +5 -1
- package/server/repositories/message-repository.mjs +7 -0
- package/server/repositories/question-repository.mjs +3 -0
- package/server/repositories/remote-relay-repository.mjs +75 -0
- package/server/repositories/session-repository.mjs +6 -1
- package/server/routes/ask-user-routes.mjs +42 -2
- package/server/routes/messages-routes.mjs +90 -16
- package/server/routes/remote-relay-routes.mjs +133 -0
- package/server/routes/sessions-routes.mjs +80 -29
- package/server/server-runtime.mjs +238 -0
- package/server/services/github-ci-activity-service.mjs +199 -0
- package/server/services/host-suspend-command.mjs +38 -0
- package/server/services/host-suspend-service.mjs +254 -0
- package/server/services/push-dispatch-service.mjs +37 -4
- package/server/services/remote-relay-caller-context.mjs +341 -0
- package/server/services/remote-relay-client.mjs +332 -0
- package/server/services/remote-relay-dispatcher.mjs +1500 -0
- package/server/services/remote-relay-inbound.mjs +187 -0
- package/server/services/remote-relay-pairing.mjs +189 -0
- package/server/services/remote-relay-registry-service.mjs +581 -0
- package/server/services/session-worker-launch-service.mjs +131 -12
- package/shared/remote-relay-contract.mjs +539 -0
- package/shared/remote-relay-mentions.mjs +129 -0
- package/shared/remote-relay-tool-core.mjs +314 -0
- 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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
329
|
+
### New in 0.9.4
|
|
330
330
|
|
|
331
|
-
- **
|
|
332
|
-
- **
|
|
333
|
-
- **
|
|
334
|
-
- **
|
|
335
|
-
- **
|
|
336
|
-
- **
|
|
337
|
-
-
|
|
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). **
|
|
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
|
|
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 |
|
|
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
|
+
"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 →
|
|
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
|
|
206
|
-
|
|
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
|
-
|
|
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,
|