Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom

This commit is contained in:
2026-05-08 15:59:25 +10:00
parent d0d1d9b045
commit 31b4110c87
457 changed files with 85157 additions and 0 deletions

View File

@@ -0,0 +1,479 @@
/**
* agent-manager.ts — Tracks agents, background execution, resume support.
*
* Background agents are subject to a configurable concurrency limit (default: 4).
* Excess agents are queued and auto-started as running agents complete.
* Foreground agents bypass the queue (they block the parent anyway).
*/
import { randomUUID } from "node:crypto";
import type { Model } from "@mariozechner/pi-ai";
import type { AgentSession, ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { resumeAgent, runAgent, type ToolActivity } from "./agent-runner.js";
import type { AgentRecord, IsolationMode, SubagentType, ThinkingLevel } from "./types.js";
import { addUsage } from "./usage.js";
import { cleanupWorktree, createWorktree, pruneWorktrees, } from "./worktree.js";
export type OnAgentComplete = (record: AgentRecord) => void;
export type OnAgentStart = (record: AgentRecord) => void;
export type OnAgentCompact = (record: AgentRecord, info: CompactionInfo) => void;
export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
/** Default max concurrent background agents. */
const DEFAULT_MAX_CONCURRENT = 4;
interface SpawnArgs {
pi: ExtensionAPI;
ctx: ExtensionContext;
type: SubagentType;
prompt: string;
options: SpawnOptions;
}
interface SpawnOptions {
description: string;
model?: Model<any>;
maxTurns?: number;
isolated?: boolean;
inheritContext?: boolean;
thinkingLevel?: ThinkingLevel;
isBackground?: boolean;
/**
* Skip the maxConcurrent queue check for this spawn — start immediately even
* if the configured concurrency limit would otherwise queue it. Used by the
* scheduler so a fired job can't be deferred past its trigger window.
*/
bypassQueue?: boolean;
/** Isolation mode — "worktree" creates a temp git worktree for the agent. */
isolation?: IsolationMode;
/** Parent abort signal — when aborted, the subagent is also stopped. */
signal?: AbortSignal;
/** Called on tool start/end with activity info (for streaming progress to UI). */
onToolActivity?: (activity: ToolActivity) => void;
/** Called on streaming text deltas from the assistant response. */
onTextDelta?: (delta: string, fullText: string) => void;
/** Called when the agent session is created (for accessing session stats). */
onSessionCreated?: (session: AgentSession) => void;
/** Called at the end of each agentic turn with the cumulative count. */
onTurnEnd?: (turnCount: number) => void;
/** Called once per assistant message_end with that message's usage delta. */
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
/** Called when the session successfully compacts. */
onCompaction?: (info: CompactionInfo) => void;
}
export class AgentManager {
private agents = new Map<string, AgentRecord>();
private cleanupInterval: ReturnType<typeof setInterval>;
private onComplete?: OnAgentComplete;
private onStart?: OnAgentStart;
private onCompact?: OnAgentCompact;
private maxConcurrent: number;
/** Queue of background agents waiting to start. */
private queue: { id: string; args: SpawnArgs }[] = [];
/** Number of currently running background agents. */
private runningBackground = 0;
constructor(
onComplete?: OnAgentComplete,
maxConcurrent = DEFAULT_MAX_CONCURRENT,
onStart?: OnAgentStart,
onCompact?: OnAgentCompact,
) {
this.onComplete = onComplete;
this.onStart = onStart;
this.onCompact = onCompact;
this.maxConcurrent = maxConcurrent;
// Cleanup completed agents after 10 minutes (but keep sessions for resume)
this.cleanupInterval = setInterval(() => this.cleanup(), 60_000);
this.cleanupInterval.unref();
}
/** Update the max concurrent background agents limit. */
setMaxConcurrent(n: number) {
this.maxConcurrent = Math.max(1, n);
// Start queued agents if the new limit allows
this.drainQueue();
}
getMaxConcurrent(): number {
return this.maxConcurrent;
}
/**
* Spawn an agent and return its ID immediately (for background use).
* If the concurrency limit is reached, the agent is queued.
*/
spawn(
pi: ExtensionAPI,
ctx: ExtensionContext,
type: SubagentType,
prompt: string,
options: SpawnOptions,
): string {
const id = randomUUID().slice(0, 17);
const abortController = new AbortController();
const record: AgentRecord = {
id,
type,
description: options.description,
status: options.isBackground ? "queued" : "running",
toolUses: 0,
startedAt: Date.now(),
abortController,
lifetimeUsage: { input: 0, output: 0, cacheWrite: 0 },
compactionCount: 0,
};
this.agents.set(id, record);
const args: SpawnArgs = { pi, ctx, type, prompt, options };
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this.maxConcurrent) {
// Queue it — will be started when a running agent completes
this.queue.push({ id, args });
return id;
}
// startAgent can throw (e.g. strict worktree-isolation failure) — clean
// up the record so callers don't see an orphan in `listAgents()`.
try {
this.startAgent(id, record, args);
} catch (err) {
this.agents.delete(id);
throw err;
}
return id;
}
/** Actually start an agent (called immediately or from queue drain). */
private startAgent(id: string, record: AgentRecord, { pi, ctx, type, prompt, options }: SpawnArgs) {
// Worktree isolation: try to create a temporary git worktree. Strict —
// fail loud if not possible (no silent fallback to main tree). Done
// BEFORE state mutation so a throw doesn't leave the record half-running.
let worktreeCwd: string | undefined;
if (options.isolation === "worktree") {
const wt = createWorktree(ctx.cwd, id);
if (!wt) {
throw new Error(
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
'Initialize git and commit at least once, or omit `isolation`.',
);
}
record.worktree = wt;
worktreeCwd = wt.path;
}
record.status = "running";
record.startedAt = Date.now();
if (options.isBackground) this.runningBackground++;
this.onStart?.(record);
// Wire parent abort signal to stop the subagent when the parent is interrupted
let detachParentSignal: (() => void) | undefined;
if (options.signal) {
const onParentAbort = () => this.abort(id);
options.signal.addEventListener("abort", onParentAbort, { once: true });
detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
}
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
const promise = runAgent(ctx, type, prompt, {
pi,
model: options.model,
maxTurns: options.maxTurns,
isolated: options.isolated,
inheritContext: options.inheritContext,
thinkingLevel: options.thinkingLevel,
cwd: worktreeCwd,
signal: record.abortController!.signal,
onToolActivity: (activity) => {
if (activity.type === "end") record.toolUses++;
options.onToolActivity?.(activity);
},
onTurnEnd: options.onTurnEnd,
onTextDelta: options.onTextDelta,
onAssistantUsage: (usage) => {
addUsage(record.lifetimeUsage, usage);
options.onAssistantUsage?.(usage);
},
onCompaction: (info) => {
record.compactionCount++;
this.onCompact?.(record, info);
options.onCompaction?.(info);
},
onSessionCreated: (session) => {
record.session = session;
// Flush any steers that arrived before the session was ready
if (record.pendingSteers?.length) {
for (const msg of record.pendingSteers) {
session.steer(msg).catch(() => {});
}
record.pendingSteers = undefined;
}
options.onSessionCreated?.(session);
},
})
.then(({ responseText, session, aborted, steered }) => {
// Don't overwrite status if externally stopped via abort()
if (record.status !== "stopped") {
record.status = aborted ? "aborted" : steered ? "steered" : "completed";
}
record.result = responseText;
record.session = session;
record.completedAt ??= Date.now();
detach();
// Final flush of streaming output file
if (record.outputCleanup) {
try { record.outputCleanup(); } catch { /* ignore */ }
record.outputCleanup = undefined;
}
// Clean up worktree if used
if (record.worktree) {
const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
record.worktreeResult = wtResult;
if (wtResult.hasChanges && wtResult.branch) {
record.result = (record.result ?? "") +
`\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
}
}
if (options.isBackground) {
this.runningBackground--;
try { this.onComplete?.(record); } catch { /* ignore completion side-effect errors */ }
this.drainQueue();
}
return responseText;
})
.catch((err) => {
// Don't overwrite status if externally stopped via abort()
if (record.status !== "stopped") {
record.status = "error";
}
record.error = err instanceof Error ? err.message : String(err);
record.completedAt ??= Date.now();
detach();
// Final flush of streaming output file on error
if (record.outputCleanup) {
try { record.outputCleanup(); } catch { /* ignore */ }
record.outputCleanup = undefined;
}
// Best-effort worktree cleanup on error
if (record.worktree) {
try {
const wtResult = cleanupWorktree(ctx.cwd, record.worktree, options.description);
record.worktreeResult = wtResult;
} catch { /* ignore cleanup errors */ }
}
if (options.isBackground) {
this.runningBackground--;
this.onComplete?.(record);
this.drainQueue();
}
return "";
});
record.promise = promise;
}
/** Start queued agents up to the concurrency limit. */
private drainQueue() {
while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
const next = this.queue.shift()!;
const record = this.agents.get(next.id);
if (!record || record.status !== "queued") continue;
try {
this.startAgent(next.id, record, next.args);
} catch (err) {
// Late failure (e.g. strict worktree-isolation) — surface on the record
// so the user/agent can see it via /agents, then keep draining.
record.status = "error";
record.error = err instanceof Error ? err.message : String(err);
record.completedAt = Date.now();
this.onComplete?.(record);
}
}
}
/**
* Spawn an agent and wait for completion (foreground use).
* Foreground agents bypass the concurrency queue.
*/
async spawnAndWait(
pi: ExtensionAPI,
ctx: ExtensionContext,
type: SubagentType,
prompt: string,
options: Omit<SpawnOptions, "isBackground">,
): Promise<AgentRecord> {
const id = this.spawn(pi, ctx, type, prompt, { ...options, isBackground: false });
const record = this.agents.get(id)!;
await record.promise;
return record;
}
/**
* Resume an existing agent session with a new prompt.
*/
async resume(
id: string,
prompt: string,
signal?: AbortSignal,
): Promise<AgentRecord | undefined> {
const record = this.agents.get(id);
if (!record?.session) return undefined;
record.status = "running";
record.startedAt = Date.now();
record.completedAt = undefined;
record.result = undefined;
record.error = undefined;
try {
const responseText = await resumeAgent(record.session, prompt, {
onToolActivity: (activity) => {
if (activity.type === "end") record.toolUses++;
},
onAssistantUsage: (usage) => {
addUsage(record.lifetimeUsage, usage);
},
onCompaction: (info) => {
record.compactionCount++;
this.onCompact?.(record, info);
},
signal,
});
record.status = "completed";
record.result = responseText;
record.completedAt = Date.now();
} catch (err) {
record.status = "error";
record.error = err instanceof Error ? err.message : String(err);
record.completedAt = Date.now();
}
return record;
}
getRecord(id: string): AgentRecord | undefined {
return this.agents.get(id);
}
listAgents(): AgentRecord[] {
return [...this.agents.values()].sort(
(a, b) => b.startedAt - a.startedAt,
);
}
abort(id: string): boolean {
const record = this.agents.get(id);
if (!record) return false;
// Remove from queue if queued
if (record.status === "queued") {
this.queue = this.queue.filter(q => q.id !== id);
record.status = "stopped";
record.completedAt = Date.now();
return true;
}
if (record.status !== "running") return false;
record.abortController?.abort();
record.status = "stopped";
record.completedAt = Date.now();
return true;
}
/** Dispose a record's session and remove it from the map. */
private removeRecord(id: string, record: AgentRecord): void {
record.session?.dispose?.();
record.session = undefined;
this.agents.delete(id);
}
private cleanup() {
const cutoff = Date.now() - 10 * 60_000;
for (const [id, record] of this.agents) {
if (record.status === "running" || record.status === "queued") continue;
if ((record.completedAt ?? 0) >= cutoff) continue;
this.removeRecord(id, record);
}
}
/**
* Remove all completed/stopped/errored records immediately.
* Called on session start/switch so tasks from a prior session don't persist.
*/
clearCompleted(): void {
for (const [id, record] of this.agents) {
if (record.status === "running" || record.status === "queued") continue;
this.removeRecord(id, record);
}
}
/** Whether any agents are still running or queued. */
hasRunning(): boolean {
return [...this.agents.values()].some(
r => r.status === "running" || r.status === "queued",
);
}
/** Abort all running and queued agents immediately. */
abortAll(): number {
let count = 0;
// Clear queued agents first
for (const queued of this.queue) {
const record = this.agents.get(queued.id);
if (record) {
record.status = "stopped";
record.completedAt = Date.now();
count++;
}
}
this.queue = [];
// Abort running agents
for (const record of this.agents.values()) {
if (record.status === "running") {
record.abortController?.abort();
record.status = "stopped";
record.completedAt = Date.now();
count++;
}
}
return count;
}
/** Wait for all running and queued agents to complete (including queued ones). */
async waitForAll(): Promise<void> {
// Loop because drainQueue respects the concurrency limit — as running
// agents finish they start queued ones, which need awaiting too.
while (true) {
this.drainQueue();
const pending = [...this.agents.values()]
.filter(r => r.status === "running" || r.status === "queued")
.map(r => r.promise)
.filter(Boolean);
if (pending.length === 0) break;
await Promise.allSettled(pending);
}
}
dispose() {
clearInterval(this.cleanupInterval);
// Clear queue
this.queue = [];
for (const record of this.agents.values()) {
record.session?.dispose();
}
this.agents.clear();
// Prune any orphaned git worktrees (crash recovery)
try { pruneWorktrees(process.cwd()); } catch { /* ignore */ }
}
}

View File

@@ -0,0 +1,479 @@
/**
* agent-runner.ts — Core execution engine: creates sessions, runs agents, collects results.
*/
import type { Model } from "@mariozechner/pi-ai";
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
import {
type AgentSession,
type AgentSessionEvent,
createAgentSession,
DefaultResourceLoader,
type ExtensionAPI,
getAgentDir,
SessionManager,
SettingsManager,
} from "@mariozechner/pi-coding-agent";
import { getAgentConfig, getConfig, getMemoryToolNames, getReadOnlyMemoryToolNames, getToolNamesForType } from "./agent-types.js";
import { buildParentContext, extractText } from "./context.js";
import { DEFAULT_AGENTS } from "./default-agents.js";
import { detectEnv } from "./env.js";
import { buildMemoryBlock, buildReadOnlyMemoryBlock } from "./memory.js";
import { buildAgentPrompt, type PromptExtras } from "./prompts.js";
import { preloadSkills } from "./skill-loader.js";
import type { SubagentType, ThinkingLevel } from "./types.js";
/** Names of tools registered by this extension that subagents must NOT inherit. */
const EXCLUDED_TOOL_NAMES = ["Agent", "get_subagent_result", "steer_subagent"];
/** Default max turns. undefined = unlimited (no turn limit). */
let defaultMaxTurns: number | undefined;
/** Normalize max turns. undefined or 0 = unlimited, otherwise minimum 1. */
export function normalizeMaxTurns(n: number | undefined): number | undefined {
if (n == null || n === 0) return undefined;
return Math.max(1, n);
}
/** Get the default max turns value. undefined = unlimited. */
export function getDefaultMaxTurns(): number | undefined { return defaultMaxTurns; }
/** Set the default max turns value. undefined or 0 = unlimited, otherwise minimum 1. */
export function setDefaultMaxTurns(n: number | undefined): void { defaultMaxTurns = normalizeMaxTurns(n); }
/** Additional turns allowed after the soft limit steer message. */
let graceTurns = 5;
/** Get the grace turns value. */
export function getGraceTurns(): number { return graceTurns; }
/** Set the grace turns value (minimum 1). */
export function setGraceTurns(n: number): void { graceTurns = Math.max(1, n); }
/**
* Try to find the right model for an agent type.
* Priority: explicit option > config.model > parent model.
*/
function resolveDefaultModel(
parentModel: Model<any> | undefined,
registry: { find(provider: string, modelId: string): Model<any> | undefined; getAvailable?(): Model<any>[] },
configModel?: string,
): Model<any> | undefined {
if (configModel) {
const slashIdx = configModel.indexOf("/");
if (slashIdx !== -1) {
const provider = configModel.slice(0, slashIdx);
const modelId = configModel.slice(slashIdx + 1);
// Build a set of available model keys for fast lookup
const available = registry.getAvailable?.();
const availableKeys = available
? new Set(available.map((m: any) => `${m.provider}/${m.id}`))
: undefined;
const isAvailable = (p: string, id: string) =>
!availableKeys || availableKeys.has(`${p}/${id}`);
const found = registry.find(provider, modelId);
if (found && isAvailable(provider, modelId)) return found;
}
}
return parentModel;
}
/** Info about a tool event in the subagent. */
export interface ToolActivity {
type: "start" | "end";
toolName: string;
}
export interface RunOptions {
/** ExtensionAPI instance — used for pi.exec() instead of execSync. */
pi: ExtensionAPI;
model?: Model<any>;
maxTurns?: number;
signal?: AbortSignal;
isolated?: boolean;
inheritContext?: boolean;
thinkingLevel?: ThinkingLevel;
/** Override working directory (e.g. for worktree isolation). */
cwd?: string;
/** Called on tool start/end with activity info. */
onToolActivity?: (activity: ToolActivity) => void;
/** Called on streaming text deltas from the assistant response. */
onTextDelta?: (delta: string, fullText: string) => void;
onSessionCreated?: (session: AgentSession) => void;
/** Called at the end of each agentic turn with the cumulative count. */
onTurnEnd?: (turnCount: number) => void;
/**
* Called once per assistant message_end with that message's usage delta.
* Lets callers maintain a lifetime accumulator that survives compaction
* (which replaces session.state.messages and resets stats-derived sums).
*/
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
/**
* Called when the session successfully compacts. `tokensBefore` is upstream's
* pre-compaction context size estimate. Aborted compactions don't fire.
*/
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
}
export interface RunResult {
responseText: string;
session: AgentSession;
/** True if the agent was hard-aborted (max_turns + grace exceeded). */
aborted: boolean;
/** True if the agent was steered to wrap up (hit soft turn limit) but finished in time. */
steered: boolean;
}
/**
* Subscribe to a session and collect the last assistant message text.
* Returns an object with a `getText()` getter and an `unsubscribe` function.
*/
function collectResponseText(session: AgentSession) {
let text = "";
const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "message_start") {
text = "";
}
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
text += event.assistantMessageEvent.delta;
}
});
return { getText: () => text, unsubscribe };
}
/** Get the last assistant text from the completed session history. */
function getLastAssistantText(session: AgentSession): string {
for (let i = session.messages.length - 1; i >= 0; i--) {
const msg = session.messages[i];
if (msg.role !== "assistant") continue;
const text = extractText(msg.content).trim();
if (text) return text;
}
return "";
}
/**
* Wire an AbortSignal to abort a session.
* Returns a cleanup function to remove the listener.
*/
function forwardAbortSignal(session: AgentSession, signal?: AbortSignal): () => void {
if (!signal) return () => {};
const onAbort = () => session.abort();
signal.addEventListener("abort", onAbort, { once: true });
return () => signal.removeEventListener("abort", onAbort);
}
export async function runAgent(
ctx: ExtensionContext,
type: SubagentType,
prompt: string,
options: RunOptions,
): Promise<RunResult> {
const config = getConfig(type);
const agentConfig = getAgentConfig(type);
// Resolve working directory: worktree override > parent cwd
const effectiveCwd = options.cwd ?? ctx.cwd;
const env = await detectEnv(options.pi, effectiveCwd);
// Get parent system prompt for append-mode agents
const parentSystemPrompt = ctx.getSystemPrompt();
// Build prompt extras (memory, skill preloading)
const extras: PromptExtras = {};
// Resolve extensions/skills: isolated overrides to false
const extensions = options.isolated ? false : config.extensions;
const skills = options.isolated ? false : config.skills;
// Skill preloading: when skills is string[], preload their content into prompt
if (Array.isArray(skills)) {
const loaded = preloadSkills(skills, effectiveCwd);
if (loaded.length > 0) {
extras.skillBlocks = loaded;
}
}
let toolNames = getToolNamesForType(type);
// Persistent memory: detect write capability and branch accordingly.
// Account for disallowedTools — a tool in the base set but on the denylist is not truly available.
if (agentConfig?.memory) {
const existingNames = new Set(toolNames);
const denied = agentConfig.disallowedTools ? new Set(agentConfig.disallowedTools) : undefined;
const effectivelyHas = (name: string) => existingNames.has(name) && !denied?.has(name);
const hasWriteTools = effectivelyHas("write") || effectivelyHas("edit");
if (hasWriteTools) {
// Read-write memory: add any missing memory tool names (read/write/edit)
const extraNames = getMemoryToolNames(existingNames);
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
extras.memoryBlock = buildMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
} else {
// Read-only memory: only add read tool name, use read-only prompt
const extraNames = getReadOnlyMemoryToolNames(existingNames);
if (extraNames.length > 0) toolNames = [...toolNames, ...extraNames];
extras.memoryBlock = buildReadOnlyMemoryBlock(agentConfig.name, agentConfig.memory, effectiveCwd);
}
}
// Build system prompt from agent config
let systemPrompt: string;
if (agentConfig) {
systemPrompt = buildAgentPrompt(agentConfig, effectiveCwd, env, parentSystemPrompt, extras);
} else {
// Unknown type fallback: spread the canonical general-purpose config (defensive —
// unreachable in practice since index.ts resolves unknown types before calling runAgent).
const fallback = DEFAULT_AGENTS.get("general-purpose");
if (!fallback) throw new Error(`No fallback config available for unknown type "${type}"`);
systemPrompt = buildAgentPrompt({ ...fallback, name: type }, effectiveCwd, env, parentSystemPrompt, extras);
}
// When skills is string[], we've already preloaded them into the prompt.
// Still pass noSkills: true since we don't need the skill loader to load them again.
const noSkills = skills === false || Array.isArray(skills);
const agentDir = getAgentDir();
// Load extensions/skills: true or string[] → load; false → don't.
// Suppress AGENTS.md/CLAUDE.md and APPEND_SYSTEM.md — upstream's
// buildSystemPrompt() re-appends both AFTER systemPromptOverride, which
// would defeat prompt_mode: replace and isolated: true. Parent context, if
// wanted, reaches the subagent via prompt_mode: append (parentSystemPrompt
// is embedded in systemPromptOverride) or inherit_context (conversation).
const loader = new DefaultResourceLoader({
cwd: effectiveCwd,
agentDir,
noExtensions: extensions === false,
noSkills,
noPromptTemplates: true,
noThemes: true,
noContextFiles: true,
systemPromptOverride: () => systemPrompt,
appendSystemPromptOverride: () => [],
});
await loader.reload();
// Resolve model: explicit option > config.model > parent model
const model = options.model ?? resolveDefaultModel(
ctx.model, ctx.modelRegistry, agentConfig?.model,
);
// Resolve thinking level: explicit option > agent config > undefined (inherit)
const thinkingLevel = options.thinkingLevel ?? agentConfig?.thinking;
const sessionOpts: Parameters<typeof createAgentSession>[0] = {
cwd: effectiveCwd,
agentDir,
sessionManager: SessionManager.inMemory(effectiveCwd),
settingsManager: SettingsManager.create(effectiveCwd, agentDir),
modelRegistry: ctx.modelRegistry,
model,
tools: toolNames,
resourceLoader: loader,
};
if (thinkingLevel) {
sessionOpts.thinkingLevel = thinkingLevel;
}
const { session } = await createAgentSession(sessionOpts);
// Build disallowed tools set from agent config
const disallowedSet = agentConfig?.disallowedTools
? new Set(agentConfig.disallowedTools)
: undefined;
// Filter active tools: remove our own tools to prevent nesting,
// apply extension allowlist if specified, and apply disallowedTools denylist
if (extensions !== false) {
const builtinToolNameSet = new Set(toolNames);
const activeTools = session.getActiveToolNames().filter((t) => {
if (EXCLUDED_TOOL_NAMES.includes(t)) return false;
if (disallowedSet?.has(t)) return false;
if (builtinToolNameSet.has(t)) return true;
if (Array.isArray(extensions)) {
return extensions.some(ext => t.startsWith(ext) || t.includes(ext));
}
return true;
});
session.setActiveToolsByName(activeTools);
} else if (disallowedSet) {
// Even with extensions disabled, apply denylist to built-in tools
const activeTools = session.getActiveToolNames().filter(t => !disallowedSet.has(t));
session.setActiveToolsByName(activeTools);
}
// Bind extensions so that session_start fires and extensions can initialize
// (e.g. loading credentials, setting up state). Placed after tool filtering
// so extension-provided skills/prompts from extendResourcesFromExtensions()
// respect the active tool set. All ExtensionBindings fields are optional.
await session.bindExtensions({
onError: (err) => {
options.onToolActivity?.({
type: "end",
toolName: `extension-error:${err.extensionPath}`,
});
},
});
options.onSessionCreated?.(session);
// Track turns for graceful max_turns enforcement
let turnCount = 0;
const maxTurns = normalizeMaxTurns(options.maxTurns ?? agentConfig?.maxTurns ?? defaultMaxTurns);
let softLimitReached = false;
let aborted = false;
let currentMessageText = "";
const unsubTurns = session.subscribe((event: AgentSessionEvent) => {
if (event.type === "turn_end") {
turnCount++;
options.onTurnEnd?.(turnCount);
if (maxTurns != null) {
if (!softLimitReached && turnCount >= maxTurns) {
softLimitReached = true;
session.steer("You have reached your turn limit. Wrap up immediately — provide your final answer now.");
} else if (softLimitReached && turnCount >= maxTurns + graceTurns) {
aborted = true;
session.abort();
}
}
}
if (event.type === "message_start") {
currentMessageText = "";
}
if (event.type === "message_update" && event.assistantMessageEvent.type === "text_delta") {
currentMessageText += event.assistantMessageEvent.delta;
options.onTextDelta?.(event.assistantMessageEvent.delta, currentMessageText);
}
if (event.type === "tool_execution_start") {
options.onToolActivity?.({ type: "start", toolName: event.toolName });
}
if (event.type === "tool_execution_end") {
options.onToolActivity?.({ type: "end", toolName: event.toolName });
}
if (event.type === "message_end" && event.message.role === "assistant") {
const u = (event.message as any).usage;
if (u) options.onAssistantUsage?.({
input: u.input ?? 0,
output: u.output ?? 0,
cacheWrite: u.cacheWrite ?? 0,
});
}
if (event.type === "compaction_end" && !event.aborted && event.result) {
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
}
});
const collector = collectResponseText(session);
const cleanupAbort = forwardAbortSignal(session, options.signal);
// Build the effective prompt: optionally prepend parent context
let effectivePrompt = prompt;
if (options.inheritContext) {
const parentContext = buildParentContext(ctx);
if (parentContext) {
effectivePrompt = parentContext + prompt;
}
}
try {
await session.prompt(effectivePrompt);
} finally {
unsubTurns();
collector.unsubscribe();
cleanupAbort();
}
const responseText = collector.getText().trim() || getLastAssistantText(session);
return { responseText, session, aborted, steered: softLimitReached };
}
/**
* Send a new prompt to an existing session (resume).
*/
export async function resumeAgent(
session: AgentSession,
prompt: string,
options: {
onToolActivity?: (activity: ToolActivity) => void;
onAssistantUsage?: (usage: { input: number; output: number; cacheWrite: number }) => void;
onCompaction?: (info: { reason: "manual" | "threshold" | "overflow"; tokensBefore: number }) => void;
signal?: AbortSignal;
} = {},
): Promise<string> {
const collector = collectResponseText(session);
const cleanupAbort = forwardAbortSignal(session, options.signal);
const unsubEvents = (options.onToolActivity || options.onAssistantUsage || options.onCompaction)
? session.subscribe((event: AgentSessionEvent) => {
if (event.type === "tool_execution_start") options.onToolActivity?.({ type: "start", toolName: event.toolName });
if (event.type === "tool_execution_end") options.onToolActivity?.({ type: "end", toolName: event.toolName });
if (event.type === "message_end" && event.message.role === "assistant") {
const u = (event.message as any).usage;
if (u) options.onAssistantUsage?.({
input: u.input ?? 0,
output: u.output ?? 0,
cacheWrite: u.cacheWrite ?? 0,
});
}
if (event.type === "compaction_end" && !event.aborted && event.result) {
options.onCompaction?.({ reason: event.reason, tokensBefore: event.result.tokensBefore });
}
})
: () => {};
try {
await session.prompt(prompt);
} finally {
collector.unsubscribe();
unsubEvents();
cleanupAbort();
}
return collector.getText().trim() || getLastAssistantText(session);
}
/**
* Send a steering message to a running subagent.
* The message will interrupt the agent after its current tool execution.
*/
export async function steerAgent(
session: AgentSession,
message: string,
): Promise<void> {
await session.steer(message);
}
/**
* Get the subagent's conversation messages as formatted text.
*/
export function getAgentConversation(session: AgentSession): string {
const parts: string[] = [];
for (const msg of session.messages) {
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 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(` Tool: ${(c as any).name ?? (c as any).toolName ?? "unknown"}`);
}
if (textParts.length > 0) parts.push(`[Assistant]: ${textParts.join("\n")}`);
if (toolCalls.length > 0) parts.push(`[Tool Calls]:\n${toolCalls.join("\n")}`);
} else if (msg.role === "toolResult") {
const text = extractText(msg.content);
const truncated = text.length > 200 ? text.slice(0, 200) + "..." : text;
parts.push(`[Tool Result (${msg.toolName})]: ${truncated}`);
}
}
return parts.join("\n\n");
}

View File

@@ -0,0 +1,164 @@
/**
* agent-types.ts — Unified agent type registry.
*
* Merges embedded default agents with user-defined agents from .pi/agents/*.md.
* User agents override defaults with the same name. Disabled agents are kept but excluded from spawning.
*/
import { DEFAULT_AGENTS } from "./default-agents.js";
import type { AgentConfig } from "./types.js";
/** All known built-in tool names. */
export const BUILTIN_TOOL_NAMES: string[] = ["read", "bash", "edit", "write", "grep", "find", "ls"];
/** Unified runtime registry of all agents (defaults + user-defined). */
const agents = new Map<string, AgentConfig>();
/**
* 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();
// Start with defaults
for (const [name, config] of DEFAULT_AGENTS) {
agents.set(name, config);
}
// Overlay user agents (overrides defaults with same name)
for (const [name, config] of userAgents) {
agents.set(name, config);
}
}
/** Case-insensitive key resolution. */
function resolveKey(name: string): string | undefined {
if (agents.has(name)) return name;
const lower = name.toLowerCase();
for (const key of agents.keys()) {
if (key.toLowerCase() === lower) return key;
}
return undefined;
}
/** 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 {
const key = resolveKey(name);
return key ? agents.get(key) : undefined;
}
/** Get all enabled type names (for spawning and tool descriptions). */
export function getAvailableTypes(): string[] {
return [...agents.entries()]
.filter(([_, config]) => config.enabled !== false)
.map(([name]) => name);
}
/** 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 {
const key = resolveKey(type);
if (!key) return false;
return agents.get(key)?.enabled !== false;
}
/** 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;
const names = config?.builtinToolNames?.length ? config.builtinToolNames : [...BUILTIN_TOOL_NAMES];
return 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;
description: string;
builtinToolNames: string[];
extensions: true | string[] | false;
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,
description: config.description,
builtinToolNames: config.builtinToolNames ?? BUILTIN_TOOL_NAMES,
extensions: config.extensions,
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,
description: gp.description,
builtinToolNames: gp.builtinToolNames ?? BUILTIN_TOOL_NAMES,
extensions: gp.extensions,
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",
};
}

View File

@@ -0,0 +1,58 @@
/**
* context.ts — Extract parent conversation context for subagent inheritance.
*/
import type { ExtensionContext } from "@mariozechner/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)
`;
}

View File

@@ -0,0 +1,95 @@
/**
* 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 }
*/
/** 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");
return { id: manager.spawn(pi, ctx, type, prompt, options ?? {}) };
},
);
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 };
}

View File

@@ -0,0 +1,136 @@
/**
* custom-agents.ts — Load user-defined agents from project (.pi/agents/) 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 "@mariozechner/pi-coding-agent";
import { BUILTIN_TOOL_NAMES } from "./agent-types.js";
import type { AgentConfig, MemoryScope, ThinkingLevel } from "./types.js";
/**
* Scan for custom agent .md files from multiple locations.
* Discovery hierarchy (higher priority wins):
* 1. Project: <cwd>/.pi/agents/*.md
* 2. Global: $PI_CODING_AGENT_DIR/agents/*.md (default: ~/.pi/agent/agents/*.md)
*
* Project-level agents override global ones with the same name.
* Any name is allowed — names matching defaults (e.g. "Explore") override them.
*/
export function loadCustomAgents(cwd: string): Map<string, AgentConfig> {
const globalDir = join(getAgentDir(), "agents");
const projectDir = join(cwd, ".pi", "agents");
const agents = new Map<string, AgentConfig>();
loadFromDir(globalDir, agents, "global"); // lower priority
loadFromDir(projectDir, agents, "project"); // higher priority (overwrites)
return agents;
}
/** Load agent configs from a directory into the map. */
function loadFromDir(dir: string, agents: Map<string, AgentConfig>, source: "project" | "global"): 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 name = basename(file, ".md");
let content: string;
try {
content = readFileSync(join(dir, file), "utf-8");
} catch {
continue;
}
const { frontmatter: fm, body } = parseFrontmatter<Record<string, unknown>>(content);
agents.set(name, {
name,
displayName: str(fm.display_name),
description: str(fm.description) ?? name,
builtinToolNames: csvList(fm.tools, BUILTIN_TOOL_NAMES),
disallowedTools: csvListOptional(fm.disallowed_tools),
extensions: inheritField(fm.extensions ?? fm.inherit_extensions),
skills: inheritField(fm.skills ?? fm.inherit_skills),
model: str(fm.model),
thinking: str(fm.thinking) as ThinkingLevel | undefined,
maxTurns: nonNegativeInt(fm.max_turns),
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: fm.isolation === "worktree" ? "worktree" : undefined,
enabled: fm.enabled !== false, // default true; explicitly false disables
source,
});
}
}
// ---- 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 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) ?? [];
}
/**
* 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 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;
}

View File

@@ -0,0 +1,123 @@
/**
* 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 complex, multi-step tasks",
// 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 codebase exploration agent (read-only)",
builtinToolNames: READ_ONLY_TOOLS,
extensions: true,
skills: true,
model: "anthropic/claude-haiku-4-5-20251001",
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 for implementation planning (read-only)",
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,
},
],
]);

View File

@@ -0,0 +1,33 @@
/**
* env.ts — Detect environment info (git, platform) for subagent system prompts.
*/
import type { ExtensionAPI } from "@mariozechner/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,
};
}

