diff --git a/FEATURES-SPEC.md b/FEATURES-SPEC.md index b60b7343d..4c50103ac 100644 --- a/FEATURES-SPEC.md +++ b/FEATURES-SPEC.md @@ -167,6 +167,7 @@ happens while nobody is at the keyboard. - Browser-bridge token setting - Web runs trust the project for Claude Code automatically — no manual trust step - A cloud run's row follows the session's real branch and PR, with its armed draft PR opened when the session opens none +- Another machine's runs on the shared data branch are told apart: their rows carry a glyph naming the machine that started them (the Overview's working-now card spells it out), and a run is listed once even when two checkouts share its archive ## Notifications diff --git a/packages/framework/dashboard/components/AgentHistory.SPEC.md b/packages/framework/dashboard/components/AgentHistory.SPEC.md index 5129d5984..d41cfc1f1 100644 --- a/packages/framework/dashboard/components/AgentHistory.SPEC.md +++ b/packages/framework/dashboard/components/AgentHistory.SPEC.md @@ -6,7 +6,7 @@ The dashboard's left column, present on every page: the brand, the New agent lau - **New agent adapts to what exists** - inside a project it starts another agent there; with exactly one project registered it starts there; with several it asks which; with none it offers to add a project first, since there is nowhere to run an agent. - **The recent list follows the scope** - a selected project shows its own agents; the Overview pools every project's agents newest-first, each row naming its project and jumping into it. - **A started agent appears immediately** - a placeholder row says "starting…" from the moment Start is pressed until the real agent shows up, and gives up after a while if none ever does. -- **Each row says what its agent is doing** - working, waiting on the user, publishing, or its final status — and for a cloud session, what the cloud side is doing; plus when it started, which driver ran it, and whether it runs on another device or as a cloud session. +- **Each row says what its agent is doing** - working, waiting on the user, publishing, or its final status — and for a cloud session, what the cloud side is doing; plus when it started, which driver ran it, whether it runs on another device or as a cloud session, and which machine started it when that was not this one. - **Exactly one destination is highlighted** - New, Overview, Tickets, a project, or an agent row. - **Projects carry their health** - each project shows whether it is activated, and turns red with the reason when the daemon has recorded an error for it. @@ -52,7 +52,7 @@ Scanning the list, the user needs to tell at a glance which agents need them, wh Each row shows a status word with a matching dot: a working agent pulses and reads its running status; an agent parked on the user reads "waiting" with a still dot, because it used to pulse identically whether it was mid-edit or had been idle for an hour; an agent that ended clean but has not yet reported its handoff reads "publishing…" with a pulsing dot, matching the wording on its own page; an agent that handed its task to a cloud session reads, once its local half is done, what the cloud side is doing per the cloud state rule (`cloud-run-state`): "waiting" with a still dot when the browser bridge holds a question its session is parked on, "in cloud" while the session is assumed to still be working, "merged" once the framework merged its pull request, and "done" once it has a pull request or the session can no longer be working — never "in cloud" forever. Otherwise the row shows the agent's final status. -Beside that: how long ago it started (preceded by its project name on the Overview), and, at the end of the line, a device glyph naming the device when the agent runs on another machine, a cloud glyph when it is a cloud session, and the logo of the driver that ran it. Underneath sits the agent's task; a task too long for the rail's fixed width is faded at its end and shows in full on hover, while one that fits is shown plainly with no hover at all. +Beside that: how long ago it started (preceded by its project name on the Overview), and, at the end of the line, a glyph naming the machine when another machine's daemon started the agent — the data branch is shared, so other machines' agents are listed here, and one that looked like this daemon's own was a mystery; a glyph rather than a word, since the rail's fixed width would cut a machine name short — a device glyph naming the device when the agent runs on another machine, a cloud glyph when it is a cloud session, and the logo of the driver that ran it. Underneath sits the agent's task; a task too long for the rail's fixed width is faded at its end and shows in full on hover, while one that fits is shown plainly with no hover at all. ### Destinations and what is highlighted diff --git a/packages/framework/dashboard/components/AgentHistory.test.SPEC.md b/packages/framework/dashboard/components/AgentHistory.test.SPEC.md index 80f9605e7..1563ec791 100644 --- a/packages/framework/dashboard/components/AgentHistory.test.SPEC.md +++ b/packages/framework/dashboard/components/AgentHistory.test.SPEC.md @@ -2,7 +2,7 @@ What the tests cover: - **What a row's status says** - a working agent reads "running" and animates; one parked on the user reads "waiting" with the animation stopped; an agent that ended clean with a handoff still to come reads "publishing…" and animates, and reads plain "done" once the handoff has reported or when none was armed; a finished agent is never relabelled "waiting". - **The starting placeholder** - it carries the highlight while the user follows an agent whose row has not arrived yet; it retires as soon as any agent lands that was not in the list when Start was pressed, even one that failed too fast to ever be seen running; and an agent that was already in the list does not count as that handover. -- **Where the agent runs** - an agent on another device shows a glyph naming that device, while a local one shows none; an agent handed to a cloud session reads "in cloud" while its session may still be working, "waiting" when the bridge holds a question for it, "merged" once the framework merged its pull request, and "done" once it has a pull request or is too old for its session to be alive instead of "done" (because the cloud side is still working), shows a cloud glyph, and still names its driver — but one stopped early reads plainly "stopped", since nothing is working anywhere. +- **Where the agent runs** - an agent on another device shows a glyph naming that device, while a local one shows none; an agent another machine's daemon started shows a glyph naming that machine, while this daemon's own shows none; an agent handed to a cloud session reads "in cloud" while its session may still be working, "waiting" when the bridge holds a question for it, "merged" once the framework merged its pull request, and "done" once it has a pull request or is too old for its session to be alive instead of "done" (because the cloud side is still working), shows a cloud glyph, and still names its driver — but one stopped early reads plainly "stopped", since nothing is working anywhere. - **Scope of the list** - with no project selected the rail still shows New agent and says "No agents yet."; the Overview pools every project's agents, naming each row's project, and selecting one jumps into that project's agent. - **New agent** - with one project it starts there; inside a project it starts another agent there; with several projects and none selected it opens a picker instead of starting immediately. - **Tickets** - offered both inside a project and on the Overview, opens the ticket view, carries the active marker while it is the current view (with Overview not also claiming it), and is not offered at all when there is nowhere to route it. diff --git a/packages/framework/dashboard/components/AgentHistory.test.tsx b/packages/framework/dashboard/components/AgentHistory.test.tsx index 551892f31..29339a411 100644 --- a/packages/framework/dashboard/components/AgentHistory.test.tsx +++ b/packages/framework/dashboard/components/AgentHistory.test.tsx @@ -172,6 +172,16 @@ describe('AgentHistory rows', () => { expect(screen.getByLabelText('Runs on my-laptop')).toBeTruthy() }) + test('a run another machine started says whose it is; this daemon\'s own does not (#1067, #1648)', () => { + const rows = [ + { projectId: 'p', projectName: 'gemstack', agent: agent({ id: 'theirs', host: 'rom-thinkpad-x280', otherHost: true }) }, + { projectId: 'p', projectName: 'gemstack', agent: agent({ id: 'mine', host: 'this-mac' }) }, + ] + renderRail( {}} />) + expect(screen.getByLabelText('Started on rom-thinkpad-x280')).toBeTruthy() + expect(screen.queryByLabelText('Started on this-mac')).toBeNull() + }) + test('a local run has no device glyph (#1067)', () => { renderRail( {}} />) expect(screen.queryByLabelText(/Runs on/)).toBeNull() diff --git a/packages/framework/dashboard/components/AgentHistory.tsx b/packages/framework/dashboard/components/AgentHistory.tsx index c2d7c9978..42bef89fc 100644 --- a/packages/framework/dashboard/components/AgentHistory.tsx +++ b/packages/framework/dashboard/components/AgentHistory.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { Plus, ChevronDown, Cloud, MonitorSmartphone, Settings, LayoutDashboard, FolderGit2, Ticket } from 'lucide-react' +import { Plus, ChevronDown, Cloud, Laptop, MonitorSmartphone, Settings, LayoutDashboard, FolderGit2, Ticket } from 'lucide-react' import type { AgentMeta, AgentStatus, RecentAgent, ProjectSummary } from '../../src/index.js' import { DRIVER_LABELS, driverFromImpl, cloudRunState, type CloudRunState } from '../../src/client.js' import { Button, buttonVariants } from './ui/button.js' @@ -257,6 +257,7 @@ export function AgentHistory({ waiting={row.agent.settledAt !== undefined} remote={row.agent.target === 'remote'} cloud={row.agent.target === 'web'} + {...(row.agent.otherHost && row.agent.host ? { startedOn: row.agent.host } : {})} cloudState={cloudRunState(row.agent, Date.now())} {...(row.agent.remoteLabel ? { remoteLabel: row.agent.remoteLabel } : {})} onClick={row.onClick} @@ -531,6 +532,7 @@ function AgentHistoryRow({ cloud = false, cloudState, remoteLabel, + startedOn, }: { status: AgentStatus intent: string | undefined @@ -553,6 +555,8 @@ function AgentHistoryRow({ cloudState?: CloudRunState | undefined /** The device'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 }) { // Only a live agent can be waiting on you; a finished one is just finished — except a web run // whose cloud session the bridge reports as parked (#1668). @@ -608,8 +612,20 @@ function AgentHistoryRow({ 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. */} - {(remote || cloud || picked) && ( + {(remote || cloud || picked || startedOn) && ( + {/* Another machine's daemon started this run (#1648): the shared data branch lists every + machine's runs here, and one that looked exactly like this daemon's own was a mystery + solved only by reading the archive. A glyph, not a word in the meta line: the rail's + fixed width truncated a hostname to "from…". */} + {startedOn && ( + + }> + + + Started on {startedOn}, by that machine's daemon. + + )} {remote && ( }> diff --git a/packages/framework/dashboard/components/Agents.SPEC.md b/packages/framework/dashboard/components/Agents.SPEC.md index f3294581a..2c0d67d9d 100644 --- a/packages/framework/dashboard/components/Agents.SPEC.md +++ b/packages/framework/dashboard/components/Agents.SPEC.md @@ -36,7 +36,7 @@ The user recognises an agent in this card by the same wording they see everywher #### Business logic -A row shows the agent's one-liner, its project's name, and how long ago it was last active, with the exact date and time on hover. The one-liner is the agent's intent, falling back to the session name it chose for itself, then to its scope, and finally to its project's name — a working agent almost always has an intent or a chosen name. +A row shows the agent's one-liner, its project's name, "from" and the machine's name when another machine's daemon started it, and how long ago it was last active, with the exact date and time on hover. The one-liner is the agent's intent, falling back to the session name it chose for itself, then to its scope, and finally to its project's name — a working agent almost always has an intent or a chosen name. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/dashboard/components/Agents.test.SPEC.md b/packages/framework/dashboard/components/Agents.test.SPEC.md index 08f122988..67f08d5fb 100644 --- a/packages/framework/dashboard/components/Agents.test.SPEC.md +++ b/packages/framework/dashboard/components/Agents.test.SPEC.md @@ -5,6 +5,7 @@ What the tests cover: the Overview's Agents card. - When nothing is working, the card says so instead of disappearing. - While the list is still being fetched, that is shown as loading rather than as "nothing is working". - An agent with no intent still gets a readable label — the session name it chose — instead of a blank line. +- An agent another machine's daemon started names that machine; this daemon's own agents do not. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/dashboard/components/Agents.test.tsx b/packages/framework/dashboard/components/Agents.test.tsx index 371d270b6..840e4ce1e 100644 --- a/packages/framework/dashboard/components/Agents.test.tsx +++ b/packages/framework/dashboard/components/Agents.test.tsx @@ -68,3 +68,11 @@ describe('Agents (#1139)', () => { expect(screen.getByText('oauth-work')).toBeTruthy() }) }) + +describe('Agents (#1648)', () => { + test('a run another machine started says whose it is; this daemon\'s own does not', () => { + render() + expect(screen.getByText('from rom-thinkpad-x280')).toBeTruthy() + expect(screen.getAllByText(/^from /)).toHaveLength(1) + }) +}) diff --git a/packages/framework/dashboard/components/Agents.tsx b/packages/framework/dashboard/components/Agents.tsx index 23ef1208e..fa971cffa 100644 --- a/packages/framework/dashboard/components/Agents.tsx +++ b/packages/framework/dashboard/components/Agents.tsx @@ -47,6 +47,7 @@ export function Agents({ key={`${a.projectId}:${a.agentId}`} label={activeLabel(a)} cloud={a.cloud} + host={a.host} at={a.updatedAt} projectName={a.projectName} onOpen={() => onSelectAgent(a.projectId, a.agentId)} @@ -62,6 +63,7 @@ export function Agents({ function AgentRow({ label, cloud, + host, at, projectName, onOpen, @@ -70,6 +72,8 @@ function AgentRow({ label: string /** A web run's cloud side at work (#1668): say where it is, since no local process backs the row. */ cloud?: 'in-cloud' | 'waiting' | undefined + /** The machine whose daemon started it (#1648), when that is not this one. */ + host?: string | undefined /** ISO: the session's last activity. */ at: string | undefined projectName: string @@ -89,6 +93,8 @@ function AgentRow({ {label} {cloud && {cloud === 'waiting' ? 'waiting' : 'in cloud'}} {projectName} + {host && from {host}} + {at && ( { + const run = (host?: string): AgentMeta => ({ status: 'done', id: 'r', startedAt: '', updatedAt: '', ...(host ? { host } : {}) }) as AgentMeta + assert.equal(markOtherHost(run('rom-thinkpad-x280'), 'suleiman-mbp').otherHost, true) + assert.equal(markOtherHost(run('suleiman-mbp'), 'suleiman-mbp').otherHost, undefined) + // A record from before the host field was written says nothing about where it ran. + assert.equal(markOtherHost(run(), 'suleiman-mbp').otherHost, undefined) +}) diff --git a/packages/framework/src/dashboard-rpc/reads.ts b/packages/framework/src/dashboard-rpc/reads.ts index 188160151..1e67685f3 100644 --- a/packages/framework/src/dashboard-rpc/reads.ts +++ b/packages/framework/src/dashboard-rpc/reads.ts @@ -25,6 +25,7 @@ import { bridgeQuestions } from '../dashboard/bridge-store.js' import type { BridgeEvent, BridgeHello, BridgeQuestion } from '../dashboard/bridge-endpoints.js' import type { BridgeAnswer, BridgeContact, BridgeVersion } from '../dashboard/bridge-store.js' import { readDaemonToken, readPreferences, type Preferences } from '../registry.js' +import { hostname } from 'node:os' // The read model behind the dashboard (#405): the agent history, an agent's replay, and the // surfaced PLAN/TODO docs — each keyed by project id and backed by @@ -85,7 +86,7 @@ export async function onAgents(projectId: string): Promise { // no longer costs anything — nothing here depends on this staying above one. const remote = contextRemote()?.list(projectId) ?? [] const cwd = await resolveProjectPath(projectId) - const local = cwd ? (await readAllAgents(cwd)).map(markCloudWaiting) : [] + const local = cwd ? (await readAllAgents(cwd)).map(forDashboard) : [] if (remote.length === 0) return local // A relayed agent (#1067) lives only in the daemon's memory, not on disk; surface it in the list so // a reload re-opens it instead of losing it. Remote wins an id tie (it is the live authority). @@ -102,6 +103,22 @@ export function markCloudWaiting(agent: AgentMeta): AgentMeta { return { ...agent, cloudWaiting: true } } +/** + * A run another machine's daemon started is marked as from another host (#1648): the data branch + * is shared precisely so other machines' runs show up here, and a row that looked exactly like + * one of this daemon's own sent the user reading the archive by hand to learn whose it was. A run + * with no recorded host predates the field and passes through untouched. + */ +export function markOtherHost(agent: AgentMeta, thisHost: string = hostname()): AgentMeta { + if (agent.host === undefined || agent.host === thisHost) return agent + return { ...agent, otherHost: true } +} + +/** Every annotation a run's record gets on its way to the dashboard: what the daemon knows and the disk cannot. */ +export function forDashboard(agent: AgentMeta): AgentMeta { + return markOtherHost(markCloudWaiting(agent)) +} + /** * The agent ids that still have a worktree on disk (#737). An agent that failed or was stopped keeps * its checkout so you can go look at what it was holding; this is how the dashboard knows which @@ -215,7 +232,8 @@ export async function onOverview(): Promise { /** Recent sessions pooled across every project (#shared-shell), newest first, for the home rail. */ export async function onRecentAgents(): Promise { - return withProjects(projects => buildRecentAgents(projects)) + const recent = await withProjects(projects => buildRecentAgents(projects)) + return recent.map(row => ({ ...row, agent: forDashboard(row.agent) })) } /** Hot tickets across every project (#1112): being worked on, likely next, and queued. */ diff --git a/packages/framework/src/dashboard/overview.SPEC.md b/packages/framework/src/dashboard/overview.SPEC.md index 90f4f6bea..deb79ab1d 100644 --- a/packages/framework/src/dashboard/overview.SPEC.md +++ b/packages/framework/src/dashboard/overview.SPEC.md @@ -6,10 +6,10 @@ The user has several projects registered and opens the dashboard without picking ## Business logic — TL;DR -- **Working now** - every project's running agents, plus every `web`-target agent whose cloud session is still working or parked on a question, most recently active first, each labelled with its task, its session name and whether it has signalled ready for merge. +- **Working now** - every project's running agents, plus every `web`-target agent whose cloud session is still working or parked on a question, most recently active first, each labelled with its task, its session name, whether it has signalled ready for merge, and — when another machine's daemon started it — that machine's name. - **Backlog size** - the number of still-open agent queue entries summed across every project. - **Recently active projects** - the five projects that saw activity most recently. -- **Recent agents, pooled** - the newest agents across all projects in one list, each tagged with the project it belongs to. +- **Recent agents, pooled** - the newest agents across all projects in one list, each tagged with the project it belongs to, and listed once even when two registered checkouts share the archive that holds it. - **Every project's tickets** - one ticket list per registered project, in registry order, projects kept even when they have no tickets. - **Hot tickets** - a shortlist of tickets across all projects, sorted into three lanes: being worked on now, waiting in the agent queue, or merely flagged high priority. - **A broken project is silently absent** - anything unreadable contributes nothing rather than failing the whole Overview. @@ -24,7 +24,7 @@ The user wants to know, without opening any project, which agents are running an #### Business logic -Every project's live agents are read and only those still running are listed, one entry per agent — a single project can have several in flight, each in its own worktree. Every project's archived agents are read too, for the `web`-target agents whose cloud side is still at work per the cloud state rule (`cloud-run-state`): in cloud, or waiting on a question the browser bridge holds. Those are listed as well, marked with that state, keyed to the project's own path since their checkout may be gone, and once each even when two registered checkouts of one repository share the archive that holds them; a cloud session at work is an agent at work, and "no agents working" over a session waiting on the user was a lie. Each entry carries the project it belongs to, the agent's own checkout so its git and file status is read from the worktree it actually edits, what the user asked for, when the agent last spoke, the session name the agent chose if it has picked one, and whether it has signalled ready for merge (which drives the building-versus-ready indicator). The list is ordered by last activity, most recent first. +Every project's live agents are read and only those still running are listed, one entry per agent — a single project can have several in flight, each in its own worktree. Every project's archived agents are read too, for the `web`-target agents whose cloud side is still at work per the cloud state rule (`cloud-run-state`): in cloud, or waiting on a question the browser bridge holds. Those are listed as well, marked with that state, keyed to the project's own path since their checkout may be gone, and once each even when two registered checkouts of one repository share the archive that holds them; a cloud session at work is an agent at work, and "no agents working" over a session waiting on the user was a lie. Each entry carries the project it belongs to, the agent's own checkout so its git and file status is read from the worktree it actually edits, what the user asked for, when the agent last spoke, the session name the agent chose if it has picked one, whether it has signalled ready for merge (which drives the building-versus-ready indicator), and the name of the machine whose daemon started it when that is not this machine — the data branch is shared, so another machine's agents appear here too. The list is ordered by last activity, most recent first. ### Backlog size @@ -54,7 +54,7 @@ On the Overview no single project is selected, yet the sidebar should still list #### Business logic -Every project's agents are pooled into one list, each row tagged with the project it belongs to so selecting it jumps into that project's agent. The list is ordered by start time, newest first, and capped at thirty rows. A project whose agents cannot be read contributes nothing. +Every project's agents are pooled into one list, each row tagged with the project it belongs to so selecting it jumps into that project's agent. The list is ordered by start time, newest first, and capped at thirty rows. An agent is listed once: two registered checkouts of one repository share the archive that holds it, and the first project to list it keeps it. A project whose agents cannot be read contributes nothing. ### Every project's tickets diff --git a/packages/framework/src/dashboard/overview.test.SPEC.md b/packages/framework/src/dashboard/overview.test.SPEC.md index 4f272223d..d160f9cbd 100644 --- a/packages/framework/src/dashboard/overview.test.SPEC.md +++ b/packages/framework/src/dashboard/overview.test.SPEC.md @@ -1,8 +1,8 @@ What the tests cover, for the cross-project Overview: -- **Working now** — only running agents are surfaced, ordered by last activity with the most recent first; finished agents are left out. A `web`-target agent whose cloud session is still working or waiting on a bridged question is listed, marked with that state and keyed to the project's path; one whose work was adopted, one too old for its session to be alive, and one that was stopped are not; two projects sharing one archive list each such agent once. +- **Working now** — only running agents are surfaced, ordered by last activity with the most recent first; finished agents are left out. A `web`-target agent whose cloud session is still working or waiting on a bridged question is listed, marked with that state and keyed to the project's path; one whose work was adopted, one too old for its session to be alive, and one that was stopped are not; two projects sharing one archive list each such agent once. An agent another machine's daemon started carries that machine's name; one this machine started, or one with no recorded host, carries none. - **Backlog and recents** — the open agent queue entries of every project are summed into one total; recently active projects come back newest first, capped at five, and a project that has never seen activity is omitted. -- **Recent agents** — every project's agents are pooled into one newest-first list, each tagged with the project it belongs to; a project whose agents cannot be read contributes nothing. +- **Recent agents** — every project's agents are pooled into one newest-first list, each tagged with the project it belongs to; two projects sharing one archive list each agent once, under the first; a project whose agents cannot be read contributes nothing. - **Ticket lanes** — a ticket being implemented by a running agent, or merely planned, is "in progress"; a ticket linked from an open agent queue entry is in the AI Queue lane; a ticket flagged high priority and nothing else is "high priority"; anything in none of the three is dropped. Precedence is in progress over AI Queue over high priority. - **Priority scale** — priority is read on the ticket format's 0-to-10 scale, so 7 and above is high and 6 and below is not; word spellings such as "high", "urgent", "p0" and "p1" do not count as high. - **Hot tickets** — tickets from all projects are pooled, bucketed, ordered lane first, and tickets in no lane drop off the card; the ticket a running agent is implementing carries that agent's identity so the card can link into it; a finished agent is not implementing anything, so its ticket falls back to whatever other lane applies; a ticket path is matched only within its own project, so one project's agent never lights up another project's identically named ticket; a project whose tickets cannot be read contributes nothing. diff --git a/packages/framework/src/dashboard/overview.test.ts b/packages/framework/src/dashboard/overview.test.ts index cf65bf93c..a5e5ff826 100644 --- a/packages/framework/src/dashboard/overview.test.ts +++ b/packages/framework/src/dashboard/overview.test.ts @@ -278,3 +278,37 @@ test('buildOverview lists a web run whose cloud side is still at work, and says ], ) }) + +test('buildRecentAgents lists a run once when two checkouts share its archive (#1648)', async () => { + const shared = [agent('r2', '2026-07-13T12:00:00Z'), agent('r1', '2026-07-13T09:00:00Z')] + const recent = await buildRecentAgents([project('alpha', '/a'), project('alpha-clone', '/a2')], { + agents: async () => shared, + }) + assert.deepEqual( + recent.map(r => ({ project: r.projectName, id: r.agent.id })), + [ + { project: 'alpha', id: 'r2' }, + { project: 'alpha', id: 'r1' }, + ], + ) +}) + +test('buildOverview names the machine that started a run when it is not this one (#1648)', async () => { + const at = '2026-07-13T12:00:00Z' + const run = (id: string, host?: string): AgentMeta => + ({ version: 1, status: 'running', id, startedAt: at, updatedAt: at, intent: id, cwd: `/wt/${id}`, ...(host ? { host } : {}) }) as AgentMeta + const overview = await buildOverview([project('a', '/a')], { + liveAgents: async () => [run('mine', 'this-mac'), run('theirs', 'rom-thinkpad-x280'), run('unknown')] as never, + agents: async () => [], + queue: async () => [], + host: 'this-mac', + }) + assert.deepEqual( + overview.active.map(r => ({ id: r.agentId, host: r.host })), + [ + { id: 'mine', host: undefined }, + { id: 'theirs', host: 'rom-thinkpad-x280' }, + { id: 'unknown', host: undefined }, + ], + ) +}) diff --git a/packages/framework/src/dashboard/overview.ts b/packages/framework/src/dashboard/overview.ts index 8262983e9..b83b09d7c 100644 --- a/packages/framework/src/dashboard/overview.ts +++ b/packages/framework/src/dashboard/overview.ts @@ -5,6 +5,7 @@ import { readTickets, type WorkspaceTicket } from './tickets.js' import { TICKETS_DIR } from '../tickets.js' import { cloudRunState } from '../cloud-run-state.js' import { bridgeQuestions } from './bridge-store.js' +import { hostname } from 'node:os' // The first-sidebar Overview (#437, part of #314): a cross-project glance at what the agent // is working on right now, the size of the backlog, and the recently active projects. It @@ -36,6 +37,8 @@ export interface ActiveAgent { * cloud side is the agent, and "no agents working" over a waiting cloud session was a lie. */ cloud?: 'in-cloud' | 'waiting' + /** The machine whose daemon started it (#1648), only when that is not this one. */ + host?: string } /** One recently active project, most-recent first. */ @@ -88,7 +91,10 @@ export async function buildRecentAgents(projects: ProjectSummary[], deps: Recent } } all.sort((a, b) => (b.agent.startedAt ?? '').localeCompare(a.agent.startedAt ?? '')) - return all.slice(0, RECENT_RUNS_LIMIT) + // One row per run (#1648): two checkouts of one repository share their archive, so every run + // was listed once per checkout; the first project to list it keeps it. + const seen = new Set() + return all.filter(row => !seen.has(row.agent.id) && seen.add(row.agent.id)).slice(0, RECENT_RUNS_LIMIT) } /** One project's tickets, for the cross-project Tickets page (#1144). */ @@ -268,6 +274,8 @@ export interface OverviewDeps { /** Whether the bridge holds a question for that cloud session (default: the daemon's bridge store). */ waiting?: (sessionId: string) => boolean now?: () => number + /** This machine's hostname; `node:os` in production. */ + host?: string } /** @@ -282,6 +290,7 @@ export async function buildOverview(projects: ProjectSummary[], deps: OverviewDe const agents = deps.agents ?? readAllAgents const waiting = deps.waiting ?? (sessionId => bridgeQuestions().get(sessionId) !== undefined) const now = (deps.now ?? Date.now)() + const thisHost = deps.host ?? hostname() const active: ActiveAgent[] = [] // One entry per web run across projects: two checkouts of one repository share their archive, @@ -297,6 +306,9 @@ export async function buildOverview(projects: ProjectSummary[], deps: OverviewDe ...(meta.updatedAt ? { updatedAt: meta.updatedAt } : {}), ...(meta.sessionName ? { sessionName: meta.sessionName } : {}), ...(meta.readyForMerge ? { readyForMerge: true } : {}), + // Another machine's daemon started it (#1648): the shared data branch shows its runs here too. + ...(meta.host !== undefined && meta.host !== thisHost ? { host: meta.host } : {}), + }) for (const project of projects) { // Every live agent of the project (#738), not just the one that used to sit at its path. diff --git a/packages/framework/src/store/agent-store.SPEC.md b/packages/framework/src/store/agent-store.SPEC.md index a3bae9cfc..f6e4c08ce 100644 --- a/packages/framework/src/store/agent-store.SPEC.md +++ b/packages/framework/src/store/agent-store.SPEC.md @@ -34,7 +34,7 @@ The user opens an agent and sees a header: what it was asked for, which coding a An agent's every fact arrives as an event appended to its event log, and the agent meta is the running fold of those events. Folding is the same operation whether it happens as an event is appended or by replaying a whole log from scratch, so a record rebuilt after a restart says exactly what the live one said. -The agent meta carries: whether the agent is running, done, stopped or failed; its id and its start and last-update times; the process and host that own it; what it was asked for; which driver and workspace it uses; the wrapped CLI's session id, session link and the agent-chosen session name; the branch its work is on; the ticket it implements, when the framework picked one; the pull request opened for it; whether it signalled ready for merge; what its handoff is armed to do and how that handoff reported back; the gate it is parked on; whether it has settled on the user; for a cloud session, whether the browser bridge holds a question it is parked on (marked by the daemon on the way to the dashboard, never stored); the port its browser preview listens on; its run target and, for a relayed agent, the device label; whether it started as a build or a direct prompt; the model the current leg runs; and, for a cloud session, the hand-off anchor commit. +The agent meta carries: whether the agent is running, done, stopped or failed; its id and its start and last-update times; the process and host that own it; what it was asked for; which driver and workspace it uses; the wrapped CLI's session id, session link and the agent-chosen session name; the branch its work is on; the ticket it implements, when the framework picked one; the pull request opened for it; whether it signalled ready for merge; what its handoff is armed to do and how that handoff reported back; the gate it is parked on; whether it has settled on the user; for a cloud session, whether the browser bridge holds a question it is parked on, and whether another machine's daemon started the agent — both marked by the daemon on the way to the dashboard, never stored; the port its browser preview listens on; its run target and, for a relayed agent, the device label; whether it started as a build or a direct prompt; the model the current leg runs; and, for a cloud session, the hand-off anchor commit. Individual folding rules that are not merely "the last value wins": diff --git a/packages/framework/src/store/agent-store.ts b/packages/framework/src/store/agent-store.ts index 44cfb662e..27ff37703 100644 --- a/packages/framework/src/store/agent-store.ts +++ b/packages/framework/src/store/agent-store.ts @@ -206,6 +206,13 @@ export interface AgentMeta { * label is, because the bridge store is in memory and the archive on disk knows nothing of it. */ cloudWaiting?: boolean + /** + * The run was started by another machine's daemon (#1648): its {@link host} is not this one. + * Not stored either — annotated on the way to the dashboard like {@link cloudWaiting}, since + * the shared data branch shows every machine's runs here and only this daemon knows which host + * it is. + */ + otherHost?: boolean /** * The loopback port the agent's browser preview is listening on (#813), or absent when the agent * has no browser. What lets the daemon proxy the pane: the port is allocated per agent and the