From 47a6bfdb849ee86c32c604ec96e7d765b137a42e Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 09:42:29 -0600 Subject: [PATCH 01/17] Add: local Fleet web dashboard --- README.md | 13 +++ docs/usage.md | 16 ++++ specs/web/web.spec.md | 72 ++++++++++++++ src/cli.ts | 4 +- src/fledge-plugin.ts | 4 +- src/run.ts | 35 +++++++ src/web.ts | 215 ++++++++++++++++++++++++++++++++++++++++++ test/web.test.ts | 23 +++++ 8 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 specs/web/web.spec.md create mode 100644 src/web.ts create mode 100644 test/web.test.ts diff --git a/README.md b/README.md index 3107318..f121091 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ let show skill find-worktrees --json # progressive body load let open ./agent.3md --json let context --json let doctor --json +let web # local, read-only Fleet dashboard ``` **Federation over relocation:** indexes host assets *in place* — never migrates @@ -64,6 +65,18 @@ Any project that already uses fledge can install `let` the same way as `fledge-plugin-memory` / `fledge-plugin-github`. Agents then call `fledge let …` without a separate PATH setup. +### Local Fleet view + +```bash +let web +# or: fledge let web +``` + +Starts a local-only dashboard at `http://127.0.0.1:8731`. It is a visual view +of Let's own federated index: worktrees, providers, bounded session activity, +instructions, and skills. It does not expose transcripts, filesystem paths, +terminal output, or agent controls. + ### Standalone (any host: Claude, Codex, Cursor, Kimi, …) ```bash diff --git a/docs/usage.md b/docs/usage.md index eca437f..ad7534e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -41,6 +41,22 @@ for every kind. All commands accept `--json` (default machine path for agents). +### `let web` + +Start the local, read-only Fleet dashboard. + +```bash +let web +let web --port 8732 +fledge let web +``` + +The dashboard binds only to `127.0.0.1` (default port `8731`) and refreshes +from Let's structured index cards. It displays worktrees, provider/session +activity, instructions, and skills. It never serves session transcripts, full +filesystem paths, raw terminal output, secrets, shell execution, or agent +controls. + ### `let history` (alias: `usage`) Rank hosts and projects by session activity (path-only, no transcripts). diff --git a/specs/web/web.spec.md b/specs/web/web.spec.md new file mode 100644 index 0000000..3c79720 --- /dev/null +++ b/specs/web/web.spec.md @@ -0,0 +1,72 @@ +--- +module: web +version: 1 +status: active +files: + - src/web.ts +db_tables: [] +depends_on: + - catalog +--- + +# Web + +## Purpose + +Serve a local, read-only Fleet dashboard backed directly by Let's federated +index cards. The dashboard makes agent activity inspectable without becoming a +workflow engine or a remote-control surface. + +## Public API + +| Export | Description | +|--------|-------------| +| `buildFleetSnapshot` | Build a bounded, metadata-only Fleet snapshot from Let catalog APIs. | +| `startFleetWeb` | Start the local-only web server and return its URL and stop handle. | +| `FleetFreshness` | One of live, recent, stale, or unknown activity states. | +| `FleetSession` | Sanitized provider, session label, and bounded freshness metadata. | +| `FleetRow` | Sanitized worktree, activity, session, instruction, and skill summary. | +| `FleetSnapshot` | Sanitized rows, bounded session metadata, refresh interval, and policy. | + +## Invariants + +1. `let web` binds only to `127.0.0.1` and defaults to port `8731`. +2. Fleet data is derived from Let catalog APIs, not parsed terminal output. +3. Responses never include filesystem paths, session bodies, terminal output, + secrets, browser shell endpoints, or agent-control actions. +4. Worktrees, sessions, instructions, and skills are capped before rendering. +5. Session-only records are bounded and include metadata only. +6. The normal CLI exits after ordinary commands, but remains alive while `web` + owns the local server. + +## Behavioral Examples + +``` +Given multiple host worktrees and path-only sessions +When let web starts +Then the browser receives sanitized Fleet rows without transcript contents +``` + +``` +Given `let web --port 8732` +When the server starts +Then the URL is http://127.0.0.1:8732 and no non-local interface is bound +``` + +## Error Cases + +| Error | When | Behavior | +|-------|------|----------| +| invalid port | `--port` is not an integer from 1 to 65535 | Return a validation envelope. | +| unavailable port | local port already bound | Let reports Bun's startup failure; no fallback external binding. | + +## Dependencies + +- `./catalog/context-builder` - trusted scan context. +- `./catalog/find` - structured federated index cards. + +## Change Log + +| Version | Date | Changes | +|---------|------|---------| +| 1 | 2026-08-01 | Initial local read-only Fleet dashboard. | diff --git a/src/cli.ts b/src/cli.ts index a65d27d..b238460 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,4 +6,6 @@ import { runLet } from "./run.ts"; const result = await runLet(process.argv.slice(2)); process.stdout.write(result.text); -process.exit(result.code); +if (!result.keepAlive) { + process.exit(result.code); +} diff --git a/src/fledge-plugin.ts b/src/fledge-plugin.ts index 34a9778..f1f1282 100644 --- a/src/fledge-plugin.ts +++ b/src/fledge-plugin.ts @@ -27,7 +27,9 @@ async function main(): Promise { await new Promise((resolve) => { process.stdout.write("", () => resolve()); }); - process.exit(result.code); + if (!result.keepAlive) { + process.exit(result.code); + } } catch (err) { sendError(err instanceof Error ? err.message : String(err)); process.exit(10); diff --git a/src/run.ts b/src/run.ts index 558d11c..09a4931 100644 --- a/src/run.ts +++ b/src/run.ts @@ -20,6 +20,7 @@ import { runDoctor } from "./doctor.ts"; import { type Envelope, LET_VERSION, withEnvelope } from "./envelope.ts"; import { LetError } from "./errors.ts"; import { runMcpServe } from "./mcp/serve.ts"; +import { startFleetWeb } from "./web.ts"; import { initLetWorkbed } from "./workbed/init.ts"; import { memoryDelete, @@ -102,6 +103,7 @@ Discovery: let history [--scope project|user|all] [--cwd ] [--json] let skill route|list|get <…> [--json] let route # alias for skill route + let web [--port N] [--cwd ] # local read-only Fleet dashboard Workbed: let init [--cwd ] [--json] @@ -152,6 +154,8 @@ export type RunResult = { /** Full text to print (help plain text, or JSON envelope). */ text: string; envelope?: Envelope; + /** A local server owns the event loop; command entry must not call process.exit. */ + keepAlive?: boolean; }; /** @@ -208,6 +212,37 @@ export async function runLet( return { code: 0, text: "" }; } + if (command === "web") { + const portRaw = flagStr(parsed.flags, "port"); + const port = portRaw ? Number(portRaw) : undefined; + if ( + portRaw && + (!Number.isInteger(port) || (port ?? 0) < 1 || (port ?? 0) > 65535) + ) { + const bad = await withEnvelope("web", async () => { + throw new LetError( + "validation", + "--port must be an integer from 1 to 65535", + { port: portRaw }, + ); + }); + return { + code: exitCodeForEnvelope(bad), + text: `${JSON.stringify(bad, null, 2)}\n`, + envelope: bad, + }; + } + const dashboard = startFleetWeb({ + cwd: flagStr(parsed.flags, "cwd") ?? defaults.cwd ?? process.cwd(), + port, + }); + return { + code: 0, + text: `Let Fleet is running at ${dashboard.url}\nRead-only local index: no transcripts, shell access, or agent controls.\n`, + keepAlive: true, + }; + } + const envelope = await withEnvelope(command, async () => { if (command === "version") { return { version: LET_VERSION }; diff --git a/src/web.ts b/src/web.ts new file mode 100644 index 0000000..15163ac --- /dev/null +++ b/src/web.ts @@ -0,0 +1,215 @@ +/** + * Local-only read-only web view for Let's federated discovery index. + * It intentionally uses index cards only: no session bodies, terminal output, + * filesystem paths, shell endpoints, or agent-control actions are exposed. + */ + +import { basename } from "node:path"; +import { buildScanContext } from "./catalog/context-builder.ts"; +import { findAssets } from "./catalog/find.ts"; +import type { IndexCard } from "./catalog/types.ts"; + +export type FleetFreshness = "live" | "recent" | "stale" | "unknown"; + +export type FleetSession = { + provider: string; + name: string; + freshness: FleetFreshness; + activity: string; +}; + +export type FleetRow = { + provider: string; + project: string; + worktree: string; + branch: string; + status: string; + freshness: FleetFreshness; + activity: string; + sessions: FleetSession[]; + instructions: Array<{ name: string; provider: string; scope: string }>; + skills: Array<{ name: string; provider: string; scope: string }>; +}; + +export type FleetSnapshot = { + source: "let"; + generatedAt: string; + refreshSeconds: number; + rows: FleetRow[]; + sessionOnly: FleetSession[]; + policy: string; +}; + +function freshness( + mtime: number | undefined, + now = Date.now(), +): { + freshness: FleetFreshness; + activity: string; +} { + if (!mtime) { + return { freshness: "unknown", activity: "Unknown" }; + } + const age = Math.max(0, now - mtime); + if (age < 60_000) { + return { freshness: "live", activity: "Just now" }; + } + if (age < 3_600_000) { + return { + freshness: "recent", + activity: `${Math.floor(age / 60_000)}m ago`, + }; + } + if (age < 86_400_000) { + return { + freshness: "stale", + activity: `${Math.floor(age / 3_600_000)}h ago`, + }; + } + return { + freshness: "stale", + activity: `${Math.floor(age / 86_400_000)}d ago`, + }; +} + +function projectName(card: IndexCard): string { + const root = card.repo_root; + return root ? basename(root) : card.name; +} + +function sessionView(card: IndexCard, now: number): FleetSession { + return { + provider: card.host, + name: card.name, + ...freshness(card.mtime_ms, now), + }; +} + +/** Build a bounded, metadata-only snapshot directly from Let catalog APIs. */ +export async function buildFleetSnapshot( + cwd: string, + now = Date.now(), +): Promise { + const ctx = buildScanContext({ cwd, scope: "all", limit: 48 }); + const [worktrees, sessions, instructions, skills] = await Promise.all([ + findAssets("worktrees", ctx), + findAssets("sessions", ctx), + findAssets("instructions", ctx), + findAssets("skills", ctx), + ]); + + const sessionsByRoot = new Map(); + for (const session of sessions.items) { + if (!session.repo_root) { + continue; + } + const matching = sessionsByRoot.get(session.repo_root) ?? []; + matching.push(session); + sessionsByRoot.set(session.repo_root, matching); + } + + const rows = worktrees.items.slice(0, 48).map((worktree) => { + const matchingSessions = [ + ...(sessionsByRoot.get(worktree.path) ?? []), + ...(worktree.repo_root + ? (sessionsByRoot.get(worktree.repo_root) ?? []) + : []), + ] + .filter( + (session, index, all) => + all.findIndex((item) => item.id === session.id) === index, + ) + .sort((left, right) => (right.mtime_ms ?? 0) - (left.mtime_ms ?? 0)) + .slice(0, 3) + .map((session) => sessionView(session, now)); + const activitySource = matchingSessions[0]?.activity + ? matchingSessions[0] + : freshness(worktree.mtime_ms, now); + return { + provider: worktree.host, + project: projectName(worktree), + worktree: worktree.name, + branch: worktree.branch ?? "Detached", + status: worktree.status ?? "unknown", + freshness: activitySource.freshness, + activity: activitySource.activity, + sessions: matchingSessions, + instructions: instructions.items.slice(0, 8).map((item) => ({ + name: item.name, + provider: item.host, + scope: item.scope, + })), + skills: skills.items.slice(0, 8).map((item) => ({ + name: item.name, + provider: item.host, + scope: item.scope, + })), + } satisfies FleetRow; + }); + + const attachedSessionIds = new Set( + rows.flatMap((row) => + row.sessions.map((session) => `${session.provider}:${session.name}`), + ), + ); + const sessionOnly = sessions.items + .sort((left, right) => (right.mtime_ms ?? 0) - (left.mtime_ms ?? 0)) + .map((session) => sessionView(session, now)) + .filter( + (session) => + !attachedSessionIds.has(`${session.provider}:${session.name}`), + ) + .slice(0, 12); + + rows.sort((left, right) => { + const order: Record = { + live: 0, + recent: 1, + stale: 2, + unknown: 3, + }; + return ( + order[left.freshness] - order[right.freshness] || + left.project.localeCompare(right.project) + ); + }); + return { + source: "let", + generatedAt: new Date(now).toISOString(), + refreshSeconds: 20, + rows, + sessionOnly, + policy: + "Read-only local index. No session transcripts, paths, secrets, terminal output, or agent controls are exposed.", + }; +} + +function html(): string { + return `Let Fleet
Let / local observatory

Fleet activity

Worktrees and agent sessions discovered from Let's native index. Select a row for bounded context.

Loading…
WorkProviderBranchActivity

`; +} + +export function startFleetWeb(options: { cwd: string; port?: number }): { + url: string; + stop: () => void; +} { + const port = options.port ?? 8731; + const server = Bun.serve({ + hostname: "127.0.0.1", + port, + fetch: async (request) => { + const pathname = new URL(request.url).pathname; + if (pathname === "/api/fleet") { + return Response.json(await buildFleetSnapshot(options.cwd)); + } + if (pathname === "/" || pathname === "/index.html") { + return new Response(html(), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + return new Response("Not found", { status: 404 }); + }, + }); + return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() }; +} diff --git a/test/web.test.ts b/test/web.test.ts new file mode 100644 index 0000000..acb51c1 --- /dev/null +++ b/test/web.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from "bun:test"; +import { runLet } from "../src/run.ts"; +import { buildFleetSnapshot } from "../src/web.ts"; + +describe("web fleet snapshot", () => { + test("is metadata-only and bounded", async () => { + const snapshot = await buildFleetSnapshot(process.cwd(), Date.now()); + expect(snapshot.source).toBe("let"); + expect(snapshot.rows.length).toBeLessThanOrEqual(48); + expect(snapshot.policy).toContain("No session transcripts"); + for (const row of snapshot.rows) { + expect(JSON.stringify(row)).not.toContain("/Users/"); + expect(row.skills.length).toBeLessThanOrEqual(8); + expect(row.instructions.length).toBeLessThanOrEqual(8); + } + }); +}); + +test("web rejects an unsafe port before starting a server", async () => { + const result = await runLet(["web", "--port", "not-a-port"]); + expect(result.code).toBe(1); + expect(result.text).toContain("--port must be an integer"); +}); From 19953bf85663ec9a80822049376870e883648416 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 09:54:47 -0600 Subject: [PATCH 02/17] Fix: clarify Fleet activity hierarchy --- specs/web/web.spec.md | 11 ++- src/web.ts | 182 ++++++++++++++++++++++++------------------ test/web.test.ts | 43 ++++++++-- 3 files changed, 149 insertions(+), 87 deletions(-) diff --git a/specs/web/web.spec.md b/specs/web/web.spec.md index 3c79720..532100c 100644 --- a/specs/web/web.spec.md +++ b/specs/web/web.spec.md @@ -22,10 +22,13 @@ workflow engine or a remote-control surface. | Export | Description | |--------|-------------| | `buildFleetSnapshot` | Build a bounded, metadata-only Fleet snapshot from Let catalog APIs. | +| `fleetStateForSessions` | Classify observed heartbeats as Recent activity or History. | | `startFleetWeb` | Start the local-only web server and return its URL and stop handle. | | `FleetFreshness` | One of live, recent, stale, or unknown activity states. | | `FleetSession` | Sanitized provider, session label, and bounded freshness metadata. | -| `FleetRow` | Sanitized worktree, activity, session, instruction, and skill summary. | +| `FleetState` | Recent activity or History repository activity classification. | +| `FleetWorktree` | Sanitized worktree and branch metadata belonging to a repository. | +| `FleetRepository` | Repository-first collection of worktrees and secondary session evidence. | | `FleetSnapshot` | Sanitized rows, bounded session metadata, refresh interval, and policy. | ## Invariants @@ -34,8 +37,10 @@ workflow engine or a remote-control surface. 2. Fleet data is derived from Let catalog APIs, not parsed terminal output. 3. Responses never include filesystem paths, session bodies, terminal output, secrets, browser shell endpoints, or agent-control actions. -4. Worktrees, sessions, instructions, and skills are capped before rendering. -5. Session-only records are bounded and include metadata only. +4. Repository cards group worktrees first; sessions remain secondary evidence. +5. Live process detection is unavailable in this MVP. Fresh session metadata is Recent activity; stale records are History. +6. Worktrees, sessions, instructions, and skills are capped before rendering. +7. Session-only records are bounded and include metadata only. 6. The normal CLI exits after ordinary commands, but remains alive while `web` owns the local server. diff --git a/src/web.ts b/src/web.ts index 15163ac..1350485 100644 --- a/src/web.ts +++ b/src/web.ts @@ -10,6 +10,7 @@ import { findAssets } from "./catalog/find.ts"; import type { IndexCard } from "./catalog/types.ts"; export type FleetFreshness = "live" | "recent" | "stale" | "unknown"; +export type FleetState = "recent" | "history"; export type FleetSession = { provider: string; @@ -18,14 +19,18 @@ export type FleetSession = { activity: string; }; -export type FleetRow = { +export type FleetWorktree = { provider: string; - project: string; worktree: string; branch: string; status: string; - freshness: FleetFreshness; +}; + +export type FleetRepository = { + project: string; + state: FleetState; activity: string; + worktrees: FleetWorktree[]; sessions: FleetSession[]; instructions: Array<{ name: string; provider: string; scope: string }>; skills: Array<{ name: string; provider: string; scope: string }>; @@ -35,8 +40,10 @@ export type FleetSnapshot = { source: "let"; generatedAt: string; refreshSeconds: number; - rows: FleetRow[]; - sessionOnly: FleetSession[]; + recentActivity: FleetRepository[]; + history: FleetRepository[]; + unmatchedSessions: FleetSession[]; + liveProcessDetection: "unavailable"; policy: string; }; @@ -85,6 +92,18 @@ function sessionView(card: IndexCard, now: number): FleetSession { }; } +export function fleetStateForSessions(sessions: FleetSession[]): FleetState { + if ( + sessions.some( + (session) => + session.freshness === "live" || session.freshness === "recent", + ) + ) { + return "recent"; + } + return "history"; +} + /** Build a bounded, metadata-only snapshot directly from Let catalog APIs. */ export async function buildFleetSnapshot( cwd: string, @@ -98,87 +117,94 @@ export async function buildFleetSnapshot( findAssets("skills", ctx), ]); - const sessionsByRoot = new Map(); - for (const session of sessions.items) { - if (!session.repo_root) { - continue; - } - const matching = sessionsByRoot.get(session.repo_root) ?? []; - matching.push(session); - sessionsByRoot.set(session.repo_root, matching); - } - - const rows = worktrees.items.slice(0, 48).map((worktree) => { - const matchingSessions = [ - ...(sessionsByRoot.get(worktree.path) ?? []), - ...(worktree.repo_root - ? (sessionsByRoot.get(worktree.repo_root) ?? []) - : []), - ] - .filter( - (session, index, all) => - all.findIndex((item) => item.id === session.id) === index, - ) - .sort((left, right) => (right.mtime_ms ?? 0) - (left.mtime_ms ?? 0)) - .slice(0, 3) - .map((session) => sessionView(session, now)); - const activitySource = matchingSessions[0]?.activity - ? matchingSessions[0] - : freshness(worktree.mtime_ms, now); - return { - provider: worktree.host, + const instructionCards = instructions.items.slice(0, 8).map((item) => ({ + name: item.name, + provider: item.host, + scope: item.scope, + })); + const skillCards = skills.items.slice(0, 8).map((item) => ({ + name: item.name, + provider: item.host, + scope: item.scope, + })); + const repositories = new Map(); + const worktreeRepoByPath = new Map(); + for (const worktree of worktrees.items.slice(0, 48)) { + const key = worktree.repo_root ?? worktree.path; + worktreeRepoByPath.set(worktree.path, key); + const repository = repositories.get(key) ?? { project: projectName(worktree), + state: "history" as FleetState, + activity: "No session heartbeat", + worktrees: [], + sessions: [], + instructions: instructionCards, + skills: skillCards, + }; + repository.worktrees.push({ + provider: worktree.host, worktree: worktree.name, branch: worktree.branch ?? "Detached", status: worktree.status ?? "unknown", - freshness: activitySource.freshness, - activity: activitySource.activity, - sessions: matchingSessions, - instructions: instructions.items.slice(0, 8).map((item) => ({ - name: item.name, - provider: item.host, - scope: item.scope, - })), - skills: skills.items.slice(0, 8).map((item) => ({ - name: item.name, - provider: item.host, - scope: item.scope, - })), - } satisfies FleetRow; - }); + }); + repositories.set(key, repository); + } - const attachedSessionIds = new Set( - rows.flatMap((row) => - row.sessions.map((session) => `${session.provider}:${session.name}`), - ), - ); - const sessionOnly = sessions.items - .sort((left, right) => (right.mtime_ms ?? 0) - (left.mtime_ms ?? 0)) - .map((session) => sessionView(session, now)) - .filter( - (session) => - !attachedSessionIds.has(`${session.provider}:${session.name}`), - ) - .slice(0, 12); - - rows.sort((left, right) => { - const order: Record = { - live: 0, - recent: 1, - stale: 2, - unknown: 3, - }; - return ( - order[left.freshness] - order[right.freshness] || - left.project.localeCompare(right.project) + const unmatchedSessions: FleetSession[] = []; + for (const session of sessions.items) { + const key = session.repo_root + ? repositories.has(session.repo_root) + ? session.repo_root + : worktreeRepoByPath.get(session.repo_root) + : undefined; + const view = sessionView(session, now); + if (!key) { + unmatchedSessions.push(view); + continue; + } + repositories.get(key)?.sessions.push(view); + } + + const recentActivity: FleetRepository[] = []; + const history: FleetRepository[] = []; + for (const repository of repositories.values()) { + repository.worktrees.sort((left, right) => + left.branch.localeCompare(right.branch), ); - }); + repository.sessions.sort((left, right) => { + const order: Record = { + live: 0, + recent: 1, + stale: 2, + unknown: 3, + }; + return order[left.freshness] - order[right.freshness]; + }); + const newest = repository.sessions[0]; + repository.state = fleetStateForSessions(repository.sessions); + if (repository.state === "recent") { + repository.activity = `Observed session activity ${newest.activity}`; + recentActivity.push(repository); + } else { + repository.activity = newest + ? `Last session ${newest.activity}` + : "No session heartbeat"; + history.push(repository); + } + } + for (const group of [recentActivity, history]) { + group.sort((left, right) => left.project.localeCompare(right.project)); + } return { source: "let", generatedAt: new Date(now).toISOString(), refreshSeconds: 20, - rows, - sessionOnly, + recentActivity, + history, + unmatchedSessions: unmatchedSessions + .sort((left, right) => left.activity.localeCompare(right.activity)) + .slice(0, 12), + liveProcessDetection: "unavailable", policy: "Read-only local index. No session transcripts, paths, secrets, terminal output, or agent controls are exposed.", }; @@ -186,8 +212,8 @@ export async function buildFleetSnapshot( function html(): string { return `Let Fleet
Let / local observatory

Fleet activity

Worktrees and agent sessions discovered from Let's native index. Select a row for bounded context.

Loading…
WorkProviderBranchActivity

