mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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}"`,
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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.`;
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}` : "";
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, 70–85% 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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
};
|
||||
}
|
||||
@@ -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 (0–100), 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; }
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user