Add 5 pi extensions: pi-subagents, pi-crew, rpiv-pi, pi-interactive-shell, pi-intercom
This commit is contained in:
479
extensions/pi-subagents/src/agent-manager.ts
Normal file
479
extensions/pi-subagents/src/agent-manager.ts
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
479
extensions/pi-subagents/src/agent-runner.ts
Normal file
479
extensions/pi-subagents/src/agent-runner.ts
Normal 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");
|
||||
}
|
||||
164
extensions/pi-subagents/src/agent-types.ts
Normal file
164
extensions/pi-subagents/src/agent-types.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
|
||||
58
extensions/pi-subagents/src/context.ts
Normal file
58
extensions/pi-subagents/src/context.ts
Normal 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)
|
||||
`;
|
||||
}
|
||||
95
extensions/pi-subagents/src/cross-extension-rpc.ts
Normal file
95
extensions/pi-subagents/src/cross-extension-rpc.ts
Normal 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 };
|
||||
}
|
||||
136
extensions/pi-subagents/src/custom-agents.ts
Normal file
136
extensions/pi-subagents/src/custom-agents.ts
Normal 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;
|
||||
}
|
||||
123
extensions/pi-subagents/src/default-agents.ts
Normal file
123
extensions/pi-subagents/src/default-agents.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
]);
|
||||
33
extensions/pi-subagents/src/env.ts
Normal file
33
extensions/pi-subagents/src/env.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
141
extensions/pi-subagents/src/group-join.ts
Normal file
141
extensions/pi-subagents/src/group-join.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
1884
extensions/pi-subagents/src/index.ts
Normal file
1884
extensions/pi-subagents/src/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
40
extensions/pi-subagents/src/invocation-config.ts
Normal file
40
extensions/pi-subagents/src/invocation-config.ts
Normal 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;
|
||||
}
|
||||
165
extensions/pi-subagents/src/memory.ts
Normal file
165
extensions/pi-subagents/src/memory.ts
Normal 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;
|
||||
}
|
||||
81
extensions/pi-subagents/src/model-resolver.ts
Normal file
81
extensions/pi-subagents/src/model-resolver.ts
Normal 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}`;
|
||||
}
|
||||
96
extensions/pi-subagents/src/output-file.ts
Normal file
96
extensions/pi-subagents/src/output-file.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
85
extensions/pi-subagents/src/prompts.ts
Normal file
85
extensions/pi-subagents/src/prompts.ts
Normal 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.`;
|
||||
143
extensions/pi-subagents/src/schedule-store.ts
Normal file
143
extensions/pi-subagents/src/schedule-store.ts
Normal 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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
365
extensions/pi-subagents/src/schedule.ts
Normal file
365
extensions/pi-subagents/src/schedule.ts
Normal 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"];
|
||||
}
|
||||
}
|
||||
186
extensions/pi-subagents/src/settings.ts
Normal file
186
extensions/pi-subagents/src/settings.ts
Normal 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);
|
||||
}
|
||||
79
extensions/pi-subagents/src/skill-loader.ts
Normal file
79
extensions/pi-subagents/src/skill-loader.ts
Normal 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;
|
||||
}
|
||||
163
extensions/pi-subagents/src/types.ts
Normal file
163
extensions/pi-subagents/src/types.ts
Normal 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[];
|
||||
}
|
||||
518
extensions/pi-subagents/src/ui/agent-widget.ts
Normal file
518
extensions/pi-subagents/src/ui/agent-widget.ts
Normal 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, 70–85% warning, ≥85% error.
|
||||
* Compaction count rendered as `↻N` in dim.
|
||||
*
|
||||
* "12.3k token" — no annotations
|
||||
* "12.3k token (45%)" — percent only
|
||||
* "12.3k token (↻2)" — compactions only (e.g. right after compact)
|
||||
* "12.3k token (45% · ↻2)" — both
|
||||
*/
|
||||
export function formatSessionTokens(
|
||||
tokens: number,
|
||||
percent: number | null,
|
||||
theme: Theme,
|
||||
compactions = 0,
|
||||
): string {
|
||||
const tokenStr = formatTokens(tokens);
|
||||
const annot: string[] = [];
|
||||
if (percent !== null) {
|
||||
const color = percent >= 85 ? "error" : percent >= 70 ? "warning" : "dim";
|
||||
annot.push(theme.fg(color, `${Math.round(percent)}%`));
|
||||
}
|
||||
if (compactions > 0) {
|
||||
annot.push(theme.fg("dim", `↻${compactions}`));
|
||||
}
|
||||
if (annot.length === 0) return tokenStr;
|
||||
return `${tokenStr} (${annot.join(" · ")})`;
|
||||
}
|
||||
|
||||
/** Format turn count with optional max limit: "⟳5≤30" or "⟳5". */
|
||||
export function formatTurns(turnCount: number, maxTurns?: number | null): string {
|
||||
return maxTurns != null ? `⟳${turnCount}≤${maxTurns}` : `⟳${turnCount}`;
|
||||
}
|
||||
|
||||
/** Format milliseconds as human-readable duration. */
|
||||
export function formatMs(ms: number): string {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
/** Format duration from start/completed timestamps. */
|
||||
export function formatDuration(startedAt: number, completedAt?: number): string {
|
||||
if (completedAt) return formatMs(completedAt - startedAt);
|
||||
return `${formatMs(Date.now() - startedAt)} (running)`;
|
||||
}
|
||||
|
||||
/** Get display name for any agent type (built-in or custom). */
|
||||
export function getDisplayName(type: SubagentType): string {
|
||||
return getConfig(type).displayName;
|
||||
}
|
||||
|
||||
/** Short label for prompt mode: "twin" for append, nothing for replace (the default). */
|
||||
export function getPromptModeLabel(type: SubagentType): string | undefined {
|
||||
const config = getConfig(type);
|
||||
return config.promptMode === "append" ? "twin" : undefined;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
243
extensions/pi-subagents/src/ui/conversation-viewer.ts
Normal file
243
extensions/pi-subagents/src/ui/conversation-viewer.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
104
extensions/pi-subagents/src/ui/schedule-menu.ts
Normal file
104
extensions/pi-subagents/src/ui/schedule-menu.ts
Normal 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");
|
||||
}
|
||||
60
extensions/pi-subagents/src/usage.ts
Normal file
60
extensions/pi-subagents/src/usage.ts
Normal 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 (0–100), or null when unavailable
|
||||
* (no model contextWindow, or post-compaction before the next response).
|
||||
*/
|
||||
export function getSessionContextPercent(session: SessionLike | undefined): number | null {
|
||||
if (!session) return null;
|
||||
try { return session.getSessionStats().contextUsage?.percent ?? null; }
|
||||
catch { return null; }
|
||||
}
|
||||
162
extensions/pi-subagents/src/worktree.ts
Normal file
162
extensions/pi-subagents/src/worktree.ts
Normal 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 */ }
|
||||
}
|
||||
Reference in New Issue
Block a user