From 13125ae98f5f1065f6c63fb900afa0f12c5fc7f6 Mon Sep 17 00:00:00 2001
From: Suleiman Shahbari
Date: Sun, 11 Oct 2026 01:13:47 +0300
Subject: [PATCH] Machines are saved by OpenAgent, and a run names its project
The saved machines move from the browser's storage to the home file, and a
machine's key is never handed to a browser. A run sent to another machine
names its project by the address its repository was cloned from, and starts
in that machine's own copy of it.
Co-Authored-By: Claude Opus 5.5
---
packages/openagent/DECISIONS.md | 20 +-
packages/openagent/dashboard/App.tsx | 2 +-
.../components/AddDeviceDialog.test.tsx | 39 ---
.../components/AddMachineDialog.test.tsx | 57 +++++
...dDeviceDialog.tsx => AddMachineDialog.tsx} | 46 ++--
.../dashboard/components/AgentActionBar.tsx | 2 +-
.../components/AgentHistory.test.tsx | 6 +-
.../dashboard/components/AgentHistory.tsx | 10 +-
.../dashboard/components/AgentView.test.tsx | 6 +-
.../dashboard/components/AgentView.tsx | 20 +-
.../dashboard/components/Composer.test.tsx | 75 +++---
.../dashboard/components/Composer.tsx | 82 ++++---
.../components/ConnectionIndicator.tsx | 32 +--
.../components/DevicesSettings.test.tsx | 73 ------
.../components/MachinesSettings.test.tsx | 84 +++++++
...vicesSettings.tsx => MachinesSettings.tsx} | 72 +++---
.../components/RemoteAgentNotice.tsx | 10 +-
.../dashboard/components/RunOnMenu.test.tsx | 72 ++++--
.../dashboard/components/RunOnMenu.tsx | 116 +++++----
.../dashboard/components/SessionLine.tsx | 2 +-
.../components/SettingsPage.test.tsx | 5 +-
.../dashboard/components/SettingsPage.tsx | 6 +-
.../components/StartAgentButton.test.tsx | 2 +-
.../components/StartAgentForm.test.tsx | 37 ++-
.../dashboard/components/StartAgentForm.tsx | 51 ++--
.../dashboard/lib/connection.test.ts | 10 +
.../openagent/dashboard/lib/connection.ts | 19 ++
.../dashboard/lib/draft-handoff.test.ts | 29 +--
.../openagent/dashboard/lib/draft-handoff.ts | 32 +--
.../openagent/dashboard/lib/machines.test.ts | 93 ++++++++
packages/openagent/dashboard/lib/machines.ts | 77 ++++++
.../openagent/dashboard/lib/profiles.test.ts | 96 --------
packages/openagent/dashboard/lib/profiles.ts | 173 --------------
.../openagent/dashboard/lib/remote-target.ts | 31 ++-
.../dashboard/lib/use-device-status.test.tsx | 46 ----
.../dashboard/lib/use-device-status.ts | 39 ---
.../dashboard/lib/use-live-events.ts | 2 +-
.../dashboard/lib/use-machine-status.test.tsx | 45 ++++
.../dashboard/lib/use-machine-status.ts | 34 +++
packages/openagent/dashboard/rpc/devices.ts | 13 -
packages/openagent/dashboard/rpc/machines.ts | 14 ++
packages/openagent/dashboard/test-machines.ts | 40 ++++
packages/openagent/src/client.ts | 2 +
packages/openagent/src/daemon-runtime.test.ts | 84 ++++++-
packages/openagent/src/daemon-runtime.ts | 128 ++++++----
packages/openagent/src/daemon.ts | 8 +-
.../openagent/src/dashboard-rpc/context.ts | 8 +-
.../openagent/src/dashboard-rpc/control.ts | 14 +-
.../src/dashboard-rpc/devices.test.ts | 38 ---
.../openagent/src/dashboard-rpc/devices.ts | 25 --
.../openagent/src/dashboard-rpc/events.ts | 2 +-
packages/openagent/src/dashboard-rpc/index.ts | 6 +-
.../src/dashboard-rpc/machines.test.ts | 92 +++++++
.../openagent/src/dashboard-rpc/machines.ts | 45 ++++
.../openagent/src/dashboard-rpc/modules.ts | 4 +-
.../src/dashboard-rpc/projects.test.ts | 3 +-
.../openagent/src/dashboard-rpc/projects.ts | 7 +-
packages/openagent/src/dashboard-rpc/reads.ts | 2 +-
.../src/dashboard-rpc/relay-agent.ts | 6 +-
.../src/dashboard-rpc/relay-dispatch.test.ts | 12 +-
.../src/dashboard-rpc/relay-dispatch.ts | 28 +--
packages/openagent/src/dashboard/http.ts | 2 +-
.../src/dashboard/relay-endpoints.ts | 73 ++++--
.../dashboard/remote-run.integration.test.ts | 224 +++++++++++++-----
.../src/dashboard/remote-run.test.ts | 77 +++---
.../openagent/src/dashboard/remote-run.ts | 88 ++++---
.../openagent/src/dashboard/rpc-serve.test.ts | 2 +-
packages/openagent/src/dashboard/rpc-serve.ts | 13 +-
.../openagent/src/dashboard/server.test.ts | 77 ++++--
packages/openagent/src/dashboard/server.ts | 24 +-
packages/openagent/src/dashboard/types.ts | 16 +-
packages/openagent/src/machine-url.test.ts | 16 ++
packages/openagent/src/machine-url.ts | 19 ++
packages/openagent/src/module-server.d.ts | 2 +-
packages/openagent/src/registry.test.ts | 69 ++++++
packages/openagent/src/registry.ts | 108 ++++++++-
.../openagent/src/repository-address.test.ts | 53 +++++
packages/openagent/src/repository-address.ts | 37 +++
packages/openagent/src/store/agent-store.ts | 4 +-
79 files changed, 1936 insertions(+), 1202 deletions(-)
delete mode 100644 packages/openagent/dashboard/components/AddDeviceDialog.test.tsx
create mode 100644 packages/openagent/dashboard/components/AddMachineDialog.test.tsx
rename packages/openagent/dashboard/components/{AddDeviceDialog.tsx => AddMachineDialog.tsx} (53%)
delete mode 100644 packages/openagent/dashboard/components/DevicesSettings.test.tsx
create mode 100644 packages/openagent/dashboard/components/MachinesSettings.test.tsx
rename packages/openagent/dashboard/components/{DevicesSettings.tsx => MachinesSettings.tsx} (50%)
create mode 100644 packages/openagent/dashboard/lib/connection.test.ts
create mode 100644 packages/openagent/dashboard/lib/connection.ts
create mode 100644 packages/openagent/dashboard/lib/machines.test.ts
create mode 100644 packages/openagent/dashboard/lib/machines.ts
delete mode 100644 packages/openagent/dashboard/lib/profiles.test.ts
delete mode 100644 packages/openagent/dashboard/lib/profiles.ts
delete mode 100644 packages/openagent/dashboard/lib/use-device-status.test.tsx
delete mode 100644 packages/openagent/dashboard/lib/use-device-status.ts
create mode 100644 packages/openagent/dashboard/lib/use-machine-status.test.tsx
create mode 100644 packages/openagent/dashboard/lib/use-machine-status.ts
delete mode 100644 packages/openagent/dashboard/rpc/devices.ts
create mode 100644 packages/openagent/dashboard/rpc/machines.ts
create mode 100644 packages/openagent/dashboard/test-machines.ts
delete mode 100644 packages/openagent/src/dashboard-rpc/devices.test.ts
delete mode 100644 packages/openagent/src/dashboard-rpc/devices.ts
create mode 100644 packages/openagent/src/dashboard-rpc/machines.test.ts
create mode 100644 packages/openagent/src/dashboard-rpc/machines.ts
create mode 100644 packages/openagent/src/machine-url.test.ts
create mode 100644 packages/openagent/src/machine-url.ts
create mode 100644 packages/openagent/src/repository-address.test.ts
create mode 100644 packages/openagent/src/repository-address.ts
diff --git a/packages/openagent/DECISIONS.md b/packages/openagent/DECISIONS.md
index a838b0adc..db144d992 100644
--- a/packages/openagent/DECISIONS.md
+++ b/packages/openagent/DECISIONS.md
@@ -11,7 +11,7 @@ already made, and lists it in its pull request; for anything else it proposes an
- A projection of files: it shows a project's runs by reading what the runs write, and it
runs no agent of its own. Picked over the dashboard owning the agent it shows, which is
what made every capability a person wanted — another coding agent, a schedule, a run on
- a device — a feature of OpenAgent.
+ another machine — a feature of OpenAgent.
- It ships no prompt text. What a project can be asked to do is what the project's own
skills say. Picked over the built-in presets: a prompt that lives here cannot be read,
changed or run by the agent working the repository.
@@ -186,6 +186,24 @@ already made, and lists it in its pull request; for anything else it proposes an
- No cap on a person's runs: the click is the brake. Picked over the one-run-per-checkout
guard, which existed because runs shared a working tree and they no longer do.
+## Running on another machine
+- The machines a person saved are kept in their home file, beside their projects and their
+ settings, and a machine's key is never handed to a browser. Picked over the browser's own
+ storage, where each browser had its own list and the daemon, which makes the calls, did
+ not know the machines.
+- A run sent to another machine names its project by the address its repository was cloned
+ from, and starts in that machine's own copy of it. Picked over the folder's name, where
+ two projects named alike are mixed up and a renamed folder breaks the link, and over
+ linking the two folders by hand, a step for every project on every machine.
+- A machine that does not have the project starts nothing, and the person is told which
+ project to add there. Picked over starting in the folder that machine's OpenAgent was
+ started in, which may be another project.
+- A project with no repository address runs on this machine only, and "Run on" says so.
+ Picked over sending the repository straight to the other machine, a second road for
+ code beside the git host.
+- On screen, a computer that runs agents is a machine. Device is kept for a phone or a
+ browser that gets in. Picked over device for both.
+
## Saying something to a run
- What a person says reaches a run through what the run's tool reads: a line in the run's
inbox file while the run works, the project's `resume` line once it has ended. Picked
diff --git a/packages/openagent/dashboard/App.tsx b/packages/openagent/dashboard/App.tsx
index b2c513a03..632909882 100644
--- a/packages/openagent/dashboard/App.tsx
+++ b/packages/openagent/dashboard/App.tsx
@@ -119,7 +119,7 @@ export function App() {
// with the typed prompt at once, before the run's tool writes its card. `id` is the one the
// project's start hook answered (#761) — the URL already points there, and this is what tells
// the main pane that a session missing from the list is starting, not gone.
- // `runsOn` names the device a just-started remote agent executes on (#1067), so the live view can
+ // `runsOn` names the machine a just-started remote agent executes on (#1067), so the live view can
// mark where it runs and degrade the panels that are local-only. Undefined for a local agent.
const [agentStart, setAgentStart] = useState<{ tick: number; intent: string; id: string | null; projectId: string | null; runsOn?: string }>({ tick: 0, intent: '', id: null, projectId: null })
// The agents of the project on screen, for its pages, or of the picked project, for the sidebar:
diff --git a/packages/openagent/dashboard/components/AddDeviceDialog.test.tsx b/packages/openagent/dashboard/components/AddDeviceDialog.test.tsx
deleted file mode 100644
index 11795d99d..000000000
--- a/packages/openagent/dashboard/components/AddDeviceDialog.test.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import { afterEach, describe, expect, test, vi } from 'vitest'
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { AddDeviceDialog } from './AddDeviceDialog.js'
-import { listProfiles } from '../lib/profiles.js'
-
-afterEach(() => {
- cleanup()
- localStorage.clear()
-})
-
-describe('AddDeviceDialog (#1052)', () => {
- test('pasting a ?token= URL saves a profile and closes', () => {
- const onClose = vi.fn()
- const onAdded = vi.fn()
- render( )
- fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://192.168.1.5:4200/?token=abc123' } })
- fireEvent.click(screen.getByRole('button', { name: 'Add device' }))
- expect(listProfiles()).toEqual([
- { id: 'http://192.168.1.5:4200', label: '192.168.1.5:4200', url: 'http://192.168.1.5:4200', token: 'abc123' },
- ])
- expect(onAdded).toHaveBeenCalled()
- expect(onClose).toHaveBeenCalled()
- })
-
- test('a URL without a token cannot be saved', () => {
- render( )
- fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://192.168.1.5:4200' } })
- expect((screen.getByRole('button', { name: 'Add device' }) as HTMLButtonElement).disabled).toBe(true)
- expect(screen.getByText(/no token/i)).toBeTruthy()
- })
-
- test('an optional label overrides the host default', () => {
- render( )
- fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://box:4200/?token=xyz' } })
- fireEvent.change(screen.getByPlaceholderText(/Name/), { target: { value: 'Workshop' } })
- fireEvent.click(screen.getByRole('button', { name: 'Add device' }))
- expect(listProfiles()[0]!.label).toBe('Workshop')
- })
-})
diff --git a/packages/openagent/dashboard/components/AddMachineDialog.test.tsx b/packages/openagent/dashboard/components/AddMachineDialog.test.tsx
new file mode 100644
index 000000000..6e7f887b6
--- /dev/null
+++ b/packages/openagent/dashboard/components/AddMachineDialog.test.tsx
@@ -0,0 +1,57 @@
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+
+vi.mock('../rpc/machines.js', async () => (await import('../test-machines.js')).machinesRpc)
+
+import { daemon, machinesRpc, saveMachines } from '../test-machines.js'
+import { AddMachineDialog } from './AddMachineDialog.js'
+
+afterEach(async () => {
+ cleanup()
+ localStorage.clear()
+ await saveMachines()
+})
+
+describe('AddMachineDialog (#1052)', () => {
+ test('pasting a ?token= URL hands it to the daemon, which saves the machine, and closes', async () => {
+ const onClose = vi.fn()
+ const onAdded = vi.fn()
+ render( )
+ fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://192.168.1.5:4200/?token=abc123' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Add machine' }))
+ await waitFor(() => expect(onClose).toHaveBeenCalled())
+ expect(daemon.machines).toEqual([{ id: 'http://192.168.1.5:4200', label: '192.168.1.5:4200', url: 'http://192.168.1.5:4200' }])
+ expect(daemon.tokens['http://192.168.1.5:4200']).toBe('abc123')
+ expect(onAdded).toHaveBeenCalled()
+ // The key is the daemon's to keep: none of it stays in the browser.
+ expect(localStorage.length).toBe(0)
+ })
+
+ test('a URL without a token cannot be saved', () => {
+ render( )
+ fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://192.168.1.5:4200' } })
+ expect((screen.getByRole('button', { name: 'Add machine' }) as HTMLButtonElement).disabled).toBe(true)
+ expect(screen.getByText(/no token/i)).toBeTruthy()
+ expect(machinesRpc.sendAddMachine).not.toHaveBeenCalled()
+ })
+
+ test('an optional label overrides the host default', async () => {
+ const onClose = vi.fn()
+ render( )
+ fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://box:4200/?token=xyz' } })
+ fireEvent.change(screen.getByPlaceholderText(/Name/), { target: { value: 'Workshop' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Add machine' }))
+ await waitFor(() => expect(onClose).toHaveBeenCalled())
+ expect(daemon.machines[0]!.label).toBe('Workshop')
+ })
+
+ test('a save the daemon refuses is said, and the dialog stays open', async () => {
+ machinesRpc.sendAddMachine.mockResolvedValueOnce({ ok: false, error: 'The home file could not be written.' })
+ const onClose = vi.fn()
+ render( )
+ fireEvent.change(screen.getByPlaceholderText(/host:port/), { target: { value: 'http://box:4200/?token=xyz' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Add machine' }))
+ expect((await screen.findByRole('alert')).textContent).toBe('The home file could not be written.')
+ expect(onClose).not.toHaveBeenCalled()
+ })
+})
diff --git a/packages/openagent/dashboard/components/AddDeviceDialog.tsx b/packages/openagent/dashboard/components/AddMachineDialog.tsx
similarity index 53%
rename from packages/openagent/dashboard/components/AddDeviceDialog.tsx
rename to packages/openagent/dashboard/components/AddMachineDialog.tsx
index d3f5249c9..9e008627d 100644
--- a/packages/openagent/dashboard/components/AddDeviceDialog.tsx
+++ b/packages/openagent/dashboard/components/AddMachineDialog.tsx
@@ -1,23 +1,30 @@
import { useState, type KeyboardEvent } from 'react'
-import { addProfile, parseDeviceUrl } from '../lib/profiles.js'
+import { parseMachineUrl } from '../../src/client.js'
+import { addMachine } from '../lib/machines.js'
import { Button } from './ui/button.js'
import { Dialog } from './ui/dialog.js'
-// The "Add a device" modal off the gear (#1052). A device is any reachable daemon — a LAN IP, a
-// tailnet name, a tunnel URL — so the input is not a LAN-IP model but the full `?token=` URL the
-// box prints on its non-loopback bind (cli.ts). We parse the origin and token out of one paste;
-// the token is a per-browser secret, so it lands in localStorage (profiles.ts), never a server file.
+// The "Add a machine" modal (#1052). A machine is any reachable daemon — a LAN IP, a tailnet name,
+// a tunnel URL — so the input is not a LAN-IP model but the full `?token=` URL the other machine
+// prints when it is opened to the network (cli.ts). The address and the key are read out of one
+// paste and handed to the daemon, which keeps them: the key is not kept in the browser.
-export function AddDeviceDialog({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
+export function AddMachineDialog({ onClose, onAdded }: { onClose: () => void; onAdded: () => void }) {
const [url, setUrl] = useState('')
const [label, setLabel] = useState('')
- const parsed = parseDeviceUrl(url)
- // A pasted URL with no `?token=` cannot authenticate against a guarded box, so it is not savable.
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+ const parsed = parseMachineUrl(url)
+ // A pasted URL with no `?token=` cannot authenticate against a guarded machine, so it is not savable.
const valid = parsed !== null && parsed.token !== ''
- const save = () => {
- if (!parsed || !valid) return
- addProfile({ url: parsed.url, token: parsed.token, ...(label.trim() ? { label } : {}) })
+ const save = async () => {
+ if (!valid || saving) return
+ setSaving(true)
+ setError(null)
+ const result = await addMachine(url, label.trim() || undefined).catch(() => ({ ok: false as const, error: 'The machine could not be saved.' }))
+ setSaving(false)
+ if (!result.ok) return setError(result.error)
onAdded()
onClose()
}
@@ -25,15 +32,15 @@ export function AddDeviceDialog({ onClose, onAdded }: { onClose: () => void; onA
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
- save()
+ void save()
}
}
return (
- { if (!next) onClose() }} title="Add a device">
+ { if (!next) onClose() }} title="Add a machine">
- Paste the URL the box printed on its network bind (it looks like http://host:port/?token=…).
+ Paste the URL the other machine printed when it was opened to the network (it looks like http://host:port/?token=…).
void; onA
/>
{url.trim() !== '' && !valid && (
- {parsed === null ? 'That is not a valid URL.' : 'This URL has no token, so the box could not authenticate you.'}
+ {parsed === null ? 'That is not a valid URL.' : 'This URL has no token, so the machine would refuse every call.'}
+
+ )}
+ {error && (
+
+ {error}
)}
Cancel
-
- Add device
+ void save()}>
+ Add machine
diff --git a/packages/openagent/dashboard/components/AgentActionBar.tsx b/packages/openagent/dashboard/components/AgentActionBar.tsx
index 205e08710..99fde8a94 100644
--- a/packages/openagent/dashboard/components/AgentActionBar.tsx
+++ b/packages/openagent/dashboard/components/AgentActionBar.tsx
@@ -34,7 +34,7 @@ export function AgentActionBar({
label?: string | undefined
/** The session's project, said in the chip beside the name. */
projectName?: string | null | undefined
- /** Where the session runs, in a few words: this machine, a device by its name, the cloud. */
+ /** Where the session runs, in a few words: this machine, another machine by its name, the cloud. */
runsOn?: string
/** True when this finished agent still has a worktree on disk, so it can be removed (#737). */
retainedWorktree?: boolean
diff --git a/packages/openagent/dashboard/components/AgentHistory.test.tsx b/packages/openagent/dashboard/components/AgentHistory.test.tsx
index 2837377c0..18405caca 100644
--- a/packages/openagent/dashboard/components/AgentHistory.test.tsx
+++ b/packages/openagent/dashboard/components/AgentHistory.test.tsx
@@ -17,7 +17,7 @@ const onAgent = vi.hoisted(() => vi.fn((_projectId: string, _agentId: string) =>
vi.mock('../rpc/reads.js', () => ({ onAgent }))
// The rail now also carries the app chrome moved off the top navbar (#772 follow-up). Three of
-// those pull the preferences/devices RPC stubs into jsdom, which this suite deliberately
+// those pull the preferences/machines RPC stubs into jsdom, which this suite deliberately
// avoids. It is about the agents list, not the chrome (each has its own suite), so stub them out.
vi.mock('./ThemeToggle.js', () => ({ ThemeToggle: () => null }))
vi.mock('./NotificationsMenu.js', () => ({ NotificationsMenu: () => null }))
@@ -196,7 +196,7 @@ describe('AgentHistory (#785)', () => {
// bespoke collapsing of #862 — the shadcn Sidebar owns collapse, and the shell never drove
// the old prop, so those strip/float tests are retired with it.
describe('AgentHistory rows', () => {
- test('a run on a connected device shows a device glyph naming the device (#1067)', () => {
+ test('a run on a saved machine shows a machine glyph naming the machine (#1067)', () => {
renderRail( {}} />)
expect(screen.getByLabelText('Runs on my-laptop')).toBeTruthy()
})
@@ -211,7 +211,7 @@ describe('AgentHistory rows', () => {
expect(screen.queryByLabelText('Started on this-mac')).toBeNull()
})
- test('a local run has no device glyph (#1067)', () => {
+ test('a local run has no machine glyph (#1067)', () => {
renderRail( {}} />)
expect(screen.queryByLabelText(/Runs on/)).toBeNull()
})
diff --git a/packages/openagent/dashboard/components/AgentHistory.tsx b/packages/openagent/dashboard/components/AgentHistory.tsx
index af8a63c33..654e23b84 100644
--- a/packages/openagent/dashboard/components/AgentHistory.tsx
+++ b/packages/openagent/dashboard/components/AgentHistory.tsx
@@ -533,13 +533,13 @@ function AgentHistoryRow({
/** Ended clean, its process still saving its record and cleaning up its checkout (#1455): the row must
* not say "done" while the session's own pill says "saving…". */
saving?: boolean
- /** Runs on a connected device (#1067): the row gets a device glyph next to the agent logo. */
+ /** Runs on a saved machine (#1067): the row gets a machine glyph next to the agent logo. */
remote?: boolean
/** A Claude Code cloud session (#1263): the row gets a cloud glyph beside the agent logo. */
cloud?: boolean
/** What that session is doing (#1668), once the local half is over; undefined = the status is the word. */
cloudState?: CloudRunState | undefined
- /** The device's label, for the glyph's tooltip. */
+ /** The machine's label, for the glyph's tooltip. */
remoteLabel?: string | undefined
/** The machine whose daemon started the run, when that is another machine (#1648): a glyph names it. */
startedOn?: string | undefined
@@ -598,7 +598,7 @@ function AgentHistoryRow({
{parked ? 'waiting' : inCloud ? 'in cloud' : cloudWord ? cloudWord : savingNow ? 'saving…' : status}
{subtitle}
- {/* Right cluster: a device glyph when the run is relayed to a connected device (#1067),
+ {/* Right cluster: a machine glyph when the run is relayed to a saved machine (#1067),
a cloud glyph for a Claude Code cloud session (#1263), then the driver logo. The logo
is the only thing naming the driver on this row, so it carries a title rather than
being decorative. */}
@@ -645,9 +645,9 @@ function AgentHistoryRow({
{remote && (
}>
-
+
- {remoteLabel ? `Runs on ${remoteLabel}` : 'Runs on a connected device'}
+ {remoteLabel ? `Runs on ${remoteLabel}` : 'Runs on another machine'}
)}
{cloud && (
diff --git a/packages/openagent/dashboard/components/AgentView.test.tsx b/packages/openagent/dashboard/components/AgentView.test.tsx
index 93756f0f9..5e35d82fe 100644
--- a/packages/openagent/dashboard/components/AgentView.test.tsx
+++ b/packages/openagent/dashboard/components/AgentView.test.tsx
@@ -640,7 +640,7 @@ describe('an ended agent whose subagents still work', () => {
})
describe('where the agent runs, for the chip beside its name', () => {
- test('this machine by itself; a device by the name it was given; the cloud and GitHub Actions by theirs', () => {
+ test('this machine by itself; another machine by the name it was given; the cloud and GitHub Actions by theirs', () => {
const runsOn = () => screen.getByTestId('bar-runs-on').textContent
const { rerender } = render(view({ live: true }))
expect(runsOn()).toBe('This machine')
@@ -649,7 +649,7 @@ describe('where the agent runs, for the chip beside its name', () => {
rerender(view({ live: true, target: 'remote', remoteLabel: 'Studio' }))
expect(runsOn()).toBe('Studio')
rerender(view({ live: true, target: 'remote' }))
- expect(runsOn()).toBe('A device')
+ expect(runsOn()).toBe('Another machine')
rerender(view({ live: true, target: 'web' }))
expect(runsOn()).toBe('Cloud')
rerender(view({ live: true, target: 'actions' }))
@@ -680,7 +680,7 @@ describe('a run just started', () => {
expect(screen.getByText('Working…')).toBeTruthy()
})
- test('an agent just started on a device says nothing of a set-up before its card is listed: no checkout is made for it here', () => {
+ test('an agent just started on a machine says nothing of a set-up before its card is listed: no checkout is made for it here', () => {
const events = [{ kind: 'driver', event: { type: 'start', prompt: 'Say hi' } }, { kind: 'driver', event: { type: 'text', text: 'Hi.' } }] as OpenAgentEvent[]
render(view({ live: true, events, remoteLabel: 'laptop' }))
expect(screen.queryByText('Session set up')).toBeNull()
diff --git a/packages/openagent/dashboard/components/AgentView.tsx b/packages/openagent/dashboard/components/AgentView.tsx
index a99d00e4b..668cdfe50 100644
--- a/packages/openagent/dashboard/components/AgentView.tsx
+++ b/packages/openagent/dashboard/components/AgentView.tsx
@@ -81,11 +81,11 @@ export function AgentView({
label?: string | undefined
/** The session's project, said in the chip beside the session's name in the top bar. */
projectName?: string | null | undefined
- /** Where the agent executes (#1053/#610): `actions` swaps the live feed for a burst-mode affordance; `remote` is relayed to a device (#1067); `web` is handed to a Claude Code cloud session. */
+ /** Where the agent executes (#1053/#610): `actions` swaps the live feed for a burst-mode affordance; `remote` is relayed to a machine (#1067); `web` is handed to a Claude Code cloud session. */
target?: 'local' | 'actions' | 'remote' | 'web' | undefined
- /** The device this agent executes on (#1067), when it is relayed to a connected one. Set only for a
- * just-started remote run: its diff, handoff, and push/PR now relay to the device (slice 2), so the
- * panels are shown, and a "runs on " notice only flags that the browser preview stays local. */
+ /** The machine this agent executes on (#1067), when it is relayed to another one. Set only for a
+ * just-started remote run: its diff, handoff, and push/PR now relay to the machine (slice 2), so the
+ * panels are shown, and a "runs on " notice only flags that the browser preview stays local. */
remoteLabel?: string | undefined
files: string[]
/** The live channel's health (#948) — surfaced as a banner over the feed. */
@@ -155,15 +155,15 @@ export function AgentView({
const subagent = card?.parent !== undefined
// What was set up for the agent before it began, off its card: the chat's "Session set up" line.
const { workspace, branch, base, driver, model } = card ?? {}
- // An agent just started on a device has no card listed yet: the device's name says where it runs.
+ // An agent just started on a machine has no card listed yet: the machine's name says where it runs.
const elsewhere = (target !== undefined && target !== 'local') || remoteLabel !== undefined
const setup = useMemo(() => ({ workspace, branch, base, driver, model, elsewhere }), [workspace, branch, base, driver, model, elsewhere])
// The model by the name its coding agent gives it, for the row under the message box.
const picked = driverFromImpl(driver)
const models = useModels()
const modelLabel = model ? modelName(picked ? models?.[picked] : undefined, model) : undefined
- // Where the agent runs, for the chip beside its name: a device by the name it was given.
- const runsOn = remoteLabel ?? (target === 'web' ? 'Cloud' : target === 'actions' ? 'GitHub Actions' : target === 'remote' ? 'A device' : 'This machine')
+ // Where the agent runs, for the chip beside its name: a machine by the name it was given.
+ const runsOn = remoteLabel ?? (target === 'web' ? 'Cloud' : target === 'actions' ? 'GitHub Actions' : target === 'remote' ? 'Another machine' : 'This machine')
const [open, setOpen] = useState(false)
// What the installed modules add to this run's page: a summary in the bar above the message box.
const { runSlots: mountedSlots, panels: mountedPanels } = useMountedModules()
@@ -344,9 +344,9 @@ export function AgentView({
{/* A run handed to Claude Code on the web (#610): the work is happening in a cloud session
this machine cannot stream, so point at where it is rather than show an empty feed. */}
- {/* A run relayed to a connected device (#1067): its diff, handoff, and push/PR now relay to the
- device (slice 2), so this notice only flags that the browser preview stays local-only for now. */}
-
+ {/* A run relayed to a saved machine (#1067): its diff, handoff, and push/PR now relay to the
+ machine (slice 2), so this notice only flags that the browser preview stays local-only for now. */}
+
{/* Nothing to show yet is not the same thing in both states: a live run is waiting for its
first event, a finished one is still reading its log. */}
{!feedSettled ? (
diff --git a/packages/openagent/dashboard/components/Composer.test.tsx b/packages/openagent/dashboard/components/Composer.test.tsx
index 2bfebc37f..72a132f0c 100644
--- a/packages/openagent/dashboard/components/Composer.test.tsx
+++ b/packages/openagent/dashboard/components/Composer.test.tsx
@@ -1,8 +1,7 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { Preferences } from '../../src/index.js'
-import { addProfile } from '../lib/profiles.js'
-import { selectRemoteDevice } from '../lib/remote-target.js'
+import { selectMachine } from '../lib/remote-target.js'
import { hoverTooltip, openMenu } from '../test-utils.js'
// Preferences are the shared daemon store; stub them so the composer reads a fixed value.
@@ -22,10 +21,10 @@ vi.mock('../lib/editors.js', () => ({ useDetectedEditors: () => [] }))
// `/` list; stub the reads.
const onCommands = vi.hoisted(() => vi.fn())
vi.mock('../rpc/projects.js', () => ({ onProjects: () => Promise.resolve([]), onCommands }))
-// The device health poll (#1072) reaches the daemon over an RPC; a hoisted stub so each test can
-// answer online/offline for the "Run on" target (#1073).
-const checkDevices = vi.hoisted(() => vi.fn())
-vi.mock('../rpc/devices.js', () => ({ checkDevices }))
+// The saved machines and their health poll (#1072) are the daemon's, reached over an RPC: a
+// pretend daemon each test seeds, and answers online/offline through, for the "Run on" target (#1073).
+vi.mock('../rpc/machines.js', async () => (await import('../test-machines.js')).machinesRpc)
+import { daemon, machinesRpc, saveMachines } from '../test-machines.js'
// Stub the Tiptap editor (it needs a real DOM/ProseMirror): a plain input driving onChange, a
// "type-submit" button firing onSubmit, and a ref exposing the same handle the composer calls.
@@ -97,20 +96,20 @@ function renderComposer(over: Partial[0]> = {}) {
return { onSubmit }
}
-beforeEach(() => {
+beforeEach(async () => {
prefs = {}
updatePreferences.mockReset()
sessionStorage.clear()
localStorage.clear()
- selectRemoteDevice(null)
- checkDevices.mockReset()
- checkDevices.mockResolvedValue({}) // default: no devices reachable
+ selectMachine(null)
+ await saveMachines() // default: no machine saved
onCommands.mockReset()
- onCommands.mockResolvedValue({ commands: [{ name: 'work-queue', description: 'Work the agent queue' }], startHook: true, gitHost: true })
+ onCommands.mockResolvedValue({ commands: [{ name: 'work-queue', description: 'Work the agent queue' }], startHook: true, gitHost: true, remote: true, address: 'github.com/acme/shop' })
})
afterEach(cleanup)
const STUDIO = 'http://192.168.1.5:4200'
+const STUDIO_MACHINE = { id: STUDIO, label: 'Studio', url: STUDIO }
// The driver/model trigger names both in its own label (#1143): with no model pinned it is a logo
// and a chevron, so the name cannot come from the rendered text the way it used to.
@@ -200,13 +199,13 @@ describe('Composer (#721)', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
- // #1066: a draft carried across a device hop lands in sessionStorage; the launcher seeds it into
+ // #1139: a draft carried across a navigation lands in sessionStorage; the launcher seeds it into
// the editor on mount, and takes it once.
- test('the launcher rehydrates a draft carried from another device (#1066)', () => {
- sessionStorage.setItem('oa.pending-draft', 'carried from the studio box')
+ test('the launcher rehydrates a carried draft (#1139)', () => {
+ sessionStorage.setItem('oa.pending-draft', 'carried from a ticket')
const { onSubmit } = renderComposer({ submitLabel: 'Start session' })
fireEvent.click(screen.getByRole('button', { name: /Start session/ }))
- expect(onSubmit).toHaveBeenCalledWith('carried from the studio box')
+ expect(onSubmit).toHaveBeenCalledWith('carried from a ticket')
expect(sessionStorage.getItem('oa.pending-draft')).toBeNull() // taken once
})
@@ -228,12 +227,12 @@ describe('Composer (#721)', () => {
expect(screen.queryByRole('button', { name: 'Send' })).toBeNull() // nothing seeded
})
- // #1073: pressing Start on an offline "Run on" device would silently attempt the ~15s relay, so
+ // #1073: pressing Start on an offline "Run on" machine would silently attempt the ~15s relay, so
// Start is blocked with a reason pointing back to the "Run on" pick. No auto-fallback: the target stays.
- test('an offline "Run on" device disables Start and shows the reason (#1073)', async () => {
- checkDevices.mockResolvedValue({ [STUDIO]: false })
- addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
- selectRemoteDevice(STUDIO)
+ test('an offline "Run on" machine disables Start and shows the reason (#1073)', async () => {
+ await saveMachines(STUDIO_MACHINE)
+ daemon.reachable = { [STUDIO]: false }
+ selectMachine(STUDIO)
const { onSubmit } = renderComposer()
fireEvent.change(screen.getByLabelText('prompt'), { target: { value: 'ship it' } })
await waitFor(() => expect(screen.getByText(/Studio is offline/)).toBeTruthy())
@@ -245,13 +244,13 @@ describe('Composer (#721)', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
- test('an online "Run on" device leaves Start enabled with no offline note (#1073)', async () => {
- checkDevices.mockResolvedValue({ [STUDIO]: true })
- addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
- selectRemoteDevice(STUDIO)
+ test('an online "Run on" machine leaves Start enabled with no offline note (#1073)', async () => {
+ await saveMachines(STUDIO_MACHINE)
+ daemon.reachable = { [STUDIO]: true }
+ selectMachine(STUDIO)
const { onSubmit } = renderComposer()
fireEvent.change(screen.getByLabelText('prompt'), { target: { value: 'ship it' } })
- await waitFor(() => expect(checkDevices).toHaveBeenCalled())
+ await waitFor(() => expect(machinesRpc.onMachinesReachable).toHaveBeenCalled())
expect(screen.queryByText(/is offline/)).toBeNull()
const submit = screen.getByRole('button', { name: 'Send' })
expect(submit.hasAttribute('disabled')).toBe(false)
@@ -359,8 +358,8 @@ describe('the row of chips above the box', () => {
expect(box().contains(runOn())).toBe(false)
})
- test('the chip reads the picked device, and its menu picks a device and this machine again', async () => {
- addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
+ test('the chip reads the picked machine, and its menu picks a machine and this machine again', async () => {
+ await saveMachines(STUDIO_MACHINE)
renderComposer({ aboveControls: null })
expect(runOn().textContent).toBe('This machine')
await openMenu(runOn())
@@ -371,10 +370,24 @@ describe('the row of chips above the box', () => {
await waitFor(() => expect(runOn().textContent).toBe('This machine'))
})
- test('an offline picked device still reads its label on the chip, with the note under the box', async () => {
- checkDevices.mockResolvedValue({ [STUDIO]: false })
- addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
- selectRemoteDevice(STUDIO)
+ test('in a project with no repository address the chip reads "This machine" whatever was picked before, and an offline machine does not block the Start', async () => {
+ // Its origin is a folder on this disk: a remote, and still no name another machine knows it by.
+ onCommands.mockResolvedValue({ commands: [], startHook: true, gitHost: false, remote: true })
+ await saveMachines(STUDIO_MACHINE)
+ daemon.reachable = { [STUDIO]: false }
+ selectMachine(STUDIO)
+ const { onSubmit } = renderComposer({ aboveControls: null })
+ await waitFor(() => expect(runOn().textContent).toBe('This machine'))
+ fireEvent.change(screen.getByLabelText('prompt'), { target: { value: 'ship it' } })
+ expect(screen.queryByText(/is offline/)).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: 'Send' }))
+ expect(onSubmit).toHaveBeenCalledWith('ship it')
+ })
+
+ test('an offline picked machine still reads its label on the chip, with the note under the box', async () => {
+ await saveMachines(STUDIO_MACHINE)
+ daemon.reachable = { [STUDIO]: false }
+ selectMachine(STUDIO)
renderComposer({ aboveControls: null })
await waitFor(() => expect(screen.getByText(/Studio is offline/)).toBeTruthy())
expect(runOn().textContent).toBe('Studio')
diff --git a/packages/openagent/dashboard/components/Composer.tsx b/packages/openagent/dashboard/components/Composer.tsx
index 4bd87dc26..5969d0270 100644
--- a/packages/openagent/dashboard/components/Composer.tsx
+++ b/packages/openagent/dashboard/components/Composer.tsx
@@ -16,11 +16,12 @@ import { PresetCreatePanel } from './PresetCreatePanel.js'
import { CommandsMenu } from './CommandsMenu.js'
import { DriverModelMenu, type DriverOption } from './DriverModelMenu.js'
import { RunOnMenu } from './RunOnMenu.js'
-import { AddDeviceDialog } from './AddDeviceDialog.js'
-import { useConnectionProfiles, connectLocal, removeProfile, type ConnectionProfile } from '../lib/profiles.js'
-import { useSelectedRemoteDeviceId, selectRemoteDevice } from '../lib/remote-target.js'
-import { useDeviceStatus } from '../lib/use-device-status.js'
-import { stashDraftFromUrl, takePendingDraft } from '../lib/draft-handoff.js'
+import { AddMachineDialog } from './AddMachineDialog.js'
+import { useMachines, removeMachine, type Machine } from '../lib/machines.js'
+import { connectLocal } from '../lib/connection.js'
+import { useSelectedMachineId, selectMachine } from '../lib/remote-target.js'
+import { useMachineStatus } from '../lib/use-machine-status.js'
+import { takePendingDraft } from '../lib/draft-handoff.js'
import { driverOptions, useModels } from '../lib/models.js'
import { readyCommands, useProjectLauncher } from '../lib/use-project-launcher.js'
import { ClaudeLogo, CodexLogo } from './driver-logos.js'
@@ -106,17 +107,13 @@ export const Composer = forwardRef(null)
- // The saved daemons this browser can hop to (#1052). Which one we are on now comes from the URL,
- // fixed for the page's life (a device switch reloads), so it is read once rather than as state.
- const profiles = useConnectionProfiles()
- const deviceStatus = useDeviceStatus(profiles) // #1072: online/offline per saved device
- const selectedDeviceId = useSelectedRemoteDeviceId() // #1067: the device this run targets, if any
- const selectedDevice = selectedDeviceId ? profiles.find(p => p.id === selectedDeviceId) : undefined
- // #1073: block Start when the target device is known-offline; an absent/unknown status must not block.
- const targetOffline = !!selectedDeviceId && deviceStatus[selectedDeviceId] === 'offline'
- const currentUrl = typeof window === 'undefined' ? null : window.location.origin
+ // The machines saved on the daemon this dashboard is on (#1052). Which daemon that is comes from
+ // the URL, fixed for the page's life, so it is read once rather than as state.
+ const machines = useMachines()
+ const machineStatus = useMachineStatus(machines) // #1072: online/offline per saved machine
+ const currentHost = typeof window === 'undefined' ? null : window.location.host
const isLocalConnection = typeof window === 'undefined' ? true : isLoopbackHost(window.location.hostname)
// The registered projects for the `@` picker — the same list the launcher reads.
const projects = useLoaded(onProjects, [], [])
@@ -128,7 +125,15 @@ export const Composer = forwardRef m.id === selectedMachineId) : undefined
+ // #1073: block Start when the target machine is known-offline; an absent/unknown status must not block.
+ const targetOffline = !!selectedMachine && machineStatus[selectedMachine.id] === 'offline'
useImperativeHandle(ref, () => ({
clear: () => {
@@ -138,9 +143,7 @@ export const Composer = forwardRef editorRef.current?.focus(),
}))
- // Rehydrate a draft carried in — from another device (#1066) or from the click that navigated
- // here (#1139) — launcher-only. stashDraftFromUrl is idempotent, so calling it here is race-safe
- // even if the SPA-entry call has not run yet.
+ // Rehydrate a draft carried in from the click that navigated here (#1139), launcher-only.
//
// Handed to the editor as `initialText` rather than pushed through the handle: the draft is taken
// once and cleared, while `loadTemplate` silently does nothing until Tiptap has resolved
@@ -149,7 +152,6 @@ export const Composer = forwardRef(undefined)
useEffect(() => {
if (compact || inAgent) return
- stashDraftFromUrl()
const carried = takePendingDraft()
if (carried) setCarriedDraft(carried)
}, [compact, inAgent])
@@ -161,7 +163,7 @@ export const Composer = forwardRef {
e?.preventDefault()
const text = prompt.trim()
- // #1073: a keyboard submit must be blocked too when the target device is offline.
+ // #1073: a keyboard submit must be blocked too when the target machine is offline.
if (!text || busy || submittingRef.current || targetOffline || !canSubmit) return
submittingRef.current = true
void Promise.resolve(onSubmit(text)).finally(() => {
@@ -232,28 +234,30 @@ export const Composer = forwardRef saveProjectPresetList(projectPresets.filter(p => p.id !== id))}
/>
)
- // Where the next run starts (#1052/#1067): this machine or a saved device. Launcher-only: a
+ // Where the next run starts (#1052/#1067): this machine or a saved machine. Launcher-only: a
// session already runs where it was started.
const runOnEl = inAgent ? null : (
selectRemoteDevice(p.id),
- onSelectLocal: () => selectRemoteDevice(null),
+ selectedMachineId,
+ onlyHere,
+ // #1067: selecting a machine makes it the run target in place, no navigation.
+ onSelect: (m: Machine) => selectMachine(m.id),
+ onSelectLocal: () => selectMachine(null),
onConnectLocal: connectLocal,
- onAddDevice: () => setAddingDevice(true),
- // #1072: drop a saved device; clear the selection if it was the run target.
- onRemove: (p: ConnectionProfile) => {
- if (selectedDeviceId === p.id) selectRemoteDevice(null)
- removeProfile(p.id)
+ onAddMachine: () => setAddingMachine(true),
+ // #1072: drop a saved machine; clear the selection if it was the run target.
+ onRemove: (m: Machine) => {
+ void removeMachine(m.id).then(removed => {
+ if (removed && selectedMachineId === m.id) selectMachine(null)
+ })
},
- status: deviceStatus,
+ status: machineStatus,
}}
/>
)
@@ -303,13 +307,13 @@ export const Composer = forwardRef
- {`${selectedDevice?.label ?? 'The selected device'} is offline. Pick another place to run, then start.`}
+ {`${selectedMachine?.label ?? 'The selected machine'} is offline. Pick another place to run, then start.`}
)
- // The "Add a device" modal (#1052), rendered by both forms since the "Run on" pick is in both. A portal, so
+ // The "Add a machine" modal (#1052), rendered by both forms since the "Run on" pick is in both. A portal, so
// its place in the tree does not matter.
- const deviceDialog = addingDevice && setAddingDevice(false)} onAdded={() => editorRef.current?.focus()} />
+ const machineDialog = addingMachine && setAddingMachine(false)} onAdded={() => editorRef.current?.focus()} />
// Compact (#723): a single row for the navbar — editor, then the same controls and submit. It
// stays one row on purpose (#755): the header must not grow taller to gain them.
@@ -320,7 +324,7 @@ export const Composer = forwardRef
)
}
@@ -377,7 +381,7 @@ export const Composer = forwardRef
)}
- {deviceDialog}
+ {machineDialog}
>
)
})
diff --git a/packages/openagent/dashboard/components/ConnectionIndicator.tsx b/packages/openagent/dashboard/components/ConnectionIndicator.tsx
index f9d8a1b10..062d6b464 100644
--- a/packages/openagent/dashboard/components/ConnectionIndicator.tsx
+++ b/packages/openagent/dashboard/components/ConnectionIndicator.tsx
@@ -1,31 +1,16 @@
-import { useEffect } from 'react'
import { Laptop, MonitorSmartphone } from 'lucide-react'
-import { useConnectionProfiles, currentConnection, rememberLocalOrigin } from '../lib/profiles.js'
-import { useDeviceStatus } from '../lib/use-device-status.js'
-import { stashDraftFromUrl } from '../lib/draft-handoff.js'
-import { cn } from '../lib/utils.js'
+import { currentConnection } from '../lib/connection.js'
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
// The "connected to " indicator (#1052): which daemon the dashboard is talking to. Every
// transport is same-origin, so the browser's origin IS the connection — loopback is this machine's
-// own daemon ("Local"), any other origin is a device you hopped to. It reads accented off Local so
-// a remote box (where the agent runs on someone else's hardware) is never mistaken for your own.
+// own daemon ("Local"), any other origin is another machine's, named by its address. It reads
+// accented off Local so a remote box (where the agent runs on someone else's hardware) is never
+// mistaken for your own.
export function ConnectionIndicator() {
- const profiles = useConnectionProfiles()
- const deviceStatus = useDeviceStatus(profiles) // #1072: reachability of the saved devices
- // Remember the loopback origin we launched from, so "Local" can return to the right port later.
- // Also move any carried draft (#1066) out of the URL at SPA boot, before it reaches the address bar.
- useEffect(() => {
- if (typeof window === 'undefined') return
- stashDraftFromUrl()
- rememberLocalOrigin(window.location.origin, window.location.hostname)
- }, [])
if (typeof window === 'undefined') return null
- const origin = window.location.origin
- const { label, isLocal } = currentConnection(profiles, origin, window.location.hostname)
+ const { label, isLocal } = currentConnection(window.location.host, window.location.hostname)
const Icon = isLocal ? Laptop : MonitorSmartphone
- // On this machine the dot is trivially online; on a remote device it follows the poll (#1072).
- const online = isLocal || deviceStatus[profiles.find(p => p.url === origin)?.id ?? ''] === 'online'
return (
}
>
-
+
{label}
- {isLocal ? 'Connected to this machine' : `Connected to ${label} — the agent runs on that device`}
+ {isLocal ? 'Connected to this machine' : `Connected to ${label} — the agent runs on that machine`}
)
diff --git a/packages/openagent/dashboard/components/DevicesSettings.test.tsx b/packages/openagent/dashboard/components/DevicesSettings.test.tsx
deleted file mode 100644
index 5bd473145..000000000
--- a/packages/openagent/dashboard/components/DevicesSettings.test.tsx
+++ /dev/null
@@ -1,73 +0,0 @@
-import { afterEach, describe, expect, test, vi } from 'vitest'
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-
-// The device health poll (#1072) reaches the daemon over an RPC; hoisted so the factory below can
-// close over it (a plain const is initialised after vi.mock hoists).
-const checkDevices = vi.hoisted(() => vi.fn())
-vi.mock('../rpc/devices.js', () => ({ checkDevices }))
-
-import { DevicesSettings } from './DevicesSettings.js'
-import { addProfile, listProfiles } from '../lib/profiles.js'
-import { selectRemoteDevice, getSelectedRemoteDeviceId } from '../lib/remote-target.js'
-
-const STUDIO = 'http://192.168.1.5:4200'
-const BOX = 'http://box.tail.ts:4200'
-
-afterEach(() => {
- cleanup()
- localStorage.clear()
- selectRemoteDevice(null) // module-level state, so it outlives a test unless reset
- checkDevices.mockReset()
-})
-
-describe('DevicesSettings (#1052/#1072)', () => {
- test('says so when there are no devices, rather than showing an empty list', () => {
- checkDevices.mockResolvedValue({})
- render( )
- expect(screen.getByText(/No devices saved/)).toBeTruthy()
- })
-
- test('lists each saved device with its origin', () => {
- checkDevices.mockResolvedValue({})
- addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
- render( )
- expect(screen.getByText('Studio')).toBeTruthy()
- expect(screen.getByText(STUDIO)).toBeTruthy()
- })
-
- test('removing a device drops it from storage', () => {
- checkDevices.mockResolvedValue({})
- addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
- render( )
-
- fireEvent.click(screen.getByLabelText('Remove Studio'))
-
- expect(listProfiles()).toEqual([])
- })
-
- test('removing the device a run is targeting clears the run target (#1072)', () => {
- // The composer applies this guard on its own remove; managing the roster from settings has to
- // apply it too, or the next agent points at a device that is no longer in the list.
- checkDevices.mockResolvedValue({})
- const studio = addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
- selectRemoteDevice(studio.id)
- render( )
-
- fireEvent.click(screen.getByLabelText('Remove Studio'))
-
- expect(getSelectedRemoteDeviceId()).toBe(null)
- })
-
- test('removing some other device leaves the run target alone', () => {
- checkDevices.mockResolvedValue({})
- const studio = addProfile({ url: STUDIO, token: 'aaa', label: 'Studio' })
- addProfile({ url: BOX, token: 'bbb', label: 'Box' })
- selectRemoteDevice(studio.id)
- render( )
-
- fireEvent.click(screen.getByLabelText('Remove Box'))
-
- expect(getSelectedRemoteDeviceId()).toBe(studio.id)
- expect(listProfiles().map(p => p.label)).toEqual(['Studio'])
- })
-})
diff --git a/packages/openagent/dashboard/components/MachinesSettings.test.tsx b/packages/openagent/dashboard/components/MachinesSettings.test.tsx
new file mode 100644
index 000000000..5cba331d4
--- /dev/null
+++ b/packages/openagent/dashboard/components/MachinesSettings.test.tsx
@@ -0,0 +1,84 @@
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+
+vi.mock('../rpc/machines.js', async () => (await import('../test-machines.js')).machinesRpc)
+
+import { daemon, machinesRpc, saveMachines } from '../test-machines.js'
+import { MachinesSettings } from './MachinesSettings.js'
+import { selectMachine, getSelectedMachineId } from '../lib/remote-target.js'
+
+const STUDIO = { id: 'http://192.168.1.5:4200', label: 'Studio', url: 'http://192.168.1.5:4200' }
+const BOX = { id: 'http://box.tail.ts:4200', label: 'Box', url: 'http://box.tail.ts:4200' }
+
+afterEach(async () => {
+ cleanup()
+ selectMachine(null) // module-level state, so it outlives a test unless reset
+ await saveMachines()
+})
+
+describe('MachinesSettings (#1052/#1072)', () => {
+ // First in the file on purpose: it needs a page that has not read the list yet.
+ test('before the daemon has answered it does not claim there are none; once it has, it says so rather than showing an empty list', async () => {
+ let land: (machines: never[]) => void = () => {}
+ machinesRpc.onMachines.mockImplementationOnce(() => new Promise(resolve => (land = resolve)))
+ render( )
+ await waitFor(() => expect(machinesRpc.onMachines).toHaveBeenCalled())
+ expect(screen.queryByText(/No machines saved/)).toBeNull()
+ land([])
+ await waitFor(() => expect(screen.getByText(/No machines saved/)).toBeTruthy())
+ })
+
+ test('lists each saved machine with its address, and says where the list is kept', async () => {
+ await saveMachines(STUDIO)
+ render( )
+ expect(screen.getByText('Studio')).toBeTruthy()
+ expect(screen.getByText(STUDIO.url)).toBeTruthy()
+ expect(screen.getByText(/every\s+browser that opens it shows the same list/)).toBeTruthy()
+ })
+
+ test('removing a machine takes it off the daemon\'s list and off the page', async () => {
+ await saveMachines(STUDIO)
+ render( )
+
+ fireEvent.click(screen.getByLabelText('Remove Studio'))
+
+ await waitFor(() => expect(screen.getByText(/No machines saved/)).toBeTruthy())
+ expect(daemon.machines).toEqual([])
+ })
+
+ test('removing the machine a run is targeting clears the run target (#1072)', async () => {
+ // The composer applies this guard on its own remove; managing the list from settings has to
+ // apply it too, or the next agent points at a machine that is no longer in the list.
+ await saveMachines(STUDIO)
+ selectMachine(STUDIO.id)
+ render( )
+
+ fireEvent.click(screen.getByLabelText('Remove Studio'))
+
+ await waitFor(() => expect(getSelectedMachineId()).toBe(null))
+ })
+
+ test('a remove the daemon could not do leaves the machine listed and the run target as it was', async () => {
+ await saveMachines(STUDIO)
+ selectMachine(STUDIO.id)
+ machinesRpc.sendRemoveMachine.mockRejectedValueOnce(new Error('the daemon is gone'))
+ render( )
+
+ fireEvent.click(screen.getByLabelText('Remove Studio'))
+
+ await waitFor(() => expect(machinesRpc.onMachines).toHaveBeenCalled()) // the list was read again
+ expect(screen.getByText('Studio')).toBeTruthy()
+ expect(getSelectedMachineId()).toBe(STUDIO.id)
+ })
+
+ test('removing some other machine leaves the run target alone', async () => {
+ await saveMachines(BOX, STUDIO)
+ selectMachine(STUDIO.id)
+ render( )
+
+ fireEvent.click(screen.getByLabelText('Remove Box'))
+
+ expect(getSelectedMachineId()).toBe(STUDIO.id)
+ await waitFor(() => expect(daemon.machines.map(machine => machine.label)).toEqual(['Studio']))
+ })
+})
diff --git a/packages/openagent/dashboard/components/DevicesSettings.tsx b/packages/openagent/dashboard/components/MachinesSettings.tsx
similarity index 50%
rename from packages/openagent/dashboard/components/DevicesSettings.tsx
rename to packages/openagent/dashboard/components/MachinesSettings.tsx
index 7a88c0650..eabe1cff0 100644
--- a/packages/openagent/dashboard/components/DevicesSettings.tsx
+++ b/packages/openagent/dashboard/components/MachinesSettings.tsx
@@ -1,82 +1,82 @@
import { useState } from 'react'
import { Trash2 } from 'lucide-react'
-import { useConnectionProfiles, removeProfile, type ConnectionProfile } from '../lib/profiles.js'
-import { useDeviceStatus } from '../lib/use-device-status.js'
-import { useSelectedRemoteDeviceId, selectRemoteDevice } from '../lib/remote-target.js'
-import { AddDeviceDialog } from './AddDeviceDialog.js'
+import { useMachines, useMachinesLoaded, removeMachine, type Machine } from '../lib/machines.js'
+import { useMachineStatus } from '../lib/use-machine-status.js'
+import { useSelectedMachineId, selectMachine } from '../lib/remote-target.js'
+import { AddMachineDialog } from './AddMachineDialog.js'
import { Button } from './ui/button.js'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import { cn } from '../lib/utils.js'
-// Saved devices, as a settings section (#1052/#1072).
+// Saved machines, as a settings section (#1052/#1072).
//
-// Adding and removing a device already worked, but only from the composer's "Run on" menu, and the
+// Adding and removing a machine already worked, but only from the composer's "Run on" menu, and the
// composer exists on a project launcher and nowhere else: from the Overview or the settings page
-// there was no way to manage the roster at all. The picker keeps listing devices, because choosing
-// an agent target is a per-agent act; which devices exist is configuration, so it belongs here.
+// there was no way to manage the list at all. The picker keeps listing machines, because choosing
+// where an agent runs is a per-agent act; which machines exist is configuration, so it belongs here.
//
-// Unlike everything else on the settings page these are NOT preferences. A device carries a token,
-// so it lives in this browser's localStorage and never reaches the daemon (see profiles.ts). The
-// section says so, because the reasonable assumption for a settings row is that it follows you to
-// the next browser, and this one does not.
+// The list is the daemon's, in the person's home file beside their settings, so it is the same in
+// every browser on this computer.
-export function DevicesSettings() {
- const profiles = useConnectionProfiles()
- const status = useDeviceStatus(profiles)
- const selectedDeviceId = useSelectedRemoteDeviceId()
+export function MachinesSettings() {
+ const machines = useMachines()
+ const loaded = useMachinesLoaded()
+ const status = useMachineStatus(machines)
+ const selectedMachineId = useSelectedMachineId()
const [adding, setAdding] = useState(false)
- // The same guard the composer applies (#1072): a device that is removed must not stay the agent
+ // The same guard the composer applies (#1072): a machine that is removed must not stay the agent
// target, or the next agent points at something that is no longer in the list.
- const remove = (profile: ConnectionProfile) => {
- if (selectedDeviceId === profile.id) selectRemoteDevice(null)
- removeProfile(profile.id)
+ const remove = (machine: Machine) => {
+ void removeMachine(machine.id).then(removed => {
+ if (removed && selectedMachineId === machine.id) selectMachine(null)
+ })
}
return (
-
Devices
+
Machines
- Other machines running OpenAgent that you can run a session on. Saved in this browser, not on the
- server, because each one is reached with its own token.
+ Other machines running OpenAgent that you can run a session on. Saved with this OpenAgent, so every
+ browser that opens it shows the same list.
setAdding(true)}>
- Add device
+ Add machine
- {profiles.length === 0 ? (
+ {!loaded ? null : machines.length === 0 ? (
- No devices saved. Add one with the URL another machine prints when it starts.
+ No machines saved. Add one with the URL another machine prints when it is opened to the network.
) : (
- {adding && setAdding(false)} onAdded={() => setAdding(false)} />}
+ {adding && setAdding(false)} onAdded={() => setAdding(false)} />}
)
}
/** Online / offline, or neither while the first ping is still out. */
-function DeviceStatusBadge({ state }: { state: 'online' | 'offline' | undefined }) {
+function MachineStatusBadge({ state }: { state: 'online' | 'offline' | undefined }) {
const label = state === 'online' ? 'Online' : state === 'offline' ? 'Offline' : 'Checking…'
return (
diff --git a/packages/openagent/dashboard/components/RemoteAgentNotice.tsx b/packages/openagent/dashboard/components/RemoteAgentNotice.tsx
index 1aa1a19bb..e8fcf6cb6 100644
--- a/packages/openagent/dashboard/components/RemoteAgentNotice.tsx
+++ b/packages/openagent/dashboard/components/RemoteAgentNotice.tsx
@@ -1,16 +1,16 @@
import { MonitorSmartphone } from 'lucide-react'
-// The agent view's banner for a session running on a connected device (#1067, slice 2). Its live feed,
+// The agent view's banner for a session running on a saved machine (#1067, slice 2). Its live feed,
// diff, worktree, handoff, and push/PR all relay back through the local daemon and render normally, so
-// this only flags where the agent executes. Renders nothing without a device label, so the agent view
+// this only flags where the agent executes. Renders nothing without a machine's name, so the agent view
// can mount it unconditionally.
-export function RemoteAgentNotice({ device }: { device?: string | undefined }) {
- if (!device) return null
+export function RemoteAgentNotice({ machine }: { machine?: string | undefined }) {
+ if (!machine) return null
return (
- Running on {device}. Its worktree, diff, and pull request live on the device; the browser preview is not available for remote runs yet.
+ Running on {machine}. Its worktree, diff, and pull request live on that machine; the browser preview is not available for remote runs yet.
)
diff --git a/packages/openagent/dashboard/components/RunOnMenu.test.tsx b/packages/openagent/dashboard/components/RunOnMenu.test.tsx
index 52ca5ce61..a8ab0a1dd 100644
--- a/packages/openagent/dashboard/components/RunOnMenu.test.tsx
+++ b/packages/openagent/dashboard/components/RunOnMenu.test.tsx
@@ -1,23 +1,24 @@
import { afterEach, describe, expect, test, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import type { ConnectionProfile } from '../lib/profiles.js'
+import type { Machine } from '../lib/machines.js'
import { hoverTooltip, openMenu } from '../test-utils.js'
import { RunOnMenu, type ConnectionControl } from './RunOnMenu.js'
afterEach(cleanup)
-const STUDIO: ConnectionProfile = { id: 'studio', url: 'http://192.168.1.5:4200', token: 'aaa', label: 'Studio' }
+const STUDIO: Machine = { id: 'studio', url: 'http://192.168.1.5:4200', label: 'Studio' }
function renderMenu(over: Partial = {}, chip = true) {
const connection: ConnectionControl = {
- profiles: [STUDIO],
- currentUrl: 'http://localhost:4200',
+ machines: [STUDIO],
+ currentHost: 'localhost:4200',
isLocal: true,
- selectedDeviceId: null,
+ selectedMachineId: null,
+ onlyHere: false,
onSelect: vi.fn(),
onSelectLocal: vi.fn(),
onConnectLocal: vi.fn(),
- onAddDevice: vi.fn(),
+ onAddMachine: vi.fn(),
onRemove: vi.fn(),
status: {},
...over,
@@ -29,7 +30,7 @@ function renderMenu(over: Partial = {}, chip = true) {
const trigger = () => screen.getByRole('button', { name: 'Run on' })
describe('the "Run on" pick as a chip', () => {
- test('it reads "This machine" while no device is picked, and looks like a chip', () => {
+ test('it reads "This machine" while no machine is picked, and looks like a chip', () => {
renderMenu()
expect(trigger().textContent).toBe('This machine')
expect(trigger().className).toContain('rounded-full')
@@ -38,35 +39,62 @@ describe('the "Run on" pick as a chip', () => {
expect(trigger().querySelectorAll('svg')).toHaveLength(2)
})
- test('it reads the picked device\'s label, an offline one too, and a long label is cut short', () => {
- renderMenu({ selectedDeviceId: 'studio', status: { studio: 'offline' } })
+ test('it reads the picked machine\'s label, an offline one too, and a long label is cut short', () => {
+ renderMenu({ selectedMachineId: 'studio', status: { studio: 'offline' } })
expect(trigger().textContent).toBe('Studio')
const name = screen.getByText('Studio')
expect(name.className).toContain('truncate')
expect(trigger().className).toContain('min-w-0')
})
- test('a pick that names a removed device reads "This machine"', () => {
- renderMenu({ selectedDeviceId: 'gone' })
+ test('a pick that names a removed machine reads "This machine"', () => {
+ renderMenu({ selectedMachineId: 'gone' })
expect(trigger().textContent).toBe('This machine')
})
- test('open on a device\'s own daemon, it reads that device\'s label, and "A device" for one not saved', () => {
- renderMenu({ isLocal: false, currentUrl: STUDIO.url })
+ test('open on another machine\'s own dashboard, it reads that machine\'s address, which has a row of its own; "This machine" goes home', async () => {
+ const connection = renderMenu({ isLocal: false, currentHost: '10.0.0.9:4200' })
+ expect(trigger().textContent).toBe('10.0.0.9:4200')
+ await openMenu(trigger())
+ const items = screen.getAllByRole('menuitem')
+ expect(items.map(item => item.textContent)).toEqual([
+ expect.stringContaining('This machine'),
+ expect.stringContaining('10.0.0.9:4200'),
+ expect.stringContaining('Studio'),
+ expect.stringContaining('Add a machine…'),
+ ])
+ fireEvent.click(items[0]!)
+ expect(connection.onConnectLocal).toHaveBeenCalled()
+ expect(connection.onSelectLocal).not.toHaveBeenCalled()
+ })
+
+ test('on another machine\'s dashboard one of its saved machines can be picked, and its own row unpicks it', async () => {
+ const connection = renderMenu({ isLocal: false, currentHost: '10.0.0.9:4200', selectedMachineId: 'studio' })
expect(trigger().textContent).toBe('Studio')
- cleanup()
- renderMenu({ isLocal: false, currentUrl: 'http://10.0.0.9:4200' })
- expect(trigger().textContent).toBe('A device')
+ await openMenu(trigger())
+ fireEvent.click(screen.getByRole('menuitem', { name: /^10\.0\.0\.9:4200/ }))
+ expect(connection.onSelectLocal).toHaveBeenCalled()
+ })
+
+ test('a project with no repository address runs here only: the machines cannot be picked, a pick made elsewhere does not hold, and the menu says why', async () => {
+ const connection = renderMenu({ onlyHere: true, selectedMachineId: 'studio' })
+ expect(trigger().textContent).toBe('This machine')
+ await openMenu(trigger())
+ const studio = screen.getByRole('menuitem', { name: /^Studio/ })
+ expect(studio.getAttribute('aria-disabled')).toBe('true')
+ fireEvent.click(studio)
+ expect(connection.onSelect).not.toHaveBeenCalled()
+ expect(screen.getByText('This project has no repository address, so it cannot be sent to another machine.')).toBeTruthy()
})
- test('its menu lists this machine, the devices and "Add a device…", and a click picks', async () => {
- const connection = renderMenu({ selectedDeviceId: 'studio' })
+ test('its menu lists this machine, the machines and "Add a machine…", and a click picks', async () => {
+ const connection = renderMenu({ selectedMachineId: 'studio' })
await openMenu(trigger())
const items = screen.getAllByRole('menuitem')
expect(items).toHaveLength(3)
expect(items[0]!.textContent).toContain('This machine')
expect(items[1]!.textContent).toContain('Studio')
- expect(items[2]!.textContent).toContain('Add a device…')
+ expect(items[2]!.textContent).toContain('Add a machine…')
fireEvent.click(items[1]!)
expect(connection.onSelect).toHaveBeenCalledWith(STUDIO)
@@ -77,10 +105,10 @@ describe('the "Run on" pick as a chip', () => {
expect(connection.onConnectLocal).not.toHaveBeenCalled()
})
- test('the X on a device\'s row removes it and does not pick it', async () => {
+ test('the X on a machine\'s row removes it and does not pick it', async () => {
const connection = renderMenu()
await openMenu(trigger())
- fireEvent.click(screen.getByRole('button', { name: 'Remove device Studio' }))
+ fireEvent.click(screen.getByRole('button', { name: 'Remove machine Studio' }))
expect(connection.onRemove).toHaveBeenCalledWith(STUDIO)
expect(connection.onSelect).not.toHaveBeenCalled()
})
@@ -88,7 +116,7 @@ describe('the "Run on" pick as a chip', () => {
describe('the "Run on" pick as an icon button', () => {
test('it shows no words, and its tooltip names the target', async () => {
- renderMenu({ selectedDeviceId: 'studio' }, false)
+ renderMenu({ selectedMachineId: 'studio' }, false)
expect(trigger().textContent).toBe('')
expect(trigger().className).not.toContain('rounded-full')
expect((await hoverTooltip(trigger())).textContent).toBe('Run on — Studio')
diff --git a/packages/openagent/dashboard/components/RunOnMenu.tsx b/packages/openagent/dashboard/components/RunOnMenu.tsx
index f1448476e..45cb3622a 100644
--- a/packages/openagent/dashboard/components/RunOnMenu.tsx
+++ b/packages/openagent/dashboard/components/RunOnMenu.tsx
@@ -1,6 +1,6 @@
import { Check, ChevronDown, Laptop, MonitorSmartphone, Plus, X } from 'lucide-react'
-import type { ConnectionProfile } from '../lib/profiles.js'
-import type { DeviceStatus } from '../lib/use-device-status.js'
+import type { Machine } from '../lib/machines.js'
+import type { MachineStatus } from '../lib/use-machine-status.js'
import { cn } from '../lib/utils.js'
import { buttonVariants } from './ui/button.js'
import { chipClass } from './ui/chip.js'
@@ -9,54 +9,53 @@ import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip.js'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from './ui/dropdown-menu.js'
/**
- * Where the next run starts (#1052/#1066/#1067): this machine, or a saved device. A device is a
- * run TARGET, selected in place: picking one does not navigate the browser. The local daemon
- * relays the start to it, and the device runs its own project's start hook. A device is not
- * persisted as a preference: its token is this browser's secret.
+ * Where the next run starts (#1052/#1066/#1067): the machine this dashboard is on, or one of the
+ * machines saved on it. A machine is a run TARGET, selected in place: picking one does not
+ * navigate the browser. The daemon relays the start to it, and that machine runs the start hook
+ * of its own copy of the project.
*/
export type ConnectionControl = {
- /** The saved remote daemons this browser can run on. */
- profiles: ConnectionProfile[]
- /** `window.location.origin`, to mark the daemon the dashboard is on now. */
- currentUrl: string | null
- /** Whether the current origin is loopback (this machine's own daemon). */
+ /** The saved machines a run can be sent to. */
+ machines: Machine[]
+ /** The host of the dashboard's address, which names the machine it is on when that is not this one. */
+ currentHost: string | null
+ /** Whether the dashboard's address is loopback (this machine's own daemon). */
isLocal: boolean
- /** The device selected as the run target (its profile id), or null when this machine is (#1067). */
- selectedDeviceId: string | null
- /** Select a saved device as the run target, with no navigation (#1067). */
- onSelect: (profile: ConnectionProfile) => void
- /** Clear the device selection back to this machine (#1067). */
+ /** The machine selected as the run target (its id), or null when the dashboard's own machine is (#1067). */
+ selectedMachineId: string | null
+ /** The project has no repository address, the one name another machine would know it by: it runs here only. */
+ onlyHere: boolean
+ /** Select a saved machine as the run target, with no navigation (#1067). */
+ onSelect: (machine: Machine) => void
+ /** Clear the selection back to the dashboard's own machine (#1067). */
onSelectLocal: () => void
- /** Return to this machine's own daemon when currently on a remote one (#1066). */
+ /** Return to this machine's own daemon when currently on another machine's dashboard (#1066). */
onConnectLocal: () => void
- onAddDevice: () => void
- /** Drop a saved device (#1072). The caller clears the selection if this was the run target. */
- onRemove: (profile: ConnectionProfile) => void
- /** Each device's online/offline status (#1072); an id absent from the map is still being checked. */
- status: Record
+ onAddMachine: () => void
+ /** Drop a saved machine (#1072). The caller clears the selection if this was the run target. */
+ onRemove: (machine: Machine) => void
+ /** Each machine's online/offline status (#1072); an id absent from the map is still being checked. */
+ status: Record
}
-/** A small reachability dot on a device row (#1072): green online, muted offline or still unknown. */
-function StatusDot({ status }: { status: DeviceStatus | undefined }) {
+/** A small reachability dot on a machine's row (#1072): green online, muted offline or still unknown. */
+function StatusDot({ status }: { status: MachineStatus | undefined }) {
return
}
-// One flat "Run on" list (#1066/#1067): this machine, then the saved devices and "Add a device",
-// with a single checkmark. When genuinely on a remote daemon (a manual connection), that device
-// carries the mark, and "This machine" goes home.
+// One flat "Run on" list (#1066/#1067): this machine, then the saved machines and "Add a machine",
+// with a single checkmark. On another machine's dashboard (opened by its address), that machine
+// has a row of its own, which carries the mark, and "This machine" goes home.
// Two looks for the one menu: an icon button for the compact single row, and a chip that also says
// the target in words, for the launcher's row above the box.
export function RunOnMenu({ connection, busy, chip = false }: { connection: ConnectionControl; busy: boolean; chip?: boolean | undefined }) {
- // The selected device (#1067), meaningful only on the local daemon; on a remote daemon the
- // connected device is the target instead. An id pointing at a removed device reads as none.
- const selectedDevice =
- connection.isLocal && connection.selectedDeviceId ? connection.profiles.find(p => p.id === connection.selectedDeviceId) : undefined
- const isTargetDevice = (profile: ConnectionProfile): boolean =>
- connection.isLocal ? selectedDevice?.id === profile.id : profile.url === connection.currentUrl
- const summary = !connection.isLocal
- ? (connection.profiles.find(p => p.url === connection.currentUrl)?.label ?? 'A device')
- : (selectedDevice?.label ?? 'This machine')
- const onThisMachine = connection.isLocal && !selectedDevice
+ // The selected machine (#1067). An id pointing at a removed machine reads as none, and so does
+ // any pick on a project that runs here only.
+ const selectedMachine =
+ connection.selectedMachineId && !connection.onlyHere ? connection.machines.find(m => m.id === connection.selectedMachineId) : undefined
+ const dashboardsMachine = connection.isLocal ? 'This machine' : (connection.currentHost ?? 'Another machine')
+ const summary = selectedMachine?.label ?? dashboardsMachine
+ const onThisMachine = connection.isLocal && !selectedMachine
const Icon = onThisMachine ? Laptop : MonitorSmartphone
return (
@@ -105,17 +104,31 @@ export function RunOnMenu({ connection, busy, chip = false }: { connection: Conn
- {/* Saved devices: a click SELECTS the device as the run target (no navigation). The dot
- shows reachability (#1072) and the X removes the saved device. */}
- {connection.profiles.map(profile => {
- const status = connection.status[profile.id]
+ {/* On another machine's dashboard, that machine is where a run starts unless one of its
+ saved machines is picked. */}
+ {!connection.isLocal && (
+ connection.onSelectLocal()}>
+
+
+
+
+ )}
+ {/* Saved machines: a click SELECTS the machine as the run target (no navigation). The dot
+ shows reachability (#1072) and the X removes the saved machine. */}
+ {connection.machines.map(machine => {
+ const status = connection.status[machine.id]
const offline = status === 'offline'
return (
- connection.onSelect(profile)}>
-
+ connection.onSelect(machine)}
+ >
+
-
+
{
// Remove, not select: keep the row's own click out of it (#1072).
e.stopPropagation()
- connection.onRemove(profile)
+ connection.onRemove(machine)
}}
- aria-label={`Remove device ${profile.label}`}
+ aria-label={`Remove machine ${machine.label}`}
className="mt-0.5 rounded p-0.5 text-[var(--color-muted-foreground)] hover:text-danger"
/>
}
>
- Remove {profile.label}
+ Remove {machine.label}
)
})}
- connection.onAddDevice()}>
+ {connection.onlyHere && connection.machines.length > 0 && (
+
+ This project has no repository address, so it cannot be sent to another machine.
+
+ )}
+ connection.onAddMachine()}>
-
+
diff --git a/packages/openagent/dashboard/components/SessionLine.tsx b/packages/openagent/dashboard/components/SessionLine.tsx
index 836f59eba..6cc0cea45 100644
--- a/packages/openagent/dashboard/components/SessionLine.tsx
+++ b/packages/openagent/dashboard/components/SessionLine.tsx
@@ -8,7 +8,7 @@ import { Dots, Seconds } from './ToolCalls.js'
/** What was set up for the agent before it read its prompt, as its card says it. */
export type SessionSetup = { [K in 'workspace' | 'branch' | 'base' | 'driver' | 'model']?: AgentMeta[K] | undefined } & {
- /** True for an agent that runs elsewhere (GitHub Actions, the cloud, another device): no checkout is made for it on this machine. */
+ /** True for an agent that runs elsewhere (GitHub Actions, the cloud, another machine): no checkout is made for it on this machine. */
elsewhere?: boolean | undefined
}
diff --git a/packages/openagent/dashboard/components/SettingsPage.test.tsx b/packages/openagent/dashboard/components/SettingsPage.test.tsx
index 39f58f427..29b5b47a6 100644
--- a/packages/openagent/dashboard/components/SettingsPage.test.tsx
+++ b/packages/openagent/dashboard/components/SettingsPage.test.tsx
@@ -3,16 +3,15 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import { refreshPreferences } from '../lib/preferences.js'
import { ModulesContext, byMountOrder, type MountedModules } from '../lib/use-modules.js'
-// The reads this page makes, answered as an empty machine: no devices, no editors detected, no
+// The reads this page makes, answered as an empty machine: no saved machines, no editors detected, no
// stored preferences. The rest of the module is kept, since the onboarding checklist inside the
// page reads far more than the settings rows do.
-const checkDevices = vi.hoisted(() => vi.fn(async () => ({})))
const prefsRead = vi.hoisted(() => vi.fn(async (): Promise> => ({})))
vi.mock('../rpc/preferences.js', async importOriginal => ({
...(await importOriginal()),
onPreferences: prefsRead,
}))
-vi.mock('../rpc/devices.js', () => ({ checkDevices }))
+vi.mock('../rpc/machines.js', async () => (await import('../test-machines.js')).machinesRpc)
// The coding agents' own lists, as the daemon asked them: Claude Code answered, Codex could not.
const onModels = vi.hoisted(() =>
vi.fn(async () => ({
diff --git a/packages/openagent/dashboard/components/SettingsPage.tsx b/packages/openagent/dashboard/components/SettingsPage.tsx
index 5cb71bf5e..938bf81c5 100644
--- a/packages/openagent/dashboard/components/SettingsPage.tsx
+++ b/packages/openagent/dashboard/components/SettingsPage.tsx
@@ -13,7 +13,7 @@ import { useNotificationPermission } from '../lib/notification-permission.js'
import { OnboardingChecklist } from './OnboardingChecklist.js'
import { BridgeSettings } from './BridgeSettings.js'
import { BridgeBrowserSettings } from './BridgeBrowserSettings.js'
-import { DevicesSettings } from './DevicesSettings.js'
+import { MachinesSettings } from './MachinesSettings.js'
import { Card, CardContent, CardHeader, CardTitle } from './ui/card.js'
import { Checkbox } from './ui/checkbox.js'
import { ScrollArea } from './ui/scroll-area.js'
@@ -116,8 +116,8 @@ export function SettingsPage({
/>
- {/* A saved device is the other place a session can run on. */}
-
+ {/* A saved machine is the other place a session can run on. */}
+
{
await configureFirst(MENU)
expect(onConfigure).toHaveBeenCalledTimes(1)
expect(onStart).not.toHaveBeenCalled()
- // Carried through the draft stash the device hop and the modules' "Configure first" use (#1066/#1139), so the
+ // Carried through the draft stash the modules' "Configure first" uses (#1139), so the
// launcher rehydrates with this prompt verbatim rather than an empty composer.
expect(takePendingDraft()).toBe(PROMPT)
})
diff --git a/packages/openagent/dashboard/components/StartAgentForm.test.tsx b/packages/openagent/dashboard/components/StartAgentForm.test.tsx
index 7ed970152..b03e6a638 100644
--- a/packages/openagent/dashboard/components/StartAgentForm.test.tsx
+++ b/packages/openagent/dashboard/components/StartAgentForm.test.tsx
@@ -15,9 +15,9 @@ const updatePreferences = vi.hoisted(() => vi.fn())
// Whether the saved preferences have been read: a test turns it off to see what waits for them.
const prefsLoaded = vi.hoisted(() => ({ current: true }))
vi.mock('../lib/preferences.js', () => ({ usePreferences: () => prefs.current, usePreferencesLoaded: () => prefsLoaded.current, updatePreferences }))
-const device = vi.hoisted(() => ({ current: null as null | { id: string; url: string; token: string; label: string } }))
-vi.mock('../lib/profiles.js', () => ({ useConnectionProfiles: () => (device.current ? [device.current] : []) }))
-vi.mock('../lib/remote-target.js', () => ({ useSelectedRemoteDeviceId: () => device.current?.id ?? null }))
+const machine = vi.hoisted(() => ({ current: null as null | { id: string; url: string; label: string } }))
+vi.mock('../lib/machines.js', () => ({ useMachines: () => (machine.current ? [machine.current] : []) }))
+vi.mock('../lib/remote-target.js', () => ({ useSelectedMachineId: () => machine.current?.id ?? null }))
const start = vi.hoisted(() => vi.fn())
vi.mock('../lib/use-start-agent.js', async () => ({
@@ -64,7 +64,7 @@ afterEach(() => {
updatePreferences.mockReset()
prefs.current = {}
prefsLoaded.current = true
- device.current = null
+ machine.current = null
})
const COMMANDS = [{ name: 'work-queue', description: 'Work the agent queue' }]
@@ -77,7 +77,7 @@ const publishOptions = () => screen.getAllByRole('menuitem').map(item => item.qu
/** The launcher's "start from" chip, when there is one. */
const startFromChip = () => screen.queryByRole('button', { name: 'The agent starts from' })
/** A project whose start line passes the branch on, whose folder is on `my/work`. */
-const WITH_BRANCHES = { commands: [], startHook: true, gitHost: true, remote: true, startFrom: { main: 'main', local: 'my/work' } }
+const WITH_BRANCHES = { commands: [], startHook: true, gitHost: true, remote: true, address: 'github.com/acme/shop', startFrom: { main: 'main', local: 'my/work' } }
const props = { projectId: 'p1', files: [], context: new Set(), addContext: noop, removeContext: noop, toggleContext: noop }
describe('StartAgentForm (#1774)', () => {
@@ -259,9 +259,9 @@ describe('StartAgentForm (#1774)', () => {
expect((screen.getByText('submit-typed') as HTMLButtonElement).disabled).toBe(false)
})
- test('a picked device runs its own start hook: the start carries it, and this project\'s missing hook does not block', async () => {
- onCommands.mockResolvedValue({ commands: [], startHook: false, gitHost: true })
- device.current = { id: 'd1', url: 'http://box:4200', token: 't', label: 'box' }
+ test('a picked machine runs its own start hook: the start names it by its id and carries no key, and this project\'s missing hook does not block', async () => {
+ onCommands.mockResolvedValue({ commands: [], startHook: false, gitHost: true, remote: true, address: 'github.com/acme/shop' })
+ machine.current = { id: 'd1', url: 'http://box:4200', label: 'box' }
start.mockResolvedValue({ agentId: 'r2' })
const onAgentStarted = vi.fn()
render( )
@@ -269,10 +269,23 @@ describe('StartAgentForm (#1774)', () => {
expect(screen.queryByRole('alert')).toBeNull()
fireEvent.click(screen.getByText('submit-typed'))
await waitFor(() => expect(onAgentStarted).toHaveBeenCalledWith('do the thing', 'r2', 'box'))
- expect(start).toHaveBeenCalledWith('p1', 'do the thing', { remote: { url: 'http://box:4200', token: 't', label: 'box' } })
+ expect(start).toHaveBeenCalledWith('p1', 'do the thing', { machine: 'd1' })
expect(onStartCheck).not.toHaveBeenCalled()
})
+ test('a project with no repository address runs here only: a machine picked on another project is not its target', async () => {
+ onCommands.mockResolvedValue({ commands: [], startHook: true, gitHost: false, remote: true })
+ machine.current = { id: 'd1', url: 'http://box:4200', label: 'box' }
+ start.mockResolvedValue({ agentId: 'r3' })
+ const onAgentStarted = vi.fn()
+ render( )
+ await waitFor(() => expect(onCommands).toHaveBeenCalled())
+ fireEvent.click(screen.getByText('submit-typed'))
+ await waitFor(() => expect(start).toHaveBeenCalled())
+ expect(start.mock.calls[0]![2]).not.toHaveProperty('machine')
+ expect(onAgentStarted).toHaveBeenCalledWith('do the thing', 'r3', undefined)
+ })
+
test('what would stop the run is said before the Start, for the coding agent picked; a warning is said too, and neither turns Start off', async () => {
onCommands.mockResolvedValue({ commands: [], startHook: true, gitHost: true })
prefs.current = { driver: 'codex' }
@@ -356,15 +369,15 @@ describe('StartAgentForm (#1774)', () => {
expect(screen.getByTestId('above').textContent).toBe('gemstack')
})
- test('with a device picked there is no "start from" chip, and the Start names no branch of this machine', async () => {
+ test('with a machine picked there is no "start from" chip, and the Start names no branch of this machine', async () => {
onCommands.mockResolvedValue(WITH_BRANCHES)
- device.current = { id: 'd1', url: 'http://box:4200', token: 't', label: 'box' }
+ machine.current = { id: 'd1', url: 'http://box:4200', label: 'box' }
start.mockResolvedValue({ agentId: 'r2' })
prefs.current = { startFrom: { p1: 'local' } }
render( )
await waitFor(() => expect(onCommands).toHaveBeenCalled())
fireEvent.click(screen.getByText('submit-typed'))
- await waitFor(() => expect(start).toHaveBeenCalledWith('p1', 'do the thing', { remote: { url: 'http://box:4200', token: 't', label: 'box' } }))
+ await waitFor(() => expect(start).toHaveBeenCalledWith('p1', 'do the thing', { machine: 'd1' }))
expect(startFromChip()).toBeNull()
})
diff --git a/packages/openagent/dashboard/components/StartAgentForm.tsx b/packages/openagent/dashboard/components/StartAgentForm.tsx
index a73657a61..1f2feb86f 100644
--- a/packages/openagent/dashboard/components/StartAgentForm.tsx
+++ b/packages/openagent/dashboard/components/StartAgentForm.tsx
@@ -4,8 +4,8 @@ import { onProjects, onStartCheck } from '../rpc/projects.js'
import type { ProjectSummary } from '../../src/index.js'
import { usePreferences, usePreferencesLoaded, updatePreferences } from '../lib/preferences.js'
import { offeredPublishPicks, publishPickIn } from '../../src/client.js'
-import { useConnectionProfiles } from '../lib/profiles.js'
-import { useSelectedRemoteDeviceId } from '../lib/remote-target.js'
+import { useMachines } from '../lib/machines.js'
+import { useSelectedMachineId } from '../lib/remote-target.js'
import { cleanupPick, offersPostMergeCleanup, startPicks, useStartAgent } from '../lib/use-start-agent.js'
import { readyCommands, useProjectLauncher } from '../lib/use-project-launcher.js'
import { useLoaded } from '../lib/use-async.js'
@@ -35,8 +35,8 @@ const STARTING = 'Starting session'
// "My local branch", the branch the project's folder is on, as committed on this machine. The pick
// is saved per project. The local pick hands the start hook the branch's name as `BASE`; the main
// branch hands it none. The chip is drawn only where the pick is obeyed: the daemon names the two
-// branches only when the project's start line passes `BASE` on, and a device has its own branches,
-// so with a device picked there is no chip and the Start names no branch.
+// branches only when the project's start line passes `BASE` on, and a machine has its own branches,
+// so with a machine picked there is no chip and the Start names no branch.
// The "Auto" menu, under the box at the left (the model menu is at the right): what the agent
// does by itself when it finishes. Its button reads the picks, so nothing is hidden.
// In it, how far the run takes its work: Nothing, Commit, Publish branch, Open PR, Merge on green.
@@ -60,7 +60,7 @@ export function StartAgentForm({
projectId: string
/** The project's name, for its chip above the box; absent until the projects are read. */
projectName?: string | null | undefined
- /** `runsOn` names the device a remote agent executes on (#1067), for the "runs on " marker. */
+ /** `runsOn` names the machine a remote agent executes on (#1067), for the "runs on " marker. */
onAgentStarted?: ((intent: string, agentId: string, runsOn?: string) => void) | undefined
/** The project's files for the `#` picker (#504), owned by the shell. */
files: string[]
@@ -79,26 +79,29 @@ export function StartAgentForm({
const preferences = usePreferences()
const launcher = useProjectLauncher(projectId)
- // The device this run targets (#1067), if one is picked in "Run on". Its token is a per-browser
- // secret, so it rides the start as memory-only `options.remote` and is never persisted. A device
- // runs its own project's start hook, so this project's lack of one does not block it.
- const profiles = useConnectionProfiles()
- const selectedDeviceId = useSelectedRemoteDeviceId()
- const remoteDevice = selectedDeviceId ? profiles.find(p => p.id === selectedDeviceId) : undefined
- const noStartHook = launcher !== null && !launcher.startHook && !remoteDevice
- // A device starts the run in its own project, whose commands this launcher does not read. A command
+ // The machine this run targets (#1067), if one is picked in "Run on": the start names it by its
+ // id, and the daemon, which holds its key, sends the run there. A project with no repository
+ // address has no name another machine knows it by, so a pick made on another project's launcher
+ // does not hold here; until the launcher is read that is not known, and the pick holds. A
+ // machine runs the start hook of its own copy of the project, so this copy's lack of one does
+ // not block it.
+ const machines = useMachines()
+ const selectedMachineId = useSelectedMachineId()
+ const machine = selectedMachineId && (launcher === null || launcher.address !== undefined) ? machines.find(m => m.id === selectedMachineId) : undefined
+ const noStartHook = launcher !== null && !launcher.startHook && !machine
+ // A machine starts the run in its own project, whose commands this launcher does not read. A command
// whose skill has yet to reach the branch the agent starts from is not one the run can follow with.
- const commands = remoteDevice ? [] : readyCommands(launcher?.commands ?? [])
+ const commands = machine ? [] : readyCommands(launcher?.commands ?? [])
const offersCleanup = offersPostMergeCleanup(commands)
- // Whether a pull request can be opened here: unknown until the launcher is read, and a device's
+ // Whether a pull request can be opened here: unknown until the launcher is read, and a machine's
// own project is not read at all, so both are offered every pick; the daemon that starts the run
// holds a pull request pick to the branch where its project has no git host.
- const gitHost = remoteDevice ? true : (launcher?.gitHost ?? true)
+ const gitHost = machine ? true : (launcher?.gitHost ?? true)
// A project with no remote can publish nothing: its picks stop at the commit.
- const remote = remoteDevice ? true : (launcher?.remote ?? true)
+ const remote = machine ? true : (launcher?.remote ?? true)
const publishPick = publishPickIn(preferences.publish, gitHost, remote)
// The two branches the agent can start from, where the pick is offered at all.
- const startFrom = remoteDevice ? undefined : launcher?.startFrom
+ const startFrom = machine ? undefined : launcher?.startFrom
const startFromPick: StartFrom = preferences.startFrom?.[projectId] ?? 'main'
const preferencesLoaded = usePreferencesLoaded()
// The whole map is written: a save names keys, and this is one key. The main branch is the
@@ -108,10 +111,10 @@ export function StartAgentForm({
updatePreferences({ startFrom: pick === 'local' ? { ...others, [projectId]: 'local' } : others })
}
- // Re-read when the pick changes: `claude` being logged in says nothing about `codex`. A device
- // runs on its own machine, so this one's CLIs say nothing about it.
+ // Re-read when the pick changes: `claude` being logged in says nothing about `codex`. A run on
+ // another machine uses that machine's CLIs, so this one's say nothing about it.
const driver = preferences.driver
- const readiness = useLoaded(remoteDevice ? null : () => onStartCheck(projectId, driver), null, [projectId, driver, remoteDevice === undefined])
+ const readiness = useLoaded(machine ? null : () => onStartCheck(projectId, driver), null, [projectId, driver, machine === undefined])
// The Context mixes whole projects (registered paths) and single files (relative paths): the
// files are listed to be removed, and each kind is counted in the picker's summary. The current
@@ -135,14 +138,14 @@ export function StartAgentForm({
...startPicks(preferences),
...cleanupPick(preferences, commands),
...(startFrom && startFromPick === 'local' ? { base: startFrom.local } : {}),
- ...(remoteDevice ? { remote: { url: remoteDevice.url, token: remoteDevice.token, label: remoteDevice.label } } : {}),
+ ...(machine ? { machine: machine.id } : {}),
})
setNote(null)
if (result) {
// Show the run in the Runs rail immediately (#405): its tool writes the run's card a beat
// later, so seed an optimistic row with the typed prompt until the real one takes over.
- // A remote agent (#1067) carries the device label so the view can mark where it executes.
- onAgentStarted?.(text, result.agentId, remoteDevice?.label) // select the run we just started (#761)
+ // A remote agent (#1067) carries the machine label so the view can mark where it executes.
+ onAgentStarted?.(text, result.agentId, machine?.label) // select the run we just started (#761)
composerRef.current?.clear()
}
}
diff --git a/packages/openagent/dashboard/lib/connection.test.ts b/packages/openagent/dashboard/lib/connection.test.ts
new file mode 100644
index 000000000..ffbe9b184
--- /dev/null
+++ b/packages/openagent/dashboard/lib/connection.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, test } from 'vitest'
+import { currentConnection } from './connection.js'
+
+describe('which daemon the dashboard is on (#1052)', () => {
+ test('a loopback address is this machine, any other is that machine by its address', () => {
+ expect(currentConnection('localhost:4200', 'localhost')).toEqual({ label: 'Local', isLocal: true })
+ expect(currentConnection('127.0.0.1:4200', '127.0.0.1')).toEqual({ label: 'Local', isLocal: true })
+ expect(currentConnection('192.168.1.5:4200', '192.168.1.5')).toEqual({ label: '192.168.1.5:4200', isLocal: false })
+ })
+})
diff --git a/packages/openagent/dashboard/lib/connection.ts b/packages/openagent/dashboard/lib/connection.ts
new file mode 100644
index 000000000..50a72a4ce
--- /dev/null
+++ b/packages/openagent/dashboard/lib/connection.ts
@@ -0,0 +1,19 @@
+import { isLoopbackHost } from '../../src/client.js'
+
+// Which daemon the dashboard is talking to. The page is served by its daemon and every transport
+// is same-origin, so the browser's address IS the connection: a loopback address is this
+// machine's own daemon, any other is another machine's, opened by its address.
+
+/** This machine's own daemon, on the default port (daemon.ts). */
+const LOCAL_ORIGIN = 'http://127.0.0.1:4200'
+
+/** Navigate back to this machine's own loopback daemon. */
+export function connectLocal(): void {
+ globalThis.location?.assign(LOCAL_ORIGIN)
+}
+
+/** Which daemon the dashboard is on, for the connected indicator: this machine's own on a
+ * loopback address, else the other machine by its address's host. */
+export function currentConnection(host: string, hostname: string): { label: string; isLocal: boolean } {
+ return isLoopbackHost(hostname) ? { label: 'Local', isLocal: true } : { label: host, isLocal: false }
+}
diff --git a/packages/openagent/dashboard/lib/draft-handoff.test.ts b/packages/openagent/dashboard/lib/draft-handoff.test.ts
index 87e07d086..44120f59c 100644
--- a/packages/openagent/dashboard/lib/draft-handoff.test.ts
+++ b/packages/openagent/dashboard/lib/draft-handoff.test.ts
@@ -1,32 +1,17 @@
import { afterEach, describe, expect, test } from 'vitest'
-import { stashDraftFromUrl, takePendingDraft } from './draft-handoff.js'
+import { stashPendingDraft, takePendingDraft } from './draft-handoff.js'
-// jsdom provides sessionStorage, window.location and history.replaceState, so these round-trip real.
-afterEach(() => {
- sessionStorage.clear()
- history.replaceState(null, '', '/')
-})
-
-describe('draft-handoff (#1066)', () => {
- test('stashDraftFromUrl moves ?draft= into sessionStorage and strips it from the URL', () => {
- history.replaceState(null, '', '/?draft=' + encodeURIComponent('ship the thing') + '&keep=1')
- stashDraftFromUrl()
- expect(sessionStorage.getItem('oa.pending-draft')).toBe('ship the thing')
- // The prompt leaves the address bar (and so history + Referer); other params stay.
- expect(window.location.search).toBe('?keep=1')
- })
+// jsdom provides sessionStorage, so these round-trip real.
+afterEach(() => sessionStorage.clear())
+describe('a draft carried across a navigation (#1139)', () => {
test('takePendingDraft returns then clears the stash', () => {
- history.replaceState(null, '', '/?draft=hello')
- stashDraftFromUrl()
- expect(takePendingDraft()).toBe('hello')
+ stashPendingDraft('ship the thing')
+ expect(takePendingDraft()).toBe('ship the thing')
expect(takePendingDraft()).toBeNull() // cleared, so a reload does not re-seed it
})
- test('no draft param is a no-op that leaves the URL alone', () => {
- history.replaceState(null, '', '/?other=1')
- stashDraftFromUrl()
+ test('with nothing carried there is nothing to take', () => {
expect(takePendingDraft()).toBeNull()
- expect(window.location.search).toBe('?other=1')
})
})
diff --git a/packages/openagent/dashboard/lib/draft-handoff.ts b/packages/openagent/dashboard/lib/draft-handoff.ts
index cc9194293..21aa127d9 100644
--- a/packages/openagent/dashboard/lib/draft-handoff.ts
+++ b/packages/openagent/dashboard/lib/draft-handoff.ts
@@ -1,9 +1,8 @@
-// The composer draft carried across a device hop (#1066). connectTo() appends `?draft=` alongside
-// the token; the shared-token bootstrap 302 (#1051) strips only `?token=`, so the draft lands on the remote SPA as
-// `/?draft=…`. This moves it out of the URL into sessionStorage at boot so the typed prompt never
-// sits in the address bar, history, or a Referer header, and the launcher rehydrates from it once.
+// The composer draft carried across a navigation inside the app, held in sessionStorage so the
+// typed prompt never sits in the address bar, history, or a Referer header. The launcher
+// rehydrates from it once.
-/** sessionStorage key holding a draft carried from another device, until the launcher takes it. */
+/** sessionStorage key holding a carried draft, until the launcher takes it. */
const PENDING_DRAFT_KEY = 'oa.pending-draft'
/** sessionStorage, or undefined wherever this module is loaded without a browser behind it. */
@@ -15,26 +14,13 @@ function session(): Storage | undefined {
}
}
-/** At SPA boot: move `?draft=` out of the URL into sessionStorage and strip it from the address bar
- * (and so from history and any Referer). Idempotent and a no-op when there is no draft param. */
-export function stashDraftFromUrl(): void {
- if (typeof window === 'undefined') return
- const url = new URL(window.location.href)
- const draft = url.searchParams.get('draft')
- if (draft === null) return
- session()?.setItem(PENDING_DRAFT_KEY, draft)
- url.searchParams.delete('draft')
- window.history.replaceState(null, '', url.pathname + url.search + url.hash)
-}
-
/**
- * Carry a draft across an in-app navigation (#1139): the same stash the device hop uses, written
- * directly rather than through the URL. For a click that knows what the next session should be
- * about but lands on the launcher, where that knowledge would otherwise be dropped — a hot ticket
- * with no run of its own.
+ * Carry a draft across an in-app navigation (#1139). For a click that knows what the next session
+ * should be about but lands on the launcher, where that knowledge would otherwise be dropped — a
+ * hot ticket with no run of its own.
*
- * Deliberately not a `?draft=` param like the device hop: this navigation never leaves the tab, so
- * there is nothing to hand another device and no reason to put the prompt in the address bar.
+ * Deliberately not a `?draft=` param: this navigation never leaves the tab, so there is no reason
+ * to put the prompt in the address bar.
*/
export function stashPendingDraft(draft: string): void {
session()?.setItem(PENDING_DRAFT_KEY, draft)
diff --git a/packages/openagent/dashboard/lib/machines.test.ts b/packages/openagent/dashboard/lib/machines.test.ts
new file mode 100644
index 000000000..3bc1ababb
--- /dev/null
+++ b/packages/openagent/dashboard/lib/machines.test.ts
@@ -0,0 +1,93 @@
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
+import { createElement } from 'react'
+
+vi.mock('../rpc/machines.js', async () => (await import('../test-machines.js')).machinesRpc)
+
+import { daemon, machinesRpc, saveMachines } from '../test-machines.js'
+import { addMachine, removeMachine, useMachines, useMachinesLoaded } from './machines.js'
+
+const STUDIO = { id: 'http://192.168.1.5:4200', label: 'Studio', url: 'http://192.168.1.5:4200' }
+
+afterEach(async () => {
+ cleanup()
+ localStorage.clear()
+ await saveMachines()
+})
+
+function Known() {
+ const loaded = useMachinesLoaded()
+ const machines = useMachines()
+ return createElement('p', null, loaded ? machines.map(machine => machine.label).join(', ') || 'none' : 'not read yet')
+}
+
+function List() {
+ return createElement('p', null, useMachines().map(machine => machine.label).join(', ') || 'none')
+}
+
+describe('the saved machines', () => {
+ // First in the file on purpose: it needs a page that has not read the list yet.
+ test('are the daemon\'s list, read when a page first needs them; a first read that fails is tried again by the next page, and until an answer "none saved" is not known', async () => {
+ daemon.machines = [STUDIO]
+ machinesRpc.onMachines.mockRejectedValueOnce(new Error('the daemon is starting'))
+ const first = render(createElement(Known))
+ await waitFor(() => expect(machinesRpc.onMachines).toHaveBeenCalledTimes(1))
+ await act(async () => {})
+ expect(screen.getByText('not read yet')).toBeTruthy()
+ first.unmount()
+ render(createElement(Known))
+ await waitFor(() => expect(screen.getByText('Studio')).toBeTruthy())
+ })
+
+ test('a machine is saved by the daemon, and the page then shows it; nothing is kept in the browser', async () => {
+ render(createElement(List))
+ let result: Awaited> | undefined
+ await act(async () => {
+ result = await addMachine('http://box.tail.ts:4200/?token=bbb', 'Box')
+ })
+ expect(result).toEqual({ ok: true, machine: { id: 'http://box.tail.ts:4200', label: 'Box', url: 'http://box.tail.ts:4200' } })
+ expect(machinesRpc.sendAddMachine).toHaveBeenCalledWith('http://box.tail.ts:4200/?token=bbb', 'Box')
+ expect(screen.getByText('Box')).toBeTruthy()
+ expect(localStorage.length).toBe(0)
+ expect(JSON.stringify(result)).not.toContain('bbb')
+ })
+
+ test('a paste the daemon refuses changes nothing', async () => {
+ await saveMachines(STUDIO)
+ render(createElement(List))
+ await act(async () => {
+ expect((await addMachine('http://box.tail.ts:4200/')).ok).toBe(false)
+ })
+ expect(screen.getByText('Studio')).toBeTruthy()
+ })
+
+ test('a removed machine leaves the page', async () => {
+ await saveMachines(STUDIO)
+ render(createElement(List))
+ expect(screen.getByText('Studio')).toBeTruthy()
+ await act(async () => {
+ expect(await removeMachine(STUDIO.id)).toBe(true)
+ })
+ expect(screen.getByText('none')).toBeTruthy()
+ })
+
+ test('a remove the daemon could not do is answered as not done, without throwing, and the machine is still shown', async () => {
+ await saveMachines(STUDIO)
+ render(createElement(List))
+ machinesRpc.sendRemoveMachine.mockRejectedValueOnce(new Error('the daemon is gone'))
+ await act(async () => {
+ expect(await removeMachine(STUDIO.id)).toBe(false)
+ })
+ expect(screen.getByText('Studio')).toBeTruthy()
+ })
+
+ test('the list is read again when the window is looked at again: another browser may have changed it', async () => {
+ render(createElement(List))
+ await waitFor(() => expect(machinesRpc.onMachines).toHaveBeenCalled())
+ daemon.machines = [STUDIO]
+ await act(async () => {
+ window.dispatchEvent(new Event('focus'))
+ })
+ await waitFor(() => expect(screen.getByText('Studio')).toBeTruthy())
+ })
+})
diff --git a/packages/openagent/dashboard/lib/machines.ts b/packages/openagent/dashboard/lib/machines.ts
new file mode 100644
index 000000000..d14a00b90
--- /dev/null
+++ b/packages/openagent/dashboard/lib/machines.ts
@@ -0,0 +1,77 @@
+import { useEffect, useSyncExternalStore } from 'react'
+import { onMachines, sendAddMachine, sendRemoveMachine, type AddMachineResult, type Machine } from '../rpc/machines.js'
+
+// The saved machines: the other computers running OpenAgent that a run can be sent to. The list
+// is the daemon's, kept in the person's home file, so every browser on this computer shows the
+// same one, and it is read again when the window is looked at again. A machine's key never
+// reaches the browser: the daemon holds it and makes the calls.
+
+export type { Machine }
+
+const EMPTY: Machine[] = []
+let cache: Machine[] | null = null
+/** Counts the reads sent, so an answer that a later read has overtaken is dropped. */
+let reads = 0
+/** The reads still out, so a page that mounts meanwhile does not send another. */
+let reading = 0
+let watching = false
+const listeners = new Set<() => void>()
+
+function subscribe(listener: () => void): () => void {
+ listeners.add(listener)
+ return () => listeners.delete(listener)
+}
+
+/** Read the list from the daemon again. A read that fails keeps what is shown. */
+export function refreshMachines(): Promise {
+ const read = ++reads
+ reading++
+ return onMachines()
+ .then(
+ machines => {
+ if (read !== reads) return
+ cache = machines
+ for (const listener of listeners) listener()
+ },
+ () => {},
+ )
+ .finally(() => reading--)
+}
+
+function ensureLoaded(): void {
+ // Not read yet, or the read failed: the next page that needs the list asks again.
+ if (cache === null && reading === 0) void refreshMachines()
+ if (watching || typeof window === 'undefined') return
+ watching = true
+ // Another browser on this computer may have saved or removed a machine since.
+ window.addEventListener('focus', () => void refreshMachines())
+}
+
+/** Save the machine a pasted address names; the list is read again once the daemon has it. */
+export async function addMachine(pasted: string, label?: string): Promise {
+ const result = await sendAddMachine(pasted, label)
+ if (result.ok) await refreshMachines()
+ return result
+}
+
+/**
+ * Take a saved machine off the list, and answer whether the daemon did. The list is read again
+ * either way, so a machine the daemon could not remove is still shown.
+ */
+export async function removeMachine(id: string): Promise {
+ const removed = await sendRemoveMachine(id).then(() => true, () => false)
+ await refreshMachines()
+ return removed
+}
+
+/** The saved machines as reactive state: empty until the daemon has answered. */
+export function useMachines(): Machine[] {
+ const machines = useSyncExternalStore(subscribe, () => cache ?? EMPTY, () => EMPTY)
+ useEffect(ensureLoaded, [])
+ return machines
+}
+
+/** Whether the daemon has answered with the list: until then "none saved" is not known. */
+export function useMachinesLoaded(): boolean {
+ return useSyncExternalStore(subscribe, () => cache !== null, () => false)
+}
diff --git a/packages/openagent/dashboard/lib/profiles.test.ts b/packages/openagent/dashboard/lib/profiles.test.ts
deleted file mode 100644
index fd6887174..000000000
--- a/packages/openagent/dashboard/lib/profiles.test.ts
+++ /dev/null
@@ -1,96 +0,0 @@
-import { afterEach, describe, expect, test, vi } from 'vitest'
-import {
- listProfiles,
- addProfile,
- removeProfile,
- parseDeviceUrl,
- connectUrl,
- connectTo,
- currentConnection,
- localOrigin,
- rememberLocalOrigin,
-} from './profiles.js'
-
-// jsdom provides a real localStorage, so these round-trip through it directly.
-afterEach(() => localStorage.clear())
-
-describe('profiles.ts (#1052)', () => {
- test('add / list / remove round-trips through localStorage', () => {
- expect(listProfiles()).toEqual([])
- const a = addProfile({ url: 'http://192.168.1.5:4200', token: 'aaa', label: 'Studio' })
- const b = addProfile({ url: 'http://box.tail.ts:4200', token: 'bbb' })
- expect(listProfiles().map(p => p.label)).toEqual(['box.tail.ts:4200', 'Studio']) // newest first
- expect(b.label).toBe('box.tail.ts:4200') // falls back to the host
- removeProfile(a.id)
- expect(listProfiles().map(p => p.id)).toEqual([b.id])
- })
-
- test('re-adding the same origin refreshes rather than duplicates', () => {
- addProfile({ url: 'http://192.168.1.5:4200', token: 'old', label: 'Studio' })
- addProfile({ url: 'http://192.168.1.5:4200', token: 'new' })
- const list = listProfiles()
- expect(list).toHaveLength(1)
- expect(list[0]!.token).toBe('new')
- })
-
- test('profiles survive a reload (a fresh read of localStorage)', () => {
- addProfile({ url: 'http://192.168.1.5:4200', token: 'aaa' })
- // Nothing cached across a real navigation — read straight from storage.
- const reread = JSON.parse(localStorage.getItem('oa.devices')!)
- expect(reread[0].token).toBe('aaa')
- })
-
- test('parseDeviceUrl pulls the origin and token out of a pasted URL', () => {
- expect(parseDeviceUrl('http://192.168.1.5:4200/?token=abc123')).toEqual({ url: 'http://192.168.1.5:4200', token: 'abc123' })
- expect(parseDeviceUrl(' http://box:4200/some/path?token=xyz ')).toEqual({ url: 'http://box:4200', token: 'xyz' })
- expect(parseDeviceUrl('http://box:4200')).toEqual({ url: 'http://box:4200', token: '' })
- expect(parseDeviceUrl('not a url')).toBeNull()
- // A scheme-less paste parses with `localhost:` as the scheme and a `null` origin — reject it
- // rather than storing an unusable device (and crashing the dialog's host label).
- expect(parseDeviceUrl('localhost:4200/?token=abc')).toBeNull()
- expect(parseDeviceUrl('ftp://box:4200/?token=abc')).toBeNull()
- })
-
- test('connectUrl carries the token for the bootstrap hop', () => {
- expect(connectUrl({ url: 'http://box:4200', token: 'abc' })).toBe('http://box:4200/?token=abc')
- expect(connectUrl({ url: 'http://127.0.0.1:4200', token: '' })).toBe('http://127.0.0.1:4200') // Local, no token
- })
-
- test('connectUrl carries the composer draft alongside the token (#1066)', () => {
- expect(connectUrl({ url: 'http://box:4200', token: 'abc' }, 'ship it')).toBe('http://box:4200/?token=abc&draft=ship%20it')
- // No token (Local): the draft still rides.
- expect(connectUrl({ url: 'http://127.0.0.1:4200', token: '' }, 'hi')).toBe('http://127.0.0.1:4200/?draft=hi')
- // An oversize draft is dropped so it can't blow the URL length; the hop still connects.
- expect(connectUrl({ url: 'http://box:4200', token: 'abc' }, 'x'.repeat(8000))).toBe('http://box:4200/?token=abc')
- // The cap measures the percent-encoded form: multibyte text encodes ~9x longer, and the
- // encoded length is what the destination daemon's header budget sees.
- expect(connectUrl({ url: 'http://box:4200', token: 'abc' }, '中'.repeat(1000))).toBe('http://box:4200/?token=abc')
- })
-
- test('connectTo navigates carrying the token and the draft (#1066)', () => {
- // jsdom won't let location.assign be redefined, so stub the whole location the code reads.
- const assign = vi.fn()
- vi.stubGlobal('location', { assign })
- connectTo({ id: 'x', label: 'Studio', url: 'http://box:4200', token: 'abc' }, 'my prompt')
- expect(assign).toHaveBeenCalledTimes(1)
- const url = assign.mock.calls[0]![0] as string
- expect(url).toContain('token=abc')
- expect(url).toContain('draft=' + encodeURIComponent('my prompt'))
- vi.unstubAllGlobals()
- })
-
- test('currentConnection labels the daemon the browser is on', () => {
- const profiles = [{ id: 'http://192.168.1.5:4200', label: 'Studio', url: 'http://192.168.1.5:4200', token: 'aaa' }]
- expect(currentConnection(profiles, 'http://127.0.0.1:4200', '127.0.0.1')).toEqual({ label: 'Local', isLocal: true })
- expect(currentConnection(profiles, 'http://192.168.1.5:4200', '192.168.1.5')).toEqual({ label: 'Studio', isLocal: false })
- expect(currentConnection(profiles, 'http://10.0.0.9:4200', '10.0.0.9')).toEqual({ label: '10.0.0.9', isLocal: false }) // unsaved
- })
-
- test('localOrigin returns the remembered loopback origin, else the default', () => {
- expect(localOrigin()).toBe('http://127.0.0.1:4200')
- rememberLocalOrigin('http://localhost:9999', 'localhost')
- expect(localOrigin()).toBe('http://localhost:9999')
- rememberLocalOrigin('http://192.168.1.5:4200', '192.168.1.5') // ignored off loopback
- expect(localOrigin()).toBe('http://localhost:9999')
- })
-})
diff --git a/packages/openagent/dashboard/lib/profiles.ts b/packages/openagent/dashboard/lib/profiles.ts
deleted file mode 100644
index 257905d0f..000000000
--- a/packages/openagent/dashboard/lib/profiles.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { useSyncExternalStore } from 'react'
-import { isLoopbackHost } from '../../src/client.js'
-
-// Client connection profiles (#1052): the saved daemons this browser can hop to. "A device I have"
-// is a CONNECTION, not an agent driver — the SPA is served by its daemon and every transport is
-// same-origin, so switching devices means navigating the browser to that daemon's origin, where the
-// #1051 bootstrap sets the oa_daemon cookie from `?token=` and everything is same-origin again.
-//
-// Storage is client-side localStorage on purpose: the token is a per-browser secret, so it must
-// never reach the daemon's registry file (the wrong home, shared across browsers). Node-free leaf
-// like agent-names.ts / preference-defaults.ts, so nothing node leaks into the SPA bundle.
-
-/** A saved daemon this browser can connect to. `token` is empty only for the loopback Local one. */
-export type ConnectionProfile = { id: string; label: string; url: string; token: string }
-
-/** localStorage key for the saved remote devices (an array of {@link ConnectionProfile}). */
-const DEVICES_KEY = 'oa.devices'
-
-/** localStorage key remembering the loopback origin the dashboard was launched from (#1052), so
- * "Local" returns to the right port even from a remote box. */
-const LOCAL_ORIGIN_KEY = 'oa.local-origin'
-
-/** Fallback loopback origin when none was remembered — the default daemon port (daemon.ts). */
-const DEFAULT_LOCAL_ORIGIN = 'http://127.0.0.1:4200'
-
-/** localStorage, or undefined wherever this module is loaded without a browser behind it. */
-function store(): Storage | undefined {
- try {
- return globalThis.localStorage
- } catch {
- return undefined
- }
-}
-
-function isProfile(value: unknown): value is ConnectionProfile {
- const p = value as Partial | null
- return !!p && typeof p.id === 'string' && typeof p.label === 'string' && typeof p.url === 'string' && typeof p.token === 'string'
-}
-
-/** The saved remote devices, newest first, filtered to well-formed entries. */
-export function listProfiles(): ConnectionProfile[] {
- try {
- const raw = store()?.getItem(DEVICES_KEY)
- if (!raw) return []
- const parsed: unknown = JSON.parse(raw)
- return Array.isArray(parsed) ? parsed.filter(isProfile) : []
- } catch {
- return []
- }
-}
-
-function writeProfiles(list: ConnectionProfile[]): void {
- store()?.setItem(DEVICES_KEY, JSON.stringify(list))
- notify()
-}
-
-/** Save (or update, keyed by origin) a device. Returns the stored profile. A repeat paste of the
- * same box refreshes its token rather than stacking a duplicate. */
-export function addProfile(input: { url: string; token: string; label?: string }): ConnectionProfile {
- const profile: ConnectionProfile = {
- id: input.url,
- label: input.label?.trim() || hostLabel(input.url),
- url: input.url,
- token: input.token,
- }
- writeProfiles([profile, ...listProfiles().filter(p => p.id !== profile.id)])
- return profile
-}
-
-/** Drop a saved device by id. */
-export function removeProfile(id: string): void {
- writeProfiles(listProfiles().filter(p => p.id !== id))
-}
-
-/** The origin's host as a fallback label (e.g. `192.168.1.5:4200`) when none was given. */
-function hostLabel(url: string): string {
- try {
- return new URL(url).host
- } catch {
- return url
- }
-}
-
-/**
- * Pull the origin and token out of the full `http://host:port/?token=…` URL the box prints on its
- * non-loopback bind (cli.ts). Returns null if the paste is not a URL. The url is normalized to the
- * bare origin so it matches `window.location.origin` for the connected indicator.
- */
-export function parseDeviceUrl(pasted: string): { url: string; token: string } | null {
- try {
- const u = new URL(pasted.trim())
- // A scheme-less paste like `localhost:4200/?token=…` parses with `localhost:` as the scheme
- // and an opaque `null` origin — not a device address.
- if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
- return { url: u.origin, token: u.searchParams.get('token') ?? '' }
- } catch {
- return null
- }
-}
-
-/** Remember the loopback origin the dashboard is served from, so "Local" can return to the right
- * port from a remote box. A no-op off loopback. */
-export function rememberLocalOrigin(origin: string, host: string): void {
- if (isLoopbackHost(host)) store()?.setItem(LOCAL_ORIGIN_KEY, origin)
-}
-
-/** The loopback origin to send "Local" to: the remembered launch origin, else the default port. */
-export function localOrigin(): string {
- return store()?.getItem(LOCAL_ORIGIN_KEY) ?? DEFAULT_LOCAL_ORIGIN
-}
-
-/** Cap on a carried composer draft (#1066), measured on the percent-encoded form — the thing whose
- * length the URL actually spends (multibyte text encodes up to 9x longer). Above this the hop drops
- * the draft (plain connect) so a huge paste can't blow the URL length. */
-const MAX_CARRIED_DRAFT = 7000
-
-/** The connect URL for a device: its origin plus the token for the one bootstrap hop (#1051), and
- * the composer draft (#1066) so a device hop never nukes the typed prompt. The bootstrap 302 strips
- * only `token`, so `draft` survives to land on the remote SPA. */
-export function connectUrl(profile: Pick, draft?: string): string {
- const parts: string[] = []
- if (profile.token) parts.push(`token=${encodeURIComponent(profile.token)}`)
- const encodedDraft = draft ? encodeURIComponent(draft) : ''
- if (encodedDraft && encodedDraft.length <= MAX_CARRIED_DRAFT) parts.push(`draft=${encodedDraft}`)
- return parts.length ? `${profile.url}/?${parts.join('&')}` : profile.url
-}
-
-/** Navigate the browser to a saved device, carrying its token and the live composer draft (#1066).
- * This is the connection hop, NOT an agent submit: the remote SPA re-authenticates same-origin from the
- * cookie the bootstrap sets, and rehydrates the draft into its composer. */
-export function connectTo(profile: ConnectionProfile, draft?: string): void {
- globalThis.location?.assign(connectUrl(profile, draft))
-}
-
-/** Navigate back to this machine's own loopback daemon (no token). */
-export function connectLocal(): void {
- globalThis.location?.assign(localOrigin())
-}
-
-/** Which daemon the dashboard is currently talking to, for the connected indicator. Loopback = the
- * local machine; else the matching saved device's label, or the bare host if it is unsaved. */
-export function currentConnection(profiles: ConnectionProfile[], origin: string, host: string): { label: string; isLocal: boolean } {
- if (isLoopbackHost(host)) return { label: 'Local', isLocal: true }
- return { label: profiles.find(p => p.url === origin)?.label ?? host, isLocal: false }
-}
-
-// A tiny store so the gear and the connected indicator re-render when devices change. localStorage
-// has no in-tab change event, so writes notify explicitly; the snapshot is cached until then
-// (useSyncExternalStore compares by identity, so re-reading fresh each call would loop).
-let snapshotCache: ConnectionProfile[] | null = null
-const listeners = new Set<() => void>()
-
-function notify(): void {
- snapshotCache = null
- for (const listener of listeners) listener()
-}
-
-function subscribe(listener: () => void): () => void {
- listeners.add(listener)
- return () => listeners.delete(listener)
-}
-
-function getSnapshot(): ConnectionProfile[] {
- return (snapshotCache ??= listProfiles())
-}
-
-/** The saved devices as reactive state. Prerender has no localStorage, so it renders the empty list
- * and the real one loads on the client. */
-export function useConnectionProfiles(): ConnectionProfile[] {
- return useSyncExternalStore(subscribe, getSnapshot, () => EMPTY)
-}
-
-const EMPTY: ConnectionProfile[] = []
diff --git a/packages/openagent/dashboard/lib/remote-target.ts b/packages/openagent/dashboard/lib/remote-target.ts
index 24f3047be..fcb85b7ec 100644
--- a/packages/openagent/dashboard/lib/remote-target.ts
+++ b/packages/openagent/dashboard/lib/remote-target.ts
@@ -1,24 +1,23 @@
import { useSyncExternalStore } from 'react'
-// The saved device selected as this browser's run target (#1067). Ephemeral, in-memory only: a
-// device's token is a per-browser secret, so which device a run goes to is transient UI state, never
-// a persisted preference. Null means this machine: the run starts here, through this project's own
-// start hook. Node-free leaf like profiles.ts, so nothing leaks into the SPA bundle. The value is
-// the device's profile id (its origin url).
+// The saved machine picked as where the next run starts (#1067). In memory only: it is a pick for
+// the runs started from this page, not a saved preference, so a reload goes back to this machine.
+// Null means this machine: the run starts here, through this project's own start hook. The value
+// is the machine's id.
-let selectedDeviceId: string | null = null
+let selectedMachineId: string | null = null
const listeners = new Set<() => void>()
-/** Select a saved device (by profile id) as the run's target, or null to go back to this machine. */
-export function selectRemoteDevice(id: string | null): void {
- if (selectedDeviceId === id) return
- selectedDeviceId = id
+/** Pick a saved machine (by id) as the run's target, or null to go back to this machine. */
+export function selectMachine(id: string | null): void {
+ if (selectedMachineId === id) return
+ selectedMachineId = id
for (const listener of listeners) listener()
}
-/** The selected device id, read at submit time to attach the run's relay target. */
-export function getSelectedRemoteDeviceId(): string | null {
- return selectedDeviceId
+/** The picked machine's id, read at submit time to say where the run starts. */
+export function getSelectedMachineId(): string | null {
+ return selectedMachineId
}
function subscribe(listener: () => void): () => void {
@@ -26,7 +25,7 @@ function subscribe(listener: () => void): () => void {
return () => listeners.delete(listener)
}
-/** The selected device id as reactive state; null on the server and until a device is picked. */
-export function useSelectedRemoteDeviceId(): string | null {
- return useSyncExternalStore(subscribe, getSelectedRemoteDeviceId, () => null)
+/** The picked machine's id as reactive state; null on the server and until one is picked. */
+export function useSelectedMachineId(): string | null {
+ return useSyncExternalStore(subscribe, getSelectedMachineId, () => null)
}
diff --git a/packages/openagent/dashboard/lib/use-device-status.test.tsx b/packages/openagent/dashboard/lib/use-device-status.test.tsx
deleted file mode 100644
index a83162959..000000000
--- a/packages/openagent/dashboard/lib/use-device-status.test.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { afterEach, describe, expect, test, vi } from 'vitest'
-import { cleanup, render, screen, waitFor } from '@testing-library/react'
-import type { ConnectionProfile } from './profiles.js'
-
-const checkDevices = vi.hoisted(() => vi.fn())
-vi.mock('../rpc/devices.js', () => ({ checkDevices }))
-
-const { useDeviceStatus } = await import('./use-device-status.js')
-
-afterEach(() => {
- cleanup()
- checkDevices.mockReset()
-})
-
-const STUDIO = 'http://192.168.1.5:4200'
-const profiles: ConnectionProfile[] = [{ id: STUDIO, label: 'Studio', url: STUDIO, token: 'aaa' }]
-
-function Probe({ list = profiles }: { list?: ConnectionProfile[] }) {
- const status = useDeviceStatus(list)
- return {status[STUDIO] ?? 'unknown'}
-}
-
-// #1072: the browser holds the tokens, so it hands the daemon each device and reads back a reachable
-// map. The dots are display-only, so this just has to surface online/offline as the poll answers.
-describe('useDeviceStatus', () => {
- test('a reachable device surfaces online', async () => {
- checkDevices.mockResolvedValue({ [STUDIO]: true })
- render( )
- await waitFor(() => expect(screen.getByText('online')).toBeTruthy())
- // It passed the device's id/url/token to the daemon for the cookie'd ping.
- expect(checkDevices).toHaveBeenCalledWith([{ id: STUDIO, url: STUDIO, token: 'aaa' }])
- })
-
- test('an unreachable device surfaces offline', async () => {
- checkDevices.mockResolvedValue({ [STUDIO]: false })
- render( )
- await waitFor(() => expect(screen.getByText('offline')).toBeTruthy())
- })
-
- test('with no saved devices it never polls', async () => {
- render( )
- await new Promise(resolve => setTimeout(resolve, 30))
- expect(checkDevices).not.toHaveBeenCalled()
- expect(screen.getByText('unknown')).toBeTruthy()
- })
-})
diff --git a/packages/openagent/dashboard/lib/use-device-status.ts b/packages/openagent/dashboard/lib/use-device-status.ts
deleted file mode 100644
index 36d59ba1d..000000000
--- a/packages/openagent/dashboard/lib/use-device-status.ts
+++ /dev/null
@@ -1,39 +0,0 @@
-import { useMemo } from 'react'
-import type { ConnectionProfile } from './profiles.js'
-import { usePolled } from './use-async.js'
-import { checkDevices } from '../rpc/devices.js'
-
-// The saved devices' online/offline status (#1072). The daemon holds no device token (they are a
-// per-browser secret, #1052), so the browser hands it each device's {id, url, token} and the daemon
-// does the cookie'd ping; this polls that on a short interval. Display-only, so nothing binds a
-// control to the poll value: the status dots just read the map.
-
-/** A saved device's reachability, or absent while the first check is still out (reads as "unknown"). */
-export type DeviceStatus = 'online' | 'offline'
-
-/** How often to re-check the saved devices. Cheap: one 3s-capped ping each, no agent involved. */
-const POLL_MS = 10_000
-
-/**
- * Poll each saved device's reachability and return an id -> status map. An id missing from the map
- * means the first check has not come back yet (draw it neutral, not offline). Prerender has no
- * daemon, so it starts empty and fills on the client.
- */
-export function useDeviceStatus(profiles: ConnectionProfile[]): Record {
- const targets = profiles.filter(p => p.token).map(p => ({ id: p.id, url: p.url, token: p.token }))
- // Re-poll when the device set OR any device's token changes, not on every render (targets is a
- // fresh array). The token is in the key because a re-paste refreshes it without changing the id
- // (the id is the URL) — keying on ids alone kept pinging with the dead token and showed offline.
- const key = targets.map(t => `${t.id} ${t.token}`).join('|')
- const load = useMemo(
- () => (targets.length ? () => checkDevices(targets) : null),
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [key],
- )
- const reachable = usePolled>(load, {}, POLL_MS, [key]).value
- return useMemo(() => {
- const out: Record = {}
- for (const [id, ok] of Object.entries(reachable)) out[id] = ok ? 'online' : 'offline'
- return out
- }, [reachable])
-}
diff --git a/packages/openagent/dashboard/lib/use-live-events.ts b/packages/openagent/dashboard/lib/use-live-events.ts
index 3c871fa49..33a2424d8 100644
--- a/packages/openagent/dashboard/lib/use-live-events.ts
+++ b/packages/openagent/dashboard/lib/use-live-events.ts
@@ -36,7 +36,7 @@ function retryDelay(attempt: number): number {
/**
* How long a reconnect waits for the server's end-of-replay marker before swapping anyway
* (#1383). The on-disk tail sends `stream-sync` the moment its replay is delivered, so this
- * deadline only fires for the in-memory sources (relay #426, relayed device runs #1067),
+ * deadline only fires for the in-memory sources (relay #426, relayed machine runs #1067),
* which have no replay boundary to report — their buffered history streams in well under it.
*/
const SYNC_GRACE_MS = 1500
diff --git a/packages/openagent/dashboard/lib/use-machine-status.test.tsx b/packages/openagent/dashboard/lib/use-machine-status.test.tsx
new file mode 100644
index 000000000..1b568d359
--- /dev/null
+++ b/packages/openagent/dashboard/lib/use-machine-status.test.tsx
@@ -0,0 +1,45 @@
+import { afterEach, describe, expect, test, vi } from 'vitest'
+import { cleanup, render, screen, waitFor } from '@testing-library/react'
+import type { Machine } from './machines.js'
+
+vi.mock('../rpc/machines.js', async () => (await import('../test-machines.js')).machinesRpc)
+
+import { daemon, machinesRpc, saveMachines } from '../test-machines.js'
+const { useMachineStatus } = await import('./use-machine-status.js')
+
+afterEach(async () => {
+ cleanup()
+ await saveMachines()
+})
+
+const STUDIO = 'http://192.168.1.5:4200'
+const machines: Machine[] = [{ id: STUDIO, label: 'Studio', url: STUDIO }]
+
+function Probe({ list = machines }: { list?: Machine[] }) {
+ const status = useMachineStatus(list)
+ return {status[STUDIO] ?? 'unknown'}
+}
+
+// #1072: the daemon holds each machine's key, so it makes the call and the page reads back a
+// reachable map. The dots are display-only, so this just has to surface online/offline as the poll answers.
+describe('useMachineStatus', () => {
+ test('a reachable machine surfaces online, and the page hands the daemon no key to ask with', async () => {
+ daemon.reachable = { [STUDIO]: true }
+ render( )
+ await waitFor(() => expect(screen.getByText('online')).toBeTruthy())
+ expect(machinesRpc.onMachinesReachable).toHaveBeenCalledWith()
+ })
+
+ test('an unreachable machine surfaces offline', async () => {
+ daemon.reachable = { [STUDIO]: false }
+ render( )
+ await waitFor(() => expect(screen.getByText('offline')).toBeTruthy())
+ })
+
+ test('with no saved machines it never polls', async () => {
+ render( )
+ await new Promise(resolve => setTimeout(resolve, 30))
+ expect(machinesRpc.onMachinesReachable).not.toHaveBeenCalled()
+ expect(screen.getByText('unknown')).toBeTruthy()
+ })
+})
diff --git a/packages/openagent/dashboard/lib/use-machine-status.ts b/packages/openagent/dashboard/lib/use-machine-status.ts
new file mode 100644
index 000000000..1c038cda7
--- /dev/null
+++ b/packages/openagent/dashboard/lib/use-machine-status.ts
@@ -0,0 +1,34 @@
+import { useMemo } from 'react'
+import type { Machine } from './machines.js'
+import { usePolled } from './use-async.js'
+import { onMachinesReachable } from '../rpc/machines.js'
+
+// The saved machines' online/offline status (#1072). The daemon holds each machine's key, so it
+// makes the call and this polls its answer on a short interval. Display-only, so nothing binds a
+// control to the poll value: the status dots just read the map.
+
+/** A saved machine's reachability, or absent while the first check is still out (reads as "unknown"). */
+export type MachineStatus = 'online' | 'offline'
+
+/** How often to re-check the saved machines. Cheap: one 3s-capped ping each, no agent involved. */
+const POLL_MS = 10_000
+
+/**
+ * Poll each saved machine's reachability and return an id -> status map. An id missing from the
+ * map means the first check has not come back yet (draw it neutral, not offline). Prerender has
+ * no daemon, so it starts empty and fills on the client.
+ */
+export function useMachineStatus(machines: Machine[]): Record {
+ // Poll again at once when the list changes, not on every render (the list is a fresh array).
+ const key = machines.map(machine => machine.id).join('|')
+ const load = useMemo(
+ () => (key ? () => onMachinesReachable() : null),
+ [key],
+ )
+ const reachable = usePolled>(load, {}, POLL_MS, [key]).value
+ return useMemo(() => {
+ const out: Record = {}
+ for (const [id, ok] of Object.entries(reachable)) out[id] = ok ? 'online' : 'offline'
+ return out
+ }, [reachable])
+}
diff --git a/packages/openagent/dashboard/rpc/devices.ts b/packages/openagent/dashboard/rpc/devices.ts
deleted file mode 100644
index 91fcb5377..000000000
--- a/packages/openagent/dashboard/rpc/devices.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type * as impl from '../../src/dashboard-rpc/devices.js'
-import { rpc } from '../lib/rpc.js'
-
-// The types the RPCs speak in, straight from the implementations — erased at build, so importing
-// them here pulls no server code into the bundle.
-export type * from '../../src/dashboard-rpc/devices.js'
-
-// Typed stubs for the `devices` RPCs (F3). Each is checked against the implementation's own
-// signature, so a rename or a changed argument is a type error here rather than a 404 at runtime.
-// These used to be re-export shims whose only job was pinning Telefunc's baked RPC keys to this
-// file's path; the transport addresses calls by name now, so the path carries no meaning.
-
-export const checkDevices = rpc('checkDevices')
diff --git a/packages/openagent/dashboard/rpc/machines.ts b/packages/openagent/dashboard/rpc/machines.ts
new file mode 100644
index 000000000..b673165dc
--- /dev/null
+++ b/packages/openagent/dashboard/rpc/machines.ts
@@ -0,0 +1,14 @@
+import type * as impl from '../../src/dashboard-rpc/machines.js'
+import { rpc } from '../lib/rpc.js'
+
+// The types the RPCs speak in, straight from the implementations — erased at build, so importing
+// them here pulls no server code into the bundle.
+export type * from '../../src/dashboard-rpc/machines.js'
+
+// Typed stubs for the `machines` RPCs (F3). Each is checked against the implementation's own
+// signature, so a rename or a changed argument is a type error here rather than a 404 at runtime.
+
+export const onMachines = rpc('onMachines')
+export const sendAddMachine = rpc('sendAddMachine')
+export const sendRemoveMachine = rpc('sendRemoveMachine')
+export const onMachinesReachable = rpc('onMachinesReachable')
diff --git a/packages/openagent/dashboard/test-machines.ts b/packages/openagent/dashboard/test-machines.ts
new file mode 100644
index 000000000..e9ef68c2e
--- /dev/null
+++ b/packages/openagent/dashboard/test-machines.ts
@@ -0,0 +1,40 @@
+import { vi } from 'vitest'
+import { parseMachineUrl } from '../src/client.js'
+import type { AddMachineResult, Machine } from './rpc/machines.js'
+
+// A daemon's saved machines, for a test: what the `machines` RPCs read and write. A test file
+// mocks the stubs with it, `vi.mock('../rpc/machines.js', async () => (await import('../test-machines.js')).machinesRpc)`,
+// and seeds it through {@link saveMachines}.
+
+/** What the pretend daemon holds: the list, each machine's key, and which machines answer. */
+export const daemon: { machines: Machine[]; tokens: Record; reachable: Record } = {
+ machines: [],
+ tokens: {},
+ reachable: {},
+}
+
+export const machinesRpc = {
+ onMachines: vi.fn(async (): Promise => [...daemon.machines]),
+ sendAddMachine: vi.fn(async (pasted: string, label?: string): Promise => {
+ const parsed = parseMachineUrl(pasted)
+ if (!parsed?.token) return { ok: false, error: 'refused' }
+ const machine: Machine = { id: parsed.url, label: label?.trim() || new URL(parsed.url).host, url: parsed.url }
+ daemon.machines = [machine, ...daemon.machines.filter(saved => saved.id !== machine.id)]
+ daemon.tokens[machine.id] = parsed.token
+ return { ok: true, machine }
+ }),
+ sendRemoveMachine: vi.fn(async (id: string): Promise => {
+ daemon.machines = daemon.machines.filter(saved => saved.id !== id)
+ }),
+ onMachinesReachable: vi.fn(async (): Promise> => ({ ...daemon.reachable })),
+}
+
+/** Set what the pretend daemon has saved, and have the page read it. Call with none to empty it. */
+export async function saveMachines(...machines: Machine[]): Promise {
+ daemon.machines = machines
+ daemon.tokens = {}
+ daemon.reachable = {}
+ for (const stub of Object.values(machinesRpc)) stub.mockClear()
+ const { refreshMachines } = await import('./lib/machines.js')
+ await refreshMachines()
+}
diff --git a/packages/openagent/src/client.ts b/packages/openagent/src/client.ts
index 94632eceb..5a0b99e24 100644
--- a/packages/openagent/src/client.ts
+++ b/packages/openagent/src/client.ts
@@ -29,6 +29,8 @@ export { NOTIFICATION_DEFAULTS, MAX_SPEND_OFFSET, notifies, browserNotifyEnabled
// dashboard labels the connection with it, so they must agree on what "local" means — the browser
// kept its own looser copy, which answered `false` for every 127.0.0.0/8 address but the first.
export { isLoopbackHost } from './loopback-host.js'
+// How a pasted machine address is read, so the dialog and the daemon read a paste the same way.
+export { parseMachineUrl } from './machine-url.js'
// A bridged question as the gate panel renders it (#1554): pure, so the client projects it itself.
export { bridgeChoiceRequest } from './dashboard/bridge-question.js'
// What a web run's cloud side is doing, from its record (#1668): pure, so every surface derives the same word.
diff --git a/packages/openagent/src/daemon-runtime.test.ts b/packages/openagent/src/daemon-runtime.test.ts
index 8fd53a271..cdfe8cdae 100644
--- a/packages/openagent/src/daemon-runtime.test.ts
+++ b/packages/openagent/src/daemon-runtime.test.ts
@@ -7,7 +7,10 @@ import { join } from 'node:path'
import { createProjectRuntime } from './daemon-runtime.js'
import { PROJECT_HOOKS_FILE } from './project-hooks.js'
import { OPENAGENT_DIR } from './openagent-dir.js'
-import { addProject, listProjects } from './registry.js'
+import { addProject, listProjects, projectId, removeProject } from './registry.js'
+import type { OpenAgentEvent } from './events.js'
+import { nodeGitRunner } from '@openagt/agent-data'
+import { addWorktree, agentBranchName, worktreePath } from '@openagt/skill-branches'
import { DATA_BRANCH, excludeFromGit, readSharing, withFileBranch } from '@openagt/agent-data'
// A Start, as the daemon does it (#1774): the project's own start hook line, nothing else. The
@@ -386,3 +389,82 @@ test('removing a project with its files while a run of this machine is still ali
await rm(base, { recursive: true, force: true })
}
})
+
+/** A repository in a folder of its own under `base`, with `origin` set to `origin` when one is given. */
+async function clone(base: string, name: string, origin?: string): Promise {
+ const dir = join(base, name)
+ await mkdir(dir, { recursive: true })
+ execFileSync('git', ['init', '-q'], { cwd: dir })
+ if (origin) execFileSync('git', ['remote', 'add', 'origin', origin], { cwd: dir })
+ return dir
+}
+
+test('a relayed call\'s project is this machine\'s own, found by its repository\'s address: the first on the list, every time', async () => {
+ const base = await realpath(await mkdtemp(join(tmpdir(), 'openagent-address-')))
+ const env = { XDG_CONFIG_HOME: join(base, 'cfg') }
+ await mkdir(env.XDG_CONFIG_HOME, { recursive: true })
+ const at = new Date().toISOString()
+ const other = await clone(base, 'other', 'https://github.com/acme/other.git')
+ const local = await clone(base, 'notes')
+ const first = await clone(base, 'shop', 'https://github.com/Acme/Shop.git')
+ const second = await clone(base, 'shop-again', 'git@github.com:acme/shop.git')
+ for (const dir of [other, local, first, second]) await addProject(dir, at, undefined, env)
+ const runtime = createProjectRuntime({ cwd: other, env })
+ try {
+ // Two folders cloned from one repository: the one added first, and the same one on every call,
+ // so a run's later calls reach the project it started in.
+ for (let i = 0; i < 3; i++) assert.equal(await runtime.projectAt('github.com/acme/shop'), projectId(first))
+ assert.equal(await runtime.projectAt('github.com/acme/other'), projectId(other))
+ // No project cloned from it, and no project at all for a folder with no remote: nothing is fallen back to.
+ assert.equal(await runtime.projectAt('github.com/acme/nowhere'), undefined)
+ assert.equal(await runtime.projectAt(''), undefined)
+ // Off the list, the first is no longer this machine's project, and the second answers.
+ await removeProject(projectId(first), undefined, env)
+ assert.equal(await runtime.projectAt('github.com/acme/shop'), projectId(second))
+ } finally {
+ await runtime.dispose()
+ await rm(base, { recursive: true, force: true })
+ }
+})
+
+test('a relayed run\'s events are read from the project the call names, not from the folder the daemon was started in', async () => {
+ const base = await realpath(await mkdtemp(join(tmpdir(), 'openagent-tail-')))
+ const env = { XDG_CONFIG_HOME: join(base, 'cfg') }
+ await mkdir(env.XDG_CONFIG_HOME, { recursive: true })
+ const home = await clone(base, 'home')
+ // The project with the run: a real repository with the run's own checkout, its card and its diary.
+ const shop = await clone(base, 'shop')
+ const git = nodeGitRunner()
+ const agentId = '2026-07-19T10-00-00-000Z'
+ await git(['config', 'user.email', 't@t'], shop)
+ await git(['config', 'user.name', 't'], shop)
+ await writeFile(join(shop, 'index.html'), 'Hello \n')
+ await git(['add', '-A'], shop)
+ await git(['commit', '-q', '-m', 'init'], shop)
+ await addWorktree(shop, { agentId, branch: agentBranchName(agentId) }, git)
+ const worktree = worktreePath(shop, agentId)
+ await mkdir(join(worktree, OPENAGENT_DIR), { recursive: true })
+ await writeFile(join(worktree, OPENAGENT_DIR, `${agentId}.json`), JSON.stringify({ id: agentId, startedAt: '2026-07-19T10:00:00.000Z', status: 'running', caller: { pid: process.pid, host: hostname() } }))
+ await writeFile(join(worktree, OPENAGENT_DIR, `${agentId}.jsonl`), JSON.stringify({ kind: 'said', text: 'from the shop' }) + '\n')
+ await addProject(shop, new Date().toISOString(), undefined, env)
+ const runtime = createProjectRuntime({ cwd: home, env })
+ const read = (project: string): Promise =>
+ new Promise(resolvePromise => {
+ const events: OpenAgentEvent[] = []
+ const stop = runtime.tailRelayEvents(project, agentId, event => events.push(event))
+ setTimeout(() => {
+ stop()
+ resolvePromise(events)
+ }, 600)
+ })
+ try {
+ const events = await read(projectId(shop))
+ assert.equal(JSON.stringify(events).includes('from the shop'), true, JSON.stringify(events))
+ // The same run asked for in the daemon's own folder, or in no project of this machine: nothing.
+ assert.deepEqual(await read(projectId(home)), [])
+ assert.deepEqual(await read('no-such-project'), [])
+ } finally {
+ await runtime.dispose()
+ await rm(base, { recursive: true, force: true })
+ }
+})
diff --git a/packages/openagent/src/daemon-runtime.ts b/packages/openagent/src/daemon-runtime.ts
index 8c618b3d6..855bafc78 100644
--- a/packages/openagent/src/daemon-runtime.ts
+++ b/packages/openagent/src/daemon-runtime.ts
@@ -1,4 +1,4 @@
-import { resolve } from 'node:path'
+import { basename, resolve } from 'node:path'
import { hostname } from 'node:os'
import { stat } from 'node:fs/promises'
import { fromDiaryLine, isPidAlive, projectBranches, readLiveMetas, resolveAgentDiary, type AgentMeta, type AnyDiaryLine } from './store/index.js'
@@ -8,7 +8,8 @@ import type { EventsSource, RemoteAgents } from './dashboard/rpc-serve.js'
import { RelayedAgents, startRemoteAgent } from './dashboard/remote-run.js'
import { dispatchRelayRpc } from './dashboard-rpc/relay-dispatch.js'
import { tailAgentEvents } from './dashboard-rpc/events-tail.js'
-import { addProject, listProjects, projectId, removeProject } from './registry.js'
+import { addProject, listMachines, listProjects, projectId, removeProject } from './registry.js'
+import { repositoryAddress } from './repository-address.js'
import { writeHookLines } from './built-in.js'
import { fileBranchRepo, installProject, writeSharing } from '@openagt/agent-data'
import { removeProjectFiles } from './remove-files.js'
@@ -21,11 +22,14 @@ import { providedDataChanged } from './store/provided.js'
/**
* What the daemon does for a project (#393): start a run, add a project, and relay a run to and
- * from a connected device. Split from daemon.ts so that file reads as the daemon's lifecycle
+ * from a saved machine. Split from daemon.ts so that file reads as the daemon's lifecycle
* (state file, ports, boot, shutdown). The daemon runs no agent itself (#1774): a Start is the
* project's own `start` hook line, and the run it begins belongs to whatever tool that line names.
*/
+/** How long a project folder's repository address is trusted before git is asked again. */
+const ADDRESS_KEPT_MS = 10_000
+
/** Inputs to {@link createProjectRuntime}. */
export interface ProjectRuntimeOptions {
/** The daemon's home workspace; a run with no project id targets it. */
@@ -39,18 +43,21 @@ export interface ProjectRuntime {
onStart: (prompt: string, options?: StartAgentOptions, targetProjectId?: string) => Promise
onAddProject: (path: string, share: boolean) => Promise
onRemoveProject: (projectId: string, files?: boolean) => Promise
- /** The live event stream for an agent this daemon is relaying from a device (#1067), else undefined
+ /** The live event stream for an agent this daemon is relaying from a machine (#1067), else undefined
* so `onEvents` falls back to tailing the on-disk log. Wired as the dashboard's events source. */
remoteEventsSource: EventsSource
+ /** This machine's own id for its project cloned from `address`, the name a relayed call gives a
+ * project; undefined when no project here is cloned from it. */
+ projectAt: (address: string) => Promise
/** Tail a relay-started agent's on-disk events (#1067): the daemon's `/_relay/events` endpoint uses
* it to stream one agent back to whichever daemon relayed it here. */
- tailRelayEvents: (agentId: string, onEvent: (event: OpenAgentEvent) => void) => () => void
- /** The relayed-agent lookup the dashboard's read RPCs consult (#1067 slice 2): which device a remote
+ tailRelayEvents: (projectId: string, agentId: string, onEvent: (event: OpenAgentEvent) => void) => () => void
+ /** The relayed-agent lookup the dashboard's read RPCs consult (#1067 slice 2): which machine a remote
* run runs on, so a run-scoped RPC forwards there instead of resolving a local checkout. */
remoteAgents: RemoteAgents
- /** The device side of the relay (#1067 slice 2): run one whitelisted read/steer/handoff RPC against
- * this daemon's own home checkout, for a daemon that relayed an agent here. */
- onRelayRpc: (fn: string, args: unknown[]) => Promise
+ /** The machine side of the relay (#1067 slice 2): run one whitelisted read/steer/handoff RPC against
+ * this daemon's own copy of the project, for a daemon that relayed an agent here. */
+ onRelayRpc: (projectId: string, fn: string, args: unknown[]) => Promise
/** Let go of the relayed streams. */
dispose: () => Promise
}
@@ -61,17 +68,17 @@ export interface ProjectRuntime {
*/
export function createProjectRuntime({ cwd, env }: ProjectRuntimeOptions): ProjectRuntime {
const homeId = projectId(resolve(cwd))
- // Runs this daemon is relaying to/from a connected device (#1067): the local half of a remote agent.
+ // Runs this daemon is relaying to/from a saved machine (#1067): the local half of a remote agent.
const relayedAgents = new RelayedAgents()
// The relayed-agent lookup the dashboard's read RPCs consult (#1067 slice 2): is this agentId remote, and
- // which device owns it. Outlives the event stream so a finished remote agent's push/PR still reaches it.
+ // which machine owns it. Outlives the event stream so a finished remote agent's push/PR still reaches it.
const remoteAgents: RemoteAgents = {
target: agentId => relayedAgents.target(agentId),
list: projectId => relayedAgents.list(projectId),
}
- // The device side of the relay (#1067 slice 2): run one whitelisted read/steer/handoff RPC against this
- // daemon's own home checkout, for a daemon that relayed an agent here. Home id forces the addressed project.
- const onRelayRpc = (fn: string, args: unknown[]): Promise => dispatchRelayRpc(homeId, fn, args)
+ // The machine side of the relay (#1067 slice 2): run one whitelisted read/steer/handoff RPC against this
+ // daemon's own copy of the project the call named, for a daemon that relayed an agent here.
+ const onRelayRpc = (project: string, fn: string, args: unknown[]): Promise => dispatchRelayRpc(project, fn, args)
// A project id resolves to its repo path via the registry; the home id (or none)
// resolves to the daemon's own `cwd` without a lookup.
@@ -81,36 +88,63 @@ export function createProjectRuntime({ cwd, env }: ProjectRuntimeOptions): Proje
return records.find(record => record.id === id)?.path
}
+ // The project here that a relayed call names by its repository's address: the first on the list
+ // cloned from it, every time, so a run's later calls reach the project it started in. A folder's
+ // address is kept for a few seconds, so a run's reads, which poll, do not ask git about every
+ // project each time. Only an address is kept: a folder that had none is asked again.
+ const addresses = new Map()
+ const addressOf = async (path: string): Promise => {
+ const kept = addresses.get(path)
+ if (kept && kept.until > Date.now()) return kept.address
+ const address = await repositoryAddress(path)
+ if (address === undefined) addresses.delete(path)
+ else addresses.set(path, { address, until: Date.now() + ADDRESS_KEPT_MS })
+ return address
+ }
+ const projectAt = async (address: string): Promise => {
+ for (const record of await listProjects(undefined, env).catch(() => [])) {
+ if ((await addressOf(record.path)) === address) return record.id
+ }
+ return undefined
+ }
+
// Start (#1774): the project's own `start` hook line, which answers the id of the run it began.
// The daemon names no tool and holds nothing about the run: no slot, no cap (a person's click is
// the brake), no process to stop at shutdown. A project without the line cannot start a run here.
const onStart = async (prompt: string, options: StartAgentOptions = {}, targetProjectId?: string): Promise => {
- // Run on a connected device (#1067): forward the start to the remote daemon, which runs its own
- // project's hook, and relay the run's events back. `remote` is stripped so the device does not
- // relay onward, and `base` because a branch of this machine names nothing on the device (the device drops it too). The
- // device starts it in its own home project.
- if (options.remote) {
- const { remote, base: _thisMachines, ...forwarded } = options
- const result = await startRemoteAgent(remote, { prompt, options: forwarded })
- if (result.ok) {
- // A relayed agent has no local checkout or pid, so its list row is a memory-only stub (#1077):
- // registered here so onAgents can show it and a dashboard reload re-opens it. Never written to disk.
- const now = new Date().toISOString()
- const meta: AgentMeta = {
- status: 'running',
- id: result.agentId,
- startedAt: now,
- updatedAt: now,
- target: 'remote',
- intent: prompt,
- ...(remote.label ? { remoteLabel: remote.label } : {}),
- }
- relayedAgents.register(result.agentId, remote, meta, targetProjectId ?? homeId)
+ const projectCwd = await resolveProject(targetProjectId)
+ if (!projectCwd) return { ok: false, error: `unknown project: ${targetProjectId}` }
+ // Run on a saved machine (#1067): forward the start to its daemon, which runs the hook of its
+ // own copy of this project, and relay the run's events back. The project is named by its
+ // repository's address, the one name both machines have for it. `machine` is stripped so the
+ // other side does not relay onward, and `base` because a branch of this machine names nothing
+ // there (it drops both too).
+ if (options.machine !== undefined) {
+ const { machine: id, base: _thisMachines, ...forwarded } = options
+ const machine = (await listMachines(undefined, env)).find(saved => saved.id === id)
+ if (!machine) return { ok: false, error: 'That machine is no longer saved. Pick another place to run.' }
+ const project = await repositoryAddress(projectCwd)
+ if (!project) return { ok: false, error: 'This project has no repository address, so it cannot be sent to another machine.' }
+ const target = { url: machine.url, token: machine.token, project }
+ const result = await startRemoteAgent(target, { prompt, options: forwarded })
+ if (!result.ok) {
+ return 'noProject' in result ? { ok: false, error: `${basename(projectCwd)} is not on ${machine.label}. Add it there first.` } : result
+ }
+ // A relayed agent has no local checkout or pid, so its list row is a memory-only stub (#1077):
+ // registered here so onAgents can show it and a dashboard reload re-opens it. Never written to disk.
+ const now = new Date().toISOString()
+ const meta: AgentMeta = {
+ status: 'running',
+ id: result.agentId,
+ startedAt: now,
+ updatedAt: now,
+ target: 'remote',
+ intent: prompt,
+ remoteLabel: machine.label,
}
+ relayedAgents.register(result.agentId, target, meta, targetProjectId ?? homeId)
return result
}
- const projectCwd = await resolveProject(targetProjectId)
- if (!projectCwd) return { ok: false, error: `unknown project: ${targetProjectId}` }
// The branch to start from ends up on the line's command line: only a branch name goes there.
// Refused, not dropped: a run started from the main branch instead would be a silent swap.
if (options.base !== undefined && !isBranchName(options.base)) return { ok: false, error: `not a branch name: ${String(options.base)}` }
@@ -198,22 +232,28 @@ export function createProjectRuntime({ cwd, env }: ProjectRuntimeOptions): Proje
return { ok: true, cleanup }
}
- // The dashboard's events source (#1067): a stream for an agent this daemon is relaying from a device,
+ // The dashboard's events source (#1067): a stream for an agent this daemon is relaying from a machine,
// else undefined so `onEvents` tails the on-disk log as usual for an ordinary local agent.
const remoteEventsSource: EventsSource = (_projectId, agentId) => relayedAgents.get(agentId)
// Tail a relay-started run's own log (#1067) for the `/_relay/events` endpoint: the diary the
// run's tool keeps, its lines turned into events. The relocating tail, for the same reason as
// the dashboard's onEvents: the diary becomes the finished run's when the run ends.
- const tailRelayEvents = (agentId: string, onEvent: (event: OpenAgentEvent) => void): (() => void) =>
- tailAgentEvents(() => resolveAgentDiary(cwd, agentId), line => {
- const event = fromDiaryLine(line)
- if (event) onEvent(event)
- })
+ const tailRelayEvents = (project: string, agentId: string, onEvent: (event: OpenAgentEvent) => void): (() => void) =>
+ tailAgentEvents(
+ async () => {
+ const projectCwd = await resolveProject(project)
+ return projectCwd ? resolveAgentDiary(projectCwd, agentId) : undefined
+ },
+ line => {
+ const event = fromDiaryLine(line)
+ if (event) onEvent(event)
+ },
+ )
const dispose = async (): Promise => {
relayedAgents.dispose()
}
- return { onStart, onAddProject, onRemoveProject, remoteEventsSource, tailRelayEvents, remoteAgents, onRelayRpc, dispose }
+ return { onStart, onAddProject, onRemoveProject, remoteEventsSource, projectAt, tailRelayEvents, remoteAgents, onRelayRpc, dispose }
}
diff --git a/packages/openagent/src/daemon.ts b/packages/openagent/src/daemon.ts
index d92e6e963..7d03c319f 100644
--- a/packages/openagent/src/daemon.ts
+++ b/packages/openagent/src/daemon.ts
@@ -109,7 +109,7 @@ export async function runDaemon(cwd: string, opts: RunDaemonOptions = {}): Promi
// very first command in a fresh workspace, before any run has made the directory.
await mkdir(daemonDir(cwd), { recursive: true })
- // Everything the dashboard drives per project (a run's start, project install, the device
+ // Everything the dashboard drives per project (a run's start, project install, the machine
// relay) lives in the runtime, so this body stays about the daemon's own lifecycle.
const runtime = createProjectRuntime({ cwd, env })
// Known only once the dashboard listens; the bridge browser is told it (#1332).
@@ -150,12 +150,12 @@ export async function runDaemon(cwd: string, opts: RunDaemonOptions = {}): Promi
onStart: runtime.onStart,
onAddProject: runtime.onAddProject,
onRemoveProject: runtime.onRemoveProject,
- // Relay an agent to/from a connected device (#1067): the events source streams an agent this daemon
- // is relaying, `remote` lets the read RPCs forward a remote agent's reads/steer/push to its device
+ // Relay an agent to/from a saved machine (#1067): the events source streams an agent this daemon
+ // is relaying, `remote` lets the read RPCs forward a remote agent's reads/steer/push to its machine
// (slice 2), and the `/_relay/*` endpoints let another daemon run + read + steer a session here.
eventsSource: runtime.remoteEventsSource,
remote: runtime.remoteAgents,
- relay: { tailEvents: runtime.tailRelayEvents, rpc: runtime.onRelayRpc },
+ relay: { project: runtime.projectAt, tailEvents: runtime.tailRelayEvents, rpc: runtime.onRelayRpc },
// The browser bridge (#1237): absent unless the preference is on, which 404s every route.
...(bridgeToken ? { bridgeToken, bridgeSessions: () => listBridgeSessions(env) } : {}),
// The bridge browser follows its switch (#1332): on launches it, off closes it, without a restart.
diff --git a/packages/openagent/src/dashboard-rpc/context.ts b/packages/openagent/src/dashboard-rpc/context.ts
index 011899705..b1bf7d862 100644
--- a/packages/openagent/src/dashboard-rpc/context.ts
+++ b/packages/openagent/src/dashboard-rpc/context.ts
@@ -65,7 +65,7 @@ export async function resolveAgentPath(projectId: string, agentId?: string): Pro
/**
* The in-memory {@link EventsSource} (#426). It answers only for a run this daemon is relaying
- * from a connected device (#1067) — such a run has no diary on this machine, since it works in a
+ * from a saved machine (#1067) — such a run has no diary on this machine, since it works in a
* checkout over there — and returns undefined for an ordinary local run, whose diary `onEvents`
* tails off disk.
*/
@@ -75,11 +75,11 @@ export function contextEventsSource(): EventsSource {
/**
* The relayed-agent lookup (#1067 slice 2). A run-scoped RPC uses it to tell an ordinary local agent
- * (resolve a local checkout) from one running on a connected device (forward the call there).
+ * (resolve a local checkout) from one running on a saved machine (forward the call there).
*
* The one accessor with a default rather than a throw, because "unwired" has a real meaning here:
- * a call arriving over `/_relay/rpc` is the *device* side of the relay, and the agent it names is
- * local to that device. Forwarding it onward would be a loop, so the honest answer there is that
+ * a call arriving over `/_relay/rpc` is the *machine* side of the relay, and the agent it names is
+ * local to that machine. Forwarding it onward would be a loop, so the honest answer there is that
* nothing is relayed from here.
*/
export function contextRemote(): RemoteAgents {
diff --git a/packages/openagent/src/dashboard-rpc/control.ts b/packages/openagent/src/dashboard-rpc/control.ts
index 8fa7232b5..3f3c9abc9 100644
--- a/packages/openagent/src/dashboard-rpc/control.ts
+++ b/packages/openagent/src/dashboard-rpc/control.ts
@@ -74,7 +74,7 @@ export async function sendChoice(projectId: string, id: string, pick: string | s
if (labels.length !== picked.length) return { ok: false, error: 'every pick must be one of the question\'s options' }
if (!question.multi && labels.length !== 1) return { ok: false, error: 'pick exactly one option' }
return sayToRun(cwd, agentId, { kind: 'answer', question: question.title, answer: labels.length ? labels.join(', ') : '(none)' })
- }, { ok: false, error: 'could not reach the device' })
+ }, { ok: false, error: 'could not reach the machine' })
}
/**
@@ -112,7 +112,7 @@ export async function sendMessage(projectId: string, text: string, agentId?: str
const cwd = await resolveProjectPath(projectId)
if (!cwd || !agentId || !isRunId(agentId)) return { ok: false, error: 'unknown session' }
return sayToRun(cwd, agentId, { kind: 'message', text: message })
- }, { ok: false, error: 'could not reach the device' })
+ }, { ok: false, error: 'could not reach the machine' })
}
/**
@@ -151,7 +151,7 @@ export async function sendDeleteAgent(projectId: string, agentId: string): Promi
/**
* Start a run in the project (#405, #1774): the project's own `start` hook line, reached through
- * the daemon's wired `startAgent` (which relays to a connected device when the options name
+ * the daemon's wired `startAgent` (which relays to a saved machine when the options name
* one). Answers the id of the run the hook began, or why there is none: a project without the
* line cannot start a run from here.
*/
@@ -221,7 +221,7 @@ export async function sendOpenPullRequest(projectId: string, agentId: string, op
await (await projectRuns(target.cwd).catch(() => undefined))?.patch(agentId, { pr: { number: opened.number, url: opened.url } })
}
return opened
- }, { ok: false, error: 'could not reach the device' })
+ }, { ok: false, error: 'could not reach the machine' })
}
/**
@@ -234,7 +234,7 @@ export async function sendPush(projectId: string, agentId: string): Promise pushAgentBranch(target.cwd, target.agent))
- }, { ok: false, error: 'could not reach the device' })
+ }, { ok: false, error: 'could not reach the machine' })
}
/**
@@ -261,7 +261,7 @@ export async function sendMergeBranch(projectId: string, agentId: string): Promi
if (!target) return { ok: false, error: 'unknown session' }
if (target.agent.status === 'running') return { ok: false, error: 'that session is still going' }
return withAgentLock(agentLockKey(target.cwd, agentId), () => mergeAgentBranch(target.cwd, target.agent))
- }, { ok: false, error: 'could not reach the device' })
+ }, { ok: false, error: 'could not reach the machine' })
}
/**
@@ -275,7 +275,7 @@ export async function sendMerge(projectId: string, agentId: string): Promise Promise }> {
- const srv: Server = createServer((_req, res) => {
- res.writeHead(status)
- res.end()
- })
- await new Promise(r => srv.listen(0, '127.0.0.1', () => r()))
- const port = (srv.address() as AddressInfo).port
- return { url: `http://127.0.0.1:${port}`, close: () => new Promise(r => srv.close(() => r())) }
-}
-
-test('checkDevices maps each device id to whether it answered (#1072)', async () => {
- const up = await device(200)
- const down = await device(401)
- try {
- const result = await checkDevices([
- { id: 'up', url: up.url, token: 't' },
- { id: 'down', url: down.url, token: 't' },
- { id: 'gone', url: 'http://127.0.0.1:1', token: 't' }, // nothing listening
- ])
- assert.deepEqual(result, { up: true, down: false, gone: false })
- } finally {
- await up.close()
- await down.close()
- }
-})
-
-test('checkDevices drops malformed entries and returns {} for none (#1072)', async () => {
- assert.deepEqual(await checkDevices([]), {})
- // Bad shapes from the browser are filtered, not pinged, so they never appear in the map.
- assert.deepEqual(await checkDevices([{ id: 1 } as never, null as never]), {})
-})
diff --git a/packages/openagent/src/dashboard-rpc/devices.ts b/packages/openagent/src/dashboard-rpc/devices.ts
deleted file mode 100644
index 0b2906426..000000000
--- a/packages/openagent/src/dashboard-rpc/devices.ts
+++ /dev/null
@@ -1,25 +0,0 @@
-import { pingRemote } from '../dashboard/remote-run.js'
-
-// The saved-devices health surface (#1072). The tokens live browser-side (per #1052), so the browser
-// hands this daemon each device's {id, url, token} and the daemon does the cookie'd cross-origin
-// ping; the token is used for the check and never persisted. Returns a reachable map the status dots
-// read. No context needed: it acts only on what the browser passed, so it is inert on any host.
-
-/** One device to health-check: its profile id plus the origin and token to reach it with. */
-export interface DeviceCheck {
- id: string
- url: string
- token: string
-}
-
-function isDeviceCheck(value: unknown): value is DeviceCheck {
- const d = value as Partial | null
- return !!d && typeof d.id === 'string' && typeof d.url === 'string' && typeof d.token === 'string'
-}
-
-/** Ping each saved device and map its id to whether it answered (#1072). Bad entries are dropped. */
-export async function checkDevices(devices: DeviceCheck[]): Promise> {
- const valid = (Array.isArray(devices) ? devices : []).filter(isDeviceCheck)
- const entries = await Promise.all(valid.map(async d => [d.id, await pingRemote({ url: d.url, token: d.token })] as const))
- return Object.fromEntries(entries)
-}
diff --git a/packages/openagent/src/dashboard-rpc/events.ts b/packages/openagent/src/dashboard-rpc/events.ts
index 88f101856..748513156 100644
--- a/packages/openagent/src/dashboard-rpc/events.ts
+++ b/packages/openagent/src/dashboard-rpc/events.ts
@@ -41,7 +41,7 @@ export type LiveFeedEvent = OpenAgentEvent | StreamSync | PartialMessage
* The `agentId` names the run whose diary is followed; without one there is nothing to stream.
*
* Two sources, chosen by what is wired. An in-memory stream wins: an agent the daemon is relaying
- * from a device (#1067). Otherwise the on-disk log. The daemon's source answers only for relayed
+ * from a machine (#1067). Otherwise the on-disk log. The daemon's source answers only for relayed
* runs, so an ordinary local agent falls through to tailing the log.
*
* Only the on-disk tail sends the {@link StreamSync} marker: the in-memory sources have no replay
diff --git a/packages/openagent/src/dashboard-rpc/index.ts b/packages/openagent/src/dashboard-rpc/index.ts
index 4ab93df04..0e9e81166 100644
--- a/packages/openagent/src/dashboard-rpc/index.ts
+++ b/packages/openagent/src/dashboard-rpc/index.ts
@@ -19,7 +19,7 @@ export {
export { type EditorInfo } from '../dashboard/open-in-app.js'
export { onQuota } from './quota.js'
export { onModels } from './models.js'
-export { checkDevices, type DeviceCheck } from './devices.js'
+export { onMachines, sendAddMachine, sendRemoveMachine, onMachinesReachable, type Machine, type AddMachineResult } from './machines.js'
export { onModules, runModuleCommand, readModule, type DashboardModule, type ModuleCommandResult, type ModuleReadResult } from './modules.js'
export { onProjectSkills, sendChangeSkills, sendCommitSkills, type ProjectSkills, type ProjectSkill, type ChangeSkillsResult } from './skills.js'
@@ -29,7 +29,7 @@ import * as projects from './projects.js'
import * as preferences from './preferences.js'
import * as quota from './quota.js'
import * as models from './models.js'
-import * as devices from './devices.js'
+import * as machines from './machines.js'
import * as modules from './modules.js'
import * as skills from './skills.js'
import { streamAgentEvents } from './events.js'
@@ -52,7 +52,7 @@ export type RpcHandler = (...args: never[]) => unknown
export const RPC_HANDLERS: Record = Object.assign(
Object.create(null) as Record,
Object.fromEntries(
- [reads, control, projects, preferences, quota, models, devices, modules, skills]
+ [reads, control, projects, preferences, quota, models, machines, modules, skills]
.flatMap(module => Object.entries(module))
.filter((entry): entry is [string, RpcHandler] => typeof entry[1] === 'function'),
),
diff --git a/packages/openagent/src/dashboard-rpc/machines.test.ts b/packages/openagent/src/dashboard-rpc/machines.test.ts
new file mode 100644
index 000000000..6fff23f0f
--- /dev/null
+++ b/packages/openagent/src/dashboard-rpc/machines.test.ts
@@ -0,0 +1,92 @@
+import { strict as assert } from 'node:assert'
+import { test } from 'node:test'
+import { createServer, type Server } from 'node:http'
+import type { AddressInfo } from 'node:net'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { onMachines, onMachinesReachable, sendAddMachine, sendRemoveMachine } from './machines.js'
+import { REGISTRY_FILE } from '../registry.js'
+
+// These run against the real registry, pointed at a temp $XDG_CONFIG_HOME so the person's own
+// home file is never read or written.
+async function withHome(run: (file: string) => Promise): Promise {
+ const dir = await mkdtemp(join(tmpdir(), 'oa-machines-'))
+ const previous = process.env.XDG_CONFIG_HOME
+ process.env.XDG_CONFIG_HOME = dir
+ try {
+ await run(join(dir, REGISTRY_FILE))
+ } finally {
+ if (previous === undefined) delete process.env.XDG_CONFIG_HOME
+ else process.env.XDG_CONFIG_HOME = previous
+ await rm(dir, { recursive: true, force: true })
+ }
+}
+
+// A throwaway daemon that answers /_relay/ping with a fixed status, and records the cookie it was sent.
+async function machine(status: number): Promise<{ url: string; cookies: string[]; close: () => Promise }> {
+ const cookies: string[] = []
+ const srv: Server = createServer((req, res) => {
+ cookies.push(req.headers.cookie ?? '')
+ res.writeHead(status)
+ res.end()
+ })
+ await new Promise(r => srv.listen(0, '127.0.0.1', () => r()))
+ const port = (srv.address() as AddressInfo).port
+ return { url: `http://127.0.0.1:${port}`, cookies, close: () => new Promise(r => srv.close(() => r())) }
+}
+
+test('a pasted address is saved in the home file, and the browser is answered without its key', async () => {
+ await withHome(async file => {
+ const added = await sendAddMachine('http://192.168.1.5:4200/?token=sekret', ' Studio ')
+ assert.deepEqual(added, { ok: true, machine: { id: 'http://192.168.1.5:4200', label: 'Studio', url: 'http://192.168.1.5:4200' } })
+ const listed = await onMachines()
+ assert.deepEqual(listed, [{ id: 'http://192.168.1.5:4200', label: 'Studio', url: 'http://192.168.1.5:4200' }])
+ assert.equal(JSON.stringify([added, listed]).includes('sekret'), false)
+ // The key is where the daemon reads it: the person's own file.
+ assert.equal(JSON.parse(await readFile(file, 'utf8')).machines[0].token, 'sekret')
+ })
+})
+
+test('a paste that is no address, or carries no key, is refused and nothing is saved', async () => {
+ await withHome(async () => {
+ assert.deepEqual(await sendAddMachine('not a url'), { ok: false, error: 'That is not a web address.' })
+ const noKey = await sendAddMachine('http://192.168.1.5:4200/')
+ assert.equal(noKey.ok, false)
+ assert.deepEqual(await sendAddMachine(42 as never), { ok: false, error: 'That is not a web address.' })
+ assert.deepEqual(await onMachines(), [])
+ })
+})
+
+test('a removed machine is off the list', async () => {
+ await withHome(async () => {
+ await sendAddMachine('http://192.168.1.5:4200/?token=a')
+ await sendAddMachine('http://10.0.0.2:4200/?token=b')
+ await sendRemoveMachine('http://192.168.1.5:4200')
+ assert.deepEqual((await onMachines()).map(saved => saved.id), ['http://10.0.0.2:4200'])
+ })
+})
+
+test('each saved machine is asked with its own key, and the answer says which ones are there (#1072)', async () => {
+ await withHome(async () => {
+ const up = await machine(200)
+ const refused = await machine(401)
+ try {
+ await sendAddMachine(`${up.url}/?token=up-key`)
+ await sendAddMachine(`${refused.url}/?token=old-key`)
+ await sendAddMachine('http://127.0.0.1:1/?token=x') // nothing listening
+ assert.deepEqual(await onMachinesReachable(), { [up.url]: true, [refused.url]: false, 'http://127.0.0.1:1': false })
+ assert.deepEqual(up.cookies, ['oa_daemon=up-key'])
+ assert.deepEqual(refused.cookies, ['oa_daemon=old-key'])
+ } finally {
+ await up.close()
+ await refused.close()
+ }
+ })
+})
+
+test('with no saved machine nothing is asked', async () => {
+ await withHome(async () => {
+ assert.deepEqual(await onMachinesReachable(), {})
+ })
+})
diff --git a/packages/openagent/src/dashboard-rpc/machines.ts b/packages/openagent/src/dashboard-rpc/machines.ts
new file mode 100644
index 000000000..e54ae7919
--- /dev/null
+++ b/packages/openagent/src/dashboard-rpc/machines.ts
@@ -0,0 +1,45 @@
+import { pingRemote } from '../dashboard/remote-run.js'
+import { addMachine, listMachines, removeMachine } from '../registry.js'
+import { parseMachineUrl } from '../machine-url.js'
+
+// The saved machines, for the dashboard: the other computers a run can be sent to. The list is
+// the daemon's, in the person's home file, so every browser on this computer shows the same
+// machines. A machine's key is never part of an answer: the daemon holds it and makes the calls.
+
+/** A saved machine as the browser sees it: no key. */
+export interface Machine {
+ id: string
+ label: string
+ url: string
+}
+
+export type AddMachineResult = { ok: true; machine: Machine } | { ok: false; error: string }
+
+/** The saved machines, newest first. */
+export async function onMachines(): Promise {
+ return (await listMachines()).map(({ id, label, url }) => ({ id, label, url }))
+}
+
+/**
+ * Save the machine a pasted line names: the address another OpenAgent prints when it is opened
+ * to the network, which carries its key. Saving an address again replaces its name and key.
+ */
+export async function sendAddMachine(pasted: string, label?: string): Promise {
+ const parsed = typeof pasted === 'string' ? parseMachineUrl(pasted) : null
+ if (!parsed) return { ok: false, error: 'That is not a web address.' }
+ if (!parsed.token) return { ok: false, error: 'This address has no token, so the machine would refuse every call.' }
+ const { id, url, label: saved } = await addMachine({ ...parsed, ...(typeof label === 'string' ? { label } : {}) })
+ return { ok: true, machine: { id, label: saved, url } }
+}
+
+/** Take a saved machine off the list. */
+export async function sendRemoveMachine(id: string): Promise {
+ await removeMachine(id)
+}
+
+/** Whether each saved machine answers right now, by id: one short call to each, with its key. */
+export async function onMachinesReachable(): Promise> {
+ const machines = await listMachines()
+ const entries = await Promise.all(machines.map(async machine => [machine.id, await pingRemote(machine)] as const))
+ return Object.fromEntries(entries)
+}
diff --git a/packages/openagent/src/dashboard-rpc/modules.ts b/packages/openagent/src/dashboard-rpc/modules.ts
index cb15a57c8..45c8fba4a 100644
--- a/packages/openagent/src/dashboard-rpc/modules.ts
+++ b/packages/openagent/src/dashboard-rpc/modules.ts
@@ -69,7 +69,7 @@ export async function runModuleCommand(projectId: string, pkg: string, args: str
* Call read `name` of a module's server part in one project: how a module's browser part reads
* what a command cannot answer fast enough. Refused for an unknown project, a package that is not
* a module of that project, and a module with no server part. A read whose input names a run this
- * daemon relays to a connected device (`input.agentId`) is read over there, by that device's own
+ * daemon relays to a saved machine (`input.agentId`) is read over there, by that machine's own
* copy of the module, since the run's checkout is there.
*/
export async function readModule(projectId: string, pkg: string, name: string, input: unknown): Promise {
@@ -81,5 +81,5 @@ export async function readModule(projectId: string, pkg: string, name: string, i
if (!module) return { ok: false, error: `${pkg} is no module of this project` }
if (!module.server) return { ok: false, error: `${pkg} has no server part` }
return callModuleRead(module.server, name, serverHost(root), input)
- }, { ok: false, error: 'the device this run works on did not answer' })
+ }, { ok: false, error: 'the machine this run works on did not answer' })
}
diff --git a/packages/openagent/src/dashboard-rpc/projects.test.ts b/packages/openagent/src/dashboard-rpc/projects.test.ts
index dd146b462..d296ede59 100644
--- a/packages/openagent/src/dashboard-rpc/projects.test.ts
+++ b/packages/openagent/src/dashboard-rpc/projects.test.ts
@@ -75,6 +75,7 @@ test('onCommands names the two branches an agent can start from only when the st
git('checkout', '-q', '-b', 'my/work')
await hooks(TAKES)
assert.equal(await startFrom(), undefined, 'no remote: one place to start from, nothing to pick')
+ assert.equal((await onCommands(id))?.address, undefined, 'no remote: no name another machine knows the project by')
git('remote', 'add', 'origin', 'https://example.com/x.git')
git('update-ref', 'refs/remotes/origin/main', 'main')
@@ -96,7 +97,7 @@ test('onCommands names the two branches an agent can start from only when the st
await rm(join(dir, PROJECT_HOOKS_FILE))
git('checkout', '-q', 'main')
- assert.deepEqual(await onCommands(id), { commands: [], startHook: false, gitHost: false, remote: true })
+ assert.deepEqual(await onCommands(id), { commands: [], startHook: false, gitHost: false, remote: true, address: 'example.com/x' })
assert.equal(await onCommands('no-such-project'), null)
} finally {
await restore()
diff --git a/packages/openagent/src/dashboard-rpc/projects.ts b/packages/openagent/src/dashboard-rpc/projects.ts
index 9a30edfe2..82612d15c 100644
--- a/packages/openagent/src/dashboard-rpc/projects.ts
+++ b/packages/openagent/src/dashboard-rpc/projects.ts
@@ -4,6 +4,7 @@ import { DATA_BRANCH, branchReach, originDefaultBranch, pullFileBranch, writeSha
import { readProjectHooks, runCheckHook, startLineTakesBase, type StartReadiness } from '../project-hooks.js'
import { isPublishPick, publishPickIn, type PublishPick } from '../publish-levels.js'
import { hasRemote } from '../has-remote.js'
+import { repositoryAddress } from '../repository-address.js'
import { currentBranch } from '../dashboard/git-status.js'
import { waitingSkills } from '../dashboard/start-branch.js'
import { pickDirectory, type PickDirectoryResult } from '../pick-directory.js'
@@ -112,6 +113,8 @@ export interface ProjectLauncher {
gitHost: boolean
/** Whether the project's repository has an `origin` remote; without one nothing can be published, so the publish menu stops at the commit. */
remote: boolean
+ /** The address the project's repository was cloned from, the name another machine knows the project by; absent, the project cannot be sent to one. */
+ address?: string
/**
* The two branches an agent can start from here, for the launcher's "start from" chip: `main`,
* the name of origin's default branch, and `local`, the branch the project's folder is on now.
@@ -128,7 +131,7 @@ export interface ProjectLauncher {
export async function onCommands(projectId: string): Promise {
const cwd = await resolveProjectPath(projectId)
if (!cwd) return null
- const [commands, hooks, gitHost, remote, main, local] = await Promise.all([readProjectCommands(cwd), readProjectHooks(cwd), projectGitHost(cwd).catch(() => undefined), hasRemote(cwd), originDefaultBranch(cwd), currentBranch(cwd)])
+ const [commands, hooks, gitHost, remote, address, main, local] = await Promise.all([readProjectCommands(cwd), readProjectHooks(cwd), projectGitHost(cwd).catch(() => undefined), hasRemote(cwd), repositoryAddress(cwd), originDefaultBranch(cwd), currentBranch(cwd)])
// Both read locally, never fetched. `HEAD` is a folder on no branch: there is no local branch to start from.
const startFrom = hooks.start !== undefined && startLineTakesBase(hooks.start) && remote && main !== undefined && local !== undefined && local !== 'HEAD' ? { main: main.slice('origin/'.length), local } : undefined
// A skill written into the folder reaches agents once it is on the branch they start from: until then its command is said to be waiting.
@@ -137,7 +140,7 @@ export async function onCommands(projectId: string): Promise(build: (projects: Awaited(
diff --git a/packages/openagent/src/dashboard-rpc/relay-dispatch.test.ts b/packages/openagent/src/dashboard-rpc/relay-dispatch.test.ts
index b851877ce..308fa194a 100644
--- a/packages/openagent/src/dashboard-rpc/relay-dispatch.test.ts
+++ b/packages/openagent/src/dashboard-rpc/relay-dispatch.test.ts
@@ -3,8 +3,8 @@ import { test } from 'node:test'
import { provideTestContext } from './test-context.js'
import { dispatchRelayRpc, RELAY_RPC_NAMES } from './relay-dispatch.js'
-// The device-side whitelist dispatcher (#1067 slice 2): a daemon that relayed an agent here calls one of a
-// fixed set of run-scoped RPCs against this device's own home checkout. These assert the whitelist is
+// The machine-side whitelist dispatcher (#1067 slice 2): a daemon that relayed an agent here calls one of a
+// fixed set of run-scoped RPCs against this machine's own copy of the project the call named. These assert the whitelist is
// exactly the run-scoped read/steer/handoff surface (and excludes start/delete), and that an unknown
// name is refused. The real HTTP round-trip through /_relay/rpc is in remote-run.integration.test.ts.
@@ -12,11 +12,11 @@ test('RELAY_RPC_NAMES is the run-scoped read/steer/handoff surface and excludes
for (const name of [
'onProjectFiles', 'readModule',
'onGitStatus', 'onAgentWorktree', 'onAgentHandoff', 'onAgent',
- 'sendStop', 'sendChoice', 'sendMessage', 'sendOpenPullRequest', 'sendMerge',
+ 'sendStop', 'sendChoice', 'sendMessage', 'sendOpenPullRequest', 'sendPush', 'sendMerge',
]) {
assert.ok(RELAY_RPC_NAMES.includes(name), `expected ${name} on the relay whitelist`)
}
- // Starting an agent, deleting a session, and removing a worktree are NOT relayable: a device runs its
+ // Starting an agent, deleting a session, and removing a worktree are NOT relayable: a machine runs its
// own guarded start, and destroying history/checkouts is not something a relaying daemon may reach.
// A module's command may write the project's data, so only its reads are relayable (#1774).
for (const off of ['sendStart', 'sendDeleteAgent', 'sendRemoveWorktree', 'sendPreview', 'runModuleCommand']) {
@@ -29,9 +29,9 @@ test('dispatchRelayRpc rejects an unknown rpc name (#1067 slice 2)', async () =>
await assert.rejects(dispatchRelayRpc('home', 'nope', []), /unknown relay rpc/)
})
-test('dispatchRelayRpc runs a whitelisted rpc against the home id and returns its empty shape (#1067 slice 2)', async () => {
+test('dispatchRelayRpc runs a whitelisted rpc against the project id it is given and returns its empty shape (#1067 slice 2)', async () => {
// No project is registered under this id, so onGitStatus resolves no checkout and returns null - proof
- // the call reached the whitelisted impl with the home id (the caller's arg[0] project id is dropped).
+ // the call reached the whitelisted impl with that id (the caller's arg[0] project id is dropped).
provideTestContext()
const result = await dispatchRelayRpc('openagent:no-such-home', 'onGitStatus', ['remote-project-id', 'run-1'])
assert.equal(result, null)
diff --git a/packages/openagent/src/dashboard-rpc/relay-dispatch.ts b/packages/openagent/src/dashboard-rpc/relay-dispatch.ts
index ef9ee80c4..b7f8fda9e 100644
--- a/packages/openagent/src/dashboard-rpc/relay-dispatch.ts
+++ b/packages/openagent/src/dashboard-rpc/relay-dispatch.ts
@@ -1,16 +1,16 @@
import { onProjectFiles, onGitStatus, onAgentWorktree, onAgentHandoff, onAgent } from './reads.js'
import { readModule } from './modules.js'
-import { sendStop, sendChoice, sendMessage, sendOpenPullRequest, sendMerge } from './control.js'
+import { sendStop, sendChoice, sendMessage, sendOpenPullRequest, sendPush, sendMerge } from './control.js'
-// The device side of the remote-agent relay (#1067 slice 2). A daemon that relayed an agent here asks this
-// to read/steer/hand off THAT run against THIS device's own checkout. Every entry is a run-scoped RPC
+// The machine side of the remote-agent relay (#1067 slice 2). A daemon that relayed an agent here asks this
+// to read/steer/hand off THAT run against THIS machine's own checkout. Every entry is a run-scoped RPC
// the dashboard already exposes to its own browser. The only change: arg[0] (the remote daemon's
-// project id, meaningless here) is replaced with this device's home project id, so a relayed call can
-// only ever address the device's own home checkout, never another registered project. These functions
-// resolve their path through the same registry the browser's own calls do (the home project is
-// registered at daemon start). They run with no browser request behind them, which is sound because
+// project id, meaningless here) is replaced with this machine's own id for the project the call
+// named by its repository's address (relay-endpoints.ts). These functions
+// resolve their path through the same registry the browser's own calls do.
+// They run with no browser request behind them, which is sound because
// the one thing they read off the context here — is this agent relayed onward? — defaults to no,
-// and on the device that is the truth: the agent is local here, so forwarding it again would loop.
+// and on the machine that is the truth: the agent is local here, so forwarding it again would loop.
// Whitelist only: start/delete/remove-worktree stay OFF it.
type RelayFn = (...args: unknown[]) => Promise
@@ -19,19 +19,19 @@ type RelayFn = (...args: unknown[]) => Promise
const RELAY_FNS = Object.assign(Object.create(null) as Record, {
onProjectFiles, readModule,
onGitStatus, onAgentWorktree, onAgentHandoff, onAgent,
- sendStop, sendChoice, sendMessage, sendOpenPullRequest, sendMerge,
+ sendStop, sendChoice, sendMessage, sendOpenPullRequest, sendPush, sendMerge,
}) as unknown as Record
/** The names a relay caller may invoke. */
export const RELAY_RPC_NAMES: readonly string[] = Object.keys(RELAY_FNS)
/**
- * Dispatch one relayed RPC against `homeId` (this device's own project). `args[0]` from the caller is
- * the remote daemon's project id and is meaningless here, so it is replaced with `homeId`; the rest
- * (path, agentId, ...) carry through unchanged. Throws on an unknown name.
+ * Dispatch one relayed RPC against `projectId` (this machine's own id for the project). `args[0]`
+ * from the caller is the remote daemon's project id and is meaningless here, so it is replaced
+ * with `projectId`; the rest (path, agentId, ...) carry through unchanged. Throws on an unknown name.
*/
-export async function dispatchRelayRpc(homeId: string, fn: string, args: unknown[]): Promise {
+export async function dispatchRelayRpc(projectId: string, fn: string, args: unknown[]): Promise {
const impl = RELAY_FNS[fn]
if (!impl) throw new Error(`unknown relay rpc: ${fn}`)
- return impl(homeId, ...args.slice(1))
+ return impl(projectId, ...args.slice(1))
}
diff --git a/packages/openagent/src/dashboard/http.ts b/packages/openagent/src/dashboard/http.ts
index 966ded202..a0fd1aed9 100644
--- a/packages/openagent/src/dashboard/http.ts
+++ b/packages/openagent/src/dashboard/http.ts
@@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
/**
* The plumbing every one of the daemon's non-RPC HTTP surfaces shares: the browser bridge
- * (`/_bridge`), the web-start queue (`/_web-start`) and the device relay (`/_relay`).
+ * (`/_bridge`), the web-start queue (`/_web-start`) and the machine relay (`/_relay`).
*
* All three answer plain-text statuses, JSON payloads, and read capped JSON request bodies, and
* each carried its own copy — two body readers that differed only in which message they rejected
diff --git a/packages/openagent/src/dashboard/relay-endpoints.ts b/packages/openagent/src/dashboard/relay-endpoints.ts
index 72b6c71da..21fe2c9a0 100644
--- a/packages/openagent/src/dashboard/relay-endpoints.ts
+++ b/packages/openagent/src/dashboard/relay-endpoints.ts
@@ -1,40 +1,52 @@
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { OpenAgentEvent } from '../events.js'
import type { StartAgentOptions, StartAgentResult } from './types.js'
+import type { RelayStartResult } from './remote-run.js'
import { end, readJsonBody, requireGet, sendJson } from './http.js'
/**
- * The device-side of the remote-agent relay (#1067): the two endpoints a daemon exposes so another
- * daemon (holding this device's token) can run a session here and watch it. They live under
+ * The machine-side of the remote-agent relay (#1067): the two endpoints a daemon exposes so another
+ * daemon (holding this machine's token) can run a session here and watch it. They live under
* `/_relay`, behind the shared-token guard (#1051) in {@link startDashboard}. The guard admits a matching
* `oa_daemon` cookie without the browser-only `?token=` 302, so a daemon-to-daemon call passes with
* a cookie and a token-less caller is already 401'd before it reaches here.
*
- * - `POST /_relay/start` starts an ordinary local agent and returns its {@link StartAgentResult}. It
- * runs in this device's own home checkout (slice 1); which project it targets is a later slice.
- * - `GET /_relay/events?run=` streams that agent's events as newline-delimited JSON until it
- * ends or the caller disconnects.
+ * - `POST /_relay/start` starts an ordinary local agent and returns its {@link StartAgentResult}.
+ * - `GET /_relay/events?run=&project=` streams that agent's events as
+ * newline-delimited JSON until it ends or the caller disconnects.
* - `GET /_relay/ping` (#1072) a cookie-guarded reachability probe: 200 and an empty body, starts
* nothing. The online/offline status the dashboard shows is the local daemon calling this on each
- * saved device with its token; a token-less caller is already 401'd by the shared-token guard (#1051) above.
+ * saved machine with its token; a token-less caller is already 401'd by the shared-token guard (#1051) above.
* - `POST /_relay/rpc` (#1067 slice 2) runs one whitelisted run-scoped RPC (a read/diff/steer/handoff/
- * push/PR) against this device's own checkout for the daemon relaying an agent here, answering {result}.
+ * push/PR) against this machine's own checkout for the daemon relaying an agent here, answering {result}.
+ *
+ * The three name their project by its repository's address, and work in this machine's own
+ * project cloned from it. A call that names none, or one this machine does not have, starts and
+ * reads nothing: there is no project a call falls back to.
*/
export const RELAY_PREFIX = '/_relay'
/** What the daemon wires behind the relay endpoints: its own start closure and an events tail. */
export interface RelayHandlers {
- start: (prompt: string, options: StartAgentOptions, projectId?: string) => StartAgentResult | Promise
- tailEvents: (agentId: string, onEvent: (event: OpenAgentEvent) => void) => () => void
- /** Run one whitelisted read/steer/handoff RPC against THIS device's own checkout, for the daemon
+ /** This machine's own id for its project cloned from `address`, or undefined when it has none. */
+ project: (address: string) => Promise
+ start: (prompt: string, options: StartAgentOptions, projectId: string) => StartAgentResult | Promise
+ tailEvents: (projectId: string, agentId: string, onEvent: (event: OpenAgentEvent) => void) => () => void
+ /** Run one whitelisted read/steer/handoff RPC against THIS machine's own checkout, for the daemon
* relaying an agent here (#1067 slice 2); the caller wraps the result as {result}. */
- rpc?: (fn: string, args: unknown[]) => Promise
+ rpc?: (projectId: string, fn: string, args: unknown[]) => Promise
}
-/** The body `POST /_relay/start` accepts: exactly what a local Start needs, minus any project id. */
+/** The body `POST /_relay/start` accepts: what a local Start needs, and the project's address. */
interface RelayStartBody {
prompt?: unknown
options?: unknown
+ project?: unknown
+}
+
+/** This machine's id for the project a call names, or undefined: no address, or no such project here. */
+async function projectNamed(handlers: RelayHandlers, address: unknown): Promise {
+ return typeof address === 'string' && address ? handlers.project(address) : undefined
}
const MAX_START_BODY = 256 * 1024
@@ -75,24 +87,33 @@ async function handleStart(req: IncomingMessage, res: ServerResponse, handlers:
const prompt = typeof body.prompt === 'string' ? body.prompt : ''
const options = (body.options && typeof body.options === 'object' ? body.options : {}) as StartAgentOptions
// Never relay onward from a relayed agent: strip any nested target before starting it here.
- // And a start relayed to a device never names a branch: the caller's branches are not this machine's.
- const { remote: _drop, base: _callers, ...local } = options
- let result: StartAgentResult
+ // And a start relayed to a machine never names a branch: the caller's branches are not this machine's.
+ const { machine: _drop, base: _callers, ...local } = options
+ let result: RelayStartResult
try {
- result = await handlers.start(prompt, local, undefined)
+ const projectId = await projectNamed(handlers, body.project)
+ result =
+ projectId === undefined
+ ? { ok: false, error: `no project on this machine is cloned from ${String(body.project)}`, noProject: true }
+ : await handlers.start(prompt, local, projectId)
} catch (err) {
result = { ok: false, error: err instanceof Error ? err.message : String(err) }
}
sendJson(res, result)
}
-/** `GET /_relay/events?run=`: stream the agent's events as newline-delimited JSON. */
-function handleEvents(req: IncomingMessage, res: ServerResponse, handlers: RelayHandlers): void {
+/** `GET /_relay/events?run=&project=`: stream the agent's events as newline-delimited JSON. */
+async function handleEvents(req: IncomingMessage, res: ServerResponse, handlers: RelayHandlers): Promise {
if (!requireGet(req, res)) return
- const agentId = new URL(req.url ?? '/', 'http://localhost').searchParams.get('run')
+ const query = new URL(req.url ?? '/', 'http://localhost').searchParams
+ const agentId = query.get('run')
if (!agentId) return end(res, 400, 'missing run id')
+ const projectId = await projectNamed(handlers, query.get('project')).catch(() => undefined)
+ if (projectId === undefined) return end(res, 404, 'no such project on this machine')
+ // The caller went away while the project was looked up: a tail started now would never be stopped.
+ if (res.destroyed || req.socket.destroyed) return
res.writeHead(200, { 'content-type': 'application/x-ndjson', 'cache-control': 'no-cache' })
- const stop = handlers.tailEvents(agentId, event => {
+ const stop = handlers.tailEvents(projectId, agentId, event => {
// A dropped write (the caller went away mid-line) must not throw out of the tail callback.
try {
res.write(`${JSON.stringify(event)}\n`)
@@ -106,13 +127,13 @@ function handleEvents(req: IncomingMessage, res: ServerResponse, handlers: Relay
}
const MAX_RPC_BODY = 256 * 1024
-/** POST /_relay/rpc: run one whitelisted RPC on this device and answer {result}. */
+/** POST /_relay/rpc: run one whitelisted RPC on this machine and answer {result}. */
async function handleRpc(req: IncomingMessage, res: ServerResponse, handlers: RelayHandlers): Promise {
if (req.method !== 'POST') return end(res, 405, 'method not allowed', { allow: 'POST' })
if (!handlers.rpc) return end(res, 404, 'relay rpc not enabled')
- let body: { fn?: unknown; args?: unknown }
+ let body: { fn?: unknown; args?: unknown; project?: unknown }
try {
- body = (await readJsonBody(req, MAX_RPC_BODY)) as { fn?: unknown; args?: unknown }
+ body = (await readJsonBody(req, MAX_RPC_BODY)) as { fn?: unknown; args?: unknown; project?: unknown }
} catch {
return end(res, 400, 'invalid request body')
}
@@ -120,7 +141,9 @@ async function handleRpc(req: IncomingMessage, res: ServerResponse, handlers: Re
const args = Array.isArray(body.args) ? body.args : []
if (!fn) return end(res, 400, 'missing rpc name')
try {
- sendJson(res, { result: await handlers.rpc(fn, args) })
+ const projectId = await projectNamed(handlers, body.project)
+ if (projectId === undefined) return end(res, 404, 'no such project on this machine')
+ sendJson(res, { result: await handlers.rpc(projectId, fn, args) })
} catch (err) {
end(res, 500, err instanceof Error ? err.message : 'rpc failed')
}
diff --git a/packages/openagent/src/dashboard/remote-run.integration.test.ts b/packages/openagent/src/dashboard/remote-run.integration.test.ts
index 2274c64a7..a0631f28e 100644
--- a/packages/openagent/src/dashboard/remote-run.integration.test.ts
+++ b/packages/openagent/src/dashboard/remote-run.integration.test.ts
@@ -1,26 +1,27 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
-import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
+import { execFileSync } from 'node:child_process'
+import { mkdtemp, mkdir, writeFile, rm, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
-import { join, resolve } from 'node:path'
+import { basename, join } from 'node:path'
import { EventStream } from '../event-stream.js'
-import { startDashboard } from './server.js'
+import { startDashboard, type Dashboard } from './server.js'
import { testDashboardOptions } from '../dashboard-rpc/test-context.js'
import { relayRpc } from './remote-run.js'
-import { createProjectRuntime } from '../daemon-runtime.js'
-import { dispatchRelayRpc } from '../dashboard-rpc/relay-dispatch.js'
+import { createProjectRuntime, type ProjectRuntime } from '../daemon-runtime.js'
import { forwardStream } from '../dashboard-rpc/stream-forward.js'
-import { projectId } from '../registry.js'
+import { addMachine, addProject, projectId } from '../registry.js'
import type { StartAgentOptions, StartAgentResult } from './types.js'
import type { OpenAgentEvent } from '../events.js'
import type { HandoffResult } from './agent-handoff.js'
-// The real two-daemon proof for "run on a connected device" (#1067). Two HTTP servers stand up on
-// loopback: daemon A (the browser's local daemon, a real project runtime) relays a start to daemon B
-// (the device, a dashboard whose Start is stubbed so no run actually begins). We assert the run is
-// created on B, that A resolved no project and ran no hook of its own, and that B's events stream
-// back through A's relayed-run source in order. That is the whole path minus the final same-origin
-// RPC hop on A, which relay.test.ts / server.test.ts cover on their own.
+// The real two-daemon proof for "run on a saved machine" (#1067). Two HTTP servers stand up on
+// loopback, each with a home file of its own: machine A (the person's own, a real project runtime)
+// relays a start to machine B (a dashboard whose Start is stubbed so no run actually begins). Both
+// have the same repository in a different folder, cloned a different way, and B has a second
+// project besides. We assert the run is created in B's copy of THAT project, that A ran no hook
+// of its own, and that B's events stream back through A's relayed-run source in order. That is
+// the whole path minus the final same-origin RPC hop on A, which server.test.ts covers on its own.
const TOKEN = 'zX2p8Q0hqk3m9tR7vN1cW4bY6sJ5aL0dFgHiKlMnOp'
@@ -32,6 +33,15 @@ async function fakeBundle(): Promise {
return dir
}
+/** A repository in a folder of its own, cloned (as far as its `origin` says) from `origin`, or with none. */
+async function repository(under: string, name: string, origin?: string): Promise {
+ const dir = join(under, name)
+ await mkdir(dir, { recursive: true })
+ execFileSync('git', ['init', '-q'], { cwd: dir })
+ if (origin) execFileSync('git', ['remote', 'add', 'origin', origin], { cwd: dir })
+ return dir
+}
+
/** Consume a stream until an event of `stopKind` arrives, or a timeout trips. */
async function collectUntil(stream: AsyncIterable, stopKind: string, timeoutMs = 4000): Promise {
const got: OpenAgentEvent[] = []
@@ -45,71 +55,127 @@ async function collectUntil(stream: AsyncIterable, stopKind: str
return got
}
-test('a run submitted with options.remote is created on the other daemon and its events stream back (#1067)', async () => {
- // Daemon B: the device. Its Start is stubbed to record the call and emit a short event stream,
- // so the relay path is exercised without spawning a real agent.
- const bStarts: Array<{ prompt: string; options: StartAgentOptions; projectId?: string }> = []
+const B_RUN = 'remote-run-1'
+
+interface TwoMachines {
+ /** Machine A's runtime, and the env its home file is read through. */
+ a: ProjectRuntime
+ envA: NodeJS.ProcessEnv
+ /** A's copy of the shop, a second project of A's with no remote, and the id A saved B under. */
+ shopA: string
+ localOnlyA: string
+ machineB: string
+ /** B's dashboard, its copy of the shop, and the Starts it was asked for. */
+ b: Dashboard
+ shopB: string
+ bStarts: Array<{ prompt: string; options: StartAgentOptions; projectId: string }>
+ close: () => Promise
+}
+
+/**
+ * Stand the two machines up. B's home file is the one `process.env` names, since the calls a
+ * relayed read lands in find a project's folder through it; A's is handed to its runtime.
+ */
+async function twoMachines(opts: { shopOnB?: boolean } = {}): Promise {
+ const root = await realpath(await mkdtemp(join(tmpdir(), 'relay-two-')))
+ const envA = { ...process.env, XDG_CONFIG_HOME: join(root, 'home-a') }
+ const envB = { ...process.env, XDG_CONFIG_HOME: join(root, 'home-b') }
+ const previous = process.env.XDG_CONFIG_HOME
+ process.env.XDG_CONFIG_HOME = envB.XDG_CONFIG_HOME
+ const at = new Date().toISOString()
+
+ // B: another project first on its list, then (unless the test says not) the shop, cloned over HTTPS.
+ const otherB = await repository(join(root, 'b'), 'other', 'https://github.com/acme/other.git')
+ const shopB = await repository(join(root, 'b'), 'work-shop', 'https://github.com/Acme/Shop.git')
+ await addProject(otherB, at, undefined, envB)
+ if (opts.shopOnB !== false) await addProject(shopB, at, undefined, envB)
+ const runtimeB = createProjectRuntime({ cwd: otherB, env: envB })
+
+ // B's Start is stubbed to record the call and emit a short event stream, so the relay path is
+ // exercised without spawning a real agent.
+ const bStarts: TwoMachines['bStarts'] = []
const bStreams = new Map>()
- const B_RUN = 'remote-run-1'
const bStart = (prompt: string, options: StartAgentOptions, pid?: string): StartAgentResult => {
- bStarts.push({ prompt, options, ...(pid ? { projectId: pid } : {}) })
+ bStarts.push({ prompt, options, projectId: pid ?? '(none)' })
const stream = new EventStream()
stream.push({ kind: 'session-update', sessionId: 'hello from B' } as OpenAgentEvent)
stream.push({ kind: 'end', ok: true } as OpenAgentEvent)
stream.close()
- bStreams.set(B_RUN, stream)
+ bStreams.set(`${pid} ${B_RUN}`, stream)
return { ok: true, agentId: B_RUN }
}
- const bTail = (agentId: string, onEvent: (event: OpenAgentEvent) => void): (() => void) => forwardStream(bStreams.get(agentId), onEvent)
+ const bTail = (pid: string, agentId: string, onEvent: (event: OpenAgentEvent) => void): (() => void) => forwardStream(bStreams.get(`${pid} ${agentId}`), onEvent)
const bundle = await fakeBundle()
- // B's own home checkout, whose id the device-side dispatch forces every relayed RPC onto (slice 2), so
- // a relayed read can only ever address B's own project. A plain temp dir (no repo, not registered), which
- // is enough to prove the /_relay/rpc path: an unregistered home resolves to no checkout, so onGitStatus
- // comes back null - proof the call reached B's own onGitStatus and returned over the endpoint.
- const cwdB = await mkdtemp(join(tmpdir(), 'relay-b-'))
- const homeIdB = projectId(resolve(cwdB))
- const bRpc = (fn: string, args: unknown[]): Promise => dispatchRelayRpc(homeIdB, fn, args)
// The rest of the one host's wiring (D3) comes from the shared defaults: this test drives the
// relay endpoints, not the other capabilities.
- const deviceB = await startDashboard(
+ const b = await startDashboard(
testDashboardOptions({
clientBundleDir: bundle,
token: TOKEN,
onStart: bStart,
- relay: { tailEvents: bTail, rpc: bRpc },
+ relay: { project: runtimeB.projectAt, tailEvents: bTail, rpc: runtimeB.onRelayRpc },
}),
)
- // Daemon A: the browser's own daemon, a real project runtime. Its onStart takes the remote branch.
- const cwdA = await mkdtemp(join(tmpdir(), 'relay-a-'))
- const runtimeA = createProjectRuntime({ cwd: cwdA, env: process.env })
- const homeIdA = projectId(resolve(cwdA))
+ // A: the shop cloned over SSH into another folder, a project with no remote, and B saved as a machine.
+ const shopA = await repository(join(root, 'a'), 'shop', 'git@github.com:acme/shop.git')
+ const localOnlyA = await repository(join(root, 'a'), 'notes')
+ await addProject(shopA, at, undefined, envA)
+ await addProject(localOnlyA, at, undefined, envA)
+ const machineB = (await addMachine({ url: b.url, token: TOKEN, label: 'Studio' }, undefined, envA)).id
+ const a = createProjectRuntime({ cwd: shopA, env: envA })
+
+ return {
+ a,
+ envA,
+ shopA,
+ localOnlyA,
+ machineB,
+ b,
+ shopB,
+ bStarts,
+ close: async () => {
+ await a.dispose()
+ await runtimeB.dispose()
+ await b.close()
+ if (previous === undefined) delete process.env.XDG_CONFIG_HOME
+ else process.env.XDG_CONFIG_HOME = previous
+ await rm(bundle, { recursive: true, force: true })
+ await rm(root, { recursive: true, force: true })
+ },
+ }
+}
+test('a run started on a saved machine lands in that machine\'s own copy of the project, and its events stream back (#1067)', async () => {
+ const m = await twoMachines()
try {
- const result = await runtimeA.onStart('build the thing', { base: 'a-branch-of-this-machine', remote: { url: deviceB.url, token: TOKEN, label: 'my-laptop' } })
+ const shopIdA = projectId(m.shopA)
+ const result = await m.a.onStart('build the thing', { base: 'a-branch-of-this-machine', model: 'opus', machine: m.machineB }, shopIdA)
// The agent was created on B, and A returned B's own agent id (not a locally allocated one).
- assert.equal(result.ok, true)
- assert.equal(result.ok && result.agentId, B_RUN)
- assert.equal(bStarts.length, 1)
- assert.equal(bStarts[0]!.prompt, 'build the thing')
- assert.equal(bStarts[0]!.options.remote, undefined) // stripped before forwarding, no onward relay
- assert.equal(bStarts[0]!.options.base, undefined) // a branch of this machine names nothing on the device
- assert.equal(bStarts[0]!.projectId, undefined) // slice 1: the device's own home checkout
-
- // The relayed agent keeps a local list row on A (#1077), so a dashboard reload re-opens it instead of
- // losing it: a remote stub carrying B's agent id, the device label, and the prompt, running until the
- // relay stream ends. Read before draining, while it is still live.
- const listed = runtimeA.remoteAgents.list(homeIdA)
+ assert.deepEqual(result, { ok: true, agentId: B_RUN })
+ assert.equal(m.bStarts.length, 1)
+ assert.equal(m.bStarts[0]!.prompt, 'build the thing')
+ // In B's copy of the shop: not the folder B's daemon was started in, and not A's id for the project.
+ assert.equal(m.bStarts[0]!.projectId, projectId(m.shopB))
+ assert.notEqual(projectId(m.shopB), shopIdA)
+ assert.equal(m.bStarts[0]!.options.machine, undefined) // stripped before forwarding, no onward relay
+ assert.equal(m.bStarts[0]!.options.base, undefined) // a branch of this machine names nothing on the other
+ assert.equal(m.bStarts[0]!.options.model, 'opus') // the rest of the picks arrive
+
+ // The relayed agent keeps a local list row on A (#1077), under A's project, so a dashboard reload
+ // re-opens it instead of losing it: a remote stub carrying B's agent id, the machine's name, and
+ // the prompt, running until the relay stream ends. Read before draining, while it is still live.
+ const listed = m.a.remoteAgents.list(shopIdA)
assert.equal(listed.length, 1)
assert.equal(listed[0]!.id, B_RUN)
assert.equal(listed[0]!.target, 'remote')
assert.equal(listed[0]!.status, 'running')
- assert.equal(listed[0]!.remoteLabel, 'my-laptop')
+ assert.equal(listed[0]!.remoteLabel, 'Studio')
assert.equal(listed[0]!.intent, 'build the thing')
// The events stream back through A's relayed-run source, in order.
- const stream = runtimeA.remoteEventsSource(homeIdA, B_RUN)
+ const stream = m.a.remoteEventsSource(shopIdA, B_RUN)
assert.ok(stream, 'A should expose a live stream for the relayed run')
const events = await collectUntil(stream!, 'end')
assert.deepEqual(
@@ -119,23 +185,55 @@ test('a run submitted with options.remote is created on the other daemon and its
// B's stubbed start pushed `{kind:'end', ok:true}` and closed, so once A has drained the relayed
// stream the list row settles to done (#1077): the state a reload would now read off the list.
- assert.equal(runtimeA.remoteAgents.list(homeIdA)[0]!.status, 'done')
-
- // Slice 2: a run-scoped read relays to B over /_relay/rpc and comes back. The caller's arg[0] is
- // A's local project id; B's dispatch drops it and reads against its own home, which has no repo
- // registered, so onGitStatus returns null - proof the call reached B's onGitStatus and returned.
- const gitStatus = await relayRpc({ url: deviceB.url, token: TOKEN }, 'onGitStatus', [homeIdA, B_RUN])
- assert.equal(gitStatus, null)
+ assert.equal(m.a.remoteAgents.list(shopIdA)[0]!.status, 'done')
- // And a publish runs ON the device: B_RUN is not a real session on B, so its sendOpenPullRequest
- // returns an ok:false HandoffResult - proof the call ran on B's side (its checkout, its remote) and came back.
- const push = (await relayRpc({ url: deviceB.url, token: TOKEN }, 'sendOpenPullRequest', [homeIdA, B_RUN])) as HandoffResult
+ // Slice 2: a run-scoped call relays to B over /_relay/rpc with the target A kept for the run, and
+ // runs in B's copy of the shop. The caller's arg[0] is A's project id; B's dispatch replaces it
+ // with its own. B_RUN is not a real session on B, so the publish comes back refused: proof the
+ // call ran on B's side (its checkout, its remote) and came back.
+ const target = m.a.remoteAgents.target(B_RUN)
+ assert.deepEqual(target, { url: m.b.url, token: TOKEN, project: 'github.com/acme/shop' })
+ const push = (await relayRpc(target!, 'sendOpenPullRequest', [shopIdA, B_RUN])) as HandoffResult
assert.equal(push.ok, false)
+ // The same call for a project B does not have reaches no project at all.
+ await assert.rejects(relayRpc({ ...target!, project: 'github.com/acme/nowhere' }, 'sendOpenPullRequest', [shopIdA, B_RUN]), /404/)
+ } finally {
+ await m.close()
+ }
+})
+
+test('a project the other machine does not have is refused by name, and starts nothing there', async () => {
+ const m = await twoMachines({ shopOnB: false })
+ try {
+ const shopIdA = projectId(m.shopA)
+ const result = await m.a.onStart('build the thing', { machine: m.machineB }, shopIdA)
+ assert.deepEqual(result, { ok: false, error: `${basename(m.shopA)} is not on Studio. Add it there first.` })
+ assert.equal(m.bStarts.length, 0) // never in B's other project
+ assert.deepEqual(m.a.remoteAgents.list(shopIdA), [])
+ } finally {
+ await m.close()
+ }
+})
+
+test('a project with no repository address cannot be sent to another machine: the Start is refused and nothing is sent', async () => {
+ const m = await twoMachines()
+ try {
+ const result = await m.a.onStart('build the thing', { machine: m.machineB }, projectId(m.localOnlyA))
+ assert.deepEqual(result, { ok: false, error: 'This project has no repository address, so it cannot be sent to another machine.' })
+ assert.equal(m.bStarts.length, 0)
+ } finally {
+ await m.close()
+ }
+})
+
+test('a machine that is no longer saved is refused, and the run does not start here in its place', async () => {
+ const m = await twoMachines()
+ try {
+ const result = await m.a.onStart('build the thing', { machine: 'http://10.9.9.9:4200' }, projectId(m.shopA))
+ assert.equal(result.ok, false)
+ assert.match(result.ok ? '' : result.error, /no longer saved/)
+ assert.equal(m.bStarts.length, 0)
} finally {
- await runtimeA.dispose()
- await deviceB.close()
- await rm(bundle, { recursive: true, force: true })
- await rm(cwdA, { recursive: true, force: true })
- await rm(cwdB, { recursive: true, force: true })
+ await m.close()
}
})
diff --git a/packages/openagent/src/dashboard/remote-run.test.ts b/packages/openagent/src/dashboard/remote-run.test.ts
index 83867a072..292a297d2 100644
--- a/packages/openagent/src/dashboard/remote-run.test.ts
+++ b/packages/openagent/src/dashboard/remote-run.test.ts
@@ -2,10 +2,13 @@ import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
-import { startRemoteAgent, streamRemoteEvents, pingRemote, relayRpc, RelayedAgents } from './remote-run.js'
+import { startRemoteAgent, streamRemoteEvents, pingRemote, relayRpc, RelayedAgents, type RemoteTarget } from './remote-run.js'
import { type AgentMeta } from '../store/index.js'
import type { OpenAgentEvent } from '../events.js'
+/** The project every call here names: a repository's address, the name both machines have for it. */
+const PROJECT = 'github.com/acme/shop'
+
// A throwaway loopback server; the handler decides how it answers. Returns its base url + close.
async function server(handler: (req: IncomingMessage, res: ServerResponse) => void): Promise<{ url: string; close: () => Promise }> {
const srv: Server = createServer(handler)
@@ -24,11 +27,11 @@ function stubMeta(id: string, overrides: Partial = {}): AgentMeta {
async function drainAgent(agents: RelayedAgents, agentId: string): Promise {
const stream = agents.get(agentId)
assert.ok(stream)
- for await (const _e of stream!) { /* consume until the device closes the body */ }
+ for await (const _e of stream!) { /* consume until the machine closes the body */ }
}
/** Wait until the stream ends (its `onEnd` fires) or a timeout trips, collecting events meanwhile. */
-function drain(target: { url: string; token: string }, agentId: string, timeoutMs = 4000): Promise<{ events: OpenAgentEvent[]; ended: boolean }> {
+function drain(target: RemoteTarget, agentId: string, timeoutMs = 4000): Promise<{ events: OpenAgentEvent[]; ended: boolean }> {
return new Promise(resolvePromise => {
const events: OpenAgentEvent[] = []
const timer = setTimeout(() => resolvePromise({ events, ended: false }), timeoutMs)
@@ -51,27 +54,27 @@ test('startRemoteAgent posts to /_relay/start with the oa_daemon cookie, no Orig
})
})
try {
- const result = await startRemoteAgent({ url: srv.url, token: 'sekret' }, { prompt: 'do it', options: { model: 'opus' } })
+ const result = await startRemoteAgent({ url: srv.url, token: 'sekret', project: PROJECT }, { prompt: 'do it', options: { model: 'opus' } })
assert.deepEqual(result, { ok: true, agentId: 'r1' })
assert.equal(captured.method, 'POST')
assert.equal(captured.url, '/_relay/start')
assert.equal(captured.cookie, 'oa_daemon=sekret') // the shared-token cookie (#1051), daemon to daemon
assert.equal(captured.origin, undefined) // NO Origin header, so it passes the remote CSRF guard
- assert.deepEqual(captured.body, { prompt: 'do it', options: { model: 'opus' } })
+ assert.deepEqual(captured.body, { prompt: 'do it', options: { model: 'opus' }, project: PROJECT }) // the project rides every start
} finally {
await srv.close()
}
})
-test('startRemoteAgent surfaces a non-2xx from the device as an ok:false result (#1067)', async () => {
+test('startRemoteAgent surfaces a non-2xx from the machine as an ok:false result (#1067)', async () => {
const srv = await server((_req, res) => {
res.writeHead(403, { 'content-type': 'text/plain' })
res.end('unauthorized')
})
try {
- const result = await startRemoteAgent({ url: srv.url, token: 'wrong' }, { prompt: 'x', options: {} })
+ const result = await startRemoteAgent({ url: srv.url, token: 'wrong', project: PROJECT }, { prompt: 'x', options: {} })
assert.equal(result.ok, false)
- if (!result.ok) assert.match(result.error, /403|device/)
+ if (!result.ok) assert.match(result.error, /403|machine/)
} finally {
await srv.close()
}
@@ -94,7 +97,7 @@ test('pingRemote GETs /_relay/ping with the oa_daemon cookie and is true on a 2x
}
})
-test('pingRemote is false on a non-2xx from the device (#1072)', async () => {
+test('pingRemote is false on a non-2xx from the machine (#1072)', async () => {
const srv = await server((_req, res) => {
res.writeHead(401, { 'content-type': 'text/plain' })
res.end('unauthorized')
@@ -106,22 +109,26 @@ test('pingRemote is false on a non-2xx from the device (#1072)', async () => {
}
})
-test('pingRemote is false when the device is unreachable (#1072)', async () => {
+test('pingRemote is false when the machine is unreachable (#1072)', async () => {
// A port nothing is listening on: the fetch rejects, which pingRemote swallows as offline.
assert.equal(await pingRemote({ url: 'http://127.0.0.1:1', token: 't' }), false)
})
test('streamRemoteEvents parses ndjson lines in order and ends when the body closes (#1067)', async () => {
- const srv = await server((_req, res) => {
+ let asked: string | undefined
+ const srv = await server((req, res) => {
+ asked = req.url
res.writeHead(200, { 'content-type': 'application/x-ndjson' })
res.write(`${JSON.stringify({ kind: 'session-update', sessionId: 'a' })}\n`)
res.write(`${JSON.stringify({ kind: 'session-update', sessionId: 'b' })}\n`)
res.end()
})
try {
- const { events, ended } = await drain({ url: srv.url, token: 't' }, 'r1')
+ const { events, ended } = await drain({ url: srv.url, token: 't', project: PROJECT }, 'r1')
assert.deepEqual(events.map(e => (e as { sessionId?: string }).sessionId), ['a', 'b'])
assert.equal(ended, true)
+ // The run and its project, by the repository's address: the machine has no other way to find the run's folder.
+ assert.equal(asked, '/_relay/events?run=r1&project=github.com%2Facme%2Fshop')
} finally {
await srv.close()
}
@@ -137,7 +144,7 @@ test('a line split across two chunks is reassembled, not dropped (#1067)', async
res.end()
})
try {
- const { events } = await drain({ url: srv.url, token: 't' }, 'r1')
+ const { events } = await drain({ url: srv.url, token: 't', project: PROJECT }, 'r1')
assert.deepEqual(events.map(e => (e as { sessionId?: string }).sessionId), ['split'])
} finally {
await srv.close()
@@ -150,7 +157,7 @@ test('a 401 from the remote (rotated token) surfaces as a clean stream-end, no e
res.end('unauthorized')
})
try {
- const { events, ended } = await drain({ url: srv.url, token: 'stale' }, 'r1')
+ const { events, ended } = await drain({ url: srv.url, token: 'stale', project: PROJECT }, 'r1')
assert.equal(events.length, 0)
assert.equal(ended, true) // ended cleanly (a done), not an error the caller has to retry
} finally {
@@ -158,7 +165,7 @@ test('a 401 from the remote (rotated token) surfaces as a clean stream-end, no e
}
})
-test('RelayedAgents feeds a run stream from the device and drops its token when the stream ends (#1067)', async () => {
+test('RelayedAgents feeds a run stream from the machine and drops its token when the stream ends (#1067)', async () => {
const srv = await server((_req, res) => {
res.writeHead(200, { 'content-type': 'application/x-ndjson' })
res.write(`${JSON.stringify({ kind: 'session-update', sessionId: 'hi' })}\n`)
@@ -166,11 +173,11 @@ test('RelayedAgents feeds a run stream from the device and drops its token when
})
try {
const agents = new RelayedAgents()
- agents.register('r1', { url: srv.url, token: 't' }, stubMeta('r1'), 'proj-1')
+ agents.register('r1', { url: srv.url, token: 't', project: PROJECT }, stubMeta('r1'), 'proj-1')
const stream = agents.get('r1') // grabbed synchronously, before the remote stream ends
assert.ok(stream)
const got: OpenAgentEvent[] = []
- for await (const e of stream!) got.push(e) // replays, then ends when the device closes the body
+ for await (const e of stream!) got.push(e) // replays, then ends when the machine closes the body
assert.deepEqual(got.map(e => (e as { sessionId?: string }).sessionId), ['hi'])
assert.equal(agents.get('r1'), undefined) // dropped: the token no longer lives here
} finally {
@@ -185,7 +192,7 @@ test('RelayedAgents closes cleanly on a 401 with no events (#1067)', async () =>
})
try {
const agents = new RelayedAgents()
- agents.register('r1', { url: srv.url, token: 'stale' }, stubMeta('r1'), 'proj-1')
+ agents.register('r1', { url: srv.url, token: 'stale', project: PROJECT }, stubMeta('r1'), 'proj-1')
const stream = agents.get('r1')
assert.ok(stream)
const got: OpenAgentEvent[] = []
@@ -196,7 +203,7 @@ test('RelayedAgents closes cleanly on a 401 with no events (#1067)', async () =>
}
})
-test('relayRpc posts to /_relay/rpc with the oa_daemon cookie, no Origin, and returns the device result (#1067 slice 2)', async () => {
+test('relayRpc posts to /_relay/rpc with the oa_daemon cookie, no Origin, and returns the machine result (#1067 slice 2)', async () => {
let captured: { method?: string | undefined; url?: string | undefined; cookie?: string | undefined; origin?: string | undefined; body: unknown } = { body: null }
const srv = await server((req, res) => {
let raw = ''
@@ -208,25 +215,25 @@ test('relayRpc posts to /_relay/rpc with the oa_daemon cookie, no Origin, and re
})
})
try {
- const result = await relayRpc({ url: srv.url, token: 'sekret' }, 'onGitStatus', ['pid', 'r1'])
- assert.deepEqual(result, { dirty: true, branch: 'main' }) // the device's own result, unwrapped from {result}
+ const result = await relayRpc({ url: srv.url, token: 'sekret', project: PROJECT }, 'onGitStatus', ['pid', 'r1'])
+ assert.deepEqual(result, { dirty: true, branch: 'main' }) // the machine's own result, unwrapped from {result}
assert.equal(captured.method, 'POST')
assert.equal(captured.url, '/_relay/rpc')
assert.equal(captured.cookie, 'oa_daemon=sekret') // the shared-token cookie (#1051), daemon to daemon
assert.equal(captured.origin, undefined) // NO Origin header, so it passes the remote CSRF guard
- assert.deepEqual(captured.body, { fn: 'onGitStatus', args: ['pid', 'r1'] })
+ assert.deepEqual(captured.body, { fn: 'onGitStatus', args: ['pid', 'r1'], project: PROJECT })
} finally {
await srv.close()
}
})
-test('relayRpc throws on a non-2xx from the device (#1067 slice 2)', async () => {
+test('relayRpc throws on a non-2xx from the machine (#1067 slice 2)', async () => {
const srv = await server((_req, res) => {
res.writeHead(500, { 'content-type': 'text/plain' })
res.end('rpc failed')
})
try {
- await assert.rejects(relayRpc({ url: srv.url, token: 't' }, 'onGitStatus', []), /500|device/)
+ await assert.rejects(relayRpc({ url: srv.url, token: 't', project: PROJECT }, 'onGitStatus', []), /500|machine/)
} finally {
await srv.close()
}
@@ -238,7 +245,7 @@ test('RelayedAgents.list surfaces a relayed run as a remote row, scoped to its p
const srv = await server((_req, res) => res.writeHead(200, { 'content-type': 'application/x-ndjson' }))
try {
const agents = new RelayedAgents()
- agents.register('r1', { url: srv.url, token: 't' }, stubMeta('r1', { intent: 'do it' }), 'proj-1')
+ agents.register('r1', { url: srv.url, token: 't', project: PROJECT }, stubMeta('r1', { intent: 'do it' }), 'proj-1')
const rows = agents.list('proj-1') // read before the fetch does anything: the stub is set synchronously
assert.equal(rows.length, 1)
assert.equal(rows[0]?.id, 'r1')
@@ -252,7 +259,7 @@ test('RelayedAgents.list surfaces a relayed run as a remote row, scoped to its p
}
})
-// Register a relayed agent against a device that emits one log line then an optional end line and closes,
+// Register a relayed agent against a machine that emits one log line then an optional end line and closes,
// and return the status left on its list row once RelayedAgents has fully drained the stream (#1077).
async function relayEndStatus(endEvent: OpenAgentEvent | null): Promise {
const srv = await server((_req, res) => {
@@ -263,7 +270,7 @@ async function relayEndStatus(endEvent: OpenAgentEvent | null): Promise {
+test("a relayed run's list row flips to the machine's ending, or stopped if the stream just drops (#1077)", async () => {
assert.equal(await relayEndStatus({ kind: 'end', ok: true } as OpenAgentEvent), 'done')
assert.equal(await relayEndStatus({ kind: 'end', stopped: true, ok: false } as OpenAgentEvent), 'stopped')
assert.equal(await relayEndStatus({ kind: 'end', ok: false } as OpenAgentEvent), 'failed')
- // A run that ended on a question waits for its answer: the row says so, as the device's own does.
+ // A run that ended on a question waits for its answer: the row says so, as the machine's own does.
assert.equal(await relayEndStatus({ kind: 'end', ok: false, waiting: true } as OpenAgentEvent), 'waiting')
assert.equal(await relayEndStatus(null), 'stopped') // no end event: the stream dropped, so it is no longer live
})
-test('dispose clears the relayed run list and its device target (#1077)', async () => {
+test('dispose clears the relayed run list and its machine target (#1077)', async () => {
const srv = await server((_req, res) => {
res.writeHead(200, { 'content-type': 'application/x-ndjson' })
res.end()
})
try {
const agents = new RelayedAgents()
- agents.register('r1', { url: srv.url, token: 't' }, stubMeta('r1'), 'proj-1')
+ agents.register('r1', { url: srv.url, token: 't', project: PROJECT }, stubMeta('r1'), 'proj-1')
assert.equal(agents.list('proj-1').length, 1) // present before shutdown
agents.dispose()
assert.deepEqual(agents.list('proj-1'), []) // and gone after
@@ -305,13 +312,13 @@ test('RelayedAgents.target outlives the event stream and dispose clears it (#106
})
try {
const agents = new RelayedAgents()
- const target = { url: srv.url, token: 't' }
+ const target = { url: srv.url, token: 't', project: PROJECT }
agents.register('r1', target, stubMeta('r1'), 'proj-1')
const stream = agents.get('r1')
assert.ok(stream)
- for await (const _e of stream!) { /* drain until the device closes the body, ending the pump */ }
- assert.equal(agents.get('r1'), undefined) // the event stream is gone once the device closes
- assert.deepEqual(agents.target('r1'), target) // but the device target outlives it, for a post-run push/PR
+ for await (const _e of stream!) { /* drain until the machine closes the body, ending the pump */ }
+ assert.equal(agents.get('r1'), undefined) // the event stream is gone once the machine closes
+ assert.deepEqual(agents.target('r1'), target) // but the machine target outlives it, for a post-run push/PR
agents.dispose()
assert.equal(agents.target('r1'), undefined) // cleared on shutdown
} finally {
diff --git a/packages/openagent/src/dashboard/remote-run.ts b/packages/openagent/src/dashboard/remote-run.ts
index d41517598..e134bf545 100644
--- a/packages/openagent/src/dashboard/remote-run.ts
+++ b/packages/openagent/src/dashboard/remote-run.ts
@@ -5,41 +5,54 @@ import type { StartAgentOptions, StartAgentResult } from './types.js'
import { errorMessage } from '../error-message.js'
/**
- * The server-side half of "run on a connected device" (#1067). The local daemon holds the saved
- * device's token, so it - not the browser - drives the remote daemon: it POSTs the agent to the
+ * The server-side half of "run on a saved machine" (#1067). The local daemon holds the saved
+ * machine's token, so it - not the browser - drives the remote daemon: it POSTs the agent to the
* remote's `/_relay/start` and then fetch-streams the remote's `/_relay/events` back into a local
* {@link EventStream}, which the dashboard reads over its normal same-origin `onEvents` channel. So
* the browser never talks cross-origin and the token never leaves the two daemons (issue #1067 (b)).
*
+ * Every call but the ping names its project, by its repository's address
+ * (repository-address.ts): a project's folder, and so its id, is another one on each machine.
+ *
* Authentication is the shared-token cookie (#1051), sent daemon-to-daemon: `Cookie: oa_daemon=` with no
* `Origin` header. The remote's guard admits a matching cookie without the browser-only `?token=`
* 302, and its `/_rpc` CSRF check (absent Origin passes) is not even on these raw routes.
*/
-/** Where a relayed agent executes: the remote daemon's origin and its #1051 token. Memory-only. */
-export interface RemoteTarget {
+/** How a machine is reached: its daemon's origin and its #1051 token. */
+export interface MachineAccess {
url: string
token: string
}
-/** The body a relay start forwards to the remote's `/_relay/start`. */
+/** Where a relayed agent works: a machine, and the project there, by its repository's address. */
+export interface RemoteTarget extends MachineAccess {
+ project: string
+}
+
+/** What a relay start forwards to the remote's `/_relay/start`, beside the project. */
export interface RelayStartBody {
prompt: string
options: StartAgentOptions
}
+/**
+ * What a machine answers a relayed start with: a Start's own outcome, or `noProject` when it has
+ * no project cloned from the address the start named.
+ */
+export type RelayStartResult = StartAgentResult | { ok: false; error: string; noProject: true }
+
const START_TIMEOUT_MS = 15_000
-/** How long a status ping waits before calling a device offline (#1072): short, since it polls. */
+/** How long a status ping waits before calling a machine offline (#1072): short, since it polls. */
const PING_TIMEOUT_MS = 3_000
/**
- * Health-check a saved device (#1072): a cookie'd `GET /_relay/ping`, true on any 2xx, false on a
- * non-2xx, an unreachable host, or the timeout. The token stays in memory for the check only, never
- * persisted, same as {@link startRemoteAgent}. This is how the browser's status dots learn reachable
- * from not: it has the tokens, the daemon does the cross-origin request.
+ * Health-check a saved machine (#1072): a cookie'd `GET /_relay/ping`, true on any 2xx, false on a
+ * non-2xx, an unreachable host, or the timeout. This is how the browser's status dots learn
+ * reachable from not.
*/
-export async function pingRemote(target: RemoteTarget): Promise {
+export async function pingRemote(target: MachineAccess): Promise {
try {
const res = await fetch(`${trimSlashes(target.url)}/_relay/ping`, {
headers: { cookie: `oa_daemon=${target.token}` },
@@ -57,42 +70,43 @@ function relayHeaders(token: string): Record {
}
/**
- * Start an agent on the remote daemon and return its {@link StartAgentResult} (with the remote's own run
- * id). A non-2xx or a transport failure surfaces as an `ok: false` result the dashboard shows, the
- * same shape a local refusal has, so the caller does not special-case remote errors.
+ * Start an agent on the remote daemon, in its project cloned from `target.project`, and return
+ * what it answered (with the remote's own run id). A non-2xx or a transport failure surfaces as an
+ * `ok: false` result the dashboard shows, the same shape a local refusal has, so the caller does
+ * not special-case remote errors.
*/
-export async function startRemoteAgent(target: RemoteTarget, body: RelayStartBody): Promise {
+export async function startRemoteAgent(target: RemoteTarget, body: RelayStartBody): Promise {
try {
const res = await fetch(`${trimSlashes(target.url)}/_relay/start`, {
method: 'POST',
headers: relayHeaders(target.token),
- body: JSON.stringify(body),
+ body: JSON.stringify({ ...body, project: target.project }),
signal: AbortSignal.timeout(START_TIMEOUT_MS),
})
- if (!res.ok) return { ok: false, error: `the device refused the run (${res.status})` }
- return (await res.json()) as StartAgentResult
+ if (!res.ok) return { ok: false, error: `the machine refused the run (${res.status})` }
+ return (await res.json()) as RelayStartResult
} catch (err) {
- return { ok: false, error: `could not reach the device: ${errorMessage(err)}` }
+ return { ok: false, error: `could not reach the machine: ${errorMessage(err)}` }
}
}
-const RPC_TIMEOUT_MS = 60_000 // a relayed git push/PR runs over the network on the device
+const RPC_TIMEOUT_MS = 60_000 // a relayed git push/PR runs over the network on the machine
/**
- * Relay one run-scoped RPC to the device that owns a remote agent (#1067 slice 2). The local daemon
- * holds the device token, so a read/diff/handoff/push/PR for a relayed agent runs ON the device: POST
- * {fn, args} to the remote's /_relay/rpc over the shared-token cookie (#1051) (no Origin), returning the device's
- * result. Throws on an unreachable device or a non-2xx so the caller falls back to its own empty/error
+ * Relay one run-scoped RPC to the machine that owns a remote agent (#1067 slice 2). The local daemon
+ * holds the machine token, so a read/diff/handoff/push/PR for a relayed agent runs ON the machine: POST
+ * {fn, args} to the remote's /_relay/rpc over the shared-token cookie (#1051) (no Origin), returning the machine's
+ * result. Throws on an unreachable machine or a non-2xx so the caller falls back to its own empty/error
* shape, the same way a failed local read does.
*/
export async function relayRpc(target: RemoteTarget, fn: string, args: unknown[]): Promise {
const res = await fetch(`${trimSlashes(target.url)}/_relay/rpc`, {
method: 'POST',
headers: relayHeaders(target.token),
- body: JSON.stringify({ fn, args }),
+ body: JSON.stringify({ fn, args, project: target.project }),
signal: AbortSignal.timeout(RPC_TIMEOUT_MS),
})
- if (!res.ok) throw new Error(`the device refused the request (${res.status})`)
+ if (!res.ok) throw new Error(`the machine refused the request (${res.status})`)
const body = (await res.json()) as { result?: unknown }
return body.result
}
@@ -118,9 +132,9 @@ export function streamRemoteEvents(
}
void (async () => {
try {
- const url = `${trimSlashes(target.url)}/_relay/events?run=${encodeURIComponent(agentId)}`
+ const url = `${trimSlashes(target.url)}/_relay/events?run=${encodeURIComponent(agentId)}&project=${encodeURIComponent(target.project)}`
const res = await fetch(url, { headers: relayHeaders(target.token), signal: controller.signal })
- // 401 = the device rotated its token. Nothing more will stream; end cleanly (a done, not a loss).
+ // 401 = the machine rotated its token. Nothing more will stream; end cleanly (a done, not a loss).
if (!res.ok || !res.body) return end()
const reader = res.body.getReader()
const decoder = new TextDecoder()
@@ -170,11 +184,11 @@ interface RelayedAgent {
/**
* The local daemon's live relayed runs (#1067), keyed by the remote agent id. Registering an agent opens
* an {@link EventStream} the dashboard reads through `onEvents`, fed by {@link streamRemoteEvents}
- * from the remote. This map is where a saved device's token lives daemon-side: in memory, for the
- * run's lifetime.
+ * from the remote. The target is the saved machine as it was at the Start, so a machine removed
+ * since still answers for the runs it has.
*
* The `targets` map outlives the event pump (#1067 slice 2): a finished remote agent's post-run reads,
- * open-PR and merge still have to reach the device after its event stream has ended, so the device
+ * open-PR and merge still have to reach the machine after its event stream has ended, so the machine
* target is kept until {@link dispose} clears it, not dropped when the stream closes.
*
* The `metas` map (#1077) holds a local {@link AgentMeta} stub per relayed agent so `onAgents` can show a
@@ -196,7 +210,7 @@ export class RelayedAgents {
const stream = new EventStream()
const cancel = streamRemoteEvents(target, agentId, event => {
stream.push(event)
- this.apply(agentId, event) // fold the event into the run's list row, mirroring the device
+ this.apply(agentId, event) // fold the event into the run's list row, mirroring the machine
}, () => this.endStream(agentId))
this.agents.set(agentId, { target, stream, cancel })
}
@@ -206,7 +220,7 @@ export class RelayedAgents {
return agentId ? this.agents.get(agentId)?.stream : undefined
}
- /** The device a relayed agent runs on, kept past the event stream so post-run push/PR still reach it. */
+ /** The machine a relayed agent runs on, kept past the event stream so post-run push/PR still reach it. */
target(agentId: string | undefined): RemoteTarget | undefined {
return agentId ? this.targets.get(agentId) : undefined
}
@@ -220,7 +234,7 @@ export class RelayedAgents {
}
/** Fold each relayed event into the agent's list row via the store's own reducer (#1077), so the
- * local stub mirrors the device: the terminal status on `end`, the waiting flag while it is parked
+ * local stub mirrors the machine: the terminal status on `end`, the waiting flag while it is parked
* (#785), the driver once its session starts. Events carry no write time, so this stamps its own. */
private apply(agentId: string, event: OpenAgentEvent): void {
const entry = this.metas.get(agentId)
@@ -239,7 +253,7 @@ export class RelayedAgents {
if (entry && entry.meta.status === 'running') entry.meta.status = 'stopped'
}
- /** Stop every pump, close every stream, and forget every device target + list stub, on daemon shutdown. */
+ /** Stop every pump, close every stream, and forget every machine target + list stub, on daemon shutdown. */
dispose(): void {
for (const [agentId, agent] of this.agents) {
agent.cancel()
@@ -257,8 +271,8 @@ function trimSlashes(url: string): string {
}
/**
- * Fold one relayed event into the memory-only row of a run on a device: what the device's own
- * card would say, kept here because that card is on the device. The events are the ones a run's
+ * Fold one relayed event into the memory-only row of a run on a machine: what the machine's own
+ * card would say, kept here because that card is on the machine. The events are the ones a run's
* diary yields: the agent's session id, its cost, its end.
*/
export function foldRelayedEvent(meta: AgentMeta, event: OpenAgentEvent, at: string): AgentMeta {
diff --git a/packages/openagent/src/dashboard/rpc-serve.test.ts b/packages/openagent/src/dashboard/rpc-serve.test.ts
index 8b7a4edb4..9f0fdb4a5 100644
--- a/packages/openagent/src/dashboard/rpc-serve.test.ts
+++ b/packages/openagent/src/dashboard/rpc-serve.test.ts
@@ -72,7 +72,7 @@ test('every RPC the modules export is dispatchable by its own name', () => {
// The registry is built from the modules' exports rather than a hand-written list, which is what
// makes "exported but never registered" impossible — the failure that shipped per-project
// preferences broken (#866) as a 400 with nothing else to go on.
- for (const name of ['onAgents', 'onProjects', 'sendStop', 'onPreferences', 'onQuota', 'checkDevices']) {
+ for (const name of ['onAgents', 'onProjects', 'sendStop', 'onPreferences', 'onQuota', 'onMachines']) {
assert.equal(typeof RPC_HANDLERS[name], 'function', name)
}
})
diff --git a/packages/openagent/src/dashboard/rpc-serve.ts b/packages/openagent/src/dashboard/rpc-serve.ts
index 68a059c70..927c864a9 100644
--- a/packages/openagent/src/dashboard/rpc-serve.ts
+++ b/packages/openagent/src/dashboard/rpc-serve.ts
@@ -11,6 +11,7 @@ import type { BridgeBrowserOwner } from '../bridge-browser.js'
import type { ProjectErrorsReader } from '../project-errors.js'
import type { AddProjectResult, RemoveProjectResult, StartAgentOptions, StartAgentResult } from './types.js'
import type { AgentMeta } from '../store/index.js'
+import type { RemoteTarget } from './remote-run.js'
/** Wired by the daemon so `sendStart` can reach the daemon's own `startAgent` closure. */
export type StartAgentHandler = (
@@ -26,15 +27,15 @@ export type AddProjectHandler = (path: string, share: boolean) => AddProjectResu
export type RemoveProjectHandler = (projectId: string, files: boolean) => RemoveProjectResult | Promise
/** Resolve an agent to its live event stream: the relay feeds `onEvents` from its own in-memory stream
- * rather than a file on disk (#426), and the daemon feeds an agent it is relaying from a device (#1067).
+ * rather than a file on disk (#426), and the daemon feeds an agent it is relaying from a machine (#1067).
* Returns undefined when there is no in-memory stream, so `onEvents` falls back to tailing the log. */
export type EventsSource = (projectId: string, agentId?: string) => AsyncIterable | undefined
-/** Look up the device a relayed agent (#1067) executes on, or undefined for an ordinary local agent. The
+/** Look up the machine a relayed agent (#1067) executes on, or undefined for an ordinary local agent. The
* daemon wires this from its live relayed-agent map; a run-scoped RPC uses it to forward a remote agent's
- * read/steer/handoff to that device instead of resolving a (nonexistent) local checkout. */
+ * read/steer/handoff to that machine instead of resolving a (nonexistent) local checkout. */
export interface RemoteAgents {
- target(agentId: string | undefined): { url: string; token: string } | undefined
+ target(agentId: string | undefined): RemoteTarget | undefined
/** A project's relayed run stubs (#1077), so `onAgents` can show a remote agent in the list and re-open it after a reload. */
list(projectId: string): AgentMeta[]
}
@@ -51,10 +52,10 @@ export interface DashboardContext {
startAgent: StartAgentHandler
addProject: AddProjectHandler
removeProject: RemoveProjectHandler
- /** The in-memory event stream for an agent relayed from a connected device (#1067), else undefined. */
+ /** The in-memory event stream for an agent relayed from a saved machine (#1067), else undefined. */
eventsSource: EventsSource
/** The relayed-agent lookup (#1067 slice 2), so a run-scoped RPC can tell a local agent from one
- * running on a connected device and forward the call there. */
+ * running on a saved machine and forward the call there. */
remote: RemoteAgents
/** The user-preferences store (#410), over the registry file. */
preferences: PreferencesStore
diff --git a/packages/openagent/src/dashboard/server.test.ts b/packages/openagent/src/dashboard/server.test.ts
index 3c4f540b7..54f9306d1 100644
--- a/packages/openagent/src/dashboard/server.test.ts
+++ b/packages/openagent/src/dashboard/server.test.ts
@@ -249,7 +249,7 @@ test('a valid ?token= sets the HttpOnly oa_daemon cookie and 302s to the clean p
assert.equal(res.location, '/') // the token is stripped from the redirect target
assert.match(res.setCookie ?? '', /^oa_daemon=/)
assert.match(res.setCookie ?? '', /HttpOnly/)
- // Lax, not Strict, so the cookie survives the cross-origin device-hop redirect (#1052).
+ // Lax, not Strict, so the cookie survives the redirect when the address is opened from a link elsewhere (#1052).
assert.match(res.setCookie ?? '', /SameSite=Lax/)
assert.match(res.setCookie ?? '', /Path=\//)
} finally {
@@ -346,8 +346,13 @@ function readNdjson(url: string, cookie: string, count: number): Promise<{ statu
})
}
-// A guarded dashboard wired for the device relay (#1067): a stub start that records its calls, and
-// an events tail backed by a fixed list. Mirrors what the daemon wires, minus a real spawn.
+/** The project the relay tests name, by its repository's address, and this machine's own id for it. */
+const SHOP = 'github.com/acme/shop'
+const SHOP_ID = 'shop-here'
+
+// A guarded dashboard wired for the machine relay (#1067): a stub start that records its calls, and
+// an events tail backed by a fixed list. Mirrors what the daemon wires, minus a real spawn. It has
+// one project, cloned from {@link SHOP}.
async function relayDashboard(opts: { token?: string | undefined } = { token: TOKEN }): Promise<{
base: string
starts: Array<{ prompt: string; options: StartAgentOptions; projectId?: string }>
@@ -363,11 +368,13 @@ async function relayDashboard(opts: { token?: string | undefined } = { token: TO
{ kind: 'session-update', sessionId: 'e1' } as OpenAgentEvent,
{ kind: 'session-update', sessionId: 'e2' } as OpenAgentEvent,
]
- const tailEvents = (_agentId: string, onEvent: (event: OpenAgentEvent) => void): (() => void) => {
- for (const e of events) onEvent(e)
+ const tailEvents = (projectId: string, _agentId: string, onEvent: (event: OpenAgentEvent) => void): (() => void) => {
+ if (projectId === SHOP_ID) for (const e of events) onEvent(e)
return () => {}
}
- const dash = await dashboard({ clientBundleDir: bundle, ...(opts.token ? { token: opts.token } : {}), onStart, relay: { tailEvents } })
+ const project = async (address: string): Promise => (address === SHOP ? SHOP_ID : undefined)
+ const rpc = async (projectId: string, fn: string, args: unknown[]): Promise => ({ projectId, fn, args })
+ const dash = await dashboard({ clientBundleDir: bundle, ...(opts.token ? { token: opts.token } : {}), onStart, relay: { project, tailEvents, rpc } })
return {
base: dash.url,
starts,
@@ -381,7 +388,7 @@ async function relayDashboard(opts: { token?: string | undefined } = { token: TO
test('/_relay/start needs the cookie: 401 without it, starts the run with it (#1067)', async () => {
const { base, starts, close } = await relayDashboard()
try {
- const body = JSON.stringify({ prompt: 'do it', options: { model: 'opus' } })
+ const body = JSON.stringify({ prompt: 'do it', options: { model: 'opus' }, project: SHOP })
const unauth = await postAuth(`${base}/_relay/start`, body)
assert.equal(unauth.status, 401) // the shared-token guard (#1051) fronts the relay too
assert.equal(starts.length, 0)
@@ -391,29 +398,60 @@ test('/_relay/start needs the cookie: 401 without it, starts the run with it (#1
assert.deepEqual(JSON.parse(ok.body), { ok: true, agentId: 'srv-run' })
assert.equal(starts.length, 1)
assert.equal(starts[0]!.prompt, 'do it')
- assert.equal(starts[0]!.projectId, undefined) // slice 1 runs in the device's own home checkout
+ assert.equal(starts[0]!.projectId, SHOP_ID) // in this machine's own project cloned from that address
+ } finally {
+ await close()
+ }
+})
+
+test('/_relay/start starts nothing for a project this machine does not have, or for none named', async () => {
+ const { base, starts, close } = await relayDashboard()
+ try {
+ for (const project of ['github.com/acme/other', undefined, 7]) {
+ const res = await postAuth(`${base}/_relay/start`, JSON.stringify({ prompt: 'do it', options: {}, project }), `oa_daemon=${TOKEN}`)
+ assert.equal(res.status, 200)
+ const answer = JSON.parse(res.body) as { ok: boolean; noProject?: boolean }
+ assert.equal(answer.ok, false)
+ assert.equal(answer.noProject, true) // said apart from any other refusal, so the caller can name the project
+ }
+ assert.equal(starts.length, 0) // and never in some other project of this machine
} finally {
await close()
}
})
-test('/_relay/start strips a nested remote target so a relayed run never relays onward (#1067)', async () => {
+test('/_relay/rpc runs the call in the project it names, and answers 404 for one this machine does not have', async () => {
+ const { base, close } = await relayDashboard()
+ try {
+ const ok = await postAuth(`${base}/_relay/rpc`, JSON.stringify({ fn: 'onGitStatus', args: ['callers-id', 'r1'], project: SHOP }), `oa_daemon=${TOKEN}`)
+ assert.equal(ok.status, 200)
+ assert.deepEqual(JSON.parse(ok.body), { result: { projectId: SHOP_ID, fn: 'onGitStatus', args: ['callers-id', 'r1'] } })
+ const other = await postAuth(`${base}/_relay/rpc`, JSON.stringify({ fn: 'onGitStatus', args: [], project: 'github.com/acme/other' }), `oa_daemon=${TOKEN}`)
+ assert.equal(other.status, 404)
+ const none = await postAuth(`${base}/_relay/rpc`, JSON.stringify({ fn: 'onGitStatus', args: [] }), `oa_daemon=${TOKEN}`)
+ assert.equal(none.status, 404)
+ } finally {
+ await close()
+ }
+})
+
+test('/_relay/start strips a nested machine so a relayed run never relays onward (#1067)', async () => {
const { base, starts, close } = await relayDashboard()
try {
- const body = JSON.stringify({ prompt: 'x', options: { remote: { url: 'http://evil', token: 'z' }, model: 'opus' } })
+ const body = JSON.stringify({ prompt: 'x', options: { machine: 'http://evil', model: 'opus' }, project: SHOP })
const ok = await postAuth(`${base}/_relay/start`, body, `oa_daemon=${TOKEN}`)
assert.equal(ok.status, 200)
- assert.equal(starts[0]!.options.remote, undefined) // the onward target was dropped
+ assert.equal(starts[0]!.options.machine, undefined) // the onward target was dropped
assert.equal(starts[0]!.options.model, 'opus') // the rest of the options survive
} finally {
await close()
}
})
-test('/_relay/start drops the branch to start from: a start relayed to a device never names a branch', async () => {
+test('/_relay/start drops the branch to start from: a start relayed to a machine never names a branch', async () => {
const { base, starts, close } = await relayDashboard()
try {
- const body = JSON.stringify({ prompt: 'x', options: { base: 'a-branch-of-the-caller', model: 'opus' } })
+ const body = JSON.stringify({ prompt: 'x', options: { base: 'a-branch-of-the-caller', model: 'opus' }, project: SHOP })
const ok = await postAuth(`${base}/_relay/start`, body, `oa_daemon=${TOKEN}`)
assert.equal(ok.status, 200)
assert.equal(starts.length, 1)
@@ -427,12 +465,17 @@ test('/_relay/start drops the branch to start from: a start relayed to a device
test('/_relay/events needs the cookie and streams the run\'s events as ndjson (#1067)', async () => {
const { base, close } = await relayDashboard()
try {
- const unauth = await fetchAuth(`${base}/_relay/events?run=srv-run`)
+ const events = `${base}/_relay/events?run=srv-run&project=${encodeURIComponent(SHOP)}`
+ const unauth = await fetchAuth(events)
assert.equal(unauth.status, 401)
- const streamed = await readNdjson(`${base}/_relay/events?run=srv-run`, `oa_daemon=${TOKEN}`, 2)
+ const streamed = await readNdjson(events, `oa_daemon=${TOKEN}`, 2)
assert.equal(streamed.status, 200)
assert.deepEqual(streamed.lines.map(l => (l as { sessionId?: string }).sessionId), ['e1', 'e2'])
+
+ // A project this machine does not have, or none named, has no run to stream.
+ assert.equal((await fetchAuth(`${base}/_relay/events?run=srv-run&project=github.com%2Facme%2Fother`, `oa_daemon=${TOKEN}`)).status, 404)
+ assert.equal((await fetchAuth(`${base}/_relay/events?run=srv-run`, `oa_daemon=${TOKEN}`)).status, 404)
} finally {
await close()
}
@@ -458,7 +501,7 @@ test('/_relay/ping is 401 without the cookie, 200 with it, and starts nothing (#
test('a loopback relay rejects a cross-origin POST and a rebound Host, and starts nothing', async () => {
const { base, starts, close } = await relayDashboard({ token: undefined })
try {
- const body = JSON.stringify({ prompt: 'do it', options: { model: 'opus' } })
+ const body = JSON.stringify({ prompt: 'do it', options: { model: 'opus' }, project: SHOP })
const crossOrigin = await postCrossOrigin(`${base}/_relay/start`)
assert.equal(crossOrigin.status, 403) // an Origin that is not this server: CSRF
@@ -466,7 +509,7 @@ test('a loopback relay rejects a cross-origin POST and a rebound Host, and start
assert.equal(rebound.status, 403) // same-origin to the browser, but the Host names evil.com
assert.equal(starts.length, 0) // neither reached the spawn
- // The real device caller sends no Origin and a loopback Host, so it still passes.
+ // The real machine caller sends no Origin and a loopback Host, so it still passes.
const ok = await postAuth(`${base}/_relay/start`, body)
assert.equal(ok.status, 200)
assert.equal(starts.length, 1)
diff --git a/packages/openagent/src/dashboard/server.ts b/packages/openagent/src/dashboard/server.ts
index f7c0478a5..7e0c9933c 100644
--- a/packages/openagent/src/dashboard/server.ts
+++ b/packages/openagent/src/dashboard/server.ts
@@ -68,24 +68,26 @@ export interface DashboardOptions {
*/
token?: string
/**
- * The live-events source for a run this daemon is relaying from a connected device (#1067):
+ * The live-events source for a run this daemon is relaying from a saved machine (#1067):
* a stream for such a run, else undefined so `onEvents` tails the run's diary off disk.
*/
eventsSource: EventsSource
/**
* The relayed-agent lookup the read RPCs consult (#1067 slice 2). A run-scoped RPC uses it to
- * forward a remote agent's read/steer/handoff to the device that owns it.
+ * forward a remote agent's read/steer/handoff to the machine that owns it.
*/
remote: RemoteAgents
/**
* Serve a relay-started run's events back to the daemon that relayed it here (#1067): the
* `/_relay/*` endpoints (start + events, plus the slice-2 `rpc`). All are fronted by the same
- * `token` guard above, so a device without the cookie cannot start or read a run.
+ * `token` guard above, so a machine without the cookie cannot start or read a run.
*/
relay?: {
- tailEvents: (agentId: string, onEvent: (event: import('../events.js').OpenAgentEvent) => void) => () => void
+ /** This machine's own id for its project cloned from an address, which is how a relayed call names one. */
+ project: (address: string) => Promise
+ tailEvents: (projectId: string, agentId: string, onEvent: (event: import('../events.js').OpenAgentEvent) => void) => () => void
/** Run one whitelisted run-scoped RPC against this daemon's own checkout (#1067 slice 2). */
- rpc?: (fn: string, args: unknown[]) => Promise
+ rpc?: (projectId: string, fn: string, args: unknown[]) => Promise
}
/**
* The browser bridge (#1237): the token a Claude web extension presents to report the question
@@ -157,10 +159,10 @@ export function startDashboard(opts: DashboardOptions): Promise {
{ host },
)
- // The device-to-daemon relay endpoints (#1067): wired only when an events tail is supplied.
+ // The machine-to-daemon relay endpoints (#1067): wired only when an events tail is supplied.
// Fronted by the same token guard as every other route below.
const relayHandlers: RelayHandlers | undefined = opts.relay
- ? { start: opts.onStart, tailEvents: opts.relay.tailEvents, ...(opts.relay.rpc ? { rpc: opts.relay.rpc } : {}) }
+ ? { project: opts.relay.project, start: opts.onStart, tailEvents: opts.relay.tailEvents, ...(opts.relay.rpc ? { rpc: opts.relay.rpc } : {}) }
: undefined
// The browser bridge (#1237). Off unless a token was supplied, and it carries that token itself
@@ -226,10 +228,10 @@ export function startDashboard(opts: DashboardOptions): Promise {
}
// #1051: one guard fronting every route on a non-loopback bind; a no-op when no token is set.
if (token !== undefined && !authorizeDaemonRequest(req, res, token)) return
- // The device relay (#1067): another daemon posts a start here and streams the run's events back.
+ // The machine relay (#1067): another daemon posts a start here and streams the run's events back.
// The token guard above is a no-op on a loopback bind, so — exactly like the RPC mount below —
// the relay carries its own CSRF + DNS-rebinding guard, or a page the user merely visited could
- // POST /_relay/start to start a run (the real device caller sends no Origin and a loopback
+ // POST /_relay/start to start a run (the real machine caller sends no Origin and a loopback
// Host, so both checks pass it; only a browser's cross-origin/rebound request is turned away).
if (pathname === RELAY_PREFIX || pathname.startsWith(`${RELAY_PREFIX}/`)) {
if (!guardBrowserOrigin(req, res, host)) return
@@ -277,7 +279,7 @@ function closeServer(server: Server): Promise {
}
/**
- * The CSRF + DNS-rebinding guard the RPC mount applies, lifted to the device relay, which
+ * The CSRF + DNS-rebinding guard the RPC mount applies, lifted to the machine relay, which
* dispatches outside it. The relay is state-changing (it starts a run on this machine) and is
* wired unconditionally on a loopback bind, where the shared-token guard is a no-op, so without
* this a page the user merely visited reaches it. Returns true to admit the request; on rejection
@@ -307,7 +309,7 @@ function authorizeDaemonRequest(req: IncomingMessage, res: ServerResponse, token
url.searchParams.delete('token')
const query = url.searchParams.toString()
res.writeHead(302, {
- // Lax, not Strict: the device-hop (#1052) is a cross-origin top-level nav, and a Strict cookie set on it is withheld from the redirect right after, so the clean path 401s. Lax still rides top-level GET navs; CSRF stays covered by the same-origin check on /_rpc.
+ // Lax, not Strict: opening this address from a link (#1052) is a cross-origin top-level nav, and a Strict cookie set on it is withheld from the redirect right after, so the clean path 401s. Lax still rides top-level GET navs; CSRF stays covered by the same-origin check on /_rpc.
'set-cookie': `${DAEMON_COOKIE}=${token}; HttpOnly; SameSite=Lax; Path=/`,
location: url.pathname + (query ? `?${query}` : ''),
})
diff --git a/packages/openagent/src/dashboard/types.ts b/packages/openagent/src/dashboard/types.ts
index ba0722749..a295b4dab 100644
--- a/packages/openagent/src/dashboard/types.ts
+++ b/packages/openagent/src/dashboard/types.ts
@@ -62,7 +62,7 @@ export interface StartAgentOptions {
/**
* The branch the agent's own branch starts from, as this machine has it, commits that are not
* pushed included: the launcher's "My local branch" pick. Absent, origin's default branch. A
- * word that is no branch name refuses the Start. Not sent to a device: its branches are its own.
+ * word that is no branch name refuses the Start. Not sent to another machine: its branches are its own.
*/
base?: string
/**
@@ -71,16 +71,12 @@ export interface StartAgentOptions {
*/
then?: string
/**
- * Run this session on a connected device (#1067): the local daemon relays the start to the remote
- * daemon at `url` (authenticating with `token` as the `oa_daemon` cookie) and streams its events
- * back into the local agent view. The device `label` rides along (memory-only, like `url`/`token`) so
- * the local session list + notice can show which device the agent is on after a reload (#1077).
- * Memory-only relay config the dashboard sets at submit time from a saved device. NEVER persisted to
- * Preferences or the registry: a device token is a per-browser secret. Absent = run locally.
- * Stripped before the start is forwarded, so the remote starts an ordinary local run and does not
- * relay onward.
+ * Run this session on a saved machine (#1067), named by its id: the local daemon relays the
+ * start to that machine's daemon, which starts it in its own copy of the project, and streams
+ * its events back into the local agent view. Absent = run on this machine. Stripped before the
+ * start is forwarded, so the machine starts an ordinary local run and does not relay onward.
*/
- remote?: { url: string; token: string; label?: string }
+ machine?: string
}
/** The outcome of a Start attempt (#345): the id of the run the project's start hook began, or why there is none. */
diff --git a/packages/openagent/src/machine-url.test.ts b/packages/openagent/src/machine-url.test.ts
new file mode 100644
index 000000000..2e455456a
--- /dev/null
+++ b/packages/openagent/src/machine-url.test.ts
@@ -0,0 +1,16 @@
+import { strict as assert } from 'node:assert'
+import { test } from 'node:test'
+import { parseMachineUrl } from './machine-url.js'
+
+test('the address and the key come out of the line a machine prints', () => {
+ assert.deepEqual(parseMachineUrl(' http://192.168.1.5:4200/?token=abc '), { url: 'http://192.168.1.5:4200', token: 'abc' })
+ assert.deepEqual(parseMachineUrl('https://box.tail.net/some/page?token=x%2By'), { url: 'https://box.tail.net', token: 'x+y' })
+})
+
+test('an address with no token has an empty key', () => {
+ assert.deepEqual(parseMachineUrl('http://192.168.1.5:4200/'), { url: 'http://192.168.1.5:4200', token: '' })
+})
+
+test('what is not a web address is null', () => {
+ for (const pasted of ['', 'not a url', 'localhost:4200/?token=abc', 'ftp://host/?token=abc']) assert.equal(parseMachineUrl(pasted), null, pasted)
+})
diff --git a/packages/openagent/src/machine-url.ts b/packages/openagent/src/machine-url.ts
new file mode 100644
index 000000000..3f9a1256a
--- /dev/null
+++ b/packages/openagent/src/machine-url.ts
@@ -0,0 +1,19 @@
+/**
+ * Pull a machine's address and key out of the line it prints when it is opened to the network
+ * (`http://host:port/?token=…`, cli.ts). The address is cut down to its origin. Not a web
+ * address is `null`; one with no `token` has an empty key, which cannot be saved.
+ *
+ * A leaf with no Node in it: the dashboard reads a paste with it as the person types, and the
+ * daemon reads it again before it saves.
+ */
+export function parseMachineUrl(pasted: string): { url: string; token: string } | null {
+ try {
+ const u = new URL(pasted.trim())
+ // A scheme-less paste like `localhost:4200/?token=…` parses with `localhost:` as the scheme
+ // and an opaque `null` origin: not a machine's address.
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
+ return { url: u.origin, token: u.searchParams.get('token') ?? '' }
+ } catch {
+ return null
+ }
+}
diff --git a/packages/openagent/src/module-server.d.ts b/packages/openagent/src/module-server.d.ts
index 25203fcb6..33bd78193 100644
--- a/packages/openagent/src/module-server.d.ts
+++ b/packages/openagent/src/module-server.d.ts
@@ -43,7 +43,7 @@ export interface ModuleServerHost {
/**
* What a module's browser part sent with a read: JSON, bounded in size. `agentId`, when present,
- * names the run the read is about; a run relayed to a connected device is read over there.
+ * names the run the read is about; a run relayed to a saved machine is read over there.
*/
export type ModuleReadInput = { agentId?: string } & Record
diff --git a/packages/openagent/src/registry.test.ts b/packages/openagent/src/registry.test.ts
index b5daf6bfe..d5696522c 100644
--- a/packages/openagent/src/registry.test.ts
+++ b/packages/openagent/src/registry.test.ts
@@ -2,12 +2,15 @@ import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { join } from 'node:path'
import {
+ addMachine,
addProject,
ensureDaemonToken,
+ listMachines,
listProjects,
projectId,
readDaemonToken,
readPreferences,
+ removeMachine,
removeProject,
readRegistry,
registryPreferencesStore,
@@ -541,3 +544,69 @@ test('a filesystem with no chmod still writes the registry (#1095)', async () =>
assert.equal(await readDaemonToken(noChmod, ENV), token)
})
+
+const STUDIO = 'http://192.168.1.5:4200'
+
+test('addMachine saves a machine with its key, newest first, named by its host when no name is given', async () => {
+ const fs = memFs()
+ const saved = await addMachine({ url: STUDIO, token: 'aaa' }, fs, ENV)
+ assert.deepEqual(saved, { id: STUDIO, label: '192.168.1.5:4200', url: STUDIO, token: 'aaa' })
+ await addMachine({ url: 'http://10.0.0.2:4200', token: 'bbb', label: ' Office ' }, fs, ENV)
+ assert.deepEqual((await listMachines(fs, ENV)).map(machine => machine.label), ['Office', '192.168.1.5:4200'])
+ // The file itself holds the list, top-level, beside the projects and the preferences.
+ const stored = JSON.parse(fs.files.get(FILE)!)
+ assert.equal(stored.machines.length, 2)
+ assert.equal(stored.preferences.machines, undefined)
+})
+
+test('saving an address again replaces its name and its key and keeps one entry', async () => {
+ const fs = memFs()
+ await addMachine({ url: STUDIO, token: 'old', label: 'Studio' }, fs, ENV)
+ await addMachine({ url: STUDIO, token: 'new', label: 'Studio 2' }, fs, ENV)
+ assert.deepEqual(await listMachines(fs, ENV), [{ id: STUDIO, label: 'Studio 2', url: STUDIO, token: 'new' }])
+})
+
+test('removeMachine takes one machine off the list and says whether it was there', async () => {
+ const fs = memFs()
+ await addMachine({ url: STUDIO, token: 'aaa' }, fs, ENV)
+ await addMachine({ url: 'http://10.0.0.2:4200', token: 'bbb' }, fs, ENV)
+ assert.equal(await removeMachine(STUDIO, fs, ENV), true)
+ assert.deepEqual((await listMachines(fs, ENV)).map(machine => machine.id), ['http://10.0.0.2:4200'])
+ assert.equal(await removeMachine(STUDIO, fs, ENV), false)
+})
+
+test('a hand-edited machines list keeps only well-formed entries, one per address', async () => {
+ const machines = [
+ { label: 'Studio', url: STUDIO, token: 'aaa' },
+ { label: 'Twice', url: STUDIO, token: 'zzz' },
+ { label: 'No key', url: 'http://10.0.0.2:4200', token: '' },
+ { label: 7, url: 'http://10.0.0.3:4200', token: 'ccc' },
+ 'junk',
+ ]
+ const fs = memFs({ [FILE]: JSON.stringify({ projects: [], preferences: {}, machines }) })
+ assert.deepEqual(await listMachines(fs, ENV), [{ id: STUDIO, label: 'Studio', url: STUDIO, token: 'aaa' }])
+ assert.deepEqual(await listMachines(memFs({ [FILE]: JSON.stringify({ projects: [], preferences: {}, machines: 'x' }) }), ENV), [])
+})
+
+test('the saved machines survive the other registry mutators, and an emptied list is not written', async () => {
+ const fs = memFs()
+ await addProject('/repos/alpha', '2026-10-11T00:00:00.000Z', fs, ENV)
+ await writePreferences({ model: 'opus' }, fs, ENV)
+ const token = await ensureDaemonToken(fs, ENV)
+ await addMachine({ url: STUDIO, token: 'aaa' }, fs, ENV)
+ await addProject('/repos/beta', '2026-10-11T00:00:00.000Z', fs, ENV)
+ await patchPreferences({ theme: 'dark' }, fs, ENV)
+ const registry = await readRegistry(fs, ENV)
+ assert.equal(registry.machines?.length, 1)
+ assert.equal(registry.projects.length, 2)
+ assert.deepEqual(registry.preferences, { model: 'opus', theme: 'dark' })
+ assert.equal(registry.daemonToken, token)
+ await removeMachine(STUDIO, fs, ENV)
+ assert.equal(fs.files.get(FILE)!.includes('machines'), false) // an empty list is not written
+})
+
+test('the preferences the browser is handed never carry the saved machines', async () => {
+ const fs = memFs()
+ await addMachine({ url: STUDIO, token: 'aaa' }, fs, ENV)
+ assert.equal(JSON.stringify(await readPreferences(fs, ENV)).includes('aaa'), false)
+})
diff --git a/packages/openagent/src/registry.ts b/packages/openagent/src/registry.ts
index 2ceb18f3a..57573a9a9 100644
--- a/packages/openagent/src/registry.ts
+++ b/packages/openagent/src/registry.ts
@@ -8,8 +8,9 @@ import { nodeFs } from './node-fs.js'
* The multi-project registry (#390): the list of projects the user has
* installed OpenAgent into, kept as a single JSON file `.bashrc`-style —
* `$HOME/.openagent.json` — so it is the user's responsibility to re-create
- * per machine. The same file also holds the user's dashboard preferences (#410),
- * so the daemon owns one user file and the UI never needs localStorage.
+ * per machine. The same file also holds the user's dashboard preferences (#410)
+ * and the machines they saved, so the daemon owns one user file and the UI never
+ * needs localStorage.
*/
/** One registered project. */
@@ -22,6 +23,25 @@ export interface ProjectRecord {
addedAt: string
}
+/**
+ * A saved machine: another computer running OpenAgent that a run can be sent to. Kept in this
+ * file, the person's own, so every browser on this computer shows the same machines and the
+ * key that reaches one never leaves the two machines.
+ */
+export interface MachineRecord {
+ /** The machine's address, which is also what names it: saving the same address again replaces it. */
+ id: string
+ /** The name the person gave it, or its host when they gave none. */
+ label: string
+ /** Where its OpenAgent answers, as an origin (`http://192.168.1.5:4200`). */
+ url: string
+ /** The key that machine asks for. Never handed to a browser. */
+ token: string
+}
+
+/** The cap on saved machines and on a name's length, so a hand-edited file cannot bloat. */
+const MACHINE_LIMITS = { count: 50, label: 60 } as const
+
/**
* The dashboard's Global options (#410), persisted next to the project list so they
* survive restarts without localStorage — the daemon reads/writes them, the SPA reads
@@ -126,6 +146,11 @@ export interface Registry {
* browser bundle. Absent on a loopback-only machine.
*/
daemonToken?: string
+ /**
+ * The saved machines. Top-level like {@link daemonToken}, and for the same reason: each one
+ * carries a key, so the list is never part of what the browser is handed.
+ */
+ machines?: MachineRecord[]
}
/** A read/write handle for the user preferences, wired into the dashboard's context by the daemon. */
@@ -142,7 +167,7 @@ export interface PreferencesStore {
/** The registry file name: a single file under `$XDG_CONFIG_HOME` (dotted under `$HOME`). */
export const REGISTRY_FILE = 'openagent.json'
-/** Owner read/write only: the file holds the daemon token (#1051). */
+/** Owner read/write only: the file holds the daemon token (#1051) and the saved machines' keys. */
export const REGISTRY_FILE_MODE = 0o600
/**
@@ -319,6 +344,23 @@ export function sanitizeCustomPresets(value: unknown): CustomPreset[] {
return out
}
+/** Keep well-formed machines, one per address (first wins), names and count capped. */
+function sanitizeMachines(value: unknown): MachineRecord[] {
+ if (!Array.isArray(value)) return []
+ const machines: MachineRecord[] = []
+ const seen = new Set()
+ for (const raw of value) {
+ if (machines.length >= MACHINE_LIMITS.count) break
+ if (typeof raw !== 'object' || raw === null) continue
+ const { label, url, token } = raw as Record
+ if (typeof label !== 'string' || typeof url !== 'string' || typeof token !== 'string') continue
+ if (!url || !token || seen.has(url)) continue
+ seen.add(url)
+ machines.push({ id: url, label: label.trim().slice(0, MACHINE_LIMITS.label) || url, url, token })
+ }
+ return machines
+}
+
/**
* Read the whole registry. Forgiving: a missing / unreadable / malformed file — or one in a shape
* this no longer writes — yields an empty registry, never throws. Projects are deduped by resolved
@@ -338,11 +380,13 @@ export async function readRegistry(
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return empty
const obj = parsed as Record
const projects = Array.isArray(obj.projects) ? dedupeProjects(obj.projects) : []
+ const machines = sanitizeMachines(obj.machines)
return {
projects,
preferences: sanitizePreferences(obj.preferences),
// #1051: kept only as a non-empty string, so a hand-edited registry can't smuggle a junk token.
...(typeof obj.daemonToken === 'string' && obj.daemonToken ? { daemonToken: obj.daemonToken } : {}),
+ ...(machines.length ? { machines } : {}),
}
}
@@ -363,11 +407,12 @@ export async function readRegistry(
*/
async function writeRegistry(registry: Registry, fs: RegistryFs, env: NodeJS.ProcessEnv): Promise {
const file = registryPath(env)
- const { projects, preferences, daemonToken } = registry
+ const { projects, preferences, daemonToken, machines } = registry
const contents = {
projects,
preferences,
...(daemonToken ? { daemonToken } : {}),
+ ...(machines?.length ? { machines } : {}),
}
const json = JSON.stringify(contents, null, 2)
await fs.mkdir(dirname(file))
@@ -532,6 +577,61 @@ export async function readDaemonToken(
return (await readRegistry(fs, env)).daemonToken
}
+/** The saved machines, newest first. Forgiving like every read of this file: none on a bad file. */
+export async function listMachines(
+ fs: RegistryFs = nodeRegistryFs(),
+ env: NodeJS.ProcessEnv = process.env,
+): Promise {
+ return (await readRegistry(fs, env)).machines ?? []
+}
+
+/**
+ * Save a machine, newest first. One per address: saving an address again replaces its name and
+ * its key, which is how a machine whose key changed is fixed. Answers the stored record.
+ */
+export async function addMachine(
+ machine: { url: string; token: string; label?: string },
+ fs: RegistryFs = nodeRegistryFs(),
+ env: NodeJS.ProcessEnv = process.env,
+): Promise {
+ return serialize(async () => {
+ const registry = await readRegistry(fs, env)
+ const record: MachineRecord = {
+ id: machine.url,
+ label: machine.label?.trim().slice(0, MACHINE_LIMITS.label) || hostOf(machine.url),
+ url: machine.url,
+ token: machine.token,
+ }
+ const machines = [record, ...(registry.machines ?? []).filter(saved => saved.id !== record.id)].slice(0, MACHINE_LIMITS.count)
+ await writeRegistry({ ...registry, machines }, fs, env)
+ return record
+ })
+}
+
+/** Take a saved machine off the list, by id. Answers whether one was there. */
+export async function removeMachine(
+ id: string,
+ fs: RegistryFs = nodeRegistryFs(),
+ env: NodeJS.ProcessEnv = process.env,
+): Promise {
+ return serialize(async () => {
+ const registry = await readRegistry(fs, env)
+ const machines = (registry.machines ?? []).filter(saved => saved.id !== id)
+ if (machines.length === (registry.machines ?? []).length) return false
+ await writeRegistry({ ...registry, machines }, fs, env)
+ return true
+ })
+}
+
+/** An address's host and port (`192.168.1.5:4200`): a machine's name when the person gave none. */
+function hostOf(url: string): string {
+ try {
+ return new URL(url).host
+ } catch {
+ return url
+ }
+}
+
/** A {@link PreferencesStore} bound to the real registry file, wired by the daemon so the
* dashboard's preferences RPCs read/write the user's home file.
*
diff --git a/packages/openagent/src/repository-address.test.ts b/packages/openagent/src/repository-address.test.ts
new file mode 100644
index 000000000..9eb8ab945
--- /dev/null
+++ b/packages/openagent/src/repository-address.test.ts
@@ -0,0 +1,53 @@
+import { strict as assert } from 'node:assert'
+import { test } from 'node:test'
+import { addressOfRemote, repositoryAddress } from './repository-address.js'
+
+test('one repository reads the same however it was cloned', () => {
+ for (const remote of [
+ 'git@github.com:Acme/Shop.git',
+ 'https://github.com/acme/shop',
+ 'https://github.com/acme/shop.git',
+ 'https://someone@github.com/acme/shop/',
+ 'ssh://git@github.com:22/acme/shop.git',
+ ' git://github.com/acme/shop.git\n',
+ ]) {
+ assert.equal(addressOfRemote(remote), 'github.com/acme/shop', remote)
+ }
+})
+
+test('another host, and a path with groups in it, keep their own address', () => {
+ assert.equal(addressOfRemote('git@gitlab.example.com:team/tools/shop.git'), 'gitlab.example.com/team/tools/shop')
+ assert.notEqual(addressOfRemote('https://gitlab.com/acme/shop'), addressOfRemote('https://github.com/acme/shop'))
+})
+
+test('what is not part of the name is dropped: a query, a fragment, a slash after .git, a password with an @ in it', () => {
+ for (const remote of [
+ 'https://github.com/acme/shop.git?ref=main',
+ 'https://github.com/acme/shop#readme',
+ 'https://github.com/acme/shop/.git/',
+ 'https://me:p@ss@github.com/acme/shop.git',
+ 'me@work@github.com:acme/shop.git',
+ ]) {
+ assert.equal(addressOfRemote(remote), 'github.com/acme/shop', remote)
+ }
+})
+
+test('a folder on this disk is no address', () => {
+ for (const remote of ['/Users/me/code/shop', '../shop.git', 'file:///srv/git/shop.git', 'file://nas/share/shop.git', 'C:\\code\\shop', '']) {
+ assert.equal(addressOfRemote(remote), undefined, remote)
+ }
+})
+
+test('repositoryAddress reads origin, and a repository with none has no address', async () => {
+ const asked: string[][] = []
+ const withOrigin = async (args: string[]) => {
+ asked.push(args)
+ return 'git@github.com:acme/shop.git\n'
+ }
+ assert.equal(await repositoryAddress('/repo', withOrigin), 'github.com/acme/shop')
+ assert.deepEqual(asked, [['remote', 'get-url', 'origin']])
+ const none = async (): Promise => {
+ throw new Error("error: No such remote 'origin'")
+ }
+ assert.equal(await repositoryAddress('/repo', none), undefined)
+})
diff --git a/packages/openagent/src/repository-address.ts b/packages/openagent/src/repository-address.ts
new file mode 100644
index 000000000..c191ac837
--- /dev/null
+++ b/packages/openagent/src/repository-address.ts
@@ -0,0 +1,37 @@
+import { nodeGitRunner, type GitRunner } from '@openagt/agent-data'
+
+/**
+ * The one name a project has on every machine: where its repository was cloned from, as
+ * `host/path` (`github.com/me/shop`). A project's id is made from its folder's path, which is
+ * another path on another machine, so a run sent to a machine names its project by this.
+ *
+ * The same repository reads the same whichever way it was cloned: the scheme, a user before the
+ * host, a port, a trailing `.git` or slash are dropped, and letters are lowered, since the git
+ * hosts match names without regard to case. Not an address at all (a path on this disk, a
+ * `file:` address) is none.
+ *
+ * What it does not see through: a host that serves the two ways under two names or two paths
+ * (`ssh.github.com`, Azure DevOps). Such a project reads as two, and the other machine says it
+ * does not have it.
+ */
+export function addressOfRemote(remote: string): string | undefined {
+ const url = remote.trim().replace(/[?#].*$/, '')
+ // `git@host:path`, the scp-like form: no scheme, a colon before the path.
+ const scp = /^(?:[^/\s]*@)?([^:/\s@]+):(?!\/\/)(.+)$/.exec(url)
+ const web = scp ? null : /^([a-z][a-z0-9+.-]*):\/\/(?:[^/\s]*@)?([^:/\s@]+)(?::\d+)?\/(.+)$/i.exec(url)
+ if (web?.[1]!.toLowerCase() === 'file') return undefined
+ const [host, rest] = scp ? [scp[1]!, scp[2]!] : web ? [web[2]!, web[3]!] : []
+ // One letter before a colon is a drive (`C:\repo`), a folder on this disk.
+ if (host === undefined || rest === undefined || host.length < 2) return undefined
+ const path = rest.replace(/\/+$/, '').replace(/\.git$/i, '').replace(/^\/+|\/+$/g, '').toLowerCase()
+ return path ? `${host.toLowerCase()}/${path}` : undefined
+}
+
+/**
+ * The address of the repository at `cwd`: its `origin` remote, the one every push goes to. A
+ * project with no `origin`, or one that is a folder on this disk, has none and runs on this
+ * machine only.
+ */
+export async function repositoryAddress(cwd: string, git: GitRunner = nodeGitRunner()): Promise {
+ return git(['remote', 'get-url', 'origin'], cwd).then(addressOfRemote, () => undefined)
+}
diff --git a/packages/openagent/src/store/agent-store.ts b/packages/openagent/src/store/agent-store.ts
index 63ccd5478..850ee6433 100644
--- a/packages/openagent/src/store/agent-store.ts
+++ b/packages/openagent/src/store/agent-store.ts
@@ -154,12 +154,12 @@ export interface AgentMeta {
browserStreamPort?: number
/**
* Where this run executes (#1050/#1053/#610): `actions` for a GitHub Actions run, `web` for a
- * Claude Code cloud session, `remote` when relayed to a connected device (#1067), absent for a
+ * Claude Code cloud session, `remote` when relayed to a saved machine (#1067), absent for a
* local run. Persisted so the agent view can tell a burst-mode Actions run from a stalled live
* feed, show a cloud agent's session link after a reload, and gate the browser pane off (#1053).
*/
target?: 'local' | 'actions' | 'remote' | 'web'
- /** The connected device a remote agent (#1067) executes on, for the session list + notice after a reload. */
+ /** The saved machine a remote agent (#1067) executes on, for the session list + notice after a reload. */
remoteLabel?: string
/**
* The flow this agent started under (#1467): `build` for the scope→build orchestration, `prompt`