diff --git a/packages/pi-herd/package.json b/packages/pi-herd/package.json index f59b2c9..0e42f91 100644 --- a/packages/pi-herd/package.json +++ b/packages/pi-herd/package.json @@ -38,7 +38,7 @@ } }, "devDependencies": { - "@earendil-works/pi-coding-agent": "^0.79.3", + "@earendil-works/pi-coding-agent": "^0.80.2", "@weshipwork/pi-subagents": "workspace:*", "@types/node": "^24.10.1", "tsx": "^4.20.6", diff --git a/packages/pi-herdr/package.json b/packages/pi-herdr/package.json index 90700de..dc32e83 100644 --- a/packages/pi-herdr/package.json +++ b/packages/pi-herdr/package.json @@ -43,9 +43,9 @@ } }, "devDependencies": { - "@earendil-works/pi-ai": "^0.79.3", - "@earendil-works/pi-coding-agent": "^0.79.3", - "@earendil-works/pi-tui": "^0.79.3", + "@earendil-works/pi-ai": "^0.80.2", + "@earendil-works/pi-coding-agent": "^0.80.2", + "@earendil-works/pi-tui": "^0.80.2", "@types/node": "^24.10.1", "tsx": "^4.20.6", "typebox": "^1.1.24", diff --git a/packages/pi-subagents/CHANGELOG.md b/packages/pi-subagents/CHANGELOG.md index 62947bc..a84faa2 100644 --- a/packages/pi-subagents/CHANGELOG.md +++ b/packages/pi-subagents/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.12.0-mirror.0] - 2026-06-26 + +### Changed +- Updated the WeShip.work fork from upstream `@tintinweb/pi-subagents` `v0.10.3` to `v0.12.0`, including upstream `v0.10.4`, `v0.11.0`, and `v0.12.0` changes. +- Preserved the read-only Mirror mode observation seam for `@weshipwork/pi-herd`. + +## [0.12.0] - 2026-06-24 + +### Added +- **FleetView — a Claude Code-style subagent navigator below the editor.** A persistent, navigable list of `main` + every active subagent renders beneath the editor whenever agents are running — **auto-shown, no keypress needed** — mirroring Claude Code's bottom fleet bar: `⏺`/`◯` selection markers, agent type + description, right-aligned `elapsed · ↓ tokens`, and a `↓ N more` overflow once past five rows. Press `↓` (or `←`) at an **empty prompt** to move focus into the list, `↑`/`↓` to select, `Enter` to open the selected agent's live, auto-updating conversation overlay, and `Esc` (or `↑` above `main`) to return to the prompt. Implemented as a `belowEditor` widget with all key handling routed through `onTerminalInput` (which fires before the editor); it only captures arrow keys at an empty prompt — and acts on key-**press** only (kitty-protocol release events are ignored, otherwise each tap moved twice) — so typing, history, and cursor movement are untouched. Rows are ordered **earliest-launched first**; only openable agents (those with a session) are shown, so pending/queued agents appear once they start and `Enter` never dead-ends; **finished agents linger ~4s** before dropping out (their elapsed freezes at completion); and a viewer **stays open through its agent's completion** so the final output remains readable. Selection follows the viewed agent by id, so closing a viewer returns you to the same agent even if the list reordered while it was open. Every rendered line is width-clamped — the narrow-terminal crash/flicker class previously fixed in v0.2.7 and [#7](https://github.com/tintinweb/pi-subagents/issues/7). Toggle via `/agents → Settings → Fleet view` (default on; pure-UI, so no LLM-context cost). + +## [0.11.0] - 2026-06-23 + +### Added +- **`persist_session` / `session_dir` agent frontmatter — persist a subagent as a real pi session** ([#111](https://github.com/tintinweb/pi-subagents/pull/111) — thanks [@codesoda](https://github.com/codesoda)). `persist_session: true` runs the subagent through `SessionManager.create(...)` instead of `SessionManager.inMemory(...)`, so its full transcript is written to pi's normal session location (`~/.pi/agent/sessions`) — inspectable and resumable after the fact, like a top-level session — rather than living in memory only. Useful for long-running, multi-round orchestrations (plan → review → implement → verify) where each subagent's conversation is worth keeping. `session_dir` optionally overrides where the persisted session is written (absolute, `~`, or agent-cwd-relative path); omitted, persistence follows pi's own precedence — `PI_CODING_AGENT_SESSION_DIR`, then the settings manager's `getSessionDir()`, then pi's default location. Both default off/unset, so existing agents are unchanged: the in-memory path is byte-identical to before, and the sidechain `.output` transcript is still written either way. + +## [0.10.4] - 2026-06-23 + +### Fixed +- **Background agent records lost before result is read** ([#108](https://github.com/tintinweb/pi-subagents/issues/108) — thanks [@philipmw](https://github.com/philipmw)). On session switch or `/new`/`/resume`, `clearCompleted()` removed completed agent records regardless of whether the LLM had retrieved the result, causing `get_subagent_result` to return "Agent not found" for agents that had finished but hadn't been checked yet. `clearCompleted()` now accepts a `skipUnconsumed` flag; session event handlers pass `true`, so records with `resultConsumed=false` are preserved across session transitions. The 10-minute cleanup timer handles eventual eviction. Note: a full session shutdown (`session_shutdown`) calls `dispose()` which clears all records unconditionally — that path is not affected by this fix. + +### Added +- **Foreground agent lifecycle completion and conversation logging** ([#105](https://github.com/tintinweb/pi-subagents/pull/105) — thanks [@benrhodeland](https://github.com/benrhodeland)). Two gaps closed: (1) **`onComplete` now fires for foreground agents**, emitting `subagents:completed` / `subagents:failed` lifecycle events and writing a `subagents:record` entry to the parent JSONL — previously only background agents emitted these, leaving cross-extension observers with an orphaned `subagents:started` event and no matching completion. `resultConsumed` is pre-set so the callback skips notifications (the result is returned inline); no change to the tool's return value. (2) **Foreground agent conversations are now streamed to `.output` files** (same `.pi/output/agent-.jsonl` path as background agents) — inline subagent transcripts were previously permanently lost after `spawnAndWait` returned. + ## [0.10.3] - 2026-06-12 ### Added diff --git a/packages/pi-subagents/README.md b/packages/pi-subagents/README.md index d2794e5..92a7b9e 100644 --- a/packages/pi-subagents/README.md +++ b/packages/pi-subagents/README.md @@ -15,6 +15,7 @@ https://github.com/user-attachments/assets/8685261b-9338-4fea-8dfe-1c590d5df543 - **Claude Code look & feel** — same tool names, calling conventions, and UI patterns (`Agent`, `get_subagent_result`, `steer_subagent`) — feels native - **Parallel background agents** — spawn multiple agents that run concurrently with automatic queuing (configurable concurrency limit, default 4) and smart group join (consolidated notifications) - **Live widget UI** — persistent above-editor widget with animated spinners, live tool activity, token counts, and colored status icons +- **FleetView** — Claude Code-style navigable list of `main` + every running subagent rendered below the editor (earliest-launched first). Press `↓` (or `←`) at an empty prompt to jump in, `↑`/`↓` to move the selection, `Enter` to open the selected agent's live, auto-updating conversation, `Esc` to return. Finished agents linger briefly before dropping out, and a viewer stays open through completion so you can read the final output. Toggle via `/agents → Settings → Fleet view` - **Conversation viewer** — select any agent in `/agents` to open a live-scrolling overlay of its full conversation (auto-follows new content, scroll up to pause). Stop a still-running agent from here by pressing `x` (then `x` again to confirm) — works for background agents too - **Custom agent types** — define agents in `.pi/agents/.md` with YAML frontmatter: custom system prompts, model selection, thinking levels, tool restrictions - **Mid-run steering** — inject messages into running agents to redirect their work without restarting @@ -50,7 +51,7 @@ pi install -l npm:@weshipwork/pi-subagents Pin an exact npm version: ```sh -pi install npm:@weshipwork/pi-subagents@0.10.3-mirror.0 +pi install npm:@weshipwork/pi-subagents@0.12.0-mirror.0 ``` Try it for one Pi run without writing settings: @@ -150,6 +151,21 @@ The token field is annotated with two optional signals inside parens: - **`NN%`** — context-window utilization (color-coded: <70% dim, 70–85% warning, ≥85% error). Omitted when the model has no declared `contextWindow`, or briefly right after compaction. - **`⇊N`** — number of times the session has compacted, when > 0. Stays dim; the percent's color carries urgency. +### FleetView + +While subagents are running, a Claude Code-style navigable list renders **below** the editor: + +``` + esc to interrupt · ← for agents · ↓ to manage + + ⏺ main + ◯ general-purpose Sleep then report 1 11s · ↓ 13.1k tokens + ◯ general-purpose Sleep then report 2 11s · ↓ 13.1k tokens + ↓ 3 more +``` + +The list is ordered earliest-launched first, and only shows agents you can actually open (pending/queued agents with no session yet appear once they start). At an **empty prompt**, press `↓` (or `←`) to move focus from the prompt into the list — the selected row is marked `⏺`, the rest `◯`. `↑`/`↓` move the selection, `Enter` opens the selected agent's live conversation overlay (it auto-updates as the agent works), and `Esc` (or `↑` above `main`) returns to the prompt. Selecting `main` returns to the normal view. A viewer stays open when its agent finishes so you can read the final output, and finished agents linger in the list for a few seconds before dropping out. Typing anything at a non-empty prompt behaves normally — the list only captures arrow keys when the prompt is empty. Disable it entirely via `/agents → Settings → Fleet view`. + Individual agent results render Claude Code-style in the conversation: | State | Example | @@ -163,7 +179,7 @@ Individual agent results render Claude Code-style in the conversation: Completed results can be expanded (ctrl+o in pi) to show the full agent output inline. -Background agent completion notifications render as styled boxes: +Both foreground and background agents stream their full conversation to a `.pi/output/agent-.jsonl` transcript file. Background agent completion notifications render as styled boxes: ``` ✓ Find auth files completed @@ -243,6 +259,8 @@ All fields are optional — sensible defaults for everything. | `model` | inherit parent | Model — `provider/modelId` or fuzzy name (`"haiku"`, `"sonnet"`) | | `thinking` | inherit | off, minimal, low, medium, high, xhigh | | `max_turns` | unlimited | Max agentic turns before graceful shutdown. `0` or omit for unlimited | +| `persist_session` | `false` | Persist this subagent as a normal pi session instead of keeping the session in memory only. The sidechain output transcript is still written either way | +| `session_dir` | pi default | Optional session directory when `persist_session: true`; omitted uses pi's normal session location, and relative paths resolve from the agent cwd | | `prompt_mode` | `replace` | `replace`: body is the full system prompt (no AGENTS.md / CLAUDE.md inheritance). `append`: body appended to parent's prompt (agent acts as a "parent twin" — inherits parent's AGENTS.md / CLAUDE.md) | | `inherit_context` | `false` | Fork parent conversation into agent | | `run_in_background` | `false` | Run in background by default | @@ -457,8 +475,8 @@ Agent lifecycle events are emitted via `pi.events.emit()` so other extensions ca |-------|------|------------| | `subagents:created` | Background agent registered | `id`, `type`, `description`, `isBackground` | | `subagents:started` | Agent transitions to running (including queued→running) | `id`, `type`, `description` | -| `subagents:completed` | Agent finished successfully | `id`, `type`, `durationMs`, `tokens` (lifetime `{ input, output, total }`), `toolUses`, `result` | -| `subagents:failed` | Agent errored, stopped, or aborted | same as completed + `error`, `status` | +| `subagents:completed` | Agent finished successfully (background and foreground) | `id`, `type`, `durationMs`, `tokens` (lifetime `{ input, output, total }`), `toolUses`, `result` | +| `subagents:failed` | Agent errored, stopped, or aborted (background and foreground) | same as completed + `error`, `status` | | `subagents:steered` | Steering message sent | `id`, `message` | | `subagents:compacted` | Agent's session successfully compacted | `id`, `type`, `description`, `reason` (`"manual"` / `"threshold"` / `"overflow"`), `tokensBefore`, `compactionCount` | | `subagents:scheduled` | Schedule lifecycle change | `{ type: "added" \| "removed" \| "updated" \| "fired" \| "error", … }` (job/agentId/error fields per type) | diff --git a/packages/pi-subagents/README.mirror.md b/packages/pi-subagents/README.mirror.md index bb5c834..7b7774d 100644 --- a/packages/pi-subagents/README.mirror.md +++ b/packages/pi-subagents/README.mirror.md @@ -2,6 +2,8 @@ This package is a minimal fork of `@tintinweb/pi-subagents` for `@weshipwork/pi-herd` Mirror mode. +Current fork release: `0.12.0-mirror.0`, tracking upstream `@tintinweb/pi-subagents` tag `v0.12.0`. + The fork adds a read-only observation seam at `src/mirror.ts` and installs `globalThis.__piSubagentsMirrorService` so `pi-herd` can mirror Subagent snapshots and live updates without hosting or steering Subagents. ## Install for Mirror mode diff --git a/packages/pi-subagents/package.json b/packages/pi-subagents/package.json index 82bd4b5..e1d8936 100644 --- a/packages/pi-subagents/package.json +++ b/packages/pi-subagents/package.json @@ -1,6 +1,6 @@ { "name": "@weshipwork/pi-subagents", - "version": "0.10.3-mirror.0", + "version": "0.12.0-mirror.0", "description": "Fork of tintinweb pi-subagents with read-only mirror observation support for pi-herd.", "author": "Three One Four / tintinweb", "license": "MIT", diff --git a/packages/pi-subagents/src/agent-manager.ts b/packages/pi-subagents/src/agent-manager.ts index 05a5707..d47b5ae 100644 --- a/packages/pi-subagents/src/agent-manager.ts +++ b/packages/pi-subagents/src/agent-manager.ts @@ -311,7 +311,12 @@ export class AgentManager { } } - if (options.isBackground) { + // Fire onComplete for foreground agents too — lifecycle symmetry. + // Mark resultConsumed so the callback skips notifications (result returned inline). + if (!options.isBackground) { + record.resultConsumed = true; + try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ } + } else { this.runningBackground--; try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ } this.drainQueue(); @@ -342,7 +347,12 @@ export class AgentManager { } catch { /* ignore cleanup errors */ } } - if (options.isBackground) { + // Fire onComplete for foreground agents too — lifecycle symmetry. + // Mark resultConsumed so the callback skips notifications (result returned inline). + if (!options.isBackground) { + record.resultConsumed = true; + this.onComplete?.(record); + } else { this.runningBackground--; this.onComplete?.(record); this.drainQueue(); @@ -351,6 +361,11 @@ export class AgentManager { }); record.promise = promise; + + // Notify caller that spawn is complete (record is in the map, promise is set). + // Called synchronously — onSessionCreated fires asynchronously inside runAgent. + // Used by spawnAndWait to let the caller set up output files before streaming starts. + this.onSpawned?.(id); } /** Start queued agents up to the concurrency limit. */ @@ -372,9 +387,20 @@ export class AgentManager { } } + /** + * Called synchronously right after spawn, before onSessionCreated fires. + * Lets the caller set up the output file path on the record. + * The record is guaranteed to be in this.agents at this point. + */ + private onSpawned?: (id: string) => void; + /** * Spawn an agent and wait for completion (foreground use). * Foreground agents bypass the concurrency queue. + * Returns { id, record } so callers can access the agent ID. + * + * @param onSpawned - Called synchronously after spawn(), before onSessionCreated fires. + * Use this to set record.outputFile so streamToOutputFile can pick it up. */ async spawnAndWait( pi: ExtensionAPI, @@ -382,11 +408,19 @@ export class AgentManager { type: SubagentType, prompt: string, options: Omit, - ): Promise { - const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false }); - const record = this.agents.get(id)!; - await record.promise; - return record; + onSpawned?: (id: string) => void, + ): Promise<{ id: string; record: AgentRecord }> { + // Temporarily register the onSpawned hook so startAgent can call it. + const prevOnSpawned = this.onSpawned; + this.onSpawned = onSpawned; + try { + const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false }); + const record = this.agents.get(id)!; + await record.promise; + return { id, record }; + } finally { + this.onSpawned = prevOnSpawned; + } } /** @@ -480,10 +514,13 @@ export class AgentManager { /** * Remove all completed/stopped/errored records immediately. * Called on session start/switch so tasks from a prior session don't persist. + * Pass skipUnconsumed=true to preserve records the LLM hasn't read yet + * (resultConsumed=false) — they will be evicted by the 10-minute cleanup timer instead. */ - clearCompleted(): void { + clearCompleted(skipUnconsumed = false): void { for (const [id, record] of this.agents) { if (record.status === "running" || record.status === "queued") continue; + if (skipUnconsumed && !record.resultConsumed) continue; this.removeRecord(id, record); } } diff --git a/packages/pi-subagents/src/agent-runner.ts b/packages/pi-subagents/src/agent-runner.ts index a5e300e..ef075ea 100644 --- a/packages/pi-subagents/src/agent-runner.ts +++ b/packages/pi-subagents/src/agent-runner.ts @@ -288,6 +288,13 @@ function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => return () => signal.removeEventListener("abort", onAbort); } +function resolveConfiguredSessionDir(sessionDir: string | undefined, cwd: string): string | undefined { + if (!sessionDir) return undefined; + if (sessionDir === "~" || sessionDir.startsWith("~/")) return resolve(homedir(), sessionDir.slice(2)); + if (isAbsolute(sessionDir)) return sessionDir; + return resolve(cwd, sessionDir); +} + export async function runAgent( ctx: ExtensionContext, type: SubagentType, @@ -576,11 +583,18 @@ export async function runAgent( return !noExtensions; }); + const settingsManager = SettingsManager.create(configCwd, agentDir); + const configuredSessionDir = resolveConfiguredSessionDir(agentConfig?.sessionDir, effectiveCwd); + const defaultSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR ?? settingsManager.getSessionDir?.(); + const sessionManager = agentConfig?.persistSession + ? SessionManager.create(effectiveCwd, configuredSessionDir ?? defaultSessionDir) + : SessionManager.inMemory(effectiveCwd); + const sessionOpts: Parameters[0] = { cwd: effectiveCwd, agentDir, - sessionManager: SessionManager.inMemory(effectiveCwd), - settingsManager: SettingsManager.create(configCwd, agentDir), + sessionManager, + settingsManager, modelRegistry: ctx.modelRegistry, model, tools: allowedTools, diff --git a/packages/pi-subagents/src/custom-agents.ts b/packages/pi-subagents/src/custom-agents.ts index f44283f..8fb525c 100644 --- a/packages/pi-subagents/src/custom-agents.ts +++ b/packages/pi-subagents/src/custom-agents.ts @@ -65,6 +65,8 @@ function loadFromDir(dir: string, agents: Map, source: "pro model: str(fm.model), thinking: str(fm.thinking) as ThinkingLevel | undefined, maxTurns: nonNegativeInt(fm.max_turns), + persistSession: fm.persist_session != null ? fm.persist_session === true : undefined, + sessionDir: str(fm.session_dir), systemPrompt: body.trim(), promptMode: fm.prompt_mode === "append" ? "append" : "replace", inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined, diff --git a/packages/pi-subagents/src/index.ts b/packages/pi-subagents/src/index.ts index dc21d91..3ee30e5 100644 --- a/packages/pi-subagents/src/index.ts +++ b/packages/pi-subagents/src/index.ts @@ -46,6 +46,7 @@ import { SPINNER, type UICtx, } from "./ui/agent-widget.js"; +import { FleetList, type FleetUICtx } from "./ui/fleet-list.js"; import { showSchedulesMenu } from "./ui/schedule-menu.js"; import { addUsage, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage } from "./usage.js"; @@ -299,6 +300,7 @@ export default function (pi: ExtensionAPI) { function sendIndividualNudge(record: AgentRecord) { agentActivity.delete(record.id); widget.markFinished(record.id); + fleet.onAgentFinished(record.id); scheduleNudge(record.id, () => emitIndividualNudge(record)); widget.update(); } @@ -306,7 +308,7 @@ export default function (pi: ExtensionAPI) { // ---- Group join manager ---- const groupJoin = new GroupJoinManager( (records, partial) => { - for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); } + for (const r of records) { agentActivity.delete(r.id); widget.markFinished(r.id); fleet.onAgentFinished(r.id); } const groupKey = `group:${records.map(r => r.id).join(",")}`; scheduleNudge(groupKey, () => { @@ -387,6 +389,7 @@ export default function (pi: ExtensionAPI) { if (record.resultConsumed) { agentActivity.delete(record.id); widget.markFinished(record.id); + fleet.onAgentFinished(record.id); widget.update(); return; } @@ -471,12 +474,12 @@ export default function (pi: ExtensionAPI) { // Capture ctx from session_start for RPC spawn handler + start the scheduler. pi.on("session_start", async (_event, ctx) => { currentCtx = ctx; - manager.clearCompleted(); + manager.clearCompleted(true); if (isSchedulingEnabled() && !scheduler.isActive()) startScheduler(ctx); }); pi.on("session_before_switch", () => { - manager.clearCompleted(); + manager.clearCompleted(true); scheduler.stop(); }); @@ -503,12 +506,19 @@ export default function (pi: ExtensionAPI) { manager.abortAll(); for (const timer of pendingNudges.values()) clearTimeout(timer); pendingNudges.clear(); + fleet.dispose(); manager.dispose(); }); // Live widget: show running agents above editor const widget = new AgentWidget(manager, agentActivity); + // Claude Code-style FleetView: navigable list of main + subagents below the editor. + const fleet = new FleetList(manager, agentActivity); + let fleetViewEnabled = true; + function isFleetViewEnabled(): boolean { return fleetViewEnabled; } + function setFleetViewEnabled(b: boolean): void { fleetViewEnabled = b; fleet.setEnabled(b); } + // ---- Join mode configuration ---- let defaultJoinMode: JoinMode = 'smart'; function getDefaultJoinMode(): JoinMode { return defaultJoinMode; } @@ -601,6 +611,7 @@ export default function (pi: ExtensionAPI) { // Grab UI context from first tool execution + clear lingering widget on new turn pi.on("tool_execution_start", async (_event, ctx) => { widget.setUICtx(ctx.ui as UICtx); + fleet.setUICtx(ctx.ui as unknown as FleetUICtx); widget.onTurnStart(); }); @@ -660,6 +671,7 @@ export default function (pi: ExtensionAPI) { setScopeModels: setScopeModelsEnabled, setDisableDefaultAgents: setDisableDefaultAgents, setToolDescriptionMode: setToolDescriptionMode, + setFleetView: setFleetViewEnabled, }, (event, payload) => pi.events.emit(event, payload), ); @@ -1173,6 +1185,8 @@ Terse command-style prompts produce shallow, generic work. agentActivity.set(id, bgState); widget.ensureTimer(); widget.update(); + fleet.ensureTimer(); + fleet.update(); if (record) { mirrorHub.publish({ type: PI_SUBAGENTS_MIRROR_EVENT_TYPE.AGENT_SNAPSHOT, agent: agentRecordToMirrorSnapshot(record) }); @@ -1233,7 +1247,9 @@ Terse command-style prompts produce shallow, generic work. } }; - // Wire session creation to register in widget + // Wire session creation: register in widget + stream to output file. + // The output file path is set synchronously after spawn (below), + // before onSessionCreated fires — same pattern as background agents. const origOnSession = fgCallbacks.onSessionCreated; fgCallbacks.onSessionCreated = (session: any) => { origOnSession(session); @@ -1243,9 +1259,18 @@ Terse command-style prompts produce shallow, generic work. mirrorHub.publish({ type: PI_SUBAGENTS_MIRROR_EVENT_TYPE.AGENT_SNAPSHOT, agent: agentRecordToMirrorSnapshot(a) }); agentActivity.set(a.id, fgState); widget.ensureTimer(); + fleet.ensureTimer(); + fleet.update(); break; } } + // Stream conversation to output file (foreground agent logging) + if (fgId) { + const rec = manager.getRecord(fgId); + if (rec?.outputFile) { + rec.outputCleanup = streamToOutputFile(session, rec.outputFile, fgId, ctx.cwd); + } + } }; // Animate spinner at ~80ms (smooth rotation through 10 braille frames) @@ -1258,7 +1283,7 @@ Terse command-style prompts produce shallow, generic work. let record: AgentRecord; try { - record = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, { + const fgResult = await manager.spawnAndWait(pi, ctx, subagentType, params.prompt, { description: params.description, model, maxTurns: effectiveMaxTurns, @@ -1269,7 +1294,16 @@ Terse command-style prompts produce shallow, generic work. invocation: agentInvocation, signal, ...fgCallbacks, + }, (fgAgentId) => { + // onSpawned: called synchronously after spawn, before onSessionCreated fires. + // Set up the output file so streamToOutputFile can pick it up. + const fgRec = manager.getRecord(fgAgentId); + if (fgRec) { + fgRec.outputFile = createOutputFilePath(ctx.cwd, fgAgentId, ctx.sessionManager.getSessionId()); + writeInitialEntry(fgRec.outputFile, fgAgentId, params.prompt, ctx.cwd); + } }); + record = fgResult.record; } catch (err) { clearInterval(spinnerInterval); return textResult(err instanceof Error ? err.message : String(err)); @@ -1281,6 +1315,7 @@ Terse command-style prompts produce shallow, generic work. if (fgId) { agentActivity.delete(fgId); widget.markFinished(fgId); + fleet.onAgentFinished(fgId); } // Get final token count @@ -1872,7 +1907,7 @@ Guidelines for choosing settings: Write the file using the write tool. Only write the file, nothing else.`; - const record = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, { + const { record } = await manager.spawnAndWait(pi, ctx, "general-purpose", generatePrompt, { description: `Generate ${name} agent`, maxTurns: 5, }); @@ -1991,6 +2026,7 @@ ${systemPrompt} scopeModels: isScopeModelsEnabled(), disableDefaultAgents: isDefaultsDisabled(), toolDescriptionMode: getToolDescriptionMode(), + fleetView: isFleetViewEnabled(), }; } @@ -2052,6 +2088,13 @@ ${systemPrompt} currentValue: isDefaultsDisabled() ? "on" : "off", values: ["on", "off"], }, + { + id: "fleetView", + label: "Fleet view", + description: "Claude Code-style main+subagents list below the editor (↓/← to navigate, Enter to view)", + currentValue: isFleetViewEnabled() ? "on" : "off", + values: ["on", "off"], + }, { id: "toolDescriptionMode", label: "Tool description", @@ -2110,6 +2153,10 @@ ${systemPrompt} } else if (id === "toolDescriptionMode") { setToolDescriptionMode(value as ToolDescriptionMode); notifyApplied(ctx, `Tool description set to ${value}. Takes effect on next pi session.`); + } else if (id === "fleetView") { + const enabled = value === "on"; + setFleetViewEnabled(enabled); + notifyApplied(ctx, `Fleet view ${enabled ? "enabled" : "disabled"}`); } } diff --git a/packages/pi-subagents/src/settings.ts b/packages/pi-subagents/src/settings.ts index 7a5cd74..8a1397f 100644 --- a/packages/pi-subagents/src/settings.ts +++ b/packages/pi-subagents/src/settings.ts @@ -66,6 +66,12 @@ export interface SubagentsSettings { * next pi session. */ toolDescriptionMode?: ToolDescriptionMode; + /** + * Whether the Claude Code-style FleetView (the navigable main+subagents list + * rendered below the editor) is shown. Defaults to `true`. Pure-UI: when off, + * the list never registers and the global key handler never captures input. + */ + fleetView?: boolean; } export type ToolDescriptionMode = "full" | "compact" | "custom"; @@ -80,6 +86,7 @@ export interface SettingsAppliers { setScopeModels: (enabled: boolean) => void; setDisableDefaultAgents: (b: boolean) => void; setToolDescriptionMode: (mode: ToolDescriptionMode) => void; + setFleetView: (b: boolean) => void; } /** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */ @@ -136,6 +143,9 @@ function sanitize(raw: unknown): SubagentsSettings { if (typeof r.toolDescriptionMode === "string" && VALID_TOOL_DESCRIPTION_MODES.has(r.toolDescriptionMode)) { out.toolDescriptionMode = r.toolDescriptionMode as ToolDescriptionMode; } + if (typeof r.fleetView === "boolean") { + out.fleetView = r.fleetView; + } return out; } @@ -194,6 +204,7 @@ export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels); if (typeof s.disableDefaultAgents === "boolean") appliers.setDisableDefaultAgents(s.disableDefaultAgents); if (s.toolDescriptionMode) appliers.setToolDescriptionMode(s.toolDescriptionMode); + if (typeof s.fleetView === "boolean") appliers.setFleetView(s.fleetView); } /** diff --git a/packages/pi-subagents/src/types.ts b/packages/pi-subagents/src/types.ts index bd57025..c43679e 100644 --- a/packages/pi-subagents/src/types.ts +++ b/packages/pi-subagents/src/types.ts @@ -41,6 +41,10 @@ export interface AgentConfig { model?: string; thinking?: ThinkingLevel; maxTurns?: number; + /** Persist this subagent as a normal pi session instead of keeping it in memory only. */ + persistSession?: boolean; + /** Optional session directory used when persistSession is true. Omitted = pi's normal session location. */ + sessionDir?: string; systemPrompt: string; promptMode: "replace" | "append"; /** Default for spawn: fork parent conversation. undefined = caller decides. */ diff --git a/packages/pi-subagents/src/ui/fleet-list.ts b/packages/pi-subagents/src/ui/fleet-list.ts new file mode 100644 index 0000000..f46b590 --- /dev/null +++ b/packages/pi-subagents/src/ui/fleet-list.ts @@ -0,0 +1,358 @@ +/** + * fleet-list.ts — Claude Code-style "FleetView" list rendered below the editor. + * + * Shows `main` + each running/queued subagent as a navigable list. Pressing ↓ (or + * ←) at an empty prompt activates the list; ↑/↓ move the selection (filled ⏺ marker), + * Enter opens the selected agent's live conversation overlay, Esc returns to the prompt. + * A viewer stays open when its agent finishes; finished agents linger briefly in the list. + * + * Mechanics (see plan): the list is a `belowEditor` widget (render-only), and ALL key + * handling goes through `onTerminalInput` — which fires before the focused editor and + * can `consume` keys — gated on `getEditorText() === ""` so normal typing is untouched. + */ + +import { isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui"; +import type { AgentManager } from "../agent-manager.js"; +import type { AgentRecord } from "../types.js"; +import { getLifetimeTotal } from "../usage.js"; +import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js"; +import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js"; + +/** Widget key for the below-editor fleet list. */ +const FLEET_KEY = "fleet"; +/** Max agent rows shown at once; extras collapse into a "↓ N more" indicator. */ +const MAX_AGENT_ROWS = 5; +/** Re-render cadence so elapsed/token stats tick while agents run. */ +const TICK_MS = 200; +/** How long a finished agent lingers in the list before it drops out. */ +const FINISHED_LINGER_MS = 4000; + +/** Minimal UI surface the FleetView needs from `ctx.ui` (structural subset). */ +export type FleetUICtx = { + setWidget( + key: string, + content: undefined | ((tui: any, theme: Theme) => { render(width: number): string[]; invalidate(): void; dispose?(): void }), + options?: { placement?: "aboveEditor" | "belowEditor" }, + ): void; + onTerminalInput(handler: (data: string) => { consume?: boolean; data?: string } | undefined): () => void; + getEditorText(): string; + notify(message: string, type?: "info" | "warning" | "error"): void; + custom( + factory: (tui: any, theme: Theme, keybindings: any, done: (result: T) => void) => { render(width: number): string[]; invalidate(): void; dispose?(): void }, + options?: { overlay?: boolean; overlayOptions?: unknown; onHandle?: (handle: unknown) => void }, + ): Promise; +}; + +type MainEntry = { kind: "main" }; +type AgentEntry = { kind: "agent"; record: AgentRecord }; +type FleetEntry = MainEntry | AgentEntry; + +/** `11s` — integer seconds, no decimal/suffix (matches Claude Code, unlike formatMs). */ +export function formatFleetElapsed(ms: number): string { + return `${Math.max(0, Math.round(ms / 1000))}s`; +} + +/** `↓ 13.1k tokens` — down-arrow prefix, compact magnitude, plural "tokens". */ +export function formatFleetTokens(count: number): string { + let compact: string; + if (count >= 1_000_000) compact = `${(count / 1_000_000).toFixed(1)}M`; + else if (count >= 1_000) compact = `${(count / 1_000).toFixed(1)}k`; + else compact = `${count}`; + return `↓ ${compact} tokens`; +} + +/** + * Place `right` flush to `width`, truncating `left` first so the stats survive. + * The final clamp guarantees the line never exceeds `width` (which would wrap and + * desync pi's line-diff → flicker) even on a terminal too narrow for the stats. + */ +function rightAlign(left: string, right: string, width: number): string { + const rightW = visibleWidth(right); + const maxLeft = Math.max(0, width - rightW - 1); + const leftClamped = truncateToWidth(left, maxLeft); + const gap = Math.max(1, width - visibleWidth(leftClamped) - rightW); + return truncateToWidth(leftClamped + " ".repeat(gap) + right, width); +} + +export class FleetList { + private ui: FleetUICtx | undefined; + private tui: any | undefined; + private inputUnsub: (() => void) | undefined; + private widgetRegistered = false; + private timer: ReturnType | undefined; + + private enabled = true; + /** Whether arrow keys currently navigate the list (vs. flow to the editor). */ + private active = false; + /** 0 = `main`, 1..N = subagents. */ + private selectedIndex = 0; + /** Set while a conversation overlay is open; calling it closes the overlay. */ + private viewerClose: (() => void) | undefined; + private viewingAgentId: string | undefined; + + constructor( + private manager: AgentManager, + private agentActivity: Map, + ) {} + + // ---- Lifecycle ---- + + setEnabled(enabled: boolean): void { + if (enabled === this.enabled) return; + this.enabled = enabled; + if (!enabled) this.active = false; + this.update(); + } + + /** Capture the UI context and (re)register the global input handler. */ + setUICtx(ui: FleetUICtx): void { + if (ui === this.ui) return; + this.inputUnsub?.(); + this.ui = ui; + this.widgetRegistered = false; + this.tui = undefined; + this.inputUnsub = ui.onTerminalInput(data => this.handleKey(data)); + } + + /** Ensure the re-render timer is running (called when an agent spawns). */ + ensureTimer(): void { + if (!this.timer) this.timer = setInterval(() => this.update(), TICK_MS); + } + + /** + * Called when an agent finishes. The viewer (if open on it) stays open so the + * final output remains readable, and the row lingers in the list — just refresh. + */ + onAgentFinished(_id: string): void { + this.update(); + } + + dispose(): void { + if (this.timer) { clearInterval(this.timer); this.timer = undefined; } + this.inputUnsub?.(); + this.inputUnsub = undefined; + if (this.viewerClose) { this.viewerClose(); this.viewerClose = undefined; } + this.viewingAgentId = undefined; + if (this.ui && this.widgetRegistered) this.ui.setWidget(FLEET_KEY, undefined); + this.widgetRegistered = false; + this.tui = undefined; + this.active = false; + // Null last so a `viewerClose()` microtask above can't re-register the widget. + this.ui = undefined; + } + + /** Re-register/refresh the below-editor widget; clears it when no agents remain. */ + update(): void { + if (!this.ui) return; + const hasAgents = this.enabled && this.agentRecords().length > 0; + + if (!hasAgents) { + if (this.widgetRegistered) { + this.ui.setWidget(FLEET_KEY, undefined); + this.widgetRegistered = false; + this.tui = undefined; + } + if (this.timer) { clearInterval(this.timer); this.timer = undefined; } + this.active = false; + this.selectedIndex = 0; + return; + } + + this.clampSelection(); + this.ensureTimer(); // keep stats ticking whenever the list is shown (e.g. after a re-enable) + + if (!this.widgetRegistered) { + this.ui.setWidget(FLEET_KEY, (tui, theme) => { + this.tui = tui; + return { + render: (w: number) => this.renderBar(w, theme), + invalidate: () => { this.widgetRegistered = false; this.tui = undefined; }, + }; + }, { placement: "belowEditor" }); + this.widgetRegistered = true; + } else { + this.tui?.requestRender(); + } + } + + // ---- Roster ---- + + /** + * Agents shown in the list, ordered earliest-launched first so the ones you + * started sooner sit at the top. Every row is openable (has a session), so Enter + * never dead-ends. Included: running/queued, plus the agent currently being + * viewed, plus recently-finished ones (they linger briefly before dropping out). + * Pending agents with no session yet are hidden until they start. + * (`listAgents()` is newest-first, so we re-sort.) + */ + private agentRecords(): AgentRecord[] { + const now = Date.now(); + return this.manager.listAgents() + .filter(a => a.session && ( + a.status === "running" || a.status === "queued" + || a.id === this.viewingAgentId + || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS) + )) + .sort((a, b) => a.startedAt - b.startedAt); + } + + private roster(): FleetEntry[] { + return [{ kind: "main" }, ...this.agentRecords().map(record => ({ kind: "agent" as const, record }))]; + } + + private clampSelection(): void { + const max = this.roster().length - 1; + if (this.selectedIndex > max) this.selectedIndex = Math.max(0, max); + if (this.selectedIndex < 0) this.selectedIndex = 0; + } + + // ---- Key handling ---- + + /** Returns `{consume:true}` to swallow a key, or undefined to let it through. */ + handleKey(data: string): { consume?: boolean; data?: string } | undefined { + if (!this.enabled || !this.ui) return undefined; + // Input listeners receive BOTH key-press and key-release (the kitty protocol + // emits both, and matchesKey matches either) — act on press only, or every + // tap would move/fire twice. Repeats still pass through for held-key nav. + if (isKeyRelease(data)) return undefined; + // While an overlay is open, let it own all input. + if (this.viewerClose) return undefined; + + if (!this.active) { + // Activate: ↓ or ← at an empty prompt moves focus into the list. + const isActivator = matchesKey(data, "down") || matchesKey(data, "left"); + if (isActivator && this.agentRecords().length > 0 && this.ui.getEditorText() === "") { + this.active = true; + this.selectedIndex = 0; + this.update(); + return { consume: true }; + } + return undefined; + } + + // Active — arrows navigate, Enter opens, Esc / Up-past-top exits. + if (matchesKey(data, "down")) { + const max = this.roster().length - 1; + this.selectedIndex = Math.min(max, this.selectedIndex + 1); + this.update(); + return { consume: true }; + } + if (matchesKey(data, "up")) { + if (this.selectedIndex === 0) { this.deactivate(); return { consume: true }; } + this.selectedIndex -= 1; + this.update(); + return { consume: true }; + } + if (matchesKey(data, "escape")) { this.deactivate(); return { consume: true }; } + if (matchesKey(data, Key.enter)) { this.openSelected(); return { consume: true }; } + + // Any other key cancels navigation and flows to the editor. + this.deactivate(); + return undefined; + } + + private deactivate(): void { + this.active = false; + this.selectedIndex = 0; + this.update(); + } + + private openSelected(): void { + const entry = this.roster()[this.selectedIndex]; + if (!entry || entry.kind === "main") { + // `main` = return to the prompt; the native transcript is already shown. + this.deactivate(); + return; + } + const record = entry.record; + if (!this.ui) return; + if (!record.session) { + this.ui.notify(`Agent is ${record.status} — no session available.`, "info"); + return; + } + const session = record.session; + const activity = this.agentActivity.get(record.id); + this.viewingAgentId = record.id; + + void this.ui.custom( + (tui, theme, keybindings, done) => { + this.viewerClose = () => done(undefined); + return new ConversationViewer( + tui, + session, + record, + activity, + theme, + done, + () => { + if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info"); + }, + keybindings, + ); + }, + { + overlay: true, + overlayOptions: { anchor: "center", width: "90%", maxHeight: `${VIEWPORT_HEIGHT_PCT}%` }, + }, + ).then(() => this.clearViewer(), () => this.clearViewer()); + } + + /** Reset overlay state and return to the list (on close, auto-close, or error). */ + private clearViewer(): void { + // Keep the cursor on the agent we were viewing — re-resolve by id so it + // still feels natural if the list reordered (an earlier agent finished) + // while the overlay was open. If that agent is gone, leave the index for + // update()'s clamp to settle. + if (this.viewingAgentId) { + const idx = this.roster().findIndex(e => e.kind === "agent" && e.record.id === this.viewingAgentId); + if (idx >= 0) this.selectedIndex = idx; + } + this.viewerClose = undefined; + this.viewingAgentId = undefined; + this.update(); + } + + // ---- Rendering ---- + + private renderBar(width: number, theme: Theme): string[] { + const agents = this.roster().slice(1) as AgentEntry[]; + if (agents.length === 0) return []; + // Clamp locally so a render between a roster shrink and the next update() + // (e.g. on terminal resize) never loses the selection marker. + const sel = Math.min(this.selectedIndex, agents.length); + + const hint = this.active + ? "↑↓ select · enter view · esc back" + : "esc to interrupt · ← for agents · ↓ to manage"; + const lines: string[] = []; + lines.push(truncateToWidth(" " + theme.fg("dim", hint), width)); + lines.push(""); + lines.push(truncateToWidth(` ${this.bullet(0, sel, theme)} main`, width)); + + // Window the agent rows so the selected one stays visible. + const visible = Math.min(MAX_AGENT_ROWS, agents.length); + const selAgent = Math.max(0, sel - 1); + const start = selAgent < visible ? 0 : selAgent - visible + 1; + const hiddenBelow = agents.length - (start + visible); + + if (start > 0) lines.push(rightAlign("", theme.fg("dim", `↑ ${start} more`), width)); + for (let a = start; a < start + visible; a++) { + lines.push(this.renderAgentRow(a + 1, sel, agents[a].record, width, theme)); + } + if (hiddenBelow > 0) lines.push(rightAlign("", theme.fg("dim", `↓ ${hiddenBelow} more`), width)); + + return lines; + } + + private bullet(rosterIndex: number, sel: number, theme: Theme): string { + return rosterIndex === sel ? theme.fg("accent", "⏺") : theme.fg("dim", "◯"); + } + + private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string { + const left = ` ${this.bullet(rosterIndex, sel, theme)} ${theme.fg("muted", getDisplayName(record.type))} ${record.description}`; + const tokens = getLifetimeTotal(this.agentActivity.get(record.id)?.lifetimeUsage ?? record.lifetimeUsage); + const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished + const right = theme.fg("dim", `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}`); + return rightAlign(left, right, width); + } +} diff --git a/packages/pi-subagents/test/agent-manager.test.ts b/packages/pi-subagents/test/agent-manager.test.ts index 9e13ec5..5216ff9 100644 --- a/packages/pi-subagents/test/agent-manager.test.ts +++ b/packages/pi-subagents/test/agent-manager.test.ts @@ -94,18 +94,88 @@ describe("AgentManager — Bug 1 race condition (resultConsumed vs onComplete)", expect(completedRecord!.resultConsumed).toBeFalsy(); }); - it("onComplete is not called for foreground agents", async () => { - let onCompleteCalled = false; - manager = new AgentManager(() => { - onCompleteCalled = true; + it("onComplete IS called for foreground agents (lifecycle symmetry)", async () => { + let completedRecord: AgentRecord | undefined; + manager = new AgentManager((r) => { + completedRecord = r; }); resolvedRun(); + const { record } = await manager.spawnAndWait(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + }); + + expect(completedRecord).toBeDefined(); + expect(completedRecord!.status).toBe("completed"); + // resultConsumed is set by spawnAndWait so onComplete skips notifications + expect(completedRecord!.resultConsumed).toBe(true); + expect(record).toBe(completedRecord); + }); +}); + +describe("AgentManager — spawnAndWait onSpawned + foreground output file wiring (#105)", () => { + let manager: AgentManager; + afterEach(() => manager?.dispose()); + + it("fields set on the record in onSpawned are visible when onSessionCreated fires", async () => { + // The load-bearing ordering guarantee: onSpawned fires synchronously inside + // spawn(), before runAgent's async onSessionCreated fires. index.ts relies on + // this to set record.outputFile so streamToOutputFile can pick it up. + manager = new AgentManager(); + let capturedId: string | undefined; + let outputFileSeenAtSessionCreated: string | undefined; + + vi.mocked(runAgent).mockImplementation(async (_ctx, _type, _prompt, opts: any) => { + const session = mockSession(); + // Yield one microtask to mirror real behavior: in production, onSessionCreated + // fires async (after network/session setup). onSpawned fires synchronously + // inside spawn() before runAgent's promise even starts. This await lets the + // remainder of startAgent (record.promise = …, onSpawned?.()) finish first. + await Promise.resolve(); + opts.onSessionCreated?.(session); + outputFileSeenAtSessionCreated = capturedId + ? manager.getRecord(capturedId)?.outputFile + : undefined; + return { responseText: "done", session, aborted: false, steered: false }; + }); + await manager.spawnAndWait(mockPi, mockCtx, "general-purpose", "test", { description: "test", + }, (fgId) => { + capturedId = fgId; + manager.getRecord(fgId)!.outputFile = "/fake/agent.jsonl"; + }); + + expect(outputFileSeenAtSessionCreated).toBe("/fake/agent.jsonl"); + }); + + it("onSpawned id matches the id returned by spawnAndWait", async () => { + manager = new AgentManager(); + let spawnedId: string | undefined; + resolvedRun(); + + const { id } = await manager.spawnAndWait(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + }, (fgId) => { spawnedId = fgId; }); + + expect(spawnedId).toBe(id); + }); + + it("onComplete fires on the error path with resultConsumed=true", async () => { + // The .then path is covered by the lifecycle-symmetry test above; this guards + // the .catch path which lacks try/catch around onComplete (a known asymmetry). + let completedRecord: AgentRecord | undefined; + manager = new AgentManager((r) => { completedRecord = r; }); + vi.mocked(runAgent).mockRejectedValue(new Error("agent failed")); + + const { record } = await manager.spawnAndWait(mockPi, mockCtx, "general-purpose", "test", { + description: "test", }); - expect(onCompleteCalled).toBe(false); + expect(completedRecord).toBeDefined(); + expect(completedRecord!.status).toBe("error"); + expect(completedRecord!.resultConsumed).toBe(true); + expect(record).toBe(completedRecord); }); }); @@ -237,6 +307,57 @@ describe("AgentManager — Bug 3 clearCompleted", () => { manager.clearCompleted(); expect(manager.getRecord(id)).toBeUndefined(); }); + + it("clearCompleted(true) preserves completed records with resultConsumed=false", async () => { + manager = new AgentManager(); + resolvedRun(); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + expect(manager.getRecord(id)!.status).toBe("completed"); + expect(manager.getRecord(id)!.resultConsumed).toBeFalsy(); + + manager.clearCompleted(true); + expect(manager.getRecord(id)).toBeDefined(); + }); + + it("clearCompleted(true) removes completed records with resultConsumed=true", async () => { + manager = new AgentManager(); + resolvedRun(); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + const record = manager.getRecord(id)!; + await record.promise; + record.resultConsumed = true; + + manager.clearCompleted(true); + expect(manager.getRecord(id)).toBeUndefined(); + }); + + it("clearCompleted(true) still removes running=false queued=false records when resultConsumed=false for error status", async () => { + manager = new AgentManager(); + vi.mocked(runAgent).mockRejectedValue(new Error("boom")); + + const id = manager.spawn(mockPi, mockCtx, "general-purpose", "test", { + description: "test", + isBackground: true, + }); + await manager.getRecord(id)!.promise; + expect(manager.getRecord(id)!.status).toBe("error"); + expect(manager.getRecord(id)!.resultConsumed).toBeFalsy(); + + // Error records with unread results are also preserved — the LLM should + // be able to read the error message via get_subagent_result before the + // record is evicted. + manager.clearCompleted(true); + expect(manager.getRecord(id)).toBeDefined(); + }); }); // Eager init removes the optional/required asymmetry that previously required diff --git a/packages/pi-subagents/test/agent-runner.test.ts b/packages/pi-subagents/test/agent-runner.test.ts index 53cc585..024c148 100644 --- a/packages/pi-subagents/test/agent-runner.test.ts +++ b/packages/pi-subagents/test/agent-runner.test.ts @@ -7,7 +7,9 @@ const { loaderExtensionsRef, getAgentDir, sessionManagerInMemory, + sessionManagerCreate, settingsManagerCreate, + settingsManagerGetSessionDir, } = vi.hoisted(() => ({ createAgentSession: vi.fn(), defaultResourceLoaderCtor: vi.fn(), @@ -20,7 +22,9 @@ const { }, getAgentDir: vi.fn(() => "/mock/agent-dir"), sessionManagerInMemory: vi.fn(() => ({ kind: "memory-session-manager" })), - settingsManagerCreate: vi.fn(() => ({ kind: "settings-manager" })), + sessionManagerCreate: vi.fn(() => ({ kind: "persistent-session-manager" })), + settingsManagerGetSessionDir: vi.fn(() => undefined as string | undefined), + settingsManagerCreate: vi.fn(() => ({ kind: "settings-manager", getSessionDir: settingsManagerGetSessionDir })), })); vi.mock("@earendil-works/pi-coding-agent", () => ({ @@ -53,7 +57,7 @@ vi.mock("@earendil-works/pi-coding-agent", () => ({ } }, getAgentDir, - SessionManager: { inMemory: sessionManagerInMemory }, + SessionManager: { inMemory: sessionManagerInMemory, create: sessionManagerCreate }, SettingsManager: { create: settingsManagerCreate }, })); @@ -149,6 +153,9 @@ beforeEach(() => { defaultResourceLoaderCtor.mockClear(); getAgentDir.mockClear(); sessionManagerInMemory.mockClear(); + sessionManagerCreate.mockClear(); + settingsManagerGetSessionDir.mockReset(); + settingsManagerGetSessionDir.mockReturnValue(undefined); settingsManagerCreate.mockClear(); loaderExtensionsRef.current = { extensions: [], errors: [], runtime: {} }; }); @@ -500,6 +507,53 @@ function lastLoaderOpts(): Record { return defaultResourceLoaderCtor.mock.calls[0][0]; } +describe("agent-runner session persistence", () => { + it("uses an in-memory session by default", async () => { + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig()); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(sessionManagerInMemory).toHaveBeenCalledWith("/tmp"); + expect(sessionManagerCreate).not.toHaveBeenCalled(); + expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ + sessionManager: { kind: "memory-session-manager" }, + })); + }); + + it("uses pi's normal persistent session location when persistSession is true", async () => { + vi.mocked(getAgentConfig).mockReturnValueOnce(makeAgentConfig({ persistSession: true })); + settingsManagerGetSessionDir.mockReturnValue("/normal/pi/sessions"); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi }); + + expect(sessionManagerInMemory).not.toHaveBeenCalled(); + expect(sessionManagerCreate).toHaveBeenCalledWith("/tmp", "/normal/pi/sessions"); + expect(createAgentSession).toHaveBeenCalledWith(expect.objectContaining({ + sessionManager: { kind: "persistent-session-manager" }, + })); + }); + + it("uses a frontmatter sessionDir when persistSession is true and sessionDir is configured", async () => { + vi.mocked(getAgentConfig).mockReturnValueOnce( + makeAgentConfig({ persistSession: true, sessionDir: ".seams/pi-sessions/seam-plan-reviewer" }), + ); + settingsManagerGetSessionDir.mockReturnValue("/normal/pi/sessions"); + const { session } = createSession("OK"); + createAgentSession.mockResolvedValue({ session }); + + await runAgent(ctx, "Explore", "go", { pi, cwd: "/repo" }); + + expect(sessionManagerCreate).toHaveBeenCalledWith( + "/repo", + "/repo/.seams/pi-sessions/seam-plan-reviewer", + ); + }); +}); + describe("agent-runner master tool allowlist", () => { it("extensions: true with extension tools — all 7 built-ins plus extension tools land in the allowlist", async () => { vi.mocked(getConfig).mockReturnValueOnce(makeConfig({ extensions: true })); diff --git a/packages/pi-subagents/test/clear-completed-wiring.test.ts b/packages/pi-subagents/test/clear-completed-wiring.test.ts new file mode 100644 index 0000000..047e6cf --- /dev/null +++ b/packages/pi-subagents/test/clear-completed-wiring.test.ts @@ -0,0 +1,172 @@ +/** + * clear-completed-wiring.test.ts — reproduces issue #108 end-to-end through the + * REAL session lifecycle handlers + the REAL get_subagent_result tool. + * + * Bug: a background agent that has COMPLETED but whose result the LLM hasn't read + * yet (resultConsumed=false) was wiped by clearCompleted() on session_start / + * session_before_switch, so the next get_subagent_result returned "Agent not + * found". The fix makes both handlers call clearCompleted(true), preserving + * unread records (the 10-minute timer evicts them later). + * + * These tests exercise the wiring, not the manager method in isolation: spawn a + * real background agent, let it complete, fire the real session event, then read + * it back through the real tool — the exact path the reporter hit. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); // pi.on(...) — session_start, session_before_switch, session_shutdown + const events = new Map(); // pi.events.on(...) — subagents:rpc:*, etc. + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { + emit: vi.fn(), + on: vi.fn((event: string, handler: any) => { + events.set(event, handler); + return vi.fn(); + }), + }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle, events }; +} + +function ctx() { + return { + hasUI: false, + ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, + getSystemPrompt: vi.fn(() => "parent"), + } as any; +} + +const textOf = (r: any): string => r.content[0].text; +// Let runAgent's resolved .then() chain settle so the record reaches "completed". +const flush = async () => { + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); +}; + +// Spawn a real background agent and drive it to status "completed" with +// resultConsumed=false (only get_subagent_result sets that flag for background). +async function spawnCompletedBackgroundAgent(tools: Map): Promise { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "THE-RESULT-PAYLOAD", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + }); + const spawn = await tools.get("Agent").execute( + "tc-spawn", + { prompt: "go", description: "Review monero_en.rs in depth", subagent_type: "general-purpose", run_in_background: true }, + undefined, + undefined, + ctx(), + ); + const id = textOf(spawn).match(/Agent ID: (\S+)/)?.[1]; + expect(id, "background spawn should surface an agent id").toBeTruthy(); + await flush(); + return id as string; +} + +describe("issue #108: unread completed background agents survive session events", () => { + let tmpDir: string; + let agentDir: string; + let prevCwd: string; + let prevAgentDir: string | undefined; + let prevHome: string | undefined; + + beforeEach(() => { + // Hermetic cwd + global dir, scheduling off, so session_start doesn't spin a + // scheduler or touch the dev's filesystem — isolates the clearCompleted path. + tmpDir = mkdtempSync(join(tmpdir(), "pi-108-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-108-agentdir-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + prevHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.HOME = agentDir; + prevCwd = process.cwd(); + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false })); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("session_before_switch (user switches sessions) does NOT wipe the unread result", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const id = await spawnCompletedBackgroundAgent(tools); + + // The exact #108 trigger: a session switch fires before the LLM read the result. + await lifecycle.get("session_before_switch")?.(); + + const res = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); + const out = textOf(res); + expect(out).not.toContain("Agent not found"); + expect(out).toContain("THE-RESULT-PAYLOAD"); + + await lifecycle.get("session_shutdown")?.({}, ctx()); + }); + + it("session_start (/resume) does NOT wipe the unread result", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const id = await spawnCompletedBackgroundAgent(tools); + + await lifecycle.get("session_start")?.({}, ctx()); + + const res = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); + const out = textOf(res); + expect(out).not.toContain("Agent not found"); + expect(out).toContain("THE-RESULT-PAYLOAD"); + + await lifecycle.get("session_shutdown")?.({}, ctx()); + }); + + it("once read, a session switch DOES evict it — the fix stays surgical, no leak", async () => { + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + const id = await spawnCompletedBackgroundAgent(tools); + + // LLM reads the result → resultConsumed=true. + const first = await tools.get("get_subagent_result").execute("tc-read1", { agent_id: id }, undefined, undefined, ctx()); + expect(textOf(first)).toContain("THE-RESULT-PAYLOAD"); + + // Now a session switch SHOULD clean it up (consumed records are not preserved). + await lifecycle.get("session_before_switch")?.(); + + const second = await tools.get("get_subagent_result").execute("tc-read2", { agent_id: id }, undefined, undefined, ctx()); + expect(textOf(second)).toContain("Agent not found"); + + await lifecycle.get("session_shutdown")?.({}, ctx()); + }); +}); diff --git a/packages/pi-subagents/test/custom-agents.test.ts b/packages/pi-subagents/test/custom-agents.test.ts index 94fd28a..03a9a5d 100644 --- a/packages/pi-subagents/test/custom-agents.test.ts +++ b/packages/pi-subagents/test/custom-agents.test.ts @@ -39,6 +39,8 @@ tools: read, grep, find model: anthropic/claude-opus-4-6 thinking: high max_turns: 30 +persist_session: true +session_dir: .seams/pi-sessions/seam-plan-reviewer prompt_mode: replace inherit_context: true run_in_background: true @@ -57,6 +59,8 @@ You are a security auditor.`); expect(agent.model).toBe("anthropic/claude-opus-4-6"); expect(agent.thinking).toBe("high"); expect(agent.maxTurns).toBe(30); + expect(agent.persistSession).toBe(true); + expect(agent.sessionDir).toBe(".seams/pi-sessions/seam-plan-reviewer"); expect(agent.promptMode).toBe("replace"); expect(agent.inheritContext).toBe(true); expect(agent.runInBackground).toBe(true); @@ -81,6 +85,8 @@ Just a prompt.`); expect(agent.model).toBeUndefined(); expect(agent.thinking).toBeUndefined(); expect(agent.maxTurns).toBeUndefined(); + expect(agent.persistSession).toBeUndefined(); + expect(agent.sessionDir).toBeUndefined(); expect(agent.promptMode).toBe("replace"); expect(agent.inheritContext).toBeUndefined(); expect(agent.runInBackground).toBeUndefined(); diff --git a/packages/pi-subagents/test/fleet-list.test.ts b/packages/pi-subagents/test/fleet-list.test.ts new file mode 100644 index 0000000..48ef7f2 --- /dev/null +++ b/packages/pi-subagents/test/fleet-list.test.ts @@ -0,0 +1,348 @@ +import { visibleWidth } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import type { AgentManager } from "../src/agent-manager.js"; +import type { AgentRecord } from "../src/types.js"; +import { getDisplayName } from "../src/ui/agent-widget.js"; +import { FleetList, type FleetUICtx, formatFleetElapsed, formatFleetTokens } from "../src/ui/fleet-list.js"; + +// ---- Key sequences (see node_modules/@earendil-works/pi-tui/dist/keys.js) ---- +const DOWN = "\x1b[B"; +const UP = "\x1b[A"; +const LEFT = "\x1b[D"; +const RIGHT = "\x1b[C"; +const ESC = "\x1b"; +const ENTER = "\r"; +// Kitty-protocol key-RELEASE for ↓ (event type 3) — listeners receive these too. +const DOWN_RELEASE = "\x1b[1;1:3B"; + +const theme = { fg: (c: string, s: string) => `<${c}>${s}`, bold: (s: string) => `*${s}*` }; + +/** A no-op session so a record is "openable" by default (the list hides session-less agents). */ +const FAKE_SESSION = { subscribe: () => () => {}, messages: [] }; + +function makeRecord(over: Partial = {}): AgentRecord { + return { + id: "a1", + type: "general-purpose", + description: "Sleep then report 1", + status: "running", + toolUses: 0, + startedAt: Date.now(), + session: FAKE_SESSION as any, + lifetimeUsage: { input: 13100, output: 0, cacheWrite: 0 }, + compactionCount: 0, + ...over, + } as AgentRecord; +} + +/** Fake manager exposing only what FleetList touches. */ +function fakeManager(agents: AgentRecord[]): AgentManager { + return { + listAgents: () => agents, + abort: () => true, + } as unknown as AgentManager; +} + +interface Harness { + fleet: FleetList; + ui: FleetUICtx; + /** Feed a key to the registered input handler; returns the consume result. */ + press: (data: string) => { consume?: boolean } | undefined; + /** Render the currently-registered below-editor widget at the given width. */ + render: (width?: number) => string[]; + setEditorText: (t: string) => void; + /** Whether an overlay has been opened. */ + overlayOpened: () => boolean; + /** Whether the most recently opened overlay's `done` was invoked (closed). */ + overlayClosed: () => boolean; + /** Simulate the viewer closing itself (Esc → done); flushes the close microtask. */ + closeOverlay: () => Promise; +} + +function harness(agents: AgentRecord[]): Harness { + let inputHandler: ((data: string) => { consume?: boolean } | undefined) | undefined; + let widgetFactory: ((tui: any, theme: any) => { render(w: number): string[] }) | undefined; + let editorText = ""; + let opened = false; + let closed = false; + let overlayDone: ((r: undefined) => void) | undefined; + const fakeTui = { requestRender: () => {}, terminal: { columns: 120, rows: 40 } }; + + const ui: FleetUICtx = { + setWidget: (_key, content) => { widgetFactory = content as any; }, + onTerminalInput: (h) => { inputHandler = h; return () => { inputHandler = undefined; }; }, + getEditorText: () => editorText, + notify: () => {}, + custom: ((factory: any) => { + opened = true; + return new Promise((resolve) => { + const done = (r: undefined) => { closed = true; overlayDone = undefined; resolve(r); }; + overlayDone = done; + // Construct the overlay component so the controller wires viewerClose. + factory(fakeTui, theme, undefined, done); + }); + }) as FleetUICtx["custom"], + }; + + const fleet = new FleetList(fakeManager(agents), new Map()); + fleet.setUICtx(ui); + fleet.update(); + + return { + fleet, + ui, + press: (data) => inputHandler?.(data), + render: (width = 120) => (widgetFactory ? widgetFactory(fakeTui, theme).render(width) : []), + setEditorText: (t) => { editorText = t; }, + overlayOpened: () => opened, + overlayClosed: () => closed, + closeOverlay: async () => { overlayDone?.(undefined); await Promise.resolve(); }, + }; +} + +describe("formatFleetElapsed", () => { + it("renders integer seconds (no decimal, no suffix)", () => { + expect(formatFleetElapsed(0)).toBe("0s"); + expect(formatFleetElapsed(11_000)).toBe("11s"); + expect(formatFleetElapsed(11_400)).toBe("11s"); + expect(formatFleetElapsed(11_600)).toBe("12s"); + }); + it("floors negatives to 0s", () => { + expect(formatFleetElapsed(-500)).toBe("0s"); + }); +}); + +describe("formatFleetTokens", () => { + it("prefixes a down-arrow and uses plural 'tokens'", () => { + expect(formatFleetTokens(13_100)).toBe("↓ 13.1k tokens"); + expect(formatFleetTokens(950)).toBe("↓ 950 tokens"); + expect(formatFleetTokens(1_200_000)).toBe("↓ 1.2M tokens"); + }); +}); + +describe("FleetList navigation", () => { + it("does not register a widget when there are no agents", () => { + const h = harness([]); + expect(h.render()).toEqual([]); + }); + + it("activates on ↓ at an empty prompt, consuming the key", () => { + const h = harness([makeRecord()]); + const res = h.press(DOWN); + expect(res).toEqual({ consume: true }); + // main selected, list active → nav hint shown + expect(h.render().some(l => l.includes("enter view"))).toBe(true); + }); + + it("also activates on ← (matches the '← for agents' hint)", () => { + const h = harness([makeRecord()]); + expect(h.press(LEFT)).toEqual({ consume: true }); + }); + + it("does NOT activate when the prompt is non-empty (typing is preserved)", () => { + const h = harness([makeRecord()]); + h.setEditorText("hello"); + expect(h.press(DOWN)).toBeUndefined(); + }); + + it("ignores key-release events so one tap moves exactly one row", () => { + const h = harness([ + makeRecord({ id: "a1", description: "one" }), + makeRecord({ id: "a2", description: "two" }), + ]); + h.press(DOWN); // activate → selection on main (idx 0) + h.press(DOWN_RELEASE); // release half of the SAME tap — must be a no-op + expect(h.render().find(l => l.includes("main"))).toContain("⏺"); + h.press(DOWN); // a real second tap → first agent + h.press(DOWN_RELEASE); + expect(h.render().find(l => l.includes("one"))).toContain("⏺"); + expect(h.render().find(l => l.includes("two"))).toContain("◯"); + }); + + it("moves selection down/up and clamps at the ends", () => { + const agents = [ + makeRecord({ id: "a1", description: "one" }), + makeRecord({ id: "a2", description: "two" }), + ]; + const h = harness(agents); + h.press(DOWN); // activate → index 0 (main) + h.press(DOWN); // → 1 (a1) + expect(h.render().find(l => l.includes("one"))).toContain("⏺"); + h.press(DOWN); // → 2 (a2) + h.press(DOWN); // clamp at 2 + expect(h.render().find(l => l.includes("two"))).toContain("⏺"); + expect(h.render().find(l => l.includes("one"))).toContain("◯"); + }); + + it("↑ above 'main' deactivates (returns to the prompt)", () => { + const h = harness([makeRecord()]); + h.press(DOWN); // activate, index 0 + expect(h.press(UP)).toEqual({ consume: true }); + // back to inactive hint + expect(h.render().some(l => l.includes("← for agents"))).toBe(true); + }); + + it("Esc deactivates", () => { + const h = harness([makeRecord()]); + h.press(DOWN); + expect(h.press(ESC)).toEqual({ consume: true }); + expect(h.render().some(l => l.includes("← for agents"))).toBe(true); + }); + + it("passes non-nav keys through and cancels navigation", () => { + const h = harness([makeRecord()]); + h.press(DOWN); + expect(h.press(RIGHT)).toBeUndefined(); + expect(h.render().some(l => l.includes("← for agents"))).toBe(true); + }); + + it("ignores all input while disabled and hides the widget", () => { + const h = harness([makeRecord()]); + h.fleet.setEnabled(false); + expect(h.press(DOWN)).toBeUndefined(); + expect(h.render()).toEqual([]); + }); + + it("re-arms the refresh timer when the list is re-shown (toggle off→on)", () => { + vi.useFakeTimers(); + try { + const agents = [makeRecord({ id: "a1" })]; + const listAgents = vi.fn(() => agents); + const manager = { listAgents, abort: () => true } as unknown as AgentManager; + const fleet = new FleetList(manager, new Map()); + fleet.setUICtx({ + setWidget: () => {}, onTerminalInput: () => () => {}, getEditorText: () => "", + notify: () => {}, custom: (() => new Promise(() => {})) as FleetUICtx["custom"], + }); + fleet.update(); // shows list, arms the timer + fleet.setEnabled(false); // hides, clears the timer + fleet.setEnabled(true); // re-shows — must re-arm the timer + const before = listAgents.mock.calls.length; + vi.advanceTimersByTime(250); // a tick should fire and re-read the roster + expect(listAgents.mock.calls.length).toBeGreaterThan(before); + fleet.dispose(); + } finally { + vi.useRealTimers(); + } + }); +}); + +describe("FleetList rendering", () => { + it("renders main + agent rows with markers, type, description and right-aligned stats", () => { + const h = harness([makeRecord({ description: "Sleep then report 1" })]); + const lines = h.render(120); + // hint + blank + main + one agent + expect(lines[0]).toContain("← for agents"); + expect(lines.find(l => l.includes("main"))).toContain("⏺"); // main selected by default + const agentLine = lines.find(l => l.includes("Sleep then report 1"))!; + expect(agentLine).toContain("◯"); + expect(agentLine).toContain(getDisplayName("general-purpose")); + expect(agentLine).toContain("↓ 13.1k tokens"); + expect(agentLine).toMatch(/\d+s · ↓/); // "s · ↓ ..." (timing-agnostic) + }); + + it("orders agents earliest-launched first (top)", () => { + const agents = [ + makeRecord({ id: "new", description: "newest", startedAt: 2000 }), + makeRecord({ id: "old", description: "oldest", startedAt: 1000 }), + ]; + const lines = harness(agents).render(); + const oldIdx = lines.findIndex(l => l.includes("oldest")); + const newIdx = lines.findIndex(l => l.includes("newest")); + expect(oldIdx).toBeGreaterThanOrEqual(0); + expect(oldIdx).toBeLessThan(newIdx); // earliest sits above the later one + }); + + it("hides agents that have no session yet (pending)", () => { + const agents = [ + makeRecord({ id: "live", description: "running one" }), + makeRecord({ id: "pending", description: "queued one", status: "queued", session: undefined }), + ]; + const lines = harness(agents).render(); + expect(lines.some(l => l.includes("running one"))).toBe(true); + expect(lines.some(l => l.includes("queued one"))).toBe(false); + }); + + it("collapses overflow into a '↓ N more' indicator", () => { + const agents = Array.from({ length: 8 }, (_, i) => + makeRecord({ id: `a${i}`, description: `report ${i}` })); + const h = harness(agents); + const lines = h.render(120); + // 8 agents, cap 5 visible → "↓ 3 more" + expect(lines.some(l => l.includes("↓ 3 more"))).toBe(true); + }); + + it("never emits a line wider than the terminal (guards wrap-induced flicker)", () => { + const agents = Array.from({ length: 8 }, (_, i) => + makeRecord({ id: `a${i}`, description: `a very long agent description number ${i} that keeps going` })); + const h = harness(agents); + for (const w of [4, 8, 12, 20, 40, 80, 200]) { + for (const line of h.render(w)) { + expect(visibleWidth(line)).toBeLessThanOrEqual(w); + } + } + }); + + it("windows the visible agents so the selection stays on screen", () => { + const agents = Array.from({ length: 8 }, (_, i) => + makeRecord({ id: `a${i}`, description: `report ${i}` })); + const h = harness(agents); + h.press(DOWN); // activate (main) + // step down to the last agent (8 agents → roster index 8) + for (let i = 0; i < 8; i++) h.press(DOWN); + const lines = h.render(120); + expect(lines.find(l => l.includes("report 7"))).toContain("⏺"); + expect(lines.some(l => l.includes("↑"))).toBe(true); // hidden-above indicator + }); +}); + +describe("FleetList overlay lifecycle", () => { + it("Enter on 'main' just deactivates (no overlay)", () => { + const h = harness([makeRecord()]); + h.press(DOWN); // active, index 0 (main) + h.press(ENTER); + expect(h.overlayOpened()).toBe(false); // never opened an overlay + expect(h.render().some(l => l.includes("← for agents"))).toBe(true); + }); + + it("keeps the cursor on the viewed agent after closing, even if the list reordered", async () => { + const fakeSession = { subscribe: () => () => {}, messages: [] }; + const agents = [ + makeRecord({ id: "a1", description: "one", session: fakeSession as any }), + makeRecord({ id: "a2", description: "two", session: fakeSession as any }), + makeRecord({ id: "a3", description: "three", session: fakeSession as any }), + ]; + const h = harness(agents); + h.press(DOWN); // activate (main, idx 0) + h.press(DOWN); // a1 (idx 1) + h.press(DOWN); // a2 (idx 2) + h.press(ENTER); // open a2 + // a1 finishes and drops out while viewing → a2 shifts from idx 2 to idx 1. + agents.splice(0, 1); + await h.closeOverlay(); + // Selection follows a2 ("two") to its new position, not whatever is at idx 2 now. + expect(h.render().find(l => l.includes("two"))).toContain("⏺"); + expect(h.render().find(l => l.includes("three"))).toContain("◯"); + }); + + it("does NOT auto-close when the viewed agent finishes (final output stays readable)", () => { + const agents = [makeRecord({ id: "live", description: "the one" })]; + const h = harness(agents); + h.press(DOWN); // active (main) + h.press(DOWN); // → the agent + h.press(ENTER); // opens overlay + expect(h.overlayOpened()).toBe(true); + // The agent finishes, well past the linger window... + agents[0] = makeRecord({ id: "live", description: "the one", status: "completed", completedAt: Date.now() - 60_000 }); + h.fleet.onAgentFinished("live"); + expect(h.overlayClosed()).toBe(false); // viewer stays open + expect(h.render().some(l => l.includes("the one"))).toBe(true); // and stays listed while viewed + }); + + it("lingers a finished agent in the list, then drops it after the window", () => { + const recent = makeRecord({ id: "r", description: "recent done", status: "completed", completedAt: Date.now() }); + expect(harness([recent]).render().some(l => l.includes("recent done"))).toBe(true); + const old = makeRecord({ id: "o", description: "old done", status: "completed", completedAt: Date.now() - 60_000 }); + expect(harness([old]).render().some(l => l.includes("old done"))).toBe(false); + }); +}); diff --git a/packages/pi-subagents/test/fleet-wiring.test.ts b/packages/pi-subagents/test/fleet-wiring.test.ts new file mode 100644 index 0000000..ff7bcd0 --- /dev/null +++ b/packages/pi-subagents/test/fleet-wiring.test.ts @@ -0,0 +1,141 @@ +/** + * fleet-wiring.test.ts — end-to-end wiring of the FleetView through the REAL + * extension (src/index.ts), not the FleetList class in isolation. + * + * The unit tests in fleet-list.test.ts drive FleetList with a fake ui/manager. + * These prove the bits only the extension can: that `tool_execution_start` + * hands the fleet the live UI (so it captures input), that spawning a background + * agent actually registers the `belowEditor` widget once the agent has a session, + * and that `session_shutdown` tears it down. runAgent is mocked (no LLM); the + * manager, settings load, completion routing, and lifecycle handlers are real. + */ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/agent-runner.js", async () => { + const actual = await vi.importActual("../src/agent-runner.js"); + return { ...actual, runAgent: vi.fn() }; +}); + +import { runAgent } from "../src/agent-runner.js"; +import subagentsExtension from "../src/index.js"; + +function makePi() { + const tools = new Map(); + const lifecycle = new Map(); + const pi = { + registerMessageRenderer: vi.fn(), + registerTool: vi.fn((t: any) => tools.set(t.name, t)), + registerCommand: vi.fn(), + on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), + events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) }, + appendEntry: vi.fn(), + sendMessage: vi.fn(), + } as any; + return { pi, tools, lifecycle }; +} + +/** A UI context with the surfaces the widget + fleet touch; setWidget is spied. */ +function uiCtx() { + return { + setStatus: vi.fn(), + setWidget: vi.fn(), + notify: vi.fn(), + onTerminalInput: vi.fn(() => vi.fn()), + getEditorText: vi.fn(() => ""), + custom: vi.fn(), + }; +} + +function ctxWith(ui: ReturnType) { + return { + hasUI: true, + ui, + cwd: process.cwd(), + model: undefined, + modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, + sessionManager: { getSessionId: () => "s1", getBranch: () => [] }, + getSystemPrompt: () => "parent", + } as any; +} + +const textOf = (r: any): string => r.content[0].text; +const flush = async () => { + await new Promise((r) => setImmediate(r)); + await new Promise((r) => setImmediate(r)); +}; + +describe("FleetView wiring (real extension lifecycle)", () => { + let tmpDir: string; + let agentDir: string; + let prevCwd: string; + let prevAgentDir: string | undefined; + let prevHome: string | undefined; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), "pi-fleet-")); + agentDir = mkdtempSync(join(tmpdir(), "pi-fleet-agentdir-")); + prevAgentDir = process.env.PI_CODING_AGENT_DIR; + prevHome = process.env.HOME; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.HOME = agentDir; + prevCwd = process.cwd(); + mkdirSync(join(tmpDir, ".pi"), { recursive: true }); + // async join → completion routes straight to sendIndividualNudge (no batch + // debounce), so fleet.onAgentFinished fires synchronously on the result. + writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false, defaultJoinMode: "async" })); + process.chdir(tmpDir); + }); + + afterEach(() => { + process.chdir(prevCwd); + if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = prevAgentDir; + if (prevHome == null) delete process.env.HOME; + else process.env.HOME = prevHome; + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(agentDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("captures terminal input on tool_execution_start (fleet hooked into the UI)", async () => { + const { pi, lifecycle } = makePi(); + subagentsExtension(pi); + const ui = uiCtx(); + await lifecycle.get("tool_execution_start")?.({}, ctxWith(ui)); + expect(ui.onTerminalInput).toHaveBeenCalled(); + }); + + it("registers the belowEditor widget once a spawned agent has a session, then clears it on shutdown", async () => { + vi.mocked(runAgent).mockResolvedValue({ + responseText: "done", + session: { dispose: vi.fn() } as any, + aborted: false, + steered: false, + }); + + const { pi, tools, lifecycle } = makePi(); + subagentsExtension(pi); + + const ui = uiCtx(); + await lifecycle.get("tool_execution_start")?.({}, ctxWith(ui)); // fleet captures THIS ui + + const spawn = await tools.get("Agent").execute( + "tc", + { prompt: "go", description: "live one", subagent_type: "general-purpose", run_in_background: true }, + undefined, + undefined, + ctxWith(uiCtx()), + ); + expect(textOf(spawn)).toMatch(/Agent ID:/); + await flush(); // completion → fleet.onAgentFinished → update → widget registers + + const fleetRegs = ui.setWidget.mock.calls.filter(c => c[0] === "fleet" && typeof c[1] === "function"); + expect(fleetRegs.length, "fleet widget should register with a render factory").toBeGreaterThan(0); + + await lifecycle.get("session_shutdown")?.({}, ctxWith(uiCtx())); + expect(ui.setWidget).toHaveBeenCalledWith("fleet", undefined); // dispose cleared it + }); +}); diff --git a/packages/pi-subagents/test/settings.test.ts b/packages/pi-subagents/test/settings.test.ts index 565eb74..34b65a2 100644 --- a/packages/pi-subagents/test/settings.test.ts +++ b/packages/pi-subagents/test/settings.test.ts @@ -107,6 +107,15 @@ describe("settings persistence", () => { expect(loadSettings(projectDir)).toEqual({}); }); + it("round-trips fleetView (true and false); keeps boolean, drops non-boolean", () => { + saveSettings({ fleetView: false }, projectDir); + expect(loadSettings(projectDir)).toEqual({ fleetView: false }); + saveSettings({ fleetView: true }, projectDir); + expect(loadSettings(projectDir)).toEqual({ fleetView: true }); + writeProject({ fleetView: "on" } as any); + expect(loadSettings(projectDir)).toEqual({}); // non-boolean dropped + }); + it("sanitize drops non-boolean schedulingEnabled silently", async () => { writeProject({ schedulingEnabled: "yes" } as any); expect(loadSettings(projectDir)).toEqual({}); @@ -347,6 +356,7 @@ describe("settings persistence", () => { setScopeModels: vi.fn(), setDisableDefaultAgents: vi.fn(), setToolDescriptionMode: vi.fn(), + setFleetView: vi.fn(), }; }); @@ -383,6 +393,7 @@ describe("settings persistence", () => { scopeModels: true, disableDefaultAgents: true, toolDescriptionMode: "compact", + fleetView: false, }, appliers, ); @@ -394,6 +405,14 @@ describe("settings persistence", () => { expect(appliers.setScopeModels).toHaveBeenCalledWith(true); expect(appliers.setDisableDefaultAgents).toHaveBeenCalledWith(true); expect(appliers.setToolDescriptionMode).toHaveBeenCalledWith("compact"); + expect(appliers.setFleetView).toHaveBeenCalledWith(false); + }); + + it("applies fleetView (true and false); skips it when absent", () => { + applySettings({ fleetView: true }, appliers); + expect(appliers.setFleetView).toHaveBeenCalledWith(true); + applySettings({}, appliers); + expect(appliers.setFleetView).toHaveBeenCalledTimes(1); // absence is "use default" }); it("applies scopeModels: false", () => { @@ -467,6 +486,7 @@ describe("settings persistence", () => { setScopeModels: vi.fn(), setDisableDefaultAgents: vi.fn(), setToolDescriptionMode: vi.fn(), + setFleetView: vi.fn(), }; }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7374fe6..7f30f41 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,8 +33,8 @@ importers: packages/pi-herd: devDependencies: '@earendil-works/pi-coding-agent': - specifier: ^0.79.3 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.2 + version: 0.80.2(ws@8.21.0)(zod@4.4.3) '@types/node': specifier: ^24.10.1 version: 24.13.2 @@ -51,14 +51,14 @@ importers: packages/pi-herdr: devDependencies: '@earendil-works/pi-ai': - specifier: ^0.79.3 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.2 + version: 0.80.2(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-coding-agent': - specifier: ^0.79.3 - version: 0.79.3(ws@8.21.0)(zod@4.4.3) + specifier: ^0.80.2 + version: 0.80.2(ws@8.21.0)(zod@4.4.3) '@earendil-works/pi-tui': - specifier: ^0.79.3 - version: 0.79.3 + specifier: ^0.80.2 + version: 0.80.2 '@types/node': specifier: ^24.10.1 version: 24.13.2 @@ -104,7 +104,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.0.18 - version: 4.1.8(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -283,20 +283,38 @@ packages: resolution: {integrity: sha512-Ksvnu6CpQLYGbCSgnQEetzliI7yb+QkqtSlmmunJ69QluT45kd3DjQZRNHfRLk++Dd02Y8QvsRKMopSJCcWoWw==} engines: {node: '>=22.19.0'} + '@earendil-works/pi-agent-core@0.80.2': + resolution: {integrity: sha512-BF9WPhixIFjT6Kp3Iz3H6ugkU+4AWotM8py96XE5pIK0arJbQKMWbR+dXSWWDEmR5yc/aFQODnuyowOEzMGO7Q==} + engines: {node: '>=22.19.0'} + '@earendil-works/pi-ai@0.79.3': resolution: {integrity: sha512-lMSput/haP5uZAGbXhS5rAYd3GB7GYdJkoAUxg3VFummBeqGqGqllaTWrbHFN12kVGyVfWHhdySNXkiqVh65Iw==} engines: {node: '>=22.19.0'} hasBin: true + '@earendil-works/pi-ai@0.80.2': + resolution: {integrity: sha512-5GNKfdrRJ4uZ5Zd9iudoXggi/BbUcKnD/xfRHtdR+7q4vWqPvfx8auFuaT+ewGBVI8K4wj87eigFQ/iCSuy9RQ==} + engines: {node: '>=22.19.0'} + hasBin: true + '@earendil-works/pi-coding-agent@0.79.3': resolution: {integrity: sha512-B3rAyw4/f1l3EYjQpCKpevzuBIPjYor6fHxPHQpLMdn/NTv3536i1P4ZrsyFkKpXqJWH66xKK5hyf8sqRe3dGA==} engines: {node: '>=22.19.0'} hasBin: true + '@earendil-works/pi-coding-agent@0.80.2': + resolution: {integrity: sha512-m9v7OUit0s9LklWfh61ca/XY5INjUzjtYtNZwy3cNvyjOLk3IpBgghP8aAp0iH35rLaiRwuuWiJ8t88ODMWY+A==} + engines: {node: '>=22.19.0'} + hasBin: true + '@earendil-works/pi-tui@0.79.3': resolution: {integrity: sha512-cpmkEM1aEuGUx6YZM36VlzpulwLzqD5T2cUEkGHndDTNGEbnn5sj/9SYm+QBfKjvZsWoHfZuFBnu4+hh96/FbA==} engines: {node: '>=22.19.0'} + '@earendil-works/pi-tui@0.80.2': + resolution: {integrity: sha512-OvOAMIbXiC9OSse17YMiXIsI9AS5XM/ZV8N/k+UzdlRpPILDQYmLElevgGW92kkXR8qHBClIdzhCjuzlBGvphA==} + engines: {node: '>=22.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -545,6 +563,14 @@ packages: '@mistralai/mistralai@2.2.1': resolution: {integrity: sha512-uKU8CZmL2RzYKmplsU01hii4p3pe4HqJefpWNRWXm1Tcm0Sm4xXfwSLIy4k7ZCPlbETCGcp69E7hZs+WOJ5itQ==} + '@mistralai/mistralai@2.2.6': + resolution: {integrity: sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@napi-rs/wasm-runtime@1.1.5': resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} peerDependencies: @@ -554,6 +580,14 @@ packages: '@nodable/entities@2.2.0': resolution: {integrity: sha512-9uGyhaQavEUMC8AIddIjau4NsnsXhou+j5sBAGojCM1oxmQpVKTWR/9JxABD6UAv12vpIms55fPZKFQEhG6uBg==} + '@opentelemetry/api@1.9.0': + resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/semantic-conventions@1.41.1': + resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} + engines: {node: '>=14'} + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -1057,6 +1091,11 @@ packages: engines: {node: '>= 18'} hasBin: true + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + minimatch@10.2.5: resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} engines: {node: 18 || 20 || >=22} @@ -1159,6 +1198,11 @@ packages: safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1238,6 +1282,10 @@ packages: resolution: {integrity: sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==} engines: {node: '>=22.19.0'} + undici@8.5.0: + resolution: {integrity: sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg==} + engines: {node: '>=22.19.0'} + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1418,7 +1466,7 @@ snapshots: '@aws-sdk/types': 3.973.12 '@smithy/core': 3.24.7 '@smithy/fetch-http-handler': 5.4.7 - '@smithy/node-http-handler': 4.7.3 + '@smithy/node-http-handler': 4.7.8 '@smithy/types': 4.14.4 tslib: 2.8.1 @@ -1647,6 +1695,20 @@ snapshots: - ws - zod + '@earendil-works/pi-agent-core@0.80.2(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-ai': 0.80.2(ws@8.21.0)(zod@4.4.3) + ignore: 7.0.5 + typebox: 1.1.38 + yaml: 2.9.0 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-ai@0.79.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) @@ -1667,6 +1729,27 @@ snapshots: - ws - zod + '@earendil-works/pi-ai@0.80.2(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@anthropic-ai/sdk': 0.91.1(zod@4.4.3) + '@aws-sdk/client-bedrock-runtime': 3.1048.0 + '@google/genai': 1.52.0 + '@mistralai/mistralai': 2.2.6(@opentelemetry/api@1.9.0) + '@opentelemetry/api': 1.9.0 + '@smithy/node-http-handler': 4.7.3 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + openai: 6.26.0(ws@8.21.0)(zod@4.4.3) + partial-json: 0.1.7 + typebox: 1.1.38 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-coding-agent@0.79.3(ws@8.21.0)(zod@4.4.3)': dependencies: '@earendil-works/pi-agent-core': 0.79.3(ws@8.21.0)(zod@4.4.3) @@ -1696,11 +1779,46 @@ snapshots: - ws - zod + '@earendil-works/pi-coding-agent@0.80.2(ws@8.21.0)(zod@4.4.3)': + dependencies: + '@earendil-works/pi-agent-core': 0.80.2(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-ai': 0.80.2(ws@8.21.0)(zod@4.4.3) + '@earendil-works/pi-tui': 0.80.2 + '@silvia-odwyer/photon-node': 0.3.4 + chalk: 5.6.2 + cross-spawn: 7.0.6 + diff: 8.0.4 + glob: 13.0.6 + highlight.js: 10.7.3 + hosted-git-info: 9.0.3 + ignore: 7.0.5 + jiti: 2.7.0 + minimatch: 10.2.5 + proper-lockfile: 4.1.2 + semver: 7.8.0 + typebox: 1.1.38 + undici: 8.5.0 + yaml: 2.9.0 + optionalDependencies: + '@mariozechner/clipboard': 0.3.9 + transitivePeerDependencies: + - '@modelcontextprotocol/sdk' + - bufferutil + - supports-color + - utf-8-validate + - ws + - zod + '@earendil-works/pi-tui@0.79.3': dependencies: get-east-asian-width: 1.6.0 marked: 15.0.12 + '@earendil-works/pi-tui@0.80.2': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -1861,6 +1979,18 @@ snapshots: - bufferutil - utf-8-validate + '@mistralai/mistralai@2.2.6(@opentelemetry/api@1.9.0)': + dependencies: + '@opentelemetry/semantic-conventions': 1.41.1 + ws: 8.21.0 + zod: 4.4.3 + zod-to-json-schema: 3.25.2(zod@4.4.3) + optionalDependencies: + '@opentelemetry/api': 1.9.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -1870,6 +2000,10 @@ snapshots: '@nodable/entities@2.2.0': {} + '@opentelemetry/api@1.9.0': {} + + '@opentelemetry/semantic-conventions@1.41.1': {} + '@oxc-project/types@0.133.0': {} '@protobufjs/aspromise@1.1.2': {} @@ -2327,6 +2461,8 @@ snapshots: marked@15.0.12: {} + marked@18.0.5: {} + minimatch@10.2.5: dependencies: brace-expansion: 5.0.6 @@ -2399,7 +2535,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.1 - '@types/node': 24.13.2 + '@types/node': 25.9.3 long: 5.3.2 retry@0.12.0: {} @@ -2429,6 +2565,8 @@ snapshots: safe-buffer@5.2.1: {} + semver@7.8.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -2484,6 +2622,8 @@ snapshots: undici@8.3.0: {} + undici@8.5.0: {} + vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 @@ -2499,7 +2639,7 @@ snapshots: tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.8(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.8 '@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) @@ -2522,6 +2662,7 @@ snapshots: vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.0 '@types/node': 25.9.3 transitivePeerDependencies: - msw