From 31a981bb54f92e8a78993b815fdb527fd9f76a39 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 8 Jun 2026 14:56:54 +0200 Subject: [PATCH 1/2] docs(home-registry): define Home Registry domain terms and ADR 0008 Captures the grilled design for the Home Registry + dashboard home picker (#130): new CONTEXT.md glossary terms (Home, Home Registry, Active Home), a Home-aware Session Dashboard, and docs/adr/0008 recording the advisory, per-machine, gc-reconciled registry decision. Change-Id: I0dc1149392295870c53a17bd5bdfd5d1b98ee7c9 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CONTEXT.md | 26 ++++- ...0008-home-registry-advisory-per-machine.md | 101 ++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 docs/adr/0008-home-registry-advisory-per-machine.md diff --git a/CONTEXT.md b/CONTEXT.md index 28d4843d..ec26634f 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,6 +4,10 @@ ## Language +**Home**: +An agent-tty state root: an absolute directory containing a `sessions/` tree (and optional config), identified entirely by its path and relocatable via `--home` or `AGENT_TTY_HOME`. It is effectively a per-root store of **Sessions**. +_Avoid_: session store, AGENT_TTY_HOME (that names the override variable, not the concept), OS home / `$HOME` + **Session**: A long-lived PTY-backed terminal instance owned by `agent-tty`. @@ -172,10 +176,20 @@ The post-merge process that creates and publishes the release tag from the defau **Publish Pipeline**: The tag-triggered automation that validates, packages, and publishes a release after the **Release Finalization Step**. +### Home registry and discovery + +**Home Registry**: +A persisted, advisory, per-machine index of **Homes** that have hosted a **Session**, used to discover and pick **Homes** for observation. The **Home** directories remain the source of truth; the **Home Registry** is a reconciled cache that may go stale and is pruned, never an authority on which **Homes** exist. +_Avoid_: home database, home store, source of truth + +**Active Home**: +A **Home** with at least one **Active Session**. It is the default scope of the **Home Registry** listing and the dashboard home picker, mirroring how `list` defaults to **Active Sessions**. +_Avoid_: live home, running home + ### Dashboard and live observation **Session Dashboard**: -The human-facing, read-only terminal surface that lists **Sessions** and presents the **Live View** of a selected **Session**. Its purpose is observation, not control. +The human-facing, read-only terminal surface that selects a **Home** from the **Home Registry**, lists that **Home**'s **Sessions**, and presents the **Live View** of a selected **Session**. Its purpose is observation, not control. _Avoid_: viewer, attach UI **Live View**: @@ -210,6 +224,12 @@ _Avoid_: bare "agent", "Coder agent" ## Relationships +- A **Home** contains a set of **Sessions**; every **Session** belongs to exactly one **Home** and is located by that **Home**'s path. +- The **Home Registry** lists **Homes**; its default scope is **Active Homes**, and an `all` scope shows every registered **Home**, mirroring the `active`/`all` scope `list` and the **Session Dashboard** already apply to **Sessions**. +- A **Home** enters the **Home Registry** when it first hosts a **Session**, and is pruned once its directory or **Sessions** no longer exist; the registry is always reconciled against the **Home** directories, never the reverse. +- A **Home** directory is user-owned — its path is arbitrary via `--home`/`AGENT_TTY_HOME` — so agent-tty manages the **Sessions** inside a **Home** and that **Home**'s **Home Registry** entry, but never deletes the **Home** directory itself. +- Deregistering a **Home** has no effect on the **Home**; a deregistered **Home** re-enters the **Home Registry** the next time it hosts a **Session**. The **Home Registry** gates nothing. +- The **Home Registry** is per-machine: it indexes only **Homes** on the local machine, and sharing a **Home** across machines is out of scope because reconciliation and garbage collection assume local processes. (See the Home Registry ADR.) - A **Session** has exactly one **Session Status** at a time. - A `running` **Session** is **Active**, **Commandable**, and **Live Host Eligible**. - An `exiting` **Session** is **Active** and **Live Host Eligible**, but not **Commandable**. @@ -219,7 +239,8 @@ _Avoid_: bare "agent", "Coder agent" - A **Session** has one **Event Log**. - An **Offline Replay Eligible Session** is reconstructed from its persisted **Event Log** and manifest. - A **Snapshot Result** is derived from exactly one **Semantic Snapshot**. -- A **Session Dashboard** presents a **Live View** of exactly one selected **Session** at a time. +- A **Session Dashboard** observes one **Home** at a time, chosen from the **Home Registry** (defaulting to the **Home** it was launched with); switching **Homes** is part of its read-only observation and sends no input. +- A **Session Dashboard** presents a **Live View** of exactly one selected **Session** at a time within the selected **Home**. - A **Live View** reconstructs screen state from a **Session**'s **Event Log** and is never a **Command Target**. - A **Live View** is produced by **Event Log Follow**, never by querying the live session host. - **Event Log Follow** applies uniformly to **Live Host Eligible** and **Offline Replay Eligible** Sessions because it depends only on the append-only **Event Log**. @@ -309,3 +330,4 @@ _Avoid_: bare "agent", "Coder agent" - "demo" and "proof" are not interchangeable for coding-agent recordings: a **Hero Demo** optimizes for stable presentation, while a **Recursive Dogfood Proof** optimizes for self-dogfood coverage. - "agent" is overloaded across four referents: this project's **Triage Agent** (a Claude Code instance), Coder's **Coder workspace agent** (the SSH/exec daemon), a generic AFK implementation agent (the actor on `ready-for-agent` issues — Phase 2 of the triage pipeline), and — in **Session Dashboard** product copy only — the external client driving a **Session** (often an AI coding agent). The last sense is deliberately **not** a domain term: the **Session Dashboard** and **Live View** are defined over **Sessions**, not agents, and the **Event Log** does not record which client sent input. Do not make the dashboard agent-aware (grouping or filtering by agent identity) without first extending the domain model. Always qualify in code comments and docs. - "batch" is overloaded: a **Batch** (an ordered **Batch Step** sequence driven through one **Command Target** by the `batch` command) is unrelated to a **Triage Batch** (the set of issues processed by one **AFK Triage** invocation). They live in different subsystems; always rely on the qualifier. +- "home" is overloaded: an agent-tty **Home** (this project's state root — the `--home`/`AGENT_TTY_HOME` directory) is not the operating-system home directory (`$HOME`, `homedir()`), even though the default **Home** sits at `~/.agent-tty` inside it. Always qualify as "agent-tty Home" in prose and product copy whenever OS home is also in scope. diff --git a/docs/adr/0008-home-registry-advisory-per-machine.md b/docs/adr/0008-home-registry-advisory-per-machine.md new file mode 100644 index 00000000..e5be6562 --- /dev/null +++ b/docs/adr/0008-home-registry-advisory-per-machine.md @@ -0,0 +1,101 @@ +--- +status: accepted +--- + +# Home Registry: an advisory, per-machine, gc-reconciled index of Homes + +## Context + +`agent-tty` manages **Sessions** under a **Home** — a state root selected by +`--home`/`AGENT_TTY_HOME` (default `~/.agent-tty`) that holds a `sessions/` +tree. `list` and the **Session Dashboard** operate on exactly one **Home**, the +one resolved for that invocation. + +There is no way to enumerate **Homes**. The only cross-**Home** artifact is the +per-**Home** socket directory at `/tmp/agent-tty//` +(`src/storage/sessionPaths.ts`), and that is a dead end for discovery: the name +is a one-way hash (the **Home** path cannot be recovered from it), the directory +is created but never removed when hosts die (`src/host/rpcServer.ts` only +unlinks the per-**Session** socket *file*), and `/tmp` is reboot-ephemeral. + +We want the **Session Dashboard** to pick a **Home** and inspect that **Home**'s +**Sessions**, and we want a "which **Homes** am I using" listing comparable to +`list`. That requires persisting the set of **Homes**. Because the docs actively +encourage throwaway **Homes** (`AGENT_TTY_HOME="$(mktemp -d)"`), any persisted +set will accumulate dead entries as those directories are garbage-collected or +`rm -rf`'d. + +## Decision + +Introduce a **Home Registry**: a persisted, **advisory**, **per-machine** index +of **Homes** that have hosted a **Session**. + +- **Location:** `${XDG_STATE_HOME:-~/.local/state}/agent-tty/homes.json`. The + path is a function of the OS user, never of `AGENT_TTY_HOME` — the registry + spans **Homes**, so it cannot live inside one (a **Home** is relocatable). +- **Entry shape:** `{ path, lastSeenAt }` only. All **Session** state (active + counts, statuses) is **derived live** by scanning the **Home** at read time, + never cached in the registry. `home list` sorts newest-`lastSeenAt`-first, + mirroring how **Sessions** sort newest-`createdAt`-first. +- **Source of truth is the Home directories.** The registry is reconciled + *against* them, never the reverse. A **Home** auto-registers when it first + hosts a **Session** (on `create`). It is reconciled out by three layered + mechanisms: **prune-on-read** (listings and the dashboard picker hide **Homes** + whose directory or `sessions/` is gone), **per-Home gc deregistering** a + **Home** it empties, and a **cross-Home gc sweep**. +- **`gc` becomes cross-Home by default** — it sweeps the whole **Home + Registry** — with `--home` (or an explicit `AGENT_TTY_HOME`) scoping it to a + single **Home**. gc collects Collectable **Session** directories and prunes + registry entries, but **never deletes a Home directory** (the path is + user-owned and arbitrary). `home forget ` is a non-destructive manual + deregister. +- **CLI:** a new `home` command group — `home list [--all] [--json]` (**Active + Homes** by default, `--all` includes terminal-only **Homes**) and + `home forget `. +- **Dashboard:** the **Session Dashboard** gains a read-only **Home** picker + (default scope **Active Homes**). Browsing **Homes** does **not** reconcile; + full reconcile happens only on **Home** entry, exactly as it already does for a + single **Home**. + +## Consequences + +- The picker and `home list` never show a stale **Home** (prune-on-read), and + the file stays small (gc sweep) without `agent-tty` ever deleting a user + directory. +- Deregistration is safe and idempotent: a deregistered **Home** re-registers + the next time it hosts a **Session**; the registry gates nothing. +- **Cross-machine sharing of a Home is unsupported.** Cross-**Home** gc + reconciles **Sessions** across every registered **Home**, and reconciliation + judges liveness with local PIDs (`isProcessAlive` = `process.kill(pid, 0)`) + and SIGKILLs dead-host orphans (`killProcessBestEffort` = + `process.kill(pid, 'SIGKILL')`, `src/host/lifecycle.ts`). Manifests carry no + machine identity (`src/protocol/schemas.ts` records only `hostPid`/`childPid`). + So a **Home** shared across machines (e.g. NFS) is a hazard: a cross-**Home** + gc on machine B could mark machine A's live **Session** `failed` and SIGKILL + an unrelated local PID. The per-machine, local-only boundary contains this and + matches the Coder model (separate workspaces are separate machines and + filesystems, each with its own registry). +- **Backward-incompatible CLI change:** plain `gc` changes from "collect the + default **Home**" to "sweep all registered **Homes**." Automation that relied + on `gc` meaning the default **Home** must pass `--home`. + +## Alternatives considered + +- **An authoritative registry** (add/remove are the only way **Homes** exist). + Rejected: it becomes a second source of truth that can disagree with the + filesystem (a `rm -rf`'d **Home**, or a **Home** created before this feature). + An advisory index reconciled at read time never diverges. +- **Deriving active Homes from the `/tmp` socket tree** instead of persisting. + Rejected: the directory name is a one-way hash (no path recovery), socket + directories linger after hosts die (false "active"), the tree is + reboot-ephemeral, and it could never surface terminal-only **Homes** for + offline replay. +- **A machine-identity guard now** (stamp manifests with a machine id; skip + reconcile/kill for **Sessions** whose id ≠ current). Deferred, not rejected: it + is the right hardening *if* shared-filesystem or remote-aggregation **Homes** + ever come into scope, but it is unjustified for v1 given the per-machine + boundary and the Coder model. Tracked as follow-up. +- **gc deletes emptied Home directories.** Rejected: `--home` is an arbitrary, + user-owned path; a stale registry entry plus `rm -rf` is a footgun. gc stops + at **Session** directories and registry entries; the **Home** directory is the + user's to delete. From 7026d7289a6fcfca5b722514ef7d877806b0c7c2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 8 Jun 2026 16:51:23 +0200 Subject: [PATCH 2/2] feat(home-registry): Home Registry, `home` commands, and dashboard Home picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist every agent-tty Home (state root) that has hosted a Session in a per-machine advisory registry, so the dashboard can switch Homes and `home list` can enumerate them — comparable to `list` for Sessions. - HomeRegistry store at ${XDG_STATE_HOME:-~/.local/state}/agent-tty/homes.json: {path, lastSeenAt} entries, atomic temp+rename writes, independent of AGENT_TTY_HOME. Auto-registered on `create` (advisory; never fails create). - `home list [--all] [--json]` and `home forget `: a read-only manifest scan that never reconciles, prune-on-read, newest-first; counts derived live. - Dashboard gains a full-screen Home picker (press H); browsing Homes never reconciles, entering a Home reconciles exactly as the single-Home dashboard. - gc becomes cross-Home by default — sweeps every registered Home and deregisters emptied/gone ones; `--home`/`AGENT_TTY_HOME` scopes to one and touches no registry. Never deletes a Home directory. New cross-Home result shape (homes[] + aggregates). BACKWARD-INCOMPATIBLE: automation relying on plain `gc` collecting only the default Home must now pass `--home`. - CommandContext.explicitHome distinguishes a selected vs defaulted Home. Per-machine / local-only boundary (reconcile uses local PIDs); cross-machine shared Homes are unsupported and the machine-id guard is deferred (ADR 0008). Implements #130. Change-Id: I05a58990ffaeadcb9deebee37042a6f968c0000e Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Thomas Kosiewski --- CHANGELOG.md | 2 + ...0008-home-registry-advisory-per-machine.md | 6 +- src/cli/commands/create.ts | 14 + src/cli/commands/gc.ts | 206 +++++++++++-- src/cli/commands/home/forget.ts | 46 +++ src/cli/commands/home/list.ts | 49 ++++ src/cli/context.ts | 5 + src/cli/main.ts | 48 +++- src/dashboard/app.tsx | 271 ++++++++++++++++-- src/storage/homeRegistry.ts | 219 ++++++++++++++ src/storage/homeScope.ts | 149 ++++++++++ test/integration/gc.test.ts | 165 ++++++++++- test/integration/lifecycle.test.ts | 21 +- test/unit/cli/context.test.ts | 17 ++ test/unit/commands/create.test.ts | 26 ++ test/unit/commands/gc.test.ts | 160 ++++++++++- test/unit/commands/golden-envelopes.test.ts | 157 ++++++++-- test/unit/commands/home-forget.test.ts | 73 +++++ test/unit/commands/home-list.test.ts | 104 +++++++ test/unit/commands/inspect.test.ts | 1 + test/unit/commands/mark.test.ts | 1 + test/unit/commands/paste.test.ts | 1 + test/unit/commands/record-export.test.ts | 1 + test/unit/commands/resize.test.ts | 1 + test/unit/commands/run.test.ts | 1 + test/unit/commands/screenshot.test.ts | 1 + test/unit/commands/send-keys.test.ts | 1 + test/unit/commands/signal.test.ts | 1 + test/unit/commands/snapshot.test.ts | 1 + test/unit/commands/type.test.ts | 1 + test/unit/commands/wait.test.ts | 1 + test/unit/storage/homeRegistry.test.ts | 205 +++++++++++++ test/unit/storage/homeScope.test.ts | 177 ++++++++++++ 33 files changed, 2047 insertions(+), 85 deletions(-) create mode 100644 src/cli/commands/home/forget.ts create mode 100644 src/cli/commands/home/list.ts create mode 100644 src/storage/homeRegistry.ts create mode 100644 src/storage/homeScope.ts create mode 100644 test/unit/commands/home-forget.test.ts create mode 100644 test/unit/commands/home-list.test.ts create mode 100644 test/unit/storage/homeRegistry.test.ts create mode 100644 test/unit/storage/homeScope.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 134216ff..70952888 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,13 @@ - `agent-tty batch `: run an ordered sequence of input-and-`wait` steps against one session in a single invocation, supplied as a positional JSON array or `--file`. Each step is one verb (`type`, `paste`, `sendKeys`, `run`, or `wait`); every `wait` is anchored to a Wait Baseline (the Event Log sequence after the preceding input step) so it cannot match a stale screen the way a hand-written `run`/`wait`/`send-keys` loop can (ADR 0007). Fail-fast by default with a non-zero exit and a per-step `--json` envelope; `--keep-going` attempts every step. SIGINT/SIGTERM flushes a partial envelope (in-flight step `interrupted`, later steps `not-run`). Adds a new `WAIT_TIMEOUT` error and exit code `11` for timed-out wait steps inside a batch ([#126](https://github.com/coder/agent-tty/pull/126), closes [#123](https://github.com/coder/agent-tty/issues/123)). - Optional `screenHash` on `snapshot` and render-`wait` results (also on matched `batch` wait steps): a lowercase 64-char SHA-256 of the canonical visible-screen text (`visibleLines[].text` joined by `\n`, no scrollback, cursor, or styles). Gives automation a stable token to tell whether the rendered screen actually changed between two observations without diffing full text, and unlike the Event Log sequence it does not advance on cursor moves or no-op repaints. Present on every result that observed a snapshot (live matches, captures, and the offline `matched:false` fallback); absent only when no screen was observed (live timeout, consecutive-failure giveup, replay-error throw). Standalone `wait` adds an `--after-seq` flag, and `type` / `paste` results now return their Event Log `seq` so callers can anchor a following wait themselves ([#127](https://github.com/coder/agent-tty/pull/127), closes [#125](https://github.com/coder/agent-tty/issues/125)). +- **Home Registry + dashboard Home picker**: agent-tty now remembers every **Home** (state root) that has hosted a Session in a per-machine, advisory index at `${XDG_STATE_HOME:-~/.local/state}/agent-tty/homes.json`, auto-registered on `create` and independent of `AGENT_TTY_HOME`. New `agent-tty home list [--all] [--json]` lists registered Homes — Active Homes by default, `--all` adds terminal-only ones — each with live active/total Session counts and a last-seen timestamp, newest first; `agent-tty home forget ` deregisters a Home without touching disk. The read-only `dashboard` gains an additive Home picker (press `H`, `Enter` to switch): browsing Homes performs a read-only scan that never reconciles or mutates a Session, while entering a Home reconciles exactly as the single-Home dashboard does today. Both surfaces prune dead or empty Homes on read so a deleted `mktemp -d` Home never lingers in a listing (ADR 0008, [#130](https://github.com/coder/agent-tty/issues/130)). ### Changed - Both renderer backends (`libghostty-vt` and `ghostty-web`) now produce one canonical visible-screen form (exactly `rows` lines, full grapheme clusters, interior blank cells as spaces, ASCII-only trailing trim) shared by the new Screen Hash, host Screen Stability comparison, and the text Render Wait matcher. This narrows a long-standing divergence so the three can never disagree about "the screen", and intentionally changes the default `ghostty-web` stability/text-wait comparand on screens with grapheme clusters, interior gaps, or non-ASCII trailing characters ([#127](https://github.com/coder/agent-tty/pull/127)). - README front door rewritten: agent-facing one-liner and "like Playwright, but for terminal apps" framing up top, a new **What you'd use it for** section, a **Watch sessions live** section covering the read-only `dashboard`, and explicit PNG + WebM artifact positioning vs text/asciicast tools. The command surface is folded into prose and moved after the demos; `ROADMAP.md` is retired and every cross-reference removed ([#122](https://github.com/coder/agent-tty/pull/122)). The Codex/Claude agent demo videos now sit right after **What you'd use it for**, before Quickstart, instead of being buried near the bottom ([#128](https://github.com/coder/agent-tty/pull/128)). +- **`gc` is now cross-Home by default** (backward-incompatible): plain `agent-tty gc` sweeps every registered Home and deregisters the ones it empties or finds deleted, rather than collecting only the default Home. The result envelope changes shape accordingly — a top-level `homes[]` of per-Home outcomes (`removedSessions`, `skippedSessions`, `totalBytesFreed`, `existed`, `deregistered`) plus aggregate `removedSessionCount`/`totalBytesFreed`/`deregisteredHomes` — replacing the former flat `removedSessions`/`skippedSessions`/`totalBytesFreed`. Pass `--home ` (or set `AGENT_TTY_HOME`) to scope collection to a single Home as before. gc never deletes a Home directory. Automation that relied on `gc` meaning the default Home, or on the old result shape, must pass `--home` and read `homes[]` (ADR 0008, [#130](https://github.com/coder/agent-tty/issues/130)). ## [v0.3.0] - 2026-06-03 diff --git a/docs/adr/0008-home-registry-advisory-per-machine.md b/docs/adr/0008-home-registry-advisory-per-machine.md index e5be6562..b2800d2e 100644 --- a/docs/adr/0008-home-registry-advisory-per-machine.md +++ b/docs/adr/0008-home-registry-advisory-per-machine.md @@ -16,7 +16,7 @@ per-**Home** socket directory at `/tmp/agent-tty//` (`src/storage/sessionPaths.ts`), and that is a dead end for discovery: the name is a one-way hash (the **Home** path cannot be recovered from it), the directory is created but never removed when hosts die (`src/host/rpcServer.ts` only -unlinks the per-**Session** socket *file*), and `/tmp` is reboot-ephemeral. +unlinks the per-**Session** socket _file_), and `/tmp` is reboot-ephemeral. We want the **Session Dashboard** to pick a **Home** and inspect that **Home**'s **Sessions**, and we want a "which **Homes** am I using" listing comparable to @@ -38,7 +38,7 @@ of **Homes** that have hosted a **Session**. never cached in the registry. `home list` sorts newest-`lastSeenAt`-first, mirroring how **Sessions** sort newest-`createdAt`-first. - **Source of truth is the Home directories.** The registry is reconciled - *against* them, never the reverse. A **Home** auto-registers when it first + _against_ them, never the reverse. A **Home** auto-registers when it first hosts a **Session** (on `create`). It is reconciled out by three layered mechanisms: **prune-on-read** (listings and the dashboard picker hide **Homes** whose directory or `sessions/` is gone), **per-Home gc deregistering** a @@ -92,7 +92,7 @@ of **Homes** that have hosted a **Session**. offline replay. - **A machine-identity guard now** (stamp manifests with a machine id; skip reconcile/kill for **Sessions** whose id ≠ current). Deferred, not rejected: it - is the right hardening *if* shared-filesystem or remote-aggregation **Homes** + is the right hardening _if_ shared-filesystem or remote-aggregation **Homes** ever come into scope, but it is unjustified for v1 given the per-machine boundary and the Coder model. Tracked as follow-up. - **gc deletes emptied Home directories.** Rejected: `--home` is an arbitrary, diff --git a/src/cli/commands/create.ts b/src/cli/commands/create.ts index accdb812..7e824be2 100644 --- a/src/cli/commands/create.ts +++ b/src/cli/commands/create.ts @@ -14,6 +14,7 @@ import { import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; import type { SessionRecord } from '../../protocol/schemas.js'; +import { upsertHome } from '../../storage/homeRegistry.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { manifestPath, @@ -178,6 +179,19 @@ export async function runCreateCommand(options: CommandOptions): Promise { } const home = options.context.home; + // Advisory: remember this Home in the per-machine Home Registry so it can be + // discovered later (`home list`, dashboard picker). A registry hiccup must + // never fail session creation — but log it at debug so it stays diagnosable + // instead of vanishing. + await upsertHome(home).catch((error: unknown) => { + options.context.logger.debug( + 'failed to register Home in the Home Registry', + { + home, + error: error instanceof Error ? error.message : String(error), + }, + ); + }); const sessionDirectory = sessionDir(home, sessionId); const socketFile = socketPath(sessionDirectory); let lastError: CliError | null = null; diff --git a/src/cli/commands/gc.ts b/src/cli/commands/gc.ts index 3fa60b14..d887e89f 100644 --- a/src/cli/commands/gc.ts +++ b/src/cli/commands/gc.ts @@ -11,12 +11,17 @@ import { isTerminalSessionStatus, } from '../../protocol/sessionStatusPolicy.js'; import type { SessionRecord } from '../../protocol/schemas.js'; +import { + forgetHome as forgetHomeDefault, + readHomeRegistry, +} from '../../storage/homeRegistry.js'; import { readManifestIfExists } from '../../storage/manifests.js'; import { manifestPath, sessionDir } from '../../storage/sessionPaths.js'; import { invariant } from '../../util/assert.js'; import { hasErrorCode } from '../../util/hasErrorCode.js'; -export interface GcResult { +/** Per-Home collection result returned by `gcSessions`. */ +export interface GcSessionSweep { removedSessions: string[]; skippedSessions: Array<{ sessionId: string; @@ -26,6 +31,33 @@ export interface GcResult { totalBytesFreed: number; } +/** What gc did to a single Home during a (possibly cross-Home) run. */ +export interface GcHomeOutcome { + home: string; + /** Whether the Home directory existed at sweep time. */ + existed: boolean; + removedSessions: string[]; + skippedSessions: Array<{ + sessionId: string; + reason: string; + }>; + totalBytesFreed: number; + /** Whether the Home was deregistered from the Home Registry this run (or + * would be, under --dry-run). Only ever true for a cross-Home sweep. */ + deregistered: boolean; +} + +/** Command-level result emitted by `gc`. Always cross-Home shaped, even when + * scoped to a single Home via --home/AGENT_TTY_HOME (then `homes` has one + * entry). */ +export interface GcResult { + dryRun: boolean; + homes: GcHomeOutcome[]; + removedSessionCount: number; + totalBytesFreed: number; + deregisteredHomes: string[]; +} + interface CommandOptions { context: CommandContext; json: boolean; @@ -171,29 +203,43 @@ function buildGcLines(result: GcResult): string[] { : 'Estimated bytes freed'; lines.push( - `${actionLabel} ${String(result.removedSessions.length)} session(s).`, + `${actionLabel} ${String(result.removedSessionCount)} session(s) across ${String(result.homes.length)} home(s).`, ); lines.push(`${bytesLabel}: ${String(result.totalBytesFreed)}`); - if (result.removedSessions.length > 0) { - lines.push('Sessions:'); - for (const sessionId of result.removedSessions) { - lines.push(` - ${sessionId}`); + for (const home of result.homes) { + const hasActivity = + home.removedSessions.length > 0 || + home.skippedSessions.length > 0 || + home.deregistered; + if (!hasActivity) { + continue; } - } - if (result.skippedSessions.length > 0) { - lines.push('Skipped:'); - for (const skippedSession of result.skippedSessions) { - lines.push(` - ${skippedSession.sessionId}: ${skippedSession.reason}`); + lines.push(`${home.home}:`); + for (const sessionId of home.removedSessions) { + lines.push(` - removed ${sessionId}`); + } + for (const skippedSession of home.skippedSessions) { + lines.push( + ` - skipped ${skippedSession.sessionId}: ${skippedSession.reason}`, + ); + } + if (home.deregistered) { + lines.push( + result.dryRun + ? ' - would deregister (no sessions left)' + : ' - deregistered (no sessions left)', + ); } } if ( - result.removedSessions.length === 0 && - result.skippedSessions.length === 0 + result.removedSessionCount === 0 && + result.deregisteredHomes.length === 0 && + result.homes.every((home) => home.skippedSessions.length === 0) ) { - lines.push('No sessions found.'); + lines.push('Nothing to collect.'); } return lines; @@ -231,7 +277,7 @@ export async function gcSessions( home: string, options: GcExecutionOptions, dependencies: GcDependencies = defaultDependencies, -): Promise { +): Promise { invariant( typeof home === 'string' && home.length > 0, 'home must not be empty', @@ -251,7 +297,7 @@ export async function gcSessions( invariant(now instanceof Date, 'now() must return a Date'); invariant(Number.isFinite(now.getTime()), 'now() must return a valid Date'); - const result: GcResult = { + const result: GcSessionSweep = { removedSessions: [], skippedSessions: [], dryRun: options.dryRun, @@ -410,17 +456,137 @@ export async function gcSessions( return result; } -export async function runGcCommand(options: CommandOptions): Promise { +async function homeDirectoryExists(home: string): Promise { + try { + const stats = await stat(home); + return stats.isDirectory(); + } catch { + return false; + } +} + +async function homeHasSessionDirectories(home: string): Promise { + try { + const entries = await readdir(resolve(home, 'sessions'), { + withFileTypes: true, + }); + // Count only real Session directories; stray files (e.g. macOS .DS_Store or + // a lock file) must not keep an otherwise-empty Home registered. + return entries.some((entry) => entry.isDirectory()); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return false; // the sessions tree is gone → no Sessions left + } + // Unreadable for another reason (e.g. EACCES): the Home directory is still + // the source of truth, so stay conservative and do NOT treat it as empty + // (which would wrongly deregister a Home over a transient permission error). + return true; + } +} + +export interface GcCommandDependencies { + sweepHome: ( + home: string, + options: GcExecutionOptions, + ) => Promise; + readRegistry: () => Promise>; + forgetHome: (home: string) => Promise; + homeExists: (home: string) => Promise; + homeHasSessions: (home: string) => Promise; +} + +const defaultCommandDependencies: GcCommandDependencies = { + sweepHome: (home, options) => gcSessions(home, options), + readRegistry: () => readHomeRegistry(), + forgetHome: (home) => forgetHomeDefault(home), + homeExists: homeDirectoryExists, + homeHasSessions: homeHasSessionDirectories, +}; + +export async function runGcCommand( + options: CommandOptions, + dependencies: GcCommandDependencies = defaultCommandDependencies, +): Promise { const olderThanMs = options.olderThan === undefined ? null : parseDurationToMs(options.olderThan); - const home = options.context.home; - const result = await gcSessions(home, { + const executionOptions: GcExecutionOptions = { dryRun: options.dryRun, staleOnly: options.staleOnly, olderThanMs, - }); + }; + + // An explicitly selected Home (--home / AGENT_TTY_HOME) scopes gc to that one + // Home and never touches the registry. Plain `gc` sweeps every registered + // Home and prunes dead/emptied ones. The resolved default Home is always + // swept too, so `gc` still collects it even before it has been registered. + const sweepRegistry = !options.context.explicitHome; + const targets: string[] = []; + const seen = new Set(); + const addTarget = (home: string): void => { + if (!seen.has(home)) { + seen.add(home); + targets.push(home); + } + }; + addTarget(options.context.home); + + const registeredPaths = new Set(); + if (sweepRegistry) { + const entries = await dependencies.readRegistry(); + for (const entry of entries) { + registeredPaths.add(entry.path); + addTarget(entry.path); + } + } + + const homes: GcHomeOutcome[] = []; + const deregisteredHomes: string[] = []; + for (const home of targets) { + const existed = await dependencies.homeExists(home); + const sweep = await dependencies.sweepHome(home, executionOptions); + + let deregistered = false; + // Only ever deregister a Home that is actually registered. Emptied or gone + // Homes drop out so the picker/`home list` stay tidy; never delete the Home + // directory itself. Under --dry-run nothing is removed, so only an + // already-gone Home reads as a would-deregister. + if (sweepRegistry && registeredPaths.has(home)) { + const noSessionsLeft = + !existed || !(await dependencies.homeHasSessions(home)); + if (noSessionsLeft) { + deregistered = true; + deregisteredHomes.push(home); + if (!options.dryRun) { + await dependencies.forgetHome(home); + } + } + } + + homes.push({ + home, + existed, + removedSessions: sweep.removedSessions, + skippedSessions: sweep.skippedSessions, + totalBytesFreed: sweep.totalBytesFreed, + deregistered, + }); + } + + const result: GcResult = { + dryRun: options.dryRun, + homes, + removedSessionCount: homes.reduce( + (total, home) => total + home.removedSessions.length, + 0, + ), + totalBytesFreed: homes.reduce( + (total, home) => total + home.totalBytesFreed, + 0, + ), + deregisteredHomes, + }; emitSuccess({ command: 'gc', diff --git a/src/cli/commands/home/forget.ts b/src/cli/commands/home/forget.ts new file mode 100644 index 00000000..238c5a54 --- /dev/null +++ b/src/cli/commands/home/forget.ts @@ -0,0 +1,46 @@ +import { emitSuccess } from '../../output.js'; +import { + createHomeRegistry, + normalizeHomePath, +} from '../../../storage/homeRegistry.js'; + +const COMMAND_NAME = 'home forget'; + +export interface HomeForgetResult { + path: string; + forgotten: boolean; +} + +export interface HomeForgetCommandOptions { + json: boolean; + path: string; +} + +export interface HomeForgetCommandDependencies { + forget?: (path: string) => Promise; +} + +export async function runHomeForgetCommand( + options: HomeForgetCommandOptions, + dependencies: HomeForgetCommandDependencies = {}, +): Promise { + // Normalize for display and matching; the store normalizes again (idempotent). + // forget never touches the Home directory on disk — registry-only. + const normalizedPath = normalizeHomePath(options.path); + const forget = + dependencies.forget ?? + ((path: string) => createHomeRegistry().forget(path)); + const forgotten = await forget(normalizedPath); + + const result: HomeForgetResult = { path: normalizedPath, forgotten }; + emitSuccess({ + command: COMMAND_NAME, + json: options.json, + result, + lines: [ + forgotten + ? `Forgot Home: ${normalizedPath}` + : `Home not in registry: ${normalizedPath}`, + ], + }); +} diff --git a/src/cli/commands/home/list.ts b/src/cli/commands/home/list.ts new file mode 100644 index 00000000..4e4bae6e --- /dev/null +++ b/src/cli/commands/home/list.ts @@ -0,0 +1,49 @@ +import { emitSuccess } from '../../output.js'; +import { + listRegisteredHomes, + type HomeListingScope, + type RegisteredHome, +} from '../../../storage/homeScope.js'; + +const COMMAND_NAME = 'home list'; + +export interface HomeListResult { + homes: RegisteredHome[]; +} + +export interface HomeListCommandOptions { + json: boolean; + all: boolean; +} + +export interface HomeListCommandDependencies { + listHomes?: (scope: HomeListingScope) => Promise; +} + +function buildHomeListLines(homes: RegisteredHome[]): string[] { + if (homes.length === 0) { + return ['No registered Homes.']; + } + + return homes.map( + (home) => + `${home.path} ${String(home.activeSessions)}/${String(home.totalSessions)} active last seen ${home.lastSeenAt}`, + ); +} + +export async function runHomeListCommand( + options: HomeListCommandOptions, + dependencies: HomeListCommandDependencies = {}, +): Promise { + const scope: HomeListingScope = options.all ? 'all' : 'active'; + const listHomes = dependencies.listHomes ?? listRegisteredHomes; + const homes = await listHomes(scope); + + const result: HomeListResult = { homes }; + emitSuccess({ + command: COMMAND_NAME, + json: options.json, + result, + lines: buildHomeListLines(homes), + }); +} diff --git a/src/cli/context.ts b/src/cli/context.ts index a21db6cb..8b37c41d 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -33,6 +33,9 @@ export interface GlobalCliOptions { export interface CommandContext { readonly home: string; + /** Whether `home` was explicitly selected (`--home`/`AGENT_TTY_HOME`) rather + * than defaulted. gc uses this to scope to one Home vs. sweeping the registry. */ + readonly explicitHome: boolean; readonly timeoutMs: number | undefined; readonly colorEnabled: boolean; readonly logLevel: LogLevel; @@ -124,6 +127,7 @@ export async function resolveCommandContext( env: NodeJS.ProcessEnv = process.env, ): Promise { const configuredHome = options.home ?? env.AGENT_TTY_HOME; + const explicitHome = configuredHome !== undefined; const home = configuredHome === undefined ? resolveHome(env.AGENT_TTY_HOME) @@ -150,6 +154,7 @@ export async function resolveCommandContext( return Object.freeze({ home, + explicitHome, timeoutMs: options.timeoutMs, colorEnabled: options.color ?? true, logLevel, diff --git a/src/cli/main.ts b/src/cli/main.ts index adbc67c6..ee540157 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -12,6 +12,8 @@ import { runDashboardCommand } from './commands/dashboard.js'; import { runDestroyCommand } from './commands/destroy.js'; import { runDoctorCommand } from './commands/doctor.js'; import { runGcCommand } from './commands/gc.js'; +import { runHomeForgetCommand } from './commands/home/forget.js'; +import { runHomeListCommand } from './commands/home/list.js'; import { runInspectCommand } from './commands/inspect.js'; import { runListCommand } from './commands/list.js'; import { runMarkCommand } from './commands/mark.js'; @@ -125,7 +127,7 @@ async function main(): Promise { .exitOverride(); program - .option('--home ', 'Override the agent-tty home directory') + .option('--home ', 'Override the agent-tty Home directory') .option( '--timeout-ms ', 'Set a shared CLI timeout in milliseconds', @@ -218,6 +220,50 @@ async function main(): Promise { ), ); + const homeCommand = program + .command('home') + .description('Inspect and manage the per-machine Home Registry'); + + homeCommand + .command('list') + .description('List registered agent-tty Homes') + .option( + '--all', + 'Include Homes whose Sessions are all terminal, not just Active Homes', + false, + ) + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'home list', + async ( + options: { all: boolean; json: boolean }, + context: CommandContext, + ) => { + void context; + await runHomeListCommand(options); + }, + ), + ); + + homeCommand + .command('forget ') + .description('Remove a Home from the registry (does not delete it on disk)') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'home forget', + async ( + path: string, + options: { json: boolean }, + context: CommandContext, + ) => { + void context; + await runHomeForgetCommand({ json: options.json, path }); + }, + ), + ); + program .command('version') .description('Print version') diff --git a/src/dashboard/app.tsx b/src/dashboard/app.tsx index 5de3a033..9de1a926 100644 --- a/src/dashboard/app.tsx +++ b/src/dashboard/app.tsx @@ -13,6 +13,10 @@ import { type ProjectedCell, type ProjectedView, } from './liveViewProjection.js'; +import { + listRegisteredHomes, + type RegisteredHome, +} from '../storage/homeScope.js'; import { listDashboardSessions, type DashboardScope, @@ -28,7 +32,7 @@ const FRAME_INTERVAL_MS = 33; const LIST_REFRESH_MS = 1500; const PAN_STEP = 1; -type Focus = 'list' | 'live'; +type Focus = 'list' | 'live' | 'home'; // ── cell-grid painting ──────────────────────────────────────────────────────── @@ -240,6 +244,121 @@ function SessionList({ ); } +// ── home picker (full-screen) ───────────────────────────────────────────────── + +function shortHomePath(home: string): string { + // Show the trailing two path segments so throwaway temp Homes and + // ~/.agent-tty stay distinguishable without overflowing the header. + const segments = home.split('/').filter((segment) => segment.length > 0); + if (segments.length <= 2) { + return home; + } + return `…/${segments.slice(-2).join('/')}`; +} + +function homeName(home: string): string { + const segments = home.split('/').filter((segment) => segment.length > 0); + return segments.at(-1) ?? home; +} + +/** Compact "time since" for the picker's last-seen column (s/m/h/d). */ +function relativeAge(iso: string): string { + const then = Date.parse(iso); + if (!Number.isFinite(then)) { + return '?'; + } + const seconds = Math.max(0, Math.floor((Date.now() - then) / 1000)); + if (seconds < 60) { + return `${String(seconds)}s`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${String(minutes)}m`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${String(hours)}h`; + } + return `${String(Math.floor(hours / 24))}d`; +} + +const HOME_NAME_WIDTH = 22; +const HOME_COUNT_WIDTH = 18; + +/** + * The full-screen Home picker: a deliberate mode switch that takes over the + * body (both the Session list and the Live View) while choosing a Home, so it + * reads as "switch Home" rather than an in-place content swap. Selecting a Home + * returns to the normal two-pane view on that Home. + */ +function HomePicker({ + homes, + selectedIndex, + scope, + currentHome, + height, + width, +}: { + homes: RegisteredHome[]; + selectedIndex: number; + scope: DashboardScope; + currentHome: string; + height: number; + width: number; +}): React.ReactNode { + // Box border (2) + header (1) + current-Home footer (1) frame the rows. + const visible = Math.max(1, height - 4); + const start = + homes.length <= visible + ? 0 + : Math.min( + Math.max(0, selectedIndex - Math.floor(visible / 2)), + homes.length - visible, + ); + const windowed = homes.slice(start, start + visible); + + return ( + + + Switch Home · {scope} ({homes.length}){start > 0 ? ' ↑' : ''} + {start + visible < homes.length ? ' ↓' : ''} + + {homes.length === 0 ? ( + + No registered Homes — create a session in one to register it. + + ) : ( + windowed.map((home, index) => { + const selected = start + index === selectedIndex; + const isCurrent = home.path === currentHome; + const counts = `${String(home.activeSessions)} active / ${String(home.totalSessions)}`; + return ( + + {selected ? '▸ ' : ' '} + {isCurrent ? ( + + ) : ( + + )} + {' '} + {homeName(home.path).padEnd(HOME_NAME_WIDTH)} + {counts.padEnd(HOME_COUNT_WIDTH)} + {'last seen '} + {relativeAge(home.lastSeenAt)} + + ); + }) + )} + {`current: ${shortHomePath(currentHome)}`} + + ); +} + // ── follower wiring (one selected session at a time) ────────────────────────── interface FollowerState { @@ -350,6 +469,13 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode { const [pan, setPan] = useState({ row: 0, col: 0 }); const [error, setError] = useState(null); + // The Home the dashboard currently observes. Initialized to the launched/ + // resolved Home (additive picker); selecting another Home in the picker only + // changes what is observed, never restricting navigation. + const [home, setHome] = useState(options.home); + const [homes, setHomes] = useState([]); + const [homeIndex, setHomeIndex] = useState(0); + const lastKnown = useRef>(new Map()); const frameRef = useRef(null); const selectedIdRef = useRef(selectedId); @@ -366,7 +492,7 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode { } refreshing = true; try { - const next = await listDashboardSessions(options.home, scope); + const next = await listDashboardSessions(home, scope); if (!alive) { return; } @@ -417,7 +543,36 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode { alive = false; clearInterval(timer); }; - }, [scope, options.home, options.sessionId]); + }, [scope, home, options.sessionId]); + + // Load registered Homes whenever the picker opens or the scope changes while + // it is open. This is a read-only scan (no reconciliation) shared with + // `home list`, so browsing Homes never mutates any Session. + useEffect(() => { + if (focus !== 'home') { + return; + } + // Object flag (not a bare boolean) so a stale async resolution after the + // picker closes or the scope changes can't clobber fresh state. + const load = { cancelled: false }; + void (async () => { + try { + const registered = await listRegisteredHomes(scope); + if (load.cancelled) { + return; + } + setHomes(registered); + setHomeIndex(0); + } catch (caught) { + if (!load.cancelled) { + setError(caught instanceof Error ? caught.message : String(caught)); + } + } + })(); + return () => { + load.cancelled = true; + }; + }, [focus, scope]); const selectedIndex = Math.max( 0, @@ -474,14 +629,54 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode { exit(); return; } - if (key.tab) { - setFocus((current) => (current === 'list' ? 'live' : 'list')); + // 'H' toggles the Home picker from anywhere; it is additive — closing it + // leaves the current Home and Session selection untouched. + if (input === 'H') { + setFocus((current) => (current === 'home' ? 'list' : 'home')); return; } + // 'a' toggles active/all scope for both the Session list and the Home + // picker (they share one scope), mirroring `list`/`home list`. if (input === 'a') { setScope((current) => (current === 'active' ? 'all' : 'active')); return; } + + if (focus === 'home') { + if (key.escape) { + setFocus('list'); + return; + } + if (key.upArrow || input === 'k') { + setHomeIndex((index) => Math.max(0, index - 1)); + return; + } + if (key.downArrow || input === 'j') { + setHomeIndex((index) => + Math.min(Math.max(0, homes.length - 1), index + 1), + ); + return; + } + if (key.return) { + const picked = homes[homeIndex]; + if (picked !== undefined && picked.path !== home) { + // Switching Home re-seeds the Session list (which reconciles on + // entry); clear pinned state carried over from the previous Home. + setHome(picked.path); + setSelectedId(null); + lastKnown.current = new Map(); + setPan({ row: 0, col: 0 }); + } + setFocus('list'); + return; + } + return; // swallow other keys while the picker is open + } + + if (key.tab) { + setFocus((current) => (current === 'list' ? 'live' : 'list')); + return; + } if (input === 'z') { setMode((current) => current === 'one-to-one' ? 'overview' : 'one-to-one', @@ -518,37 +713,57 @@ function App({ options }: { options: DashboardAppOptions }): React.ReactNode { {'agent-tty dashboard'} - {' read-only · '} - {selectedSession - ? `${shortId(selectedSession.sessionId)} ${selectedSession.status}` - : 'no session selected'} + {focus === 'home' + ? ' read-only · choosing Home…' + : ` read-only · ${shortHomePath(home)} · ${ + selectedSession + ? `${shortId(selectedSession.sessionId)} ${selectedSession.status}` + : 'no session selected' + }`} - - + {focus === 'home' ? ( + // Full-screen takeover: the picker replaces both panes so switching a + // Home reads as a deliberate mode, not an in-place content swap. + + ) : ( + <> + + + + )} - {`focus:${focus} · Tab switch · `} - {focus === 'list' ? '↑/↓ j/k select' : '↑/↓ h/j/k/l pan'} - {' · a scope · z overview · q quit'} + {focus === 'home' + ? '↑/↓ j/k select Home · ⏎ open · a scope · esc cancel · q quit' + : focus === 'list' + ? 'focus:list · Tab switch · ↑/↓ j/k select · a scope · H homes · z overview · q quit' + : 'focus:live · Tab switch · ↑/↓ h/j/k/l pan · a scope · H homes · z overview · q quit'} {error !== null ? ` · ERR: ${error}` : ''} diff --git a/src/storage/homeRegistry.ts b/src/storage/homeRegistry.ts new file mode 100644 index 00000000..f7a7faa4 --- /dev/null +++ b/src/storage/homeRegistry.ts @@ -0,0 +1,219 @@ +import { realpathSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, normalize, resolve } from 'node:path'; +import process from 'node:process'; + +import { z } from 'zod'; + +import { invariant } from '../util/assert.js'; +import { hasErrorCode } from '../util/hasErrorCode.js'; +import { writeTextFileAtomic } from './manifests.js'; + +const STATE_SUBDIRECTORY = 'agent-tty'; +const REGISTRY_FILENAME = 'homes.json'; +const REGISTRY_VERSION = 1; + +/** + * A single Home Registry entry: just the Home path and when it was last seen. + * Session counts and statuses are derived live by scanning the Home, never + * cached here (see `src/dashboard/homeScope.ts`). + */ +export interface HomeRegistryEntry { + path: string; + lastSeenAt: string; +} + +const HomeRegistryEntrySchema = z + .object({ + path: z.string().min(1), + lastSeenAt: z.string().min(1), + }) + .strict(); + +// Non-strict at the top level so a future field never makes an existing file +// unreadable; entries themselves are validated and bad ones drop out. +const HomeRegistryFileSchema = z.object({ + version: z.number(), + homes: z.array(HomeRegistryEntrySchema), +}); + +/** + * Resolve the per-machine Home Registry path. It is a function of the OS user + * (`${XDG_STATE_HOME:-~/.local/state}/agent-tty/homes.json`) and deliberately + * independent of `AGENT_TTY_HOME`: the registry spans Homes, so it cannot live + * inside one. Per the XDG spec, a relative `XDG_STATE_HOME` is ignored. + */ +export function resolveHomeRegistryPath( + env: NodeJS.ProcessEnv = process.env, +): string { + const xdgStateHome = env.XDG_STATE_HOME; + const base = + xdgStateHome !== undefined && + xdgStateHome.length > 0 && + isAbsolute(xdgStateHome) + ? xdgStateHome + : join(homedir(), '.local', 'state'); + + const registryPath = normalize( + join(base, STATE_SUBDIRECTORY, REGISTRY_FILENAME), + ); + invariant(isAbsolute(registryPath), 'home registry path must be absolute'); + return registryPath; +} + +/** + * Normalize a Home path to the canonical form stored in the registry, matching + * how `resolveHome`/`validateHomePath` canonicalize: resolve to absolute, + * normalize, then realpath if the directory exists (so symlinks and `..` + * collapse). A path whose directory is gone (a forgettable dead Home) falls + * back to its normalized absolute form. + */ +export function normalizeHomePath( + homePath: string, + realpath: (path: string) => string = realpathSync, +): string { + invariant( + typeof homePath === 'string' && homePath.length > 0, + 'home path must be a non-empty string', + ); + + const absolute = isAbsolute(homePath) ? homePath : resolve(homePath); + const normalized = normalize(absolute); + try { + return realpath(normalized); + } catch { + return normalized; + } +} + +export interface HomeRegistryDependencies { + registryPath: string; + readFile: (path: string) => Promise; + writeAtomic: (path: string, contents: string) => Promise; + realpath: (path: string) => string; + now: () => Date; +} + +function defaultDependencies(): HomeRegistryDependencies { + return { + registryPath: resolveHomeRegistryPath(), + readFile: (path) => readFile(path, 'utf8'), + writeAtomic: (path, contents) => + writeTextFileAtomic({ + path, + pathLabel: 'home registry path', + contents, + writeErrorMessage: `Failed to write the Home Registry at ${path}.`, + }), + realpath: realpathSync, + now: () => new Date(), + }; +} + +export interface HomeRegistry { + /** All entries, newest-`lastSeenAt`-first. Does not prune (that is a read-time + * concern of the caller, which scans each Home); never throws on a missing or + * corrupt file — an advisory registry behaves as empty and is rebuilt. */ + read(): Promise; + /** Register a Home (or refresh its `lastSeenAt`). Atomic and idempotent. */ + upsert(homePath: string): Promise; + /** Remove a Home from the registry. Returns whether an entry was removed. + * Never touches the Home directory on disk. */ + forget(homePath: string): Promise; + /** Replace the registry contents atomically. */ + write(entries: HomeRegistryEntry[]): Promise; +} + +function sortNewestFirst(entries: HomeRegistryEntry[]): HomeRegistryEntry[] { + return [...entries].sort((left, right) => + right.lastSeenAt.localeCompare(left.lastSeenAt), + ); +} + +export function createHomeRegistry( + overrides: Partial = {}, +): HomeRegistry { + const deps = { ...defaultDependencies(), ...overrides }; + + async function readRaw(): Promise { + let raw: string; + try { + raw = await deps.readFile(deps.registryPath); + } catch (error) { + if (hasErrorCode(error, 'ENOENT')) { + return []; + } + // Advisory store: an unreadable registry behaves as empty rather than + // breaking discovery; it is rebuilt on the next create. + return []; + } + + let data: unknown; + try { + data = JSON.parse(raw); + } catch { + return []; + } + + const parsed = HomeRegistryFileSchema.safeParse(data); + return parsed.success ? parsed.data.homes : []; + } + + async function write(entries: HomeRegistryEntry[]): Promise { + const file = { + version: REGISTRY_VERSION, + homes: sortNewestFirst(entries), + }; + await deps.writeAtomic( + deps.registryPath, + `${JSON.stringify(file, null, 2)}\n`, + ); + } + + return { + async read() { + return sortNewestFirst(await readRaw()); + }, + + async upsert(homePath) { + const key = normalizeHomePath(homePath, deps.realpath); + const lastSeenAt = deps.now().toISOString(); + // Read immediately before write to keep the lost-update window small. + // Concurrent creates can still race, but the atomic temp+rename keeps the + // file from ever being corrupt, and a dropped entry re-registers next time. + const existing = await readRaw(); + const filtered = existing.filter((entry) => entry.path !== key); + filtered.unshift({ path: key, lastSeenAt }); + await write(filtered); + }, + + async forget(homePath) { + const key = normalizeHomePath(homePath, deps.realpath); + const existing = await readRaw(); + const filtered = existing.filter((entry) => entry.path !== key); + if (filtered.length === existing.length) { + return false; + } + await write(filtered); + return true; + }, + + write, + }; +} + +/** Convenience: register a Home using the default (real-fs) registry. */ +export async function upsertHome(homePath: string): Promise { + await createHomeRegistry().upsert(homePath); +} + +/** Convenience: forget a Home using the default (real-fs) registry. */ +export async function forgetHome(homePath: string): Promise { + return createHomeRegistry().forget(homePath); +} + +/** Convenience: read all registered Homes using the default (real-fs) registry. */ +export async function readHomeRegistry(): Promise { + return createHomeRegistry().read(); +} diff --git a/src/storage/homeScope.ts b/src/storage/homeScope.ts new file mode 100644 index 00000000..ebe9a3cf --- /dev/null +++ b/src/storage/homeScope.ts @@ -0,0 +1,149 @@ +import { readdir } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { + isActiveSessionStatus, + isDestroyedSessionStatus, +} from '../protocol/sessionStatusPolicy.js'; +import type { SessionRecord } from '../protocol/schemas.js'; +import { createHomeRegistry, type HomeRegistry } from './homeRegistry.js'; +import { readManifestIfExists } from './manifests.js'; +import { manifestPath, sessionDir } from './sessionPaths.js'; + +/** + * Active/all scope for listing Homes. Structurally identical to the dashboard's + * Session scope, but declared here so this neutral module — shared by the + * `home list` CLI command and the dashboard Home picker — depends on neither + * the CLI nor the dashboard layer. + */ +export type HomeListingScope = 'active' | 'all'; + +/** + * A registered Home enriched with live, derived Session counts for display in + * `home list` and the dashboard Home picker. The counts are never persisted. + */ +export interface RegisteredHome { + path: string; + activeSessions: number; + totalSessions: number; + lastSeenAt: string; +} + +export interface HomeSessionCounts { + activeSessions: number; + totalSessions: number; +} + +export interface ScanHomeDependencies { + readdir: (path: string) => Promise; + readManifestIfExists: (path: string) => Promise; +} + +const defaultScanDependencies: ScanHomeDependencies = { + readdir, + readManifestIfExists, +}; + +/** + * Count a Home's visible Sessions WITHOUT reconciling. + * + * This is read-only by contract: it never calls `reconcileSession` and never + * writes a manifest, so listing Homes (CLI or dashboard picker) cannot mutate + * Session state. Active counts may therefore be momentarily stale (a dead host + * still shows `running` until something reconciles it); entering a Home in the + * dashboard, or running `gc`, reconciles for real. + * + * "Visible" excludes destroyed Sessions, mirroring the dashboard's `all` scope + * (a destroyed Session's Event Log may already be collected). It never throws: + * a missing/unreadable sessions tree, or a corrupt individual manifest, counts + * as zero/skip so a single bad Session can't break discovery. + */ +export async function scanHome( + home: string, + dependencies: ScanHomeDependencies = defaultScanDependencies, +): Promise { + const sessionsRoot = resolve(home, 'sessions'); + + let entries: string[]; + try { + entries = await dependencies.readdir(sessionsRoot); + } catch { + // Missing or unreadable sessions tree → nothing observable here. + return { activeSessions: 0, totalSessions: 0 }; + } + + let activeSessions = 0; + let totalSessions = 0; + for (const entry of entries) { + let manifest: SessionRecord | null; + try { + manifest = await dependencies.readManifestIfExists( + manifestPath(sessionDir(home, entry)), + ); + } catch { + continue; + } + + if (manifest === null || isDestroyedSessionStatus(manifest.status)) { + continue; + } + + totalSessions += 1; + if (isActiveSessionStatus(manifest.status)) { + activeSessions += 1; + } + } + + return { activeSessions, totalSessions }; +} + +export interface ListRegisteredHomesDependencies { + registry: Pick; + scanHome: (home: string) => Promise; +} + +function defaultListDependencies(): ListRegisteredHomesDependencies { + return { + registry: createHomeRegistry(), + scanHome: (home) => scanHome(home), + }; +} + +/** + * List registered Homes for a scope, newest-`lastSeenAt`-first. Shared by the + * `home list` command and the dashboard Home picker so the two surfaces never + * disagree under the same scope. + * + * - **prune-on-read**: a Home with zero visible Sessions (directory or + * `sessions/` gone, empty, or only destroyed) is omitted and never shown, + * regardless of scope. The registry file is not rewritten here — durable + * compaction (deregistration) is gc's job. + * - `active` scope additionally requires at least one Active Session (an + * **Active Home**); `all` includes Homes whose Sessions are all terminal. + */ +export async function listRegisteredHomes( + scope: HomeListingScope, + dependencies: ListRegisteredHomesDependencies = defaultListDependencies(), +): Promise { + const entries = await dependencies.registry.read(); + + const homes: RegisteredHome[] = []; + for (const entry of entries) { + const counts = await dependencies.scanHome(entry.path); + if (counts.totalSessions === 0) { + continue; // prune-on-read: nothing observable in this Home + } + if (scope === 'active' && counts.activeSessions === 0) { + continue; // not an Active Home + } + homes.push({ + path: entry.path, + activeSessions: counts.activeSessions, + totalSessions: counts.totalSessions, + lastSeenAt: entry.lastSeenAt, + }); + } + + homes.sort((left, right) => right.lastSeenAt.localeCompare(left.lastSeenAt)); + return homes; +} diff --git a/test/integration/gc.test.ts b/test/integration/gc.test.ts index f7844dcf..e87cf21d 100644 --- a/test/integration/gc.test.ts +++ b/test/integration/gc.test.ts @@ -1,6 +1,16 @@ -import { mkdtemp, realpath, stat } from 'node:fs/promises'; +import { spawnSync } from 'node:child_process'; +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + stat, + writeFile, +} from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import process from 'node:process'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -15,14 +25,24 @@ import { type SuccessEnvelope, } from '../helpers.js'; -interface GcResult { +interface GcHomeOutcome { + home: string; + existed: boolean; removedSessions: string[]; skippedSessions: Array<{ sessionId: string; reason: string; }>; + totalBytesFreed: number; + deregistered: boolean; +} + +interface GcResult { dryRun: boolean; + homes: GcHomeOutcome[]; + removedSessionCount: number; totalBytesFreed: number; + deregisteredHomes: string[]; } async function pathExists(path: string): Promise { @@ -70,11 +90,19 @@ describe('gc integration', { timeout: 30000 }, () => { const gcEnvelope = JSON.parse(gcResult.stdout) as SuccessEnvelope; expect(gcEnvelope.ok).toBe(true); expect(gcEnvelope.command).toBe('gc'); - expect(gcEnvelope.result.removedSessions).toEqual([sessionId]); - expect(gcEnvelope.result.skippedSessions).toEqual([]); + // AGENT_TTY_HOME is set → gc is scoped to this one Home (no registry sweep). + expect(gcEnvelope.result.homes).toHaveLength(1); + const outcome = gcEnvelope.result.homes[0]; + expect(outcome?.home).toBe(testHome); + expect(outcome?.removedSessions).toEqual([sessionId]); + expect(outcome?.skippedSessions).toEqual([]); expect(gcEnvelope.result.dryRun).toBe(false); + expect(gcEnvelope.result.removedSessionCount).toBe(1); expect(gcEnvelope.result.totalBytesFreed).toBeGreaterThan(0); + expect(gcEnvelope.result.deregisteredHomes).toEqual([]); expect(await pathExists(sessionDirectory)).toBe(false); + // gc never deletes the Home directory itself. + expect(await pathExists(testHome)).toBe(true); }); it('gc collects exited, failed, and destroyed sessions', async () => { @@ -105,10 +133,129 @@ describe('gc integration', { timeout: 30000 }, () => { const gcEnvelope = JSON.parse(gcResult.stdout) as SuccessEnvelope; expect(gcEnvelope.ok).toBe(true); - expect(gcEnvelope.result.removedSessions).toHaveLength(3); - expect(gcEnvelope.result.removedSessions).toContain(exitedId); - expect(gcEnvelope.result.removedSessions).toContain(failedId); - expect(gcEnvelope.result.removedSessions).toContain(destroyedId); - expect(gcEnvelope.result.skippedSessions).toEqual([]); + expect(gcEnvelope.result.homes).toHaveLength(1); + const outcome = gcEnvelope.result.homes[0]; + expect(outcome?.removedSessions).toHaveLength(3); + expect(outcome?.removedSessions).toContain(exitedId); + expect(outcome?.removedSessions).toContain(failedId); + expect(outcome?.removedSessions).toContain(destroyedId); + expect(outcome?.skippedSessions).toEqual([]); + expect(gcEnvelope.result.removedSessionCount).toBe(3); + }); +}); + +describe('gc cross-Home integration', { timeout: 30000 }, () => { + // Seed a terminal (exited) Session directly so the cross-Home sweep needs no + // live host — gc reconciles a terminal Session to a no-op and collects it. + async function seedExitedSession( + home: string, + sessionId: string, + ): Promise { + const dir = join(home, 'sessions', sessionId); + await mkdir(dir, { recursive: true }); + const manifest = { + version: 1, + sessionId, + createdAt: '2026-06-08T10:00:00.000Z', + updatedAt: '2026-06-08T10:00:00.000Z', + status: 'exited', + command: ['/bin/sh', '-c', 'exit 0'], + cwd: '/tmp', + cols: 80, + rows: 24, + hostPid: null, + childPid: null, + exitCode: 0, + exitSignal: null, + }; + await writeFile( + join(dir, 'session.json'), + JSON.stringify(manifest), + 'utf8', + ); + await writeFile(join(dir, 'events.jsonl'), '', 'utf8'); + } + + // Run the real CLI with a sanitized env: no AGENT_TTY_HOME (so gc is NOT + // scoped and performs the cross-Home registry sweep), a fake HOME (so the + // resolved default Home is hermetic and absent), and a temp XDG_STATE_HOME + // (so the Home Registry is hermetic). + function runGcCrossHome(fakeHome: string, stateHome: string) { + const env: NodeJS.ProcessEnv = { ...process.env }; + delete env.AGENT_TTY_HOME; + env.HOME = fakeHome; + env.XDG_STATE_HOME = stateHome; + return spawnSync( + process.execPath, + ['--import', 'tsx', './src/cli/main.ts', 'gc', '--json'], + { cwd: process.cwd(), encoding: 'utf8', env, timeout: 30000 }, + ); + } + + it('sweeps every registered Home by default and deregisters emptied or gone ones', async () => { + const root = await realpath( + await mkdtemp(join(tmpdir(), 'agent-tty-gc-xhome-')), + ); + try { + const fakeHome = join(root, 'fake-home'); // default Home → fakeHome/.agent-tty (absent) + const stateHome = join(root, 'state'); + const homeA = join(root, 'home-a'); + const homeB = join(root, 'home-b'); + const homeGone = join(root, 'home-gone'); // registered but never created on disk + await mkdir(fakeHome, { recursive: true }); + await seedExitedSession(homeA, 'a-exit'); + await seedExitedSession(homeB, 'b-exit'); + await mkdir(join(stateHome, 'agent-tty'), { recursive: true }); + await writeFile( + join(stateHome, 'agent-tty', 'homes.json'), + JSON.stringify({ + version: 1, + homes: [ + { path: homeA, lastSeenAt: '2026-06-08T12:00:00.000Z' }, + { path: homeB, lastSeenAt: '2026-06-07T12:00:00.000Z' }, + { path: homeGone, lastSeenAt: '2026-06-06T12:00:00.000Z' }, + ], + }), + 'utf8', + ); + + const result = runGcCrossHome(fakeHome, stateHome); + expect(result.stderr).toBe(''); + expect(result.status).toBe(0); + + const envelope = JSON.parse(result.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + + // Both registered Homes' Sessions are collected in one sweep. + expect(envelope.result.removedSessionCount).toBe(2); + const sweptHomes = envelope.result.homes.map((home) => home.home); + expect(sweptHomes).toContain(homeA); + expect(sweptHomes).toContain(homeB); + + // Emptied + gone Homes are deregistered; the unregistered default Home is not. + const deregistered = envelope.result.deregisteredHomes; + expect(deregistered).toContain(homeA); + expect(deregistered).toContain(homeB); + expect(deregistered).toContain(homeGone); + expect(deregistered.some((home) => home.includes('fake-home'))).toBe( + false, + ); + + // The registry is compacted to empty after deregistration. + const registryRaw = await readFile( + join(stateHome, 'agent-tty', 'homes.json'), + 'utf8', + ); + expect((JSON.parse(registryRaw) as { homes: unknown[] }).homes).toEqual( + [], + ); + + // Session directories are collected, but Home directories are never deleted. + expect(await pathExists(homeA)).toBe(true); + expect(await pathExists(homeB)).toBe(true); + expect(await pathExists(join(homeA, 'sessions', 'a-exit'))).toBe(false); + } finally { + await rm(root, { recursive: true, force: true }); + } }); }); diff --git a/test/integration/lifecycle.test.ts b/test/integration/lifecycle.test.ts index c1632746..ad23ede0 100644 --- a/test/integration/lifecycle.test.ts +++ b/test/integration/lifecycle.test.ts @@ -45,14 +45,24 @@ interface SessionSummary { pid: number | null; } -interface GcResult { +interface GcHomeOutcome { + home: string; + existed: boolean; removedSessions: string[]; skippedSessions: Array<{ sessionId: string; reason: string; }>; + totalBytesFreed: number; + deregistered: boolean; +} + +interface GcResult { dryRun: boolean; + homes: GcHomeOutcome[]; + removedSessionCount: number; totalBytesFreed: number; + deregisteredHomes: string[]; } let testHome = ''; @@ -641,12 +651,17 @@ describe('lifecycle integration', { timeout: 30000 }, () => { gcEnvelope.command, 'gc envelope should identify the gc command', ).toBe('gc'); + // AGENT_TTY_HOME is set → gc is scoped to this one Home. + expect( + gcEnvelope.result.homes, + 'gc should report exactly the scoped Home', + ).toHaveLength(1); expect( - gcEnvelope.result.removedSessions, + gcEnvelope.result.homes[0]?.removedSessions, 'gc should remove the reconciled stale session directory', ).toEqual([sessionId]); expect( - gcEnvelope.result.skippedSessions, + gcEnvelope.result.homes[0]?.skippedSessions, 'gc should not skip the reconciled stale session', ).toEqual([]); expect( diff --git a/test/unit/cli/context.test.ts b/test/unit/cli/context.test.ts index a9915453..614df6eb 100644 --- a/test/unit/cli/context.test.ts +++ b/test/unit/cli/context.test.ts @@ -43,6 +43,7 @@ describe('CLI context resolution', () => { ); expect(context.home).toBe(TEST_FLAG_HOME); + expect(context.explicitHome).toBe(true); }); it('falls back to AGENT_TTY_HOME when --home is absent', async () => { @@ -52,6 +53,21 @@ describe('CLI context resolution', () => { ); expect(context.home).toBe(TEST_ENV_HOME); + expect(context.explicitHome).toBe(true); + }); + + it('marks the home as explicit only when --home or AGENT_TTY_HOME is set', async () => { + // Neither flag nor env → the default Home; gc treats this as the + // cross-Home sweep trigger. + await expect(resolveCommandContext({}, {})).resolves.toMatchObject({ + explicitHome: false, + }); + await expect( + resolveCommandContext({ home: TEST_FLAG_HOME }, {}), + ).resolves.toMatchObject({ explicitHome: true }); + await expect( + resolveCommandContext({}, { AGENT_TTY_HOME: TEST_ENV_HOME }), + ).resolves.toMatchObject({ explicitHome: true }); }); it('loads config files during context resolution', async () => { @@ -208,6 +224,7 @@ describe('CLI context resolution', () => { const command = program.command('version'); const cachedContext = Object.freeze({ home: TEST_FLAG_HOME, + explicitHome: true, timeoutMs: undefined, colorEnabled: true, logLevel: 'info' as const, diff --git a/test/unit/commands/create.test.ts b/test/unit/commands/create.test.ts index 403693aa..d5cb9683 100644 --- a/test/unit/commands/create.test.ts +++ b/test/unit/commands/create.test.ts @@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({ sessionDir: vi.fn(), manifestPath: vi.fn(), socketPath: vi.fn(), + upsertHome: vi.fn(), })); vi.mock('../../../src/cli/output.js', () => ({ @@ -39,6 +40,10 @@ vi.mock('../../../src/storage/sessionPaths.js', () => ({ socketPath: mocks.socketPath, })); +vi.mock('../../../src/storage/homeRegistry.js', () => ({ + upsertHome: mocks.upsertHome, +})); + import { runCreateCommand } from '../../../src/cli/commands/create.js'; import { createLogger } from '../../../src/util/logger.js'; @@ -51,6 +56,7 @@ describe('create command', () => { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; const baseOptions = { @@ -106,6 +112,26 @@ describe('create command', () => { mocks.socketPath.mockImplementation( (sessionDirectory: string) => `${sessionDirectory}/rpc.sock`, ); + mocks.upsertHome.mockResolvedValue(undefined); + }); + + it('registers the resolved Home in the Home Registry on create', async () => { + await runCreateCommand({ ...baseOptions, context }); + + expect(mocks.upsertHome).toHaveBeenCalledWith(context.home); + }); + + it('still succeeds when registering the Home fails (advisory, never blocks create)', async () => { + mocks.upsertHome.mockRejectedValue(new Error('registry write failed')); + + await expect( + runCreateCommand({ ...baseOptions, context }), + ).resolves.toBeUndefined(); + + // The Session result is still emitted despite the registry hiccup. + expect(mocks.emitSuccess).toHaveBeenCalledWith( + expect.objectContaining({ command: 'create' }), + ); }); it('passes home, env, term, name, and shell through session creation', async () => { diff --git a/test/unit/commands/gc.test.ts b/test/unit/commands/gc.test.ts index c178ea64..66c9e497 100644 --- a/test/unit/commands/gc.test.ts +++ b/test/unit/commands/gc.test.ts @@ -1,12 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { ERROR_CODES } from '../../../src/protocol/errors.js'; import { gcSessions, parseDurationToMs, + runGcCommand, type GcDependencies, + type GcResult, + type GcSessionSweep, } from '../../../src/cli/commands/gc.js'; +import type { CommandContext } from '../../../src/cli/context.js'; import type { SessionRecord } from '../../../src/protocol/schemas.js'; +import { createLogger } from '../../../src/util/logger.js'; interface MockDirectoryNode { kind: 'dir'; @@ -485,3 +490,156 @@ describe('gc command helpers', () => { expect(removedPaths).toEqual([`${home}/sessions/stale-01`]); }); }); + +describe('runGcCommand (cross-Home sweep)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + function gcContext(home: string, explicitHome: boolean): CommandContext { + return { + home, + explicitHome, + timeoutMs: undefined, + colorEnabled: true, + logLevel: 'info', + logger: createLogger('info', () => undefined), + profileDefault: undefined, + rendererDefault: 'ghostty-web', + configFile: null, + } as const; + } + + const emptySweep: GcSessionSweep = { + removedSessions: [], + skippedSessions: [], + dryRun: false, + totalBytesFreed: 0, + }; + + function captureEnvelopeResult(): () => GcResult { + const spy = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + return () => { + const calls = spy.mock.calls as unknown[][]; + const last = calls.at(-1)?.[0]; + if (typeof last !== 'string') { + throw new Error('expected an emitted gc envelope'); + } + return (JSON.parse(last) as { result: GcResult }).result; + }; + } + + it('scopes to a single Home and never touches the registry when explicit', async () => { + const readRegistry = vi.fn(() => + Promise.resolve([{ path: '/registered' }]), + ); + const forgetHome = vi.fn(() => Promise.resolve()); + const sweepHome = vi.fn(() => + Promise.resolve({ + ...emptySweep, + removedSessions: ['s1'], + totalBytesFreed: 10, + }), + ); + const readResult = captureEnvelopeResult(); + + await runGcCommand( + { + context: gcContext('/h/explicit', true), + json: true, + dryRun: false, + staleOnly: false, + olderThan: undefined, + }, + { + sweepHome, + readRegistry, + forgetHome, + homeExists: () => Promise.resolve(true), + homeHasSessions: () => Promise.resolve(true), + }, + ); + + expect(readRegistry).not.toHaveBeenCalled(); + expect(forgetHome).not.toHaveBeenCalled(); + const result = readResult(); + expect(result.homes.map((home) => home.home)).toEqual(['/h/explicit']); + expect(result.removedSessionCount).toBe(1); + expect(result.totalBytesFreed).toBe(10); + expect(result.deregisteredHomes).toEqual([]); + }); + + it('sweeps registered Homes and deregisters emptied or gone ones', async () => { + const forgetHome = vi.fn((_home: string) => Promise.resolve()); + const sweepHome = vi.fn(() => Promise.resolve(emptySweep)); + const readResult = captureEnvelopeResult(); + + await runGcCommand( + { + context: gcContext('/h/default', false), + json: true, + dryRun: false, + staleOnly: false, + olderThan: undefined, + }, + { + sweepHome, + readRegistry: () => + Promise.resolve([ + { path: '/h/default' }, + { path: '/h/empty' }, + { path: '/h/gone' }, + ]), + forgetHome, + homeExists: (home) => Promise.resolve(home !== '/h/gone'), + homeHasSessions: (home) => Promise.resolve(home === '/h/default'), + }, + ); + + // /h/empty (emptied) and /h/gone (directory gone) are deregistered; + // /h/default still has Sessions, so it stays registered. + expect(forgetHome.mock.calls.map((call) => call[0]).sort()).toEqual([ + '/h/empty', + '/h/gone', + ]); + const result = readResult(); + expect(result.homes.map((home) => home.home)).toEqual([ + '/h/default', + '/h/empty', + '/h/gone', + ]); + expect([...result.deregisteredHomes].sort()).toEqual([ + '/h/empty', + '/h/gone', + ]); + }); + + it('dry-run never deregisters an emptied Home, only already-gone ones', async () => { + const forgetHome = vi.fn(() => Promise.resolve()); + const readResult = captureEnvelopeResult(); + + await runGcCommand( + { + context: gcContext('/h/default', false), + json: true, + dryRun: true, + staleOnly: false, + olderThan: undefined, + }, + { + sweepHome: () => Promise.resolve({ ...emptySweep, dryRun: true }), + readRegistry: () => + Promise.resolve([{ path: '/h/gone' }, { path: '/h/empty' }]), + forgetHome, + homeExists: (home) => Promise.resolve(home !== '/h/gone'), + // dry-run removed nothing, so a populated Home still reports Sessions. + homeHasSessions: () => Promise.resolve(true), + }, + ); + + // No registry writes under --dry-run. + expect(forgetHome).not.toHaveBeenCalled(); + // Only the already-gone Home is reported as a would-deregister. + expect(readResult().deregisteredHomes).toEqual(['/h/gone']); + }); +}); diff --git a/test/unit/commands/golden-envelopes.test.ts b/test/unit/commands/golden-envelopes.test.ts index bd53bd9b..b0dbf6fb 100644 --- a/test/unit/commands/golden-envelopes.test.ts +++ b/test/unit/commands/golden-envelopes.test.ts @@ -151,12 +151,46 @@ const GcSkippedSessionSchema = z }) .strict(); -const GcResultSchema = z +const GcHomeOutcomeSchema = z .object({ + home: NonEmptyStringSchema, + existed: z.boolean(), removedSessions: z.array(NonEmptyStringSchema), skippedSessions: z.array(GcSkippedSessionSchema), + totalBytesFreed: z.number().nonnegative(), + deregistered: z.boolean(), + }) + .strict(); + +const GcResultSchema = z + .object({ dryRun: z.boolean(), + homes: z.array(GcHomeOutcomeSchema), + removedSessionCount: z.number().int().nonnegative(), totalBytesFreed: z.number().nonnegative(), + deregisteredHomes: z.array(NonEmptyStringSchema), + }) + .strict(); + +const RegisteredHomeSchema = z + .object({ + path: NonEmptyStringSchema, + activeSessions: z.number().int().nonnegative(), + totalSessions: z.number().int().nonnegative(), + lastSeenAt: NonEmptyStringSchema, + }) + .strict(); + +const HomeListResultSchema = z + .object({ + homes: z.array(RegisteredHomeSchema), + }) + .strict(); + +const HomeForgetResultSchema = z + .object({ + path: NonEmptyStringSchema, + forgotten: z.boolean(), }) .strict(); @@ -560,58 +594,141 @@ const goldenResultContracts: readonly GoldenResultContractCase[] = [ command: 'gc', schema: GcResultSchema, validResult: { - removedSessions: ['01J0000000TEST000000000000'], - skippedSessions: [ + dryRun: false, + homes: [ { - sessionId: '01J0000000SKIP000000000000', - reason: 'running', + home: '/home/user/.agent-tty', + existed: true, + removedSessions: ['01J0000000TEST000000000000'], + skippedSessions: [ + { + sessionId: '01J0000000SKIP000000000000', + reason: 'running', + }, + ], + totalBytesFreed: 4096, + deregistered: false, }, ], - dryRun: false, + removedSessionCount: 1, totalBytesFreed: 4096, + deregisteredHomes: [], }, invalidResult: { - removedSessions: ['01J0000000TEST000000000000'], - skippedSessions: [], dryRun: false, + homes: [], + removedSessionCount: 1, totalBytesFreed: -1, + deregisteredHomes: [], }, extraFieldResult: { - removedSessions: ['01J0000000TEST000000000000'], - skippedSessions: [], dryRun: false, - totalBytesFreed: 4096, + homes: [], + removedSessionCount: 0, + totalBytesFreed: 0, + deregisteredHomes: [], removedCount: 1, }, }, { - name: 'gc (dry-run)', + name: 'gc (dry-run, cross-Home prune)', command: 'gc', schema: GcResultSchema, validResult: { - removedSessions: [], - skippedSessions: [], dryRun: true, + homes: [ + { + home: '/tmp/throwaway-home', + existed: false, + removedSessions: [], + skippedSessions: [], + totalBytesFreed: 0, + deregistered: true, + }, + ], + removedSessionCount: 0, totalBytesFreed: 0, + deregisteredHomes: ['/tmp/throwaway-home'], }, invalidResult: { - removedSessions: [], - skippedSessions: [ + // a home outcome missing the required `existed` field + dryRun: true, + homes: [ { - sessionId: '01J0000000SKIP000000000000', + home: '/tmp/throwaway-home', + removedSessions: [], + skippedSessions: [], + totalBytesFreed: 0, + deregistered: true, }, ], - dryRun: true, + removedSessionCount: 0, totalBytesFreed: 0, + deregisteredHomes: [], }, extraFieldResult: { - removedSessions: [], - skippedSessions: [], dryRun: true, + homes: [], + removedSessionCount: 0, totalBytesFreed: 0, + deregisteredHomes: [], wouldRemoveSessions: [], }, }, + { + name: 'home list', + command: 'home list', + schema: HomeListResultSchema, + validResult: { + homes: [ + { + path: '/home/user/.agent-tty', + activeSessions: 2, + totalSessions: 5, + lastSeenAt: '2026-06-08T12:00:00.000Z', + }, + ], + }, + invalidResult: { + homes: [ + { + path: '/home/user/.agent-tty', + activeSessions: -1, + totalSessions: 5, + lastSeenAt: '2026-06-08T12:00:00.000Z', + }, + ], + }, + extraFieldResult: { + homes: [ + { + path: '/home/user/.agent-tty', + activeSessions: 2, + totalSessions: 5, + lastSeenAt: '2026-06-08T12:00:00.000Z', + label: 'main', + }, + ], + }, + }, + { + name: 'home forget', + command: 'home forget', + schema: HomeForgetResultSchema, + validResult: { + path: '/tmp/throwaway-home', + forgotten: true, + }, + invalidResult: { + path: '', + forgotten: true, + }, + extraFieldResult: { + path: '/tmp/throwaway-home', + forgotten: false, + existed: true, + }, + }, { name: 'run', command: 'run', diff --git a/test/unit/commands/home-forget.test.ts b/test/unit/commands/home-forget.test.ts new file mode 100644 index 00000000..e7e3e993 --- /dev/null +++ b/test/unit/commands/home-forget.test.ts @@ -0,0 +1,73 @@ +import { isAbsolute } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runHomeForgetCommand } from '../../../src/cli/commands/home/forget.js'; +import type { SuccessEnvelope } from '../../helpers.js'; + +function getWrittenStdout(calls: readonly unknown[][]): string { + expect(calls).toHaveLength(1); + const [output] = calls[0] ?? []; + if (typeof output !== 'string') { + throw new Error('expected stdout to be written as a string'); + } + return output; +} + +describe('home forget command', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forgets a registered Home and reports it in the JSON envelope', async () => { + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + const forgotten: string[] = []; + const forget = (path: string): Promise => { + forgotten.push(path); + return Promise.resolve(true); + }; + + await runHomeForgetCommand( + { json: true, path: '/homes/alpha' }, + { forget }, + ); + + expect(forgotten).toEqual(['/homes/alpha']); + const parsed = JSON.parse( + getWrittenStdout(stdout.mock.calls as unknown[][]), + ) as SuccessEnvelope<{ path: string; forgotten: boolean }>; + expect(parsed.command).toBe('home forget'); + expect(parsed.result).toEqual({ path: '/homes/alpha', forgotten: true }); + }); + + it('reports forgotten:false when the Home was not registered', async () => { + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + + await runHomeForgetCommand( + { json: false, path: '/homes/ghost' }, + { forget: () => Promise.resolve(false) }, + ); + + expect(getWrittenStdout(stdout.mock.calls as unknown[][])).toBe( + 'Home not in registry: /homes/ghost\n', + ); + }); + + it('normalizes a relative path to absolute before forgetting', async () => { + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + const forgotten: string[] = []; + + await runHomeForgetCommand( + { json: true, path: 'rel/home' }, + { + forget: (path) => { + forgotten.push(path); + return Promise.resolve(true); + }, + }, + ); + + expect(forgotten).toHaveLength(1); + expect(isAbsolute(forgotten[0] ?? '')).toBe(true); + }); +}); diff --git a/test/unit/commands/home-list.test.ts b/test/unit/commands/home-list.test.ts new file mode 100644 index 00000000..870557e3 --- /dev/null +++ b/test/unit/commands/home-list.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { runHomeListCommand } from '../../../src/cli/commands/home/list.js'; +import type { + HomeListingScope, + RegisteredHome, +} from '../../../src/storage/homeScope.js'; +import type { SuccessEnvelope } from '../../helpers.js'; + +function getWrittenStdout(calls: readonly unknown[][]): string { + expect(calls).toHaveLength(1); + const [output] = calls[0] ?? []; + if (typeof output !== 'string') { + throw new Error('expected stdout to be written as a string'); + } + return output; +} + +const SAMPLE: RegisteredHome[] = [ + { + path: '/homes/newest', + activeSessions: 1, + totalSessions: 3, + lastSeenAt: '2026-06-08T00:00:00.000Z', + }, + { + path: '/homes/older', + activeSessions: 0, + totalSessions: 2, + lastSeenAt: '2026-06-01T00:00:00.000Z', + }, +]; + +describe('home list command', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('widens to all scope with --all and renders one line per Home', async () => { + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + const scopes: HomeListingScope[] = []; + const listHomes = (scope: HomeListingScope): Promise => { + scopes.push(scope); + return Promise.resolve(SAMPLE); + }; + + await runHomeListCommand({ json: false, all: true }, { listHomes }); + + expect(scopes).toEqual(['all']); + const output = getWrittenStdout(stdout.mock.calls as unknown[][]); + expect(output).toContain( + '/homes/newest 1/3 active last seen 2026-06-08T00:00:00.000Z', + ); + expect(output).toContain( + '/homes/older 0/2 active last seen 2026-06-01T00:00:00.000Z', + ); + }); + + it('defaults to active scope', async () => { + vi.spyOn(process.stdout, 'write').mockReturnValue(true); + const scopes: HomeListingScope[] = []; + + await runHomeListCommand( + { json: false, all: false }, + { + listHomes: (scope) => { + scopes.push(scope); + return Promise.resolve([]); + }, + }, + ); + + expect(scopes).toEqual(['active']); + }); + + it('emits a JSON envelope carrying the Homes', async () => { + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + + await runHomeListCommand( + { json: true, all: false }, + { listHomes: () => Promise.resolve(SAMPLE) }, + ); + + const parsed = JSON.parse( + getWrittenStdout(stdout.mock.calls as unknown[][]), + ) as SuccessEnvelope<{ homes: RegisteredHome[] }>; + expect(parsed.ok).toBe(true); + expect(parsed.command).toBe('home list'); + expect(parsed.result.homes).toEqual(SAMPLE); + }); + + it('renders a friendly line when no Homes are registered', async () => { + const stdout = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + + await runHomeListCommand( + { json: false, all: false }, + { listHomes: () => Promise.resolve([]) }, + ); + + expect(getWrittenStdout(stdout.mock.calls as unknown[][])).toBe( + 'No registered Homes.\n', + ); + }); +}); diff --git a/test/unit/commands/inspect.test.ts b/test/unit/commands/inspect.test.ts index f8670e3d..f300344a 100644 --- a/test/unit/commands/inspect.test.ts +++ b/test/unit/commands/inspect.test.ts @@ -67,6 +67,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/mark.test.ts b/test/unit/commands/mark.test.ts index 49382736..291285d3 100644 --- a/test/unit/commands/mark.test.ts +++ b/test/unit/commands/mark.test.ts @@ -31,6 +31,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/paste.test.ts b/test/unit/commands/paste.test.ts index 23a3f343..da5f1297 100644 --- a/test/unit/commands/paste.test.ts +++ b/test/unit/commands/paste.test.ts @@ -34,6 +34,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/record-export.test.ts b/test/unit/commands/record-export.test.ts index f7341496..9b476781 100644 --- a/test/unit/commands/record-export.test.ts +++ b/test/unit/commands/record-export.test.ts @@ -103,6 +103,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/resize.test.ts b/test/unit/commands/resize.test.ts index 0f89a60d..e1be4d7c 100644 --- a/test/unit/commands/resize.test.ts +++ b/test/unit/commands/resize.test.ts @@ -31,6 +31,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/run.test.ts b/test/unit/commands/run.test.ts index 5167f054..3bc966a9 100644 --- a/test/unit/commands/run.test.ts +++ b/test/unit/commands/run.test.ts @@ -35,6 +35,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/screenshot.test.ts b/test/unit/commands/screenshot.test.ts index 1b236dd4..eadcb3cf 100644 --- a/test/unit/commands/screenshot.test.ts +++ b/test/unit/commands/screenshot.test.ts @@ -80,6 +80,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; const TEST_SCREENSHOT_SHA256 = 'a'.repeat(64); diff --git a/test/unit/commands/send-keys.test.ts b/test/unit/commands/send-keys.test.ts index 2eae21cc..c9528732 100644 --- a/test/unit/commands/send-keys.test.ts +++ b/test/unit/commands/send-keys.test.ts @@ -31,6 +31,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/signal.test.ts b/test/unit/commands/signal.test.ts index e65adab2..341b64be 100644 --- a/test/unit/commands/signal.test.ts +++ b/test/unit/commands/signal.test.ts @@ -31,6 +31,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/snapshot.test.ts b/test/unit/commands/snapshot.test.ts index 3320157b..74289316 100644 --- a/test/unit/commands/snapshot.test.ts +++ b/test/unit/commands/snapshot.test.ts @@ -73,6 +73,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/type.test.ts b/test/unit/commands/type.test.ts index d0b181cf..39c56cf6 100644 --- a/test/unit/commands/type.test.ts +++ b/test/unit/commands/type.test.ts @@ -34,6 +34,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/commands/wait.test.ts b/test/unit/commands/wait.test.ts index 0030387f..f9022bcc 100644 --- a/test/unit/commands/wait.test.ts +++ b/test/unit/commands/wait.test.ts @@ -52,6 +52,7 @@ const TEST_CONTEXT = { logger: createLogger('info', () => undefined), profileDefault: undefined, rendererDefault: 'ghostty-web', + explicitHome: false, configFile: null, } as const; diff --git a/test/unit/storage/homeRegistry.test.ts b/test/unit/storage/homeRegistry.test.ts new file mode 100644 index 00000000..b9c2c11c --- /dev/null +++ b/test/unit/storage/homeRegistry.test.ts @@ -0,0 +1,205 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + createHomeRegistry, + normalizeHomePath, + resolveHomeRegistryPath, + type HomeRegistry, +} from '../../../src/storage/homeRegistry.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function tempRegistryPath(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'agent-tty-registry-')); + temporaryDirectories.push(directory); + // Nest under a not-yet-created subdir so the atomic write must mkdir -p. + return join(directory, 'state', 'agent-tty', 'homes.json'); +} + +/** A registry over a temp file with a controllable clock and identity realpath + * (so path canonicalization is deterministic regardless of what exists). */ +async function makeRegistry(times: string[] = []): Promise<{ + registry: HomeRegistry; + registryPath: string; +}> { + const registryPath = await tempRegistryPath(); + let tick = 0; + const registry = createHomeRegistry({ + registryPath, + realpath: (path) => path, + now: () => + new Date( + times[Math.min(tick++, times.length - 1)] ?? '2026-01-01T00:00:00.000Z', + ), + }); + return { registry, registryPath }; +} + +describe('HomeRegistry store', () => { + it('registers a Home and reads it back', async () => { + const { registry } = await makeRegistry(['2026-06-01T00:00:00.000Z']); + + await registry.upsert('/homes/alpha'); + + expect(await registry.read()).toEqual([ + { path: '/homes/alpha', lastSeenAt: '2026-06-01T00:00:00.000Z' }, + ]); + }); + + it('dedupes the same Home and refreshes lastSeenAt', async () => { + const { registry } = await makeRegistry([ + '2026-06-01T00:00:00.000Z', + '2026-06-02T00:00:00.000Z', + ]); + + await registry.upsert('/homes/alpha'); + await registry.upsert('/homes/alpha'); + + expect(await registry.read()).toEqual([ + { path: '/homes/alpha', lastSeenAt: '2026-06-02T00:00:00.000Z' }, + ]); + }); + + it('reads newest-lastSeenAt first', async () => { + const { registry } = await makeRegistry([ + '2026-06-01T00:00:00.000Z', + '2026-06-03T00:00:00.000Z', + '2026-06-02T00:00:00.000Z', + ]); + + await registry.upsert('/homes/old'); + await registry.upsert('/homes/newest'); + await registry.upsert('/homes/middle'); + + expect((await registry.read()).map((entry) => entry.path)).toEqual([ + '/homes/newest', + '/homes/middle', + '/homes/old', + ]); + }); + + it('forget removes an entry and reports it; an unknown Home is a no-op', async () => { + const { registry } = await makeRegistry([ + '2026-06-01T00:00:00.000Z', + '2026-06-02T00:00:00.000Z', + ]); + await registry.upsert('/homes/alpha'); + await registry.upsert('/homes/beta'); + + expect(await registry.forget('/homes/alpha')).toBe(true); + expect((await registry.read()).map((entry) => entry.path)).toEqual([ + '/homes/beta', + ]); + + expect(await registry.forget('/homes/does-not-exist')).toBe(false); + expect((await registry.read()).map((entry) => entry.path)).toEqual([ + '/homes/beta', + ]); + }); + + it('reads a missing registry file as empty', async () => { + const { registry } = await makeRegistry(); + expect(await registry.read()).toEqual([]); + }); + + it('reads a corrupt registry file as empty (advisory, non-fatal)', async () => { + const { registry, registryPath } = await makeRegistry([ + '2026-06-01T00:00:00.000Z', + ]); + await registry.upsert('/homes/alpha'); + // Clobber with invalid JSON; a lost registry must never throw. + await registry.write([{ path: '/homes/alpha', lastSeenAt: 'x' }]); + const { writeFile } = await import('node:fs/promises'); + await writeFile(registryPath, '{ not valid json', 'utf8'); + + expect(await registry.read()).toEqual([]); + }); + + it('writes atomically and survives concurrent upserts without corruption', async () => { + const { registry, registryPath } = await makeRegistry([ + '2026-06-01T00:00:00.000Z', + ]); + + await Promise.all([ + registry.upsert('/homes/same'), + registry.upsert('/homes/same'), + registry.upsert('/homes/same'), + registry.upsert('/homes/same'), + registry.upsert('/homes/same'), + ]); + + const raw = await readFile(registryPath, 'utf8'); + // Always valid JSON (temp+rename), trailing newline, and deduped to one. + expect(raw.endsWith('\n')).toBe(true); + expect(() => JSON.parse(raw) as unknown).not.toThrow(); + expect((await registry.read()).map((entry) => entry.path)).toEqual([ + '/homes/same', + ]); + }); + + it('keeps both Homes when distinct upserts are awaited in sequence', async () => { + const { registry } = await makeRegistry([ + '2026-06-01T00:00:00.000Z', + '2026-06-02T00:00:00.000Z', + ]); + await registry.upsert('/homes/alpha'); + await registry.upsert('/homes/beta'); + + expect((await registry.read()).map((entry) => entry.path).sort()).toEqual([ + '/homes/alpha', + '/homes/beta', + ]); + }); +}); + +describe('resolveHomeRegistryPath', () => { + it('honors an absolute XDG_STATE_HOME', () => { + expect(resolveHomeRegistryPath({ XDG_STATE_HOME: '/custom/state' })).toBe( + '/custom/state/agent-tty/homes.json', + ); + }); + + it('ignores a relative XDG_STATE_HOME and falls back to ~/.local/state', () => { + expect(resolveHomeRegistryPath({ XDG_STATE_HOME: 'relative/state' })).toBe( + join(homedir(), '.local', 'state', 'agent-tty', 'homes.json'), + ); + }); + + it('defaults to ~/.local/state and is independent of AGENT_TTY_HOME', () => { + const expected = join( + homedir(), + '.local', + 'state', + 'agent-tty', + 'homes.json', + ); + expect(resolveHomeRegistryPath({})).toBe(expected); + // The registry spans Homes, so AGENT_TTY_HOME must not relocate it. + expect(resolveHomeRegistryPath({ AGENT_TTY_HOME: '/some/home' })).toBe( + expected, + ); + }); +}); + +describe('normalizeHomePath', () => { + it('resolves a relative path to absolute (identity realpath)', () => { + expect(normalizeHomePath('/already/abs', (path) => path)).toBe( + '/already/abs', + ); + expect(normalizeHomePath('relative/sub', (path) => path)).toBe( + join(process.cwd(), 'relative', 'sub'), + ); + }); +}); diff --git a/test/unit/storage/homeScope.test.ts b/test/unit/storage/homeScope.test.ts new file mode 100644 index 00000000..a5a657df --- /dev/null +++ b/test/unit/storage/homeScope.test.ts @@ -0,0 +1,177 @@ +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { + listRegisteredHomes, + scanHome, + type HomeSessionCounts, + type ScanHomeDependencies, +} from '../../../src/storage/homeScope.js'; +import type { SessionRecord } from '../../../src/protocol/schemas.js'; +import { readManifest, writeManifest } from '../../../src/storage/manifests.js'; +import { manifestPath, sessionDir } from '../../../src/storage/sessionPaths.js'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +function makeRecord( + overrides: Partial & { sessionId: string }, +): SessionRecord { + return { + version: 1, + createdAt: '2026-06-02T12:00:00.000Z', + updatedAt: '2026-06-02T12:00:00.000Z', + status: 'running', + command: ['/bin/sh'], + cwd: '/tmp/workspace', + cols: 80, + rows: 24, + hostPid: null, + childPid: null, + exitCode: null, + exitSignal: null, + ...overrides, + }; +} + +describe('scanHome (read-only Session counts)', () => { + let home = ''; + + beforeEach(async () => { + home = await realpath( + await mkdtemp(join(tmpdir(), 'agent-tty-homescope-')), + ); + temporaryDirectories.push(home); + }); + + async function seedSession( + record: Partial & { sessionId: string }, + ): Promise { + const dir = sessionDir(home, record.sessionId); + await mkdir(dir, { recursive: true }); + await writeManifest(manifestPath(dir), makeRecord(record)); + } + + it('counts active and visible (non-destroyed) Sessions', async () => { + await seedSession({ sessionId: 'running-1', status: 'running' }); + await seedSession({ sessionId: 'exited-1', status: 'exited' }); + await seedSession({ sessionId: 'destroyed-1', status: 'destroyed' }); + + expect(await scanHome(home)).toEqual({ + activeSessions: 1, + totalSessions: 2, + }); + }); + + it('treats a missing Home as having no Sessions', async () => { + expect(await scanHome(join(home, 'does-not-exist'))).toEqual({ + activeSessions: 0, + totalSessions: 0, + }); + }); + + it('NEVER reconciles: a running Session with a dead host stays running', async () => { + // hostPid that is essentially never alive. listSessions WOULD reconcile this + // to `failed`; scanHome must not — listing must never mutate Session state. + await seedSession({ + sessionId: 'dead-host', + status: 'running', + hostPid: 2_147_483_646, + childPid: 2_147_483_645, + }); + const before = await readManifest( + manifestPath(sessionDir(home, 'dead-host')), + ); + + const counts = await scanHome(home); + + expect(counts).toEqual({ activeSessions: 1, totalSessions: 1 }); + const after = await readManifest( + manifestPath(sessionDir(home, 'dead-host')), + ); + // Byte-for-byte unchanged — no reconcile, no rewrite. + expect(after).toEqual(before); + expect(after.status).toBe('running'); + }); + + it('skips a Session whose manifest fails to read and counts the rest', async () => { + // A single corrupt/unreadable manifest must not break discovery of the Home. + const dependencies: ScanHomeDependencies = { + readdir: () => Promise.resolve(['corrupt', 'good']), + readManifestIfExists: (path) => + path.includes('corrupt') + ? Promise.reject(new Error('manifest is unreadable')) + : Promise.resolve( + makeRecord({ sessionId: 'good', status: 'running' }), + ), + }; + + expect(await scanHome('/fake/home', dependencies)).toEqual({ + activeSessions: 1, + totalSessions: 1, + }); + }); +}); + +describe('listRegisteredHomes (scope, prune, ordering)', () => { + const counts: Record = { + '/homes/live': { activeSessions: 2, totalSessions: 4 }, + '/homes/terminal-only': { activeSessions: 0, totalSessions: 3 }, + '/homes/empty': { activeSessions: 0, totalSessions: 0 }, + }; + + function deps(scopeEntries: Array<{ path: string; lastSeenAt: string }>) { + return { + registry: { read: () => Promise.resolve(scopeEntries) }, + scanHome: (home: string) => + Promise.resolve( + counts[home] ?? { activeSessions: 0, totalSessions: 0 }, + ), + }; + } + + const entries = [ + { path: '/homes/live', lastSeenAt: '2026-06-03T00:00:00.000Z' }, + { path: '/homes/terminal-only', lastSeenAt: '2026-06-05T00:00:00.000Z' }, + { path: '/homes/empty', lastSeenAt: '2026-06-09T00:00:00.000Z' }, + ]; + + it('active scope shows only Homes with an Active Session, newest-first', async () => { + const homes = await listRegisteredHomes('active', deps(entries)); + expect(homes).toEqual([ + { + path: '/homes/live', + activeSessions: 2, + totalSessions: 4, + lastSeenAt: '2026-06-03T00:00:00.000Z', + }, + ]); + }); + + it('all scope includes terminal-only Homes but still prunes empty ones', async () => { + const homes = await listRegisteredHomes('all', deps(entries)); + // newest-first: terminal-only (06-05) before live (06-03); empty pruned. + expect(homes.map((home) => home.path)).toEqual([ + '/homes/terminal-only', + '/homes/live', + ]); + }); + + it('prune-on-read omits a Home with zero visible Sessions in both scopes', async () => { + const onlyEmpty = [ + { path: '/homes/empty', lastSeenAt: '2026-06-09T00:00:00.000Z' }, + ]; + expect(await listRegisteredHomes('active', deps(onlyEmpty))).toEqual([]); + expect(await listRegisteredHomes('all', deps(onlyEmpty))).toEqual([]); + }); +});