View File

@@ -0,0 +1,141 @@
/**
* group-join.ts — Manages grouped background agent completion notifications.
*
* Instead of each agent individually nudging the main agent on completion,
* agents in a group are held until all complete (or a timeout fires),
* then a single consolidated notification is sent.
*/
import type { AgentRecord } from "./types.js";
export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
interface AgentGroup {
groupId: string;
agentIds: Set<string>;
completedRecords: Map<string, AgentRecord>;
timeoutHandle?: ReturnType<typeof setTimeout>;
delivered: boolean;
/** Shorter timeout for stragglers after a partial delivery. */
isStraggler: boolean;
}
/** Default timeout: 30s after first completion in a group. */
const DEFAULT_TIMEOUT = 30_000;
/** Straggler re-batch timeout: 15s. */
const STRAGGLER_TIMEOUT = 15_000;
export class GroupJoinManager {
private groups = new Map<string, AgentGroup>();
private agentToGroup = new Map<string, string>();
constructor(
private deliverCb: DeliveryCallback,
private groupTimeout = DEFAULT_TIMEOUT,
) {}
/** Register a group of agent IDs that should be joined. */
registerGroup(groupId: string, agentIds: string[]): void {
const group: AgentGroup = {
groupId,
agentIds: new Set(agentIds),
completedRecords: new Map(),
delivered: false,
isStraggler: false,
};
this.groups.set(groupId, group);
for (const id of agentIds) {
this.agentToGroup.set(id, groupId);
}
}
/**
* Called when an agent completes.
* Returns:
* - 'pass' — agent is not grouped, caller should send individual nudge
* - 'held' — result held, waiting for group completion
* - 'delivered' — this completion triggered the group notification
*/
onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
const groupId = this.agentToGroup.get(record.id);
if (!groupId) return 'pass';
const group = this.groups.get(groupId);
if (!group || group.delivered) return 'pass';
group.completedRecords.set(record.id, record);
// All done — deliver immediately
if (group.completedRecords.size >= group.agentIds.size) {
this.deliver(group, false);
return 'delivered';
}
// First completion in this batch — start timeout
if (!group.timeoutHandle) {
const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
group.timeoutHandle = setTimeout(() => {
this.onTimeout(group);
}, timeout);
}
return 'held';
}
private onTimeout(group: AgentGroup): void {
if (group.delivered) return;
group.timeoutHandle = undefined;
// Partial delivery — some agents still running
const remaining = new Set<string>();
for (const id of group.agentIds) {
if (!group.completedRecords.has(id)) remaining.add(id);
}
// Clean up agentToGroup for delivered agents (they won't complete again)
for (const id of group.completedRecords.keys()) {
this.agentToGroup.delete(id);
}
// Deliver what we have
this.deliverCb([...group.completedRecords.values()], true);
// Set up straggler group for remaining agents
group.completedRecords.clear();
group.agentIds = remaining;
group.isStraggler = true;
// Timeout will be started when the next straggler completes
}
private deliver(group: AgentGroup, partial: boolean): void {
if (group.timeoutHandle) {
clearTimeout(group.timeoutHandle);
group.timeoutHandle = undefined;
}
group.delivered = true;
this.deliverCb([...group.completedRecords.values()], partial);
this.cleanupGroup(group.groupId);
}
private cleanupGroup(groupId: string): void {
const group = this.groups.get(groupId);
if (!group) return;
for (const id of group.agentIds) {
this.agentToGroup.delete(id);
}
this.groups.delete(groupId);
}
/** Check if an agent is in a group. */
isGrouped(agentId: string): boolean {
return this.agentToGroup.has(agentId);
}
dispose(): void {
for (const group of this.groups.values()) {
if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
}
this.groups.clear();
this.agentToGroup.clear();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,40 @@
import type { AgentConfig, IsolationMode, JoinMode, ThinkingLevel } from "./types.js";
interface AgentInvocationParams {
model?: string;
thinking?: string;
max_turns?: number;
run_in_background?: boolean;
inherit_context?: boolean;
isolated?: boolean;
isolation?: IsolationMode;
}
export function resolveAgentInvocationConfig(
agentConfig: AgentConfig | undefined,
params: AgentInvocationParams,
): {
modelInput?: string;
modelFromParams: boolean;
thinking?: ThinkingLevel;
maxTurns?: number;
inheritContext: boolean;
runInBackground: boolean;
isolated: boolean;
isolation?: IsolationMode;
} {
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 ?? false,
isolated: agentConfig?.isolated ?? params.isolated ?? false,
isolation: agentConfig?.isolation ?? params.isolation,
};
}
export function resolveJoinMode(defaultJoinMode: JoinMode, runInBackground: boolean): JoinMode | undefined {
return runInBackground ? defaultJoinMode : undefined;
}

