Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 19 additions & 1 deletion packages/openagent/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/openagent/dashboard/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 0 additions & 39 deletions packages/openagent/dashboard/components/AddDeviceDialog.test.tsx

This file was deleted.

57 changes: 57 additions & 0 deletions packages/openagent/dashboard/components/AddMachineDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AddMachineDialog onClose={onClose} onAdded={onAdded} />)
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(<AddMachineDialog onClose={vi.fn()} onAdded={vi.fn()} />)
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(<AddMachineDialog onClose={onClose} onAdded={vi.fn()} />)
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(<AddMachineDialog onClose={onClose} onAdded={vi.fn()} />)
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()
})
})
Original file line number Diff line number Diff line change
@@ -1,39 +1,46 @@
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<string | null>(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()
}

const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
save()
void save()
}
}

return (
<Dialog open onOpenChange={next => { if (!next) onClose() }} title="Add a device">
<Dialog open onOpenChange={next => { if (!next) onClose() }} title="Add a machine">
<div className="flex w-full flex-col gap-2" onKeyDown={onKeyDown}>
<p className="text-xs text-muted-foreground">
Paste the URL the box printed on its network bind (it looks like <code>http://host:port/?token=…</code>).
Paste the URL the other machine printed when it was opened to the network (it looks like <code>http://host:port/?token=…</code>).
</p>
<input
type="text"
Expand All @@ -53,15 +60,20 @@ export function AddDeviceDialog({ onClose, onAdded }: { onClose: () => void; onA
/>
{url.trim() !== '' && !valid && (
<p className="text-xs text-warning">
{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.'}
</p>
)}
{error && (
<p role="alert" className="text-xs text-danger">
{error}
</p>
)}
<div className="mt-1 flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button type="button" size="sm" disabled={!valid} onClick={save}>
Add device
<Button type="button" size="sm" disabled={!valid || saving} onClick={() => void save()}>
Add machine
</Button>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion packages/openagent/dashboard/components/AgentActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/openagent/dashboard/components/AgentHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down Expand Up @@ -196,7 +196,7 @@ describe('AgentHistory (#785)', () => {
// bespoke collapsing <aside> 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(<AgentHistory projectId="p1" scope="p1" agents={[agent({ target: 'remote', remoteLabel: 'my-laptop' })]} selectedAgentId={null} onSelect={() => {}} />)
expect(screen.getByLabelText('Runs on my-laptop')).toBeTruthy()
})
Expand All @@ -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(<AgentHistory projectId="p1" scope="p1" agents={[agent()]} selectedAgentId={null} onSelect={() => {}} />)
expect(screen.queryByLabelText(/Runs on/)).toBeNull()
})
Expand Down
10 changes: 5 additions & 5 deletions packages/openagent/dashboard/components/AgentHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -598,7 +598,7 @@ function AgentHistoryRow({
{parked ? 'waiting' : inCloud ? 'in cloud' : cloudWord ? cloudWord : savingNow ? 'saving…' : status}
</Badge>
<span className="truncate text-xs font-normal text-muted-foreground">{subtitle}</span>
{/* 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. */}
Expand Down Expand Up @@ -645,9 +645,9 @@ function AgentHistoryRow({
{remote && (
<Tooltip>
<TooltipTrigger render={<span className="flex items-center" />}>
<MonitorSmartphone className="h-3 w-3 text-muted-foreground" aria-label={remoteLabel ? `Runs on ${remoteLabel}` : 'Runs on a connected device'} />
<MonitorSmartphone className="h-3 w-3 text-muted-foreground" aria-label={remoteLabel ? `Runs on ${remoteLabel}` : 'Runs on another machine'} />
</TooltipTrigger>
<TooltipContent>{remoteLabel ? `Runs on ${remoteLabel}` : 'Runs on a connected device'}</TooltipContent>
<TooltipContent>{remoteLabel ? `Runs on ${remoteLabel}` : 'Runs on another machine'}</TooltipContent>
</Tooltip>
)}
{cloud && (
Expand Down
6 changes: 3 additions & 3 deletions packages/openagent/dashboard/components/AgentView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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' }))
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading