diff --git a/README.md b/README.md index 3107318..4de17a5 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,19 @@ 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`. 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, …) ```bash diff --git a/docs/usage.md b/docs/usage.md index eca437f..de8136c 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 puts agents first, with projects as a +secondary view. Expand an agent to inspect its redacted task context, command, +latest prompt or update, timestamps, and recent session output when locally +available. It never provides controls, shell execution, or unredacted secrets. + ### `let history` (alias: `usage`) Rank hosts and projects by session activity (path-only, no transcripts). 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/specs/web/web.spec.md b/specs/web/web.spec.md new file mode 100644 index 0000000..a2cd391 --- /dev/null +++ b/specs/web/web.spec.md @@ -0,0 +1,108 @@ +--- +module: web +version: 1 +status: active +files: + - src/web.ts + - src/fleet-adapters.ts + - src/fleet-brand.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. | +| `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. | +| `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. | +| `FleetAgentActivity` | Safe agent-focused status derived from a local process or session metadata. | +| `FleetGitEvidence` | Local Git dirty, ahead/behind, and active Spec Sync change evidence. | +| `gitEvidence` | Read local Git evidence for a resolved agent worktree. | +| `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. | +| `mergeFleetOpenKeys` | Merge visible panel state without discarding an open panel in the other view. | +| `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. | +| `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 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. | + +## 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 raw secrets, browser shell endpoints, or agent-control actions. The localhost supervisor may include redacted session text, commands, and project 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. +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. +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 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 + +``` +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/catalog/codex.ts b/src/catalog/codex.ts index ef87a33..db512c4 100644 --- a/src/catalog/codex.ts +++ b/src/catalog/codex.ts @@ -94,6 +94,50 @@ export function findCodexSessions(ctx: ScanContext): IndexCard[] { meta: { source: `codex.${rel}`, level: "child" }, }), ); + // 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)) { + for (const session of listChildPaths(child, ctx.policy).slice( + 0, + 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( + 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/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/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/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/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/fleet-adapters.ts b/src/fleet-adapters.ts new file mode 100644 index 0000000..34ce331 --- /dev/null +++ b/src/fleet-adapters.ts @@ -0,0 +1,141 @@ +/** + * 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"; + /** Internal-only local directory used to resolve safe project context. */ + contextPath: string | null; +}; + +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 []; +} + +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, + redact: (value: string) => string, +): FleetSessionDetail { + if (!source) { + return { + latestMessage: null, + recentActivity: [], + detailAvailability: "unavailable", + contextPath: null, + }; + } + let contextPath: string | null = null; + const messages = source + .split("\n") + .flatMap((line) => { + try { + const value = JSON.parse(line); + contextPath = sessionContextPaths(value).at(-1) ?? contextPath; + return sessionText(value); + } 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", + contextPath, + }; +} 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/run.ts b/src/run.ts index 558d11c..ad0d6f8 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}\nLocal-only supervisor: redacted agent command, progress, and session detail; no 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..0fc11fe --- /dev/null +++ b/src/web.ts @@ -0,0 +1,912 @@ +/** + * 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 { existsSync, readFileSync, statSync } from "node:fs"; +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"; + +export type FleetSession = { + provider: string; + name: string; + freshness: FleetFreshness; + activity: string; +}; + +export type LiveAgent = { + agent: "Grok" | "Claude" | "Codex" | "Cursor" | "Gemini" | "Antigravity"; + cwd: string; + operation?: + | "Verifying a Spec Sync change" + | "Working in project" + | "Antigravity session details unavailable"; + command?: string; + startedAt?: string; +}; + +export type WorkingAgent = Omit & { + repo: string; + worktree: string | null; + branch: string | null; + 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" | "Adapter unavailable"; + lastAction: string; + command: string | null; + startedAt: string | null; + latestMessage: string | null; + recentActivity: string[]; + detailAvailability: "available" | "unavailable"; + phase?: string; + heartbeatAt?: string | null; + lastOutputAt?: string | null; + confidence?: "high" | "medium" | "low"; + git?: FleetGitEvidence | null; +}; + +export type FleetGitEvidence = { + dirty: boolean; + ahead: number | null; + behind: number | null; + specSyncChange: string | null; +}; + +export type FleetWorktree = { + provider: string; + worktree: string; + branch: string; + status: string; +}; + +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 }>; +}; + +export type FleetSnapshot = { + source: "let"; + generatedAt: string; + refreshSeconds: number; + recentActivity: FleetRepository[]; + history: FleetRepository[]; + unmatchedSessions: FleetSession[]; + workingNow: WorkingAgent[]; + agents: FleetAgentActivity[]; + liveProcessDetection: "local-process"; + 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 { + 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 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", + gemini: "Gemini", + antigravity: "Antigravity", + "antigravity-cli": "Antigravity", + }; + 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); +} + +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[] { + try { + const processList = Bun.spawnSync({ + cmd: ["ps", "-axo", "pid=,ppid=,comm=,command="], + }); + if (processList.exitCode !== 0) { + return []; + } + const providers: Record = { + grok: "Grok", + claude: "Claude", + codex: "Codex", + cursor: "Cursor", + gemini: "Gemini", + antigravity: "Antigravity", + "antigravity-cli": "Antigravity", + }; + 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) { + return []; + } + if (!rootCwd) { + return agent === "Antigravity" + ? [ + { + agent, + cwd: "", + operation: "Antigravity session details unavailable" as const, + }, + ] + : []; + } + 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), + }); + } + } + 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); + } catch { + return []; + } +} + +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", + }; +} + +export function gitEvidence( + cwd: string, + command: string | undefined, +): FleetGitEvidence | null { + const status = Bun.spawnSync({ + cmd: ["git", "-C", cwd, "status", "--porcelain"], + }); + if (status.exitCode !== 0) return null; + const counts = Bun.spawnSync({ + cmd: [ + "git", + "-C", + cwd, + "rev-list", + "--left-right", + "--count", + "@{upstream}...HEAD", + ], + }); + const values = new TextDecoder() + .decode(counts.stdout) + .trim() + .split(/\s+/) + .map(Number); + return { + dirty: new TextDecoder().decode(status.stdout).trim().length > 0, + behind: Number.isFinite(values[0]) ? values[0] : null, + ahead: Number.isFinite(values[1]) ? values[1] : null, + specSyncChange: command?.match(/CHG-\d+/i)?.[0] ?? null, + }; +} + +function sessionView(card: IndexCard, now: number): FleetSession { + return { + provider: card.host, + name: card.name, + ...freshness(card.mtime_ms, now), + }; +} + +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]", + ) + .replace( + /\/(?:Users|home)\/[A-Za-z0-9._-]+(?:\/[^\s"'`]+)*/g, + "[LOCAL PATH]", + ); +} + +/** Return explicit project, worktree, and branch labels for agent supervision. */ +export function fleetContextLabels( + agent: Pick, +): string[] { + const project = agent.project?.trim(); + const worktree = agent.worktree?.trim(); + const branch = agent.branch?.trim(); + const labels: string[] = []; + if (project) { + labels.push(`Project: ${project}`); + } + if (worktree) { + labels.push(`Worktree: ${worktree}`); + } + if (branch) { + labels.push(`Branch: ${branch}`); + } + return labels; +} + +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 { + return fleetSessionDetail( + readFileSync(card.path, "utf8").slice(-48_000), + redactLocalDetail, + ); + } catch { + return { + latestMessage: null, + recentActivity: [], + detailAvailability: "unavailable", + contextPath: null, + }; + } +} + +export function fleetStateForSessions(sessions: FleetSession[]): FleetState { + if ( + sessions.some( + (session) => + session.freshness === "live" || session.freshness === "recent", + ) + ) { + return "recent"; + } + 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, + now = Date.now(), +): Promise { + // 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), + 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, + 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(); + const worktreeDetails = new Map< + string, + { repo: string; worktree: string; branch: string } + >(); + for (const worktree of worktrees.items.slice(0, 48)) { + const key = repositoryPath(worktree); + 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", + }); + worktreeDetails.set(worktree.path, { + repo: repository.project, + worktree: worktree.name, + branch: worktree.branch ?? "Detached", + }); + repositories.set(key, repository); + } + + 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); + } + + labelCollidingProjects(repositories, worktreeDetails, worktreeRepoByPath); + + const agentsByName = new Map(); + const sessionMtimeByAgent = new Map(); + for (const session of sessions.items) { + const adapter = fleetAdapterFor(session); + if (!adapter) { + continue; + } + const agent = adapter.provider; + 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 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(view.freshness) === + sessionRank(existing.status === "recent" ? "recent" : "stale") && + candidateMtime > existingMtime) + ) { + const detail = sessionDetail(session); + const sessionContext = detail.contextPath + ? gitLocation(detail.contextPath) + : null; + agentsByName.set(agent, { + agent, + status: + view.freshness === "live" || view.freshness === "recent" + ? "recent" + : "history", + operation: + view.freshness === "stale" + ? "Previous session activity" + : "Recent session activity", + 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, + startedAt: null, + latestMessage: detail.latestMessage, + recentActivity: detail.recentActivity, + detailAvailability: detail.detailAvailability, + }); + sessionMtimeByAgent.set(agent, candidateMtime); + } + } + + 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); + const working: WorkingAgent = { + agent: agent.agent, + repo: details?.repo ?? "Unassigned running agent", + worktree: details?.worktree ?? null, + branch: details?.branch ?? null, + matched: details !== undefined, + operation: agent.operation, + }; + const prior = agentsByName.get(agent.agent); + agentsByName.set(agent.agent, { + agent: agent.agent, + status: "working", + operation: + agent.agent === "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: prior?.latestMessage ?? null, + recentActivity: prior?.recentActivity ?? [], + detailAvailability: + agent.command || prior?.detailAvailability === "available" + ? "available" + : "unavailable", + phase: + agent.operation === "Verifying a Spec Sync change" + ? "Verification" + : "Active work", + heartbeatAt: new Date(now).toISOString(), + lastOutputAt: prior?.lastOutputAt ?? null, + confidence: "high", + git: gitEvidence(agent.cwd, agent.command), + }); + 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 ( + order[left.status] - order[right.status] || + left.agent.localeCompare(right.agent) + ); + }); + + 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, + recentActivity, + history, + unmatchedSessions: unmatchedSessions + .sort((left, right) => left.activity.localeCompare(right.activity)) + .slice(0, 12), + workingNow, + agents, + liveProcessDetection: "local-process", + policy: + "Read-only local index. Expanded cards show redacted local task context and recent session text when available. No controls are exposed.", + }; +} + +/** 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)); +} + +/** Merge visible panel state without discarding an open panel in the other view. */ +export function mergeFleetOpenKeys( + priorOpenKeys: readonly string[], + renderedKeys: readonly string[], + currentlyOpenKeys: readonly string[], +): string[] { + const rendered = new Set(renderedKeys); + return [ + ...new Set([ + ...priorOpenKeys.filter((key) => !rendered.has(key)), + ...currentlyOpenKeys, + ]), + ]; +} + +/** 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 = fleetSupervisorHtmlV6(); + 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 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 fleetSupervisorHtmlV4(): string { + return fleetSupervisorHtmlV3().replace( + "restoreUiState=()=>{", + "restoreUiState=()=>{document.querySelectorAll('[data-view]').forEach(button=>button.setAttribute('aria-pressed',String(button.dataset.view===view)));", + ); +} + +function fleetSupervisorHtmlV5(): string { + return fleetSupervisorHtmlV4().replace( + "ui.openKeys=new Set([...document.querySelectorAll('details[data-fleet-key][open]')].map(panel=>panel.dataset.fleetKey));", + "const renderedKeys=[...document.querySelectorAll('details[data-fleet-key]')].map(panel=>panel.dataset.fleetKey),currentlyOpenKeys=[...document.querySelectorAll('details[data-fleet-key][open]')].map(panel=>panel.dataset.fleetKey);ui.openKeys=new Set([...ui.openKeys].filter(key=>!renderedKeys.includes(key)).concat(currentlyOpenKeys));", + ); +} + +function fleetSupervisorHtmlV6(): string { + return fleetSupervisorHtmlV5().replace( + "
Detail source'+e(a.evidence)+' · '+e(a.detailAvailability)+'
", + "
Evidence'+e(a.evidence)+' · '+e(a.confidence||'low')+' confidence · heartbeat '+e(a.heartbeatAt||'Unavailable')+'
Git'+e(a.git?((a.git.dirty?'dirty':'clean')+' · ahead '+(a.git.ahead??'unknown')+' · behind '+(a.git.behind??'unknown')+(a.git.specSyncChange?' · '+a.git.specSyncChange:'')):'Unavailable')+'
", + ); +} + +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…

