feat: enable permission-aware subagents

This commit is contained in:
叶林立
2026-08-26 10:49:26 +08:00
parent d3bf562189
commit 7571ba6dd9
185 changed files with 48365 additions and 26 deletions
+43
View File
@@ -0,0 +1,43 @@
/**
* abortable.ts — race a promise against an AbortSignal without cancelling the
* underlying work.
*
* Used by the `get_subagent_result` wait paths (top-level and nested): pressing
* Esc cancels only the caller's wait; the background child keeps running and its
* result stays unconsumed. The listener is removed on every settle path so the
* signal accumulates no handlers, and a late settlement of the wrapped promise
* after an abort is absorbed as a no-op (no unhandled rejection).
*/
/** Await a promise until it settles or the caller cancels, without aborting the underlying work. */
export function abortable<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return promise;
if (signal.aborted) return Promise.reject(signal.reason);
return new Promise<T>((resolve, reject) => {
let settled = false;
const cleanup = () => signal.removeEventListener("abort", onAbort);
const onAbort = () => {
if (settled) return;
settled = true;
cleanup();
reject(signal.reason);
};
signal.addEventListener("abort", onAbort, { once: true });
promise.then(
(value) => {
if (settled) return;
settled = true;
cleanup();
resolve(value);
},
(error: unknown) => {
if (settled) return;
settled = true;
cleanup();
reject(error);
},
);
});
}
+161
View File
@@ -0,0 +1,161 @@
/**
* agent-color.ts — Claude Code-compatible agent name badges.
*
* Claude Code renders a subagent's name as a badge: the configured color is the
* background, the text an inverse foreground. Its eight named colors are
* reproduced here, along with six-digit hex and the extra palette names Agency
* Agents uses, so those definitions render as written.
*/
import { getConfig } from "./agent-types.js";
const NAMED_AGENT_COLORS: Readonly<Record<string, string>> = {
// Claude Code's eight subagent colors, as its default theme renders them.
red: "#DC2626",
blue: "#6A9BCC",
green: "#16A34A",
yellow: "#CA8A04",
purple: "#827DBD",
orange: "#D97757",
pink: "#C46686",
cyan: "#0891B2",
// Agency Agents palette aliases.
amber: "#F59E0B",
teal: "#008080",
indigo: "#6366F1",
gold: "#EAB308",
"neon-green": "#10B981",
"neon-cyan": "#06B6D4",
"metallic-blue": "#3B82F6",
violet: "#8B5CF6",
rose: "#F43F5E",
lime: "#84CC16",
gray: "#6B7280",
grey: "#6B7280",
fuchsia: "#D946EF",
slate: "#64748B",
navy: "#1E3A8A",
};
const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
const BLACK = { r: 0, g: 0, b: 0 };
const WHITE = { r: 255, g: 255, b: 255 };
type Rgb = { r: number; g: number; b: number };
type ColorMode = "truecolor" | "256color";
export interface AgentNameTheme {
fg(color: string, text: string): string;
bold(text: string): string;
getColorMode?(): ColorMode;
}
export interface AgentNameStyle {
/** Existing theme foreground used when no valid agent color is configured. */
fallbackColor?: string;
/** Reapply an enclosing background after the badge instead of resetting it. */
restoreBackground?: string;
bold?: boolean;
}
/** Resolve Claude Code/Agency Agents color syntax to normalized #RRGGBB. */
export function resolveAgentColor(value: string | undefined): string | undefined {
if (!value) return undefined;
const normalized = value.trim().toLowerCase();
const resolved = NAMED_AGENT_COLORS[normalized] ?? normalized;
return /^#[0-9a-f]{6}$/i.test(resolved) ? resolved.toUpperCase() : undefined;
}
function parseHex(hex: string): Rgb {
return {
r: Number.parseInt(hex.slice(1, 3), 16),
g: Number.parseInt(hex.slice(3, 5), 16),
b: Number.parseInt(hex.slice(5, 7), 16),
};
}
/** Index of the entry in `values` closest to `value`. */
function nearest(values: readonly number[], value: number): number {
return values.reduce((best, v, i) => (Math.abs(value - v) < Math.abs(value - values[best]) ? i : best), 0);
}
/**
* Quantize to the xterm-256 palette the way pi's own theme does, returning both
* the index to emit and the color the terminal will actually show — badge
* contrast is judged against the latter.
*/
function rgbTo256({ r, g, b }: Rgb): { index: number; rgb: Rgb } {
const [rIndex, gIndex, bIndex] = [r, g, b].map((channel) => nearest(CUBE_VALUES, channel));
const distance = ({ r: cr, g: cg, b: cb }: Rgb) => 0.299 * (r - cr) ** 2 + 0.587 * (g - cg) ** 2 + 0.114 * (b - cb) ** 2;
const grayIndex = nearest(GRAY_VALUES, Math.round(0.299 * r + 0.587 * g + 0.114 * b));
const gray = { r: GRAY_VALUES[grayIndex], g: GRAY_VALUES[grayIndex], b: GRAY_VALUES[grayIndex] };
const cube = { r: CUBE_VALUES[rIndex], g: CUBE_VALUES[gIndex], b: CUBE_VALUES[bIndex] };
// Only near-neutral colors may take the gray ramp; anything else keeps its tint.
if (Math.max(r, g, b) - Math.min(r, g, b) < 10 && distance(gray) < distance(cube)) {
return { index: 232 + grayIndex, rgb: gray };
}
return { index: 16 + 36 * rIndex + 6 * gIndex + bIndex, rgb: cube };
}
function ansiColor(layer: "foreground" | "background", color: Rgb | number): string {
const code = layer === "foreground" ? 38 : 48;
return typeof color === "number"
? `\u001b[${code};5;${color}m`
: `\u001b[${code};2;${color.r};${color.g};${color.b}m`;
}
function relativeLuminance({ r, g, b }: Rgb): number {
const linear = (value: number) => {
const channel = value / 255;
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
}
/**
* Render one name as a padded background badge when `color` is valid. Claude
* Code uses one inverse color for every badge's text; black or white is picked
* by WCAG contrast here instead, so each palette entry stays readable. Invalid
* or omitted colors preserve the caller's existing theme styling.
*/
export function renderAgentNameLabel(
name: string,
color: string | undefined,
theme: AgentNameTheme,
style: AgentNameStyle = {},
): string {
const resolved = resolveAgentColor(color);
if (!resolved) {
const text = style.bold ? theme.bold(name) : name;
return style.fallbackColor ? theme.fg(style.fallbackColor, text) : text;
}
const rgb = parseHex(resolved);
const quantized = (theme.getColorMode?.() ?? "truecolor") === "256color" ? rgbTo256(rgb) : undefined;
const shown = quantized?.rgb ?? rgb;
const contrasting = relativeLuminance(shown) > 0.179 ? BLACK : WHITE;
const label = style.bold ? theme.bold(` ${name} `) : ` ${name} `;
return ansiColor("background", quantized?.index ?? rgb)
+ ansiColor("foreground", quantized ? rgbTo256(contrasting).index : contrasting)
+ label
+ "\u001b[39m"
+ (style.restoreBackground ?? "\u001b[49m");
}
/** Whether an agent renders as a badge — i.e. it has a valid configured color. */
export function hasAgentBadge(type: string | undefined): boolean {
return type !== undefined && resolveAgentColor(getConfig(type).color) !== undefined;
}
/** Render a registered agent's display name with its configured color. */
export function renderAgentName(
type: string | undefined,
theme: AgentNameTheme,
style: AgentNameStyle = {},
): string {
if (!type) return renderAgentNameLabel("Agent", undefined, theme, style);
const config = getConfig(type);
return renderAgentNameLabel(config.displayName, config.color, theme, style);
}
+261
View File
@@ -0,0 +1,261 @@
/**
* agent-file-toggle.ts — Pure helpers for the `/agents` file-editing operations:
* locating an agent's .md file, toggling its `enabled:` frontmatter flag, and
* serializing an AgentConfig back to frontmatter for eject.
*
* These live outside src/index.ts so they can be tested directly: the `/agents`
* command handler is an ~890-line closure reached only through `registerCommand`,
* which every test mocks.
*
* The read side of this data (src/custom-agents.ts) parses frontmatter with a
* real YAML parser, so it honors `enabled: false` at any position in the block.
* This module must agree with it, and splits the work accordingly:
*
* - Deciding whether a file is disabled is a *read*, so it calls that same parser
* (`isDisabledContent`) instead of mirroring it. A mirror has to be right about
* YAML's boolean spellings and about pi's fence scan, and a regex was wrong
* about both.
* - *Editing* cannot go through the parser, because re-serializing a parsed
* document would reformat a file the README tells users to hand-author —
* discarding their comments, key order, and quoting. So the edits are line-wise
* and preserve everything they don't touch.
*
* That leaves removal best-effort: it recognizes a lowercase bare `false`, and
* reports `changed: false` for the spellings it cannot rewrite, so the caller
* refuses honestly rather than announcing a change it did not make.
*/
import { existsSync } from "node:fs";
import { join, sep } from "node:path";
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
import type { AgentConfig } from "./types.js";
export type AgentFileLocation = "project" | "workspace" | "personal";
export const projectAgentsDir = (cwd: string = process.cwd()) => join(cwd, ".pi", "agents");
export const workspaceAgentsDir = (cwd: string = process.cwd()) => join(cwd, ".agents", "agents");
export const personalAgentsDir = () => join(getAgentDir(), "agents");
/**
* Find the file path of a custom agent by name, in discovery-precedence order
* (project, workspace, then global). Mirrors the load-side precedence in
* src/custom-agents.ts — if the two drift, `/agents` edits a file the loader
* isn't reading.
*/
export function findAgentFile(
name: string,
cwd: string = process.cwd(),
): { path: string; location: AgentFileLocation } | undefined {
const projectPath = join(projectAgentsDir(cwd), `${name}.md`);
if (existsSync(projectPath)) return { path: projectPath, location: "project" };
const workspacePath = join(workspaceAgentsDir(cwd), `${name}.md`);
if (existsSync(workspacePath)) return { path: workspacePath, location: "workspace" };
const personalPath = join(personalAgentsDir(), `${name}.md`);
if (existsSync(personalPath)) return { path: personalPath, location: "personal" };
return undefined;
}
/**
* Find the file behind a *loaded* agent, preferring the path the loader
* actually read (`AgentConfig.sourcePath`) over the `<type>.md` guess.
*
* An agent's type comes from its frontmatter `name:` now, so the two can
* disagree: `reviewer.md` declaring `name: code-reviewer` is loaded as
* `code-reviewer`, and probing for `code-reviewer.md` finds nothing. That is
* not a harmless miss — `/agents → Disable` would then take the no-file branch
* and write a NEW `code-reviewer.md` stub, which loses to `reviewer.md` on
* load, leaving the agent enabled while reporting success.
*
* The probe stays as the fallback: a built-in that was never ejected has no
* `sourcePath`, and a path can go stale between a load and this call.
*/
export function locateAgentFile(
name: string,
sourcePath: string | undefined,
cwd: string = process.cwd(),
): { path: string; location: AgentFileLocation } | undefined {
if (sourcePath && existsSync(sourcePath)) {
return { path: sourcePath, location: classifyAgentDir(sourcePath, cwd) };
}
return findAgentFile(name, cwd);
}
/**
* Which discovery location a loaded agent's file came from. Only ever names
* a directory in a confirmation prompt, so an unrecognized parent — which
* loadCustomAgents cannot currently produce — reports as personal rather than
* widening the type for a case that has no better answer.
*/
function classifyAgentDir(path: string, cwd: string): AgentFileLocation {
if (path.startsWith(projectAgentsDir(cwd) + sep)) return "project";
if (path.startsWith(workspaceAgentsDir(cwd) + sep)) return "workspace";
return "personal";
}
export type DisableOutcome = "disabled" | "already-disabled" | "no-frontmatter";
/** A line that sets `enabled: false`, ignoring trailing whitespace / CR. */
const ENABLED_FALSE = /^enabled:[ \t]*false[ \t]*$/;
/** An opening or closing `---` fence line. */
const FENCE = /^---[ \t]*$/;
/**
* Split a file into its frontmatter lines and everything else, agreeing with
* what `parseFrontmatter` (the load side) considers a frontmatter block.
*
* Lines keep their terminators, so an edit preserves the file's existing line
* endings instead of rewriting CRLF to LF. Returns undefined when there is no
* usable block — notably for a BOM-prefixed file, which the parser also reads
* as having none, so writing a key into it would change nothing on load.
*/
function splitFrontmatter(content: string):
| { lines: string[]; openIdx: number; closeIdx: number; eol: string }
| undefined {
const lines = content.split(/(?<=\n)/);
if (lines.length === 0 || !FENCE.test(lines[0].replace(/\r?\n$/, ""))) return undefined;
const closeIdx = lines.findIndex((l, i) => i > 0 && FENCE.test(l.replace(/\r?\n$/, "")));
if (closeIdx === -1) return undefined;
return { lines, openIdx: 0, closeIdx, eol: lines[0].endsWith("\r\n") ? "\r\n" : "\n" };
}
/**
* Does the loader consider this file disabled?
*
* Detection is a READ operation, so it asks the same parser the loader uses
* rather than mirroring it with a regex — that mirror has to be right about
* YAML's boolean spellings (`False`, `FALSE`, a trailing `# comment`, a quoted
* key) *and* about pi's fence scan, which closes the block on any line starting
* `---` and so ends it early on `----`. A throw means the file is already
* unparseable, which is what the loader sees too: it skips the agent, so there
* is no "disabled" state to report.
*/
export function isDisabledContent(content: string): boolean {
try {
return parseFrontmatter<Record<string, unknown>>(content).frontmatter.enabled === false;
} catch {
return false;
}
}
/**
* Add `enabled: false` to a file's frontmatter.
*
* `outcome` distinguishes a real edit from a no-op so the caller can report
* honestly instead of unconditionally claiming success.
*/
export function disableInContent(content: string): { content: string; outcome: DisableOutcome } {
const block = splitFrontmatter(content);
if (!block) return { content, outcome: "no-frontmatter" };
if (isDisabledContent(content)) return { content, outcome: "already-disabled" };
const lines = [...block.lines];
lines.splice(1, 0, `enabled: false${block.eol}`);
return { content: lines.join(""), outcome: "disabled" };
}
/**
* Remove `enabled: false` from a file's frontmatter, wherever it appears in the
* block — the loader honors the key at any position, so the two must agree or a
* hand-authored agent can be disabled and never re-enabled.
*
* `changed` is false when the key wasn't found, so the caller can avoid
* reporting "Enabled <name>" for a write that did nothing.
*/
export function enableInContent(content: string): { content: string; changed: boolean } {
const block = splitFrontmatter(content);
if (!block) return { content, changed: false };
const kept = block.lines.filter(
(l, i) => !(i > 0 && i < block.closeIdx && ENABLED_FALSE.test(l.replace(/\r?\n$/, ""))),
);
if (kept.length === block.lines.length) return { content, changed: false };
return { content: kept.join(""), changed: true };
}
/** Is this the empty stub `/agents` writes when disabling a built-in default? */
export function isEmptyStub(content: string): boolean {
return content.replace(/\r\n/g, "\n").trim() === "---\n---";
}
/** The answers `/agents → Create agent → Manual` collects, before serialization. */
export interface NewAgentInput {
description: string;
/** Already-resolved `tools:` value ("none", "all", or a CSV of tool names). */
tools: string;
/** `provider/modelId`, or undefined to inherit the parent's model. */
model?: string;
/** A pi thinking level, or undefined to inherit. */
thinking?: string;
systemPrompt: string;
}
/**
* Build the .md file the create wizard writes.
*
* `description` and `model` come straight from a free-text prompt, so they are
* quoted rather than interpolated — `serializeAgentFile` above quotes the
* description for the same reason. An unquoted YAML scalar mishandles ordinary
* input in two ways, and both are silent: a colon ("Scout: find things") makes
* the file unparseable, and since #212 an unparseable agent file is *skipped*,
* so the wizard reports success for an agent that does not exist; a `#`
* ("audit #security") opens a comment and truncates the value. `model` can
* carry a colon too — pi accepts a `provider/model:thinking` suffix.
*
* `tools` and `thinking` are not quoted: both are chosen from fixed menus, and
* `tools` is a CSV that must stay a bare scalar for the loader's parser.
*/
export function buildNewAgentFile(input: NewAgentInput): string {
const modelLine = input.model ? `\nmodel: ${JSON.stringify(input.model)}` : "";
const thinkingLine = input.thinking ? `\nthinking: ${input.thinking}` : "";
return `---
description: ${JSON.stringify(input.description)}
tools: ${input.tools}${modelLine}${thinkingLine}
prompt_mode: replace
---
${input.systemPrompt}
`;
}
/** Render a built-in tool list as a `tools:` frontmatter value. */
function formatToolsField(tools: string[] | undefined): string {
if (tools === undefined) return "all";
if (tools.length === 0) return "none";
return tools.join(", ");
}
/** Serialize an AgentConfig to a full .md file (frontmatter + system prompt) for eject. */
export function serializeAgentFile(cfg: AgentConfig): string {
const fmFields: string[] = [];
fmFields.push(`description: ${JSON.stringify(cfg.description)}`);
if (cfg.displayName) fmFields.push(`display_name: ${cfg.displayName}`);
if (cfg.color) fmFields.push(`color: ${JSON.stringify(cfg.color)}`);
// Absent means "all built-ins"; an EMPTY list means explicitly zero. Writing
// `all` for both would hand a deliberately tool-less agent the whole toolbox
// the first time it is ejected.
fmFields.push(`tools: ${formatToolsField(cfg.builtinToolNames)}`);
if (cfg.model) fmFields.push(`model: ${cfg.model}`);
if (cfg.thinking) fmFields.push(`thinking: ${cfg.thinking}`);
if (cfg.maxTurns) fmFields.push(`max_turns: ${cfg.maxTurns}`);
if (cfg.allowedSubagents !== undefined) {
fmFields.push(`allowed_subagents: ${cfg.allowedSubagents === "all" ? "all" : cfg.allowedSubagents.join(", ")}`);
}
fmFields.push(`prompt_mode: ${cfg.promptMode}`);
if (cfg.extensions === false) fmFields.push("extensions: false");
else if (Array.isArray(cfg.extensions)) fmFields.push(`extensions: ${cfg.extensions.join(", ")}`);
if (cfg.excludeExtensions?.length) fmFields.push(`exclude_extensions: ${cfg.excludeExtensions.join(", ")}`);
if (cfg.skills === false) fmFields.push("skills: false");
else if (Array.isArray(cfg.skills)) fmFields.push(`skills: ${cfg.skills.join(", ")}`);
if (cfg.disallowedTools?.length) fmFields.push(`disallowed_tools: ${cfg.disallowedTools.join(", ")}`);
if (cfg.inheritContext) fmFields.push("inherit_context: true");
// Both cases, not just `true`: with `backgroundByDefault` on, omitting the
// field means background, so `false` is the only way to pin an agent file to
// foreground and is no longer interchangeable with absence. No caller can
// reach it yet — Eject only handles built-in defaults, which omit the field —
// so this keeps the writer symmetric with the loader, nothing more.
if (cfg.runInBackground !== undefined) fmFields.push(`run_in_background: ${cfg.runInBackground}`);
if (cfg.outputTranscript === false) fmFields.push("output_transcript: false");
if (cfg.isolated) fmFields.push("isolated: true");
if (cfg.memory) fmFields.push(`memory: ${cfg.memory}`);
if (cfg.isolation) fmFields.push(`isolation: ${cfg.isolation}`);
return `---\n${fmFields.join("\n")}\n---\n\n${cfg.systemPrompt}\n`;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+346
View File
@@ -0,0 +1,346 @@
/**
* agent-types.ts — Unified agent type registry.
*
* Merges embedded default agents with user-defined agents from .pi/agents/*.md, .agents/agents/*.md, and global agents.
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
*/
import { createCodingTools, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
import { DEFAULT_AGENTS } from "./default-agents.js";
import type { AgentConfig } from "./types.js";
/**
* All known built-in tool names, derived from pi's own tool factories rather
* than hardcoded so the set tracks pi-mono if it adds/renames a built-in.
* `createCodingTools` → read/bash/edit/write; `createReadOnlyTools` →
* read/grep/find/ls; their de-duplicated union is the 7 built-ins
* (read, bash, edit, write, grep, find, ls). The `cwd` only binds tool
* operations we never invoke here — we read each tool's `.name` and discard it.
*/
export const BUILTIN_TOOL_NAMES: string[] = [
...new Set([...createCodingTools("."), ...createReadOnlyTools(".")].map((t) => t.name)),
];
/** Unified runtime registry of all agents (defaults + user-defined). */
const agents = new Map<string, AgentConfig>();
/** When true, DEFAULT_AGENTS are skipped during registration. */
let disableDefaults = false;
/** Check whether default agents are disabled. */
export function isDefaultsDisabled(): boolean { return disableDefaults; }
/** Set whether default agents are disabled. */
export function setDefaultsDisabled(b: boolean): void { disableDefaults = b; }
/** `fallbackSubagent` value that disables the fallback entirely (strict dispatch). */
export const NO_FALLBACK = "none";
/**
* Agent type substituted when a caller-supplied `subagent_type` doesn't resolve
* to exactly one enabled agent. `undefined` keeps the historical behavior
* (general-purpose); `NO_FALLBACK` makes dispatch fail closed. Set from
* `subagents.json` (`fallbackSubagent`).
*
* Module state rather than an index.ts closure because every caller-supplied
* spawn path needs it — the Agent tool, the scheduler, and cross-extension RPC.
*/
let fallbackSubagent: string | undefined;
/** Get the configured fallback agent type. undefined = general-purpose. */
export function getFallbackSubagent(): string | undefined { return fallbackSubagent; }
/** Set the configured fallback agent type. undefined = general-purpose. */
export function setFallbackSubagent(v: string | undefined): void { fallbackSubagent = v; }
/**
* Build a registry map: DEFAULT_AGENTS first (unless disabled via settings),
* then user agents overlaid on top (same name overrides the default).
* Pure — callers that must not disturb the process-wide registry (nested
* delegation resolving agents from its own config root) build their own map.
*/
export function buildAgentRegistry(userAgents: Map<string, AgentConfig>): Map<string, AgentConfig> {
const registry = new Map<string, AgentConfig>();
if (!disableDefaults) {
for (const [name, config] of DEFAULT_AGENTS) registry.set(name, config);
}
for (const [name, config] of userAgents) registry.set(name, config);
return registry;
}
/**
* Register agents into the unified registry.
* Starts with DEFAULT_AGENTS, then overlays user agents (overrides defaults with same name).
* Disabled agents (enabled === false) are kept in the registry but excluded from spawning.
*/
export function registerAgents(userAgents: Map<string, AgentConfig>): void {
agents.clear();
for (const [name, config] of buildAgentRegistry(userAgents)) {
agents.set(name, config);
}
}
/** Case-insensitive key resolution within a registry. */
function resolveKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
if (registry.has(name)) return name;
const lower = name.toLowerCase();
for (const key of registry.keys()) {
if (key.toLowerCase() === lower) return key;
}
return undefined;
}
/** Case-insensitive key resolution. */
function resolveKey(name: string): string | undefined {
return resolveKeyIn(agents, name);
}
/** Resolve a type name case-insensitively in a registry. Returns the canonical key or undefined. */
export function resolveTypeIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
return resolveKeyIn(registry, name);
}
/** Get the agent config for a type (case-insensitive) from a registry. */
export function getAgentConfigIn(registry: Map<string, AgentConfig>, name: string): AgentConfig | undefined {
const key = resolveKeyIn(registry, name);
return key ? registry.get(key) : undefined;
}
/** Check if a type is valid and enabled (case-insensitive) in a registry. */
export function isValidTypeIn(registry: Map<string, AgentConfig>, type: string): boolean {
const key = resolveKeyIn(registry, type);
if (!key) return false;
return registry.get(key)?.enabled !== false;
}
/** Get all enabled type names in a registry (for spawning and tool descriptions). */
export function getAvailableTypesIn(registry: Map<string, AgentConfig>): string[] {
return [...registry.entries()]
.filter(([_, config]) => config.enabled !== false)
.map(([name]) => name);
}
/**
* Case-insensitive resolution that refuses to guess. An exact match always wins;
* otherwise the name must match exactly one key. Two agents differing only in
* case are reachable (`loadCustomAgents` keys by filename across three
* directories), and picking whichever came first would silently dispatch a
* different agent, model and tool policy than the caller meant.
*/
function resolveUnambiguousKeyIn(registry: Map<string, AgentConfig>, name: string): string | undefined {
if (registry.has(name)) return name;
const lower = name.toLowerCase();
const matches = [...registry.keys()].filter(key => key.toLowerCase() === lower);
return matches.length === 1 ? matches[0] : undefined;
}
/**
* The canonical key for a caller-supplied name that identifies exactly one
* ENABLED agent, or undefined. Strict by construction: no fallback, no guessing
* between case-variants. Nested delegation resolves with this directly, since
* "unknown types are rejected rather than falling back" is its own contract.
*/
export function resolveEnabledTypeIn(
registry: Map<string, AgentConfig>,
requested: unknown,
): string | undefined {
const raw = typeof requested === "string" ? requested.trim() : "";
if (!raw) return undefined;
const key = resolveUnambiguousKeyIn(registry, raw);
return key !== undefined && registry.get(key)?.enabled !== false ? key : undefined;
}
/** Outcome of resolving a caller-supplied `subagent_type` into a spawnable type. */
export type SpawnTypeResolution =
/** Spawn this type. `fellBackFrom` is set when it isn't what the caller asked for. */
| { ok: true; type: string; fellBackFrom?: string }
/** Refuse the spawn and return this message to the caller. */
| { ok: false; message: string };
/**
* Resolve a caller-supplied agent type against a registry, applying the
* `fallbackSubagent` policy. The single decision point for every caller-supplied
* spawn — the Agent tool, the scheduler, cross-extension RPC, and the nested
* tools — so a type that fails here never reaches `runAgent`, where `getConfig`
* would silently substitute general-purpose.
*
* Unknown, disabled, and case-ambiguous names are all treated the same way:
* the caller named something that doesn't identify exactly one enabled agent.
*
* Pure over `registry` — callers that need fresh agent files reload before
* calling (the Agent tool already does, per spawn). Reloading here would mean
* importing custom-agents.ts, which imports this module.
*/
export function resolveSpawnTypeIn(
registry: Map<string, AgentConfig>,
requested: unknown,
): SpawnTypeResolution {
const raw = typeof requested === "string" ? requested.trim() : "";
const available = () => getAvailableTypesIn(registry).join(", ") || "(none)";
const key = resolveEnabledTypeIn(registry, raw);
if (key !== undefined) return { ok: true, type: key };
// A missing type follows the same policy as a wrong one rather than always
// erroring: before this setting existed an empty type fell back like any
// other unresolvable name, and only opting in should change that.
const reason = raw ? `Unknown or disabled agent type: "${raw}".` : "No agent type given.";
// Trimmed like `requested`: a padded value set programmatically would
// otherwise be reported as a missing agent.
const configured = typeof fallbackSubagent === "string" ? fallbackSubagent.trim() : undefined;
if (configured !== undefined && configured.toLowerCase() === NO_FALLBACK) {
return { ok: false, message: `${reason} Available: ${available()}.` };
}
if (configured !== undefined) {
// An explicitly configured fallback that is itself unusable is a
// misconfiguration, not a second chance to guess — say so rather than
// quietly dropping to general-purpose.
const fallbackKey = resolveUnambiguousKeyIn(registry, configured);
if (fallbackKey === undefined || registry.get(fallbackKey)?.enabled === false) {
return {
ok: false,
message:
`${reason} The configured fallbackSubagent "${configured}" is itself ` +
`unknown or disabled. Available: ${available()}.`,
};
}
return { ok: true, type: fallbackKey, fellBackFrom: raw };
}
// Unset: historical behavior, deliberately unchanged. #183 asks for the
// fallback to remain the default, so the pre-existing hole it leaves — an
// unregistered general-purpose resolving to `getConfig`'s all-tools hardcoded
// tier — is what `fallbackSubagent: none` is for, not something to close
// under everyone silently.
return { ok: true, type: "general-purpose", fellBackFrom: raw };
}
/** Resolve a caller-supplied agent type against the process-wide registry. */
export function resolveSpawnType(requested: unknown): SpawnTypeResolution {
return resolveSpawnTypeIn(agents, requested);
}
/** Resolve a type name case-insensitively. Returns the canonical key or undefined. */
export function resolveType(name: string): string | undefined {
return resolveKey(name);
}
/** Get the agent config for a type (case-insensitive). */
export function getAgentConfig(name: string): AgentConfig | undefined {
return getAgentConfigIn(agents, name);
}
/** Get all enabled type names (for spawning and tool descriptions). */
export function getAvailableTypes(): string[] {
return getAvailableTypesIn(agents);
}
/** Get all type names including disabled (for UI listing). */
export function getAllTypes(): string[] {
return [...agents.keys()];
}
/** Get names of default agents currently in the registry. */
export function getDefaultAgentNames(): string[] {
return [...agents.entries()]
.filter(([_, config]) => config.isDefault === true)
.map(([name]) => name);
}
/** Get names of user-defined agents (non-defaults) currently in the registry. */
export function getUserAgentNames(): string[] {
return [...agents.entries()]
.filter(([_, config]) => config.isDefault !== true)
.map(([name]) => name);
}
/** Check if a type is valid and enabled (case-insensitive). */
export function isValidType(type: string): boolean {
return isValidTypeIn(agents, type);
}
/** Tool names required for memory management. */
const MEMORY_TOOL_NAMES = ["read", "write", "edit"];
/**
* Get memory tool names (read/write/edit) not already in the provided set.
*/
export function getMemoryToolNames(existingToolNames: Set<string>): string[] {
return MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
}
/** Tool names needed for read-only memory access. */
const READONLY_MEMORY_TOOL_NAMES = ["read"];
/**
* Get read-only memory tool names not already in the provided set.
*/
export function getReadOnlyMemoryToolNames(existingToolNames: Set<string>): string[] {
return READONLY_MEMORY_TOOL_NAMES.filter(n => !existingToolNames.has(n));
}
/** Get built-in tool names for a type (case-insensitive). */
export function getToolNamesForType(type: string): string[] {
const key = resolveKey(type);
const raw = key ? agents.get(key) : undefined;
const config = raw?.enabled !== false ? raw : undefined;
// `undefined` (definition omitted the field) → all built-ins; an explicit `[]`
// (`tools: none` or a `tools:` with only `ext:` entries) → zero built-ins.
return config?.builtinToolNames ?? [...BUILTIN_TOOL_NAMES];
}
/** Get config for a type (case-insensitive, returns a SubagentTypeConfig-compatible object). Falls back to general-purpose. */
export function getConfig(type: string): {
displayName: string;
color?: string;
description: string;
builtinToolNames: string[];
extensions: true | string[] | false;
excludeExtensions?: string[];
skills: true | string[] | false;
promptMode: "replace" | "append";
} {
const key = resolveKey(type);
const config = key ? agents.get(key) : undefined;
if (config && config.enabled !== false) {
return {
displayName: config.displayName ?? config.name,
color: config.color,
description: config.description,
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
extensions: config.extensions,
excludeExtensions: config.excludeExtensions,
skills: config.skills,
promptMode: config.promptMode,
};
}
// Fallback for unknown/disabled types — general-purpose config
const gp = agents.get("general-purpose");
if (gp && gp.enabled !== false) {
return {
displayName: gp.displayName ?? gp.name,
color: gp.color,
description: gp.description,
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
extensions: gp.extensions,
excludeExtensions: gp.excludeExtensions,
skills: gp.skills,
promptMode: gp.promptMode,
};
}
// Absolute fallback (should never happen)
return {
displayName: "Agent",
description: "General-purpose agent for complex, multi-step tasks",
builtinToolNames: BUILTIN_TOOL_NAMES,
extensions: true,
skills: true,
promptMode: "append",
};
}
+15
View File
@@ -0,0 +1,15 @@
import { AsyncLocalStorage } from "node:async_hooks";
/**
* Marks resource loading/session construction performed for a subagent. This is
* async-context-local so concurrent top-level extension work is unaffected.
*/
const childSessionContext = new AsyncLocalStorage<boolean>();
export function inChildSessionContext(): boolean {
return childSessionContext.getStore() === true;
}
export function runInChildSessionContext<T>(fn: () => Promise<T>): Promise<T> {
return childSessionContext.run(true, fn);
}
+58
View File
@@ -0,0 +1,58 @@
/**
* context.ts — Extract parent conversation context for subagent inheritance.
*/
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
/** Extract text from a message content block array. */
export function extractText(content: unknown[]): string {
return content
.filter((c: any) => c.type === "text")
.map((c: any) => c.text ?? "")
.join("\n");
}
/**
* Build a text representation of the parent conversation context.
* Used when inherit_context is true to give the subagent visibility
* into what has been discussed/done so far.
*/
export function buildParentContext(ctx: ExtensionContext): string {
const entries = ctx.sessionManager.getBranch();
if (!entries || entries.length === 0) return "";
const parts: string[] = [];
for (const entry of entries) {
if (entry.type === "message") {
const msg = entry.message;
if (msg.role === "user") {
const text = typeof msg.content === "string"
? msg.content
: extractText(msg.content);
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
} else if (msg.role === "assistant") {
const text = extractText(msg.content);
if (text.trim()) parts.push(`[Assistant]: ${text.trim()}`);
}
// Skip toolResult messages — too verbose for context
} else if (entry.type === "compaction") {
// Include compaction summaries — they're already condensed
if (entry.summary) {
parts.push(`[Summary]: ${entry.summary}`);
}
}
}
if (parts.length === 0) return "";
return `# Parent Conversation Context
The following is the conversation history from the parent session that spawned you.
Use this context to understand what has been discussed and decided so far.
${parts.join("\n\n")}
---
# Your Task (below)
`;
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Cross-extension RPC handlers for the subagents extension.
*
* Exposes ping, spawn, and stop RPCs over the pi.events event bus,
* using per-request scoped reply channels.
*
* Reply envelope follows pi-mono convention:
* success → { success: true, data?: T }
* error → { success: false, error: string }
*/
import { type ModelRegistry, resolveModel } from "./model-resolver.js";
/** Minimal event bus interface needed by the RPC handlers. */
export interface EventBus {
on(event: string, handler: (data: unknown) => void): () => void;
emit(event: string, data: unknown): void;
}
/** RPC reply envelope — matches pi-mono's RpcResponse shape. */
export type RpcReply<T = void> =
| { success: true; data?: T }
| { success: false; error: string };
/** RPC protocol version — bumped when the envelope or method contracts change. */
export const PROTOCOL_VERSION = 2;
/** Minimal AgentManager interface needed by the spawn/stop RPCs. */
export interface SpawnCapable {
spawn(pi: unknown, ctx: unknown, type: string, prompt: string, options: any): string;
abort(id: string): boolean;
}
export interface RpcDeps {
events: EventBus;
pi: unknown; // passed through to manager.spawn
getCtx: () => unknown | undefined; // returns current ExtensionContext
manager: SpawnCapable;
}
export interface RpcHandle {
unsubPing: () => void;
unsubSpawn: () => void;
unsubStop: () => void;
}
/**
* Wire a single RPC handler: listen on `channel`, run `fn(params)`,
* emit the reply envelope on `channel:reply:${requestId}`.
*/
function handleRpc<P extends { requestId: string }>(
events: EventBus,
channel: string,
fn: (params: P) => unknown | Promise<unknown>,
): () => void {
return events.on(channel, async (raw: unknown) => {
const params = raw as P;
try {
const data = await fn(params);
const reply: { success: true; data?: unknown } = { success: true };
if (data !== undefined) reply.data = data;
events.emit(`${channel}:reply:${params.requestId}`, reply);
} catch (err: any) {
events.emit(`${channel}:reply:${params.requestId}`, {
success: false, error: err?.message ?? String(err),
});
}
});
}
/**
* Register ping, spawn, and stop RPC handlers on the event bus.
* Returns unsub functions for cleanup.
*/
export function registerRpcHandlers(deps: RpcDeps): RpcHandle {
const { events, pi, getCtx, manager } = deps;
const unsubPing = handleRpc(events, "subagents:rpc:ping", () => {
return { version: PROTOCOL_VERSION };
});
const unsubSpawn = handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
events, "subagents:rpc:spawn", ({ type, prompt, options }) => {
const ctx = getCtx();
if (!ctx) throw new Error("No active session");
// Cross-extension RPC callers (e.g. pi-tasks TaskExecute) naturally
// forward serializable values, so options.model can be a string like
// "openai-codex/gpt-5.5". Resolve it to a real Model instance here
// — same pattern the scheduler path already uses — so the spawned
// agent's auth lookup doesn't crash with "No API key found for
// undefined".
let normalizedOptions = options ?? {};
if (typeof normalizedOptions.model === "string") {
const registry = (ctx as { modelRegistry?: ModelRegistry }).modelRegistry;
if (!registry) {
throw new Error(
`Model override "${normalizedOptions.model}" provided but ctx.modelRegistry is unavailable`,
);
}
const resolved = resolveModel(normalizedOptions.model, registry);
if (typeof resolved === "string") {
// resolveModel returns a human-readable error string when the
// input doesn't match any available model. Surface it instead of
// silently falling back so the caller sees the auth/typo issue.
throw new Error(resolved);
}
normalizedOptions = { ...normalizedOptions, model: resolved };
}
return { id: manager.spawn(pi, ctx, type, prompt, normalizedOptions) };
},
);
const unsubStop = handleRpc<{ requestId: string; agentId: string }>(
events, "subagents:rpc:stop", ({ agentId }) => {
if (!manager.abort(agentId)) throw new Error("Agent not found");
},
);
return { unsubPing, unsubSpawn, unsubStop };
}
+305
View File
@@ -0,0 +1,305 @@
/**
* custom-agents.ts — Load user-defined agents from project (.pi/agents/, plus the shared .agents/agents/ workspace) and global ($PI_CODING_AGENT_DIR/agents/, default ~/.pi/agent/agents/) locations.
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
import type { AgentConfig, IsolationMode, MemoryScope, ThinkingLevel } from "./types.js";
/**
* The one thing a declared `name:` may not contain, matching Claude Code
* exactly: it reserves `:` for plugin-scoped identifiers (`my-plugin:reviewer`)
* and refuses to load a file whose name uses one.
*
* Nothing else is rejected. Claude Code's docs describe names as "lowercase
* letters and hyphens", but that is guidance — the only stated load failure is
* the colon, so `name: Code Reviewer` must work here too. (The stricter
* letters/digits/underscore/hyphen regex in Claude Code applies to the Agent
* tool's spawn-time `name` parameter, which is a different field.) Mixed case
* has to be allowed regardless: the built-in types `Explore` and `Plan` use it,
* and a file must be able to override one.
*/
const RESERVED_IN_TYPE = ":";
/**
* Scan for custom agent .md files from multiple locations.
* Discovery hierarchy (higher priority wins):
* 1. Project: <cwd>/.pi/agents/*.md (authoritative — also where /agents writes)
* 2. Workspace: <cwd>/.agents/agents/*.md (shared cross-tool .agents workspace, read-only)
* 3. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
*
* Project-level agents override global ones with the same name. On a name clash
* between the two project locations, .pi/agents wins — .pi stays the project
* authority; .agents/agents is an additional read location.
* Any name is allowed — names matching defaults (e.g. "Explore") override them.
*
* An agent's type comes from its frontmatter `name:`, falling back to the
* filename — Claude Code's rule, where "the filename doesn't have to match".
* Because the type is now declared rather than derived from a unique path, two
* files can claim the same one; the later load wins, as it always has for a
* filename clash, and `warnSkippedOverride` reports the substitution.
*/
export function loadCustomAgents(cwd: string, strict = false): Map<string, AgentConfig> {
const globalDir = join(getAgentDir(), "agents");
const workspaceProjectDir = join(cwd, ".agents", "agents");
const projectDir = join(cwd, ".pi", "agents");
const agents = new Map<string, AgentConfig>();
loadFromDir(globalDir, agents, "global", strict); // lowest priority
loadFromDir(workspaceProjectDir, agents, "project", strict); // shared workspace
loadFromDir(projectDir, agents, "project", strict); // highest priority (overwrites)
warnedLastLoad = warnedThisLoad;
warnedThisLoad = new Set();
return agents;
}
/** Load agent configs from a directory into the map. */
function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global", strict: boolean): void {
if (!existsSync(dir)) return;
let files: string[];
try {
files = readdirSync(dir).filter(f => f.endsWith(".md"));
} catch {
return;
}
for (const file of files) {
const filenameType = basename(file, ".md");
const path = join(dir, file);
const parsed = readAgentFile(path, strict);
if (!parsed) {
warnSkippedOverride(filenameType, agents);
continue;
}
const { frontmatter: fm, body } = parsed;
// Claude Code's rule: `name:` IS the agent type, and the filename need not
// match. Absent, the filename stands in — Claude Code requires the field,
// but most files here predate it and must keep loading.
const declared = str(fm.name)?.trim();
if (declared?.includes(RESERVED_IN_TYPE)) {
// Refusing beats silently substituting: the file would otherwise load
// under its filename, so `Agent({subagent_type})` would succeed against
// an agent whose declared identity nothing honoured.
warnIfNew(
`Agent file ${path} declares name "${declared}", which contains "${RESERVED_IN_TYPE}" — reserved for `
+ "plugin-scoped identifiers. Rename it, or move the label to `display_name:`. Skipping.",
);
// No `warnSkippedOverride`: this file would have registered under its
// *declared* name, which nothing else can hold (a colon keeps it out of
// the registry), so it shadowed nothing. Passing the filename instead
// would report a substitution of an unrelated agent that never happened.
continue;
}
// `||`, not `??`: a quoted empty or all-whitespace `name:` would otherwise
// register the agent under the empty type — unspawnable, and it takes the
// filename-derived one down with it.
const name = declared || filenameType;
const { builtinToolNames, extSelectors } = parseToolsField(fm.tools);
agents.set(name, {
name,
// Only `display_name` now: `name` is the type, and `getConfig` already
// falls back to the type when no label is set — so a Claude Code file
// with `name: code-reviewer` still badges as "code-reviewer".
displayName: str(fm.display_name),
color: str(fm.color),
description: str(fm.description) ?? name,
builtinToolNames,
extSelectors,
disallowedTools: csvListOptional(fm.disallowed_tools),
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
excludeExtensions: csvListOptional(fm.exclude_extensions),
skills: inheritField(fm.skills ?? fm.inherit_skills),
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,
outputTranscript: fm.output_transcript != null ? fm.output_transcript !== false : undefined,
sessionDir: str(fm.session_dir),
allowedSubagents: parseAllowedSubagents(fm.allowed_subagents),
systemPrompt: body.trim(),
promptMode: fm.prompt_mode === "append" ? "append" : "replace",
inheritContext: fm.inherit_context != null ? fm.inherit_context === true : undefined,
runInBackground: fm.run_in_background != null ? fm.run_in_background === true : undefined,
isolated: fm.isolated != null ? fm.isolated === true : undefined,
memory: parseMemory(fm.memory),
isolation: parseIsolation(fm.isolation),
enabled: fm.enabled !== false, // default true; explicitly false disables
source,
sourcePath: path,
});
}
}
/**
* Read and parse one agent file, or warn and return undefined for the caller to
* skip. One bad file must not take the whole extension down with it — an
* unparseable `.md` used to abort activation, so pi exited before the TUI.
*
* The path is as much of the fix as the recovery: a bare YAML error ("line 2,
* column 14") is unactionable when agents come from three directories at once,
* and the only other symptom is `Unknown agent type`, which reads like a typo.
*
* Under `strict` the same failure rethrows, still naming the path, so callers
* that opted into failing closed stop rather than run a substituted agent.
*/
function readAgentFile(path: string, strict: boolean): { frontmatter: Record<string, unknown>; body: string } | undefined {
try {
return parseFrontmatter<Record<string, unknown>>(readFileSync(path, "utf-8"));
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
if (strict) throw new Error(`${path}: ${reason}`);
warnIfNew(`Skipping agent file ${path}: ${reason}`);
return undefined;
}
}
/**
* A skipped file that was overriding an already-loaded agent leaves the name
* pointing at a *different* file — its own prompt, model and tools. Nothing
* downstream can flag that: unlike an unknown type, the `Agent` call succeeds.
*/
function warnSkippedOverride(name: string, agents: Map<string, AgentConfig>): void {
const surviving = agents.get(name);
// Nothing shadowed, or what it shadowed is disabled: dispatch refuses the type
// either way (see resolveEnabledTypeIn), so there is no substitution to report.
if (!surviving?.sourcePath || surviving.enabled === false) return;
warnIfNew(`Agent "${name}" now loads from ${surviving.sourcePath} instead`);
}
let warnedLastLoad = new Set<string>();
let warnedThisLoad = new Set<string>();
/**
* Agents reload on activation and again on every `Agent` call, so an unchanged
* problem would re-warn all session — over a painted TUI, since pi does not
* redirect console output. Compare against the previous load rather than every
* load ever, so a file that is fixed and then broken again still reports.
*/
function warnIfNew(message: string): void {
warnedThisLoad.add(message);
if (warnedLastLoad.has(message)) return;
console.warn(`[pi-subagents] ${message}`);
}
// ---- Field parsers ----
// All follow the same convention: omitted → default, "none"/empty → nothing, value → exact.
/** Extract a string or undefined. */
function str(val: unknown): string | undefined {
return typeof val === "string" ? val : undefined;
}
/** Extract a non-negative integer or undefined. 0 means unlimited for max_turns. */
function nonNegativeInt(val: unknown): number | undefined {
return typeof val === "number" && val >= 0 ? val : undefined;
}
/**
* Parse a raw CSV field value into items, or undefined if absent/empty/"none".
*/
function parseCsvField(val: unknown): string[] | undefined {
if (val === undefined || val === null) return undefined;
const s = String(val).trim();
if (!s || s === "none") return undefined;
const items = s.split(",").map(t => t.trim()).filter(Boolean);
return items.length > 0 ? items : undefined;
}
/**
* Parse the nested-delegation allowlist. Single field, default-off:
* omitted/empty/"none"/`false` → undefined (no nested tools); "all"/"*"/`true`
* → "all" (any enabled agent); csv → only the listed types.
*
* Booleans are accepted because `extensions:`/`skills:` take them and users
* generalize: without this, YAML's `true` stringifies into an agent type
* literally named "true", so the tools appear and every spawn is refused.
*/
function parseAllowedSubagents(val: unknown): "all" | string[] | undefined {
if (typeof val === "boolean") return val ? "all" : undefined;
const items = parseCsvField(val);
if (!items) return undefined;
return items.some(i => i === "*" || i.toLowerCase() === "all") ? "all" : items;
}
/**
* Parse a comma-separated list field with defaults.
* omitted → defaults; "none"/empty → []; csv → listed items.
*/
function csvList(val: unknown, defaults: string[]): string[] {
if (val === undefined || val === null) return defaults;
return parseCsvField(val) ?? [];
}
/**
* Partition the `tools:` CSV into the built-in tool allowlist and raw `ext:` selectors.
* `*` (and the case-insensitive alias `all`, for `tools: all`) expands to all
* built-ins; plain entries are built-in names; `ext:` entries are extension-tool
* selectors parsed later by the runner. omitted → all built-ins, no selectors.
* `tools:` present with only `ext:` entries → zero built-ins (use `*`).
*/
function parseToolsField(val: unknown): { builtinToolNames: string[]; extSelectors: string[] | undefined } {
const entries = csvList(val, BUILTIN_TOOL_NAMES);
const isWildcard = (e: string) => e === "*" || e.toLowerCase() === "all";
const hasWildcard = entries.some(isWildcard);
const plain = entries.filter(e => !isWildcard(e) && !e.startsWith("ext:"));
const extEntries = entries.filter(e => e.startsWith("ext:"));
return {
builtinToolNames: hasWildcard ? [...new Set([...BUILTIN_TOOL_NAMES, ...plain])] : plain,
extSelectors: extEntries.length > 0 ? extEntries : undefined,
};
}
/**
* Parse an optional comma-separated list field.
* omitted → undefined; "none"/empty → undefined; csv → listed items.
*/
function csvListOptional(val: unknown): string[] | undefined {
return parseCsvField(val);
}
/**
* Parse a memory scope field.
* omitted → undefined; "user"/"project"/"local" → MemoryScope.
*/
function parseMemory(val: unknown): MemoryScope | undefined {
if (val === "user" || val === "project" || val === "local") return val;
return undefined;
}
/**
* Parse the `isolation` frontmatter field.
*
* `off` is kept as a value rather than folded into `undefined` because the two
* do not mean the same thing here: agent config outranks tool-call params, so
* `off` vetoes a caller's `worktree` while an absent field lets it through.
*
* pi's frontmatter parser is not YAML 1.1 — bare `off` and `no` arrive as
* strings and only `false` becomes a boolean — so all three spellings are
* accepted rather than leaving an author's intent silently dropped. Anything
* else stays `undefined`, as before.
*/
function parseIsolation(val: unknown): IsolationMode | undefined {
if (val === "worktree") return "worktree";
if (val === "off" || val === "none" || val === "no" || val === false) return "off";
return undefined;
}
/**
* Parse an inherit field (extensions, skills).
* omitted/true → true (inherit all); false/"none"/empty → false; csv → listed names.
*/
function inheritField(val: unknown): true | string[] | false {
if (val === undefined || val === null || val === true) return true;
if (val === false || val === "none") return false;
const items = csvList(val, []);
return items.length > 0 ? items : false;
}
+126
View File
@@ -0,0 +1,126 @@
/**
* default-agents.ts — Embedded default agent configurations.
*
* These are always available but can be overridden by user .md files with the same name.
*/
import type { AgentConfig } from "./types.js";
const READ_ONLY_TOOLS = ["read", "bash", "grep", "find", "ls"];
export const DEFAULT_AGENTS: Map<string, AgentConfig> = new Map([
[
"general-purpose",
{
name: "general-purpose",
displayName: "Agent",
description: "General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.",
// builtinToolNames omitted — means "all available tools" (resolved at lookup time)
// inheritContext / runInBackground / isolated omitted — strategy fields, callers decide per-call.
// Setting them to false would lock callsite intent (see resolveAgentInvocationConfig in invocation-config.ts).
extensions: true,
skills: true,
systemPrompt: "",
promptMode: "append",
isDefault: true,
},
],
[
"Explore",
{
name: "Explore",
displayName: "Explore",
description: "Fast read-only search agent for locating code. Use it to find files by pattern (eg. \"src/components/**/*.tsx\"), grep for symbols or keywords (eg. \"API endpoints\"), or answer \"where is X defined / which files reference Y.\" Do NOT use it for code review, design-doc auditing, cross-file consistency checks, or open-ended analysis — it reads excerpts rather than whole files and will miss content past its read window. When calling, specify search breadth: \"quick\" for a single targeted lookup, \"medium\" for moderate exploration, or \"very thorough\" to search across multiple locations and naming conventions.",
builtinToolNames: READ_ONLY_TOOLS,
extensions: true,
skills: true,
// Fast/cheap model for read-only search. Provider-preferred but resilient:
// resolveModel matches this fuzzily (date-stamp optional) and falls back to
// the same model under another provider if anthropic doesn't expose it.
model: "anthropic/claude-haiku-4-5",
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
You are a file search specialist. You excel at thoroughly navigating and exploring codebases.
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools.
You are STRICTLY PROHIBITED from:
- Creating new files
- Modifying existing files
- Deleting files
- Moving or copying files
- Creating temporary files anywhere, including /tmp
- Using redirect operators (>, >>, |) or heredocs to write to files
- Running ANY commands that change system state
Use Bash ONLY for read-only operations: ls, git status, git log, git diff, find, cat, head, tail.
# Tool Usage
- Use the find tool for file pattern matching (NOT the bash find command)
- Use the grep tool for content search (NOT bash grep/rg command)
- Use the read tool for reading files (NOT bash cat/head/tail)
- Use Bash ONLY for read-only operations
- Make independent tool calls in parallel for efficiency
- Adapt search approach based on thoroughness level specified
# Output
- Use absolute file paths in all references
- Report findings as regular messages
- Do not use emojis
- Be thorough and precise`,
promptMode: "replace",
isDefault: true,
},
],
[
"Plan",
{
name: "Plan",
displayName: "Plan",
description: "Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.",
builtinToolNames: READ_ONLY_TOOLS,
extensions: true,
skills: true,
systemPrompt: `# CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS
You are a software architect and planning specialist.
Your role is EXCLUSIVELY to explore the codebase and design implementation plans.
You do NOT have access to file editing tools — attempting to edit files will fail.
You are STRICTLY PROHIBITED from:
- Creating new files
- Modifying existing files
- Deleting files
- Moving or copying files
- Creating temporary files anywhere, including /tmp
- Using redirect operators (>, >>, |) or heredocs to write to files
- Running ANY commands that change system state
# Planning Process
1. Understand requirements
2. Explore thoroughly (read files, find patterns, understand architecture)
3. Design solution based on your assigned perspective
4. Detail the plan with step-by-step implementation strategy
# Requirements
- Consider trade-offs and architectural decisions
- Identify dependencies and sequencing
- Anticipate potential challenges
- Follow existing patterns where appropriate
# Tool Usage
- Use the find tool for file pattern matching (NOT the bash find command)
- Use the grep tool for content search (NOT bash grep/rg command)
- Use the read tool for reading files (NOT bash cat/head/tail)
- Use Bash ONLY for read-only operations
# Output Format
- Use absolute file paths
- Do not use emojis
- End your response with:
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan:
- /absolute/path/to/file.ts - [Brief reason]`,
promptMode: "replace",
isDefault: true,
},
],
]);
+180
View File
@@ -0,0 +1,180 @@
/**
* Reads `enabledModels` from pi's settings (global `<agentDir>/settings.json`
* + project-local `<cwd>/.pi/settings.json`, project wins) and resolves
* entries to concrete `provider/modelId` keys for scope validation.
*
* **Project overrides global**, mirroring pi's own `SettingsManager`
* deep-merge behavior and matching the precedence we use for our own
* `subagents.json` settings (see `src/settings.ts:loadSettings`). If
* project file has `enabledModels` set, it wholly replaces global's
* (array fields are replaced, not concatenated).
*
* **Limited subset of upstream's resolveModelScope.** We support exact
* `provider/modelId` matching only. Upstream (pi-coding-agent's
* `core/model-resolver.ts`) additionally supports glob patterns
* (`*sonnet*`, `anthropic/*`), bare model IDs without provider, and
* thinking-level suffixes (`provider/*:high`). Those forms are silently
* ignored here.
*
* In practice, pi's `/scoped-models` picker writes exact `provider/modelId`
* entries, so the limitation is invisible for users who configure scope
* through pi's UI. Hand-edited settings using globs or bare IDs will
* produce an empty allowed set (scope check becomes a no-op).
*
* Example:
* enabledModels = ["anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6"]
* → resolves to { "anthropic/claude-sonnet-4-6", "anthropic/claude-opus-4-6" }
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import type { ModelEntry } from "./model-resolver.js";
/** Minimal registry shape — only the methods resolveEnabledModels actually calls. */
export interface ModelRegistryRef {
getAll(): unknown[];
getAvailable?(): unknown[];
}
/** Paths to pi's settings.json files: [project, global] (project takes precedence). */
function settingsPaths(cwd: string): [project: string, global: string] {
return [
join(cwd, ".pi", "settings.json"),
join(getAgentDir(), "settings.json"),
];
}
/** Read `enabledModels` from a single settings.json file. Undefined when missing or absent. */
function readField(path: string): string[] | undefined {
if (!existsSync(path)) return undefined;
try {
const raw = JSON.parse(readFileSync(path, "utf-8"));
if (Array.isArray(raw?.enabledModels)) return raw.enabledModels as string[];
} catch {
/* corrupt file — silent */
}
return undefined;
}
/**
* Read enabledModels from pi's settings — project-local overrides global.
* Mirrors pi's SettingsManager deep-merge for the `enabledModels` field
* (and matches our own loadSettings precedence in src/settings.ts).
* Returns undefined when neither file has the field.
*/
export function readEnabledModels(cwd: string): string[] | undefined {
const [project, global] = settingsPaths(cwd);
return readField(project) ?? readField(global);
}
/**
* Resolve enabledModels patterns → Set<"provider/modelId"> (lowercase keys).
*
* Only exact `provider/modelId` patterns are matched (case-insensitive).
* Patterns without a slash, with glob characters, or with a `:thinking`
* suffix are silently dropped. See module-level docstring for rationale.
*
* Cache: keyed on JSON.stringify(patterns) + mtime/size of *both*
* project and global settings.json files. Re-resolves when either file
* changes or the patterns argument differs.
*
* Returns undefined when no patterns are provided or no patterns match
* (scope check becomes a no-op at the call site).
*/
// Module-level cache — invalidated when either settings.json changes or patterns differ.
let cachedAllowed: Set<string> | undefined;
let cachedHash = "";
let cachedPatternsKey = "";
/** mtime+size hash of one file, or "missing" if absent. */
function hashOf(path: string): string {
try {
const s = statSync(path);
return `${s.mtimeMs}-${s.size}`;
} catch {
return "missing";
}
}
export function resolveEnabledModels(
patterns: string[] | undefined,
registry: ModelRegistryRef,
cwd: string = process.cwd(),
): Set<string> | undefined {
// Fast path: check cache (stat both project and global settings.json files)
const patternsKey = JSON.stringify(patterns);
const [project, global] = settingsPaths(cwd);
const fileHash = `${hashOf(project)};${hashOf(global)}`;
if (fileHash === cachedHash && patternsKey === cachedPatternsKey) {
return cachedAllowed;
}
// Cache miss — resolve
if (!patterns || patterns.length === 0) {
cachedHash = fileHash;
cachedPatternsKey = patternsKey;
cachedAllowed = undefined;
return undefined;
}
const available = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
const allowed = new Set<string>();
for (const pattern of patterns) {
const trimmed = pattern.trim();
if (!trimmed) continue; // skip empty/whitespace
resolveExact(trimmed, available, allowed);
}
const result = allowed.size > 0 ? allowed : undefined;
cachedHash = fileHash;
cachedPatternsKey = patternsKey;
cachedAllowed = result;
return result;
}
/**
* True when `model` is in the allowed set. Centralizes the key format
* (`provider/id` lowercase) so callers don't have to reproduce it —
* both set-building (resolveExact) and lookup go through `modelKey`.
*/
export function isModelInScope(
model: { provider: string; id: string },
allowed: Set<string>,
): boolean {
return allowed.has(modelKey(model));
}
/** Canonical lowercase `provider/id` key for the allowed set. */
function modelKey(model: { provider: string; id: string }): string {
return `${model.provider}/${model.id}`.toLowerCase();
}
/**
* Resolve exact model pattern. Example: "google/gemma-4-31b-it".
*/
function resolveExact(
pattern: string,
available: ModelEntry[],
allowed: Set<string>,
): void {
// "provider/modelId" — exact (colon is part of id, not split)
const slashIdx = pattern.indexOf("/");
if (slashIdx === -1) return; // bare modelId not supported
const provider = pattern.slice(0, slashIdx).toLowerCase();
const modelId = pattern.slice(slashIdx + 1).toLowerCase();
const exact = available.find(
m => m.provider.toLowerCase() === provider && m.id.toLowerCase() === modelId,
);
if (exact) {
allowed.add(modelKey(exact));
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* env.ts — Detect environment info (git, platform) for subagent system prompts.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import type { EnvInfo } from "./types.js";
export async function detectEnv(pi: ExtensionAPI, cwd: string): Promise<EnvInfo> {
let isGitRepo = false;
let branch = "";
try {
const result = await pi.exec("git", ["rev-parse", "--is-inside-work-tree"], { cwd, timeout: 5000 });
isGitRepo = result.code === 0 && result.stdout.trim() === "true";
} catch {
// Not a git repo or git not installed
}
if (isGitRepo) {
try {
const result = await pi.exec("git", ["branch", "--show-current"], { cwd, timeout: 5000 });
branch = result.code === 0 ? result.stdout.trim() : "unknown";
} catch {
branch = "unknown";
}
}
return {
isGitRepo,
branch,
platform: process.platform,
};
}
+141
View File
@@ -0,0 +1,141 @@
/**
* group-join.ts — Manages grouped background agent completion notifications.
*
* Instead of each agent individually nudging the main agent on completion,
* agents in a group are held until all complete (or a timeout fires),
* then a single consolidated notification is sent.
*/
import type { AgentRecord } from "./types.js";
export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
interface AgentGroup {
groupId: string;
agentIds: Set<string>;
completedRecords: Map<string, AgentRecord>;
timeoutHandle?: ReturnType<typeof setTimeout>;
delivered: boolean;
/** Shorter timeout for stragglers after a partial delivery. */
isStraggler: boolean;
}
/** Default timeout: 30s after first completion in a group. */
const DEFAULT_TIMEOUT = 30_000;
/** Straggler re-batch timeout: 15s. */
const STRAGGLER_TIMEOUT = 15_000;
export class GroupJoinManager {
private groups = new Map<string, AgentGroup>();
private agentToGroup = new Map<string, string>();
constructor(
private deliverCb: DeliveryCallback,
private groupTimeout = DEFAULT_TIMEOUT,
) {}
/** Register a group of agent IDs that should be joined. */
registerGroup(groupId: string, agentIds: string[]): void {
const group: AgentGroup = {
groupId,
agentIds: new Set(agentIds),
completedRecords: new Map(),
delivered: false,
isStraggler: false,
};
this.groups.set(groupId, group);
for (const id of agentIds) {
this.agentToGroup.set(id, groupId);
}
}
/**
* Called when an agent completes.
* Returns:
* - 'pass' — agent is not grouped, caller should send individual nudge
* - 'held' — result held, waiting for group completion
* - 'delivered' — this completion triggered the group notification
*/
onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
const groupId = this.agentToGroup.get(record.id);
if (!groupId) return 'pass';
const group = this.groups.get(groupId);
if (!group || group.delivered) return 'pass';
group.completedRecords.set(record.id, record);
// All done — deliver immediately
if (group.completedRecords.size >= group.agentIds.size) {
this.deliver(group, false);
return 'delivered';
}
// First completion in this batch — start timeout
if (!group.timeoutHandle) {
const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
group.timeoutHandle = setTimeout(() => {
this.onTimeout(group);
}, timeout);
}
return 'held';
}
private onTimeout(group: AgentGroup): void {
if (group.delivered) return;
group.timeoutHandle = undefined;
// Partial delivery — some agents still running
const remaining = new Set<string>();
for (const id of group.agentIds) {
if (!group.completedRecords.has(id)) remaining.add(id);
}
// Clean up agentToGroup for delivered agents (they won't complete again)
for (const id of group.completedRecords.keys()) {
this.agentToGroup.delete(id);
}
// Deliver what we have
this.deliverCb([...group.completedRecords.values()], true);
// Set up straggler group for remaining agents
group.completedRecords.clear();
group.agentIds = remaining;
group.isStraggler = true;
// Timeout will be started when the next straggler completes
}
private deliver(group: AgentGroup, partial: boolean): void {
if (group.timeoutHandle) {
clearTimeout(group.timeoutHandle);
group.timeoutHandle = undefined;
}
group.delivered = true;
this.deliverCb([...group.completedRecords.values()], partial);
this.cleanupGroup(group.groupId);
}
private cleanupGroup(groupId: string): void {
const group = this.groups.get(groupId);
if (!group) return;
for (const id of group.agentIds) {
this.agentToGroup.delete(id);
}
this.groups.delete(groupId);
}
/** Check if an agent is in a group. */
isGrouped(agentId: string): boolean {
return this.agentToGroup.has(agentId);
}
dispose(): void {
for (const group of this.groups.values()) {
if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
}
this.groups.clear();
this.agentToGroup.clear();
}
}
File diff suppressed because it is too large Load Diff
+130
View File
@@ -0,0 +1,130 @@
import { Type } from "@sinclair/typebox";
import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
/**
* The model-facing `isolation` parameter, shared by the `Agent` tool and the
* nested delegation tool so the two cannot drift.
*
* Shape matters more than wording here. As a single-value optional literal,
* models that fill every optional parameter — the transcript on #231 shows one
* emitting `resume: ""`, `schedule: ""` and `model: "default"` alongside it —
* had only `"worktree"` available to fill it with, and kept spawning worktrees
* across three turns while their own reasoning said to omit the field. Every
* other optional parameter has an inert filler; this one did not. `"off"` is
* listed first and described as the default so the harmless value is the
* obvious one to reach for.
*
* The wording tracks Claude Code's own `isolation` parameter, whose phrasing
* models have the most exposure to: one description on the union rather than
* per-value ones, opening "Isolation mode.", then a sentence per value in
* schema order, each with its caveats in a trailing parenthetical. Two clauses
* are ours, because our shape is not theirs — `"off"` has no counterpart there
* (their enum is `worktree | remote`, so both of their values do something),
* and neither does the uncommitted-work warning, which is the specific trap
* #231 fell into. Deliberately absent is any "only use a worktree when…"
* restriction: Claude Code's `Agent` tool states the capability and stops, and
* a second legal value is what lets a model decline one, not being told to.
*/
const isolationParamShape = {
isolation: Type.Optional(
Type.Union([Type.Literal("off"), Type.Literal("worktree")], {
description:
'Isolation mode. Default "off". "off" runs the agent in the current checkout, the same as omitting the field. "worktree" creates a temporary git worktree so the agent works on an isolated copy of the repo (a copy cannot see uncommitted or staged changes in the main checkout).',
}),
),
};
/**
* Build the `isolation` parameter for a tool schema, or nothing when the
* project disabled worktrees (`worktreeIsolation: false`).
*
* Dropping the field beats accepting it and quietly downgrading. The setting is
* for a project whose model passes `"worktree"` on *every* call, so a
* per-result "isolation was disabled" note would be noise on every result and
* would keep raising the salience of a capability that isn't there. With no
* field there is nothing to pass, nothing to drop, and nothing to explain — the
* same trade `scheduleParam` makes for disabled scheduling, at zero LLM-context
* cost. The resolver gate and the `agent-manager` check still cover the paths a
* schema can't reach: agent files, the scheduler, and cross-extension RPC.
*
* Like `scheduleParam`, this is read once at tool registration — flipping the
* setting needs a new pi session for the schema to change.
*/
export function isolationParam(enabled: boolean): Partial<typeof isolationParamShape> {
return enabled ? isolationParamShape : {};
}
interface AgentInvocationParams {
model?: string;
thinking?: string;
max_turns?: number;
run_in_background?: boolean;
inherit_context?: boolean;
isolated?: boolean;
/**
* Untyped on purpose. Both tool schemas now build this field conditionally
* and spread it, which erases TypeBox's literal inference to `unknown` (the
* `schedule` param has the same shape). The resolver below narrows by
* comparison rather than trusting the declaration, which also makes it safe
* for the cross-extension RPC path, where options arrive unvalidated.
*/
isolation?: unknown;
}
interface ResolveOptions {
/**
* Whether worktree isolation is permitted at all. False when the project set
* `worktreeIsolation: false`, which drops a requested worktree rather than
* failing the call: the fail-loud precedent covers spawns that *cannot* work,
* while this one is the user opting out, and throwing would break exactly the
* calls the `"off"` value exists to tolerate. Defaults to allowed.
*/
worktreeAllowed?: boolean;
/**
* What an unqualified spawn means — neither the call nor the agent file said.
*
* Top-level callers pass the `backgroundByDefault` setting (default `true`,
* following Claude Code). Nested callers pass `false` unconditionally: a
* detached child is killed by `abortOwnedChildren` when its parent settles
* and has no notification path of its own, so backgrounding one loses its
* work. Both call sites pass it explicitly; the `false` fallback only covers
* a caller that supplies no options at all, which in-tree means tests.
*/
defaultRunInBackground?: boolean;
}
export function resolveAgentInvocationConfig(
agentConfig: AgentConfig | undefined,
params: AgentInvocationParams,
opts?: ResolveOptions,
): {
modelInput?: string;
modelFromParams: boolean;
thinking?: ThinkingLevel;
maxTurns?: number;
inheritContext: boolean;
runInBackground: boolean;
isolated: boolean;
isolation?: IsolationMode;
} {
// Precedence first, collapse second — reversing these loses the veto, since
// an agent file's "off" only outranks a caller's "worktree" while it is still
// a value. Everything downstream then sees "worktree" or nothing at all.
const requested = agentConfig?.isolation ?? params.isolation;
const isolation = requested === "worktree" && opts?.worktreeAllowed !== false ? "worktree" : undefined;
return {
modelInput: agentConfig?.model ?? params.model,
modelFromParams: agentConfig?.model == null && params.model != null,
thinking: (agentConfig?.thinking ?? params.thinking) as ThinkingLevel | undefined,
maxTurns: agentConfig?.maxTurns ?? params.max_turns,
inheritContext: agentConfig?.inheritContext ?? params.inherit_context ?? false,
runInBackground: agentConfig?.runInBackground ?? params.run_in_background ?? opts?.defaultRunInBackground ?? false,
isolated: agentConfig?.isolated ?? params.isolated ?? false,
isolation,
};
}
export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
return runInBackground ? defaultJoinMode : undefined;
}
+65
View File
@@ -0,0 +1,65 @@
import { realpathSync, statSync } from "node:fs";
import { isAbsolute, resolve } from "node:path";
export interface MandatoryExtensionPolicy {
/** Exact, bundle-owned extension entry paths that an agent cannot disable. */
mandatoryExtensionPaths?: readonly string[];
}
function canonicalExistingPath(path: string): string {
if (!isAbsolute(path)) {
throw new Error(`Mandatory extension path must be absolute: ${path}`);
}
let canonical: string;
try {
canonical = realpathSync.native(path);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Mandatory extension path is unavailable: ${path} (${message})`);
}
const stat = statSync(canonical);
if (!stat.isFile() && !stat.isDirectory()) {
throw new Error(`Mandatory extension path is not a file or directory: ${path}`);
}
return canonical;
}
/** Validate, canonicalize, and deduplicate bundle-owned extension entry paths. */
export function normalizeMandatoryExtensionPaths(paths: readonly string[] | undefined): readonly string[] {
if (!paths?.length) return [];
return Object.freeze([...new Set(paths.map(canonicalExistingPath))]);
}
/**
* Compare loader-reported paths to a canonical mandatory entry. Loader paths
* normally exist, but resolve-only fallback keeps diagnostics deterministic if
* an extension disappears between reload and verification.
*/
export function extensionPathMatches(actualPath: string, canonicalExpectedPath: string): boolean {
let actual: string;
try {
actual = realpathSync.native(actualPath);
} catch {
actual = resolve(actualPath);
}
return actual === canonicalExpectedPath;
}
export function isMandatoryExtensionPath(
extensionPath: string,
mandatoryExtensionPaths: readonly string[],
): boolean {
return mandatoryExtensionPaths.some((path) => extensionPathMatches(extensionPath, path));
}
/** Mandatory infrastructure is useful only if the exact trusted entry survived reload. */
export function assertMandatoryExtensionsLoaded(
loadedExtensionPaths: readonly string[],
mandatoryExtensionPaths: readonly string[],
): void {
for (const mandatoryPath of mandatoryExtensionPaths) {
if (!loadedExtensionPaths.some((loadedPath) => extensionPathMatches(loadedPath, mandatoryPath))) {
throw new Error(`Mandatory extension failed to load: ${mandatoryPath}`);
}
}
}
+179
View File
@@ -0,0 +1,179 @@
/**
* memory.ts — Persistent agent memory: per-agent memory directories that persist across sessions.
*
* Memory scopes:
* - "user" → getAgentDir()/agent-memory/{agent-name}/ (default ~/.pi/agent/agent-memory/, honors $PI_CODING_AGENT_DIR)
* - "project" → .pi/agent-memory/{agent-name}/
* - "local" → .pi/agent-memory-local/{agent-name}/
*
* The user scope previously hardcoded ~/.pi/agent-memory/. That legacy location
* is still honored (read + write) when it exists and the new location doesn't,
* so existing memories aren't orphaned.
*/
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import type { MemoryScope } from "./types.js";
/** Maximum lines to read from MEMORY.md */
const MAX_MEMORY_LINES = 200;
/**
* Returns true if a name contains characters not allowed in agent/skill names.
* Uses a whitelist: only alphanumeric, hyphens, underscores, and dots (no leading dot).
*/
export function isUnsafeName(name: string): boolean {
if (!name || name.length > 128) return true;
return !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name);
}
/**
* Returns true if the given path is a symlink (defense against symlink attacks).
*/
export function isSymlink(filePath: string): boolean {
try {
return lstatSync(filePath).isSymbolicLink();
} catch {
return false;
}
}
/**
* Safely read a file, rejecting symlinks.
* Returns undefined if the file doesn't exist, is a symlink, or can't be read.
*/
export function safeReadFile(filePath: string): string | undefined {
if (!existsSync(filePath)) return undefined;
if (isSymlink(filePath)) return undefined;
try {
return readFileSync(filePath, "utf-8");
} catch {
return undefined;
}
}
/**
* Resolve the memory directory path for a given agent + scope + cwd.
* Throws if agentName contains path traversal characters.
*/
export function resolveMemoryDir(agentName: string, scope: MemoryScope, cwd: string): string {
if (isUnsafeName(agentName)) {
throw new Error(`Unsafe agent name for memory directory: "${agentName}"`);
}
switch (scope) {
case "user": {
const current = join(getAgentDir(), "agent-memory", agentName);
// Legacy location from when this path was hardcoded. Keep using it if it
// already holds this agent's memory and the new location hasn't been
// created yet — otherwise existing memories would be silently orphaned.
const legacy = join(homedir(), ".pi", "agent-memory", agentName);
if (!existsSync(current) && existsSync(legacy) && !isSymlink(legacy)) {
return legacy;
}
return current;
}
case "project":
return join(cwd, ".pi", "agent-memory", agentName);
case "local":
return join(cwd, ".pi", "agent-memory-local", agentName);
}
}
/**
* Ensure the memory directory exists, creating it if needed.
* Refuses to create directories if any component in the path is a symlink
* to prevent symlink-based directory traversal attacks.
*/
export function ensureMemoryDir(memoryDir: string): void {
// If the directory already exists, verify it's not a symlink
if (existsSync(memoryDir)) {
if (isSymlink(memoryDir)) {
throw new Error(`Refusing to use symlinked memory directory: ${memoryDir}`);
}
return;
}
mkdirSync(memoryDir, { recursive: true });
}
/**
* Read the first N lines of MEMORY.md from the memory directory, if it exists.
* Returns undefined if no MEMORY.md exists or if the path is a symlink.
*/
export function readMemoryIndex(memoryDir: string): string | undefined {
// Reject symlinked memory directories
if (isSymlink(memoryDir)) return undefined;
const memoryFile = join(memoryDir, "MEMORY.md");
const content = safeReadFile(memoryFile);
if (content === undefined) return undefined;
const lines = content.split("\n");
if (lines.length > MAX_MEMORY_LINES) {
return lines.slice(0, MAX_MEMORY_LINES).join("\n") + "\n... (truncated at 200 lines)";
}
return content;
}
/**
* Build the memory block to inject into the agent's system prompt.
* Also ensures the memory directory exists (creates it if needed).
*/
export function buildMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
// Create the memory directory so the agent can immediately write to it
ensureMemoryDir(memoryDir);
const existingMemory = readMemoryIndex(memoryDir);
const header = `# Agent Memory
You have a persistent memory directory at: ${memoryDir}/
Memory scope: ${scope}
This memory persists across sessions. Use it to build up knowledge over time.`;
const memoryContent = existingMemory
? `\n\n## Current MEMORY.md\n${existingMemory}`
: `\n\nNo MEMORY.md exists yet. Create one at ${join(memoryDir, "MEMORY.md")} to start building persistent memory.`;
const instructions = `
## Memory Instructions
- MEMORY.md is an index file — keep it concise (under 200 lines). Lines after 200 are truncated.
- Store detailed memories in separate files within ${memoryDir}/ and link to them from MEMORY.md.
- Each memory file should use this frontmatter format:
\`\`\`markdown
---
name: <memory name>
description: <one-line description>
type: <user|feedback|project|reference>
---
<memory content>
\`\`\`
- Update or remove memories that become outdated. Check for existing memories before creating duplicates.
- You have Read, Write, and Edit tools available for managing memory files.`;
return header + memoryContent + instructions;
}
/**
* Build a read-only memory block for agents that lack write/edit tools.
* Does NOT create the memory directory — agents can only consume existing memory.
*/
export function buildReadOnlyMemoryBlock(agentName: string, scope: MemoryScope, cwd: string): string {
const memoryDir = resolveMemoryDir(agentName, scope, cwd);
const existingMemory = readMemoryIndex(memoryDir);
const header = `# Agent Memory (read-only)
Memory scope: ${scope}
You have read-only access to memory. You can reference existing memories but cannot create or modify them.`;
const memoryContent = existingMemory
? `\n\n## Current MEMORY.md\n${existingMemory}`
: `\n\nNo memory is available yet. Other agents or sessions with write access can create memories for you to consume.`;
return header + memoryContent;
}
+211
View File
@@ -0,0 +1,211 @@
/**
* mention-clone.ts — start a mentioned agent through a clone of this
* conversation, without putting anything in the chat.
*
* Claude Code routes `@agent-<type>` through the main model: the mention
* becomes a `<system-reminder>` appended to the prompt and the model makes the
* tool call (see `agentMentionReminder`). That buys the spawned agent a prompt
* written with conversation context, and costs a visible turn — the model's
* reasoning and its tool block land in the transcript, for a decision the user
* already made when they typed the handle.
*
* So the turn happens somewhere else. The conversation is cloned into a
* throwaway in-memory session — same messages, same system prompt, same model —
* and that copy takes the turn off-screen. A literal clone: the session's own
* entries, projected by pi's own `sessionEntryToContextMessages`, not
* `inherit_context`'s text rendering of them.
*
* Cloned from memory rather than from the session file, which cannot be relied
* on: `SessionManager._persist` withholds every write until the first assistant
* message lands, so a fork taken before then reads an empty file and throws.
* `buildSessionContext()` has no such timing, and is compaction-aware — it walks
* the leaf path and substitutes the summary for entries folded into it, so a
* long conversation clones as what the main model is actually working from. A
* conversation with nothing in it yet clones to nothing in it yet, which is the
* correct answer rather than a failure.
*
* It is also the oldest of the equivalent Pi APIs — `buildContextEntries` on
* ReadonlySessionManager and the `sessionEntryToContextMessages` export both
* arrived in 0.80.5 — where this one has been exported unchanged from before
* the declared peer floor, and is the same code path (`byId` is only an index
* cache, so passing it or not cannot change the result). Keeping the floor
* honest costs nothing here: see the `compat-floor-pi` job.
*
* Its `thinkingLevel` is NOT used, and is the one place the newer API would be
* better. `getSessionContextSettings` starts at "off" and moves only on an
* explicit `thinking_level_change` entry, so a session where nobody ran
* `/think` reports "off" rather than the level it is really using. Omitting the
* field instead lets `createAgentSession` resolve it from settings, which is
* that real level.
*
* Three details make the spawn belong to the real session rather than the
* clone:
*
* - the clone is handed the *registered* `Agent` tool, whose handler closes
* over the main activation, so it spawns top-level: widget, fleet row,
* handle, completion notification, all as if the main model had called it;
* - that tool is re-bound to the main `ExtensionContext`, because the handler
* reads `cwd`, `model` and `sessionManager.getSessionId()` off it to place
* the transcript and the `rootSessionId`. The clone's own context would
* file both under the throwaway fork;
* - it is called with no tool-call id. The clone's turn produces one, but the
* real session never issued it, and a `<tool-use-id>` pointing at nothing
* is exactly the bug the mention-resume path had to fix;
* - and it is forced into the background. A foreground agent returns its
* answer as the tool result and is marked `resultConsumed` so no completion
* notification is sent — correct when the caller is the real conversation,
* silent loss when the caller is a fork about to be discarded. Background
* delivery is the only route from a mention back to the main model.
*
* The clone gets one tool and one job. It cannot read, write or run anything —
* an invisible turn with the full toolset could do invisible work.
*/
import type { Model } from "@earendil-works/pi-ai";
import {
buildSessionContext,
createAgentSession,
DefaultResourceLoader,
type ExtensionContext,
getAgentDir,
SessionManager,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { runInChildSessionContext } from "./child-context.js";
import { agentMentionReminder } from "./mention.js";
import type { SubagentType, ThinkingLevel } from "./types.js";
export interface MentionCloneOptions {
/** The MAIN session's context — what the spawn is attributed to, and the
* source of both the conversation and the live system prompt. */
ctx: ExtensionContext;
/** Agent type the handle resolved to. */
type: SubagentType;
/** What the user typed after the handle. */
message: string;
/** The registered `Agent` tool, reused so the spawn is an ordinary one. */
agentTool: ToolDefinition;
}
export interface MentionCloneResult {
/** True once the clone actually called `Agent`. */
spawned: boolean;
/** Why not, when it didn't. Absent on success. */
error?: string;
}
/**
* Fork the conversation, let the copy make the tool call, throw the copy away.
* Never rejects: a clone that cannot run is reported so the caller can fall
* back to starting the agent directly.
*/
export async function runMentionClone(opts: MentionCloneOptions): Promise<MentionCloneResult> {
const { ctx, type, message, agentTool } = opts;
let spawned = false;
const cloneAgentTool: ToolDefinition = {
...agentTool,
execute: (_cloneToolCallId, params, signal, onUpdate, _cloneCtx) => {
// One spawn per mention. The clone has a single tool and every reason to
// stop after using it, but a model that decides to "also" launch a second
// agent would do it where nobody can see and nobody asked.
if (spawned) {
return Promise.resolve({
content: [{ type: "text" as const, text: "Already started an agent for this mention. Stop here." }],
details: undefined,
isError: true,
});
}
spawned = true;
// undefined tool-call id + the main ctx: see the header. Background is
// forced rather than left to the clone: `run_in_background` defaults to
// false, and a foreground agent answers through its TOOL RESULT — which
// here is delivered into a session that is disposed moments later, so the
// agent would run, appear in the widget and the fleet, and reach nobody.
return agentTool.execute(
undefined as never,
{ ...(params as Record<string, unknown>), run_in_background: true } as typeof params,
signal,
onUpdate,
ctx,
);
},
};
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
try {
// Pi 0.80.8 moved createAgentSession from modelRegistry to modelRuntime;
// agent-runner.ts carries the same shim for the same reason — pass both so
// the clone keeps the parent's providers across the supported range.
const parentModelRuntime = (ctx.modelRegistry as unknown as { runtime?: unknown }).runtime;
// The conversation as the main session resolves it: compaction applied,
// branch summaries substituted.
const conversation = buildSessionContext(
ctx.sessionManager.getEntries(),
ctx.sessionManager.getLeafId(),
);
// Pi 0.82.0 added this; below it the field is absent and the clone takes
// the settings level instead, which is what a session that never ran
// `/think` is on anyway. Same shim shape as `modelRuntime` below.
const thinkingLevel = (ctx as { thinkingLevel?: ThinkingLevel }).thinkingLevel;
// This off-screen clone must expose only its synthetic Agent tool. Loading
// host/project extensions would let active-tool owners such as Tool Search
// replace the clone's allowlist during session_start, and would also run
// unrelated extension lifecycle in a session the user never sees.
const resourceLoader = new DefaultResourceLoader({
cwd: ctx.cwd,
agentDir: getAgentDir(),
noExtensions: true,
noSkills: true,
noPromptTemplates: true,
noThemes: true,
});
const created = await runInChildSessionContext(() =>
createAgentSession({
cwd: ctx.cwd,
// Nothing about the copy is worth persisting, and an in-memory manager
// is also what keeps the real session untouched.
sessionManager: SessionManager.inMemory(ctx.cwd),
resourceLoader,
model: ctx.model as Model<never> | undefined,
...(thinkingLevel && { thinkingLevel }),
modelRegistry: ctx.modelRegistry,
...(parentModelRuntime !== undefined && { modelRuntime: parentModelRuntime as never }),
// An allowlist naming exactly the clone's own tool. NOT `noTools:
// "all"`, whose doc comment ("start with no tools enabled") reads like
// it spares custom tools and does not: it resolves to an EMPTY
// allowlist, and `isAllowedTool` then drops every tool from the
// registry — the custom one included. The clone would be prompted with
// nothing to call, answer in prose, and every mention would fall
// through to the direct start with a warning. Same idiom as
// agent-runner's `tools: sessionTools` beside its nested `customTools`.
tools: [cloneAgentTool.name],
customTools: [cloneAgentTool],
} as Parameters<typeof createAgentSession>[0]),
);
session = created.session;
// The clone rebuilds a system prompt from cwd and agentDir, which is close
// but not the live one — extensions contribute to it per turn. Copy the
// real thing, so the copy reasons under the instructions the user's model
// is actually working under.
const systemPrompt = ctx.getSystemPrompt?.();
if (systemPrompt) session.agent.state.systemPrompt = systemPrompt;
// The conversation itself. Pushed rather than assigned so the array the
// session was built around stays the one it goes on using.
session.agent.state.messages.push(...conversation.messages);
// User text first, reminder after — the order Claude Code's attachment
// renderer produces, where the reminder trails the message it is about.
await session.prompt(`${message}\n\n${agentMentionReminder(type)}`);
} catch (err) {
return { spawned, error: err instanceof Error ? err.message : String(err) };
} finally {
session?.dispose?.();
}
return spawned
? { spawned: true }
: { spawned: false, error: "the conversation clone did not start it" };
}
+141
View File
@@ -0,0 +1,141 @@
/**
* mention.ts — the `@handle` grammar for messaging a subagent from the prompt.
*
* Claude Code lets you type `@code-review take another look` at the prompt and
* routes the message to that agent instead of the main model. Its grammar is
* reproduced here so the two behave identically:
*
* - suggestions fire on `@` at the start of the input or after whitespace,
* followed by `[\w-]*` (so `@src/foo.ts` is a file, never an agent);
* - a send is recognized only at the START of the input, and only with a
* non-empty message after the handle. That is why a bare `@code-review`
* goes to the main model rather than anywhere near the agent.
*
* A record's own identity is a UUID plus a deliberately non-unique description,
* neither of which is typeable, so the handle is derived from the agent type.
* Colliding handles are numbered (`explore`, `explore-2`), which is also what
* Claude Code's `allocateName` does — it recycles a name only once the task
* behind it is gone. Its SendMessage prompt describes the *registry* as
* latest-wins, which is a different thing and not how names are allocated.
*/
/**
* Suggestion trigger: `@` at a token boundary plus the partial handle typed so
* far. Ported from Claude Code, including the CJK sentence-ending punctuation
* it accepts as a boundary.
*/
export const MENTION_TRIGGER = /(^|[\s。、?!])@([\w-]*)$/;
/** Send grammar: leading `@handle`, then a non-empty message. */
const MENTION_SEND = /^@([\w-]+)\s+([\s\S]+)$/;
/**
* Upper bound on a handle, matching Claude Code's `dSS`. Nothing here generates
* a name this long, but an agent type or a model-supplied name can be arbitrary
* text, and an unbounded handle would wrap the suggestion popup.
*/
const MAX_HANDLE_LENGTH = 64;
/**
* Handles that address something other than a subagent, and so can never be
* allocated to one. Claude Code reserves exactly this name (`Vq = "main"`),
* refusing it at spawn and routing it to the main conversation instead.
*/
const RESERVED_HANDLES: ReadonlySet<string> = new Set(["main"]);
/** Whether `@handle` names the main conversation rather than any subagent. */
export function isReservedHandle(handle: string): boolean {
return RESERVED_HANDLES.has(handle.toLowerCase());
}
/** Slug of an agent type or name, restricted to the `[\w-]` the grammar allows. */
export function handleBase(type: string): string {
const slug = type.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, MAX_HANDLE_LENGTH)
// The slice can land mid-run and leave the trailing hyphen back.
.replace(/-+$/, "");
return slug || "agent";
}
/**
* `base`, else `base-2`, `base-3`, … — the first form that is neither `taken`
* nor reserved. Callers pass one shared `taken` set covering type-derived
* handles and model-supplied aliases alike, so the two can never collide.
*/
export function assignHandle(base: string, taken: ReadonlySet<string>): string {
let candidate = base;
let n = 1;
while (taken.has(candidate) || RESERVED_HANDLES.has(candidate)) {
n++;
candidate = `${base}-${n}`;
}
return candidate;
}
/**
* Map a typed handle back to a registered agent type, so `@explore fix it`
* reaches the Explore agent even when no instance has ever run. `handleBase` is
* the single source of truth in both directions, so a type is addressable by
* exactly the handle its instances would be given.
*/
export function resolveHandleToType(handle: string, types: readonly string[]): string | undefined {
const wanted = handle.toLowerCase();
// A type slugging to a reserved name is unaddressable rather than shadowing
// it — `assignHandle` refuses that name too, so its instances never hold one.
if (RESERVED_HANDLES.has(wanted)) return undefined;
return types.find(type => handleBase(type) === wanted);
}
/**
* Claude Code documents `@agent-<name>` as the form you type by hand when the
* picker isn't involved. Accepted here as an exact synonym: the caller tries the
* handle as written first, so an agent genuinely called `agent-foo` still wins
* over `@agent-` + `foo`, and only falls back to this when that finds nothing.
* Returns undefined when the prefix is absent or is the whole handle.
*/
export function stripAgentPrefix(handle: string): string | undefined {
const rest = /^agent-(.+)$/i.exec(handle)?.[1];
return rest || undefined;
}
/**
* A spawn needs the short description every agent surface renders. A mention
* carries no separate label, so the message itself becomes one: first line,
* whitespace collapsed, clipped to roughly the 3-5 words the Agent tool asks of
* the model.
*/
export function describeMention(message: string): string {
const oneLine = message.split("\n", 1)[0].replace(/\s+/g, " ").trim();
return oneLine.length > 40 ? `${oneLine.slice(0, 39).trimEnd()}` : oneLine;
}
/**
* What Claude Code sends the main model when a mention names an agent it could
* start. Its `@agent-<type>` mention is not a spawn at all: it becomes an
* `agent_mention` attachment, which renders to a synthetic `isMeta` user
* message placed after the user's own untouched text — no tool forcing, no
* allowed-tools narrowing, and the Task tool is not even named. The model reads
* this and calls the tool itself.
*
* Ported verbatim from the 2.1.233 bundle's attachment renderer, trailing space
* before the closing newline included, so the wording the model was trained
* against is the wording it gets. The one substitution is ours: pi's equivalent
* of Task is the `Agent` tool, and the agent listing that teaches valid
* `subagent_type` values is the tool spec rather than a separate attachment.
*/
export function agentMentionReminder(type: string): string {
return `<system-reminder>\nThe user has expressed a desire to invoke the agent "${type}". Please invoke the agent appropriately, passing in the required context to it. \n</system-reminder>`;
}
/**
* Split `@handle message` into its parts, or null when the text isn't a send —
* a bare handle, a leading file path, or a mention that isn't at the start.
*/
export function parseMention(text: string): { handle: string; message: string } | null {
const match = MENTION_SEND.exec(text);
if (!match) return null;
const message = match[2].trim();
return message ? { handle: match[1], message } : null;
}
+100
View File
@@ -0,0 +1,100 @@
/**
* Model resolution: exact match ("provider/modelId") with fuzzy fallback.
*/
export interface ModelEntry {
id: string;
name: string;
provider: string;
}
export interface ModelRegistry {
find(provider: string, modelId: string): any;
getAll(): any[];
getAvailable?(): any[];
}
/**
* Resolve a model string to a Model instance.
* Tries exact match first ("provider/modelId"), then fuzzy match against all available models.
* Returns the Model on success, or an error message string on failure.
*/
export function resolveModel(
input: string,
registry: ModelRegistry,
): any | string {
// Available models (those with auth configured)
const all = (registry.getAvailable?.() ?? registry.getAll()) as ModelEntry[];
const availableSet = new Set(all.map(m => `${m.provider}/${m.id}`.toLowerCase()));
// 1. Exact match: "provider/modelId" — only if available (has auth)
const slashIdx = input.indexOf("/");
if (slashIdx !== -1) {
const provider = input.slice(0, slashIdx);
const modelId = input.slice(slashIdx + 1);
if (availableSet.has(input.toLowerCase())) {
const found = registry.find(provider, modelId);
if (found) return found;
}
}
// 2. Fuzzy match against available models. Normalize separators so cosmetic
// punctuation differences still match — e.g. "claude-haiku-4.5" and
// "claude-haiku-4-5" (dot vs dash in the version) resolve to the same model.
const normalize = (s: string) => s.toLowerCase().replace(/\./g, "-");
const query = normalize(input);
// Score each model: prefer exact id match > id contains > name contains > provider+id contains
let bestMatch: ModelEntry | undefined;
let bestScore = 0;
for (const m of all) {
const id = normalize(m.id);
const name = normalize(m.name);
const full = normalize(`${m.provider}/${m.id}`);
let score = 0;
if (id === query || full === query) {
score = 100; // exact
} else if (id.includes(query) || full.includes(query)) {
score = 60 + (query.length / id.length) * 30; // substring, prefer tighter matches
} else if (name.includes(query)) {
score = 40 + (query.length / name.length) * 20;
} else if (
// A trailing date-stamp token (e.g. "20251001") is optional, so a
// date-pinned config like "claude-haiku-4-5-20251001" still matches an
// undated registry id like "claude-haiku-4-5".
query
.split(/[\s\-/]+/)
.every(part => /^\d{8}$/.test(part) || id.includes(part) || name.includes(part) || m.provider.toLowerCase().includes(part))
) {
score = 20; // all parts present somewhere
}
if (score > bestScore) {
bestScore = score;
bestMatch = m;
}
}
if (bestMatch && bestScore >= 20) {
const found = registry.find(bestMatch.provider, bestMatch.id);
if (found) return found;
}
// 3. Provider fallback: a "provider/modelId" query that didn't match under the
// named provider (exact or fuzzy above) retries against all providers. The
// named provider is preferred when present; this only kicks in when it isn't,
// so the same model from another provider beats falling back to "inherit".
if (slashIdx !== -1) {
const bare = resolveModel(input.slice(slashIdx + 1), registry);
if (typeof bare !== "string") return bare;
}
// 4. No match — list available models
const modelList = all
.map(m => ` ${m.provider}/${m.id}`)
.sort()
.join("\n");
return `Model not found: "${input}".\n\nAvailable models:\n${modelList}`;
}
+70
View File
@@ -0,0 +1,70 @@
/**
* model-scope.ts — `scopeModels` policy, shared by the top-level Agent tool and
* the nested delegation tools so a nested spawn can't escape the allowlist the
* top-level path enforces.
*
* State lives here (rather than in an index.ts closure) for the same reason
* `disableDefaults` lives in agent-types.ts: both entry points need it.
*/
import { isModelInScope, type ModelRegistryRef, readEnabledModels, resolveEnabledModels } from "./enabled-models.js";
/**
* When enabled, subagent model choices are validated against `enabledModels`
* from pi's settings — both global `<agentDir>/settings.json` and project-local
* `<cwd>/.pi/settings.json` (project overrides global). Off by default; opt-in
* via `/agents → Settings`. See the SubagentsSettings.scopeModels docstring for
* the hard-error vs warn-and-proceed policy and its rationale.
*/
let scopeModelsEnabled = false;
export function isScopeModelsEnabled(): boolean { return scopeModelsEnabled; }
export function setScopeModelsEnabled(enabled: boolean): void { scopeModelsEnabled = enabled; }
export type ModelScopeVerdict =
/** In scope, or nothing to validate against (feature off / no allowlist). */
| { kind: "ok" }
/** Caller-supplied out-of-scope choice — refuse the spawn with this message. */
| { kind: "error"; message: string }
/** Frontmatter-pinned or parent-inherited — proceed, but tell the user. */
| { kind: "warn"; message: string };
/**
* Check the effective resolved model against the user's enabledModels list.
*
* scopeModels guards against *runtime* LLM choices, not user-level config:
* - Caller-supplied out-of-scope → hard error (the orchestrator made an explicit
* out-of-scope choice; surface it so it picks differently).
* - Frontmatter-pinned or parent-inherited out-of-scope → warn but proceed (the
* user authored/installed this agent or chose the parent's model; trust it).
*/
export function checkModelScope(args: {
model: { provider: string; id: string } | undefined;
cwd: string;
modelRegistry: ModelRegistryRef;
/** True when the model came from the tool call rather than frontmatter. */
callerSupplied: boolean;
/** Display name used in the warning toast. */
agentLabel: string;
/** The raw `model:` input, when there was one. */
modelInput?: string;
}): ModelScopeVerdict {
const { model, cwd, modelRegistry, callerSupplied, agentLabel, modelInput } = args;
if (!scopeModelsEnabled || !model) return { kind: "ok" };
const allowed = resolveEnabledModels(readEnabledModels(cwd), modelRegistry, cwd);
if (!allowed || isModelInScope(model, allowed)) return { kind: "ok" };
if (callerSupplied) {
const list = [...allowed].sort().map(m => ` ${m}`).join("\n");
return {
kind: "error",
message: `Model not in scope: "${modelInput}".\n\nAllowed models (from enabledModels):\n${list}`,
};
}
const modelLabel = modelInput ?? `${model.provider}/${model.id}`;
return {
kind: "warn",
message: `Agent "${agentLabel}" using out-of-scope model "${modelLabel}"`,
};
}
+417
View File
@@ -0,0 +1,417 @@
import type { Model } from "@earendil-works/pi-ai";
import {
type AgentSession,
defineTool,
type ExtensionAPI,
type ExtensionContext,
type ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Type } from "@sinclair/typebox";
import { abortable } from "./abortable.js";
import {
buildAgentRegistry,
getAgentConfigIn,
getAvailableTypesIn,
resolveEnabledTypeIn,
resolveTypeIn,
} from "./agent-types.js";
import { loadCustomAgents } from "./custom-agents.js";
import { isolationParam, resolveAgentInvocationConfig } from "./invocation-config.js";
import { resolveModel } from "./model-resolver.js";
import { checkModelScope } from "./model-scope.js";
import {
createOutputFilePath,
getOutputTranscriptDefault,
streamToOutputFile,
writeInitialEntry,
} from "./output-file.js";
import { getForegroundOutcomeNote, getStatusNote, partialOutputSuffix } from "./status-note.js";
import type {
AgentConfig,
AgentInvocation,
AgentRecord,
IsolationMode,
ThinkingLevel,
} from "./types.js";
import { addUsage } from "./usage.js";
import { isWorktreeIsolationEnabled } from "./worktree.js";
/**
* Hard ceiling on nesting for every branch: main session = 0, its subagents = 1,
* their children = 2. `0`/`1` disables nesting entirely. Set from
* `subagents.json` (`maxSubagentDepth`). Read when a subagent session is built,
* so a change applies to sessions started after it.
*/
let maxSubagentDepth = 2;
export function getMaxSubagentDepth(): number { return maxSubagentDepth; }
export function setMaxSubagentDepth(n: number): void { maxSubagentDepth = Math.max(0, Math.floor(n)); }
const NESTED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"] as const;
interface NestedSpawnOptions {
description: string;
model?: Model<any>;
maxTurns?: number;
isolated?: boolean;
inheritContext?: boolean;
thinkingLevel?: ThinkingLevel;
isBackground?: boolean;
isolation?: IsolationMode;
invocation?: AgentInvocation;
signal?: AbortSignal;
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
onSessionCreated?: (session: AgentSession) => void;
depth: number;
parentAgentId: string;
maxSubagentDepth: number;
configCwd?: string;
rootSessionId?: string;
}
export interface NestedAgentManager {
spawn(
pi: ExtensionAPI,
ctx: ExtensionContext,
type: string,
prompt: string,
options: NestedSpawnOptions,
): string;
spawnAndWait(
pi: ExtensionAPI,
ctx: ExtensionContext,
type: string,
prompt: string,
options: Omit<NestedSpawnOptions, "isBackground">,
/** Fires synchronously after spawn, before the session exists — where the transcript is attached. */
onSpawned?: (id: string) => void,
): Promise<{ id: string; record: AgentRecord }>;
getRecord(id: string): AgentRecord | undefined;
resume(id: string, prompt: string, signal?: AbortSignal): Promise<AgentRecord | undefined>;
}
export interface NestedToolContext {
manager: NestedAgentManager;
pi: ExtensionAPI;
parentAgentId: string;
depth: number;
maxSubagentDepth: number;
/** "all" = any enabled agent; string[] = only those types. Never empty. */
allowedSubagents: "all" | string[];
/** Root used for agent/config discovery; may differ from the agent's working directory. */
configCwd: string;
}
function textResult(text: string, isError = false) {
return { content: [{ type: "text" as const, text }], isError, details: {} };
}
function ownsRecord(record: AgentRecord | undefined, parentAgentId: string): record is AgentRecord {
return record?.parentAgentId === parentAgentId;
}
/**
* How the caller received this record, which decides the outcome wording.
*
* - "inline": a foreground spawn or a resume. The full output is in this very
* result and no agent id was handed back, so the note must say there is
* nothing left to fetch — otherwise the parent invents an id, calls
* `get_subagent_result`, and hits "not owned by this parent" (#174, the same
* trap the top-level foreground path fell into).
* - "fetched": `get_subagent_result` on a background child. The parent holds a
* valid id and can poll again, so the background wording applies.
*/
type ResultPosition = "inline" | "fetched";
function formatRecord(record: AgentRecord, position: ResultPosition): string {
if (record.status === "error") {
return `Agent failed: ${record.error ?? "unknown error"}${partialOutputSuffix(record)}`;
}
if (record.status === "queued" || record.status === "running") {
return `Agent ${record.id} is ${record.status}.`;
}
// A truncated run must not read as a finished one. The top-level path carries
// this in its result headline; a nested result has no headline, so the note
// leads — appended, it would look like part of the child's own output.
const text = record.result?.trim() || record.error?.trim() || "No output.";
const note = position === "inline"
? getForegroundOutcomeNote(record.status)
: getStatusNote(record.status);
return note ? `Nested agent${note}.\n\n${text}` : text;
}
/** Build child-safe orchestration tools scoped to one parent agent instance. */
export function createNestedSubagentTools(context: NestedToolContext): ToolDefinition[] {
// Agents resolve from a registry built for THIS branch's config root (under
// worktree isolation, the copy). Never via registerAgents — that is
// process-global state shared with the main session and every other agent.
const loadRegistry = () => buildAgentRegistry(loadCustomAgents(context.configCwd));
const allowedTypesIn = (registry: Map<string, AgentConfig>): Set<string> | undefined =>
context.allowedSubagents === "all"
? undefined
: new Set(context.allowedSubagents.map(name => resolveTypeIn(registry, name) ?? name));
const availableIn = (registry: Map<string, AgentConfig>): string[] => {
const allowed = allowedTypesIn(registry);
return getAvailableTypesIn(registry).filter(name => allowed === undefined || allowed.has(name));
};
const agentTool = defineTool({
name: NESTED_TOOL_NAMES[0],
label: "Agent",
description:
"Launch a child-safe nested subagent for bounded delegated work. " +
"Only use agent types allowed by this parent agent; nesting is depth-limited.",
parameters: Type.Object({
prompt: Type.String({ description: "Self-contained task for the nested agent." }),
description: Type.String({ description: "Short 3-5 word task description." }),
subagent_type: Type.String({ description: `Allowed nested agent type. Available: ${availableIn(loadRegistry()).join(", ") || "none"}.` }),
model: Type.Optional(Type.String({ description: "Optional provider/model override." })),
thinking: Type.Optional(Type.String({ description: "Optional thinking level." })),
max_turns: Type.Optional(Type.Number({ minimum: 1 })),
run_in_background: Type.Optional(
Type.Boolean({
description: "Defaults to false for nested spawns — the call blocks and returns the child's result inline. Set true only for work you will collect later with get_subagent_result; a detached child is stopped when you finish.",
}),
),
resume: Type.Optional(Type.String({ description: "Resume a nested agent owned by this parent." })),
isolated: Type.Optional(Type.Boolean()),
inherit_context: Type.Optional(Type.Boolean()),
...isolationParam(isWorktreeIsolationEnabled()),
}),
execute: async (_toolCallId, params, signal, _onUpdate, ctx) => {
if (params.resume) {
const existing = context.manager.getRecord(params.resume);
if (!ownsRecord(existing, context.parentAgentId)) {
return textResult(`Nested agent not found or not owned by this parent: "${params.resume}".`, true);
}
const resumed = await context.manager.resume(params.resume, params.prompt, signal);
return resumed
? textResult(formatRecord(resumed, "inline"), resumed.status === "error")
: textResult(`Failed to resume nested agent "${params.resume}".`, true);
}
if (context.depth >= context.maxSubagentDepth) {
return textResult(
`Nested subagent call blocked (depth=${context.depth}, max=${context.maxSubagentDepth}). Complete the task directly.`,
true,
);
}
// Reloaded per call so new agent files are picked up without a restart.
const registry = loadRegistry();
const rawType = params.subagent_type;
// Strict resolve, never the fallback policy: a project-level
// `fallbackSubagent` must not hand a nested caller an agent its allowlist
// never named. The list stays allowlist-filtered so a typo can't enumerate
// agents this parent may not reach.
const resolvedType = resolveEnabledTypeIn(registry, rawType);
if (resolvedType === undefined) {
return textResult(
`Unknown or disabled nested agent type: "${rawType}". Allowed: ${availableIn(registry).join(", ") || "none"}.`,
true,
);
}
const allowed = allowedTypesIn(registry);
if (allowed !== undefined && !allowed.has(resolvedType)) {
return textResult(
`Nested agent type "${resolvedType}" is not allowed for this parent. Allowed: ${[...allowed].join(", ")}.`,
true,
);
}
const config = getAgentConfigIn(registry, resolvedType);
// Foreground regardless of `backgroundByDefault` — see the reasoning on
// ResolveOptions. An explicit `true` here still opts in.
const invocation = resolveAgentInvocationConfig(config, params, {
worktreeAllowed: isWorktreeIsolationEnabled(),
defaultRunInBackground: false,
});
let model = ctx.model;
if (invocation.modelInput) {
const resolvedModel = resolveModel(invocation.modelInput, ctx.modelRegistry);
if (typeof resolvedModel === "string") {
if (invocation.modelFromParams) return textResult(resolvedModel, true);
} else {
model = resolvedModel;
}
}
// Same scopeModels policy as the top-level Agent tool — a nested spawn
// must not escape the allowlist. A "warn" verdict proceeds silently:
// child sessions have no UI surface to toast to.
const scopeVerdict = checkModelScope({
model,
cwd: context.configCwd,
modelRegistry: ctx.modelRegistry,
callerSupplied: invocation.modelFromParams,
agentLabel: config?.displayName ?? resolvedType,
modelInput: invocation.modelInput,
});
if (scopeVerdict.kind === "error") return textResult(scopeVerdict.message, true);
// The whole branch shares the root session's transcript directory; read it
// off the owning parent rather than this child session's own id.
const rootSessionId = context.manager.getRecord(context.parentAgentId)?.rootSessionId;
const childDepth = context.depth + 1;
const options: NestedSpawnOptions = {
description: params.description,
model,
maxTurns: invocation.maxTurns,
isolated: invocation.isolated,
inheritContext: invocation.inheritContext,
thinkingLevel: invocation.thinking,
isolation: invocation.isolation,
invocation: {
thinking: invocation.thinking,
maxTurns: invocation.maxTurns,
isolated: invocation.isolated,
inheritContext: invocation.inheritContext,
runInBackground: invocation.runInBackground,
isolation: invocation.isolation,
},
// Nested children are hidden from every reporting surface, so their spend
// would otherwise be unattributable. Fold it into every ancestor's record:
// the top-level one appears in lifecycle events, completion notifications,
// and `/agents`, and those all read `lifetimeUsage`. The whole chain is
// walked, not just the immediate parent — a spawn callback only fires for
// that child's OWN turns, so stopping at one level would hide a
// great-grandchild from the only record anyone can see. (The live
// widget/fleet counters read their own per-agent activity tracker, which
// still sees only the top-level agent's own turns.)
onAssistantUsage: (usage) => {
for (let id: string | undefined = context.parentAgentId; id !== undefined; ) {
const ancestor = context.manager.getRecord(id);
if (!ancestor) break;
addUsage(ancestor.lifetimeUsage, usage);
id = ancestor.parentAgentId;
}
},
depth: childDepth,
parentAgentId: context.parentAgentId,
maxSubagentDepth: context.maxSubagentDepth,
configCwd: context.configCwd,
rootSessionId,
};
// Transcript wiring, same gate as the top-level path: the child's
// `output_transcript` frontmatter wins, else the project default. Without
// it a nested run leaves no artifact but the string it returned — the
// parent's own transcript records the call and the answer, never the tool
// calls in between, which is exactly what a misbehaving child needs to
// explain itself. Filed under the ROOT session and this branch's config
// root, so a nested transcript lands in the same `tasks/` directory as its
// ancestors' rather than in a directory of its own.
const transcriptSessionId =
rootSessionId !== undefined && (config?.outputTranscript ?? getOutputTranscriptDefault())
? rootSessionId
: undefined;
let childId: string | undefined;
const attachTranscript = (id: string): void => {
childId = id;
if (transcriptSessionId === undefined) return;
const rec = context.manager.getRecord(id);
if (!rec) return;
rec.outputFile = createOutputFilePath(context.configCwd, id, transcriptSessionId);
writeInitialEntry(rec.outputFile, id, params.prompt, ctx.cwd);
};
options.onSessionCreated = (session) => {
const rec = childId === undefined ? undefined : context.manager.getRecord(childId);
if (rec?.outputFile && childId !== undefined) {
rec.outputCleanup = streamToOutputFile(session, rec.outputFile, childId, ctx.cwd);
}
};
// `ctx` is forwarded to the manager unmodified, never captured at tool-build
// time: each AgentSession builds its own ExtensionRunner from that session's
// cwd/sessionManager/modelRegistry, so this is the CHILD's context. Capturing
// one earlier would silently give a grandchild the wrong worktree base, the
// wrong conversation under inherit_context, and the wrong inherited model.
//
// spawn() throws on strict worktree-isolation failure and cwd validation —
// report it as a tool error, like the top-level Agent tool does, instead of
// letting it escape into the child's turn.
try {
if (invocation.runInBackground) {
const id = context.manager.spawn(context.pi, ctx, resolvedType, params.prompt, {
...options,
isBackground: true,
});
// Synchronous, before the event loop yields — onSessionCreated fires
// asynchronously inside runAgent, so the file is attached in time.
attachTranscript(id);
return textResult(`Nested agent started in background. Agent ID: ${id}`);
}
const { record } = await context.manager.spawnAndWait(
context.pi,
ctx,
resolvedType,
params.prompt,
{ ...options, signal },
attachTranscript,
);
return textResult(formatRecord(record, "inline"), record.status === "error");
} catch (err) {
return textResult(err instanceof Error ? err.message : String(err), true);
}
},
});
const resultTool = defineTool({
name: NESTED_TOOL_NAMES[1],
label: "Get Nested Agent Result",
description: "Check or wait for a background nested agent owned by this parent.",
parameters: Type.Object({
agent_id: Type.String(),
wait: Type.Optional(Type.Boolean()),
}),
execute: async (_toolCallId, params, signal) => {
const record = context.manager.getRecord(params.agent_id);
if (!ownsRecord(record, context.parentAgentId)) {
return textResult(`Nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
}
// Wait for completion if requested. Cancellation (e.g. the parent's tool
// call is aborted) stops only this wait; the nested child keeps running and
// stays unconsumed. Queued records have no promise until the manager starts
// them, so poll — abortably — until they leave the queue, then await.
if (params.wait && (record.status === "queued" || record.status === "running")) {
while (record.status === "queued") {
await abortable(new Promise<void>(resolve => setTimeout(resolve, 250)), signal);
}
if (record.promise) await abortable(record.promise, signal);
}
return textResult(formatRecord(record, "fetched"), record.status === "error");
},
});
const steerTool = defineTool({
name: NESTED_TOOL_NAMES[2],
label: "Steer Nested Agent",
description: "Send guidance to a running nested agent owned by this parent.",
parameters: Type.Object({
agent_id: Type.String(),
message: Type.String(),
}),
execute: async (_toolCallId, params) => {
const record = context.manager.getRecord(params.agent_id);
if (!ownsRecord(record, context.parentAgentId) || record.status !== "running") {
return textResult(`Running nested agent not found or not owned by this parent: "${params.agent_id}".`, true);
}
// Session not ready yet — queue the steer. The manager flushes pending
// steers when the session is created (same contract as the top-level tool).
if (!record.session) {
if (!record.pendingSteers) record.pendingSteers = [];
record.pendingSteers.push(params.message);
return textResult(`Steering message queued for nested agent ${params.agent_id}.`);
}
try {
await record.session.steer(params.message);
} catch (err) {
return textResult(`Failed to steer nested agent: ${err instanceof Error ? err.message : String(err)}`, true);
}
return textResult(`Steering message sent to nested agent ${params.agent_id}.`);
},
});
return [agentTool, resultTool, steerTool];
}
+145
View File
@@ -0,0 +1,145 @@
/**
* output-file.ts — Streaming JSONL output file for agent transcripts.
*
* Creates a per-agent output file that streams conversation turns as JSONL,
* matching Claude Code's task output file format.
*/
import { appendFileSync, chmodSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
/**
* Project/global default for writing a subagent's `.output` transcript; a custom
* agent's `output_transcript` overrides it per agent.
*
* State lives here rather than in an index.ts closure because both spawn paths
* need it — the top-level Agent tool and the nested delegation tools. Same
* reason `scopeModels` lives in model-scope.ts: a setting only one path can read
* is a setting the other path silently ignores.
*/
let outputTranscriptDefault = true;
export function getOutputTranscriptDefault(): boolean { return outputTranscriptDefault; }
export function setOutputTranscriptDefault(b: boolean): void { outputTranscriptDefault = b; }
/**
* Encode a cwd path as a filesystem-safe directory name. Handles:
* - POSIX: "/home/user/project" → "home-user-project"
* - Windows: "C:\Users\foo\project" → "Users-foo-project"
* - UNC: "\\\\server\\share\\project" → "server-share-project"
*/
export function encodeCwd(cwd: string): string {
return cwd
.replace(/[/\\]/g, "-") // both separators → dash
.replace(/^[A-Za-z]:-/, "") // strip Windows drive prefix ("C:-")
.replace(/^-+/, ""); // strip leading dashes (POSIX root, UNC)
}
/** Create the output file path, ensuring the directory exists.
* Mirrors Claude Code's layout: /tmp/{prefix}-{uid}/{encoded-cwd}/{sessionId}/tasks/{agentId}.output */
export function createOutputFilePath(cwd: string, agentId: string, sessionId: string): string {
const encoded = encodeCwd(cwd);
const root = join(tmpdir(), `pi-subagents-${process.getuid?.() ?? 0}`);
mkdirSync(root, { recursive: true, mode: 0o700 });
// chmod is a no-op on Windows and throws on some Windows filesystems.
// On Unix we still want to enforce 0o700 past umask, so only swallow on Windows.
try {
chmodSync(root, 0o700);
} catch (err) {
if (process.platform !== "win32") throw err;
}
const dir = join(root, encoded, sessionId, "tasks");
mkdirSync(dir, { recursive: true });
return join(dir, `${agentId}.output`);
}
/**
* Ensure a transcript file exists without disturbing what is already in it.
*
* A resume reuses the agent's existing transcript (same deterministic path), so
* it must never call `writeInitialEntry` — that truncates, discarding turns the
* completion notification still points the user at, and any history the session
* has since compacted away is gone for good. Appending nothing creates the file
* when this is the agent's first transcript and is a no-op when it is not.
*/
export function ensureOutputFile(path: string): void {
try {
appendFileSync(path, "", "utf-8");
} catch { /* ignore — streaming writes are best-effort too */ }
}
/** Write the initial user prompt entry. */
export function writeInitialEntry(path: string, agentId: string, prompt: string, cwd: string): void {
const entry = {
isSidechain: true,
agentId,
type: "user",
message: { role: "user", content: prompt },
timestamp: new Date().toISOString(),
cwd,
};
writeFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
}
/**
* Subscribe to session events and flush new messages to the output file on each turn_end.
* Returns a cleanup function that does a final flush and unsubscribes.
*/
export function streamToOutputFile(
session: AgentSession,
path: string,
agentId: string,
cwd: string,
startIndex?: number,
): () => void {
// Index of the first message this stream is responsible for. A spawn writes
// messages[0] as the initial prompt entry, so it starts at 1. A resume hands
// in the session's length as of just before the run: the session already
// holds every prior turn, and re-emitting those would duplicate history that
// is already in the file.
let writtenCount = startIndex ?? 1;
const flush = () => {
const messages = session.messages;
while (writtenCount < messages.length) {
const msg = messages[writtenCount];
const entry = {
isSidechain: true,
agentId,
type: msg.role === "assistant" ? "assistant" : msg.role === "user" ? "user" : "toolResult",
message: msg,
timestamp: new Date().toISOString(),
cwd,
};
try {
appendFileSync(path, JSON.stringify(entry) + "\n", "utf-8");
} catch { /* ignore write errors */ }
writtenCount++;
}
};
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "turn_end") flush();
// Compaction replaces session.messages with a shorter, summarized array,
// leaving writtenCount past the new end — without re-anchoring, the flush
// loop would never match again and streaming would halt for good (#145).
// Flush before it runs so any not-yet-flushed tail still reaches the file,
// then re-anchor to the rebuilt array once it lands. The re-anchor is
// deferred a microtask because on the overflow-retry path pi trims the
// trailing error assistant message AFTER emitting compaction_end —
// anchoring synchronously would sit one past the trimmed array and skip
// the first post-compaction message. Aborted/failed compactions leave
// session.messages untouched, so only successful ones re-anchor.
if (event.type === "compaction_start") flush();
if (event.type === "compaction_end" && !event.aborted && event.result) {
queueMicrotask(() => { writtenCount = session.messages.length; });
}
});
return () => {
flush();
unsubscribe();
};
}
@@ -0,0 +1,90 @@
import type { AgentSession, ExtensionAPI } from "@earendil-works/pi-coding-agent";
/**
* Process-local lifecycle contract consumed by pi-permission-system.
*
* Keep these string channels local rather than importing permission-system
* internals: pi-subagents only publishes child identity, while the permission
* extension remains the sole owner of policy and authorization decisions.
*/
export const PERMISSION_CHILD_CREATED_CHANNEL = "subagents:child:session-created";
export const PERMISSION_CHILD_DISPOSED_CHANNEL = "subagents:child:disposed";
interface EventPublisher {
events?: {
emit(channel: string, payload: unknown): void;
};
}
interface SessionIdentity {
sessionManager?: {
getSessionId?: () => string | undefined;
};
}
interface ChildRegistration {
events: NonNullable<EventPublisher["events"]>;
sessionId: string;
}
const childRegistrations = new WeakMap<object, ChildRegistration>();
const ACTIVE_AGENT_TAG = /<active_agent\s+name=["'][^"']+["'][^>]*>\s*/gi;
const SAFE_AGENT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
function normalizedSessionId(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim();
return normalized || undefined;
}
/**
* Replace inherited agent identity with this child's stable config key.
* The permission system reads the first active_agent tag, so inherited parent
* tags must be removed rather than merely appending another one.
*/
export function withActiveAgentIdentity(systemPrompt: string, agentName: string): string {
const withoutInheritedIdentity = systemPrompt.replace(ACTIVE_AGENT_TAG, "").trimEnd();
const normalizedAgentName = agentName.trim();
if (!SAFE_AGENT_NAME.test(normalizedAgentName)) return withoutInheritedIdentity;
return `${withoutInheritedIdentity}\n\n<active_agent name="${normalizedAgentName}"/>`;
}
/**
* Register a child synchronously before bindExtensions(). This is deliberately
* fail-closed when an event publisher exists: a thrown lifecycle listener must
* abort child startup rather than bind a headless permission system as if it
* were an ordinary top-level session.
*/
export function registerPermissionChildSession(
pi: Pick<ExtensionAPI, "events"> | EventPublisher,
session: Pick<AgentSession, "sessionManager"> | SessionIdentity,
parentSessionId: string | undefined,
): boolean {
const events = (pi as EventPublisher).events;
const childSessionId = normalizedSessionId(
(session as SessionIdentity).sessionManager?.getSessionId?.(),
);
const normalizedParentSessionId = normalizedSessionId(parentSessionId);
if (!events || !childSessionId || !normalizedParentSessionId) return false;
const sessionKey = session as object;
if (childRegistrations.has(sessionKey)) return true;
events.emit(PERMISSION_CHILD_CREATED_CHANNEL, {
sessionId: childSessionId,
parentSessionId: normalizedParentSessionId,
});
childRegistrations.set(sessionKey, { events, sessionId: childSessionId });
return true;
}
/** Publish disposal at most once, after the child session has actually closed. */
export function unregisterPermissionChildSession(session: object | undefined): void {
if (!session) return;
const registration = childRegistrations.get(session);
if (!registration) return;
childRegistrations.delete(session);
registration.events.emit(PERMISSION_CHILD_DISPOSED_CHANNEL, {
sessionId: registration.sessionId,
});
}
+116
View File
@@ -0,0 +1,116 @@
/**
* prompts.ts — System prompt builder for agents.
*/
import type { AgentConfig, EnvInfo } from "./types.js";
/** Extra sections to inject into the system prompt (memory, skills, etc.). */
export interface PromptExtras {
/** Persistent memory content to inject (first 200 lines of MEMORY.md + instructions). */
memoryBlock?: string;
/** Preloaded skill contents to inject. */
skillBlocks?: { name: string; content: string }[];
/**
* Parent directory the worktree copy was created from. Set only for
* `isolation: "worktree"` spawns — triggers the block that tells the agent
* to stay in the copy.
*/
worktreeBase?: string;
}
/**
* Build the system prompt for an agent from its config.
*
* - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
* - "append" mode: parent system prompt + sub-agent context + env header + config.systemPrompt
* - "append" with empty systemPrompt: pure parent clone
*
* Both modes include an `<active_agent name="${config.name}"/>` tag so downstream
* extensions (e.g. permission/policy systems) can resolve per-agent policy
* inside the child session by parsing the system prompt. In replace mode the tag
* is prepended; in append mode it follows the shared inherited content so the
* parent prompt forms an identical, cacheable byte prefix with the parent
* session (the LLM's KV cache can then reuse those tokens across every spawn).
*
* @param parentSystemPrompt The parent agent's effective system prompt (for append mode).
* @param extras Optional extra sections to inject (memory, preloaded skills).
*/
export function buildAgentPrompt(
config: AgentConfig,
cwd: string,
env: EnvInfo,
parentSystemPrompt?: string,
extras?: PromptExtras,
): string {
const activeAgentTag = `<active_agent name="${config.name}"/>\n\n`;
const envBlock = `# Environment
Working directory: ${cwd}
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
Platform: ${env.platform}`;
// A worktree agent is told its cwd twice: by the env block above (the copy)
// and by whatever names the main checkout — the inherited parent prompt in
// append mode, or the task prompt in either mode. It follows the latter and
// works in the shared tree (#187), so resolve the contradiction explicitly.
const worktreeBlock = extras?.worktreeBase
? `\n\n<worktree_isolation>
Your working directory is an isolated git worktree copy of ${extras.worktreeBase}.
Work only inside it — never in ${extras.worktreeBase}, even if other instructions name that path as your working directory.
</worktree_isolation>`
: "";
// Build optional extras suffix
const extraSections: string[] = [];
if (extras?.memoryBlock) {
extraSections.push(extras.memoryBlock);
}
if (extras?.skillBlocks?.length) {
for (const skill of extras.skillBlocks) {
extraSections.push(`\n# Preloaded Skill: ${skill.name}\n${skill.content}`);
}
}
const extrasSuffix = extraSections.length > 0 ? "\n\n" + extraSections.join("\n") : "";
if (config.promptMode === "append") {
const identity = parentSystemPrompt || genericBase;
const bridge = `<sub_agent_context>
You are operating as a sub-agent invoked to handle a specific task.
- Use the read tool instead of cat/head/tail
- Use the edit tool instead of sed/awk
- Use the write tool instead of echo/heredoc
- Use the find tool instead of bash find/ls for file search
- Use the grep tool instead of bash grep/rg for content search
- Make independent tool calls in parallel
- Use absolute file paths
- Do not use emojis
- Be concise but complete
</sub_agent_context>`;
const customSection = config.systemPrompt?.trim()
? `\n\n<agent_instructions>\n${config.systemPrompt}\n</agent_instructions>`
: "";
// Place shared/stable content first so the LLM's KV cache can reuse the
// inherited prefix across all subagent invocations. The parent prompt is
// placed verbatim (no wrapper tag) so it forms an identical byte prefix
// with the parent session, maximising KV cache hits. The <active_agent>
// tag and env block vary per call and are placed after the cached prefix.
return identity + "\n\n" + bridge + "\n\n" + activeAgentTag + envBlock + worktreeBlock + customSection + extrasSuffix;
}
// "replace" mode — env header + the config's full system prompt
const replaceHeader = `You are a pi coding agent sub-agent.
You have been invoked to handle a specific task autonomously.
${envBlock}`;
return activeAgentTag + replaceHeader + worktreeBlock + "\n\n" + config.systemPrompt + extrasSuffix;
}
/** Fallback base prompt when parent system prompt is unavailable in append mode. */
const genericBase = `# Role
You are a general-purpose coding agent for complex, multi-step tasks.
You have full access to read, write, edit files, and execute commands.
Do what has been asked; nothing more, nothing less.`;
+153
View File
@@ -0,0 +1,153 @@
/**
* schedule-store.ts — File-backed store for scheduled subagents.
*
* Session-scoped: each pi session owns its own schedules at
* `<cwd>/.pi/subagent-schedules/<sessionId>.json`. `/new` starts a fresh
* empty store; `/resume` reloads.
*
* Concurrency model lifted from pi-chonky-tasks/src/task-store.ts: every
* mutation acquires a PID-based exclusion lock, re-reads the latest state
* from disk, applies the change, atomic-writes via temp+rename, releases.
*/
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { ScheduledSubagent, ScheduleStoreData } from "./types.js";
const LOCK_RETRY_MS = 50;
const LOCK_MAX_RETRIES = 100;
function isProcessRunning(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
function acquireLock(lockPath: string): void {
for (let i = 0; i < LOCK_MAX_RETRIES; i++) {
try {
writeFileSync(lockPath, `${process.pid}`, { flag: "wx" });
return;
} catch (e: any) {
if (e.code === "EEXIST") {
try {
const pid = parseInt(readFileSync(lockPath, "utf-8"), 10);
if (pid && !isProcessRunning(pid)) {
unlinkSync(lockPath);
continue;
}
} catch { /* ignore — try again */ }
const start = Date.now();
while (Date.now() - start < LOCK_RETRY_MS) { /* busy wait */ }
continue;
}
throw e;
}
}
throw new Error(`Failed to acquire schedule lock: ${lockPath}`);
}
function releaseLock(lockPath: string): void {
try { unlinkSync(lockPath); } catch { /* ignore */ }
}
/** Resolve the storage path for a session-scoped store. */
export function resolveStorePath(cwd: string, sessionId: string): string {
return join(cwd, ".pi", "subagent-schedules", `${sessionId}.json`);
}
export class ScheduleStore {
private filePath: string;
private lockPath: string;
private jobs = new Map<string, ScheduledSubagent>();
constructor(filePath: string) {
this.filePath = filePath;
this.lockPath = filePath + ".lock";
this.load();
}
/** Create the backing directory lazily — only when we're about to persist. */
private ensureDir(): void {
mkdirSync(dirname(this.filePath), { recursive: true });
}
/** Load from disk into the in-memory cache. Silent on parse errors. */
private load(): void {
if (!existsSync(this.filePath)) return;
try {
const data: ScheduleStoreData = JSON.parse(readFileSync(this.filePath, "utf-8"));
this.jobs.clear();
for (const j of data.jobs ?? []) this.jobs.set(j.id, j);
} catch { /* corrupt — start fresh, next save rewrites */ }
}
/** Atomic write via temp file + rename (POSIX-atomic). */
private save(): void {
const data: ScheduleStoreData = { version: 1, jobs: [...this.jobs.values()] };
const tmp = this.filePath + ".tmp";
writeFileSync(tmp, JSON.stringify(data, null, 2));
renameSync(tmp, this.filePath);
}
/** Acquire lock → reload → mutate → save → release. */
private withLock<T>(fn: () => T): T {
this.ensureDir();
acquireLock(this.lockPath);
try {
this.load();
const result = fn();
this.save();
return result;
} finally {
releaseLock(this.lockPath);
}
}
/** Read-only — returns a snapshot of the in-memory cache. */
list(): ScheduledSubagent[] {
return [...this.jobs.values()];
}
/** Read-only check — uses the cache. */
hasName(name: string, exceptId?: string): boolean {
for (const j of this.jobs.values()) {
if (j.id !== exceptId && j.name === name) return true;
}
return false;
}
get(id: string): ScheduledSubagent | undefined {
return this.jobs.get(id);
}
add(job: ScheduledSubagent): void {
this.withLock(() => {
this.jobs.set(job.id, job);
});
}
update(id: string, patch: Partial<ScheduledSubagent>): ScheduledSubagent | undefined {
// No-op fast path — an unknown id changes nothing, so don't lock or touch
// disk (which would otherwise lazily create the backing directory).
if (!this.jobs.has(id)) return undefined;
return this.withLock(() => {
const existing = this.jobs.get(id);
if (!existing) return undefined;
const updated = { ...existing, ...patch };
this.jobs.set(id, updated);
return updated;
});
}
remove(id: string): boolean {
// No-op fast path — see update().
if (!this.jobs.has(id)) return false;
return this.withLock(() => this.jobs.delete(id));
}
/** Delete the backing file (used when no jobs remain, optional cleanup). */
deleteFileIfEmpty(): void {
if (this.jobs.size === 0 && existsSync(this.filePath)) {
try { unlinkSync(this.filePath); } catch { /* ignore */ }
}
}
}
+374
View File
@@ -0,0 +1,374 @@
/**
* schedule.ts — `SubagentScheduler`: timer-driven dispatcher of scheduled subagents.
*
* Mirrors the engine shape of pi-cron-schedule/src/scheduler.ts:
* - two-Map split (jobs = croner Cron, intervals = setInterval/setTimeout)
* - addJob/removeJob/updateJob/scheduleJob/unscheduleJob/executeJob
* - static parsers for cron / "+10m" / "5m" / ISO formats
*
* Differences vs pi-cron-schedule:
* - Persistence is via ScheduleStore (PID-locked, session-scoped, atomic).
* - `executeJob` calls `manager.spawn(..., { bypassQueue: true })` instead
* of dispatching a user message — schedule fires bypass maxConcurrent so
* a 5-minute interval can't be deferred behind 4 long-running agents.
* - Result delivery is implicit: spawn → background completion → existing
* `subagent-notification` followUp path. No new delivery code.
*/
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Cron } from "croner";
import { nanoid } from "nanoid";
import type { AgentManager } from "./agent-manager.js";
import { resolveSpawnType } from "./agent-types.js";
import { resolveModel } from "./model-resolver.js";
import type { ScheduleStore } from "./schedule-store.js";
import type { IsolationMode, ScheduledSubagent, SubagentType, ThinkingLevel } from "./types.js";
/** Event emitted on `pi.events` for cross-extension consumers. */
export type ScheduleChangeEvent =
| { type: "added"; job: ScheduledSubagent }
| { type: "removed"; jobId: string }
| { type: "updated"; job: ScheduledSubagent }
| { type: "fired"; jobId: string; agentId: string; name: string }
| { type: "error"; jobId: string; error: string };
/** Params accepted at job creation — ID, timestamps, and state are derived. */
export interface NewJobInput {
name: string;
description: string;
schedule: string;
subagent_type: SubagentType;
prompt: string;
model?: string;
thinking?: ThinkingLevel;
max_turns?: number;
isolated?: boolean;
isolation?: IsolationMode;
}
export class SubagentScheduler {
private jobs = new Map<string, Cron>();
private intervals = new Map<string, NodeJS.Timeout>();
private store: ScheduleStore | undefined;
private pi: ExtensionAPI | undefined;
private ctx: ExtensionContext | undefined;
private manager: AgentManager | undefined;
/** Start the scheduler: bind to a session's store and arm enabled jobs. */
start(pi: ExtensionAPI, ctx: ExtensionContext, manager: AgentManager, store: ScheduleStore): void {
this.pi = pi;
this.ctx = ctx;
this.manager = manager;
this.store = store;
for (const job of store.list()) {
if (job.enabled) this.scheduleJob(job);
}
}
/** Stop all timers; drop refs. Safe to call repeatedly. */
stop(): void {
for (const cron of this.jobs.values()) cron.stop();
this.jobs.clear();
for (const t of this.intervals.values()) clearTimeout(t);
this.intervals.clear();
this.store = undefined;
this.pi = undefined;
this.ctx = undefined;
this.manager = undefined;
}
/** True if start() has bound a store and the scheduler is active. */
isActive(): boolean {
return this.store !== undefined;
}
list(): ScheduledSubagent[] {
return this.store?.list() ?? [];
}
/**
* Build a `ScheduledSubagent` from user input. Validates the schedule
* format and tags `scheduleType`. Throws on invalid input.
*/
buildJob(input: NewJobInput): ScheduledSubagent {
const detected = SubagentScheduler.detectSchedule(input.schedule);
return {
id: nanoid(10),
name: input.name,
description: input.description,
schedule: detected.normalized,
scheduleType: detected.type,
intervalMs: detected.intervalMs,
subagent_type: input.subagent_type,
prompt: input.prompt,
model: input.model,
thinking: input.thinking,
max_turns: input.max_turns,
isolated: input.isolated,
isolation: input.isolation,
enabled: true,
createdAt: new Date().toISOString(),
runCount: 0,
};
}
/** Add a job, persist, and arm if enabled. Returns the stored job. */
addJob(input: NewJobInput): ScheduledSubagent {
const store = this.requireStore();
if (store.hasName(input.name)) {
throw new Error(`A scheduled job named "${input.name}" already exists.`);
}
const job = this.buildJob(input);
store.add(job);
if (job.enabled) this.scheduleJob(job);
this.emit({ type: "added", job });
return job;
}
removeJob(id: string): boolean {
const store = this.requireStore();
if (!store.get(id)) return false;
this.unscheduleJob(id);
const ok = store.remove(id);
if (ok) this.emit({ type: "removed", jobId: id });
return ok;
}
/** Toggle / mutate a job. Re-arms based on the new `enabled` state. */
updateJob(id: string, patch: Partial<ScheduledSubagent>): ScheduledSubagent | undefined {
const store = this.requireStore();
const updated = store.update(id, patch);
if (!updated) return undefined;
this.unscheduleJob(id);
if (updated.enabled) this.scheduleJob(updated);
this.emit({ type: "updated", job: updated });
return updated;
}
/** Next-run time as ISO, or undefined if not currently armed. */
getNextRun(jobId: string): string | undefined {
const cron = this.jobs.get(jobId);
if (cron) return cron.nextRun()?.toISOString();
const job = this.store?.get(jobId);
if (!job?.enabled) return undefined;
if (job.scheduleType === "once") return job.schedule;
if (job.scheduleType === "interval" && job.intervalMs) {
// Before the first fire there's no `lastRun`, so fall back to "now" —
// accurate at create time (setInterval was just armed) and within
// intervalMs of correct in any pre-first-fire view.
const base = job.lastRun ? new Date(job.lastRun).getTime() : Date.now();
return new Date(base + job.intervalMs).toISOString();
}
return undefined;
}
// ── Scheduling primitives ────────────────────────────────────────────
private scheduleJob(job: ScheduledSubagent): void {
const store = this.store;
if (!store) return;
try {
if (job.scheduleType === "interval" && job.intervalMs) {
const t = setInterval(() => this.executeJob(job.id), job.intervalMs);
this.intervals.set(job.id, t);
} else if (job.scheduleType === "once") {
const target = new Date(job.schedule).getTime();
const delay = target - Date.now();
if (delay > 0) {
const t = setTimeout(() => {
this.executeJob(job.id);
// Auto-disable one-shots after they fire (mirrors pi-cron-schedule)
store.update(job.id, { enabled: false });
const updated = store.get(job.id);
if (updated) this.emit({ type: "updated", job: updated });
}, delay);
this.intervals.set(job.id, t);
} else {
// Past timestamp — disable, mark error, never fire
store.update(job.id, { enabled: false, lastStatus: "error" });
this.emit({ type: "error", jobId: job.id, error: `Scheduled time ${job.schedule} is in the past` });
}
} else {
const cron = new Cron(job.schedule, () => this.executeJob(job.id));
this.jobs.set(job.id, cron);
}
} catch (err) {
this.emit({ type: "error", jobId: job.id, error: err instanceof Error ? err.message : String(err) });
}
}
private unscheduleJob(id: string): void {
const cron = this.jobs.get(id);
if (cron) {
cron.stop();
this.jobs.delete(id);
}
const t = this.intervals.get(id);
if (t) {
clearTimeout(t);
clearInterval(t);
this.intervals.delete(id);
}
}
/**
* Fire a job: persist running state, spawn (bypassing the concurrency
* queue), persist completion. Fire-and-forget: the timer tick returns
* immediately so other jobs keep firing.
*/
private executeJob(id: string): void {
const store = this.store;
const pi = this.pi;
const ctx = this.ctx;
const manager = this.manager;
if (!store || !pi || !ctx || !manager) return;
const job = store.get(id);
if (!job?.enabled) return;
store.update(id, { lastStatus: "running" });
// Resolve model at fire time — registry contents may have changed since the
// job was created (auth added/removed). Fall back silently to spawn-default
// if resolution fails; the spawn path handles undefined model gracefully.
let resolvedModel: any | undefined;
if (job.model) {
const r = resolveModel(job.model, ctx.modelRegistry);
if (typeof r !== "string") resolvedModel = r;
}
let agentId: string;
try {
// Re-resolve at fire time against the registry as it stands. This does not
// reload from disk (the scheduler has no reason to rebuild process-global
// state from a timer), so it catches changes that went through /agents or
// an Agent call — not a file deleted directly from a shell. The catch below turns
// this into lastStatus: "error" plus an error event, like any other
// fire-time failure.
const dispatch = resolveSpawnType(job.subagent_type);
if (!dispatch.ok) throw new Error(dispatch.message);
agentId = manager.spawn(pi, ctx, dispatch.type, job.prompt, {
description: job.description,
isBackground: true,
bypassQueue: true,
model: resolvedModel,
maxTurns: job.max_turns,
isolated: job.isolated,
thinkingLevel: job.thinking,
isolation: job.isolation,
});
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
store.update(id, { lastRun: new Date().toISOString(), lastStatus: "error" });
this.emit({ type: "error", jobId: id, error });
return;
}
this.emit({ type: "fired", jobId: id, agentId, name: job.name });
const record = manager.getRecord(agentId);
const finalize = (status: "success" | "error") => {
const next = this.getNextRun(id);
const current = store.get(id);
store.update(id, {
lastRun: new Date().toISOString(),
lastStatus: status,
runCount: (current?.runCount ?? 0) + 1,
nextRun: next,
});
};
// AgentManager's promise resolves either way (its .catch returns ""), so we
// can't infer success/failure from the promise — read record.status instead.
// Terminal states: completed/steered = success; error/aborted/stopped = error.
if (record?.promise) {
record.promise
.then(() => {
const r = manager.getRecord(agentId);
const failed = r?.status === "error" || r?.status === "aborted" || r?.status === "stopped";
finalize(failed ? "error" : "success");
})
.catch(() => finalize("error"));
} else {
// Spawn returned without a promise (defensive — bypassQueue path always sets one).
finalize("success");
}
}
private emit(event: ScheduleChangeEvent): void {
if (this.pi) this.pi.events.emit("subagents:scheduled", event);
}
private requireStore(): ScheduleStore {
if (!this.store) throw new Error("Scheduler not started — no active session.");
return this.store;
}
// ── Format detection / parsers (statics — pure) ──────────────────────
/**
* Sniff a schedule string and tag its type. Throws on invalid input.
* Order matters: relative ("+10m") and interval ("5m") both match digit+unit;
* relative requires the leading "+" to disambiguate.
*/
static detectSchedule(s: string): { type: "cron" | "once" | "interval"; intervalMs?: number; normalized: string } {
const trimmed = s.trim();
// "+10m" — relative one-shot
const rel = SubagentScheduler.parseRelativeTime(trimmed);
if (rel !== null) return { type: "once", normalized: rel };
// "5m" — interval
const ivl = SubagentScheduler.parseInterval(trimmed);
if (ivl !== null) return { type: "interval", intervalMs: ivl, normalized: trimmed };
// ISO timestamp — one-shot. Reject past timestamps upfront so we never
// create a dead-on-arrival record (scheduleJob's safety net still catches
// micro-races from `+0s`-style relatives).
if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
const d = new Date(trimmed);
if (!Number.isNaN(d.getTime())) {
if (d.getTime() <= Date.now()) {
throw new Error(`Scheduled time ${d.toISOString()} is in the past.`);
}
return { type: "once", normalized: d.toISOString() };
}
}
// Cron — 6-field
const cronCheck = SubagentScheduler.validateCronExpression(trimmed);
if (cronCheck.valid) return { type: "cron", normalized: trimmed };
throw new Error(
`Invalid schedule "${s}". Use 6-field cron (e.g. "0 0 9 * * 1" — 9am every Monday), interval ("5m"/"1h"), or one-shot ("+10m" / ISO).`
);
}
/** 6-field cron — 'second minute hour dom month dow'. */
static validateCronExpression(expr: string): { valid: boolean; error?: string } {
const fields = expr.trim().split(/\s+/);
if (fields.length !== 6) {
return {
valid: false,
error: `Cron must have 6 fields (second minute hour dom month dow), got ${fields.length}. Example: "0 0 9 * * 1" for 9am every Monday.`,
};
}
try {
// Croner validates by construction.
new Cron(expr, () => {});
return { valid: true };
} catch (e) {
return { valid: false, error: e instanceof Error ? e.message : "Invalid cron expression" };
}
}
/** "+10s"/"+5m"/"+1h"/"+2d" → ISO timestamp. */
static parseRelativeTime(s: string): string | null {
const m = s.match(/^\+(\d+)(s|m|h|d)$/);
if (!m) return null;
const ms = parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
return new Date(Date.now() + ms).toISOString();
}
/** "10s"/"5m"/"1h"/"2d" → milliseconds. */
static parseInterval(s: string): number | null {
const m = s.match(/^(\d+)(s|m|h|d)$/);
if (!m) return null;
return parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
}
}
+490
View File
@@ -0,0 +1,490 @@
// Persistence for pi-subagents operational settings.
// - Global: ~/.pi/agent/subagents.json (via getAgentDir()) — manual defaults, never written here
// - Project: <cwd>/.pi/subagents.json — written by /agents → Settings; overrides global on load
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { NO_FALLBACK } from "./agent-types.js";
import type { AgentMentionMode, JoinMode, WidgetMode } from "./types.js";
export interface SubagentsSettings {
maxConcurrent?: number;
/**
* 0 = unlimited — the extension's single source of truth for that convention:
* `normalizeMaxTurns()` in agent-runner.ts treats 0 → `undefined`, and the
* `/agents` → Settings input prompt explicitly says "0 = unlimited".
*/
defaultMaxTurns?: number;
graceTurns?: number;
defaultJoinMode?: JoinMode;
/**
* Whether a top-level `Agent` spawn that doesn't say runs detached.
* Defaults to `true`, following Claude Code, where the agent backgrounds
* unless the caller passes `run_in_background: false`. Set `false` to restore
* the previous behaviour, where an unqualified spawn blocked the turn and
* returned its result inline.
*
* Top-level only. Nested spawns (a subagent spawning its own) always default
* to foreground regardless of this setting — see `nested-tools.ts`, where a
* detached child would be killed by `abortOwnedChildren` when its parent
* settles, with no notification path to deliver its result.
*
* An explicit `run_in_background` on the call, or in the agent file's
* frontmatter, overrides this in both directions; the setting only decides
* what "unspecified" means.
*/
backgroundByDefault?: boolean;
/**
* Master switch for the schedule subagent feature. Defaults to `true`.
* When `false`: the `Agent` tool's `schedule` param + its guideline are
* stripped from the tool spec at registration (zero LLM-context cost), the
* scheduler doesn't bind to the session, and the `/agents → Scheduled jobs`
* menu entry is hidden. Schema-level removal applies at extension load
* (next pi session); runtime menu/runtime-fire short-circuit is immediate.
*/
schedulingEnabled?: boolean;
/**
* When true, the effective model of each subagent spawn is validated
* against `enabledModels` from pi's settings — both global
* (`<agentDir>/settings.json`) and project-local (`<cwd>/.pi/settings.json`),
* with project overriding global (mirrors pi's SettingsManager deep-merge).
*
* scopeModels guards against runtime LLM choices, not user-level config.
* Out-of-scope handling reflects this:
* - Caller-supplied via `Agent({ model: "..." })` (only when frontmatter
* has no `model:`, since frontmatter is authoritative): hard error
* returned to the orchestrator, listing the allowed models. The LLM
* made an explicit out-of-scope choice and gets explicit feedback.
* - Frontmatter-pinned: warning toast + the pinned model runs. The
* agent's author/installer chose this; trust it.
* - Parent-inherited (neither caller nor frontmatter sets a model):
* warning toast + parent's model runs. The user chose the parent's
* model when starting the session; trust it.
*
* No-op when pi's `enabledModels` is empty or absent — nothing to validate
* against. Defaults to false: subagents may use any model.
*/
scopeModels?: boolean;
/**
* When true, an unreadable or unparseable agent `.md` aborts extension load
* instead of being skipped with a warning — pi exits, naming the file.
*
* Startup only, by design. Mid-session reloads (one per `Agent` call) keep
* warning: a bad edit at 3pm should not kill the session on the next
* unrelated spawn, where the failure would look disconnected from its cause.
* For a checked-in `.pi/agents/`, failing at startup is the point — the
* alternative is running a *different* agent than the file names.
* Defaults to false.
*/
strictAgentFiles?: boolean;
/**
* When true, the three built-in default agents (general-purpose, Explore, Plan)
* are not registered at startup. User-defined agents from project/global custom
* agent dirs are completely unaffected — only the hardcoded DEFAULT_AGENTS are suppressed.
* Defaults to false.
*/
disableDefaultAgents?: boolean;
/**
* Which Agent tool description the LLM sees. "full" (default) is the rich
* Claude Code-style prompt; "compact" is a ~75% smaller version (one-line
* agent type list, terse usage notes) for small/local models where tool-spec
* tokens are expensive; "custom" reads `.pi/agent-tool-description.md`
* (project, falling back to `<agentDir>/agent-tool-description.md`) with
* `{{placeholder}}` substitution — a missing/empty file falls back to "full".
* The mode is read once at tool registration — changing it applies on the
* 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;
/**
* Whether `@handle message` typed at the prompt is routed to that subagent
* instead of the main model, and whether `@` offers running agents alongside
* pi's file completion. Defaults to `model`. Applied live.
*
* - `model`: mentioning an agent that is not running asks the main model to
* spawn it with the `Agent` tool, Claude Code's behaviour. Costs a turn,
* and the model writes the agent's prompt rather than your text being it.
* - `direct`: that agent is started here instead, with the typed message as
* its prompt and no main-model turn spent.
* - `off`: the input hook falls straight through and the stacked
* autocomplete provider delegates everything back to pi's built-in one.
*
* Messaging a running agent and resuming a finished one are direct in both
* `model` and `direct`. The legacy booleans are still accepted: `true` reads
* as `model`, `false` as `off`.
*/
agentMentions?: AgentMentionMode;
/**
* Whether subagents persist their pi session by default, so `@handle` can
* reopen an agent's conversation long after its in-memory record is gone.
* Defaults to `true`. Per-agent `persist_session:` frontmatter overrides it
* in both directions. Turning it off restores the previous behaviour, where
* a handle stops resolving roughly ten minutes after the agent finishes and
* mentioning it starts a fresh run instead. Persisted sessions also appear
* nested under the spawning session in pi's `/resume`.
*/
rememberAgents?: boolean;
/**
* Display mode for the persistent above-editor agent widget:
* - `all`: show every agent (foreground + background).
* - `background`: hide foreground agents — they already render inline as the
* Agent tool result, so the widget would otherwise double-render them
* (#118); everything else (background, queued, scheduled, RPC) stays.
* - `off`: hide the widget entirely.
* Defaults to `background`. Pure-UI and applied live (toggling refreshes the
* widget).
*/
widgetMode?: WidgetMode;
/**
* Project/global default for writing each subagent's `.output` transcript
* (a JSON-lines copy of the run, stored under the OS temp dir).
* Defaults to `true`. Set `false` to make transcripts opt-in for the whole
* project (e.g. a repo that shouldn't leave run transcripts on disk for backup
* or DLP tooling to ingest). A custom agent's `output_transcript` frontmatter
* overrides this per agent. This governs only the transcript — it does NOT
* affect the persisted pi session (`persist_session`), worktree commits
* (`isolation: worktree`), or memory files.
*/
outputTranscript?: boolean;
/**
* Whether `isolation: "worktree"` may create a worktree at all. Defaults to
* `true`. Set `false` on a repo where worktrees are too slow or too large to
* be worth it (#184): a requested worktree is then dropped and the agent runs
* in the main checkout.
*
* The drop is deliberately silent — there is no per-result note, because the
* setting exists for projects whose model asks for a worktree on every call,
* where a note would be noise on every result. What keeps the orchestrator
* from claiming a `pi-agent-*` branch anyway is that it is never told the
* capability exists: `isolationParam` (invocation-config.ts) drops the field
* from both tool schemas, and `isolationGuideline` (index.ts) drops the
* matching prose from the full and compact descriptions — a custom one opts
* in via the `{{isolationGuideline}}` placeholder. Anything that
* reintroduces the prose has to reintroduce a note with it.
*
* Deliberately a downgrade rather than an error. The fail-loud rule covers
* worktrees that *cannot* be created; this is the user declining one, and
* throwing would reject exactly the calls that the `isolation: "off"` value
* exists to tolerate. Enforced below the tool boundary, so it also covers the
* scheduler and the unvalidated cross-extension RPC path.
*/
worktreeIsolation?: boolean;
/**
* Hard ceiling on nested subagent delegation, counted from the main session:
* main = 0, its subagents = 1, their children = 2. Defaults to `2`; `0` or `1`
* disables nesting project-wide. Read when a subagent session is built, so a
* change applies to agents started after it.
*/
maxSubagentDepth?: number;
/**
* Agent type substituted when a caller-supplied `subagent_type` doesn't
* resolve to exactly one enabled agent (unknown, disabled, or ambiguous by
* case). Omitted keeps the historical `general-purpose` fallback; a type name
* routes those calls to that agent instead; `"none"` disables the fallback so
* dispatch fails closed with an error naming the available types.
*
* The boolean `false` is accepted as a spelling of `"none"`, because a boolean
* would otherwise be dropped as the wrong type and silently leave the
* PERMISSIVE default in place while the author believes strict dispatch is on
* — the wrong direction to fail for this setting. Every other value is an
* agent name, so a mistaken `"off"` fails loudly at dispatch rather than
* meaning one thing here and another in the resolver.
*/
fallbackSubagent?: string;
/**
* Whether this extension's tool results carry a `usage` field, so subagent
* spend reaches the parent session's own accounting. Defaults to `false`.
*
* Subagents run in their own pi sessions, so by default the parent's footer,
* statusline and `/cost` show only what the main model spent — a session that
* delegated most of its work reads as nearly free. Pi folds
* `toolResult.usage` into `getSessionStats()`, so attaching it makes those
* surfaces count subagents too, under `/cost`'s "Tools/summaries" bucket.
*
* Off by default because it changes numbers the user may already be tracking
* (a statusline reading session cost will step up), not because the numbers
* are wrong.
*
* Three properties of what gets reported:
* - Tokens exclude `cacheRead`, for the reason in `usage.ts` — the parent's
* token total therefore rises by billed tokens only.
* - Cost is pi's own per-message `usage.cost.total`; we price nothing, and
* a model pi has no rates for contributes 0.
* - The context-window percentage is untouched. Pi derives it from assistant
* messages alone (`getContextUsage`), so a delegating session's context
* does not appear to fill up faster.
*/
reportUsage?: boolean;
/**
* Whether the subagent surfaces show an estimated dollar cost next to their
* token counts (widget, FleetView, conversation viewer, foreground results,
* completion notifications). Defaults to `false`. Applied live.
*
* Rendered as `~$0.0042` — the tilde marks it as pi's reported estimate
* rather than a billed figure, and it is omitted entirely when the model has
* no pricing data, so a local model shows tokens and no dollars.
*
* Independent of `reportUsage`: this one is what a human reads, that one is
* what the parent session counts.
*/
showCost?: boolean;
}
export type ToolDescriptionMode = "full" | "compact" | "custom";
/** Setter hooks used by applySettings to wire persisted values into in-memory state. */
export interface SettingsAppliers {
setMaxConcurrent: (n: number) => void;
setDefaultMaxTurns: (n: number) => void;
setGraceTurns: (n: number) => void;
setDefaultJoinMode: (mode: JoinMode) => void;
setBackgroundByDefault: (b: boolean) => void;
setSchedulingEnabled: (b: boolean) => void;
setScopeModels: (enabled: boolean) => void;
setStrictAgentFiles: (b: boolean) => void;
setDisableDefaultAgents: (b: boolean) => void;
setToolDescriptionMode: (mode: ToolDescriptionMode) => void;
setFleetView: (b: boolean) => void;
setAgentMentions: (mode: AgentMentionMode) => void;
setRememberAgents: (b: boolean) => void;
setWidgetMode: (mode: WidgetMode) => void;
setOutputTranscript: (b: boolean) => void;
setWorktreeIsolation: (b: boolean) => void;
setMaxSubagentDepth: (n: number) => void;
setFallbackSubagent: (v: string | undefined) => void;
setReportUsage: (b: boolean) => void;
setShowCost: (b: boolean) => void;
}
/** Emit callback — a subset of `pi.events.emit` to keep helpers testable. */
export type SettingsEmit = (event: string, payload: unknown) => void;
const VALID_JOIN_MODES: ReadonlySet<string> = new Set<JoinMode>(["async", "group", "smart"]);
const VALID_TOOL_DESCRIPTION_MODES: ReadonlySet<string> = new Set<ToolDescriptionMode>(["full", "compact", "custom"]);
const VALID_WIDGET_MODES: ReadonlySet<string> = new Set<WidgetMode>(["all", "background", "off"]);
const VALID_AGENT_MENTION_MODES: ReadonlySet<string> = new Set<AgentMentionMode>(["model", "direct", "off"]);
// Sanity ceilings — prevent hand-edited configs from asking for values that
// make no operational sense (e.g. 1e6 concurrent subagents). Permissive enough
// that any realistic power-user setting passes through.
const MAX_CONCURRENT_CEILING = 1024;
const MAX_TURNS_CEILING = 10_000;
const GRACE_TURNS_CEILING = 1_000;
const SUBAGENT_DEPTH_CEILING = 16;
/** Drop fields that don't match the expected shape. Silent — garbage becomes absent. */
function sanitize(raw: unknown): SubagentsSettings {
if (!raw || typeof raw !== "object") return {};
const r = raw as Record<string, unknown>;
const out: SubagentsSettings = {};
if (
Number.isInteger(r.maxConcurrent) &&
(r.maxConcurrent as number) >= 1 &&
(r.maxConcurrent as number) <= MAX_CONCURRENT_CEILING
) {
out.maxConcurrent = r.maxConcurrent as number;
}
if (
Number.isInteger(r.defaultMaxTurns) &&
(r.defaultMaxTurns as number) >= 0 &&
(r.defaultMaxTurns as number) <= MAX_TURNS_CEILING
) {
out.defaultMaxTurns = r.defaultMaxTurns as number;
}
if (
Number.isInteger(r.graceTurns) &&
(r.graceTurns as number) >= 1 &&
(r.graceTurns as number) <= GRACE_TURNS_CEILING
) {
out.graceTurns = r.graceTurns as number;
}
if (
Number.isInteger(r.maxSubagentDepth) &&
(r.maxSubagentDepth as number) >= 0 &&
(r.maxSubagentDepth as number) <= SUBAGENT_DEPTH_CEILING
) {
out.maxSubagentDepth = r.maxSubagentDepth as number;
}
if (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
out.defaultJoinMode = r.defaultJoinMode as JoinMode;
}
if (typeof r.backgroundByDefault === "boolean") {
out.backgroundByDefault = r.backgroundByDefault;
}
if (typeof r.schedulingEnabled === "boolean") {
out.schedulingEnabled = r.schedulingEnabled;
}
if (typeof r.scopeModels === "boolean") {
out.scopeModels = r.scopeModels;
}
if (typeof r.strictAgentFiles === "boolean") {
out.strictAgentFiles = r.strictAgentFiles;
}
if (typeof r.disableDefaultAgents === "boolean") {
out.disableDefaultAgents = r.disableDefaultAgents;
}
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;
}
// Was a boolean before the `model` mode existed. A hand-written or
// previously-written `true` means "on", which is now the default `model`.
if (typeof r.agentMentions === "boolean") {
out.agentMentions = r.agentMentions ? "model" : "off";
} else if (typeof r.agentMentions === "string" && VALID_AGENT_MENTION_MODES.has(r.agentMentions)) {
out.agentMentions = r.agentMentions as AgentMentionMode;
}
if (typeof r.rememberAgents === "boolean") {
out.rememberAgents = r.rememberAgents;
}
if (typeof r.widgetMode === "string" && VALID_WIDGET_MODES.has(r.widgetMode)) {
out.widgetMode = r.widgetMode as WidgetMode;
}
if (typeof r.outputTranscript === "boolean") {
out.outputTranscript = r.outputTranscript;
}
if (typeof r.worktreeIsolation === "boolean") {
out.worktreeIsolation = r.worktreeIsolation;
}
if (typeof r.reportUsage === "boolean") {
out.reportUsage = r.reportUsage;
}
if (typeof r.showCost === "boolean") {
out.showCost = r.showCost;
}
if (r.fallbackSubagent === false) {
// The only non-string spelling worth accepting: a boolean would otherwise be
// dropped, silently leaving the PERMISSIVE default in place. Every string is
// an agent name except the `none` sentinel, which the resolver recognizes —
// so a mistaken "off" fails loudly at dispatch instead of meaning something
// different here than it does there.
out.fallbackSubagent = NO_FALLBACK;
} else if (typeof r.fallbackSubagent === "string" && r.fallbackSubagent.trim()) {
out.fallbackSubagent = r.fallbackSubagent.trim();
}
return out;
}
function globalPath(): string {
return join(getAgentDir(), "subagents.json");
}
function projectPath(cwd: string): string {
return join(cwd, ".pi", "subagents.json");
}
/**
* Read a settings file. Missing file is silent (returns `{}`). A file that
* exists but can't be parsed emits a warning to stderr so users aren't
* silently reverted to defaults — and still returns `{}` so startup proceeds.
*/
function readSettingsFile(path: string): SubagentsSettings {
if (!existsSync(path)) return {};
try {
return sanitize(JSON.parse(readFileSync(path, "utf-8")));
} catch (err) {
const reason = err instanceof Error ? err.message : String(err);
console.warn(`[pi-subagents] Ignoring malformed settings at ${path}: ${reason}`);
return {};
}
}
/** Load merged settings: global provides defaults, project overrides. */
export function loadSettings(cwd: string = process.cwd()): SubagentsSettings {
return { ...readSettingsFile(globalPath()), ...readSettingsFile(projectPath(cwd)) };
}
/**
* Write project-local settings. Global is never touched from code.
* Returns `true` on success, `false` if the write (or mkdir) failed so the
* caller can surface a warning — persistence isn't fatal but isn't silent.
*/
export function saveSettings(s: SubagentsSettings, cwd: string = process.cwd()): boolean {
const path = projectPath(cwd);
try {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(s, null, 2), "utf-8");
return true;
} catch {
return false;
}
}
/** Apply persisted settings to the in-memory state via caller-supplied setters. */
export function applySettings(s: SubagentsSettings, appliers: SettingsAppliers): void {
if (typeof s.maxConcurrent === "number") appliers.setMaxConcurrent(s.maxConcurrent);
if (typeof s.defaultMaxTurns === "number") appliers.setDefaultMaxTurns(s.defaultMaxTurns);
if (typeof s.graceTurns === "number") appliers.setGraceTurns(s.graceTurns);
if (typeof s.maxSubagentDepth === "number") appliers.setMaxSubagentDepth(s.maxSubagentDepth);
if (typeof s.fallbackSubagent === "string") appliers.setFallbackSubagent(s.fallbackSubagent);
if (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
if (typeof s.backgroundByDefault === "boolean") appliers.setBackgroundByDefault(s.backgroundByDefault);
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
if (typeof s.scopeModels === "boolean") appliers.setScopeModels(s.scopeModels);
if (typeof s.strictAgentFiles === "boolean") appliers.setStrictAgentFiles(s.strictAgentFiles);
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);
if (s.agentMentions) appliers.setAgentMentions(s.agentMentions);
if (typeof s.rememberAgents === "boolean") appliers.setRememberAgents(s.rememberAgents);
if (s.widgetMode) appliers.setWidgetMode(s.widgetMode);
if (typeof s.outputTranscript === "boolean") appliers.setOutputTranscript(s.outputTranscript);
if (typeof s.worktreeIsolation === "boolean") appliers.setWorktreeIsolation(s.worktreeIsolation);
if (typeof s.reportUsage === "boolean") appliers.setReportUsage(s.reportUsage);
if (typeof s.showCost === "boolean") appliers.setShowCost(s.showCost);
}
/**
* Format the user-facing toast for a settings mutation. Pure function —
* routes the success/failure of `saveSettings` into the right message + level
* so the UI layer (index.ts) stays a thin wire between input and notification.
*/
export function persistToastFor(
successMsg: string,
persisted: boolean,
): { message: string; level: "info" | "warning" } {
return persisted
? { message: successMsg, level: "info" }
: { message: `${successMsg} (session only; failed to persist)`, level: "warning" };
}
/**
* Load merged settings, apply them to in-memory state, and emit the
* `subagents:settings_loaded` lifecycle event. Returns the loaded settings so
* callers can log/inspect. Extension init wires this once.
*/
export function applyAndEmitLoaded(
appliers: SettingsAppliers,
emit: SettingsEmit,
cwd: string = process.cwd(),
): SubagentsSettings {
const settings = loadSettings(cwd);
applySettings(settings, appliers);
emit("subagents:settings_loaded", { settings });
return settings;
}
/**
* Persist a settings snapshot, emit the `subagents:settings_changed` event
* (regardless of persist outcome so listeners see the in-memory change), and
* return the toast the UI should display. Event payload carries the `persisted`
* flag so listeners can react to write failures.
*/
export function saveAndEmitChanged(
snapshot: SubagentsSettings,
successMsg: string,
emit: SettingsEmit,
cwd: string = process.cwd(),
): { message: string; level: "info" | "warning" } {
const persisted = saveSettings(snapshot, cwd);
emit("subagents:settings_changed", { settings: snapshot, persisted });
return persistToastFor(successMsg, persisted);
}
+102
View File
@@ -0,0 +1,102 @@
/**
* skill-loader.ts — Preload named skills.
*
* Roots, in precedence order:
* - <cwd>/.pi/skills (project, Pi's standard)
* - <cwd>/.agents/skills (project, cross-tool Agent Skills spec — https://agentskills.io)
* - getAgentDir()/skills (user, default ~/.pi/agent/skills — Pi's standard)
* - ~/.agents/skills (user, cross-tool Agent Skills spec)
* - ~/.pi/skills (legacy global, pre-Pi)
*
* Layout per root:
* - <root>/<name>.md (flat file at the top level)
* - <root>/.../<name>/SKILL.md (directory skill, may be nested — Pi's standard)
*
* Recursion skips dotfile entries and node_modules. A directory that itself contains
* SKILL.md is a skill — we don't descend into it (Pi: skills don't nest).
*
* Symlinks are rejected for security (deviation from Pi, which follows them).
*/
import type { Dirent } from "node:fs";
import { existsSync, readdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
import { isSymlink, isUnsafeName, safeReadFile } from "./memory.js";
export interface PreloadedSkill {
name: string;
content: string;
}
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
return skillNames.map((name) => ({ name, content: loadSkillContent(name, cwd) }));
}
function loadSkillContent(name: string, cwd: string): string {
if (isUnsafeName(name)) {
return `(Skill "${name}" skipped: name contains path traversal characters)`;
}
const roots = [
join(cwd, ".pi", "skills"), // project — Pi standard
join(cwd, ".agents", "skills"), // project — Agent Skills spec
join(getAgentDir(), "skills"), // user — Pi standard
join(homedir(), ".agents", "skills"), // user — Agent Skills spec
join(homedir(), ".pi", "skills"), // legacy global, pre-Pi
];
for (const root of roots) {
const content = findInRoot(root, name);
if (content !== undefined) return content;
}
return `(Skill "${name}" not found in .pi/skills/, .agents/skills/, or global skill locations)`;
}
function findInRoot(root: string, name: string): string | undefined {
if (isSymlink(root)) return undefined; // reject symlinked roots entirely
const flat = safeReadFile(join(root, `${name}.md`))?.trim();
if (flat !== undefined) return flat;
return findSkillDirectory(root, name);
}
/** BFS under `root` for a directory named `name` containing `SKILL.md`. Pi-conforming filters. */
function findSkillDirectory(root: string, name: string): string | undefined {
if (!existsSync(root)) return undefined;
const queue: string[] = [root];
while (queue.length > 0) {
const current = queue.shift();
if (current === undefined) continue;
let entries: Dirent<string>[];
try {
entries = readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
// Deterministic byte-order traversal — locale-independent.
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const entry of entries) {
if (!entry.isDirectory()) continue;
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
// Symlinked dirs already filtered by entry.isDirectory() — Dirent uses lstat semantics.
const path = join(current, entry.name);
const skillMd = join(path, "SKILL.md");
const isSkillDir = existsSync(skillMd);
if (isSkillDir) {
if (entry.name === name) {
const content = safeReadFile(skillMd)?.trim();
if (content !== undefined) return content;
}
continue; // Pi rule: skills don't nest — don't descend into a skill dir
}
queue.push(path);
}
}
return undefined;
}
+90
View File
@@ -0,0 +1,90 @@
/**
* status-note.ts — Honest framing for an agent result: the parenthetical status
* note for a non-normal outcome, and the salvaged partial output of a failure.
*
* Lives here rather than in an index.ts closure because both entry points need
* it — the top-level tools and the nested delegation tools, which can't import
* from index.ts (that is the extension entry, and it already reaches these tools
* through agent-runner).
*/
import type { AgentRecord } from "./types.js";
/**
* Explicit parenthetical note for a non-normal terminal outcome, so the parent
* agent can't mistake partial output for a completed result. Empty string for a
* clean completion (and any unknown/non-terminal status).
*
* `stopped` (a human aborted it) is deliberately distinct from `aborted` (the
* turn limit was hit) — the parent should treat human intervention differently
* from a budget cutoff.
*/
export function getStatusNote(status: string): string {
switch (status) {
case "stopped":
return " (STOPPED BY THE USER before completion — output is partial; the task was NOT finished)";
case "aborted":
return " (aborted — hit the turn limit before completion; output may be incomplete)";
case "steered":
return " (wrapped up at the turn limit — output may be partial)";
default:
return "";
}
}
/**
* Foreground variant of `getStatusNote`. A foreground caller is in a different
* position from a background one, so it needs different text:
*
* - It already holds the agent's ENTIRE output inline, whereas the background
* notification carries a 500-char preview. So only here can we truthfully
* say there is nothing more to fetch — which is the whole point, because
* - it has no agent id. The id travels in the tool result's renderer
* `details`, which is never serialized to the model. A parent that reads
* "output may be partial" as "truncated, go retrieve the rest" therefore
* has nothing valid to call `get_subagent_result` with, and will invent an
* id (#174).
*
* Only the lead clause varies between the three, and each variation carries
* information: `wrapped up` vs `aborted` tells the parent whether the output is
* a considered final answer or a fragment, and `stopped` shouts because a human
* intervening outranks everything else in the string. Only `steered` hedges on
* completion — it was told to wrap up and did, so it may well have finished at
* the limit; an aborted run blew through its grace turns while still working,
* and `stopped` can only fire on a running agent, so neither ever delivered a
* final answer. Identical confidence gets identical wording: phrasing one fact
* two ways invites a hunt for a distinction that isn't there.
*
* Every clause is a statement about state, never an instruction to act, and
* `get_subagent_result` is never named — naming the tool we steer away from only
* raises its salience. Two instructions were tried here and cut: "re-spawn with
* a higher max_turns" (pushes a fresh multi-minute run to save one wasted tool
* call) and, on `stopped`, "ask before restarting it" (restates the lead, and
* presumes someone is present to ask — false under `pi -p`, in scheduled jobs,
* and in any background-driven run). Nothing here can measure whether wording
* improves parent behavior, so removing a false cue (which cannot induce new
* behavior) and adding an instruction (which can) are not equally safe bets.
* Don't add either back without a way to measure it.
*/
export function getForegroundOutcomeNote(status: string): string {
switch (status) {
case "stopped":
return " (STOPPED BY THE USER — everything the agent produced is above; the task is unfinished)";
case "aborted":
return " (aborted at the turn limit — everything the agent produced is above; the task is unfinished)";
case "steered":
return " (wrapped up at the turn limit — everything the agent produced is above; the task may be unfinished)";
default:
return "";
}
}
/**
* Salvaged partial output of a failed run, as a labeled suffix for the error
* surfaces (or "" if the run produced nothing). `record.result` is bounded to
* the run's own turns, so this is never a stale earlier answer (#144).
*/
export function partialOutputSuffix(record: AgentRecord): string {
const partial = record.result?.trim();
return partial ? `\n\nPartial output before the failure:\n${partial}` : "";
}
+307
View File
@@ -0,0 +1,307 @@
/**
* types.ts — Type definitions for the subagent system.
*/
import type { ThinkingLevel } from "@earendil-works/pi-ai";
import type { AgentSession } from "@earendil-works/pi-coding-agent";
import type { LifetimeUsage } from "./usage.js";
export type { ThinkingLevel };
/** Agent type: any string name (built-in defaults or user-defined). */
export type SubagentType = string;
/** Names of the three embedded default agents. */
export const DEFAULT_AGENT_NAMES = ["general-purpose", "Explore", "Plan"] as const;
/** Memory scope for persistent agent memory. */
export type MemoryScope = "user" | "project" | "local";
/**
* Isolation mode for agent execution.
*
* `"off"` exists for the caller's benefit, not the runtime's: models that fill
* every optional parameter had no legal way to decline a single-value
* `isolation` field and kept spawning worktrees they had just reasoned their
* way out of (#231, #184). It is an input spelling only —
* `resolveAgentInvocationConfig` collapses it to `undefined`, so nothing
* downstream sees a value other than `"worktree"`. In an agent file it is a
* genuine veto, since agent config outranks tool-call params.
*/
export type IsolationMode = "worktree" | "off";
/** Unified agent configuration — used for both default and user-defined agents. */
export interface AgentConfig {
name: string;
/** UI name. `display_name` wins; Claude Code's `name` is accepted as a fallback. */
displayName?: string;
/** Claude Code-compatible name color (named color or #RRGGBB). */
color?: string;
description: string;
builtinToolNames?: string[];
/** Raw `ext:` selector entries from the `tools:` CSV, e.g. ["ext:foo", "ext:bar/x"].
* Presence of any entry flips extension tools to an explicit allowlist. */
extSelectors?: string[];
/** Tool denylist — these tools are removed even if `builtinToolNames` or extensions include them. */
disallowedTools?: string[];
/** true = inherit all, string[] = only listed, false = none */
extensions: true | string[] | false;
/** Extension-name denylist applied after the `extensions:` include set. Exclude wins.
* Plain canonical names only (case-insensitive); no paths, no wildcard. */
excludeExtensions?: string[];
/** true = inherit all, string[] = only listed, false = none */
skills: true | string[] | false;
model?: string;
thinking?: ThinkingLevel;
maxTurns?: number;
/** Persist this subagent as a normal pi session instead of keeping it in memory only. */
persistSession?: boolean;
/** Write the subagent's .output transcript. Defaults to true; false suppresses only that transcript. */
outputTranscript?: boolean;
/** Optional session directory used when persistSession is true. Omitted = pi's normal session location. */
sessionDir?: string;
/**
* Nested delegation, off by default: undefined = no nested tools;
* "all" = any enabled agent; string[] = only those agent types.
*/
allowedSubagents?: "all" | string[];
systemPrompt: string;
promptMode: "replace" | "append";
/** Default for spawn: fork parent conversation. undefined = caller decides. */
inheritContext?: boolean;
/** Default for spawn: run in background. undefined = caller decides. */
runInBackground?: boolean;
/** Default for spawn: no extension tools. undefined = caller decides. */
isolated?: boolean;
/** Persistent memory scope — agents with memory get a persistent directory and MEMORY.md */
memory?: MemoryScope;
/**
* Isolation mode — "worktree" runs the agent in a temporary git worktree,
* "off" refuses one even when the caller asks (frontmatter outranks params).
*/
isolation?: IsolationMode;
/** true = this is an embedded default agent (informational) */
isDefault?: boolean;
/** false = agent is hidden from the registry */
enabled?: boolean;
/** Where this agent was loaded from */
source?: "default" | "project" | "global";
/** Path of the .md it was loaded from. Unset for embedded defaults. */
sourcePath?: string;
}
export type JoinMode = 'async' | 'group' | 'smart';
/**
* Display mode for the persistent above-editor agent widget.
* - `all`: show every agent (foreground + background).
* - `background`: hide foreground agents (they already render inline as the
* Agent tool result, #118); show background/queued/scheduled/RPC.
* - `off`: hide the widget entirely.
*/
export type WidgetMode = 'all' | 'background' | 'off';
/**
* How `@handle message` starts an agent that is not already running.
* - `model`: inject Claude Code's `agent_mention` reminder and let the main
* model spawn it with the `Agent` tool, which is what Claude Code does.
* - `direct`: spawn it here, immediately, with the typed message as its prompt
* and no main-model turn spent.
* - `off`: `@` means only "attach a file" again.
*
* Messaging a running agent and resuming a finished one are direct in every
* mode — Claude Code only differs from us on the *new* invocation.
*/
export type AgentMentionMode = 'model' | 'direct' | 'off';
/**
* What survives a record's eviction so `@handle` keeps working. The live record
* is discarded after ~10 minutes, but the pi session it wrote is still on disk,
* and this is the little that is needed to find and describe it again.
*/
export interface AgentTombstone {
handle: string;
alias?: string;
id: string;
type: SubagentType;
description: string;
/** Always set — a record with no session file is never tombstoned. */
sessionFile: string;
completedAt: number;
}
/**
* What `@handle` resolved to: an agent still in memory, or the remains of one
* whose conversation can be reopened from disk.
*/
export type MentionResolution =
| { kind: "live"; record: AgentRecord }
| { kind: "tombstone"; entry: AgentTombstone };
export interface AgentRecord {
id: string;
type: SubagentType;
/**
* Typeable name for the `@handle message` prompt mention, derived from the
* agent type and numbered when siblings collide (`explore`, `explore-2`).
* Top-level agents only — nested children are hidden from every top-level
* surface, so nothing can address them.
*/
handle?: string;
/**
* A second, memorable handle from the spawner's `name` (`@auth-audit`), drawn
* from the same namespace as `handle` so the two can never collide. Purely
* additive: `handle` is assigned regardless, so a named agent stays reachable
* by its type and `@explore` never comes to mean "start another one".
*/
alias?: string;
description: string;
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
result?: string;
error?: string;
toolUses: number;
startedAt: number;
completedAt?: number;
session?: AgentSession;
abortController?: AbortController;
promise?: Promise<string>;
groupId?: string;
joinMode?: JoinMode;
/** Set when result was already consumed via get_subagent_result — suppresses completion notification. */
resultConsumed?: boolean;
/** Steering messages queued before the session was ready. */
pendingSteers?: string[];
/** Worktree info if the agent is running in an isolated worktree. */
worktree?: { path: string; branch: string; baseSha: string; workPath: string };
/** Worktree cleanup result after agent completion. */
worktreeResult?: { hasChanges: boolean; branch?: string };
/** The tool_use_id from the original Agent tool call. */
toolCallId?: string;
/** Path to the streaming output transcript file. */
outputFile?: string;
/**
* The agent's pi session file, when it was persisted (`persist_session`, or
* the `rememberAgents` default). Captured so a mention can reopen the
* conversation after the record itself has been evicted; undefined for an
* in-memory session, which leaves nothing to reopen.
*/
sessionFile?: string;
/** Cleanup function for the output file stream subscription. */
outputCleanup?: () => void;
/**
* Lifetime usage breakdown, accumulated via `message_end` events. Survives
* compaction. Total = input + output + cacheWrite (cacheRead deliberately
* excluded — see issue #38). Initialized to zeros at spawn.
*/
lifetimeUsage: LifetimeUsage;
/** Number of times this agent's session has compacted. Initialized to 0 at spawn. */
compactionCount: number;
/**
* Whether this agent was spawned to run in the background. Tri-state, set at
* spawn from `SpawnOptions.isBackground`: `true` = background, `false` =
* foreground (has an inline Agent tool-result surface), `undefined` = the
* caller never declared it (e.g. a cross-extension RPC spawn, which is detached
* and has no inline surface). The widget's background-only filter keys off this
* — and excludes only explicit `false`, so `undefined` agents stay visible.
* Reliable across ALL spawn paths, unlike the UI-only `invocation` snapshot,
* which only the Agent-tool path populates.
*/
isBackground?: boolean;
/** Resolved spawn params, captured for UI display. Fixed at spawn time. */
invocation?: AgentInvocation;
/** Nesting depth: top-level subagent = 1. */
depth?: number;
/** Parent agent ID for ownership-scoped nested controls. */
parentAgentId?: string;
/** Effective inherited nesting cap for this branch. */
maxSubagentDepth?: number;
/**
* Session id of the root (main) session this branch descends from. Nested
* spawns inherit it so their transcripts file under the same session
* directory as their ancestors' instead of the child session's own id.
*/
rootSessionId?: string;
}
export interface AgentInvocation {
/** Short display name, e.g. "haiku" — only set when different from parent. */
modelName?: string;
thinking?: ThinkingLevel;
maxTurns?: number;
isolated?: boolean;
inheritContext?: boolean;
runInBackground?: boolean;
isolation?: IsolationMode;
}
/** Details attached to custom notification messages for visual rendering. */
export interface NotificationDetails {
id: string;
description: string;
status: string;
toolUses: number;
turnCount: number;
maxTurns?: number;
totalTokens: number;
/**
* Estimated cost in USD, from pi's per-message `usage.cost.total`. Always
* populated (0 when the model has no pricing); the renderer decides whether
* to show it, per the `showCost` setting.
*/
totalCost?: number;
durationMs: number;
outputFile?: string;
error?: string;
resultPreview: string;
/** Additional agents in a group notification. */
others?: NotificationDetails[];
}
export interface EnvInfo {
isGitRepo: boolean;
branch: string;
platform: string;
}
/**
* A subagent spawn registered to fire on a schedule.
*
* Stored at `<cwd>/.pi/subagent-schedules/<sessionId>.json`. Session-scoped:
* survives `/resume` but resets on `/new`, mirroring pi-chonky-tasks.
*/
export interface ScheduledSubagent {
id: string;
/** Unique within store. Defaults to `description`. */
name: string;
description: string;
/** Raw user input — cron expr | "+10m" | ISO | "5m". */
schedule: string;
scheduleType: "cron" | "once" | "interval";
/** Computed at create time for interval/once. */
intervalMs?: number;
// spawn params (subset of Agent tool params; no inherit_context, no resume)
subagent_type: SubagentType;
prompt: string;
model?: string;
thinking?: ThinkingLevel;
max_turns?: number;
isolated?: boolean;
isolation?: IsolationMode;
// state
enabled: boolean;
/** ISO timestamp. */
createdAt: string;
lastRun?: string;
lastStatus?: "success" | "error" | "running";
/** Refreshed on every fire and on store load. */
nextRun?: string;
runCount: number;
}
export interface ScheduleStoreData {
/** For future migrations. */
version: 1;
jobs: ScheduledSubagent[];
}
+216
View File
@@ -0,0 +1,216 @@
/**
* agent-mention.ts — what `@` can address, and the suggestions pi renders for it.
*
* A subagent is addressable whether or not it is currently running: a live
* record is messaged or resumed, an evicted one whose session is still on disk
* is reopened, and an agent *type* with no instance at all is started. That is
* the point of the handle — `@explore` means the Explore agent, not "the
* Explore process that happens to exist right now" — so the roster below unions
* all three, and the dispatcher and the popup read the same list.
*
* Rows are per *agent*, not per handle. An agent given a `name` holds two names
* (its alias and its type-derived handle) and both resolve, but it lists once,
* under the alias, with its type moved into the description so the row still
* says what it is.
*
* pi's `CombinedAutocompleteProvider` already owns `@`, where it means "attach a
* file". Extensions can wrap it (`ctx.ui.addAutocompleteProvider`), so this
* provider adds the `@` tokens that name an agent and delegates everything else
* — including all of `applyCompletion`, whose `@`-branch already inserts
* `item.value` plus a trailing space, which is exactly what a handle needs.
*
* Matching mirrors Claude Code: case-insensitive prefix, not fuzzy. What it does
* NOT mirror is Claude Code dropping files whenever an agent matches. Here `@` is
* pi's file picker first, and the handles are additive, so a token matching both
* lists both — agents first. Suppressing on any match sounds narrow and is not:
* an empty token prefix-matches every handle, so a bare `@` — the gesture people
* use to browse files — would offer no files at all, and a single letter
* beginning any handle would do the same.
*
* Both halves ship under ONE `prefix`, which is sound because wherever BOTH sides
* produce rows they measured the same span. pi's `extractAtPrefix` takes the
* token after the last of `{space, tab, ", ', =}` and keeps it only if it starts
* with `@`; `MENTION_TRIGGER` matches `@[\w-]*` at the cursor, after start-of-line
* or `[\s。、?!]`. Where those two disagree, exactly one side answers and there
* is nothing to merge: `@src/index.ts` and `@"my file` are pi's alone (no handle
* matches), `=@ex` is pi's alone (`=` is a delimiter to pi, not a boundary to us),
* and `。@ex` is ours alone (the reverse). A merged response therefore never
* carries a prefix from one side and an item from the other.
*
* Offering never-started types is a deliberate step beyond Claude Code, whose
* registry holds only live tasks, so an agent you had not launched yet was
* unaddressable.
*/
import type { AutocompleteItem, AutocompleteProvider, AutocompleteSuggestions } from "@earendil-works/pi-tui";
import type { AgentManager } from "../agent-manager.js";
import { handleBase, MENTION_TRIGGER } from "../mention.js";
import type { AgentRecord, AgentTombstone } from "../types.js";
/**
* One thing `@` can address, and what sending to it will do. `typeLabel` is the
* agent's `display_name`, resolved by the caller: this module stays independent
* of the type registry, but the popup must agree with FleetView and the widget,
* which both render the label rather than the raw type.
*/
export type MentionTarget =
| { kind: "record"; handle: string; record: AgentRecord; typeLabel: string }
| { kind: "tombstone"; handle: string; entry: AgentTombstone; typeLabel: string }
| { kind: "type"; handle: string; type: string; description: string };
/** The registry facts the roster needs, so it stays independent of agent-types. */
export type TypeInfo = { name: string; description: string };
/**
* Everything `@` can reach, in the order the popup lists it: steerable agents
* first, then the other live ones earliest-launched, then agent types with no
* live instance. A type whose handle a record already holds is omitted — that
* name addresses the existing agent, which is what makes `@explore` mean
* "message the one that's running" and only otherwise "start one".
*/
export function mentionRoster(
manager: AgentManager,
types: readonly TypeInfo[],
// Identity by default: a caller with no registry to consult gets the raw
// type, which is also what `getConfig` falls back to when no label is set.
displayNameOf: (type: string) => string = type => type,
): MentionTarget[] {
const live = (r: AgentRecord) => r.status === "running" || r.status === "queued";
const records = manager.listAgents()
.filter(r => r.handle !== undefined && r.parentAgentId === undefined)
.sort((a, b) => (Number(live(b)) - Number(live(a))) || (a.startedAt - b.startedAt));
const taken = new Set<string>();
const targets: MentionTarget[] = [];
// One row per agent, not per handle. An aliased agent lists under its alias
// only — both names resolve, but showing two rows for one agent reads as two
// agents. The type handle stays addressable whether or not it is listed.
for (const record of records) {
const handle = record.alias ?? record.handle!;
taken.add(handle.toLowerCase());
if (record.handle) taken.add(record.handle.toLowerCase());
targets.push({ kind: "record", handle, record, typeLabel: displayNameOf(record.type) });
}
// Then agents that are gone but whose conversation can be reopened. After the
// live ones: a running agent is the likelier target, and this keeps the
// ordering "what exists now, then what can be brought back, then what can be
// started".
for (const entry of manager.listTombstones()) {
const handle = entry.alias ?? entry.handle;
if (taken.has(handle.toLowerCase())) continue;
taken.add(handle.toLowerCase());
taken.add(entry.handle.toLowerCase());
targets.push({ kind: "tombstone", handle, entry, typeLabel: displayNameOf(entry.type) });
}
for (const type of types) {
const handle = handleBase(type.name);
if (taken.has(handle)) continue;
taken.add(handle);
targets.push({ kind: "type", handle, type: type.name, description: type.description });
}
return targets;
}
export function createMentionProvider(
current: AutocompleteProvider,
roster: () => MentionTarget[],
isEnabled: () => boolean,
): AutocompleteProvider {
// One warning per provider, not per keystroke: `getSuggestions` runs on every
// character typed after `@`, so an unguarded log would bury the terminal in
// the time it takes to finish a word.
let warnedInnerFailure = false;
return {
// Only `@` — the contract is "characters that should naturally trigger
// THIS provider", and pi unions each wrapper's own set onto the outermost
// one itself (interactive-mode.js:432), so re-declaring the wrapped
// provider's characters here would both misreport us and duplicate that.
triggerCharacters: ["@"],
async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
const mine = isEnabled() ? mentionItems(roster(), lines[cursorLine] ?? "", cursorCol) : null;
// Asked unconditionally: pi owns `@` and must keep answering for it even
// when a handle matches too. That is the same work vanilla pi does on any
// `@` keystroke — a capped `fd` search, or nothing at all when the host
// configured no `fd` path — but we now do it on tokens we used to answer
// alone, so it must not be able to take the popup down with it. The
// wrapped provider is not always pi's: another extension may sit inside
// us, and before this it was never called for a token naming an agent.
// try/catch, not `.catch()`: a provider that throws SYNCHRONOUSLY never
// returns the promise a `.catch()` would attach to, and the throw escapes
// this method as a rejection — which pi does not handle either
// (components/editor.js:1892 awaits with no catch of its own).
let theirs: AutocompleteSuggestions | null = null;
try {
theirs = await current.getSuggestions(lines, cursorLine, cursorCol, options);
} catch (err) {
// Safe to treat as "no files": pi discards any response whose request is
// no longer current, so an aborted search that surfaces as a rejection
// cannot leave a stale popup behind (`isAutocompleteRequestCurrent`).
// Warned rather than swallowed outright — the failure is invisible in
// the popup, and the same `console.warn` channel already carries this
// extension's other non-fatal failures.
if (!warnedInnerFailure) {
warnedInnerFailure = true;
console.warn("[pi-subagents] the autocomplete provider below us failed; showing agent rows only:", err);
}
theirs = null;
}
if (!mine) return theirs;
if (!theirs) return mine;
// Agents first: there are a handful of them against pi's 20 file rows, and
// a handle buried under fuzzy path matches is a handle nobody finds. The
// prefix is ours by the span argument in the header — identical to pi's
// whenever both sides have something to say.
return { items: [...mine.items, ...theirs.items], prefix: mine.prefix };
},
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
},
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
},
};
}
/** Suggestions for the `@…` token under the cursor, or null when it names no agent. */
function mentionItems(roster: MentionTarget[], line: string, cursorCol: number): AutocompleteSuggestions | null {
const match = MENTION_TRIGGER.exec(line.slice(0, cursorCol));
if (!match) return null;
const typed = match[2].toLowerCase();
const items: AutocompleteItem[] = [];
for (const target of roster) {
if (!target.handle.toLowerCase().startsWith(typed)) continue;
items.push({ value: `@${target.handle}`, label: `@${target.handle}`, description: describeTarget(target) });
}
return items.length > 0 ? { items, prefix: `@${match[2]}` } : null;
}
/** Name the action that will actually happen, so the list never mispromises. */
function describeTarget(target: MentionTarget): string {
if (target.kind === "type") return `start agent · ${summarize(target.description)}`;
if (target.kind === "tombstone") {
// No status: the record is gone, and "completed" would imply one is still
// being tracked. The type carries the identity the handle may not.
return `resume · ${target.typeLabel} · ${target.entry.description}`;
}
const { status, description, alias } = target.record;
const action = status === "running" || status === "queued" ? "send message" : "resume";
// A row listed under its alias has lost the type its handle would have shown,
// so name it — `@auth-audit` alone says nothing about what the agent is.
// A type-derived row already reads as its type and would just repeat itself.
const identity = alias ? `${target.typeLabel} · ` : "";
return `${action} · ${identity}${status} · ${description}`;
}
/** First sentence of an agent description, clipped — these run to paragraphs. */
function summarize(description: string): string {
const first = (description.match(/^.*?[.!?](?=\s|$)/s)?.[0] ?? description).replace(/\s+/g, " ").trim();
return first.length > 60 ? `${first.slice(0, 59).trimEnd()}` : first;
}
+629
View File
@@ -0,0 +1,629 @@
/**
* agent-widget.ts — Persistent widget showing running/completed agents above the editor.
*
* Displays a tree of agents with animated spinners, live stats, and activity descriptions.
* Uses the callback form of setWidget for themed rendering.
*/
import { truncateToWidth } from "@earendil-works/pi-tui";
import { renderAgentName } from "../agent-color.js";
import type { AgentManager } from "../agent-manager.js";
import { getConfig } from "../agent-types.js";
import type { AgentInvocation, SubagentType, WidgetMode } from "../types.js";
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent, type LifetimeUsage, type SessionLike } from "../usage.js";
// ---- Constants ----
/** Maximum number of rendered lines before overflow collapse kicks in. */
const MAX_WIDGET_LINES = 12;
/** Braille spinner frames for animated running indicator. */
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
/** Statuses that indicate an error/non-success outcome (used for linger behavior and icon rendering). */
export const ERROR_STATUSES = new Set(["error", "aborted", "steered", "stopped"]);
/** Tool name → human-readable action for activity descriptions. */
const TOOL_DISPLAY: Record<string, string> = {
read: "reading",
bash: "running command",
edit: "editing",
write: "writing",
grep: "searching",
find: "finding files",
ls: "listing",
};
// ---- Types ----
export type Theme = {
fg(color: string, text: string): string;
bold(text: string): string;
};
export type UICtx = {
setStatus(key: string, text: string | undefined): void;
setWidget(
key: string,
content: undefined | ((tui: any, theme: Theme) => { render(): string[]; invalidate(): void }),
options?: { placement?: "aboveEditor" | "belowEditor" },
): void;
};
/** Per-agent live activity state. */
export interface AgentActivity {
activeTools: Map<string, string>;
toolUses: number;
responseText: string;
session?: SessionLike;
/** Current turn count. */
turnCount: number;
/** Effective max turns for this agent (undefined = unlimited). */
maxTurns?: number;
}
/** Metadata attached to Agent tool results for custom rendering. */
export interface AgentDetails {
displayName: string;
description: string;
subagentType: string;
toolUses: number;
tokens: string;
durationMs: number;
status: "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error" | "background";
/** Human-readable description of what the agent is currently doing. */
activity?: string;
/** Current spinner frame index (for animated running indicator). */
spinnerFrame?: number;
/** Short model name if different from parent (e.g. "haiku", "sonnet"). */
modelName?: string;
/** Notable config tags (e.g. ["thinking: high", "isolated"]). */
tags?: string[];
/** Current turn count. */
turnCount?: number;
/** Effective max turns (undefined = unlimited). */
maxTurns?: number;
/** Estimated cost in USD; 0 when the model has no pricing data. */
cost?: number;
agentId?: string;
error?: string;
}
// ---- Formatting helpers ----
/** Apply foreground styling while restoring it after nested foreground/full ANSI resets. */
export function fgPreservingNestedStyles(theme: Theme, color: string, text: string): string {
const styledEmpty = theme.fg(color, "");
const styleStart = styledEmpty.replace(/\u001b\[(?:0|39)m/g, "");
return theme.fg(color, text.replace(/\u001b\[(?:0|39)m/g, reset => `${reset}${styleStart}`));
}
/** Format a token count compactly: "33.8k token", "1.2M token". */
export function formatTokens(count: number): string {
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M token`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k token`;
return `${count} token`;
}
/**
* Format a cost as `~$0.0042`, or "" when there is nothing to show.
*
* The tilde is load-bearing: this is pi's own estimate from the model's listed
* rates, not a billed figure, and the surfaces that print it sit next to token
* counts that ARE exact.
*
* Nothing is printed for zero, which is also what a model with no pricing data
* reports: `$0.00` beside a local model's tokens would claim its cost was
* measured and found to be nothing, rather than never measured at all. For the
* same reason a real cost too small for four decimals reads `<$0.0001` — it was
* measured, and rounding it to `~$0.0000` would say the opposite.
*/
export function formatCost(cost: number): string {
if (!(cost > 0)) return ""; // also catches NaN
if (cost < 0.0001) return "<$0.0001";
if (cost >= 1) return `~$${cost.toFixed(2)}`;
// Under a dollar: cents at minimum, four decimals at most, nothing trailing.
// Most single runs land between a tenth of a cent and a dime, where rounding
// to cents would collapse a 4x difference in spend into the same figure.
const rounded = Number(cost.toFixed(4));
const decimals = (String(rounded).split(".")[1] ?? "").length;
return `~$${rounded.toFixed(Math.max(2, decimals))}`;
}
/**
* Token count with optional context-fill % and compaction-count annotations.
* Thresholds for percent: <70% dim, 7085% warning, ≥85% error.
* Compaction count rendered as `⇊N` in dim.
*
* "12.3k token" — no annotations
* "12.3k token (45%)" — percent only
* "12.3k token (⇊2)" — compactions only (e.g. right after compact)
* "12.3k token (45% · ⇊2)" — both
*/
export function formatSessionTokens(
tokens: number,
percent: number | null,
theme: Theme,
compactions = 0,
): string {
const tokenStr = formatTokens(tokens);
const annot: string[] = [];
if (percent !== null) {
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
annot.push(theme.fg(color, `${Math.round(percent)}%`));
}
if (compactions > 0) {
annot.push(theme.fg("dim", `${compactions}`));
}
if (annot.length === 0) return tokenStr;
return `${tokenStr} (${annot.join(" · ")})`;
}
/** Format turn count with optional max limit: "↻5≤30" or "↻5". */
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
return maxTurns != null ? `${turnCount}${maxTurns}` : `${turnCount}`;
}
/** Format milliseconds as human-readable duration. */
export function formatMs(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
/** Format duration from start/completed timestamps. */
export function formatDuration(startedAt: number, completedAt?: number): string {
if (completedAt) return formatMs(completedAt - startedAt);
return `${formatMs(Date.now() - startedAt)} (running)`;
}
/** Get display name for any agent type (built-in or custom). */
export function getDisplayName(type: SubagentType): string {
return getConfig(type).displayName;
}
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
export function getPromptModeLabel(type: SubagentType): string | undefined {
const config = getConfig(type);
return config.promptMode === "append" ? "twin" : undefined;
}
/** Mode label is not included — callers add it where they want it. */
export function buildInvocationTags(
invocation: AgentInvocation | undefined,
): { modelName?: string; tags: string[] } {
const tags: string[] = [];
if (!invocation) return { tags };
if (invocation.thinking) tags.push(`thinking: ${invocation.thinking}`);
if (invocation.isolated) tags.push("isolated");
if (invocation.isolation === "worktree") tags.push("worktree");
if (invocation.inheritContext) tags.push("inherit context");
if (invocation.runInBackground) tags.push("background");
if (invocation.maxTurns != null) tags.push(`max turns: ${invocation.maxTurns}`);
return { modelName: invocation.modelName, tags };
}
/** Truncate text to a single line, max `len` chars. */
function truncateLine(text: string, len = 60): string {
const line = text.split("\n").find(l => l.trim())?.trim() ?? "";
if (line.length <= len) return line;
return line.slice(0, len) + "…";
}
/** Build a human-readable activity string from currently-running tools or response text. */
export function describeActivity(activeTools: Map<string, string>, responseText?: string): string {
if (activeTools.size > 0) {
const groups = new Map<string, number>();
for (const toolName of activeTools.values()) {
const action = TOOL_DISPLAY[toolName] ?? toolName;
groups.set(action, (groups.get(action) ?? 0) + 1);
}
const parts: string[] = [];
for (const [action, count] of groups) {
if (count > 1) {
parts.push(`${action} ${count} ${action === "searching" ? "patterns" : "files"}`);
} else {
parts.push(action);
}
}
return parts.join(", ") + "…";
}
// No tools active — show truncated response text if available
if (responseText && responseText.trim().length > 0) {
return truncateLine(responseText);
}
return "thinking…";
}
// ---- Widget manager ----
export class AgentWidget {
private uiCtx: UICtx | undefined;
private widgetFrame = 0;
private widgetInterval: ReturnType<typeof setInterval> | undefined;
/** Tracks how many turns each finished agent has survived. Key: agent ID, Value: turns since finished. */
private finishedTurnAge = new Map<string, number>();
/** How many extra turns errors/aborted agents linger (completed agents clear after 1 turn). */
private static readonly ERROR_LINGER_TURNS = 2;
/** Whether the widget callback is currently registered with the TUI. */
private widgetRegistered = false;
/** Cached TUI reference from widget factory callback, used for requestRender(). */
private tui: any | undefined;
/** Last status bar text, used to avoid redundant setStatus calls. */
private lastStatusText: string | undefined;
constructor(
private manager: AgentManager,
private agentActivity: Map<string, AgentActivity>,
/**
* Read live at render time. Selects which agents the widget shows — see
* `WidgetMode`. Defaults to `"all"` when a caller supplies no policy; the
* extension supplies one defaulting to `"background"`.
*/
private mode: () => WidgetMode = () => "all",
/**
* Read live at render time, like `mode`. Whether running agents show an
* estimated cost beside their token count. Defaults to off — the extension
* supplies the user's `showCost` setting.
*/
private showCost: () => boolean = () => false,
) {}
/**
* Agents eligible for the widget, per the current `WidgetMode`:
* - `off`: none (the widget's existing empty-state path hides it entirely).
* - `background`: drop only agents *known* to be foreground
* (`isBackground === false`); keep everything else — background, queued,
* scheduled, or RPC-spawned (`undefined`). Keying off the `isBackground`
* record flag rather than the UI-only `invocation` snapshot (which only the
* Agent-tool path sets), and excluding rather than allow-listing, means
* only proven-foreground runs drop out — nothing else silently vanishes.
* - `all`: every agent.
*/
private widgetAgents() {
const all = this.manager.listAgents().filter(a => !a.parentAgentId);
switch (this.mode()) {
case "off": return [];
case "background": return all.filter(a => a.isBackground !== false);
default: return all;
}
}
/** Set the UI context (grabbed from first tool execution). */
setUICtx(ctx: UICtx) {
if (ctx !== this.uiCtx) {
// UICtx changed — the widget registered on the old context is gone.
// Force re-registration on next update().
this.uiCtx = ctx;
this.widgetRegistered = false;
this.tui = undefined;
this.lastStatusText = undefined;
}
}
/**
* Called on each new turn (tool_execution_start).
* Ages finished agents and clears those that have lingered long enough.
*/
onTurnStart() {
// Age all finished agents
for (const [id, age] of this.finishedTurnAge) {
this.finishedTurnAge.set(id, age + 1);
}
// Trigger a widget refresh (will filter out expired agents)
this.update();
}
/** Ensure the widget update timer is running. */
ensureTimer() {
if (!this.widgetInterval) {
this.widgetInterval = setInterval(() => this.update(), 80);
}
}
/** Check if a finished agent should still be shown in the widget. */
private shouldShowFinished(agentId: string, status: string): boolean {
const age = this.finishedTurnAge.get(agentId) ?? 0;
const maxAge = ERROR_STATUSES.has(status) ? AgentWidget.ERROR_LINGER_TURNS : 1;
return age < maxAge;
}
/** Record an agent as finished (call when agent completes). */
markFinished(agentId: string) {
if (!this.finishedTurnAge.has(agentId)) {
this.finishedTurnAge.set(agentId, 0);
}
}
/**
* Drop an agent's finished-age (call when a settled agent starts running
* again, i.e. a background resume). markFinished only seeds an age it has not
* seen before, so a resumed agent would otherwise keep the age from its
* previous run — already past the linger limit, hiding the new run's
* completion line entirely.
*/
markRunning(agentId: string) {
this.finishedTurnAge.delete(agentId);
}
/** Render a finished agent line. */
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string; lifetimeUsage?: LifetimeUsage }, theme: Theme): string {
const modeLabel = getPromptModeLabel(a.type);
const duration = formatMs((a.completedAt ?? Date.now()) - a.startedAt);
let icon: string;
let statusText: string;
if (a.status === "completed") {
icon = theme.fg("success", "✓");
statusText = "";
} else if (a.status === "steered") {
icon = theme.fg("warning", "✓");
statusText = theme.fg("warning", " (turn limit)");
} else if (a.status === "stopped") {
icon = theme.fg("dim", "■");
statusText = theme.fg("dim", " stopped");
} else if (a.status === "error") {
icon = theme.fg("error", "✗");
const errMsg = a.error ? `: ${a.error.slice(0, 60)}` : "";
statusText = theme.fg("error", ` error${errMsg}`);
} else {
// aborted
icon = theme.fg("error", "✗");
statusText = theme.fg("warning", " aborted");
}
const parts: string[] = [];
const activity = this.agentActivity.get(a.id);
if (activity) parts.push(formatTurns(activity.turnCount, activity.maxTurns));
if (a.toolUses > 0) parts.push(`${a.toolUses} tool use${a.toolUses === 1 ? "" : "s"}`);
// From the record, not the activity tracker: that entry is deleted the
// moment an agent finishes, and "what did it cost" is a question asked
// about finished agents.
const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
if (costText) parts.push(costText);
parts.push(duration);
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
return `${icon} ${renderAgentName(a.type, theme, { fallbackColor: "dim" })}${modeTag} ${theme.fg("dim", a.description)} ${theme.fg("dim", "·")} ${theme.fg("dim", parts.join(" · "))}${statusText}`;
}
/**
* Render the widget content. Called from the registered widget's render() callback,
* reading live state each time instead of capturing it in a closure.
*/
private renderWidget(tui: any, theme: Theme): string[] {
const allAgents = this.widgetAgents();
const running = allAgents.filter(a => a.status === "running");
const queued = allAgents.filter(a => a.status === "queued");
const finished = allAgents.filter(a =>
a.status !== "running" && a.status !== "queued" && a.completedAt
&& this.shouldShowFinished(a.id, a.status),
);
const hasActive = running.length > 0 || queued.length > 0;
const hasFinished = finished.length > 0;
// Nothing to show — return empty (widget will be unregistered by update())
if (!hasActive && !hasFinished) return [];
const w = tui.terminal.columns;
const truncate = (line: string) => truncateToWidth(line, w);
const headingColor = hasActive ? "accent" : "dim";
const headingIcon = hasActive ? "●" : "○";
const frame = SPINNER[this.widgetFrame % SPINNER.length];
// Build sections separately for overflow-aware assembly.
// Each running agent = 2 lines (header + activity), finished = 1 line, queued = 1 line.
const finishedLines: string[] = [];
for (const a of finished) {
finishedLines.push(truncate(theme.fg("dim", "├─") + " " + this.renderFinishedLine(a, theme)));
}
const runningLines: string[][] = []; // each entry is [header, activity]
for (const a of running) {
const modeLabel = getPromptModeLabel(a.type);
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
const elapsed = formatMs(Date.now() - a.startedAt);
const bg = this.agentActivity.get(a.id);
const toolUses = bg?.toolUses ?? a.toolUses;
// Spend comes from the record, never from the activity tracker: the record
// is the one that survives the agent finishing, and the one nested-tools
// folds a hidden child's spend into. Reading the tracker while an agent
// runs and the record once it stops made the figure jump at completion.
const tokens = getLifetimeTotal(a.lifetimeUsage);
const contextPercent = getSessionContextPercent(bg?.session);
const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
const costText = this.showCost() ? formatCost(getLifetimeCost(a.lifetimeUsage)) : "";
const parts: string[] = [];
if (bg) parts.push(formatTurns(bg.turnCount, bg.maxTurns));
if (toolUses > 0) parts.push(`${toolUses} tool use${toolUses === 1 ? "" : "s"}`);
if (tokenText) parts.push(tokenText);
if (costText) parts.push(costText);
parts.push(elapsed);
const statsText = parts.join(" · ");
const activity = bg ? describeActivity(bg.activeTools, bg.responseText) : "thinking…";
runningLines.push([
truncate(theme.fg("dim", "├─") + ` ${theme.fg("accent", frame)} ${renderAgentName(a.type, theme, { bold: true })}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${fgPreservingNestedStyles(theme, "dim", statsText)}`),
truncate(theme.fg("dim", "│ ") + theme.fg("dim", `${activity}`)),
]);
}
const queuedLine = queued.length > 0
? truncate(theme.fg("dim", "├─") + ` ${theme.fg("muted", "◦")} ${theme.fg("dim", `${queued.length} queued`)}`)
: undefined;
// Assemble with overflow cap (heading + overflow indicator = 2 reserved lines).
const maxBody = MAX_WIDGET_LINES - 1; // heading takes 1 line
const totalBody = finishedLines.length + runningLines.length * 2 + (queuedLine ? 1 : 0);
const lines: string[] = [truncate(theme.fg(headingColor, headingIcon) + " " + theme.fg(headingColor, "Agents"))];
if (totalBody <= maxBody) {
// Everything fits — add all lines and fix up connectors for the last item.
lines.push(...finishedLines);
for (const pair of runningLines) lines.push(...pair);
if (queuedLine) lines.push(queuedLine);
// Fix last connector: swap ├─ → └─ and │ → space for activity lines.
if (lines.length > 1) {
const last = lines.length - 1;
lines[last] = lines[last].replace("├─", "└─");
// If last item is a running agent activity line, fix indent of that line
// and fix the header line above it.
if (runningLines.length > 0 && !queuedLine) {
// The last two lines are the last running agent's header + activity.
if (last >= 2) {
lines[last - 1] = lines[last - 1].replace("├─", "└─");
lines[last] = lines[last].replace("│ ", " ");
}
}
}
} else {
// Overflow — prioritize: running > queued > finished.
// Reserve 1 line for overflow indicator.
let budget = maxBody - 1;
let hiddenRunning = 0;
let hiddenFinished = 0;
// Reserve the queued line's row up front. It is a single summary of N
// waiting agents, so it cannot be folded into the "+N more" count (which
// is denominated in agents) without either under-reporting it as 1 or
// inflating the total with agents that were never getting their own rows.
// Reserving costs at most one running agent — which IS counted below —
// and makes the drop unreachable. It matters most exactly when it used to
// vanish: the pool is saturated and the queue is what the user needs to see.
const queuedReserve = queuedLine ? 1 : 0;
budget -= queuedReserve;
// 1. Running agents (2 lines each)
for (const pair of runningLines) {
if (budget >= 2) {
lines.push(...pair);
budget -= 2;
} else {
hiddenRunning++;
}
}
// 2. Queued line (always fits — its row was reserved above)
if (queuedLine) {
budget += queuedReserve;
lines.push(queuedLine);
budget--;
}
// 3. Finished agents
for (const fl of finishedLines) {
if (budget >= 1) {
lines.push(fl);
budget--;
} else {
hiddenFinished++;
}
}
// Overflow summary
const overflowParts: string[] = [];
if (hiddenRunning > 0) overflowParts.push(`${hiddenRunning} running`);
if (hiddenFinished > 0) overflowParts.push(`${hiddenFinished} finished`);
const overflowText = overflowParts.join(", ");
lines.push(truncate(theme.fg("dim", "└─") + ` ${theme.fg("dim", `+${hiddenRunning + hiddenFinished} more (${overflowText})`)}`)
);
}
return lines;
}
/** Force an immediate widget update. */
update() {
if (!this.uiCtx) return;
const allAgents = this.widgetAgents();
// Lightweight existence checks — full categorization happens in renderWidget()
let runningCount = 0;
let queuedCount = 0;
let hasFinished = false;
for (const a of allAgents) {
if (a.status === "running") { runningCount++; }
else if (a.status === "queued") { queuedCount++; }
else if (a.completedAt && this.shouldShowFinished(a.id, a.status)) { hasFinished = true; }
}
const hasActive = runningCount > 0 || queuedCount > 0;
// Nothing to show — clear widget
if (!hasActive && !hasFinished) {
if (this.widgetRegistered) {
this.uiCtx.setWidget("agents", undefined);
this.widgetRegistered = false;
this.tui = undefined;
}
if (this.lastStatusText !== undefined) {
this.uiCtx.setStatus("subagents", undefined);
this.lastStatusText = undefined;
}
if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = undefined; }
// Clean up stale entries
for (const [id] of this.finishedTurnAge) {
if (!allAgents.some(a => a.id === id)) this.finishedTurnAge.delete(id);
}
return;
}
// Status bar — only call setStatus when the text actually changes
let newStatusText: string | undefined;
if (hasActive) {
const statusParts: string[] = [];
if (runningCount > 0) statusParts.push(`${runningCount} running`);
if (queuedCount > 0) statusParts.push(`${queuedCount} queued`);
const total = runningCount + queuedCount;
newStatusText = `${statusParts.join(", ")} agent${total === 1 ? "" : "s"}`;
}
if (newStatusText !== this.lastStatusText) {
this.uiCtx.setStatus("subagents", newStatusText);
this.lastStatusText = newStatusText;
}
this.widgetFrame++;
// Register widget callback once; subsequent updates use requestRender()
// which re-invokes render() without replacing the component (avoids layout thrashing).
if (!this.widgetRegistered) {
this.uiCtx.setWidget("agents", (tui, theme) => {
this.tui = tui;
return {
render: () => this.renderWidget(tui, theme),
invalidate: () => {
// Theme changed — force re-registration so factory captures fresh theme.
this.widgetRegistered = false;
this.tui = undefined;
},
};
}, { placement: "aboveEditor" });
this.widgetRegistered = true;
} else {
// Widget already registered — just request a re-render of existing components.
this.tui?.requestRender();
}
}
dispose() {
if (this.widgetInterval) {
clearInterval(this.widgetInterval);
this.widgetInterval = undefined;
}
if (this.uiCtx) {
this.uiCtx.setWidget("agents", undefined);
this.uiCtx.setStatus("subagents", undefined);
}
this.widgetRegistered = false;
this.tui = undefined;
this.lastStatusText = undefined;
}
}
+373
View File
@@ -0,0 +1,373 @@
/**
* conversation-viewer.ts — Live conversation overlay for viewing agent sessions.
*
* Displays a scrollable, live-updating view of an agent's conversation.
* Subscribes to session events for real-time streaming updates.
*/
import type { AgentSession } from "@earendil-works/pi-coding-agent";
import { type Component, Input, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
import { renderAgentName } from "../agent-color.js";
import { extractText } from "../context.js";
import type { AgentRecord } from "../types.js";
import { getLifetimeCost, getLifetimeTotal, getSessionContextPercent } from "../usage.js";
import type { Theme } from "./agent-widget.js";
import { type AgentActivity, buildInvocationTags, describeActivity, fgPreservingNestedStyles, formatCost, formatDuration, formatSessionTokens, getPromptModeLabel } from "./agent-widget.js";
import { createViewerKeys, type ViewerKeybindings, type ViewerKeys } from "./viewer-keys.js";
/** Base lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
const CHROME_LINES_BASE = 6;
const MIN_VIEWPORT = 3;
/** Height ceiling shared by the overlay's `maxHeight` and the viewer's internal viewport cap. */
export const VIEWPORT_HEIGHT_PCT = 70;
export class ConversationViewer implements Component {
private scrollOffset = 0;
private autoScroll = true;
private unsubscribe: (() => void) | undefined;
private lastInnerW = 0;
private closed = false;
/** Two-press confirm guard for the stop key, so a stray key can't kill the agent. */
private stopArmed = false;
private keys: ViewerKeys;
/** Steering composer — present while the user is typing a message to the agent. */
private composer: Input | undefined;
constructor(
private tui: TUI,
private session: AgentSession,
private record: AgentRecord,
private activity: AgentActivity | undefined,
private theme: Theme,
private done: (result: undefined) => void,
/** Abort the agent shown here. Omitted → no stop affordance (e.g. read-only history). */
private onStop?: () => void,
/** User keybindings from `ctx.ui.custom()`. Omitted → hardcoded defaults. */
keybindings?: ViewerKeybindings,
/** Send a steering message to the agent. Omitted → no compose affordance. */
private onSteer?: (message: string) => void,
/**
* Whether the header shows an estimated cost after the token count. Read
* once, at construction: the overlay is opened from a menu, so the setting
* cannot change while it is on screen.
*/
private showCost = false,
) {
this.keys = createViewerKeys(keybindings);
this.unsubscribe = session.subscribe(() => {
if (this.closed) return;
this.tui.requestRender();
});
}
handleInput(data: string): void {
// While composing a steer message, the input owns all keys (Enter sends,
// Esc cancels — both wired in openComposer()). Editing keys flow through.
if (this.composer) {
this.composer.handleInput(data);
this.tui.requestRender();
return;
}
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
this.closed = true;
this.done(undefined);
return;
}
// Enter opens the steering composer (only while the agent can still be
// steered) — then type + Enter sends, Esc or an empty submit returns. When
// not steerable, fall through so the key still disarms a pending stop.
if (matchesKey(data, "enter") && this.canSteer()) {
this.stopArmed = false;
this.openComposer();
return;
}
// Stop/abort the agent (only while it can still be stopped). Two-press:
// first "x" arms, second confirms — any other key disarms.
if (matchesKey(data, "x")) {
if (this.isStoppable()) {
if (this.stopArmed) {
this.stopArmed = false;
this.onStop?.();
} else {
this.stopArmed = true;
}
this.tui.requestRender();
}
return;
}
if (this.stopArmed) this.stopArmed = false;
const totalLines = this.buildContentLines(this.lastInnerW).length;
const viewportHeight = this.viewportHeight();
const maxScroll = Math.max(0, totalLines - viewportHeight);
if (this.keys.scrollUp(data)) {
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (this.keys.scrollDown(data)) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (this.keys.pageUp(data)) {
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
this.autoScroll = false;
} else if (this.keys.pageDown(data)) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + viewportHeight);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (matchesKey(data, "home")) {
this.scrollOffset = 0;
this.autoScroll = false;
} else if (matchesKey(data, "end")) {
this.scrollOffset = maxScroll;
this.autoScroll = true;
}
}
render(width: number): string[] {
if (width < 6) return []; // too narrow for any meaningful rendering
const th = this.theme;
const innerW = width - 4; // border + padding
this.lastInnerW = innerW;
const lines: string[] = [];
const pad = (s: string, len: number) => {
const vis = visibleWidth(s);
return s + " ".repeat(Math.max(0, len - vis));
};
const row = (content: string) =>
th.fg("border", "│") + " " + truncateToWidth(pad(content, innerW), innerW, "...", true) + " " + th.fg("border", "│");
const hrTop = th.fg("border", `${"─".repeat(width - 2)}`);
const hrBot = th.fg("border", `${"─".repeat(width - 2)}`);
const hrMid = row(th.fg("dim", "─".repeat(innerW)));
// Header
lines.push(hrTop);
const modeLabel = getPromptModeLabel(this.record.type);
const modeTag = modeLabel ? ` ${th.fg("dim", `(${modeLabel})`)}` : "";
const statusIcon = this.record.status === "running"
? th.fg("accent", "●")
: this.record.status === "completed"
? th.fg("success", "✓")
: this.record.status === "error"
? th.fg("error", "✗")
: th.fg("dim", "○");
const duration = formatDuration(this.record.startedAt, this.record.completedAt);
const headerParts: string[] = [duration];
const toolUses = this.activity?.toolUses ?? this.record.toolUses;
if (toolUses > 0) headerParts.unshift(`${toolUses} tool${toolUses === 1 ? "" : "s"}`);
// Spend from the record, context from the live session: the record is the
// only total that survives the agent finishing and the only one carrying a
// nested child's spend.
const tokens = getLifetimeTotal(this.record.lifetimeUsage);
if (tokens > 0) {
const percent = getSessionContextPercent(this.activity?.session);
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
}
const cost = this.showCost ? formatCost(getLifetimeCost(this.record.lifetimeUsage)) : "";
if (cost) headerParts.push(cost);
lines.push(row(
`${statusIcon} ${renderAgentName(this.record.type, th, { bold: true })}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${fgPreservingNestedStyles(th, "dim", headerParts.join(" · "))}`,
));
const invocationLine = this.invocationLine();
if (invocationLine) lines.push(row(invocationLine));
lines.push(hrMid);
// Content area — rebuild every render (live data, no cache needed)
const contentLines = this.buildContentLines(innerW);
const viewportHeight = this.viewportHeight();
const maxScroll = Math.max(0, contentLines.length - viewportHeight);
if (this.autoScroll) {
this.scrollOffset = maxScroll;
}
const visibleStart = Math.min(this.scrollOffset, maxScroll);
const visible = contentLines.slice(visibleStart, visibleStart + viewportHeight);
for (let i = 0; i < viewportHeight; i++) {
lines.push(row(visible[i] ?? ""));
}
// Footer
lines.push(hrMid);
if (this.composer) {
// Composer row: the Input renders its own `> ` prompt and cursor.
lines.push(row(this.composer.render(innerW)[0] ?? ""));
const composeHint = th.fg("dim", "Enter send · Esc cancel");
const composeLeft = th.fg("accent", "✎ steer");
const composeGap = Math.max(1, innerW - visibleWidth(composeLeft) - visibleWidth(composeHint));
lines.push(row(composeLeft + " ".repeat(composeGap) + composeHint));
} else {
// Actions on the left, navigation on the right. The scroll hint keeps its
// full key list so the less-obvious bindings stay discoverable; it leads
// the right group so "Esc close" is the only part that truncates first.
const sep = th.fg("dim", " · ");
const actions: string[] = [];
if (this.canSteer()) actions.push(th.fg("dim", "Enter steer"));
if (this.isStoppable()) {
actions.push(this.stopArmed ? th.fg("error", "x again to STOP") : th.fg("dim", "x stop"));
}
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn or Shift+↑↓ · Esc close");
// Prepend the line-count/scroll-% readout only when there's spare width —
// it's the first thing dropped so it never crowds out the hints.
const scrollPct = contentLines.length <= viewportHeight
? "100%"
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
const count = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
const withCount = [count, ...actions].join(sep);
const footerLeft = visibleWidth(withCount) + visibleWidth(footerRight) + 1 <= innerW
? withCount
: actions.join(sep);
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
}
lines.push(hrBot);
return lines;
}
/** Stoppable only when a stop handler exists and the agent is still active. */
private isStoppable(): boolean {
return !!this.onStop && (this.record.status === "running" || this.record.status === "queued");
}
/** Steerable only when a steer handler exists and the agent is still active. */
private canSteer(): boolean {
return !!this.onSteer && (this.record.status === "running" || this.record.status === "queued");
}
/** Open the inline steering composer and route subsequent input to it. */
private openComposer(): void {
const input = new Input();
input.focused = true;
input.onSubmit = (value: string) => {
const message = value.trim();
this.composer = undefined;
if (message) this.onSteer?.(message);
this.tui.requestRender();
};
input.onEscape = () => {
this.composer = undefined;
this.tui.requestRender();
};
this.composer = input;
this.tui.requestRender();
}
invalidate(): void { /* no cached state to clear */ }
dispose(): void {
this.closed = true;
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = undefined;
}
}
// ---- Private ----
private viewportHeight(): number {
// Cap mirrors the overlay's maxHeight — otherwise the viewer would render
// more lines than the overlay shows and clip the footer.
const maxRows = Math.floor((this.tui.terminal.rows * VIEWPORT_HEIGHT_PCT) / 100);
return Math.max(MIN_VIEWPORT, maxRows - this.chromeLines());
}
private chromeLines(): number {
// The composer adds one row above the footer hint while it's open.
return CHROME_LINES_BASE + (this.invocationLine() ? 1 : 0) + (this.composer ? 1 : 0);
}
private invocationLine(): string | undefined {
const { modelName, tags } = buildInvocationTags(this.record.invocation);
const parts = modelName ? [modelName, ...tags] : tags;
if (parts.length === 0) return undefined;
return this.theme.fg("dim", `${parts.join(" · ")}`);
}
private buildContentLines(width: number): string[] {
if (width <= 0) return [];
const th = this.theme;
const messages = this.session.messages;
const lines: string[] = [];
if (messages.length === 0) {
lines.push(th.fg("dim", "(waiting for first message...)"));
return lines;
}
let needsSeparator = false;
for (const msg of messages) {
if (msg.role === "user") {
const text = typeof msg.content === "string"
? msg.content
: extractText(msg.content);
if (!text.trim()) continue;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.fg("accent", "[User]"));
for (const line of wrapTextWithAnsi(text.trim(), width)) {
lines.push(line);
}
} else if (msg.role === "assistant") {
const textParts: string[] = [];
const toolCalls: string[] = [];
for (const c of msg.content) {
if (c.type === "text" && c.text) textParts.push(c.text);
else if (c.type === "toolCall") {
toolCalls.push((c as any).name ?? (c as any).toolName ?? "unknown");
}
}
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.bold("[Assistant]"));
if (textParts.length > 0) {
for (const line of wrapTextWithAnsi(textParts.join("\n").trim(), width)) {
lines.push(line);
}
}
for (const name of toolCalls) {
lines.push(truncateToWidth(th.fg("muted", ` [Tool: ${name}]`), width));
}
} else if (msg.role === "toolResult") {
const text = extractText(msg.content);
const truncated = text.length > 500 ? text.slice(0, 500) + "... (truncated)" : text;
if (!truncated.trim()) continue;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(th.fg("dim", "[Result]"));
for (const line of wrapTextWithAnsi(truncated.trim(), width)) {
lines.push(th.fg("dim", line));
}
} else if ((msg as any).role === "bashExecution") {
const bash = msg as any;
if (needsSeparator) lines.push(th.fg("dim", "───"));
lines.push(truncateToWidth(th.fg("muted", ` $ ${bash.command}`), width));
if (bash.output?.trim()) {
const out = bash.output.length > 500
? bash.output.slice(0, 500) + "... (truncated)"
: bash.output;
for (const line of wrapTextWithAnsi(out.trim(), width)) {
lines.push(th.fg("dim", line));
}
}
} else {
continue;
}
needsSeparator = true;
}
// Streaming indicator for running agents
if (this.record.status === "running" && this.activity) {
const act = describeActivity(this.activity.activeTools, this.activity.responseText);
lines.push("");
lines.push(truncateToWidth(th.fg("accent", "▍ ") + th.fg("dim", act), width));
}
return lines.map(l => truncateToWidth(l, width));
}
}
+402
View File
@@ -0,0 +1,402 @@
/**
* 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 { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
import { hasAgentBadge, renderAgentName } from "../agent-color.js";
import type { AgentManager } from "../agent-manager.js";
import type { AgentRecord } from "../types.js";
import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
import { type AgentActivity, formatCost, 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<T>(
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<T>;
};
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<typeof setInterval> | 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<string, AgentActivity>,
/**
* Read live at render time. Whether each row shows an estimated cost after
* its token count. Defaults to off — the extension supplies the user's
* `showCost` setting.
*/
private showCost: () => boolean = () => false,
) {}
// ---- 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.parentAgentId && 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;
// Input listeners fire BEFORE the focused component, and dialogs
// (ctx.ui.select/confirm/input, pi's own menus) swap the prompt editor out
// while getEditorText() still reads the detached — empty — editor. So when
// anything but the editor owns the keyboard, stay out of its keys (#123).
if (!this.editorHasFocus()) {
if (this.active) this.deactivate();
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;
}
/**
* True when pi's prompt editor owns the keyboard. pi's editor is an `Editor`
* subclass (CustomEditor) while every dialog/selector is not, and the loader
* aliases pi-tui to pi's own copy, so `instanceof` is a reliable identity
* check. `focusedComponent` is TUI-private (no public accessor), hence the
* best-effort peek: unknowable focus (no tui seen yet, nothing focused)
* counts as the editor so activation keeps working.
*/
private editorHasFocus(): boolean {
const focused = (this.tui as { focusedComponent?: unknown } | undefined)?.focusedComponent;
return focused == null || focused instanceof Editor;
}
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<undefined>(
(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,
(message: string) => this.manager.steer(record.id, message),
this.showCost(),
);
},
{
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 {
// The selected row renders in the theme's primary text color so it reads as
// one selection (#230). A configured badge survives — Claude Code's FleetView
// keeps the agent color on the selected row too and only bolds it — which also
// keeps the row's width fixed as the selection moves.
const selected = rosterIndex === sel;
const name = renderAgentName(record.type, theme, selected
? { fallbackColor: "text", bold: hasAgentBadge(record.type) }
: { fallbackColor: "muted" });
const description = selected ? theme.fg("text", record.description) : record.description;
const left = ` ${this.bullet(rosterIndex, sel, theme)} ${name} ${description}`;
// The record, not the activity tracker — see the note in AgentWidget's
// running line: only the record carries a nested child's spend, and only it
// outlives the agent.
const tokens = getLifetimeTotal(record.lifetimeUsage);
const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished
const cost = this.showCost() ? formatCost(getLifetimeCost(record.lifetimeUsage)) : "";
const stats = `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}${cost ? ` · ${cost}` : ""}`;
const right = selected ? theme.fg("text", stats) : theme.fg("dim", stats);
return rightAlign(left, right, width);
}
}
+105
View File
@@ -0,0 +1,105 @@
/**
* schedule-menu.ts — `/agents → Scheduled jobs` submenu.
*
* Minimal v1 surface: list scheduled jobs, select one to inspect details +
* confirm cancellation. No create wizard (the `Agent` tool's `schedule` param
* is the canonical creation path), no toggle/cleanup (cancel is enough for
* "I scheduled something dumb, get rid of it"). Add management surfaces here
* if real demand emerges.
*/
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
import type { SubagentScheduler } from "../schedule.js";
import type { ScheduledSubagent } from "../types.js";
import { selectItem } from "./select-item.js";
/** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
function relTime(iso: string | undefined, now = Date.now()): string {
if (!iso) return "—";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return "—";
const diff = t - now;
const abs = Math.abs(diff);
const future = diff > 0;
if (abs < 60_000) return future ? "in <1m" : "<1m ago";
const m = Math.round(abs / 60_000);
if (m < 60) return future ? `in ${m}m` : `${m}m ago`;
const h = Math.round(abs / 3_600_000);
if (h < 24) return future ? `in ${h}h` : `${h}h ago`;
const d = Math.round(abs / 86_400_000);
return future ? `in ${d}d` : `${d}d ago`;
}
/** One-line status icon. */
function statusIcon(j: ScheduledSubagent): string {
if (!j.enabled) return "✗";
if (j.lastStatus === "error") return "!";
if (j.lastStatus === "running") return "⋯";
return "✓";
}
/** Compact selectable row — name, schedule, agent type, next/last run, count. */
function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
const next = scheduler.getNextRun(j.id);
return [
statusIcon(j),
j.name.padEnd(18).slice(0, 18),
j.schedule.padEnd(14).slice(0, 14),
`[${j.subagent_type}]`,
`next ${relTime(next)}`,
`last ${relTime(j.lastRun)}`,
`runs ${j.runCount}`,
].join(" ");
}
/** Multi-line details block for the cancel confirm. */
function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
const next = scheduler.getNextRun(j.id) ?? "—";
return [
`name: ${j.name}`,
`schedule: ${j.schedule} (${j.scheduleType})`,
`agent: ${j.subagent_type}`,
`prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "…" : ""}`,
`created: ${j.createdAt}`,
`last run: ${j.lastRun ?? "—"} (${j.lastStatus ?? "—"})`,
`next run: ${next}`,
`runs: ${j.runCount}`,
].join("\n");
}
/**
* List scheduled jobs; selecting one opens a cancel-confirm with details.
* Returns when the user backs out or after a cancellation.
*/
export async function showSchedulesMenu(
ctx: ExtensionCommandContext,
scheduler: SubagentScheduler,
): Promise<void> {
if (!scheduler.isActive()) {
ctx.ui.notify("Scheduler is not active in this session.", "warning");
return;
}
const jobs = scheduler.list();
if (jobs.length === 0) {
ctx.ui.notify("No scheduled jobs.", "info");
return;
}
// Numbered + item-paired: two jobs whose names agree in the first 18
// characters format identically, and matching the returned string back
// against a parallel label array cancelled whichever came first.
const job = await selectItem(
ctx.ui,
`Scheduled jobs (${jobs.length}) — select to cancel`,
jobs,
j => formatJob(j, scheduler),
);
if (!job) return;
const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
if (!ok) return;
scheduler.removeJob(job.id);
ctx.ui.notify(`Cancelled "${job.name}".`, "info");
}
+45
View File
@@ -0,0 +1,45 @@
/**
* select-item.ts — pick an item from a list via `ctx.ui.select`, safely.
*
* Pi's dialog API is `select(title, options: string[]) => Promise<string | undefined>`:
* strings in, string out, with no index or value form. Callers therefore have to
* map the returned string back to the item it came from, and the obvious way —
* `labels.indexOf(choice)` over a parallel array — silently resolves to the
* FIRST match whenever two rows format identically. Row formatters here truncate
* (job names to 18 chars, agent descriptions to whatever fits), and the text they
* truncate is LLM-authored, so collisions are ordinary rather than exotic.
*
* This numbers every row, which makes the labels unique by construction — no
* data-dependent branch that only executes in the case nobody exercises — and
* keeps each label paired with its item so a later edit that sorts or filters
* between building and resolving cannot desync them.
*/
/** Minimal shape of the `ctx.ui` surface this needs. */
export interface SelectUI {
select(title: string, options: string[]): Promise<string | undefined>;
}
/**
* Show a numbered picker and return the chosen item (not its label).
*
* Returns undefined when the user escapes, or when the returned string is not
* one we offered.
*/
export async function selectItem<T>(
ui: SelectUI,
title: string,
items: readonly T[],
format: (item: T, index: number) => string,
): Promise<T | undefined> {
// Pad the number so a 10+ item list keeps its columns aligned.
const width = String(items.length).length;
const rows = items.map((item, i) => ({
item,
label: `${String(i + 1).padStart(width)}. ${format(item, i)}`,
}));
const choice = await ui.select(title, rows.map(r => r.label));
if (!choice) return undefined;
return rows.find(r => r.label === choice)?.item;
}
+39
View File
@@ -0,0 +1,39 @@
/**
* viewer-keys.ts — Scroll key matchers for the conversation viewer.
*
* Resolves `tui.select.*` through the user's keybindings when pi provides a
* manager, falling back to the previous hardcoded keys otherwise. The viewer's
* k/j and shift+arrow aliases always work alongside whatever is bound.
*/
import { type KeyId, matchesKey } from "@earendil-works/pi-tui";
/** The `tui.select.*` keybinding ids the viewer resolves. */
export type ViewerScrollKeybinding =
| "tui.select.up"
| "tui.select.down"
| "tui.select.pageUp"
| "tui.select.pageDown";
/** Structural subset of pi-tui's `KeybindingsManager` (which satisfies it). */
export interface ViewerKeybindings {
matches(data: string, keybinding: ViewerScrollKeybinding): boolean;
}
export interface ViewerKeys {
scrollUp(data: string): boolean;
scrollDown(data: string): boolean;
pageUp(data: string): boolean;
pageDown(data: string): boolean;
}
export function createViewerKeys(keybindings?: ViewerKeybindings): ViewerKeys {
const matches = (data: string, id: ViewerScrollKeybinding, fallback: KeyId): boolean =>
keybindings ? keybindings.matches(data, id) : matchesKey(data, fallback);
return {
scrollUp: (data) => matches(data, "tui.select.up", "up") || matchesKey(data, "k"),
scrollDown: (data) => matches(data, "tui.select.down", "down") || matchesKey(data, "j"),
pageUp: (data) => matches(data, "tui.select.pageUp", "pageUp") || matchesKey(data, "shift+up"),
pageDown: (data) => matches(data, "tui.select.pageDown", "pageDown") || matchesKey(data, "shift+down"),
};
}
+167
View File
@@ -0,0 +1,167 @@
/** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
/**
* Lifetime usage components, accumulated via `message_end` events. Survives
* compaction (which replaces session.state.messages and would reset any
* stats-derived sum). cacheRead is excluded because each turn's cacheRead is
* the cumulative cached prefix re-read on that one call — summing across
* turns counts the prefix N times. See issue #38.
*
* That exclusion is about this *display* total, not about what was billed: the
* prefix really is re-read and re-charged on every call. So `cacheRead` is
* accumulated here anyway, kept out of `getLifetimeTotal` and used only where
* billing is the question — reporting to the parent session, whose own messages
* pi counts the same way (`addUsageToTotals`). Reporting 0 there would make a
* subagent's rows count differently from every other row in one total.
*
* `cost` is a plain sum for the same reason: it is what pi charged for that one
* message (`usage.cost.total`, priced from the model's rates), not a cumulative
* figure. Both are optional because a model with no pricing data reports no
* cost, and because every accumulator predates them; absent reads as 0.
*/
export type LifetimeUsage = { input: number; output: number; cacheWrite: number; cacheRead?: number; cost?: number };
/**
* Sum of lifetime *token* components for DISPLAY, or 0 if undefined.
* Deliberately excludes `cacheRead` (see above) and `cost` — that is money, not
* tokens, and lives on the same object only because it accumulates on the same
* events.
*/
export function getLifetimeTotal(u?: LifetimeUsage): number {
return u ? u.input + u.output + u.cacheWrite : 0;
}
/** Accumulated cost in USD, or 0 when unpriced/undefined. */
export function getLifetimeCost(u?: LifetimeUsage): number {
return u?.cost ?? 0;
}
/** Add a usage delta into a target accumulator (mutates target). */
export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
into.input += delta.input;
into.output += delta.output;
into.cacheWrite += delta.cacheWrite;
if (delta.cacheRead) into.cacheRead = (into.cacheRead ?? 0) + delta.cacheRead;
if (delta.cost) into.cost = (into.cost ?? 0) + delta.cost;
}
/**
* A pi `Usage`. Rebuilt here rather than imported so this module stays
* dependency-free for tests; the fields are pi's, and every one of them must be
* present: pi's `addUsageToTotals` dereferences `usage.cost.total` with no
* guard, so a partial object throws inside pi rather than at the call site.
*
* This is pi's convention for spend in anything handed to a consumer — every
* extension-facing payload that carries it takes the whole object
* (`ToolResultEvent`, `ToolResultEventResult`, `AssistantMessage`, …), never a
* flattened cost. Pi flattens only in computed read APIs it expects you to
* render, like `SessionStats`. So both places we hand usage to someone else —
* `AgentToolResult.usage` and the `subagents:completed` / `subagents:failed`
* events — carry this, and gain whatever pi adds to `Usage` for free.
*/
export type ReportedUsage = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: { input: number; output: number; cacheRead: number; cacheWrite: number; total: number };
};
/**
* Render an accumulator as a pi `Usage`, or undefined when nothing was spent —
* callers attach nothing rather than a zero, so a consumer can tell "spent
* nothing" from "never ran".
*
* `cacheRead` IS included, unlike in `getLifetimeTotal`: pi sums it across a
* session's own assistant messages, and the prefix genuinely is re-read and
* re-billed on every call. Only `total` is populated on the cost breakdown; pi
* reads nothing else from it, and the per-kind split is not tracked.
*/
export function toReportedUsage(u: LifetimeUsage): ReportedUsage | undefined {
const { input, output, cacheWrite, cacheRead = 0, cost = 0 } = u;
if (input === 0 && output === 0 && cacheWrite === 0 && cacheRead === 0 && cost === 0) return undefined;
return {
input,
output,
cacheRead,
cacheWrite,
totalTokens: input + output + cacheRead + cacheWrite,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: cost },
};
}
/**
* Subagent spend that the parent session has not been told about yet.
*
* Subagents run in their own pi sessions, so none of what they spend appears in
* the parent's `getSessionStats()`. Pi does aggregate `toolResult.usage` into
* those stats, though — so the way back into the parent's footer and `/cost` is
* to hang the spend on a tool result. Background and scheduled agents finish
* between tool calls with nothing to hang it on, hence a pool: every assistant
* message lands here as it happens, and the next tool result we return carries
* whatever has accumulated.
*
* Drain empties it, so each message is reported exactly once no matter how many
* results are returned or how many agents were running.
*/
export class PendingUsagePool {
private pending: LifetimeUsage = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 };
private dirty = false;
add(delta: LifetimeUsage): void {
addUsage(this.pending, delta);
this.dirty = true;
}
/**
* Take everything accumulated so far as a pi `Usage`, resetting the pool.
* Returns undefined when nothing is pending, so callers can leave the tool
* result untouched rather than attaching a zero.
*/
drain(): ReportedUsage | undefined {
if (!this.dirty) return undefined;
const drained = toReportedUsage(this.pending);
this.pending = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 };
this.dirty = false;
return drained;
}
}
/** Minimal shape we read from upstream `getSessionStats()`. */
export type SessionStatsLike = {
tokens: { input: number; output: number; cacheWrite: number };
contextUsage?: { percent: number | null };
};
export type SessionLike = { getSessionStats(): SessionStatsLike };
/**
* Session-scoped token count: input + output + cacheWrite as reported by
* upstream `getSessionStats().tokens` for the *current* session window.
*
* RESETS at compaction — upstream replaces `session.state.messages` and the
* stats are derived from that array. For a lifetime total that survives
* compaction, use `getLifetimeTotal(lifetimeUsage)` instead, which reads
* from an independent accumulator fed by `message_end` events.
*
* Avoids upstream's `tokens.total` field, which sums per-turn `cacheRead`
* and so counts the cumulative cached prefix N times across N turns
* (issue #38).
*/
export function getSessionTokens(session: SessionLike | undefined): number {
if (!session) return 0;
try {
const t = session.getSessionStats().tokens;
return t.input + t.output + t.cacheWrite;
} catch { return 0; }
}
/**
* Context-window utilization (0100), or null when unavailable
* (no model contextWindow, or post-compaction before the next response).
*/
export function getSessionContextPercent(session: SessionLike | undefined): number | null {
if (!session) return null;
try { return session.getSessionStats().contextUsage?.percent ?? null; }
catch { return null; }
}
+211
View File
@@ -0,0 +1,211 @@
/**
* worktree.ts — Git worktree isolation for agents.
*
* Creates a temporary git worktree so the agent works on an isolated copy of the repo.
* On completion, if no changes were made, the worktree is cleaned up.
* If changes exist, a branch is created and returned in the result.
*/
import { execFileSync } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
export interface WorktreeInfo {
/** Absolute path to the worktree directory (the copied repo's root). */
path: string;
/** Branch name created for this worktree (if changes exist). */
branch: string;
/** Commit SHA that the worktree was created from. */
baseSha: string;
/**
* Where the agent should work inside the worktree: the equivalent of the
* cwd the worktree was created from. Equals `path` when that cwd was the
* repo root; points at the copied subdirectory when it was deeper (e.g. a
* monorepo package), so the requested scoping survives isolation.
*/
workPath: string;
}
/**
* Project-wide switch for worktree isolation (`worktreeIsolation` in
* subagents.json). Default `true` — unchanged behaviour.
*
* The `"off"` isolation value gives a model a legal way to decline a worktree,
* but it still depends on the model choosing it. This is the deterministic half
* of the same fix: on a large repo where every worktree costs real time and
* disk (#184), turning it off means no caller can create one, whatever it
* passes.
*/
let worktreeIsolationEnabled = true;
export function setWorktreeIsolationEnabled(enabled: boolean): void {
worktreeIsolationEnabled = enabled;
}
export function isWorktreeIsolationEnabled(): boolean {
return worktreeIsolationEnabled;
}
export interface WorktreeCleanupResult {
/** Whether changes were found in the worktree. */
hasChanges: boolean;
/** Branch name if changes were committed. */
branch?: string;
/** Worktree path if it was kept. */
path?: string;
}
/**
* Create a temporary git worktree for an agent.
* Returns the worktree path, or undefined if not in a git repo.
*/
export function createWorktree(cwd: string, agentId: string): WorktreeInfo | undefined {
// Verify we're in a git repo with at least one commit (HEAD must exist)
let baseSha: string;
let subdir: string;
try {
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
baseSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 })
.toString()
.trim();
// Where cwd sits inside the repo ("" at the root): the agent must work at
// the same subdirectory inside the copy, or a monorepo-package cwd would
// silently widen to the whole repo. realpath both sides — git emits
// resolved paths while cwd may arrive through a symlink (macOS /tmp).
const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { cwd, stdio: "pipe", timeout: 5000 })
.toString()
.trim();
subdir = relative(realpathSync(topLevel), realpathSync(cwd));
} catch {
return undefined;
}
const branch = `pi-agent-${agentId}`;
const suffix = randomUUID().slice(0, 8);
const worktreePath = join(tmpdir(), `pi-agent-${agentId}-${suffix}`);
try {
// Create detached worktree at HEAD
execFileSync("git", ["worktree", "add", "--detach", worktreePath, "HEAD"], {
cwd,
stdio: "pipe",
timeout: 30000,
});
return { path: worktreePath, branch, baseSha, workPath: subdir ? join(worktreePath, subdir) : worktreePath };
} catch {
// If worktree creation fails, return undefined (agent runs in normal cwd)
return undefined;
}
}
/**
* Clean up a worktree after agent completion.
* - If no changes: remove worktree entirely.
* - If changes exist: create a branch, commit changes, return branch info.
*/
export function cleanupWorktree(
cwd: string,
worktree: WorktreeInfo,
agentDescription: string,
): WorktreeCleanupResult {
if (!existsSync(worktree.path)) {
return { hasChanges: false };
}
try {
// Check for uncommitted changes in the worktree
const status = execFileSync("git", ["status", "--porcelain"], {
cwd: worktree.path,
stdio: "pipe",
timeout: 10000,
}).toString().trim();
if (status) {
// Changes exist — stage, commit, and create a branch
execFileSync("git", ["add", "-A"], { cwd: worktree.path, stdio: "pipe", timeout: 10000 });
// Truncate description for commit message (no shell sanitization needed — execFileSync uses argv)
const safeDesc = agentDescription.slice(0, 200);
const commitMsg = `pi-agent: ${safeDesc}`;
execFileSync("git", ["commit", "--no-verify", "-m", commitMsg], {
cwd: worktree.path,
stdio: "pipe",
timeout: 10000,
});
} else {
const currentSha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: worktree.path,
stdio: "pipe",
timeout: 5000,
}).toString().trim();
if (currentSha === worktree.baseSha) {
// No changes — remove worktree
removeWorktree(cwd, worktree.path);
return { hasChanges: false };
}
}
// Create a branch pointing to the worktree's HEAD.
// If the branch already exists, append a suffix to avoid overwriting previous work.
let branchName = worktree.branch;
try {
execFileSync("git", ["branch", branchName], {
cwd: worktree.path,
stdio: "pipe",
timeout: 5000,
});
} catch {
// Branch already exists — use a unique suffix
branchName = `${worktree.branch}-${Date.now()}`;
execFileSync("git", ["branch", branchName], {
cwd: worktree.path,
stdio: "pipe",
timeout: 5000,
});
}
// Update branch name in worktree info for the caller
worktree.branch = branchName;
// Remove the worktree (branch persists in main repo)
removeWorktree(cwd, worktree.path);
return {
hasChanges: true,
branch: worktree.branch,
path: worktree.path,
};
} catch {
// Best effort cleanup on error
try { removeWorktree(cwd, worktree.path); } catch { /* ignore */ }
return { hasChanges: false };
}
}
/**
* Force-remove a worktree.
*/
function removeWorktree(cwd: string, worktreePath: string): void {
try {
execFileSync("git", ["worktree", "remove", "--force", worktreePath], {
cwd,
stdio: "pipe",
timeout: 10000,
});
} catch {
// If git worktree remove fails, try pruning
try {
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
} catch { /* ignore */ }
}
}
/**
* Prune any orphaned worktrees (crash recovery).
*/
export function pruneWorktrees(cwd: string): void {
try {
execFileSync("git", ["worktree", "prune"], { cwd, stdio: "pipe", timeout: 5000 });
} catch { /* ignore */ }
}