@oar-sh/oar 0.9.3 → 0.9.5

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 (76) 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 +98 -0
  4. package/README.md +76 -14
  5. package/package.json +5 -2
  6. package/server/README.md +43 -3
  7. package/server/claude-worker/claude-remote-relay-tool.mjs +100 -0
  8. package/server/claude-worker/claude-sdk-adapter.mjs +26 -4
  9. package/server/claude-worker/claude-session-process.mjs +385 -43
  10. package/server/claude-worker/claude-session-worker.mjs +5 -0
  11. package/server/claude-worker/sdk-message-normalizer.mjs +7 -0
  12. package/server/copilot-worker/copilot-remote-relay-tool.mjs +59 -0
  13. package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +12 -2
  14. package/server/copilot-worker/copilot-sdk-session-process.mjs +83 -9
  15. package/server/copilot-worker/copilot-sdk-session-worker.mjs +5 -0
  16. package/server/cursor-worker/cursor-remote-relay-tool.mjs +65 -0
  17. package/server/cursor-worker/cursor-session-worker.mjs +5 -0
  18. package/server/cursor-worker/cursor-turn-runner.mjs +45 -2
  19. package/server/cursor-worker/sdk-message-normalizer.mjs +25 -0
  20. package/server/db-schema.mjs +8 -0
  21. package/server/grok-worker/acp-client.mjs +92 -6
  22. package/server/grok-worker/grok-session-worker.mjs +5 -0
  23. package/server/grok-worker/grok-turn-runner.mjs +59 -8
  24. package/server/grok-worker/sdk-message-normalizer.mjs +13 -0
  25. package/server/mcp/oar-mcp-server.mjs +473 -0
  26. package/server/migrations/0005-remote-relays.mjs +90 -0
  27. package/server/public/app/action-confirmations.js +141 -13
  28. package/server/public/app/api-client.js +103 -0
  29. package/server/public/app/bootstrap.js +21 -6
  30. package/server/public/app/conversation-deep-link.mjs +42 -0
  31. package/server/public/app/conversation-view.js +100 -7
  32. package/server/public/app/host-suspend-ui.js +272 -0
  33. package/server/public/app/journal-view.js +4 -1
  34. package/server/public/app/mention-autocomplete.mjs +298 -0
  35. package/server/public/app/push-settings.js +1 -0
  36. package/server/public/app/pwa-install.js +8 -3
  37. package/server/public/app/relay-prompt-sanitizer.mjs +48 -5
  38. package/server/public/app/remote-relay-origin-view.mjs +76 -0
  39. package/server/public/app/remote-relay-shared.mjs +85 -0
  40. package/server/public/app/remote-relays-settings.js +618 -0
  41. package/server/public/app/remote-relays-store.mjs +126 -0
  42. package/server/public/app/settings-modal.js +4 -2
  43. package/server/public/app/settings-tabs.js +1 -1
  44. package/server/public/app/socket-handlers.js +18 -1
  45. package/server/public/app/steered-turn-merge.mjs +10 -1
  46. package/server/public/app/thinking-follow.js +260 -0
  47. package/server/public/app/tool-activity-emoji.mjs +4 -0
  48. package/server/public/index.html +264 -11
  49. package/server/relay-tools.md +5 -1
  50. package/server/repositories/message-repository.mjs +7 -0
  51. package/server/repositories/question-repository.mjs +3 -0
  52. package/server/repositories/remote-relay-repository.mjs +75 -0
  53. package/server/repositories/session-repository.mjs +6 -1
  54. package/server/routes/ask-user-routes.mjs +42 -2
  55. package/server/routes/messages-routes.mjs +98 -19
  56. package/server/routes/remote-relay-routes.mjs +133 -0
  57. package/server/routes/sessions-routes.mjs +84 -31
  58. package/server/server-runtime.mjs +238 -0
  59. package/server/services/github-ci-activity-service.mjs +199 -0
  60. package/server/services/host-suspend-command.mjs +38 -0
  61. package/server/services/host-suspend-service.mjs +254 -0
  62. package/server/services/push-dispatch-service.mjs +37 -4
  63. package/server/services/remote-relay-caller-context.mjs +346 -0
  64. package/server/services/remote-relay-client.mjs +332 -0
  65. package/server/services/remote-relay-dispatcher.mjs +1743 -0
  66. package/server/services/remote-relay-inbound.mjs +187 -0
  67. package/server/services/remote-relay-pairing.mjs +189 -0
  68. package/server/services/remote-relay-registry-service.mjs +581 -0
  69. package/server/services/session-worker-launch-service.mjs +148 -14
  70. package/shared/remote-relay-contract.mjs +556 -0
  71. package/shared/remote-relay-mentions.mjs +129 -0
  72. package/shared/remote-relay-tool-core.mjs +314 -0
  73. package/shared/steer-settle-failure.mjs +6 -0
  74. package/shared/steer-settle-markers.mjs +16 -0
  75. package/shared/worker-runtime/api-client.mjs +112 -22
  76. package/shared/worker-runtime/worker-log-file.mjs +97 -0