View File

@@ -0,0 +1,165 @@
/**
* memory.ts — Persistent agent memory: per-agent memory directories that persist across sessions.
*
* Memory scopes:
* - "user" → ~/.pi/agent-memory/{agent-name}/
* - "project" → .pi/agent-memory/{agent-name}/
* - "local" → .pi/agent-memory-local/{agent-name}/
*/
import { existsSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
import { homedir } from "node:os";
import { join, } from "node:path";
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":
return join(homedir(), ".pi", "agent-memory", agentName);
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;
}

View File

@@ -0,0 +1,81 @@
/**
* 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
const query = input.toLowerCase();
// 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 = m.id.toLowerCase();
const name = m.name.toLowerCase();
const full = `${m.provider}/${m.id}`.toLowerCase();
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 (query.split(/[\s\-/]+/).every(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. 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}`;
}

View File

@@ -0,0 +1,96 @@
/**
* 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 "@mariozechner/pi-coding-agent";
/**
* 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`);
}
/** 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,
): () => void {
let writtenCount = 1; // initial user prompt already written
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();
});
return () => {
flush();
unsubscribe();
};
}

View File

@@ -0,0 +1,85 @@
/**
* 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 }[];
}
/**
* Build the system prompt for an agent from its config.
*
* - "replace" mode: env header + config.systemPrompt (full control, no parent identity)
* - "append" mode: env header + parent system prompt + sub-agent context + config.systemPrompt
* - "append" with empty systemPrompt: pure parent clone
*
* @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 envBlock = `# Environment
Working directory: ${cwd}
${env.isGitRepo ? `Git repository: yes\nBranch: ${env.branch}` : "Not a git repository"}
Platform: ${env.platform}`;
// 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>`
: "";
return envBlock + "\n\n<inherited_system_prompt>\n" + identity + "\n</inherited_system_prompt>\n\n" + bridge + 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 replaceHeader + "\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.`;

View File

@@ -0,0 +1,143 @@
/**
* 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";
mkdirSync(dirname(filePath), { recursive: true });
this.load();
}
/** 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 {
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 {
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 {
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 */ }
}
}
}

View File

@@ -0,0 +1,365 @@
/**
* 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 "@mariozechner/pi-coding-agent";
import { Cron } from "croner";
import { nanoid } from "nanoid";
import type { AgentManager } from "./agent-manager.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 {
agentId = manager.spawn(pi, ctx, job.subagent_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"];
}
}

View File

@@ -0,0 +1,186 @@
// 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 "@mariozechner/pi-coding-agent";
import type { JoinMode } 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;
/**
* 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;
}
/** 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;
setSchedulingEnabled: (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"]);
// 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;
/** 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 (typeof r.defaultJoinMode === "string" && VALID_JOIN_MODES.has(r.defaultJoinMode)) {
out.defaultJoinMode = r.defaultJoinMode as JoinMode;
}
if (typeof r.schedulingEnabled === "boolean") {
out.schedulingEnabled = r.schedulingEnabled;
}
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 (s.defaultJoinMode) appliers.setDefaultJoinMode(s.defaultJoinMode);
if (typeof s.schedulingEnabled === "boolean") appliers.setSchedulingEnabled(s.schedulingEnabled);
}
/**
* 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);
}

View File

@@ -0,0 +1,79 @@
/**
* skill-loader.ts — Preload specific skill files and inject their content into the system prompt.
*
* When skills is a string[], reads each named skill from .pi/skills/ or ~/.pi/skills/
* and returns their content for injection into the agent's system prompt.
*/
import { homedir } from "node:os";
import { join } from "node:path";
import { isUnsafeName, safeReadFile } from "./memory.js";
export interface PreloadedSkill {
name: string;
content: string;
}
/**
* Attempt to load named skills from project and global skill directories.
* Looks for: <dir>/<name>.md, <dir>/<name>.txt, <dir>/<name>
*
* @param skillNames List of skill names to preload.
* @param cwd Working directory for project-level skills.
* @returns Array of loaded skills (missing skills are skipped with a warning comment).
*/
export function preloadSkills(skillNames: string[], cwd: string): PreloadedSkill[] {
const results: PreloadedSkill[] = [];
for (const name of skillNames) {
// Unlike memory (which throws on unsafe names because it's part of agent setup),
// skills are optional — skip gracefully to avoid blocking agent startup.
if (isUnsafeName(name)) {
results.push({ name, content: `(Skill "${name}" skipped: name contains path traversal characters)` });
continue;
}
const content = findAndReadSkill(name, cwd);
if (content !== undefined) {
results.push({ name, content });
} else {
// Include a note about missing skills so the agent knows it was requested but not found
results.push({ name, content: `(Skill "${name}" not found in .pi/skills/ or ~/.pi/skills/)` });
}
}
return results;
}
/**
* Search for a skill file in project and global directories.
* Project-level takes priority over global.
*/
function findAndReadSkill(name: string, cwd: string): string | undefined {
const projectDir = join(cwd, ".pi", "skills");
const globalDir = join(homedir(), ".pi", "skills");
// Try project first, then global
for (const dir of [projectDir, globalDir]) {
const content = tryReadSkillFile(dir, name);
if (content !== undefined) return content;
}
return undefined;
}
/**
* Try to read a skill file from a directory.
* Tries extensions in order: .md, .txt, (no extension)
*/
function tryReadSkillFile(dir: string, name: string): string | undefined {
const extensions = [".md", ".txt", ""];
for (const ext of extensions) {
const path = join(dir, name + ext);
// safeReadFile rejects symlinks to prevent reading arbitrary files
const content = safeReadFile(path);
if (content !== undefined) return content.trim();
}
return undefined;
}