`; +} + +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…

`; +} + +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 === "/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" }, + }); + } + return new Response("Not found", { status: 404 }); + }, + }); + return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop() }; +} 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(), 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 new file mode 100644 index 0000000..f4a5546 --- /dev/null +++ b/test/web.test.ts @@ -0,0 +1,248 @@ +import { describe, expect, test } from "bun:test"; +import { fleetAdapterFor, fleetSessionDetail } from "../src/fleet-adapters.ts"; +import { runLet } from "../src/run.ts"; +import { + buildFleetSnapshot, + fleetContextLabels, + fleetHtml, + fleetLiveChangeAnnouncement, + fleetStateForSessions, + gitEvidence, + mergeFleetOpenKeys, + parseLocalAgentProcessLines, + redactLocalDetail, + retainFleetOpenKeys, + selectAgentWorkContext, +} 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('"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"); + 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("Fleet API and HTML expose local supervision details without internal CWD keys", 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('"pid"'); + } + 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 project, worktree, and branch explicit", () => { + expect( + fleetContextLabels({ + project: "let", + worktree: "let", + branch: "feat/fleet", + }), + ).toEqual(["Project: let", "Worktree: 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( + [ + '{"cwd":"/Users/leif/Development/_CorvidLabs/let/.worktrees/fleet-web","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]"); + expect(detail.contextPath).toBe( + "/Users/leif/Development/_CorvidLabs/let/.worktrees/fleet-web", + ); +}); + +test("local supervisor details redact known secret-shaped values", () => { + const detail = redactLocalDetail( + "token=ghp_abcdefghijklmnopqrstuvwxyz123456 password=private-value", + ); + 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 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("details[data-fleet-key][open]"); + expect(page).toContain("captureUiState"); + expect(page).toContain("restoreUiState"); + expect(page).toContain("button.dataset.view===view"); + expect(page).toContain("setInterval(refresh,20000)"); + expect(page).toContain('role="status" aria-live="polite"'); + expect(page).not.toContain("No session heartbeat"); + expect(page).toContain( + "agent')+' working · '+plural(projects.length,'project", + ); +}); + +test("Fleet refresh retains only panels with a current stable key", () => { + expect( + retainFleetOpenKeys( + ["agent:codex", "agent:grok", "project:let"], + ["agent:codex", "project:let"], + ), + ).toEqual(["agent:codex", "project:let"]); +}); + +test("Fleet refresh keeps a hidden view's open panel until its card is removed", () => { + expect( + mergeFleetOpenKeys( + ["agent:grok", "project:let"], + ["project:let", "project:quill"], + ["project:let"], + ), + ).toEqual(["agent:grok", "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", [ + { 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("Git evidence reports a Spec Sync change when the command names one", () => { + const evidence = gitEvidence(process.cwd(), "specsync change check CHG-0068"); + expect(evidence?.specSyncChange).toBe("CHG-0068"); + expect(typeof evidence?.dirty).toBe("boolean"); +}); + +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); + 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" }]); +});