@@ -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,104 @@ All notable changes to OAR are documented here. The format follows
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.9.5] — 2026-09-27
9
+
10
+ Highlights: a Claude reply on the wrong row no longer blocks the conversation,
11
+ workers on Windows write a log, and agents can set the effort of a session on
12
+ another relay.
13
+
14
+ ### Added
15
+
16
+ - **Remote relays**: an agent can set the reasoning effort of a session it starts or
17
+ prompts on another relay (`effort` on `create_session` and `send`), and `relay_info`
18
+ lists the efforts each model there takes.
19
+
20
+ ### Changed
21
+
22
+ - Session workers on Windows now write the same `worker-<session>.log` as on
23
+ Linux, in the relay's log directory; the console window keeps showing the
24
+ output.
25
+
26
+ ### Fixed
27
+
28
+ - **Remote relays:** waiting for a remote agent's reply no longer stops at an
29
+ interim answer ("still waiting for …") while the agent goes on in a turn of
30
+ its own without a background task, for example while its subagents work.
31
+ - A Claude reply published as a *background continuation* instead of under
32
+ your message no longer blocks the conversation for up to 5 minutes: after
33
+ about 45 seconds of silence your message closes with a small muted note
34
+ naming that reply, and the next message runs normally. The note sends no
35
+ second notification, and an agent on a paired relay receives the reply
36
+ itself, not the note. A stray re-init of the Claude CLI between turns no
37
+ longer sends the reply there in the first place.
38
+
39
+ ## [0.9.4] — 2026-09-27
40
+
41
+ Highlights: remote relays (pair OAR relays and let an agent on one work on the
42
+ other), follow toggles for the running reply, and a Suspend host that waits for
43
+ agents, background tasks and CI to finish.
44
+
45
+ ### Added
46
+
47
+ - **Remote relays**: pair OAR relays in **Settings → Relays** by pasting the other
48
+ relay's web address; the pairing works both ways. Agents of every provider get a
49
+ `remote_relay` tool to list and read the other relay's sessions, prompt one, start
50
+ a new one, wait for its reply and relay its questions to you. A relay unlocks in a
51
+ conversation only once you mention it (`@` picks it from a list). Per relay, agents
52
+ may read, prompt or do everything; in ask and plan mode each write action asks you
53
+ first. The receiving relay marks such messages with a **↗ from …** badge. Tokens
54
+ stay on the relay; agents never see them.
55
+ - **OAR MCP server** (`server/mcp/oar-mcp-server.mjs`): Grok and the Copilot
56
+ extension engine now get the `preview` and `remote_relay` tools as real tools
57
+ instead of instruction text.
58
+ - **Follow the live reply**: three toggles in the running reply's header keep
59
+ its thoughts, its answer or its tool list pinned just above the composer as
60
+ the reply grows. Scrolling the transcript by hand turns it off; typing in the
61
+ composer does not. The choice is remembered per conversation until the page
62
+ reloads.
63
+ - A queued relay restart shows a banner on every client, with a **Cancel**
64
+ button (a new localhost-only cancel endpoint behind it).
65
+
66
+ ### Changed
67
+
68
+ - **Suspend host waits for the work to finish.** Instead of sleeping the
69
+ machine at once, the relay queues the suspend and fires it after 2 minutes
70
+ with nothing active: no queued, running or parked turn, no background task of
71
+ a live worker, and no open GitHub Actions run in a busy conversation's
72
+ repository (read through `gh`; a repository whose state cannot be read blocks
73
+ for 15 minutes, then is ignored). With nothing running at confirm time it
74
+ fires after a 30-second countdown. The confirmation lists what is still
75
+ active, every client shows a banner with the blockers or the countdown and a
76
+ **Cancel** button, and a push notification reports the suspend, or its drop
77
+ when the relay restarts. Suspend remains Windows-only;
78
+ `OAR_HOST_SUSPEND_DRY_RUN=1` logs instead of suspending.
79
+ - **Stop** asks for confirmation before cancelling the running turn.
80
+ - The **Install app name** setting is now called **Relay name**. Your value is
81
+ kept. It still labels the installed app, and it is the name paired relays
82
+ know this relay by; when it is empty, paired relays see the machine's
83
+ hostname.
84
+ - Dependency updates clear all five `npm audit` findings, one of them rated
85
+ high: express 4.22.3, body-parser 1.20.8, qs 6.16.0, fast-uri 3.1.8 and
86
+ hono 4.13.9. All were inside the declared ranges, so `package.json` is
87
+ unchanged; a git checkout gets them with `npm ci`.
88
+
89
+ ### Fixed
90
+
91
+ - Switching the Claude model in a conversation that already had replies no
92
+ longer strands the next message. The reply to the message that carried the
93
+ switch landed on a *continuation* row while the message itself stayed
94
+ processing, and every later message waited behind it for up to 5 minutes, or
95
+ until the session was killed.
96
+ - **Requests on an idle connection are no longer reset.** The relay closed a
97
+ kept-alive connection after 5 seconds without traffic, so a request sent on
98
+ it a moment later could be reset before the relay read a byte. Behind a
99
+ reverse proxy or tunnel that pools its connections this showed up as an
100
+ occasional 502, and a send was not repeated. Idle connections now stay open
101
+ for 65 seconds.
102
+ - After a send, the transcript stays at the newest message when the composer
103
+ grows (a session note or a warning banner appearing under it). It used to
104
+ slip up until the draft was saved.
105
+
8
106
  ## [0.9.3] — 2026-09-27
9
107
 
10
108
  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.5 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.5'` before the command.
93
93
 
94
94
  ### npm
95
95
 
@@ -326,15 +326,22 @@ 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.5
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
+ - **A misplaced Claude reply no longer blocks the conversation**: when a reply is published as a *background continuation* instead of under your message, the message closes after about 45 seconds of silence with a small note naming that reply, and the next message runs normally. It used to hold every later message for up to 5 minutes.
332
+ - **Effort for remote sessions**: an agent can set the reasoning effort of a session it starts or prompts on another relay; a new session takes the calling session's effort when the model there supports it. See [Remote relays](#remote-relays).
333
+ - **Waiting on another relay is steadier**: the wait no longer stops at an interim answer while the remote agent goes on in a turn of its own, for example while its subagents work.
334
+ - **Worker logs on Windows**: session workers now write the same `worker-<session>.log` as on Linux, in the relay's log directory, while the console window keeps showing the output.
335
+
336
+ ### New in 0.9.4
337
+
338
+ - **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).
339
+ - **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.
340
+ - **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.
341
+ - **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.
342
+ - **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.
343
+ - **Stop** asks for confirmation before it cancels the running turn.
344
+ - **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
345
 
339
346
  ### Everything else
340
347
 
@@ -438,17 +445,19 @@ Use **➡️ Share conversation** in the `⋯` menu to publish a read-only link.
438
445
  ### Notifications and the app
439
446
 
440
447
  - **⚙️ 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.
448
+ - 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
449
 
443
450
  ### The conversation menu
444
451
 
445
- Besides the entries above, the `⋯` menu holds **✍️ Edit conversation title**, **🖥️ Inspect tmux console** (a read-only view of the session's tmux pane), **🤗 Select Models**, **⚙️ Settings**, **🌄 Restart web relay** (queued until the current turn is idle), **💤 Suspend host**, and **☠️ Kill session** (stops the conversation's worker; an active turn then needs a retry or a new message). Its header shows the queue counts with **🚮 Empty queue**.
452
+ Besides the entries above, the `⋯` menu holds **✍️ Edit conversation title**, **🖥️ Inspect tmux console** (a read-only view of the session's tmux pane), **🤗 Select Models**, **⚙️ Settings**, **🌄 Restart web relay** (queued until the current turn is idle), **💤 Suspend host** (Windows; see below), and **☠️ Kill session** (stops the conversation's worker; an active turn then needs a retry or a new message). Its header shows the queue counts with **🚮 Empty queue**.
453
+
454
+ **💤 Suspend host** puts the machine the relay runs on to sleep, once the work is done. The confirmation lists what is still active. The relay then queues the suspend and fires it after 2 minutes with nothing active: no queued, running or parked turn, no background task of a live worker, and no open GitHub Actions run in the repository of a conversation that was busy since the request (read through `gh`; a repository whose state cannot be read blocks for 15 minutes, then is ignored). With nothing running when you confirm, it fires after a 30-second countdown. While a suspend is queued, every device shows a banner with the blockers or the countdown and a **Cancel** button, and a push notification reports the suspend. A relay restart drops a queued suspend and says so. **Show Suspend host action** in Settings hides the entry per browser.
446
455
 