View File

@@ -0,0 +1,163 @@
/**
* types.ts — Type definitions for the subagent system.
*/
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { AgentSession } from "@mariozechner/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. */
export type IsolationMode = "worktree";
/** Unified agent configuration — used for both default and user-defined agents. */
export interface AgentConfig {
name: string;
displayName?: string;
description: string;
builtinToolNames?: 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;
/** true = inherit all, string[] = only listed, false = none */
skills: true | string[] | false;
model?: string;
thinking?: ThinkingLevel;
maxTurns?: number;
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 */
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";
}
export type JoinMode = 'async' | 'group' | 'smart';
export interface AgentRecord {
id: string;
type: SubagentType;
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 };
/** 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;
/** 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;
}
/** 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;
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[];
}

View File

@@ -0,0 +1,518 @@
/**
* 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 "@mariozechner/pi-tui";
import type { AgentManager } from "../agent-manager.js";
import { getConfig } from "../agent-types.js";
import type { SubagentType } from "../types.js";
import { 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;
/** Lifetime usage breakdown — see LifetimeUsage docs. */
lifetimeUsage: LifetimeUsage;
}
/** 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;
agentId?: string;
error?: string;
}
// ---- Formatting helpers ----
/** 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`;
}
/**
* Token count with optional context-fill % and compaction-count annotations.
* Thresholds for percent: <70% dim, 7085% warning, ≥85% error.
* Compaction count rendered as `↻N` in dim.
*
* "12.3k token" — no annotations
* "12.3k token (45%)" — percent only
* "12.3k token (↻2)" — compactions only (e.g. right after compact)
* "12.3k token (45% · ↻2)" — both
*/
export function formatSessionTokens(
tokens: number,
percent: number | null,
theme: Theme,
compactions = 0,
): string {
const tokenStr = formatTokens(tokens);
const annot: string[] = [];
if (percent !== null) {
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
annot.push(theme.fg(color, `${Math.round(percent)}%`));
}
if (compactions > 0) {
annot.push(theme.fg("dim", `${compactions}`));
}
if (annot.length === 0) return tokenStr;
return `${tokenStr} (${annot.join(" · ")})`;
}
/** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
return maxTurns != null ? `${turnCount}${maxTurns}` : `${turnCount}`;
}
/** Format milliseconds as human-readable duration. */
export function formatMs(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
/** Format duration from start/completed timestamps. */
export function formatDuration(startedAt: number, completedAt?: number): string {
if (completedAt) return formatMs(completedAt - startedAt);
return `${formatMs(Date.now() - startedAt)} (running)`;
}
/** Get display name for any agent type (built-in or custom). */
export function getDisplayName(type: SubagentType): string {
return getConfig(type).displayName;
}
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
export function getPromptModeLabel(type: SubagentType): string | undefined {
const config = getConfig(type);
return config.promptMode === "append" ? "twin" : undefined;
}
/** 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>,
) {}
/** 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);
}
}
/** Render a finished agent line. */
private renderFinishedLine(a: { id: string; type: SubagentType; status: string; description: string; toolUses: number; startedAt: number; completedAt?: number; error?: string }, theme: Theme): string {
const name = getDisplayName(a.type);
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"}`);
parts.push(duration);
const modeTag = modeLabel ? ` ${theme.fg("dim", `(${modeLabel})`)}` : "";
return `${icon} ${theme.fg("dim", name)}${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.manager.listAgents();
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 name = getDisplayName(a.type);
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;
const tokens = getLifetimeTotal(bg?.lifetimeUsage);
const contextPercent = getSessionContextPercent(bg?.session);
const tokenText = tokens > 0 ? formatSessionTokens(tokens, contextPercent, theme, a.compactionCount) : "";
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);
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)} ${theme.bold(name)}${modeTag} ${theme.fg("muted", a.description)} ${theme.fg("dim", "·")} ${theme.fg("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;
// 1. Running agents (2 lines each)
for (const pair of runningLines) {
if (budget >= 2) {
lines.push(...pair);
budget -= 2;
} else {
hiddenRunning++;
}
}
// 2. Queued line
if (queuedLine && budget >= 1) {
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.manager.listAgents();
// 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;
}
}

View File

@@ -0,0 +1,243 @@
/**
* 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 "@mariozechner/pi-coding-agent";
import { type Component, matchesKey, type TUI, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
import { extractText } from "../context.js";
import type { AgentRecord } from "../types.js";
import { getLifetimeTotal, getSessionContextPercent } from "../usage.js";
import type { Theme } from "./agent-widget.js";
import { type AgentActivity, describeActivity, formatDuration, formatSessionTokens, getDisplayName, getPromptModeLabel } from "./agent-widget.js";
/** Lines consumed by chrome: top border + header + header sep + footer sep + footer + bottom border. */
const CHROME_LINES = 6;
const MIN_VIEWPORT = 3;
export class ConversationViewer implements Component {
private scrollOffset = 0;
private autoScroll = true;
private unsubscribe: (() => void) | undefined;
private lastInnerW = 0;
private closed = false;
constructor(
private tui: TUI,
private session: AgentSession,
private record: AgentRecord,
private activity: AgentActivity | undefined,
private theme: Theme,
private done: (result: undefined) => void,
) {
this.unsubscribe = session.subscribe(() => {
if (this.closed) return;
this.tui.requestRender();
});
}
handleInput(data: string): void {
if (matchesKey(data, "escape") || matchesKey(data, "q")) {
this.closed = true;
this.done(undefined);
return;
}
const totalLines = this.buildContentLines(this.lastInnerW).length;
const viewportHeight = this.viewportHeight();
const maxScroll = Math.max(0, totalLines - viewportHeight);
if (matchesKey(data, "up") || matchesKey(data, "k")) {
this.scrollOffset = Math.max(0, this.scrollOffset - 1);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (matchesKey(data, "down") || matchesKey(data, "j")) {
this.scrollOffset = Math.min(maxScroll, this.scrollOffset + 1);
this.autoScroll = this.scrollOffset >= maxScroll;
} else if (matchesKey(data, "pageUp")) {
this.scrollOffset = Math.max(0, this.scrollOffset - viewportHeight);
this.autoScroll = false;
} else if (matchesKey(data, "pageDown")) {
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) + " " + 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 name = getDisplayName(this.record.type);
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"}`);
const tokens = getLifetimeTotal(this.activity?.lifetimeUsage);
if (tokens > 0) {
const percent = getSessionContextPercent(this.activity?.session);
headerParts.push(formatSessionTokens(tokens, percent, th, this.record.compactionCount));
}
lines.push(row(
`${statusIcon} ${th.bold(name)}${modeTag} ${th.fg("muted", this.record.description)} ${th.fg("dim", "·")} ${th.fg("dim", headerParts.join(" · "))}`,
));
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);
const scrollPct = contentLines.length <= viewportHeight
? "100%"
: `${Math.round(((visibleStart + viewportHeight) / contentLines.length) * 100)}%`;
const footerLeft = th.fg("dim", `${contentLines.length} lines · ${scrollPct}`);
const footerRight = th.fg("dim", "↑↓ scroll · PgUp/PgDn · Esc close");
const footerGap = Math.max(1, innerW - visibleWidth(footerLeft) - visibleWidth(footerRight));
lines.push(row(footerLeft + " ".repeat(footerGap) + footerRight));
lines.push(hrBot);
return lines;
}
invalidate(): void { /* no cached state to clear */ }
dispose(): void {
this.closed = true;
if (this.unsubscribe) {
this.unsubscribe();
this.unsubscribe = undefined;
}
}
// ---- Private ----
private viewportHeight(): number {
return Math.max(MIN_VIEWPORT, this.tui.terminal.rows - CHROME_LINES);
}
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));
}
}