`; + :root{--bg:#10151a;--panel:#182128;--line:#31404a;--text:#edf5f5;--muted:#9aabb1;--aqua:#59d7cb;--lime:#afe84c;--yellow:#f1c967}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at top right,#17343a 0,transparent 32rem),var(--bg);color:var(--text);font:14px/1.45 ui-sans-serif,system-ui,sans-serif}main{max-width:1120px;margin:auto;padding:32px 24px}header{display:flex;justify-content:space-between;align-items:end;border-bottom:1px solid var(--line);padding-bottom:22px;margin-bottom:18px}.eyebrow{font:600 11px ui-monospace,monospace;letter-spacing:.13em;color:var(--aqua);text-transform:uppercase}h1{font-size:36px;letter-spacing:-.04em;margin:4px 0}h2{margin:4px 0;font-size:20px}p{color:var(--muted);max-width:660px;margin:0}.stamp{color:var(--muted);font:12px ui-monospace,monospace;text-align:right}.section{margin:26px 0 10px;display:flex;align-items:baseline;gap:10px}.section h2{font-size:15px;letter-spacing:.02em}.section p{font-size:12px}.repo{background:rgba(24,33,40,.9);border:1px solid var(--line);border-radius:10px;margin:10px 0;overflow:hidden}.repo-head{width:100%;padding:15px;background:transparent;border:0;color:var(--text);display:flex;justify-content:space-between;align-items:center;text-align:left;cursor:pointer}.repo-head:hover{background:#1d3035}.project{font-weight:700}.sub{display:block;color:var(--muted);font:12px ui-monospace,monospace;margin-top:2px}.dot{display:inline-flex;gap:7px;align-items:center;color:var(--muted);font:12px ui-monospace,monospace}.dot:before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted)}.dot.active:before{background:var(--aqua);box-shadow:0 0 0 4px #59d7cb20}.dot.recent:before{background:var(--lime)}.dot.history:before{background:var(--yellow)}table{width:100%;border-collapse:collapse}th{text-align:left;padding:8px 15px;color:var(--muted);font:600 10px ui-monospace,monospace;letter-spacing:.1em;text-transform:uppercase}td{padding:10px 15px;border-top:1px solid #27353d;font-size:13px}.worktree{font-family:ui-monospace,monospace}.details{border-top:1px solid #27353d;padding:0 15px}.details summary{padding:11px 0;color:var(--muted);cursor:pointer;font-size:12px}.session{display:flex;justify-content:space-between;gap:14px;padding:7px 0;border-top:1px solid #27353d;font:12px ui-monospace,monospace}.session span:last-child{color:var(--muted)}.empty{color:var(--muted);font-size:13px;padding:13px 0}.notice{margin-top:22px;color:var(--muted);font-size:12px}@media(max-width:740px){main{padding:22px 12px}header{display:block}.stamp{text-align:left;margin-top:12px}th:nth-child(1),td:nth-child(1){display:none}h1{font-size:30px}}
Let / local observatory

Fleet activity

Repositories first. Worktrees and branches are the primary unit; session metadata is secondary evidence, never a claim that stale history is live work.

Loading…

`; } export function startFleetWeb(options: { cwd: string; port?: number }): { diff --git a/test/web.test.ts b/test/web.test.ts index acb51c1..684b03f 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -1,21 +1,52 @@ import { describe, expect, test } from "bun:test"; import { runLet } from "../src/run.ts"; -import { buildFleetSnapshot } from "../src/web.ts"; +import { buildFleetSnapshot, fleetStateForSessions } from "../src/web.ts"; describe("web fleet snapshot", () => { test("is metadata-only and bounded", async () => { const snapshot = await buildFleetSnapshot(process.cwd(), Date.now()); expect(snapshot.source).toBe("let"); - expect(snapshot.rows.length).toBeLessThanOrEqual(48); + const repositories = [...snapshot.recentActivity, ...snapshot.history]; + expect(repositories.length).toBeLessThanOrEqual(48); expect(snapshot.policy).toContain("No session transcripts"); - for (const row of snapshot.rows) { - expect(JSON.stringify(row)).not.toContain("/Users/"); - expect(row.skills.length).toBeLessThanOrEqual(8); - expect(row.instructions.length).toBeLessThanOrEqual(8); + for (const repository of repositories) { + expect(JSON.stringify(repository)).not.toContain("/Users/"); + expect(repository.skills.length).toBeLessThanOrEqual(8); + expect(repository.instructions.length).toBeLessThanOrEqual(8); + expect(repository.worktrees.length).toBeGreaterThan(0); } }); }); +test("stale session history never appears as recent activity", () => { + expect( + fleetStateForSessions([ + { + provider: "grok", + name: "old", + freshness: "stale", + activity: "20h ago", + }, + ]), + ).toBe("history"); + expect( + fleetStateForSessions([ + { + provider: "grok", + name: "new", + freshness: "live", + activity: "Just now", + }, + { + provider: "codex", + name: "old", + freshness: "stale", + activity: "20h ago", + }, + ]), + ).toBe("recent"); +}); + test("web rejects an unsafe port before starting a server", async () => { const result = await runLet(["web", "--port", "not-a-port"]); expect(result.code).toBe(1); From c1131d2ecc93e43202f5a71eb48426e6d918122c Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 10:03:28 -0600 Subject: [PATCH 03/17] Fix: show live agent work clearly --- specs/web/web.spec.md | 7 +- src/web.ts | 147 ++++++++++++++++++++++++++++++++++++++++-- test/web.test.ts | 29 ++++++++- 3 files changed, 175 insertions(+), 8 deletions(-) diff --git a/specs/web/web.spec.md b/specs/web/web.spec.md index 532100c..feaaf91 100644 --- a/specs/web/web.spec.md +++ b/specs/web/web.spec.md @@ -29,6 +29,10 @@ workflow engine or a remote-control surface. | `FleetState` | Recent activity or History repository activity classification. | | `FleetWorktree` | Sanitized worktree and branch metadata belonging to a repository. | | `FleetRepository` | Repository-first collection of worktrees and secondary session evidence. | +| `LiveAgent` | Whitelisted local CLI agent with an internal current working directory. | +| `WorkingAgent` | Sanitized live agent record matched to a repo/worktree or marked unassigned. | +| `compactCwd` | Reduce a local cwd to a short display suffix. | +| `parseLocalAgentProcessLines` | Parse only whitelisted agent CLI processes and resolve their cwd internally. | | `FleetSnapshot` | Sanitized rows, bounded session metadata, refresh interval, and policy. | ## Invariants @@ -38,7 +42,8 @@ workflow engine or a remote-control surface. 3. Responses never include filesystem paths, session bodies, terminal output, secrets, browser shell endpoints, or agent-control actions. 4. Repository cards group worktrees first; sessions remain secondary evidence. -5. Live process detection is unavailable in this MVP. Fresh session metadata is Recent activity; stale records are History. +5. Working now comes only from a local process probe with exact agent CLI executable allowlist and cwd resolution. Session timestamps never prove a process is running. +6. Unmapped live processes are surfaced as Unassigned running agent with only a compact cwd display. 6. Worktrees, sessions, instructions, and skills are capped before rendering. 7. Session-only records are bounded and include metadata only. 6. The normal CLI exits after ordinary commands, but remains alive while `web` diff --git a/src/web.ts b/src/web.ts index 1350485..4631a0d 100644 --- a/src/web.ts +++ b/src/web.ts @@ -19,6 +19,18 @@ export type FleetSession = { activity: string; }; +export type LiveAgent = { + agent: "Grok" | "Claude" | "Codex" | "Cursor"; + cwd: string; +}; + +export type WorkingAgent = LiveAgent & { + repo: string; + worktree: string | null; + branch: string | null; + matched: boolean; +}; + export type FleetWorktree = { provider: string; worktree: string; @@ -43,7 +55,8 @@ export type FleetSnapshot = { recentActivity: FleetRepository[]; history: FleetRepository[]; unmatchedSessions: FleetSession[]; - liveProcessDetection: "unavailable"; + workingNow: WorkingAgent[]; + liveProcessDetection: "local-process"; policy: string; }; @@ -80,8 +93,105 @@ function freshness( } function projectName(card: IndexCard): string { - const root = card.repo_root; - return root ? basename(root) : card.name; + return basename(repositoryPath(card)); +} + +function repositoryPath(card: IndexCard): string { + const marker = "/.worktrees/"; + const worktreeMarker = card.path.lastIndexOf(marker); + if (worktreeMarker >= 0) { + return card.path.slice(0, worktreeMarker); + } + const codex = card.path.match(/\/\.codex\/worktrees\/[^/]+\/([^/]+)/); + if (codex?.[1]) { + return codex[1]; + } + return card.repo_root && basename(card.repo_root) !== "let" + ? card.repo_root + : card.path; +} + +export function compactCwd(cwd: string): string { + const parts = cwd.split("/").filter(Boolean); + return parts.length > 3 ? `…/${parts.slice(-3).join("/")}` : cwd; +} + +export function parseLocalAgentProcessLines( + output: string, + cwdForPid: (pid: string) => string | null, + isProjectCwd: (cwd: string) => boolean = () => true, +): LiveAgent[] { + const providers: Record = { + grok: "Grok", + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + }; + return output + .split("\n") + .map((line) => line.trim().match(/^(\d+)\s+(.+)$/)) + .flatMap((match) => { + if (!match?.[1] || !match[2]) { + return []; + } + const executable = basename(match[2]).toLowerCase(); + const agent = providers[executable]; + const cwd = agent ? cwdForPid(match[1]) : null; + if (!agent || !cwd) { + return []; + } + // GUI/app helper processes can share a Codex/Cursor binary name. Only + // accept those providers when their cwd is a real project checkout. + if ((agent === "Codex" || agent === "Cursor") && !isProjectCwd(cwd)) { + return []; + } + return [{ agent, cwd }]; + }) + .slice(0, 20); +} + +function readLiveAgents(): LiveAgent[] { + const processList = Bun.spawnSync({ cmd: ["ps", "-axo", "pid=,comm="] }); + if (processList.exitCode !== 0) { + return []; + } + return parseLocalAgentProcessLines( + new TextDecoder().decode(processList.stdout), + (pid) => { + const cwd = Bun.spawnSync({ + cmd: ["lsof", "-a", "-p", pid, "-d", "cwd", "-Fn"], + }); + const line = new TextDecoder() + .decode(cwd.stdout) + .split("\n") + .find((item) => item.startsWith("n")); + return line ? line.slice(1) : null; + }, + (cwd) => + Bun.spawnSync({ + cmd: ["git", "-C", cwd, "rev-parse", "--is-inside-work-tree"], + }).exitCode === 0, + ); +} + +function gitLocation( + cwd: string, +): { repo: string; worktree: string; branch: string } | null { + const root = Bun.spawnSync({ + cmd: ["git", "-C", cwd, "rev-parse", "--show-toplevel"], + }); + if (root.exitCode !== 0) { + return null; + } + const rootPath = new TextDecoder().decode(root.stdout).trim(); + const branch = Bun.spawnSync({ + cmd: ["git", "-C", cwd, "branch", "--show-current"], + }); + return { + repo: basename(rootPath), + worktree: basename(cwd), + branch: new TextDecoder().decode(branch.stdout).trim() || "Detached", + }; } function sessionView(card: IndexCard, now: number): FleetSession { @@ -129,8 +239,12 @@ export async function buildFleetSnapshot( })); const repositories = new Map(); const worktreeRepoByPath = new Map(); + const worktreeDetails = new Map< + string, + { repo: string; worktree: string; branch: string } + >(); for (const worktree of worktrees.items.slice(0, 48)) { - const key = worktree.repo_root ?? worktree.path; + const key = repositoryPath(worktree); worktreeRepoByPath.set(worktree.path, key); const repository = repositories.get(key) ?? { project: projectName(worktree), @@ -147,6 +261,11 @@ export async function buildFleetSnapshot( branch: worktree.branch ?? "Detached", status: worktree.status ?? "unknown", }); + worktreeDetails.set(worktree.path, { + repo: repository.project, + worktree: worktree.name, + branch: worktree.branch ?? "Detached", + }); repositories.set(key, repository); } @@ -165,6 +284,21 @@ export async function buildFleetSnapshot( repositories.get(key)?.sessions.push(view); } + const workingNow = readLiveAgents().map((agent) => { + const match = [...worktreeDetails.entries()].find( + ([path]) => agent.cwd === path || agent.cwd.startsWith(`${path}/`), + ); + const details = match?.[1] ?? gitLocation(agent.cwd); + return { + agent: agent.agent, + cwd: compactCwd(agent.cwd), + repo: details?.repo ?? "Unassigned running agent", + worktree: details?.worktree ?? null, + branch: details?.branch ?? null, + matched: details !== undefined, + } satisfies WorkingAgent; + }); + const recentActivity: FleetRepository[] = []; const history: FleetRepository[] = []; for (const repository of repositories.values()) { @@ -204,7 +338,8 @@ export async function buildFleetSnapshot( unmatchedSessions: unmatchedSessions .sort((left, right) => left.activity.localeCompare(right.activity)) .slice(0, 12), - liveProcessDetection: "unavailable", + workingNow, + liveProcessDetection: "local-process", policy: "Read-only local index. No session transcripts, paths, secrets, terminal output, or agent controls are exposed.", }; @@ -213,7 +348,7 @@ export async function buildFleetSnapshot( function html(): string { return `Let Fleet
Let / local observatory

Fleet activity

Repositories first. Worktrees and branches are the primary unit; session metadata is secondary evidence, never a claim that stale history is live work.

Loading…

`; + let fleet={workingNow:[],recentActivity:[],history:[]};const e=s=>{const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML};const work=w=>'
Worktree · '+e(w.worktree)+'Branch · '+e(w.branch)+'
';const card=r=>'
'+e(r.project)+''+r.worktrees.length+' worktree'+(r.worktrees.length===1?'':'s')+''+e(r.activity)+'
'+r.worktrees.map(work).join('')+'
Recent history ('+r.sessions.length+')'+(r.sessions.length?r.sessions.map(s=>'
'+e(s.provider)+' session'+e(s.activity)+'
').join(''):'
No session history found.
')+'
';const section=(title,copy,rows,renderer=card)=>'

'+title+' ('+rows.length+')

'+copy+'

'+(rows.length?rows.map(renderer).join(''):'

None.

')+'
';const live=a=>'
'+e(a.agent)+''+e(a.repo)+'Running locally
'+(a.matched?'
Worktree · '+e(a.worktree)+'Branch · '+e(a.branch)+'
':'
Unassigned running agentCWD · '+e(a.cwd)+'
')+'
';function render(){const projects=[...fleet.recentActivity,...fleet.history];document.querySelector('#stamp').textContent=fleet.workingNow.length+' running agents · '+projects.length+' repositories';document.querySelector('#notice').textContent=fleet.policy;document.querySelector('#app').innerHTML=section('Working now','Live local agent processes only.',fleet.workingNow,live)+section('Projects','Repositories and their worktrees.',projects)+section('Recent history','Observed session metadata, never a live-process claim.',fleet.recentActivity)+section('History','Older session records.',fleet.history)}async function refresh(){fleet=await fetch('/api/fleet').then(r=>r.json());render()}refresh();setInterval(refresh,5000);`; } export function startFleetWeb(options: { cwd: string; port?: number }): { diff --git a/test/web.test.ts b/test/web.test.ts index 684b03f..0d50d45 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -1,11 +1,17 @@ import { describe, expect, test } from "bun:test"; import { runLet } from "../src/run.ts"; -import { buildFleetSnapshot, fleetStateForSessions } from "../src/web.ts"; +import { + buildFleetSnapshot, + fleetStateForSessions, + parseLocalAgentProcessLines, +} from "../src/web.ts"; describe("web fleet snapshot", () => { test("is metadata-only and bounded", async () => { const snapshot = await buildFleetSnapshot(process.cwd(), Date.now()); expect(snapshot.source).toBe("let"); + expect(snapshot.liveProcessDetection).toBe("local-process"); + expect(JSON.stringify(snapshot.workingNow)).not.toContain("/Users/"); const repositories = [...snapshot.recentActivity, ...snapshot.history]; expect(repositories.length).toBeLessThanOrEqual(48); expect(snapshot.policy).toContain("No session transcripts"); @@ -52,3 +58,24 @@ test("web rejects an unsafe port before starting a server", async () => { expect(result.code).toBe(1); expect(result.text).toContain("--port must be an integer"); }); + +test("only whitelisted local CLI processes become working agents", () => { + const agents = parseLocalAgentProcessLines( + "7 /opt/bin/grok\n8 /Applications/Codex\n9 /opt/bin/claude\n10 /bin/zsh", + (pid) => + pid === "7" ? "/work/spec-sync" : pid === "9" ? "/work/let" : null, + ); + expect(agents).toEqual([ + { agent: "Grok", cwd: "/work/spec-sync" }, + { agent: "Claude", cwd: "/work/let" }, + ]); +}); + +test("Codex and Cursor helpers require a project cwd", () => { + const agents = parseLocalAgentProcessLines( + "7 codex\n8 cursor\n9 grok", + () => "/not-a-repository", + () => false, + ); + expect(agents).toEqual([{ agent: "Grok", cwd: "/not-a-repository" }]); +}); From 691564207d019cc880c41f6101a89cb90913a9cc Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 10:06:57 -0600 Subject: [PATCH 04/17] fix(fleet): hide process working directories --- specs/web/web.spec.md | 10 +++++----- src/web.ts | 14 ++++---------- test/web.test.ts | 12 ++++++++++++ 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/specs/web/web.spec.md b/specs/web/web.spec.md index feaaf91..7ae3506 100644 --- a/specs/web/web.spec.md +++ b/specs/web/web.spec.md @@ -23,6 +23,7 @@ workflow engine or a remote-control surface. |--------|-------------| | `buildFleetSnapshot` | Build a bounded, metadata-only Fleet snapshot from Let catalog APIs. | | `fleetStateForSessions` | Classify observed heartbeats as Recent activity or History. | +| `fleetHtml` | Render the privacy-bounded local Fleet page. | | `startFleetWeb` | Start the local-only web server and return its URL and stop handle. | | `FleetFreshness` | One of live, recent, stale, or unknown activity states. | | `FleetSession` | Sanitized provider, session label, and bounded freshness metadata. | @@ -31,7 +32,6 @@ workflow engine or a remote-control surface. | `FleetRepository` | Repository-first collection of worktrees and secondary session evidence. | | `LiveAgent` | Whitelisted local CLI agent with an internal current working directory. | | `WorkingAgent` | Sanitized live agent record matched to a repo/worktree or marked unassigned. | -| `compactCwd` | Reduce a local cwd to a short display suffix. | | `parseLocalAgentProcessLines` | Parse only whitelisted agent CLI processes and resolve their cwd internally. | | `FleetSnapshot` | Sanitized rows, bounded session metadata, refresh interval, and policy. | @@ -43,10 +43,10 @@ workflow engine or a remote-control surface. secrets, browser shell endpoints, or agent-control actions. 4. Repository cards group worktrees first; sessions remain secondary evidence. 5. Working now comes only from a local process probe with exact agent CLI executable allowlist and cwd resolution. Session timestamps never prove a process is running. -6. Unmapped live processes are surfaced as Unassigned running agent with only a compact cwd display. -6. Worktrees, sessions, instructions, and skills are capped before rendering. -7. Session-only records are bounded and include metadata only. -6. The normal CLI exits after ordinary commands, but remains alive while `web` +6. Unmapped live processes are surfaced as Unassigned running agent and agent type only; their current working directories never leave the process probe. +7. Worktrees, sessions, instructions, and skills are capped before rendering. +8. Session-only records are bounded and include metadata only. +9. The normal CLI exits after ordinary commands, but remains alive while `web` owns the local server. ## Behavioral Examples diff --git a/src/web.ts b/src/web.ts index 4631a0d..dfc66e6 100644 --- a/src/web.ts +++ b/src/web.ts @@ -24,7 +24,7 @@ export type LiveAgent = { cwd: string; }; -export type WorkingAgent = LiveAgent & { +export type WorkingAgent = Omit & { repo: string; worktree: string | null; branch: string | null; @@ -111,11 +111,6 @@ function repositoryPath(card: IndexCard): string { : card.path; } -export function compactCwd(cwd: string): string { - const parts = cwd.split("/").filter(Boolean); - return parts.length > 3 ? `…/${parts.slice(-3).join("/")}` : cwd; -} - export function parseLocalAgentProcessLines( output: string, cwdForPid: (pid: string) => string | null, @@ -291,7 +286,6 @@ export async function buildFleetSnapshot( const details = match?.[1] ?? gitLocation(agent.cwd); return { agent: agent.agent, - cwd: compactCwd(agent.cwd), repo: details?.repo ?? "Unassigned running agent", worktree: details?.worktree ?? null, branch: details?.branch ?? null, @@ -345,10 +339,10 @@ export async function buildFleetSnapshot( }; } -function html(): string { +export function fleetHtml(): string { return `Let Fleet
Let / local observatory

Fleet activity

Repositories first. Worktrees and branches are the primary unit; session metadata is secondary evidence, never a claim that stale history is live work.

Loading…

`; + let fleet={workingNow:[],recentActivity:[],history:[]};const e=s=>{const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML};const work=w=>'
Worktree · '+e(w.worktree)+'Branch · '+e(w.branch)+'
';const card=r=>'
'+e(r.project)+''+r.worktrees.length+' worktree'+(r.worktrees.length===1?'':'s')+''+e(r.activity)+'
'+r.worktrees.map(work).join('')+'
Recent history ('+r.sessions.length+')'+(r.sessions.length?r.sessions.map(s=>'
'+e(s.provider)+' session'+e(s.activity)+'
').join(''):'
No session history found.
')+'
';const section=(title,copy,rows,renderer=card)=>'

'+title+' ('+rows.length+')

'+copy+'

'+(rows.length?rows.map(renderer).join(''):'

None.

')+'
';const live=a=>'
'+e(a.agent)+''+e(a.repo)+'Running locally
'+(a.matched?'
Worktree · '+e(a.worktree)+'Branch · '+e(a.branch)+'
':'
Unassigned running agent
')+'
';function render(){const projects=[...fleet.recentActivity,...fleet.history];document.querySelector('#stamp').textContent=fleet.workingNow.length+' running agents · '+projects.length+' repositories';document.querySelector('#notice').textContent=fleet.policy;document.querySelector('#app').innerHTML=section('Working now','Live local agent processes only.',fleet.workingNow,live)+section('Projects','Repositories and their worktrees.',projects)+section('Recent history','Observed session metadata, never a live-process claim.',fleet.recentActivity)+section('History','Older session records.',fleet.history)}async function refresh(){fleet=await fetch('/api/fleet').then(r=>r.json());render()}refresh();setInterval(refresh,5000);`; } export function startFleetWeb(options: { cwd: string; port?: number }): { @@ -365,7 +359,7 @@ export function startFleetWeb(options: { cwd: string; port?: number }): { return Response.json(await buildFleetSnapshot(options.cwd)); } if (pathname === "/" || pathname === "/index.html") { - return new Response(html(), { + return new Response(fleetHtml(), { headers: { "content-type": "text/html; charset=utf-8" }, }); } diff --git a/test/web.test.ts b/test/web.test.ts index 0d50d45..da54309 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -3,6 +3,7 @@ import { runLet } from "../src/run.ts"; import { buildFleetSnapshot, fleetStateForSessions, + fleetHtml, parseLocalAgentProcessLines, } from "../src/web.ts"; @@ -12,6 +13,7 @@ describe("web fleet snapshot", () => { expect(snapshot.source).toBe("let"); expect(snapshot.liveProcessDetection).toBe("local-process"); expect(JSON.stringify(snapshot.workingNow)).not.toContain("/Users/"); + expect(JSON.stringify(snapshot.workingNow)).not.toContain('"cwd"'); const repositories = [...snapshot.recentActivity, ...snapshot.history]; expect(repositories.length).toBeLessThanOrEqual(48); expect(snapshot.policy).toContain("No session transcripts"); @@ -24,6 +26,16 @@ describe("web fleet snapshot", () => { }); }); +test("Fleet API and HTML never expose a process working directory", async () => { + const api = JSON.stringify(await buildFleetSnapshot(process.cwd(), Date.now())); + const page = fleetHtml(); + for (const response of [api, page]) { + expect(response).not.toContain('"cwd"'); + expect(response).not.toContain("/Users/"); + expect(response).not.toContain("…/"); + } +}); + test("stale session history never appears as recent activity", () => { expect( fleetStateForSessions([ From ae12ed3e6e8879a682319adc90aa0e00895c2b85 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 10:53:47 -0600 Subject: [PATCH 05/17] fix(fleet): improve agent activity control room --- specs/web/web.spec.md | 4 + src/web.ts | 251 ++++++++++++++++++++++++++++++++++++++---- test/security.test.ts | 2 +- test/web.test.ts | 38 ++++++- 4 files changed, 272 insertions(+), 23 deletions(-) diff --git a/specs/web/web.spec.md b/specs/web/web.spec.md index 7ae3506..eb5f837 100644 --- a/specs/web/web.spec.md +++ b/specs/web/web.spec.md @@ -32,6 +32,8 @@ workflow engine or a remote-control surface. | `FleetRepository` | Repository-first collection of worktrees and secondary session evidence. | | `LiveAgent` | Whitelisted local CLI agent with an internal current working directory. | | `WorkingAgent` | Sanitized live agent record matched to a repo/worktree or marked unassigned. | +| `FleetAgentActivity` | Safe agent-focused status derived from a local process or session metadata. | +| `selectAgentWorkContext` | Prefer a bounded child verifier worktree over its agent parent process context. | | `parseLocalAgentProcessLines` | Parse only whitelisted agent CLI processes and resolve their cwd internally. | | `FleetSnapshot` | Sanitized rows, bounded session metadata, refresh interval, and policy. | @@ -48,6 +50,8 @@ workflow engine or a remote-control surface. 8. Session-only records are bounded and include metadata only. 9. The normal CLI exits after ordinary commands, but remains alive while `web` owns the local server. +10. The agent view distinguishes a process-backed `Working now` status from recent or historical session metadata; no session record is represented as a running process. +11. The process probe may inspect a bounded descendant tree internally to classify an allowlisted operation label, but it never exposes command text, arguments, process IDs, or working-directory paths. ## Behavioral Examples diff --git a/src/web.ts b/src/web.ts index dfc66e6..9546e02 100644 --- a/src/web.ts +++ b/src/web.ts @@ -22,6 +22,7 @@ export type FleetSession = { export type LiveAgent = { agent: "Grok" | "Claude" | "Codex" | "Cursor"; cwd: string; + operation?: "Verifying a Spec Sync change" | "Working in project"; }; export type WorkingAgent = Omit & { @@ -31,6 +32,17 @@ export type WorkingAgent = Omit & { matched: boolean; }; +export type FleetAgentActivity = { + agent: LiveAgent["agent"]; + status: "working" | "recent" | "history"; + operation: string; + project: string | null; + worktree: string | null; + branch: string | null; + evidence: "Local process" | "Session metadata"; + lastAction: string; +}; + export type FleetWorktree = { provider: string; worktree: string; @@ -56,6 +68,7 @@ export type FleetSnapshot = { history: FleetRepository[]; unmatchedSessions: FleetSession[]; workingNow: WorkingAgent[]; + agents: FleetAgentActivity[]; liveProcessDetection: "local-process"; policy: string; }; @@ -145,28 +158,94 @@ export function parseLocalAgentProcessLines( .slice(0, 20); } +export function selectAgentWorkContext( + rootCwd: string, + descendants: Array<{ command: string; cwd: string | null }>, +): Pick { + const verifier = descendants.findLast( + (child) => + child.cwd !== null && + /\bspecsync\b[\s\S]*\bchange\s+check\b/i.test(child.command), + ); + if (verifier?.cwd) { + return { + cwd: verifier.cwd, + operation: "Verifying a Spec Sync change", + }; + } + return { cwd: rootCwd, operation: "Working in project" }; +} + function readLiveAgents(): LiveAgent[] { - const processList = Bun.spawnSync({ cmd: ["ps", "-axo", "pid=,comm="] }); + const processList = Bun.spawnSync({ + cmd: ["ps", "-axo", "pid=,ppid=,comm=,command="], + }); if (processList.exitCode !== 0) { return []; } - return parseLocalAgentProcessLines( - new TextDecoder().decode(processList.stdout), - (pid) => { - const cwd = Bun.spawnSync({ - cmd: ["lsof", "-a", "-p", pid, "-d", "cwd", "-Fn"], - }); - const line = new TextDecoder() - .decode(cwd.stdout) - .split("\n") - .find((item) => item.startsWith("n")); - return line ? line.slice(1) : null; - }, - (cwd) => - Bun.spawnSync({ - cmd: ["git", "-C", cwd, "rev-parse", "--is-inside-work-tree"], - }).exitCode === 0, - ); + const providers: Record = { + grok: "Grok", + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + }; + const rows = new TextDecoder() + .decode(processList.stdout) + .split("\n") + .flatMap((line) => { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/); + return match?.[1] && match[2] && match[3] && match[4] + ? [ + { + pid: match[1], + parentPid: match[2], + executable: match[3], + command: match[4], + }, + ] + : []; + }); + const cwdForPid = (pid: string): string | null => { + const cwd = Bun.spawnSync({ + cmd: ["lsof", "-a", "-p", pid, "-d", "cwd", "-Fn"], + }); + const line = new TextDecoder() + .decode(cwd.stdout) + .split("\n") + .find((item) => item.startsWith("n")); + return line ? line.slice(1) : null; + }; + const isProjectCwd = (cwd: string): boolean => + Bun.spawnSync({ + cmd: ["git", "-C", cwd, "rev-parse", "--is-inside-work-tree"], + }).exitCode === 0; + return rows + .flatMap((row) => { + const agent = providers[basename(row.executable).toLowerCase()]; + const rootCwd = agent ? cwdForPid(row.pid) : null; + if (!agent || !rootCwd) { + return []; + } + if ((agent === "Codex" || agent === "Cursor") && !isProjectCwd(rootCwd)) { + return []; + } + const descendants: Array<{ command: string; cwd: string | null }> = []; + const pending = [row.pid]; + while (pending.length > 0 && descendants.length < 64) { + const parentPid = pending.shift(); + for (const child of rows.filter( + (candidate) => candidate.parentPid === parentPid, + )) { + pending.push(child.pid); + descendants.push({ + command: child.command, + cwd: cwdForPid(child.pid), + }); + } + } + return [{ agent, ...selectAgentWorkContext(rootCwd, descendants) }]; + }) + .slice(0, 20); } function gitLocation( @@ -197,6 +276,27 @@ function sessionView(card: IndexCard, now: number): FleetSession { }; } +function agentForHost(host: string): LiveAgent["agent"] | null { + const normalized = host.toLowerCase(); + if (normalized.includes("grok")) { + return "Grok"; + } + if (normalized.includes("claude")) { + return "Claude"; + } + if (normalized.includes("codex")) { + return "Codex"; + } + if (normalized.includes("cursor")) { + return "Cursor"; + } + return null; +} + +function sessionRank(freshness: FleetFreshness): number { + return { live: 0, recent: 1, stale: 2, unknown: 3 }[freshness]; +} + export function fleetStateForSessions(sessions: FleetSession[]): FleetState { if ( sessions.some( @@ -209,6 +309,41 @@ export function fleetStateForSessions(sessions: FleetSession[]): FleetState { return "history"; } +function labelCollidingProjects( + repositories: Map, + worktreeDetails: Map< + string, + { repo: string; worktree: string; branch: string } + >, + worktreeRepoByPath: Map, +): void { + const keysByProject = new Map(); + for (const [key, repository] of repositories) { + const keys = keysByProject.get(repository.project) ?? []; + keys.push(key); + keysByProject.set(repository.project, keys); + } + for (const keys of keysByProject.values()) { + if (keys.length < 2) { + continue; + } + keys.sort(); + for (const [index, key] of keys.entries()) { + const repository = repositories.get(key); + if (repository) { + repository.project = `${repository.project} · ${index + 1}`; + } + } + } + for (const [path, details] of worktreeDetails) { + const key = worktreeRepoByPath.get(path); + const repository = key ? repositories.get(key) : undefined; + if (repository) { + worktreeDetails.set(path, { ...details, repo: repository.project }); + } + } +} + /** Build a bounded, metadata-only snapshot directly from Let catalog APIs. */ export async function buildFleetSnapshot( cwd: string, @@ -279,18 +414,81 @@ export async function buildFleetSnapshot( repositories.get(key)?.sessions.push(view); } + labelCollidingProjects(repositories, worktreeDetails, worktreeRepoByPath); + + const agentsByName = new Map(); + for (const session of sessions.items) { + const agent = agentForHost(session.host); + if (!agent) { + continue; + } + const key = session.repo_root + ? repositories.has(session.repo_root) + ? session.repo_root + : worktreeRepoByPath.get(session.repo_root) + : undefined; + const worktree = session.repo_root + ? worktreeDetails.get(session.repo_root) + : undefined; + const repository = key ? repositories.get(key) : undefined; + const view = sessionView(session, now); + const candidate: FleetAgentActivity = { + agent, + status: + view.freshness === "live" || view.freshness === "recent" + ? "recent" + : "history", + operation: + view.freshness === "stale" + ? "Previous session activity" + : "Recent session activity", + project: worktree?.repo ?? repository?.project ?? null, + worktree: worktree?.worktree ?? null, + branch: worktree?.branch ?? null, + evidence: "Session metadata", + lastAction: view.activity, + }; + const existing = agentsByName.get(agent); + if ( + !existing || + sessionRank(view.freshness) < + sessionRank(existing.status === "recent" ? "recent" : "stale") + ) { + agentsByName.set(agent, candidate); + } + } + const workingNow = readLiveAgents().map((agent) => { const match = [...worktreeDetails.entries()].find( ([path]) => agent.cwd === path || agent.cwd.startsWith(`${path}/`), ); const details = match?.[1] ?? gitLocation(agent.cwd); - return { + const working: WorkingAgent = { agent: agent.agent, repo: details?.repo ?? "Unassigned running agent", worktree: details?.worktree ?? null, branch: details?.branch ?? null, matched: details !== undefined, - } satisfies WorkingAgent; + operation: agent.operation, + }; + agentsByName.set(agent.agent, { + agent: agent.agent, + status: "working", + operation: agent.operation ?? "Working in project", + project: details?.repo ?? null, + worktree: details?.worktree ?? null, + branch: details?.branch ?? null, + evidence: "Local process", + lastAction: "Now", + }); + return working; + }); + const agents = [...agentsByName.values()].sort((left, right) => { + const order = { working: 0, recent: 1, history: 2 }; + return ( + order[left.status] - order[right.status] || + left.agent.localeCompare(right.agent) + ); }); const recentActivity: FleetRepository[] = []; @@ -333,6 +531,7 @@ export async function buildFleetSnapshot( .sort((left, right) => left.activity.localeCompare(right.activity)) .slice(0, 12), workingNow, + agents, liveProcessDetection: "local-process", policy: "Read-only local index. No session transcripts, paths, secrets, terminal output, or agent controls are exposed.", @@ -340,6 +539,18 @@ export async function buildFleetSnapshot( } export function fleetHtml(): string { + return `Let Fleet
Let / local observatory

Fleet activity

Who is working, where they are working, and what changed most recently.

Loading…

`; +} + +function _previousFleetHtml(): string { + return `Let Fleet
Let / local observatory

Fleet activity

See who is working first, then open a project only when you need its worktrees and history.

Loading…

`; +} + +function _legacyFleetHtml(): string { return `Let Fleet
Let / local observatory

Fleet activity

Repositories first. Worktrees and branches are the primary unit; session metadata is secondary evidence, never a claim that stale history is live work.

Loading…

`; diff --git a/test/security.test.ts b/test/security.test.ts index bef2d7f..13e1890 100644 --- a/test/security.test.ts +++ b/test/security.test.ts @@ -98,7 +98,7 @@ describe("security: path_only and secret refusal", () => { const root = tempDir("big"); const big = join(root, "BIG.md"); // Just over open preview; use show with small file still ok - writeFileSync(big, "A".repeat(10_000) + "TAIL"); + writeFileSync(big, `${"A".repeat(10_000)}TAIL`); const ctx = { ...buildScanContext({ cwd: root }), repoRoot: root, diff --git a/test/web.test.ts b/test/web.test.ts index da54309..9be3503 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -2,9 +2,10 @@ import { describe, expect, test } from "bun:test"; import { runLet } from "../src/run.ts"; import { buildFleetSnapshot, - fleetStateForSessions, fleetHtml, + fleetStateForSessions, parseLocalAgentProcessLines, + selectAgentWorkContext, } from "../src/web.ts"; describe("web fleet snapshot", () => { @@ -27,15 +28,48 @@ describe("web fleet snapshot", () => { }); test("Fleet API and HTML never expose a process working directory", async () => { - const api = JSON.stringify(await buildFleetSnapshot(process.cwd(), Date.now())); + const api = JSON.stringify( + await buildFleetSnapshot(process.cwd(), Date.now()), + ); const page = fleetHtml(); for (const response of [api, page]) { expect(response).not.toContain('"cwd"'); expect(response).not.toContain("/Users/"); expect(response).not.toContain("…/"); + expect(response).not.toContain('"argv"'); + expect(response).not.toContain('"pid"'); } }); +test("Fleet page starts by agent and keeps project worktrees collapsed", () => { + const page = fleetHtml(); + expect(page).toContain('data-view="agents"'); + expect(page).toContain("By agent"); + expect(page).toContain("By project"); + expect(page).toContain("Unassigned running agent"); + expect(page).toContain('
'); + expect(page).not.toContain('
{ + expect( + selectAgentWorkContext("/work/spec-sync", [ + { command: "grok --continue", cwd: "/work/spec-sync" }, + { + command: "specsync change check CHG-0068", + cwd: "/work/spec-sync/.claude/worktrees/fix-sandbox-14-16", + }, + ]), + ).toEqual({ + cwd: "/work/spec-sync/.claude/worktrees/fix-sandbox-14-16", + operation: "Verifying a Spec Sync change", + }); +}); + test("stale session history never appears as recent activity", () => { expect( fleetStateForSessions([ From ebaa8767943f6839cd709c1ea6cd334df0b741bd Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 11:01:23 -0600 Subject: [PATCH 06/17] feat(fleet): add local agent supervisor details --- specs/web/web.spec.md | 7 ++- src/web.ts | 129 +++++++++++++++++++++++++++++++++++++++++- test/web.test.ts | 22 ++++--- 3 files changed, 147 insertions(+), 11 deletions(-) diff --git a/specs/web/web.spec.md b/specs/web/web.spec.md index eb5f837..b053422 100644 --- a/specs/web/web.spec.md +++ b/specs/web/web.spec.md @@ -34,6 +34,7 @@ workflow engine or a remote-control surface. | `WorkingAgent` | Sanitized live agent record matched to a repo/worktree or marked unassigned. | | `FleetAgentActivity` | Safe agent-focused status derived from a local process or session metadata. | | `selectAgentWorkContext` | Prefer a bounded child verifier worktree over its agent parent process context. | +| `redactLocalDetail` | Redact known token, credential, and environment-value patterns before local display. | | `parseLocalAgentProcessLines` | Parse only whitelisted agent CLI processes and resolve their cwd internally. | | `FleetSnapshot` | Sanitized rows, bounded session metadata, refresh interval, and policy. | @@ -41,8 +42,7 @@ workflow engine or a remote-control surface. 1. `let web` binds only to `127.0.0.1` and defaults to port `8731`. 2. Fleet data is derived from Let catalog APIs, not parsed terminal output. -3. Responses never include filesystem paths, session bodies, terminal output, - secrets, browser shell endpoints, or agent-control actions. +3. Responses never include raw secrets, browser shell endpoints, or agent-control actions. The localhost supervisor may include redacted session bodies, command output, and filesystem context for active-agent supervision. 4. Repository cards group worktrees first; sessions remain secondary evidence. 5. Working now comes only from a local process probe with exact agent CLI executable allowlist and cwd resolution. Session timestamps never prove a process is running. 6. Unmapped live processes are surfaced as Unassigned running agent and agent type only; their current working directories never leave the process probe. @@ -51,7 +51,8 @@ workflow engine or a remote-control surface. 9. The normal CLI exits after ordinary commands, but remains alive while `web` owns the local server. 10. The agent view distinguishes a process-backed `Working now` status from recent or historical session metadata; no session record is represented as a running process. -11. The process probe may inspect a bounded descendant tree internally to classify an allowlisted operation label, but it never exposes command text, arguments, process IDs, or working-directory paths. +11. The process probe may inspect a bounded descendant tree internally to classify an allowlisted operation label; the localhost supervisor may show its redacted command and resolved project context, but never exposes process IDs. +12. The localhost supervisor may expose redacted command, prompt/status, and recent activity detail only after applying `redactLocalDetail`; unavailable detail is labeled instead of inferred. ## Behavioral Examples diff --git a/src/web.ts b/src/web.ts index 9546e02..e628cb7 100644 --- a/src/web.ts +++ b/src/web.ts @@ -4,6 +4,7 @@ * filesystem paths, shell endpoints, or agent-control actions are exposed. */ +import { existsSync, readFileSync, statSync } from "node:fs"; import { basename } from "node:path"; import { buildScanContext } from "./catalog/context-builder.ts"; import { findAssets } from "./catalog/find.ts"; @@ -23,6 +24,8 @@ export type LiveAgent = { agent: "Grok" | "Claude" | "Codex" | "Cursor"; cwd: string; operation?: "Verifying a Spec Sync change" | "Working in project"; + command?: string; + startedAt?: string; }; export type WorkingAgent = Omit & { @@ -41,6 +44,11 @@ export type FleetAgentActivity = { branch: string | null; evidence: "Local process" | "Session metadata"; lastAction: string; + command: string | null; + startedAt: string | null; + latestMessage: string | null; + recentActivity: string[]; + detailAvailability: "available" | "unavailable"; }; export type FleetWorktree = { @@ -243,7 +251,26 @@ function readLiveAgents(): LiveAgent[] { }); } } - return [{ agent, ...selectAgentWorkContext(rootCwd, descendants) }]; + const context = selectAgentWorkContext(rootCwd, descendants); + const verifier = descendants.findLast( + (child) => + child.cwd === context.cwd && + /\bspecsync\b[\s\S]*\bchange\s+check\b/i.test(child.command), + ); + const started = Bun.spawnSync({ + cmd: ["ps", "-p", row.pid, "-o", "lstart="], + }); + return [ + { + agent, + ...context, + command: verifier?.command ?? row.command, + startedAt: + started.exitCode === 0 + ? new TextDecoder().decode(started.stdout).trim() || undefined + : undefined, + }, + ]; }) .slice(0, 20); } @@ -297,6 +324,85 @@ function sessionRank(freshness: FleetFreshness): number { return { live: 0, recent: 1, stale: 2, unknown: 3 }[freshness]; } +export function redactLocalDetail(value: string): string { + return value + .replace( + /\b(?:ghp|github_pat|sk|xox[baprs])-?[A-Za-z0-9_-]{16,}\b/g, + "[REDACTED]", + ) + .replace( + /\b(password|passwd|secret|token|api[_-]?key|authorization)\s*[:=]\s*([^\s,}\]]+)/gi, + "$1=[REDACTED]", + ) + .replace( + /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, + "[REDACTED CERTIFICATE]", + ); +} + +function sessionText(value: unknown): string[] { + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.flatMap(sessionText); + } + if (value && typeof value === "object") { + return Object.entries(value as Record).flatMap( + ([key, child]) => + /content|text|message|prompt|status|summary/i.test(key) + ? sessionText(child) + : [], + ); + } + return []; +} + +function sessionDetail(card: IndexCard): { + latestMessage: string | null; + recentActivity: string[]; + detailAvailability: "available" | "unavailable"; +} { + if ( + card.meta?.path_only === true || + !existsSync(card.path) || + !statSync(card.path).isFile() + ) { + return { + latestMessage: null, + recentActivity: [], + detailAvailability: "unavailable", + }; + } + try { + const raw = readFileSync(card.path, "utf8").slice(-48_000); + const messages = raw + .split("\n") + .flatMap((line) => { + try { + return sessionText(JSON.parse(line)); + } catch { + return line.trim() ? [line] : []; + } + }) + .map((message) => redactLocalDetail(message.replace(/\s+/g, " ").trim())) + .filter(Boolean) + .map((message) => message.slice(0, 500)) + .slice(-8); + return { + latestMessage: messages.at(-1) ?? null, + recentActivity: messages, + detailAvailability: messages.length > 0 ? "available" : "unavailable", + }; + } catch { + return { + latestMessage: null, + recentActivity: [], + detailAvailability: "unavailable", + }; + } +} + export function fleetStateForSessions(sessions: FleetSession[]): FleetState { if ( sessions.some( @@ -432,6 +538,7 @@ export async function buildFleetSnapshot( : undefined; const repository = key ? repositories.get(key) : undefined; const view = sessionView(session, now); + const detail = sessionDetail(session); const candidate: FleetAgentActivity = { agent, status: @@ -447,6 +554,11 @@ export async function buildFleetSnapshot( branch: worktree?.branch ?? null, evidence: "Session metadata", lastAction: view.activity, + command: null, + startedAt: null, + latestMessage: detail.latestMessage, + recentActivity: detail.recentActivity, + detailAvailability: detail.detailAvailability, }; const existing = agentsByName.get(agent); if ( @@ -480,6 +592,11 @@ export async function buildFleetSnapshot( branch: details?.branch ?? null, evidence: "Local process", lastAction: "Now", + command: agent.command ? redactLocalDetail(agent.command) : null, + startedAt: agent.startedAt ?? null, + latestMessage: null, + recentActivity: [], + detailAvailability: agent.command ? "available" : "unavailable", }); return working; }); @@ -539,11 +656,21 @@ export async function buildFleetSnapshot( } export function fleetHtml(): string { + const supervisor = fleetSupervisorHtml(); + if (supervisor.length > 0) { + return supervisor; + } return `Let Fleet
Let / local observatory

Fleet activity

Who is working, where they are working, and what changed most recently.

Loading…

`; } +function fleetSupervisorHtml(): string { + return `Let Fleet
Let / local supervisor

Fleet activity

Loading…

`; +} + function _previousFleetHtml(): string { return `Let Fleet
Let / local observatory

Fleet activity

See who is working first, then open a project only when you need its worktrees and history.

Loading…

`; + let fleet={agents:[],recentActivity:[],history:[]},view='agents';const e=s=>{const d=document.createElement('div');d.textContent=String(s??'Unavailable');return d.innerHTML};const human=s=>String(s||'Unassigned running agent').replace(/[-_]+/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase());const plural=(n,w)=>n+' '+w+(n===1?'':'s');const context=a=>{const project=a.project?'Project · '+e(human(a.project))+'':'';const worktree=a.worktree&&String(a.worktree).toLowerCase()!==String(a.project||'').toLowerCase()?'Worktree · '+e(a.worktree)+'':'';const branch=a.branch?'Branch · '+e(a.branch)+'':'';return project+worktree+branch||'Context unavailable'};const agent=a=>{const contextText=[a.project,a.worktree&&String(a.worktree).toLowerCase()!==String(a.project||'').toLowerCase()?a.worktree:null,a.branch].filter(Boolean).join(' · ');const details='
Show supervision details
Task'+e(a.operation)+'
Context'+e(contextText||'Unavailable')+'
Current command'+e(a.command||'Unavailable')+'
Started'+e(a.startedAt||'Unavailable')+'
Latest prompt or update'+e(a.latestMessage||'Unavailable')+'
Detail source'+e(a.evidence)+' · '+e(a.detailAvailability)+'
'+(a.recentActivity.length?'

Recent output

'+a.recentActivity.map(e).join('\\n\\n')+'
':'

Recent output unavailable.

')+'
';return '
'+e(a.agent)+' · '+e(a.status==='working'?'Working now':a.status==='recent'?'Recent activity':'Earlier activity')+'
'+e(a.operation)+'Last update · '+e(a.lastAction)+'
'+context(a)+'
'+details+'
'};const project=r=>'
'+e(human(r.project))+' · '+e(plural(r.worktrees.length,'worktree'))+''+r.worktrees.map(w=>'
'+e(w.worktree)+''+e(w.branch)+'
').join('')+'
';function render(){const projects=[...fleet.recentActivity,...fleet.history];document.querySelector('#stamp').textContent=plural(fleet.agents.filter(a=>a.status==='working').length,'agent')+' working · '+plural(projects.length,'project');document.querySelector('#notice').textContent=fleet.policy;document.querySelector('#app').innerHTML=view==='agents'?fleet.agents.map(agent).join('')||'

No local agent or session metadata is available.

':projects.map(project).join('')||'

No projects are available.

';document.querySelectorAll('[data-view]').forEach(button=>button.addEventListener('click',()=>{view=button.dataset.view;document.querySelectorAll('[data-view]').forEach(item=>item.setAttribute('aria-pressed',String(item.dataset.view===view)));render()}))}async function refresh(){fleet=await fetch('/api/fleet').then(r=>r.json());render()}refresh();setInterval(refresh,5000);`; } function _previousFleetHtml(): string { diff --git a/test/web.test.ts b/test/web.test.ts index e92a2d6..0db4ac6 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { runLet } from "../src/run.ts"; import { buildFleetSnapshot, + fleetContextLabels, fleetHtml, fleetStateForSessions, parseLocalAgentProcessLines, @@ -17,7 +18,7 @@ describe("web fleet snapshot", () => { expect(JSON.stringify(snapshot.workingNow)).not.toContain('"cwd"'); const repositories = [...snapshot.recentActivity, ...snapshot.history]; expect(repositories.length).toBeLessThanOrEqual(48); - expect(snapshot.policy).toContain("No session transcripts"); + expect(snapshot.policy).toContain("redacted local task context"); for (const repository of repositories) { expect(JSON.stringify(repository)).not.toContain("/Users/"); expect(repository.skills.length).toBeLessThanOrEqual(8); @@ -37,7 +38,19 @@ test("Fleet API and HTML expose local supervision details without internal CWD k expect(response).not.toContain('"pid"'); } expect(page).toContain("Local-only supervisor view"); - expect(page).toContain("Latest prompt or status"); + expect(page).toContain("Latest prompt or update"); + expect(page).toContain("Recent output"); + expect(page).toContain("Show supervision details"); +}); + +test("agent context keeps a shared project and worktree from repeating", () => { + expect( + fleetContextLabels({ + project: "let", + worktree: "let", + branch: "feat/fleet", + }), + ).toEqual(["Project: let", "Branch: feat/fleet"]); }); test("local supervisor details redact known secret-shaped values", () => { From 2d72d87f3df017b7d63c43d11b792717500b2960 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 11:20:37 -0600 Subject: [PATCH 09/17] feat(gemini): discover Antigravity sessions --- specs/catalog/catalog.spec.md | 1 + src/catalog/gemini.ts | 48 +++++++++++++++++++++++++++++++++++ src/web.ts | 47 ++++++++++++++++++++++++++-------- test/hosts.test.ts | 24 ++++++++++++++++-- 4 files changed, 108 insertions(+), 12 deletions(-) diff --git a/specs/catalog/catalog.spec.md b/specs/catalog/catalog.spec.md index fea522f..06a7647 100644 --- a/specs/catalog/catalog.spec.md +++ b/specs/catalog/catalog.spec.md @@ -94,6 +94,7 @@ Context packs never include session paths or bodies. | `phraseHits` | Trigger phrase match (all words required). | | `findGeminiInstructions` | GEMINI.md + ~/.gemini global. | | `findGeminiSessions` | Path-only history/projects. | +| `antigravitySessionCards` | Discover local Antigravity CLI transcript metadata without inventing a project binding. | | `findGeminiMemory` | Antigravity brain/knowledge path-only. | | `findKimiSessions` | Path-only workspaces/sessions via workspaces.json. | | `findKimiMemory` | Path-only user-history. | diff --git a/src/catalog/gemini.ts b/src/catalog/gemini.ts index ae0ea75..0d473bc 100644 --- a/src/catalog/gemini.ts +++ b/src/catalog/gemini.ts @@ -11,6 +11,48 @@ import { geminiHome, projectGeminiDir } from "../paths.ts"; import { instructionId, pathCardId } from "./ids.ts"; import type { IndexCard } from "./types.ts"; +export function antigravitySessionCards( + brainRoot: string, + limit: number, +): IndexCard[] { + if (!isDirectory(brainRoot)) { + return []; + } + try { + return readdirSync(brainRoot) + .slice(0, limit) + .flatMap((sessionId) => { + const transcript = join( + brainRoot, + sessionId, + ".system_generated", + "logs", + "transcript_full.jsonl", + ); + if (!pathExists(transcript)) { + return []; + } + return [ + { + id: pathCardId("sessions", transcript), + kind: "sessions" as const, + host: "gemini" as const, + name: `Antigravity session ${sessionId.slice(0, 8)}`, + path: transcript, + scope: "user" as const, + mtime_ms: mtimeMs(transcript), + meta: { + source: "antigravity-cli", + detail_available: true, + }, + }, + ]; + }); + } catch { + return []; + } +} + function readProjectsMap(): Map { // path -> short name const map = new Map(); @@ -198,6 +240,12 @@ export function findGeminiSessions(ctx: ScanContext): IndexCard[] { }, }); } + cards.push( + ...antigravitySessionCards( + join(home, "antigravity-cli", "brain"), + ctx.policy.maxEntriesPerRoot, + ), + ); } return cards; diff --git a/src/web.ts b/src/web.ts index a95ff62..a0073b4 100644 --- a/src/web.ts +++ b/src/web.ts @@ -21,9 +21,12 @@ export type FleetSession = { }; export type LiveAgent = { - agent: "Grok" | "Claude" | "Codex" | "Cursor"; + agent: "Grok" | "Claude" | "Codex" | "Cursor" | "Gemini / Antigravity"; cwd: string; - operation?: "Verifying a Spec Sync change" | "Working in project"; + operation?: + | "Verifying a Spec Sync change" + | "Working in project" + | "Antigravity session details unavailable"; command?: string; startedAt?: string; }; @@ -142,6 +145,9 @@ export function parseLocalAgentProcessLines( claude: "Claude", codex: "Codex", cursor: "Cursor", + gemini: "Gemini / Antigravity", + antigravity: "Gemini / Antigravity", + "antigravity-cli": "Gemini / Antigravity", }; return output .split("\n") @@ -232,9 +238,20 @@ function readLiveAgents(): LiveAgent[] { .flatMap((row) => { const agent = providers[basename(row.executable).toLowerCase()]; const rootCwd = agent ? cwdForPid(row.pid) : null; - if (!agent || !rootCwd) { + if (!agent) { return []; } + if (!rootCwd) { + return agent === "Gemini / Antigravity" + ? [ + { + agent, + cwd: "", + operation: "Antigravity session details unavailable" as const, + }, + ] + : []; + } if ( (agent === "Codex" || agent === "Cursor") && !isProjectCwd(rootCwd) @@ -324,6 +341,9 @@ function agentForHost(host: string): LiveAgent["agent"] | null { if (normalized.includes("cursor")) { return "Cursor"; } + if (normalized.includes("gemini") || normalized.includes("antigravity")) { + return "Gemini / Antigravity"; + } return null; } @@ -613,20 +633,27 @@ export async function buildFleetSnapshot( matched: details !== undefined, operation: agent.operation, }; + const prior = agentsByName.get(agent.agent); agentsByName.set(agent.agent, { agent: agent.agent, status: "working", - operation: agent.operation ?? "Working in project", - project: details?.repo ?? null, - worktree: details?.worktree ?? null, - branch: details?.branch ?? null, + operation: + agent.agent === "Gemini / Antigravity" && prior + ? "Antigravity session activity" + : (agent.operation ?? "Working in project"), + project: details?.repo ?? prior?.project ?? null, + worktree: details?.worktree ?? prior?.worktree ?? null, + branch: details?.branch ?? prior?.branch ?? null, evidence: "Local process", lastAction: "Now", command: agent.command ? redactLocalDetail(agent.command) : null, startedAt: agent.startedAt ?? null, - latestMessage: null, - recentActivity: [], - detailAvailability: agent.command ? "available" : "unavailable", + latestMessage: prior?.latestMessage ?? null, + recentActivity: prior?.recentActivity ?? [], + detailAvailability: + agent.command || prior?.detailAvailability === "available" + ? "available" + : "unavailable", }); return working; }); diff --git a/test/hosts.test.ts b/test/hosts.test.ts index 2aef52e..b054c09 100644 --- a/test/hosts.test.ts +++ b/test/hosts.test.ts @@ -1,11 +1,31 @@ import { describe, expect, test } from "bun:test"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; import { buildScanContext } from "../src/catalog/context-builder.ts"; import { findAssets } from "../src/catalog/find.ts"; +import { antigravitySessionCards } from "../src/catalog/gemini.ts"; import { geminiHome, homeDir, kimiHome } from "../src/paths.ts"; describe("gemini adapter", () => { + test("Antigravity transcript metadata is discoverable without guessing a project", () => { + const root = mkdtempSync(join(tmpdir(), "let-antigravity-")); + const transcript = join( + root, + "session-12345678", + ".system_generated", + "logs", + "transcript_full.jsonl", + ); + mkdirSync(dirname(transcript), { recursive: true }); + writeFileSync(transcript, '{"status":"DONE"}\n'); + const cards = antigravitySessionCards(root, 10); + expect(cards).toHaveLength(1); + expect(cards[0]?.name).toBe("Antigravity session session-"); + expect(cards[0]?.meta?.detail_available).toBe(true); + expect(cards[0]?.repo_root).toBeUndefined(); + }); + test("doctor-level roots exist on this machine or skip gracefully", async () => { const ctx = buildScanContext({ cwd: process.cwd(), From 13dad5d4b0b3f710e0360cfd16a7cc6fa9eb7575 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 11:26:23 -0600 Subject: [PATCH 10/17] feat: improve: make Fleet a Let control room --- README.md | 9 ++-- specs/web/web.spec.md | 12 +++++ src/catalog/codex.ts | 23 ++++++++ src/catalog/show.ts | 13 ++++- src/fleet-adapters.ts | 111 ++++++++++++++++++++++++++++++++++++++ src/fleet-brand.ts | 47 ++++++++++++++++ src/web.ts | 122 +++++++++++++++++------------------------- test/web.test.ts | 51 ++++++++++++++++-- 8 files changed, 307 insertions(+), 81 deletions(-) create mode 100644 src/fleet-adapters.ts create mode 100644 src/fleet-brand.ts diff --git a/README.md b/README.md index f121091..4de17a5 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,11 @@ let web # or: fledge let web ``` -Starts a local-only dashboard at `http://127.0.0.1:8731`. It is a visual view -of Let's own federated index: worktrees, providers, bounded session activity, -instructions, and skills. It does not expose transcripts, filesystem paths, -terminal output, or agent controls. +Starts a local-only dashboard at `http://127.0.0.1:8731`. Fleet is a Let-native +control room built from the federated index: it shows each local agent's status, +task or latest prompt, activity, timestamps, and project context. Expanded +cards may show bounded, redacted local session text from an already-indexed +session file. It never provides agent controls or shell execution. ### Standalone (any host: Claude, Codex, Cursor, Kimi, …) diff --git a/specs/web/web.spec.md b/specs/web/web.spec.md index ee0f1e5..11b3195 100644 --- a/specs/web/web.spec.md +++ b/specs/web/web.spec.md @@ -4,6 +4,8 @@ version: 1 status: active files: - src/web.ts + - src/fleet-adapters.ts + - src/fleet-brand.ts db_tables: [] depends_on: - catalog @@ -38,6 +40,14 @@ workflow engine or a remote-control surface. | `fleetContextLabels` | Build concise context labels without repeating a project as its worktree. | | `parseLocalAgentProcessLines` | Parse only whitelisted agent CLI processes and resolve their cwd internally. | | `FleetSnapshot` | Sanitized rows, bounded session metadata, refresh interval, and policy. | +| `FLEET_SESSION_ADAPTERS` | Let-native presentation adapters for Claude, Codex, Grok, Gemini, and Antigravity session cards. | +| `fleetAdapterFor` | Resolve the local Fleet presentation adapter for an indexed session card. | +| `fleetSessionDetail` | Extract bounded, redacted display text from an already-indexed local session file. | +| `FleetProvider` | Supported Fleet session-provider name. | +| `FleetSessionDetail` | Bounded local prompt and output detail for an indexed session file. | +| `FleetSessionAdapter` | Host-card matching contract for Fleet's presentation adapters. | +| `CORVID_TOKENS_CSS` | Local serving copy of the CorvidLabs design-system token stylesheet. | +| `CORVID_THEME_JS` | Local serving copy of the standard CorvidLabs theme controller. | ## Invariants @@ -55,6 +65,8 @@ workflow engine or a remote-control surface. 11. The process probe may inspect a bounded descendant tree internally to classify an allowlisted operation label; the localhost supervisor may show its redacted command and resolved project context, but never exposes process IDs. 12. The localhost supervisor may expose redacted command, prompt/update, and recent output only after applying `redactLocalDetail`; unavailable detail is labeled instead of inferred. 13. Agent context must not repeat a worktree label when it is the same value as its project label. +14. Fleet maps host session cards through explicit presentation adapters; it does not scan host paths independently of Let's catalog. +15. Gemini must be shown through its Gemini or Antigravity adapter when indexed, otherwise Fleet shows an explicit unavailable state. ## Behavioral Examples diff --git a/src/catalog/codex.ts b/src/catalog/codex.ts index ef87a33..12ac101 100644 --- a/src/catalog/codex.ts +++ b/src/catalog/codex.ts @@ -94,6 +94,29 @@ export function findCodexSessions(ctx: ScanContext): IndexCard[] { meta: { source: `codex.${rel}`, level: "child" }, }), ); + // A current Codex session normally lives at year/month/rollout.jsonl. + // Index that final file level as well so local Fleet can show its + // already-indexed, redacted latest prompt alongside a live process. + if (isDirectory(child)) { + for (const session of listChildPaths(child, ctx.policy).slice( + 0, + 50, + )) { + if (!session.endsWith(".jsonl")) { + continue; + } + cards.push( + makeCard({ + kind: "sessions", + host: "codex", + path: session, + scope: "user", + pathOnly: true, + meta: { source: `codex.${rel}`, level: "session" }, + }), + ); + } + } } } else if (name.endsWith(".jsonl")) { cards.push( diff --git a/src/catalog/show.ts b/src/catalog/show.ts index 0bf92e0..bac73ae 100644 --- a/src/catalog/show.ts +++ b/src/catalog/show.ts @@ -2,7 +2,7 @@ * Progressive disclosure: show full body for a card, with security caps. */ -import { basename } from "node:path"; +import { basename, relative } from "node:path"; import { Agent } from "@corvidlabs/agent3md"; import type { ScanContext } from "../adapters/types.ts"; import { LetError } from "../errors.ts"; @@ -126,6 +126,17 @@ export async function resolveCard( return nameHits[0]; } if (nameHits.length > 1) { + // A checkout and its linked worktrees may legitimately contribute the + // same agent.3md plane. When one exact card belongs to the caller's cwd, + // it is the least surprising progressive-disclosure target. + const localHits = nameHits.filter((card) => { + const path = safeRealpath(card.path) ?? card.path; + const rel = relative(ctx.cwd, path); + return rel !== "" && !rel.startsWith("..") && !rel.startsWith("/"); + }); + if (localHits.length === 1 && localHits[0]) { + return localHits[0]; + } throw new LetError( "conflict", `Multiple ${kind} named "${ref}"; pass full id`, diff --git a/src/fleet-adapters.ts b/src/fleet-adapters.ts new file mode 100644 index 0000000..0e442e8 --- /dev/null +++ b/src/fleet-adapters.ts @@ -0,0 +1,111 @@ +/** + * Fleet-only presentation adapters for Let session index cards. + * + * These adapters do not scan the filesystem. Let's catalog remains the source + * of truth; Fleet only maps its indexed session cards into safe local detail. + */ + +import type { IndexCard } from "./catalog/types.ts"; + +export type FleetProvider = + | "Claude" + | "Codex" + | "Grok" + | "Gemini" + | "Antigravity"; + +export type FleetSessionDetail = { + latestMessage: string | null; + recentActivity: string[]; + detailAvailability: "available" | "unavailable"; +}; + +export type FleetSessionAdapter = { + readonly host: IndexCard["host"]; + readonly provider: FleetProvider; + matches(card: IndexCard): boolean; +}; + +function isAntigravity(card: IndexCard): boolean { + return String(card.meta?.source ?? "").startsWith("antigravity"); +} + +export const FLEET_SESSION_ADAPTERS: readonly FleetSessionAdapter[] = [ + { + host: "codex", + provider: "Codex", + matches: (card) => card.host === "codex", + }, + { host: "grok", provider: "Grok", matches: (card) => card.host === "grok" }, + { + host: "claude", + provider: "Claude", + matches: (card) => card.host === "claude", + }, + { + host: "gemini", + provider: "Antigravity", + matches: (card) => card.host === "gemini" && isAntigravity(card), + }, + { + host: "gemini", + provider: "Gemini", + matches: (card) => card.host === "gemini", + }, +]; + +export function fleetAdapterFor(card: IndexCard): FleetSessionAdapter | null { + return ( + FLEET_SESSION_ADAPTERS.find((adapter) => adapter.matches(card)) ?? null + ); +} + +function sessionText(value: unknown): string[] { + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.flatMap(sessionText); + } + if (value && typeof value === "object") { + return Object.entries(value as Record).flatMap( + ([key, child]) => + /content|text|message|prompt|status|summary|output/i.test(key) + ? sessionText(child) + : [], + ); + } + return []; +} + +/** Extract bounded display text from a session file that Let already indexed. */ +export function fleetSessionDetail( + source: string | null, + redact: (value: string) => string, +): FleetSessionDetail { + if (!source) { + return { + latestMessage: null, + recentActivity: [], + detailAvailability: "unavailable", + }; + } + const messages = source + .split("\n") + .flatMap((line) => { + try { + return sessionText(JSON.parse(line)); + } catch { + return line.trim() ? [line] : []; + } + }) + .map((message) => redact(message.replace(/\s+/g, " ").trim())) + .filter(Boolean) + .map((message) => message.slice(0, 500)) + .slice(-8); + return { + latestMessage: messages.at(-1) ?? null, + recentActivity: messages, + detailAvailability: messages.length > 0 ? "available" : "unavailable", + }; +} diff --git a/src/fleet-brand.ts b/src/fleet-brand.ts new file mode 100644 index 0000000..7eece72 --- /dev/null +++ b/src/fleet-brand.ts @@ -0,0 +1,47 @@ +/** CorvidLabs design-system assets, mirrored for Fleet's localhost server. */ + +export const CORVID_TOKENS_CSS = `:root { + color-scheme: light; + --ink: #15181B; --paper: #FAF9F6; --surface: #FFFFFF; --surface-strong: #DCDAD2; + --sheen: #0E6F66; --sheen-strong: #0B5750; --sheen-bright: #45D0BC; --steel: #1E6FA8; + --text-muted: #34383D; --text-faint: #50555B; --sheen-hover: #0B5750; + --ink-70: rgba(21,24,27,.72); --ink-60: rgba(21,24,27,.62); --ink-45: rgba(21,24,27,.45); + --ink-12: rgba(21,24,27,.12); --ink-06: rgba(21,24,27,.06); --hairline: var(--ink-12); + --header-bg: rgba(250,249,246,.92); --danger: #84241E; --warning: #8A5A00; + --success: #2F6B3A; --info: var(--sheen); --iridescence: linear-gradient(90deg,#0E6F66 0%,#1799A3 55%,#1E6FA8 100%); + --font-display: "Schibsted Grotesk", "Helvetica Neue", sans-serif; + --font-mono: "Spline Sans Mono", "SF Mono", monospace; +} +:root[data-theme="dark"] { + color-scheme: dark; + --ink: #F4F3EF; --paper: #131619; --surface: #1B1F23; --surface-strong: #272C31; + --sheen: #45D0BC; --sheen-strong: #45D0BC; --sheen-bright: #45D0BC; + --text-muted: #C8C6BE; --text-faint: #A3A199; --sheen-hover: #63D9C7; + --ink-70: rgba(244,243,239,.78); --ink-60: rgba(244,243,239,.68); --ink-45: rgba(244,243,239,.55); + --ink-12: rgba(244,243,239,.15); --ink-06: rgba(244,243,239,.08); --header-bg: rgba(19,22,25,.92); + --danger: #F08A7D; --warning: #E0B05A; --success: #6FC98A; +} +@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { + color-scheme: dark; + --ink: #F4F3EF; --paper: #131619; --surface: #1B1F23; --surface-strong: #272C31; + --sheen: #45D0BC; --sheen-strong: #45D0BC; --sheen-bright: #45D0BC; + --text-muted: #C8C6BE; --text-faint: #A3A199; --sheen-hover: #63D9C7; + --ink-70: rgba(244,243,239,.78); --ink-60: rgba(244,243,239,.68); --ink-45: rgba(244,243,239,.55); + --ink-12: rgba(244,243,239,.15); --ink-06: rgba(244,243,239,.08); --header-bg: rgba(19,22,25,.92); + --danger: #F08A7D; --warning: #E0B05A; --success: #6FC98A; +} } +`; + +export const CORVID_THEME_JS = `(() => { + "use strict"; + const root = document.documentElement; + const key = "corvid-theme"; + const dark = () => root.dataset.theme === "dark" || (!root.dataset.theme && window.matchMedia("(prefers-color-scheme: dark)").matches); + const saved = new URLSearchParams(location.search).get("theme") || (() => { try { return localStorage.getItem(key); } catch { return null; } })(); + if (saved === "light" || saved === "dark") root.dataset.theme = saved; + document.querySelectorAll("[data-corvid-theme-toggle]").forEach((button) => { + const reflect = () => { button.setAttribute("aria-pressed", String(dark())); button.setAttribute("aria-label", dark() ? "Switch to light theme" : "Switch to dark theme"); }; + button.addEventListener("click", () => { root.dataset.theme = dark() ? "light" : "dark"; try { localStorage.setItem(key, root.dataset.theme); } catch {} reflect(); }); + reflect(); + }); +})();`; diff --git a/src/web.ts b/src/web.ts index a0073b4..03a501f 100644 --- a/src/web.ts +++ b/src/web.ts @@ -9,6 +9,8 @@ import { basename } from "node:path"; import { buildScanContext } from "./catalog/context-builder.ts"; import { findAssets } from "./catalog/find.ts"; import type { IndexCard } from "./catalog/types.ts"; +import { fleetAdapterFor, fleetSessionDetail } from "./fleet-adapters.ts"; +import { CORVID_THEME_JS, CORVID_TOKENS_CSS } from "./fleet-brand.ts"; export type FleetFreshness = "live" | "recent" | "stale" | "unknown"; export type FleetState = "recent" | "history"; @@ -21,7 +23,7 @@ export type FleetSession = { }; export type LiveAgent = { - agent: "Grok" | "Claude" | "Codex" | "Cursor" | "Gemini / Antigravity"; + agent: "Grok" | "Claude" | "Codex" | "Cursor" | "Gemini" | "Antigravity"; cwd: string; operation?: | "Verifying a Spec Sync change" @@ -45,7 +47,7 @@ export type FleetAgentActivity = { project: string | null; worktree: string | null; branch: string | null; - evidence: "Local process" | "Session metadata"; + evidence: "Local process" | "Session metadata" | "Adapter unavailable"; lastAction: string; command: string | null; startedAt: string | null; @@ -145,9 +147,9 @@ export function parseLocalAgentProcessLines( claude: "Claude", codex: "Codex", cursor: "Cursor", - gemini: "Gemini / Antigravity", - antigravity: "Gemini / Antigravity", - "antigravity-cli": "Gemini / Antigravity", + gemini: "Gemini", + antigravity: "Antigravity", + "antigravity-cli": "Antigravity", }; return output .split("\n") @@ -203,6 +205,9 @@ function readLiveAgents(): LiveAgent[] { claude: "Claude", codex: "Codex", cursor: "Cursor", + gemini: "Gemini", + antigravity: "Antigravity", + "antigravity-cli": "Antigravity", }; const rows = new TextDecoder() .decode(processList.stdout) @@ -242,7 +247,7 @@ function readLiveAgents(): LiveAgent[] { return []; } if (!rootCwd) { - return agent === "Gemini / Antigravity" + return agent === "Antigravity" ? [ { agent, @@ -327,26 +332,6 @@ function sessionView(card: IndexCard, now: number): FleetSession { }; } -function agentForHost(host: string): LiveAgent["agent"] | null { - const normalized = host.toLowerCase(); - if (normalized.includes("grok")) { - return "Grok"; - } - if (normalized.includes("claude")) { - return "Claude"; - } - if (normalized.includes("codex")) { - return "Codex"; - } - if (normalized.includes("cursor")) { - return "Cursor"; - } - if (normalized.includes("gemini") || normalized.includes("antigravity")) { - return "Gemini / Antigravity"; - } - return null; -} - function sessionRank(freshness: FleetFreshness): number { return { live: 0, recent: 1, stale: 2, unknown: 3 }[freshness]; } @@ -390,34 +375,12 @@ export function fleetContextLabels( return labels; } -function sessionText(value: unknown): string[] { - if (typeof value === "string") { - return [value]; - } - if (Array.isArray(value)) { - return value.flatMap(sessionText); - } - if (value && typeof value === "object") { - return Object.entries(value as Record).flatMap( - ([key, child]) => - /content|text|message|prompt|status|summary/i.test(key) - ? sessionText(child) - : [], - ); - } - return []; -} - function sessionDetail(card: IndexCard): { latestMessage: string | null; recentActivity: string[]; detailAvailability: "available" | "unavailable"; } { - if ( - card.meta?.path_only === true || - !existsSync(card.path) || - !statSync(card.path).isFile() - ) { + if (!existsSync(card.path) || !statSync(card.path).isFile()) { return { latestMessage: null, recentActivity: [], @@ -425,25 +388,10 @@ function sessionDetail(card: IndexCard): { }; } try { - const raw = readFileSync(card.path, "utf8").slice(-48_000); - const messages = raw - .split("\n") - .flatMap((line) => { - try { - return sessionText(JSON.parse(line)); - } catch { - return line.trim() ? [line] : []; - } - }) - .map((message) => redactLocalDetail(message.replace(/\s+/g, " ").trim())) - .filter(Boolean) - .map((message) => message.slice(0, 500)) - .slice(-8); - return { - latestMessage: messages.at(-1) ?? null, - recentActivity: messages, - detailAvailability: messages.length > 0 ? "available" : "unavailable", - }; + return fleetSessionDetail( + readFileSync(card.path, "utf8").slice(-48_000), + redactLocalDetail, + ); } catch { return { latestMessage: null, @@ -574,10 +522,11 @@ export async function buildFleetSnapshot( const agentsByName = new Map(); for (const session of sessions.items) { - const agent = agentForHost(session.host); - if (!agent) { + const adapter = fleetAdapterFor(session); + if (!adapter) { continue; } + const agent = adapter.provider; const key = session.repo_root ? repositories.has(session.repo_root) ? session.repo_root @@ -638,7 +587,7 @@ export async function buildFleetSnapshot( agent: agent.agent, status: "working", operation: - agent.agent === "Gemini / Antigravity" && prior + agent.agent === "Antigravity" && prior ? "Antigravity session activity" : (agent.operation ?? "Working in project"), project: details?.repo ?? prior?.project ?? null, @@ -657,6 +606,23 @@ export async function buildFleetSnapshot( }); return working; }); + if (!agentsByName.has("Gemini") && !agentsByName.has("Antigravity")) { + agentsByName.set("Gemini", { + agent: "Gemini", + status: "history", + operation: "Gemini and Antigravity are unavailable on this machine", + project: null, + worktree: null, + branch: null, + evidence: "Adapter unavailable", + lastAction: "Not detected", + command: null, + startedAt: null, + latestMessage: null, + recentActivity: [], + detailAvailability: "unavailable", + }); + } const agents = [...agentsByName.values()].sort((left, right) => { const order = { working: 0, recent: 1, history: 2 }; return ( @@ -723,8 +689,8 @@ export function fleetHtml(): string { } function fleetSupervisorHtml(): string { - return `Let Fleet
Let / local supervisor

Fleet activity

Loading…

Let Fleet / local control room

Supervise local agents.

A read-only view built from Let’s own federated index.

Loading…

`; } @@ -753,6 +719,16 @@ export function startFleetWeb(options: { cwd: string; port?: number }): { if (pathname === "/api/fleet") { return Response.json(await buildFleetSnapshot(options.cwd)); } + if (pathname === "/assets/tokens.css") { + return new Response(CORVID_TOKENS_CSS, { + headers: { "content-type": "text/css; charset=utf-8" }, + }); + } + if (pathname === "/assets/theme.js") { + return new Response(CORVID_THEME_JS, { + headers: { "content-type": "text/javascript; charset=utf-8" }, + }); + } if (pathname === "/" || pathname === "/index.html") { return new Response(fleetHtml(), { headers: { "content-type": "text/html; charset=utf-8" }, diff --git a/test/web.test.ts b/test/web.test.ts index 0db4ac6..fc42e26 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test"; +import { fleetAdapterFor, fleetSessionDetail } from "../src/fleet-adapters.ts"; import { runLet } from "../src/run.ts"; import { buildFleetSnapshot, @@ -37,10 +38,13 @@ test("Fleet API and HTML expose local supervision details without internal CWD k expect(response).not.toContain('"cwd"'); expect(response).not.toContain('"pid"'); } - expect(page).toContain("Local-only supervisor view"); + expect(page).toContain("Let Fleet / local control room"); expect(page).toContain("Latest prompt or update"); expect(page).toContain("Recent output"); expect(page).toContain("Show supervision details"); + expect(page).toContain("/assets/tokens.css"); + expect(page).toContain("data-corvid-theme-toggle"); + expect(page).toContain("Let Fleet / local control room"); }); test("agent context keeps a shared project and worktree from repeating", () => { @@ -53,6 +57,47 @@ test("agent context keeps a shared project and worktree from repeating", () => { ).toEqual(["Project: let", "Branch: feat/fleet"]); }); +test("Fleet session adapters label Claude, Codex, Grok, Gemini, and Antigravity fixtures", () => { + const fixtures = [ + { host: "claude", meta: {}, provider: "Claude" }, + { host: "codex", meta: {}, provider: "Codex" }, + { host: "grok", meta: {}, provider: "Grok" }, + { host: "gemini", meta: {}, provider: "Gemini" }, + { + host: "gemini", + meta: { source: "antigravity-cli" }, + provider: "Antigravity", + }, + ] as const; + for (const fixture of fixtures) { + expect( + fleetAdapterFor({ + id: `sessions:${fixture.provider}`, + kind: "sessions", + host: fixture.host, + name: fixture.provider, + path: `/fixture/${fixture.provider}.jsonl`, + scope: "user", + meta: fixture.meta, + })?.provider, + ).toBe(fixture.provider); + } +}); + +test("Fleet adapter fixture keeps the latest redacted prompt and output", () => { + const detail = fleetSessionDetail( + [ + '{"prompt":"First prompt"}', + '{"output":"token=ghp_abcdefghijklmnopqrstuvwxyz123456"}', + '{"status":"Finished safely"}', + ].join("\n"), + redactLocalDetail, + ); + expect(detail.latestMessage).toBe("Finished safely"); + expect(detail.recentActivity).toContain("First prompt"); + expect(JSON.stringify(detail)).toContain("[REDACTED]"); +}); + test("local supervisor details redact known secret-shaped values", () => { const detail = redactLocalDetail( "token=ghp_abcdefghijklmnopqrstuvwxyz123456 password=private-value", @@ -65,8 +110,8 @@ test("local supervisor details redact known secret-shaped values", () => { test("Fleet page starts by agent and keeps project worktrees collapsed", () => { const page = fleetHtml(); expect(page).toContain('data-view="agents"'); - expect(page).toContain("By agent"); - expect(page).toContain("By project"); + expect(page).toContain(">Agents"); + expect(page).toContain(">Projects"); expect(page).toContain("Unassigned running agent"); expect(page).toContain('
'); expect(page).not.toContain('
Date: Sat, 1 Aug 2026 11:31:06 -0600 Subject: [PATCH 11/17] Fix: surface current Fleet sessions --- src/catalog/codex.ts | 23 ++++++++++++- src/catalog/find.ts | 6 ++++ src/web.ts | 79 +++++++++++++++++++++++++++++--------------- test/web.test.ts | 3 ++ 4 files changed, 83 insertions(+), 28 deletions(-) diff --git a/src/catalog/codex.ts b/src/catalog/codex.ts index 12ac101..db512c4 100644 --- a/src/catalog/codex.ts +++ b/src/catalog/codex.ts @@ -94,7 +94,7 @@ export function findCodexSessions(ctx: ScanContext): IndexCard[] { meta: { source: `codex.${rel}`, level: "child" }, }), ); - // A current Codex session normally lives at year/month/rollout.jsonl. + // A current Codex session normally lives at year/month/day/rollout.jsonl. // Index that final file level as well so local Fleet can show its // already-indexed, redacted latest prompt alongside a live process. if (isDirectory(child)) { @@ -103,6 +103,27 @@ export function findCodexSessions(ctx: ScanContext): IndexCard[] { 50, )) { if (!session.endsWith(".jsonl")) { + if (!isDirectory(session)) { + continue; + } + for (const rollout of listChildPaths( + session, + ctx.policy, + ).slice(0, 50)) { + if (!rollout.endsWith(".jsonl")) { + continue; + } + cards.push( + makeCard({ + kind: "sessions", + host: "codex", + path: rollout, + scope: "user", + pathOnly: true, + meta: { source: `codex.${rel}`, level: "session" }, + }), + ); + } continue; } cards.push( diff --git a/src/catalog/find.ts b/src/catalog/find.ts index fb0b574..26bfd3a 100644 --- a/src/catalog/find.ts +++ b/src/catalog/find.ts @@ -193,6 +193,12 @@ export async function findAssets( // Filter before limit so --host kimi is not starved by other hosts if (opts.host) { items = items.filter((c) => c.host === opts.host); + // A host-specific session view is an activity feed. Preserve the normal + // catalog sort for federation, but return the newest bounded session cards + // first so a current provider is not hidden by older archive buckets. + if (kind === "sessions") { + items.sort((left, right) => (right.mtime_ms ?? 0) - (left.mtime_ms ?? 0)); + } } if (opts.query) { diff --git a/src/web.ts b/src/web.ts index 03a501f..d27c3b8 100644 --- a/src/web.ts +++ b/src/web.ts @@ -349,6 +349,10 @@ export function redactLocalDetail(value: string): string { .replace( /-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/g, "[REDACTED CERTIFICATE]", + ) + .replace( + /\/(?:Users|home)\/[A-Za-z0-9._-]+(?:\/[^\s"'`]+)*/g, + "[LOCAL PATH]", ); } @@ -453,13 +457,28 @@ export async function buildFleetSnapshot( cwd: string, now = Date.now(), ): Promise { - const ctx = buildScanContext({ cwd, scope: "all", limit: 48 }); - const [worktrees, sessions, instructions, skills] = await Promise.all([ + // Fleet needs one current record per host. A narrow shared result limit can + // otherwise hide user-scoped Codex sessions behind project-scoped records. + // This remains a bounded Let index query, not a new filesystem scan. + const ctx = buildScanContext({ cwd, scope: "all", limit: 300 }); + const sessionHosts = [ + "claude", + "codex", + "grok", + "cursor", + "gemini", + "kimi", + "let", + ]; + const [worktrees, sessionResults, instructions, skills] = await Promise.all([ findAssets("worktrees", ctx), - findAssets("sessions", ctx), + Promise.all( + sessionHosts.map((host) => findAssets("sessions", ctx, { host })), + ), findAssets("instructions", ctx), findAssets("skills", ctx), ]); + const sessions = { items: sessionResults.flatMap((result) => result.items) }; const instructionCards = instructions.items.slice(0, 8).map((item) => ({ name: item.name, @@ -521,6 +540,7 @@ export async function buildFleetSnapshot( labelCollidingProjects(repositories, worktreeDetails, worktreeRepoByPath); const agentsByName = new Map(); + const sessionMtimeByAgent = new Map(); for (const session of sessions.items) { const adapter = fleetAdapterFor(session); if (!adapter) { @@ -537,35 +557,40 @@ export async function buildFleetSnapshot( : undefined; const repository = key ? repositories.get(key) : undefined; const view = sessionView(session, now); - const detail = sessionDetail(session); - const candidate: FleetAgentActivity = { - agent, - status: - view.freshness === "live" || view.freshness === "recent" - ? "recent" - : "history", - operation: - view.freshness === "stale" - ? "Previous session activity" - : "Recent session activity", - project: worktree?.repo ?? repository?.project ?? null, - worktree: worktree?.worktree ?? null, - branch: worktree?.branch ?? null, - evidence: "Session metadata", - lastAction: view.activity, - command: null, - startedAt: null, - latestMessage: detail.latestMessage, - recentActivity: detail.recentActivity, - detailAvailability: detail.detailAvailability, - }; const existing = agentsByName.get(agent); + const candidateMtime = session.mtime_ms ?? 0; + const existingMtime = sessionMtimeByAgent.get(agent) ?? 0; if ( !existing || sessionRank(view.freshness) < - sessionRank(existing.status === "recent" ? "recent" : "stale") + sessionRank(existing.status === "recent" ? "recent" : "stale") || + (sessionRank(view.freshness) === + sessionRank(existing.status === "recent" ? "recent" : "stale") && + candidateMtime > existingMtime) ) { - agentsByName.set(agent, candidate); + const detail = sessionDetail(session); + agentsByName.set(agent, { + agent, + status: + view.freshness === "live" || view.freshness === "recent" + ? "recent" + : "history", + operation: + view.freshness === "stale" + ? "Previous session activity" + : "Recent session activity", + project: worktree?.repo ?? repository?.project ?? null, + worktree: worktree?.worktree ?? null, + branch: worktree?.branch ?? null, + evidence: "Session metadata", + lastAction: view.activity, + command: null, + startedAt: null, + latestMessage: detail.latestMessage, + recentActivity: detail.recentActivity, + detailAvailability: detail.detailAvailability, + }); + sessionMtimeByAgent.set(agent, candidateMtime); } } diff --git a/test/web.test.ts b/test/web.test.ts index fc42e26..9bbfa2f 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -105,6 +105,9 @@ test("local supervisor details redact known secret-shaped values", () => { expect(detail).toContain("token=[REDACTED]"); expect(detail).toContain("password=[REDACTED]"); expect(detail).not.toContain("private-value"); + expect(redactLocalDetail("/Users/leif/Development/private")).toBe( + "[LOCAL PATH]", + ); }); test("Fleet page starts by agent and keeps project worktrees collapsed", () => { From bfa02aa249daa243adffe5e9e6c59e6cd9a9521b Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 11:35:43 -0600 Subject: [PATCH 12/17] Fix: show Fleet agent context --- specs/web/web.spec.md | 6 +++--- src/fleet-adapters.ts | 32 +++++++++++++++++++++++++++++++- src/web.ts | 22 +++++++++++++--------- test/web.test.ts | 10 +++++++--- 4 files changed, 54 insertions(+), 16 deletions(-) diff --git a/specs/web/web.spec.md b/specs/web/web.spec.md index 11b3195..e42ab7c 100644 --- a/specs/web/web.spec.md +++ b/specs/web/web.spec.md @@ -37,14 +37,14 @@ workflow engine or a remote-control surface. | `FleetAgentActivity` | Safe agent-focused status derived from a local process or session metadata. | | `selectAgentWorkContext` | Prefer a bounded child verifier worktree over its agent parent process context. | | `redactLocalDetail` | Redact known token, credential, and environment-value patterns before local display. | -| `fleetContextLabels` | Build concise context labels without repeating a project as its worktree. | +| `fleetContextLabels` | Build explicit project, worktree, and branch labels for agent supervision. | | `parseLocalAgentProcessLines` | Parse only whitelisted agent CLI processes and resolve their cwd internally. | | `FleetSnapshot` | Sanitized rows, bounded session metadata, refresh interval, and policy. | | `FLEET_SESSION_ADAPTERS` | Let-native presentation adapters for Claude, Codex, Grok, Gemini, and Antigravity session cards. | | `fleetAdapterFor` | Resolve the local Fleet presentation adapter for an indexed session card. | | `fleetSessionDetail` | Extract bounded, redacted display text from an already-indexed local session file. | | `FleetProvider` | Supported Fleet session-provider name. | -| `FleetSessionDetail` | Bounded local prompt and output detail for an indexed session file. | +| `FleetSessionDetail` | Bounded local prompt and output detail plus an internal-only path for resolving sanitized project context. | | `FleetSessionAdapter` | Host-card matching contract for Fleet's presentation adapters. | | `CORVID_TOKENS_CSS` | Local serving copy of the CorvidLabs design-system token stylesheet. | | `CORVID_THEME_JS` | Local serving copy of the standard CorvidLabs theme controller. | @@ -64,7 +64,7 @@ workflow engine or a remote-control surface. 10. The agent view distinguishes a process-backed `Working now` status from recent or historical session metadata; no session record is represented as a running process. 11. The process probe may inspect a bounded descendant tree internally to classify an allowlisted operation label; the localhost supervisor may show its redacted command and resolved project context, but never exposes process IDs. 12. The localhost supervisor may expose redacted command, prompt/update, and recent output only after applying `redactLocalDetail`; unavailable detail is labeled instead of inferred. -13. Agent context must not repeat a worktree label when it is the same value as its project label. +13. Agent context shows project, worktree, and branch independently when Let can resolve them; raw local paths never leave the server. 14. Fleet maps host session cards through explicit presentation adapters; it does not scan host paths independently of Let's catalog. 15. Gemini must be shown through its Gemini or Antigravity adapter when indexed, otherwise Fleet shows an explicit unavailable state. diff --git a/src/fleet-adapters.ts b/src/fleet-adapters.ts index 0e442e8..34ce331 100644 --- a/src/fleet-adapters.ts +++ b/src/fleet-adapters.ts @@ -18,6 +18,8 @@ export type FleetSessionDetail = { latestMessage: string | null; recentActivity: string[]; detailAvailability: "available" | "unavailable"; + /** Internal-only local directory used to resolve safe project context. */ + contextPath: string | null; }; export type FleetSessionAdapter = { @@ -78,6 +80,29 @@ function sessionText(value: unknown): string[] { return []; } +function sessionContextPaths(value: unknown): string[] { + if (Array.isArray(value)) { + return value.flatMap(sessionContextPaths); + } + if (!value || typeof value !== "object") { + return []; + } + return Object.entries(value as Record).flatMap( + ([key, child]) => { + if ( + typeof child === "string" && + /cwd|workdir|working[_-]?directory|workspace|repo[_-]?root|project[_-]?path/i.test( + key, + ) && + /^(?:\/Users|\/home)\//.test(child) + ) { + return [child]; + } + return sessionContextPaths(child); + }, + ); +} + /** Extract bounded display text from a session file that Let already indexed. */ export function fleetSessionDetail( source: string | null, @@ -88,13 +113,17 @@ export function fleetSessionDetail( latestMessage: null, recentActivity: [], detailAvailability: "unavailable", + contextPath: null, }; } + let contextPath: string | null = null; const messages = source .split("\n") .flatMap((line) => { try { - return sessionText(JSON.parse(line)); + const value = JSON.parse(line); + contextPath = sessionContextPaths(value).at(-1) ?? contextPath; + return sessionText(value); } catch { return line.trim() ? [line] : []; } @@ -107,5 +136,6 @@ export function fleetSessionDetail( latestMessage: messages.at(-1) ?? null, recentActivity: messages, detailAvailability: messages.length > 0 ? "available" : "unavailable", + contextPath, }; } diff --git a/src/web.ts b/src/web.ts index d27c3b8..697a295 100644 --- a/src/web.ts +++ b/src/web.ts @@ -356,7 +356,7 @@ export function redactLocalDetail(value: string): string { ); } -/** Return concise context labels without repeating a project as its worktree. */ +/** Return explicit project, worktree, and branch labels for agent supervision. */ export function fleetContextLabels( agent: Pick, ): string[] { @@ -367,10 +367,7 @@ export function fleetContextLabels( if (project) { labels.push(`Project: ${project}`); } - if ( - worktree && - worktree.toLocaleLowerCase() !== project?.toLocaleLowerCase() - ) { + if (worktree) { labels.push(`Worktree: ${worktree}`); } if (branch) { @@ -383,12 +380,14 @@ function sessionDetail(card: IndexCard): { latestMessage: string | null; recentActivity: string[]; detailAvailability: "available" | "unavailable"; + contextPath: string | null; } { if (!existsSync(card.path) || !statSync(card.path).isFile()) { return { latestMessage: null, recentActivity: [], detailAvailability: "unavailable", + contextPath: null, }; } try { @@ -401,6 +400,7 @@ function sessionDetail(card: IndexCard): { latestMessage: null, recentActivity: [], detailAvailability: "unavailable", + contextPath: null, }; } } @@ -569,6 +569,9 @@ export async function buildFleetSnapshot( candidateMtime > existingMtime) ) { const detail = sessionDetail(session); + const sessionContext = detail.contextPath + ? gitLocation(detail.contextPath) + : null; agentsByName.set(agent, { agent, status: @@ -579,9 +582,10 @@ export async function buildFleetSnapshot( view.freshness === "stale" ? "Previous session activity" : "Recent session activity", - project: worktree?.repo ?? repository?.project ?? null, - worktree: worktree?.worktree ?? null, - branch: worktree?.branch ?? null, + project: + sessionContext?.repo ?? worktree?.repo ?? repository?.project ?? null, + worktree: sessionContext?.worktree ?? worktree?.worktree ?? null, + branch: sessionContext?.branch ?? worktree?.branch ?? null, evidence: "Session metadata", lastAction: view.activity, command: null, @@ -716,7 +720,7 @@ export function fleetHtml(): string { function fleetSupervisorHtml(): string { return `Let Fleet
Let Fleet / local control room

Supervise local agents.

A read-only view built from Let’s own federated index.

Loading…

`; + let fleet={agents:[],recentActivity:[],history:[]},view='agents';const e=s=>{const d=document.createElement('div');d.textContent=String(s??'Unavailable');return d.innerHTML};const human=s=>String(s||'Unassigned running agent').replace(/[-_]+/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase());const plural=(n,w)=>n+' '+w+(n===1?'':'s');const context=a=>{const labels=[a.project?'Project · '+e(human(a.project))+'':'',a.worktree?'Worktree · '+e(a.worktree)+'':'',a.branch?'Branch · '+e(a.branch)+'':''].filter(Boolean);return labels.join('')||'Project context not available yet'};const agent=a=>{const contextText=[a.project,a.worktree,a.branch].filter(Boolean).join(' · ');const details='
Show supervision details
Task'+e(a.operation)+'
Context'+e(contextText||'Project context not available yet')+'
Current command'+e(a.command||'Unavailable')+'
Started'+e(a.startedAt||'Unavailable')+'
Latest prompt or update'+e(a.latestMessage||'Unavailable')+'
Detail source'+e(a.evidence)+' · '+e(a.detailAvailability)+'
'+(a.recentActivity.length?'

Recent output

'+a.recentActivity.map(e).join('\\n\\n')+'
':'

Recent output unavailable.

')+'
';return '
'+e(a.agent)+' · '+e(a.status==='working'?'Working now':a.status==='recent'?'Recent activity':'Earlier activity')+'
Task · '+e(a.operation)+'Last update · '+e(a.lastAction)+'
'+context(a)+'
'+details+'
'};const project=r=>'
'+e(human(r.project))+' · '+e(plural(r.worktrees.length,'worktree'))+''+r.worktrees.map(w=>'
'+e(w.worktree)+''+e(w.branch)+'
').join('')+'
';function render(){const projects=[...fleet.recentActivity,...fleet.history];document.querySelector('#stamp').textContent=plural(fleet.agents.filter(a=>a.status==='working').length,'agent')+' working · '+plural(projects.length,'project');document.querySelector('#notice').textContent=fleet.policy;document.querySelector('#app').innerHTML=view==='agents'?fleet.agents.map(agent).join('')||'

No local agent or session metadata is available.

':projects.map(project).join('')||'

No projects are available.

';document.querySelectorAll('[data-view]').forEach(button=>button.addEventListener('click',()=>{view=button.dataset.view;document.querySelectorAll('[data-view]').forEach(item=>item.setAttribute('aria-pressed',String(item.dataset.view===view)));render()}))}async function refresh(){fleet=await fetch('/api/fleet').then(r=>r.json());render()}refresh();setInterval(refresh,5000);`; } function _previousFleetHtml(): string { diff --git a/test/web.test.ts b/test/web.test.ts index 9bbfa2f..047c62c 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -17,6 +17,7 @@ describe("web fleet snapshot", () => { expect(snapshot.source).toBe("let"); expect(snapshot.liveProcessDetection).toBe("local-process"); expect(JSON.stringify(snapshot.workingNow)).not.toContain('"cwd"'); + expect(JSON.stringify(snapshot.agents)).not.toContain("/Users/"); const repositories = [...snapshot.recentActivity, ...snapshot.history]; expect(repositories.length).toBeLessThanOrEqual(48); expect(snapshot.policy).toContain("redacted local task context"); @@ -47,14 +48,14 @@ test("Fleet API and HTML expose local supervision details without internal CWD k expect(page).toContain("Let Fleet / local control room"); }); -test("agent context keeps a shared project and worktree from repeating", () => { +test("agent context keeps project, worktree, and branch explicit", () => { expect( fleetContextLabels({ project: "let", worktree: "let", branch: "feat/fleet", }), - ).toEqual(["Project: let", "Branch: feat/fleet"]); + ).toEqual(["Project: let", "Worktree: let", "Branch: feat/fleet"]); }); test("Fleet session adapters label Claude, Codex, Grok, Gemini, and Antigravity fixtures", () => { @@ -87,7 +88,7 @@ test("Fleet session adapters label Claude, Codex, Grok, Gemini, and Antigravity test("Fleet adapter fixture keeps the latest redacted prompt and output", () => { const detail = fleetSessionDetail( [ - '{"prompt":"First prompt"}', + '{"cwd":"/Users/leif/Development/_CorvidLabs/let/.worktrees/fleet-web","prompt":"First prompt"}', '{"output":"token=ghp_abcdefghijklmnopqrstuvwxyz123456"}', '{"status":"Finished safely"}', ].join("\n"), @@ -96,6 +97,9 @@ test("Fleet adapter fixture keeps the latest redacted prompt and output", () => expect(detail.latestMessage).toBe("Finished safely"); expect(detail.recentActivity).toContain("First prompt"); expect(JSON.stringify(detail)).toContain("[REDACTED]"); + expect(detail.contextPath).toBe( + "/Users/leif/Development/_CorvidLabs/let/.worktrees/fleet-web", + ); }); test("local supervisor details redact known secret-shaped values", () => { From 78dd65b2b8735d01c5ccfd43e26147be9392c6fb Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 11:38:16 -0600 Subject: [PATCH 13/17] Update: surface Fleet activity summaries --- src/web.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/web.ts b/src/web.ts index 697a295..c4d4993 100644 --- a/src/web.ts +++ b/src/web.ts @@ -719,8 +719,8 @@ export function fleetHtml(): string { function fleetSupervisorHtml(): string { return `Let Fleet
Let Fleet / local control room

Supervise local agents.

A read-only view built from Let’s own federated index.

Loading…

`; + *{box-sizing:border-box}body{margin:0;background:var(--paper);color:var(--ink);font:15px/1.5 var(--font-display)}main{max-width:1080px;margin:auto;padding:30px 24px}.masthead{display:grid;grid-template-columns:1fr auto;gap:20px;align-items:start;padding-bottom:20px;border-bottom:1px solid var(--hairline);position:relative}.masthead:after{content:'';position:absolute;height:4px;left:0;right:0;bottom:-1px;background:var(--iridescence)}h1{margin:4px 0;font-size:clamp(2.1rem,5vw,4rem);line-height:.95;letter-spacing:-.055em}.eyebrow,.meta,.status,.row span:first-child{font:12px/1.4 var(--font-mono);letter-spacing:.04em}.eyebrow{text-transform:uppercase;color:var(--sheen-strong)}.meta{color:var(--text-faint)}.local{margin:20px 0;padding:12px 14px;border:1px solid var(--hairline);border-left:4px solid var(--sheen);background:var(--surface)}.header-actions{display:flex;gap:9px;align-items:center}.corvid-theme-toggle{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;padding:0;background:none;border:1px solid var(--hairline);color:var(--ink-70);cursor:pointer}.corvid-theme-toggle:hover{border-color:var(--sheen);color:var(--sheen)}.corvid-theme-toggle:focus-visible,.view:focus-visible,summary:focus-visible{outline:2px solid var(--sheen);outline-offset:3px}.corvid-theme-toggle svg{width:17px;height:17px}.moon{display:none}:root[data-theme="dark"] .sun{display:none}:root[data-theme="dark"] .moon{display:inline}@media(prefers-color-scheme:dark){:root:not([data-theme="light"]) .sun{display:none}:root:not([data-theme="light"]) .moon{display:inline}}:root[data-theme="light"] .sun{display:inline}:root[data-theme="light"] .moon{display:none}.views{display:flex;gap:4px;margin:22px 0 14px;border-bottom:1px solid var(--hairline)}.view{border:0;border-bottom:2px solid transparent;padding:10px 12px;background:transparent;color:var(--text-faint);font:600 12px var(--font-mono);cursor:pointer}.view[aria-pressed="true"]{color:var(--sheen-strong);border-color:var(--sheen)}.card{border:1px solid var(--hairline);background:var(--surface);margin:9px 0;padding:16px}.card.working{border-left:4px solid var(--success)}.line{font-size:19px;font-weight:700;letter-spacing:-.025em}.row{display:flex;justify-content:space-between;gap:18px;padding:9px 0;border-top:1px solid var(--hairline)}.row span:last-child{color:var(--text-muted);text-align:right}.row.activity span:last-child{max-width:68%;text-align:left}details summary{cursor:pointer;color:var(--sheen-strong);padding:11px 0;font-weight:600}.output{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--ink-70);background:var(--surface-strong);padding:12px;font:12px/1.5 var(--font-mono)}.notice{color:var(--text-faint);font-size:12px;margin-top:24px}@media(max-width:740px){main{padding:20px 14px}.masthead{grid-template-columns:1fr}.row{display:block}.row span{display:block;margin:3px 0}.row span:last-child{text-align:left}.row.activity span:last-child{max-width:none}}@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
Let Fleet / local control room

Supervise local agents.

A read-only view built from Let’s own federated index.

Loading…

`; } function _previousFleetHtml(): string { From f1e9e718190eec610bae2de61d8be21c51c79866 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 11:43:19 -0600 Subject: [PATCH 14/17] Fix: preserve Fleet refresh state --- specs/web/web.spec.md | 4 +++ src/web.ts | 58 +++++++++++++++++++++++++++++++++++++++++-- test/web.test.ts | 35 +++++++++++++++++++++++--- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/specs/web/web.spec.md b/specs/web/web.spec.md index e42ab7c..2509328 100644 --- a/specs/web/web.spec.md +++ b/specs/web/web.spec.md @@ -38,6 +38,8 @@ workflow engine or a remote-control surface. | `selectAgentWorkContext` | Prefer a bounded child verifier worktree over its agent parent process context. | | `redactLocalDetail` | Redact known token, credential, and environment-value patterns before local display. | | `fleetContextLabels` | Build explicit project, worktree, and branch labels for agent supervision. | +| `retainFleetOpenKeys` | Keep open panel keys only while their backing Fleet cards remain available. | +| `fleetLiveChangeAnnouncement` | Describe material agent visibility or status changes for accessible live updates. | | `parseLocalAgentProcessLines` | Parse only whitelisted agent CLI processes and resolve their cwd internally. | | `FleetSnapshot` | Sanitized rows, bounded session metadata, refresh interval, and policy. | | `FLEET_SESSION_ADAPTERS` | Let-native presentation adapters for Claude, Codex, Grok, Gemini, and Antigravity session cards. | @@ -67,6 +69,8 @@ workflow engine or a remote-control surface. 13. Agent context shows project, worktree, and branch independently when Let can resolve them; raw local paths never leave the server. 14. Fleet maps host session cards through explicit presentation adapters; it does not scan host paths independently of Let's catalog. 15. Gemini must be shown through its Gemini or Antigravity adapter when indexed, otherwise Fleet shows an explicit unavailable state. +16. A 20-second refresh preserves the selected view, open Fleet panels, scroll position, and focused control where the backing card remains available; removed cards may no longer retain state. +17. Material agent visibility or status changes are announced through a polite live region without moving focus. ## Behavioral Examples diff --git a/src/web.ts b/src/web.ts index c4d4993..f373582 100644 --- a/src/web.ts +++ b/src/web.ts @@ -707,8 +707,48 @@ export async function buildFleetSnapshot( }; } +/** Keep only panels whose backing agent or project still exists. */ +export function retainFleetOpenKeys( + openKeys: readonly string[], + availableKeys: readonly string[], +): string[] { + const available = new Set(availableKeys); + return openKeys.filter((key) => available.has(key)); +} + +/** Describe a material agent-state change for the local status announcement. */ +export function fleetLiveChangeAnnouncement( + previous: readonly Pick[], + current: readonly Pick[], +): string { + if (previous.length === 0) { + return ""; + } + const previousByAgent = new Map( + previous.map((agent) => [agent.agent, agent.status]), + ); + const currentByAgent = new Map( + current.map((agent) => [agent.agent, agent.status]), + ); + const changes: string[] = []; + for (const agent of current) { + const prior = previousByAgent.get(agent.agent); + if (!prior) { + changes.push(`${agent.agent} is now visible.`); + } else if (prior !== agent.status) { + changes.push(`${agent.agent} is now ${agent.status}.`); + } + } + for (const agent of previous) { + if (!currentByAgent.has(agent.agent)) { + changes.push(`${agent.agent} is no longer visible.`); + } + } + return changes.join(" "); +} + export function fleetHtml(): string { - const supervisor = fleetSupervisorHtml(); + const supervisor = fleetSupervisorHtmlV3(); if (supervisor.length > 0) { return supervisor; } @@ -717,7 +757,21 @@ export function fleetHtml(): string { let fleet={agents:[],recentActivity:[],history:[]},view='agents';const e=s=>{const d=document.createElement('div');d.textContent=String(s??'');return d.innerHTML};const human=s=>String(s??'').replace(/[-_]+/g,' ').replace(/\\b\\w/g,c=>c.toUpperCase());const plural=(n,word)=>n+' '+word+(n===1?'':'s');const work=w=>'
Worktree · '+e(w.worktree)+'Branch · '+e(w.branch)+'
';const project=r=>'
'+e(human(r.project))+''+plural(r.worktrees.length,'worktree')+''+ (r.state==='recent'?'Recent activity':'')+'
'+r.worktrees.map(work).join('')+'
Recent history ('+r.sessions.length+')'+(r.sessions.length?r.sessions.map(s=>'
'+e(s.provider)+' session'+e(s.activity)+'
').join(''):'
No recent history.
')+'
';const agent=a=>{const project=a.project?human(a.project):'Unassigned running agent';const worktree=a.worktree&&human(a.worktree)!==project?'Worktree · '+e(a.worktree)+'':'';const branch=a.branch?'Branch · '+e(a.branch)+'':'';const status=a.status==='working'?'Working now':a.status==='recent'?'Recent session':'Earlier session';return '

'+e(a.agent)+' — '+e(a.operation)+'

Project · '+e(project)+''+e(status)+'
'+worktree+branch+'Last meaningful action · '+e(a.lastAction)+'
'};const section=(title,copy,rows,render)=>'

'+title+' ('+rows.length+')

'+copy+'

'+(rows.length?rows.map(render).join(''):'

None.

')+'
';function render(){const projects=[...fleet.recentActivity,...fleet.history];document.querySelector('#stamp').textContent=plural(fleet.agents.filter(a=>a.status==='working').length,'agent')+' working · '+plural(projects.length,'project');document.querySelector('#notice').textContent=fleet.policy;document.querySelector('#app').innerHTML=view==='agents'?section('By agent','Process-backed work is marked Working now. Session records stay clearly separate.',fleet.agents,agent):section('By project','Open a project to see worktrees and recent history.',projects,project);document.querySelectorAll('[data-view]').forEach(button=>button.addEventListener('click',()=>{view=button.dataset.view;document.querySelectorAll('[data-view]').forEach(item=>item.setAttribute('aria-pressed',String(item.dataset.view===view)));render()}))}async function refresh(){fleet=await fetch('/api/fleet').then(r=>r.json());render()}refresh();setInterval(refresh,5000);`; } -function fleetSupervisorHtml(): string { +function fleetSupervisorHtmlV2(): string { + return ` +Let Fleet
Let Fleet / local control room

Supervise local agents.

A read-only view built from Let’s own federated index.

Loading…

`; +} + +function fleetSupervisorHtmlV3(): string { + return fleetSupervisorHtmlV2().replace( + `document.querySelector('details[data-fleet-key="'+ui.focus.key+'"]').querySelector('summary')`, + `document.querySelector('details[data-fleet-key="'+ui.focus.key+'"]')?.querySelector('summary')`, + ); +} + +function _fleetSupervisorHtml(): string { return `Let Fleet
Let Fleet / local control room

Supervise local agents.

A read-only view built from Let’s own federated index.

Loading…

`; diff --git a/test/web.test.ts b/test/web.test.ts index 047c62c..31517fb 100644 --- a/test/web.test.ts +++ b/test/web.test.ts @@ -5,9 +5,11 @@ import { buildFleetSnapshot, fleetContextLabels, fleetHtml, + fleetLiveChangeAnnouncement, fleetStateForSessions, parseLocalAgentProcessLines, redactLocalDetail, + retainFleetOpenKeys, selectAgentWorkContext, } from "../src/web.ts"; @@ -114,20 +116,47 @@ test("local supervisor details redact known secret-shaped values", () => { ); }); -test("Fleet page starts by agent and keeps project worktrees collapsed", () => { +test("Fleet page preserves refresh state with stable agent and project panel keys", () => { const page = fleetHtml(); expect(page).toContain('data-view="agents"'); expect(page).toContain(">Agents"); expect(page).toContain(">Projects"); expect(page).toContain("Unassigned running agent"); - expect(page).toContain('
'); - expect(page).not.toContain('
{ + expect( + retainFleetOpenKeys( + ["agent:codex", "agent:grok", "project:let"], + ["agent:codex", "project:let"], + ), + ).toEqual(["agent:codex", "project:let"]); +}); + +test("Fleet announces only material agent changes after its first snapshot", () => { + expect( + fleetLiveChangeAnnouncement( + [{ agent: "Codex", status: "recent" }], + [ + { agent: "Codex", status: "working" }, + { agent: "Grok", status: "recent" }, + ], + ), + ).toBe("Codex is now working. Grok is now visible."); + expect( + fleetLiveChangeAnnouncement([], [{ agent: "Codex", status: "recent" }]), + ).toBe(""); +}); + test("a child verifier context overrides its agent parent worktree", () => { expect( selectAgentWorkContext("/work/spec-sync", [ From 5f0f5ea08d03622d64cba3210e9e98980ee240f4 Mon Sep 17 00:00:00 2001 From: 0xLeif Date: Sat, 1 Aug 2026 11:45:46 -0600 Subject: [PATCH 15/17] Fix: retain Fleet selected view state --- src/web.ts | 9 ++++++++- test/web.test.ts | 1 + 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/web.ts b/src/web.ts index f373582..f5afc3a 100644 --- a/src/web.ts +++ b/src/web.ts @@ -748,7 +748,7 @@ export function fleetLiveChangeAnnouncement( } export function fleetHtml(): string { - const supervisor = fleetSupervisorHtmlV3(); + const supervisor = fleetSupervisorHtmlV4(); if (supervisor.length > 0) { return supervisor; } @@ -771,6 +771,13 @@ function fleetSupervisorHtmlV3(): string { ); } +function fleetSupervisorHtmlV4(): string { + return fleetSupervisorHtmlV3().replace( + "restoreUiState=()=>{", + "restoreUiState=()=>{document.querySelectorAll('[data-view]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.view===view)));", + ); +} + function _fleetSupervisorHtml(): string { return `Let Fleet
Let Fleet / local control room

Supervise local agents.

A read-only view built from Let’s own federated index.

Loading…

Let Fleet / local control room

Supervise local agents.

A read-only view built from Let’s own federated index.

Loading…

Let Fleet / local control room

Supervise local agents.

A read-only view built from Let’s own federated index.

Loading…