447
456
  ## Settings and configuration reference
448
457
 
449
458
  ### Settings (⚙️ in the web UI)
450
459
 
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:
460
+ 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
461
 
453
462
  | Tab | Setting | Default | What it does |
454
463
  | --- | ------- | ------- | ------------ |
@@ -457,7 +466,7 @@ The modal is organised into five tabs — **General**, **Providers** (with a **C
457
466
  | 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
467
  | 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
468
  | 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 |
469
+ | 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
470
  | 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
471
  | General | Default CWD for new sessions | relay workspace root | Working directory for newly created sessions that have none of their own |
463
472
  | Providers | Copilot engine | SDK | Extension when this relay cannot run the SDK engine (see [engine choice](#github-copilot--engine-choice)) |
@@ -466,6 +475,8 @@ The modal is organised into five tabs — **General**, **Providers** (with a **C
466
475
  | 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
476
  | Providers | Cursor API key / model | — / `composer-2.5` | Enables Cursor; plus monthly plan allowances and the optional dashboard session token |
468
477
  | Providers | Grok | off | Enables Grok; default model `grok-4.5`; **Sign in** / **Sign out**; optional monthly allowance |
478
+ | 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)) |
479
+ | Relays | Remote relays | — | Pair a relay by pasting its web address; per relay **Agents may** read only, read and prompt, or do everything |
469
480
  | Previews | Live previews | — | Lists the published previews, each of which you can close |
470
481
  | Notifications | Push notifications | off | Per device: which events notify, and whether titles and message previews are included |
471
482
  | 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` |
@@ -537,6 +548,8 @@ The config file is `server/config.json` in a git checkout and `~/.oar/config.jso
537
548
  | `COPILOT_WEB_RELAY_LOG_DIR` | Log directory |
538
549
  | `OAR_STATE_ROOT` | State root of a global install, instead of `~/.oar` / `%APPDATA%\oar` |
539
550
  | `OAR_NO_UPDATE_CHECK=1` | Never contact oar.sh, not even for a manual update check |
551
+ | `OAR_HOST_SUSPEND_DRY_RUN=1` | **Suspend host** logs the suspend instead of sleeping the machine |
552
+ | `OAR_MCP_SERVER=0` | Do not attach OAR's MCP server to Grok and the Copilot extension engine; they get the preview instructions as text again, and no `remote_relay` tool |
540
553
  | `COPILOT_SDK_PATH` | The Copilot CLI runtime's `copilot-sdk` directory, instead of the newest one in the CLI's package cache |
541
554
  | `COPILOT_PKG_DIR` | An additional Copilot CLI package cache to search |
542
555
  | `GH_TOKEN`, `GITHUB_TOKEN` | GitHub token for the Copilot usage card, instead of `gh auth token` |
@@ -638,6 +651,54 @@ covering `/api/shared/*` — shared views poll for liveness and an edge-cached r
638
651
  would pin viewers to a stale snapshot. Likewise, a Cloudflare Access application over the
639
652
  bound hostname breaks share links unless it bypasses `/shared/*` and `/api/shared/*`.
640
653
 
654
+ ## Remote relays
655
+
656
+ Several OAR relays can be paired, for example the one on your workstation and the one on a
657
+ server. An agent in any session can then work on the other relay when you ask it to: list and
658
+ read its sessions, prompt one, start a new one, wait for the reply, and pass a question the
659
+ remote agent asks back to you.
660
+
661
+ **Pairing.** Open **Settings → Relays** and paste the other relay's web address (the URL you
662
+ open it with in the browser). OAR checks the address with its own token, or with a
663
+ `?token=` the pasted link carries, and asks for a token only when that fails. It reads the
664
+ other relay's name from its API. With **Also add this relay there** checked, it introduces
665
+ itself to the other relay using the address in **Public address**, so the pairing works in both
666
+ directions. When the two relays use different tokens, that introduction hands over this relay's
667
+ token, so the other relay's agents can then work here. Each side can remove the other on its own,
668
+ but removing a relay does not revoke a token it already holds: rotate the token for that. A relay
669
+ that is already paired keeps the address and token stored for it when it introduces itself again;
670
+ change those in **Settings → Relays**.
671
+
672
+ A relay's name is the **Relay name** under **General** (it also names the installed app); when it
673
+ is empty, the machine's hostname is used.
674
+
675
+ **Mention a relay to unlock it.** Agents can see which relays are paired, but a relay stays
676
+ locked in a conversation until you mention it in one of your messages: type `@` and pick it
677
+ from the list, or write its name or its host name. An IP address or `localhost` never counts as a
678
+ host name; a relay whose name is itself an address is unlocked only with a leading `@`. The
679
+ mention unlocks that relay for the rest of
680
+ the conversation. Prompts that arrive from another relay's agent never unlock anything, and a
681
+ chain of relays prompting relays stops after two hops.
682
+
683
+ **What agents can do.** Every provider gets the same `remote_relay` tool (Claude, Cursor and
684
+ Copilot directly; Grok and the Copilot extension engine through OAR's MCP server). Per paired
685
+ relay, **Agents may** limits it to *read only*, *read and prompt* (existing sessions) or *full*
686
+ (also create sessions, answer questions, stop turns, archive). In the *ask* and *plan* relay
687
+ modes, every write action first asks you with a question card. For a session it starts, an agent
688
+ can set the provider, model, relay mode and reasoning effort; what it leaves out follows the
689
+ session it works in, as far as the other relay offers it.
690
+
691
+ When an agent prompts a session on another relay, that relay shows a **↗ from …** badge with the
692
+ sending relay, session and model on the message, and marks sessions an agent created in the
693
+ conversation list. The remote agent sees a one-line header saying that another agent is talking
694
+ to it. Turns started this way do not send "reply ready" notifications on the remote relay;
695
+ question cards still do. **Accept prompts from other relays' agents** in **Settings → Relays**
696
+ switches incoming agent prompts off.
697
+
698
+ The calls go through your own relay, which holds the other relay's token: agents never see a
699
+ token. Remote addresses must use `https://`; plain `http://` is accepted only for loopback (an
700
+ SSH port forward, for example) and private network ranges.
701
+
641
702
  ## Security notes
642
703
 
643
704
  - 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 +706,7 @@ bound hostname breaks share links unless it bypasses `/shared/*` and `/api/share
645
706
  - `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
707
  - 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
708
  - 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.
709
+ - 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
710
  - 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
711
  - OAR has no telemetry. Its only request to oar.sh is the update check, which is off until you enable it.
650
712
 
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.5",
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,100 @@
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
+ effort: text('effort'),
67
+ title: text('title'),
68
+ question_id: text('question_id'),
69
+ answer: text('answer'),
70
+ choices: z.array(z.string()).optional().describe(FIELDS.choices.description),
71
+ if_busy: choice('if_busy', REMOTE_RELAY_IF_BUSY),
72
+ };
73
+
74
+ export function createRemoteRelayToolDefinition({ api, getConversationId = () => '', dbg = () => {} } = {}) {
75
+ return {
76
+ name: REMOTE_RELAY_TOOL_NAME,
77
+ description: REMOTE_RELAY_TOOL_DESCRIPTION,
78
+ inputSchema: REMOTE_RELAY_TOOL_ZOD_SHAPE,
79
+ // `extra.signal` is the MCP request's: the SDK aborts it when the CLI
80
+ // cancels the call (a Stop interrupting the turn) or the process's MCP
81
+ // transport closes, and the pending relay request goes with it.
82
+ handler: async (args, extra) => {
83
+ let result;
84
+ try {
85
+ result = await executeRemoteRelayTool(args, {
86
+ api,
87
+ conversationId: String(getConversationId() || ''),
88
+ signal: extra?.signal || null,
89
+ });
90
+ } catch (error) {
91
+ // A tool exception would surface as a failed MCP call and can abort
92
+ // the turn; a refusal the model can read cannot.
93
+ const message = error?.message || String(error);
94
+ dbg('remote_relay tool failed', message);
95
+ result = { ok: false, code: REMOTE_RELAY_CALL_FAILED, error: message };
96
+ }
97
+ return { content: [{ type: 'text', text: formatRemoteRelayToolResult(result) }] };
98
+ },
99
+ };
100
+ }
@@ -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,