View File

@@ -0,0 +1,104 @@
/**
* 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 "@mariozechner/pi-coding-agent";
import type { SubagentScheduler } from "../schedule.js";
import type { ScheduledSubagent } from "../types.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;
}
const labels = jobs.map(j => formatJob(j, scheduler));
const choice = await ctx.ui.select(
`Scheduled jobs (${jobs.length}) — select to cancel`,
labels,
);
if (!choice) return;
const idx = labels.indexOf(choice);
if (idx < 0) return;
const job = jobs[idx];
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");
}

View File

@@ -0,0 +1,60 @@
/** 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.
*/
export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
/** Sum of lifetime usage components, or 0 if undefined. */
export function getLifetimeTotal(u?: LifetimeUsage): number {
return u ? u.input + u.output + u.cacheWrite : 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;
}
/** Minimal shape we read from upstream `getSessionStats()`. */
export type SessionStatsLike = {
tokens: { input: number; output: number; cacheWrite: number };
contextUsage?: { percent: number | null };
};
export type SessionLike = { getSessionStats(): SessionStatsLike };
/**
* Session-scoped token count: input + output + cacheWrite as reported by
* upstream `getSessionStats().tokens` for the *current* session window.
*
* RESETS at compaction — upstream replaces `session.state.messages` and the
* stats are derived from that array. For a lifetime total that survives
* compaction, use `getLifetimeTotal(lifetimeUsage)` instead, which reads
* from an independent accumulator fed by `message_end` events.
*
* Avoids upstream's `tokens.total` field, which sums per-turn `cacheRead`
* and so counts the cumulative cached prefix N times across N turns
* (issue #38).
*/
export function getSessionTokens(session: SessionLike | undefined): number {
if (!session) return 0;
try {
const t = session.getSessionStats().tokens;
return t.input + t.output + t.cacheWrite;
} catch { return 0; }
}
/**
* Context-window utilization (0100), or null when unavailable
* (no model contextWindow, or post-compaction before the next response).
*/
export function getSessionContextPercent(session: SessionLike | undefined): number | null {
if (!session) return null;
try { return session.getSessionStats().contextUsage?.percent ?? null; }
catch { return null; }
}

View File

@@ -0,0 +1,162 @@
/**
* 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 } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
export interface WorktreeInfo {
/** Absolute path to the worktree directory. */
path: string;
/** Branch name created for this worktree (if changes exist). */
branch: string;
}
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)
try {
execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "pipe", timeout: 5000 });
execFileSync("git", ["rev-parse", "HEAD"], { cwd, stdio: "pipe", timeout: 5000 });
} 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 };
} 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) {
// No changes — remove worktree
removeWorktree(cwd, worktree.path);
return { hasChanges: false };
}
// 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", "-m", commitMsg], {
cwd: worktree.path,
stdio: "pipe",
timeout: 10000,
});
// 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 */ }
}