pi: replace vendored extensions with pi-task and herdr state
This commit is contained in:
parent
86b3bc3f0b
commit
4f55b7cc72
20 changed files with 464 additions and 6906 deletions
|
|
@ -1,39 +0,0 @@
|
|||
/**
|
||||
* Clipboard Image Labels
|
||||
*
|
||||
* Adds [Image #N] labels to messages that include pasted images,
|
||||
* matching the Claude Code / Codex visual style.
|
||||
*
|
||||
* Usage:
|
||||
* - Paste: Ctrl+V (Alt+V on Windows)
|
||||
* - Drag: drag any image file onto the terminal
|
||||
*
|
||||
* When images are detected, [Image #1], [Image #2], … labels are
|
||||
* appended to the message text so they appear in the conversation
|
||||
* history and are visible to the LLM alongside the actual image data.
|
||||
*
|
||||
* A small notification confirms how many images were attached.
|
||||
*/
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.on("input", async (event, ctx) => {
|
||||
// Nothing to do when no images are attached
|
||||
if (!event.images || event.images.length === 0) {
|
||||
return { action: "continue" };
|
||||
}
|
||||
|
||||
// Build [Image #N] labels – one per attached image
|
||||
const labels = event.images.map((_img, i) => `[Image #${i + 1}]`).join(" ");
|
||||
|
||||
// Append labels after any typed text (or use them alone if no text)
|
||||
const newText = event.text?.trim() ? `${event.text.trim()}\n\n${labels}` : labels;
|
||||
|
||||
// Brief confirmation so the user knows the paste registered
|
||||
const count = event.images.length;
|
||||
ctx.ui.notify(`📎 ${count} image${count > 1 ? "s" : ""} attached`, "info");
|
||||
|
||||
// Transform keeps the original images intact; only the text changes
|
||||
return { action: "transform", text: newText };
|
||||
});
|
||||
}
|
||||
261
.pi/agent/extensions/herdr-agent-state.ts
Normal file
261
.pi/agent/extensions/herdr-agent-state.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
// installed by herdr
|
||||
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
// add custom hooks/plugins beside this file instead of editing it.
|
||||
// HERDR_INTEGRATION_ID=pi
|
||||
// HERDR_INTEGRATION_VERSION=9
|
||||
// @ts-nocheck
|
||||
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
|
||||
const HERDR_ENV = process.env.HERDR_ENV;
|
||||
const socketPath = process.env.HERDR_SOCKET_PATH;
|
||||
const socketEndpoint =
|
||||
process.platform === "win32" && socketPath ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
||||
const paneId = process.env.HERDR_PANE_ID;
|
||||
const source = "herdr:pi";
|
||||
|
||||
function enabled() {
|
||||
return HERDR_ENV === "1" && !!socketPath && !!paneId;
|
||||
}
|
||||
|
||||
function sendRequestAttempt(request: unknown, timeoutMs: number): Promise<boolean> {
|
||||
if (!enabled()) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const finish = (delivered: boolean) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
socket.destroy();
|
||||
resolve(delivered);
|
||||
};
|
||||
|
||||
const socket = net.createConnection(socketEndpoint!);
|
||||
socket.on("error", () => finish(false));
|
||||
socket.on("connect", () => socket.write(`${JSON.stringify(request)}\n`));
|
||||
socket.on("data", () => finish(true));
|
||||
socket.on("end", () => finish(false));
|
||||
timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
timeout.unref?.();
|
||||
});
|
||||
}
|
||||
|
||||
async function sendRequest(request: unknown): Promise<void> {
|
||||
if (await sendRequestAttempt(request, 500)) {
|
||||
return;
|
||||
}
|
||||
await sendRequestAttempt(request, 1500);
|
||||
}
|
||||
|
||||
type AgentState = "working" | "blocked" | "idle";
|
||||
|
||||
type QueuedState = {
|
||||
state: AgentState;
|
||||
message?: string;
|
||||
seq: number;
|
||||
};
|
||||
|
||||
let reportSeq = Date.now() * 1000;
|
||||
let currentAgentSessionId: string | undefined;
|
||||
let currentAgentSessionPath: string | undefined;
|
||||
|
||||
function nextReportSeq(): number {
|
||||
reportSeq += 1;
|
||||
return reportSeq;
|
||||
}
|
||||
|
||||
function updateSessionRef(ctx: any): void {
|
||||
try {
|
||||
const file = ctx?.sessionManager?.getSessionFile?.();
|
||||
currentAgentSessionPath =
|
||||
typeof file === "string" &&
|
||||
(path.posix.isAbsolute(file) || path.win32.isAbsolute(file))
|
||||
? file
|
||||
: undefined;
|
||||
} catch {
|
||||
currentAgentSessionPath = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = ctx?.sessionManager?.getSessionId?.();
|
||||
currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
} catch {
|
||||
currentAgentSessionId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
|
||||
if (currentAgentSessionPath) {
|
||||
return { ...params, agent_session_path: currentAgentSessionPath };
|
||||
}
|
||||
if (currentAgentSessionId) {
|
||||
return { ...params, agent_session_id: currentAgentSessionId };
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function currentSessionRef(): Record<string, unknown> | undefined {
|
||||
if (currentAgentSessionPath) {
|
||||
return { agent_session_path: currentAgentSessionPath };
|
||||
}
|
||||
if (currentAgentSessionId) {
|
||||
return { agent_session_id: currentAgentSessionId };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function reportSession(sessionStartSource?: string): Promise<void> {
|
||||
const sessionRef = currentSessionRef();
|
||||
if (!sessionRef) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return sendRequest({
|
||||
id: `${source}:session:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.report_agent_session",
|
||||
params: {
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
seq: nextReportSeq(),
|
||||
session_start_source: sessionStartSource,
|
||||
...sessionRef,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
|
||||
return sendRequest({
|
||||
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.report_agent",
|
||||
params: withSessionRef({
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
state,
|
||||
message,
|
||||
seq,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let sendInFlight = false;
|
||||
let queuedState: QueuedState | undefined;
|
||||
|
||||
function queueState(state: AgentState, message?: string): void {
|
||||
queuedState = { state, message, seq: nextReportSeq() };
|
||||
if (!sendInFlight) {
|
||||
void drainStateQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async function drainStateQueue(): Promise<void> {
|
||||
if (sendInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendInFlight = true;
|
||||
try {
|
||||
while (queuedState) {
|
||||
const next = queuedState;
|
||||
queuedState = undefined;
|
||||
await sendState(next.state, next.message, next.seq);
|
||||
}
|
||||
} finally {
|
||||
sendInFlight = false;
|
||||
if (queuedState) {
|
||||
void drainStateQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function (pi) {
|
||||
if (!enabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let agentActive = false;
|
||||
let blockedCount = 0;
|
||||
let blockedMessage: string | undefined;
|
||||
let lastState: AgentState | undefined;
|
||||
let lastMessage: string | undefined;
|
||||
let rootSession = false;
|
||||
|
||||
function desiredState() {
|
||||
if (blockedCount > 0) {
|
||||
return { state: "blocked" as const, message: blockedMessage };
|
||||
}
|
||||
if (agentActive) {
|
||||
return { state: "working" as const, message: undefined };
|
||||
}
|
||||
return { state: "idle" as const, message: undefined };
|
||||
}
|
||||
|
||||
function publishState(force = false) {
|
||||
const next = desiredState();
|
||||
if (!force && next.state === lastState && next.message === lastMessage) {
|
||||
return;
|
||||
}
|
||||
lastState = next.state;
|
||||
lastMessage = next.message;
|
||||
queueState(next.state, next.message);
|
||||
}
|
||||
|
||||
pi.events.on("herdr:blocked", (data) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
if (!data?.active) {
|
||||
blockedCount = Math.max(0, blockedCount - 1);
|
||||
if (blockedCount === 0) {
|
||||
blockedMessage = undefined;
|
||||
}
|
||||
publishState();
|
||||
return;
|
||||
}
|
||||
|
||||
blockedCount += 1;
|
||||
blockedMessage = data.label;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("session_start", async (event, ctx) => {
|
||||
// TUI only: RPC/JSON/print modes are headless (no PTY herdr can display),
|
||||
// and RPC still reports hasUI=true, so mode is the reliable gate.
|
||||
if (ctx?.mode !== "tui") {
|
||||
return;
|
||||
}
|
||||
rootSession = true;
|
||||
updateSessionRef(ctx);
|
||||
await reportSession(event?.reason);
|
||||
// A reload can replace this extension mid-run without emitting another agent_start.
|
||||
agentActive = ctx?.isIdle?.() === false;
|
||||
publishState(true);
|
||||
});
|
||||
|
||||
pi.on("agent_start", (_event, ctx) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
updateSessionRef(ctx);
|
||||
void reportSession();
|
||||
agentActive = true;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("agent_settled", (_event, ctx) => {
|
||||
if (!rootSession || ctx?.isIdle?.() !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
agentActive = false;
|
||||
publishState();
|
||||
});
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
# pi lsp-feedback
|
||||
|
||||
Auto-detected defaults for this machine:
|
||||
|
||||
- `.nix` -> `~/.nix-profile/bin/nixd` + `alejandra`
|
||||
- `.sh`, `.bash` -> `~/.nix-profile/bin/bash-language-server start` + `shfmt -w`
|
||||
- `.lua` -> `~/.nix-profile/bin/lua-language-server` + `stylua`
|
||||
|
||||
## What it does
|
||||
|
||||
- Overrides pi `write` and `edit`
|
||||
- After a file change, optionally formats the file
|
||||
- Re-syncs the final file content into the language server
|
||||
- Adds a compact diagnostics summary to tool output
|
||||
- Shows a compact footer status only after supported LSP activity
|
||||
|
||||
## Commands
|
||||
|
||||
- `/diag path/to/file` - compact diagnostics for one file
|
||||
- `/lsp-restart` - restart managed language servers
|
||||
|
||||
## Tool
|
||||
|
||||
- `lsp_diagnostics` - on-demand diagnostics for supported files
|
||||
|
||||
## Notes
|
||||
|
||||
- This MVP is intentionally compact and context-efficient.
|
||||
- It stays silent on startup and only becomes visible after you touch or check a supported file.
|
||||
- Whole-project diagnostics are not implemented yet.
|
||||
- If you change this extension, run `/reload` in pi.
|
||||
|
|
@ -1,606 +0,0 @@
|
|||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { extname, isAbsolute, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { createEditTool, createWriteTool, truncateHead } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { StreamMessageReader, StreamMessageWriter, createMessageConnection } from "vscode-jsonrpc/lib/node/main.js";
|
||||
|
||||
type Diagnostic = {
|
||||
range?: {
|
||||
start?: { line?: number; character?: number };
|
||||
end?: { line?: number; character?: number };
|
||||
};
|
||||
severity?: number;
|
||||
code?: string | number;
|
||||
source?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type FormatterConfig = {
|
||||
command: string;
|
||||
args: string[];
|
||||
};
|
||||
|
||||
type ServerConfig = {
|
||||
name: string;
|
||||
command: string;
|
||||
args?: string[];
|
||||
extensions: string[];
|
||||
languageId: string;
|
||||
formatter?: FormatterConfig;
|
||||
};
|
||||
|
||||
type FeedbackSummary = {
|
||||
text: string;
|
||||
status: string;
|
||||
severity: "info" | "warning" | "error";
|
||||
errors: number;
|
||||
warnings: number;
|
||||
};
|
||||
|
||||
const MAX_INLINE_DIAGNOSTICS = 3;
|
||||
const DIAGNOSTIC_TIMEOUT_MS = 1500;
|
||||
const STATUS_KEY = "lsp-feedback";
|
||||
const profileBin = resolve(homedir(), ".nix-profile/bin");
|
||||
const perUserProfileBin = process.env.USER ? `/etc/profiles/per-user/${process.env.USER}/bin` : undefined;
|
||||
|
||||
function expandHome(path: string): string {
|
||||
if (path === "~") return homedir();
|
||||
if (path.startsWith("~/")) return resolve(homedir(), path.slice(2));
|
||||
return path;
|
||||
}
|
||||
|
||||
function normalizePath(cwd: string, input: string): string {
|
||||
const raw = input.startsWith("@") ? input.slice(1) : input;
|
||||
const expanded = expandHome(raw);
|
||||
return isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
|
||||
}
|
||||
|
||||
function shortPath(cwd: string, absolutePath: string): string {
|
||||
if (absolutePath.startsWith(`${cwd}/`)) return absolutePath.slice(cwd.length + 1);
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
function formatCommandArgs(args: string[], file: string): string[] {
|
||||
return args.map((arg) => arg.replaceAll("{file}", file));
|
||||
}
|
||||
|
||||
function commandExists(path: string | undefined): path is string {
|
||||
return !!path && existsSync(expandHome(path));
|
||||
}
|
||||
|
||||
function defaultServers(): ServerConfig[] {
|
||||
const candidates: ServerConfig[] = [
|
||||
{
|
||||
name: "nixd",
|
||||
command: `${profileBin}/nixd`,
|
||||
extensions: [".nix"],
|
||||
languageId: "nix",
|
||||
formatter: commandExists(`${profileBin}/alejandra`)
|
||||
? { command: `${profileBin}/alejandra`, args: ["{file}"] }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
name: "bash-language-server",
|
||||
command: `${profileBin}/bash-language-server`,
|
||||
args: ["start"],
|
||||
extensions: [".sh", ".bash"],
|
||||
languageId: "shellscript",
|
||||
formatter: commandExists(`${profileBin}/shfmt`)
|
||||
? { command: `${profileBin}/shfmt`, args: ["-w", "{file}"] }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
name: "lua-language-server",
|
||||
command: `${profileBin}/lua-language-server`,
|
||||
extensions: [".lua"],
|
||||
languageId: "lua",
|
||||
formatter: commandExists(`${profileBin}/stylua`)
|
||||
? { command: `${profileBin}/stylua`, args: ["{file}"] }
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
return candidates.filter((server) => commandExists(server.command));
|
||||
}
|
||||
|
||||
function findServer(servers: ServerConfig[], filePath: string): ServerConfig | undefined {
|
||||
const extension = extname(filePath).toLowerCase();
|
||||
return servers.find((server) => server.extensions.includes(extension));
|
||||
}
|
||||
|
||||
function severityName(severity?: number): string {
|
||||
switch (severity) {
|
||||
case 1:
|
||||
return "E";
|
||||
case 2:
|
||||
return "W";
|
||||
case 3:
|
||||
return "I";
|
||||
case 4:
|
||||
return "H";
|
||||
default:
|
||||
return "?";
|
||||
}
|
||||
}
|
||||
|
||||
function compactMessage(message: string | undefined): string {
|
||||
const singleLine = (message ?? "Unknown diagnostic").replace(/\s+/g, " ").trim();
|
||||
return singleLine.length > 180 ? `${singleLine.slice(0, 177)}...` : singleLine;
|
||||
}
|
||||
|
||||
function summarizeDiagnostics(fileLabel: string, diagnostics: Diagnostic[]): FeedbackSummary {
|
||||
const errors = diagnostics.filter((diag) => diag.severity === 1).length;
|
||||
const warnings = diagnostics.filter((diag) => diag.severity === 2).length;
|
||||
const severity: "info" | "warning" | "error" = errors > 0 ? "error" : warnings > 0 ? "warning" : "info";
|
||||
|
||||
if (diagnostics.length === 0) {
|
||||
return {
|
||||
text: `LSP ${fileLabel}: clean`,
|
||||
status: "clean",
|
||||
severity,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
const head = diagnostics.slice(0, MAX_INLINE_DIAGNOSTICS).map((diag) => {
|
||||
const line = (diag.range?.start?.line ?? 0) + 1;
|
||||
const character = (diag.range?.start?.character ?? 0) + 1;
|
||||
const source = diag.source ? ` ${diag.source}` : "";
|
||||
const code = diag.code !== undefined ? ` ${String(diag.code)}` : "";
|
||||
return `${severityName(diag.severity)} ${line}:${character}${source}${code} ${compactMessage(diag.message)}`;
|
||||
});
|
||||
|
||||
const remaining = diagnostics.length - head.length;
|
||||
const counts = `${errors} error(s), ${warnings} warning(s), ${diagnostics.length} total`;
|
||||
const remainder = remaining > 0 ? `\n… ${remaining} more diagnostic(s)` : "";
|
||||
|
||||
return {
|
||||
text: `LSP ${fileLabel}: ${counts}\n${head.join("\n")}${remainder}`,
|
||||
status: `${errors}E ${warnings}W`,
|
||||
severity,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
function compactStatus(server: ServerConfig, summary: FeedbackSummary): string {
|
||||
return summary.errors === 0 && summary.warnings === 0 ? `${server.name} clean` : `${server.name} ${summary.status}`;
|
||||
}
|
||||
|
||||
function buildEnv(): NodeJS.ProcessEnv {
|
||||
const pathParts = [profileBin, perUserProfileBin, process.env.PATH].filter(Boolean);
|
||||
return {
|
||||
...process.env,
|
||||
PATH: pathParts.join(":"),
|
||||
};
|
||||
}
|
||||
|
||||
async function runCommand(command: string, args: string[], cwd: string): Promise<{ code: number | null; stdout: string; stderr: string }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env: buildEnv(),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
|
||||
child.on("error", reject);
|
||||
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
function getPrimaryText(content: Array<{ type: string; text?: string }>): string {
|
||||
const firstText = content.find((item) => item.type === "text" && typeof item.text === "string");
|
||||
return firstText?.text ?? "";
|
||||
}
|
||||
|
||||
class ManagedLspClient {
|
||||
private process?: ChildProcessWithoutNullStreams;
|
||||
private connection?: ReturnType<typeof createMessageConnection>;
|
||||
private initializePromise?: Promise<void>;
|
||||
private openDocuments = new Set<string>();
|
||||
private documentVersions = new Map<string, number>();
|
||||
private diagnostics = new Map<string, Diagnostic[]>();
|
||||
private waiters = new Map<string, Set<(diagnostics: Diagnostic[]) => void>>();
|
||||
|
||||
constructor(private readonly rootDir: string, private readonly server: ServerConfig) {}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
if (this.initializePromise) return this.initializePromise;
|
||||
|
||||
this.process = spawn(this.server.command, this.server.args ?? [], {
|
||||
cwd: this.rootDir,
|
||||
env: buildEnv(),
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
this.connection = createMessageConnection(
|
||||
new StreamMessageReader(this.process.stdout),
|
||||
new StreamMessageWriter(this.process.stdin),
|
||||
);
|
||||
|
||||
this.connection.onNotification("textDocument/publishDiagnostics", (params: any) => {
|
||||
const uri = String(params?.uri ?? "");
|
||||
const diagnostics = Array.isArray(params?.diagnostics) ? (params.diagnostics as Diagnostic[]) : [];
|
||||
this.diagnostics.set(uri, diagnostics);
|
||||
const waiters = this.waiters.get(uri);
|
||||
if (waiters) {
|
||||
for (const resolve of waiters) resolve(diagnostics);
|
||||
this.waiters.delete(uri);
|
||||
}
|
||||
});
|
||||
|
||||
this.connection.listen();
|
||||
|
||||
this.initializePromise = this.connection
|
||||
.sendRequest("initialize", {
|
||||
processId: process.pid,
|
||||
rootUri: pathToFileURL(this.rootDir).toString(),
|
||||
capabilities: {
|
||||
textDocument: {
|
||||
publishDiagnostics: {
|
||||
relatedInformation: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
clientInfo: {
|
||||
name: "pi-lsp-feedback",
|
||||
version: "0.1.0",
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
this.connection?.sendNotification("initialized", {});
|
||||
});
|
||||
|
||||
return this.initializePromise;
|
||||
}
|
||||
|
||||
private waitForNextDiagnostics(uri: string): Promise<Diagnostic[]> {
|
||||
return new Promise((resolve) => {
|
||||
const existing = this.waiters.get(uri) ?? new Set<(diagnostics: Diagnostic[]) => void>();
|
||||
this.waiters.set(uri, existing);
|
||||
existing.add(resolve);
|
||||
|
||||
setTimeout(() => {
|
||||
const waiters = this.waiters.get(uri);
|
||||
if (waiters?.has(resolve)) {
|
||||
waiters.delete(resolve);
|
||||
if (waiters.size === 0) this.waiters.delete(uri);
|
||||
resolve(this.diagnostics.get(uri) ?? []);
|
||||
}
|
||||
}, DIAGNOSTIC_TIMEOUT_MS);
|
||||
});
|
||||
}
|
||||
|
||||
async syncFile(filePath: string, text: string): Promise<Diagnostic[]> {
|
||||
await this.start();
|
||||
const uri = pathToFileURL(filePath).toString();
|
||||
const nextVersion = (this.documentVersions.get(uri) ?? 0) + 1;
|
||||
this.documentVersions.set(uri, nextVersion);
|
||||
const waitForDiagnostics = this.waitForNextDiagnostics(uri);
|
||||
|
||||
if (!this.openDocuments.has(uri)) {
|
||||
this.connection?.sendNotification("textDocument/didOpen", {
|
||||
textDocument: {
|
||||
uri,
|
||||
languageId: this.server.languageId,
|
||||
version: nextVersion,
|
||||
text,
|
||||
},
|
||||
});
|
||||
this.openDocuments.add(uri);
|
||||
} else {
|
||||
this.connection?.sendNotification("textDocument/didChange", {
|
||||
textDocument: { uri, version: nextVersion },
|
||||
contentChanges: [{ text }],
|
||||
});
|
||||
}
|
||||
|
||||
this.connection?.sendNotification("textDocument/didSave", {
|
||||
textDocument: { uri },
|
||||
text,
|
||||
});
|
||||
|
||||
return waitForDiagnostics;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
try {
|
||||
await this.connection?.sendRequest("shutdown");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
this.connection?.sendNotification("exit");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
this.connection?.dispose();
|
||||
this.connection = undefined;
|
||||
this.initializePromise = undefined;
|
||||
this.openDocuments.clear();
|
||||
this.documentVersions.clear();
|
||||
this.waiters.clear();
|
||||
this.diagnostics.clear();
|
||||
|
||||
if (this.process && !this.process.killed) {
|
||||
this.process.kill();
|
||||
}
|
||||
this.process = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export default function lspFeedbackExtension(pi: ExtensionAPI) {
|
||||
const cwd = process.cwd();
|
||||
const servers = defaultServers();
|
||||
const clients = new Map<string, ManagedLspClient>();
|
||||
|
||||
const writeTool = createWriteTool(cwd);
|
||||
const editTool = createEditTool(cwd);
|
||||
|
||||
const getClient = (server: ServerConfig): ManagedLspClient => {
|
||||
const key = `${server.name}:${cwd}`;
|
||||
let client = clients.get(key);
|
||||
if (!client) {
|
||||
client = new ManagedLspClient(cwd, server);
|
||||
clients.set(key, client);
|
||||
}
|
||||
return client;
|
||||
};
|
||||
|
||||
const applyFormatter = async (server: ServerConfig, filePath: string): Promise<{ ran: boolean; changed: boolean; error?: string }> => {
|
||||
if (!server.formatter) return { ran: false, changed: false };
|
||||
|
||||
let before = "";
|
||||
try {
|
||||
before = await readFile(filePath, "utf-8");
|
||||
} catch (error: any) {
|
||||
return { ran: false, changed: false, error: `formatter pre-read failed: ${error.message}` };
|
||||
}
|
||||
|
||||
const result = await runCommand(server.formatter.command, formatCommandArgs(server.formatter.args, filePath), cwd);
|
||||
|
||||
if (result.code !== 0) {
|
||||
return {
|
||||
ran: true,
|
||||
changed: false,
|
||||
error: (result.stderr || result.stdout || `formatter exited with ${result.code}`).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const after = await readFile(filePath, "utf-8");
|
||||
return { ran: true, changed: before !== after };
|
||||
} catch (error: any) {
|
||||
return { ran: true, changed: false, error: `formatter post-read failed: ${error.message}` };
|
||||
}
|
||||
};
|
||||
|
||||
const collectFeedback = async (absolutePath: string) => {
|
||||
const server = findServer(servers, absolutePath);
|
||||
if (!server) return undefined;
|
||||
|
||||
let formatterNote: string | undefined;
|
||||
const formatter = await applyFormatter(server, absolutePath);
|
||||
if (formatter.error) formatterNote = `Formatter error: ${formatter.error}`;
|
||||
else if (formatter.ran && formatter.changed) formatterNote = `Formatted with ${server.formatter?.command.split("/").pop()}`;
|
||||
|
||||
const text = await readFile(absolutePath, "utf-8");
|
||||
const diagnostics = await getClient(server).syncFile(absolutePath, text);
|
||||
const summary = summarizeDiagnostics(shortPath(cwd, absolutePath), diagnostics);
|
||||
|
||||
return {
|
||||
summary,
|
||||
diagnostics,
|
||||
formatterNote,
|
||||
server,
|
||||
};
|
||||
};
|
||||
|
||||
const appendFeedback = (baseText: string, formatterNote: string | undefined, summary: FeedbackSummary): string => {
|
||||
const extra = [formatterNote, summary.text].filter(Boolean).join("\n");
|
||||
return extra ? `${baseText}\n${extra}` : baseText;
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
// Stay silent until a supported file is actually touched or checked.
|
||||
ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async () => {
|
||||
await Promise.all([...clients.values()].map((client) => client.stop()));
|
||||
clients.clear();
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...writeTool,
|
||||
async execute(id, params, signal, onUpdate, ctx) {
|
||||
const result = await writeTool.execute(id, params, signal, onUpdate);
|
||||
const absolutePath = normalizePath(ctx.cwd, params.path);
|
||||
const feedback = await collectFeedback(absolutePath).catch((error: any) => ({
|
||||
summary: undefined,
|
||||
diagnostics: [],
|
||||
formatterNote: `LSP feedback failed: ${error.message}`,
|
||||
}));
|
||||
|
||||
if (!feedback?.summary) {
|
||||
if (feedback?.formatterNote) {
|
||||
ctx.ui.notify(feedback.formatterNote, "warning");
|
||||
return {
|
||||
...result,
|
||||
content: [{ type: "text", text: appendFeedback(getPrimaryText(result.content), feedback.formatterNote, {
|
||||
text: "",
|
||||
status: "",
|
||||
severity: "warning",
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
}) }],
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ctx.ui.setStatus(STATUS_KEY, compactStatus(feedback.server, feedback.summary));
|
||||
if (feedback.summary.severity !== "info" || feedback.formatterNote) {
|
||||
ctx.ui.notify([feedback.formatterNote, feedback.summary.text].filter(Boolean).join("\n"), feedback.summary.severity);
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
content: [{ type: "text", text: appendFeedback(getPrimaryText(result.content), feedback.formatterNote, feedback.summary) }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
...editTool,
|
||||
async execute(id, params, signal, onUpdate, ctx) {
|
||||
const result = await editTool.execute(id, params, signal, onUpdate);
|
||||
const absolutePath = normalizePath(ctx.cwd, params.path);
|
||||
const feedback = await collectFeedback(absolutePath).catch((error: any) => ({
|
||||
summary: undefined,
|
||||
diagnostics: [],
|
||||
formatterNote: `LSP feedback failed: ${error.message}`,
|
||||
}));
|
||||
|
||||
if (!feedback?.summary) {
|
||||
if (feedback?.formatterNote) {
|
||||
ctx.ui.notify(feedback.formatterNote, "warning");
|
||||
return {
|
||||
...result,
|
||||
content: [{ type: "text", text: appendFeedback(getPrimaryText(result.content), feedback.formatterNote, {
|
||||
text: "",
|
||||
status: "",
|
||||
severity: "warning",
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
}) }],
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
ctx.ui.setStatus(STATUS_KEY, compactStatus(feedback.server, feedback.summary));
|
||||
if (feedback.summary.severity !== "info" || feedback.formatterNote) {
|
||||
ctx.ui.notify([feedback.formatterNote, feedback.summary.text].filter(Boolean).join("\n"), feedback.summary.severity);
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
content: [{ type: "text", text: appendFeedback(getPrimaryText(result.content), feedback.formatterNote, feedback.summary) }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "lsp_diagnostics",
|
||||
label: "LSP Diagnostics",
|
||||
description: "Get current diagnostics for a supported file (.nix, .sh, .bash, .lua). Syncs the file through the local language server and returns a compact report.",
|
||||
promptSnippet: "Get current LSP diagnostics for a supported file after edits",
|
||||
promptGuidelines: ["Use this tool when you need the current diagnostics for a Nix, shell, or Lua file."],
|
||||
parameters: Type.Object({
|
||||
path: Type.String({ description: "Path to the file to check" }),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const absolutePath = normalizePath(ctx.cwd, params.path);
|
||||
const feedback = await collectFeedback(absolutePath);
|
||||
if (!feedback) {
|
||||
return {
|
||||
content: [{ type: "text", text: `No configured LSP server for ${params.path}` }],
|
||||
details: { supported: false },
|
||||
};
|
||||
}
|
||||
|
||||
const lines = feedback.diagnostics.map((diag) => {
|
||||
const line = (diag.range?.start?.line ?? 0) + 1;
|
||||
const character = (diag.range?.start?.character ?? 0) + 1;
|
||||
const source = diag.source ? ` ${diag.source}` : "";
|
||||
const code = diag.code !== undefined ? ` ${String(diag.code)}` : "";
|
||||
return `${severityName(diag.severity)} ${line}:${character}${source}${code} ${compactMessage(diag.message)}`;
|
||||
});
|
||||
|
||||
const body = [
|
||||
`File: ${shortPath(ctx.cwd, absolutePath)}`,
|
||||
feedback.formatterNote,
|
||||
feedback.summary.text,
|
||||
...(lines.length > 0 ? [""] : []),
|
||||
...lines,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
const truncated = truncateHead(body, { maxLines: 120, maxBytes: 24 * 1024 });
|
||||
const text = truncated.truncated ? `${truncated.content}\n\n[Diagnostics truncated. ${feedback.diagnostics.length} total item(s)]` : body;
|
||||
|
||||
ctx.ui.setStatus(STATUS_KEY, compactStatus(feedback.server, feedback.summary));
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: {
|
||||
server: feedback.server.name,
|
||||
diagnostics: feedback.diagnostics,
|
||||
formatterNote: feedback.formatterNote,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("lsp-restart", {
|
||||
description: "Restart all managed language servers",
|
||||
handler: async (_args, ctx) => {
|
||||
await Promise.all([...clients.values()].map((client) => client.stop()));
|
||||
clients.clear();
|
||||
ctx.ui.setStatus(STATUS_KEY, undefined);
|
||||
ctx.ui.notify("LSP feedback servers restarted", "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("diag", {
|
||||
description: "Show compact diagnostics for a supported file: /diag path/to/file",
|
||||
handler: async (args, ctx) => {
|
||||
const input = args?.trim();
|
||||
if (!input) {
|
||||
ctx.ui.notify("Usage: /diag path/to/file", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
const absolutePath = normalizePath(ctx.cwd, input);
|
||||
try {
|
||||
const fileStat = await stat(absolutePath);
|
||||
if (!fileStat.isFile()) {
|
||||
ctx.ui.notify(`Not a file: ${input}`, "warning");
|
||||
return;
|
||||
}
|
||||
} catch (error: any) {
|
||||
ctx.ui.notify(`Cannot read ${input}: ${error.message}`, "error");
|
||||
return;
|
||||
}
|
||||
|
||||
const feedback = await collectFeedback(absolutePath);
|
||||
if (!feedback) {
|
||||
ctx.ui.notify(`No configured LSP server for ${input}`, "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.setStatus(STATUS_KEY, compactStatus(feedback.server, feedback.summary));
|
||||
ctx.ui.notify([feedback.formatterNote, feedback.summary.text].filter(Boolean).join("\n"), feedback.summary.severity);
|
||||
},
|
||||
});
|
||||
}
|
||||
3959
.pi/agent/extensions/lsp-feedback/package-lock.json
generated
3959
.pi/agent/extensions/lsp-feedback/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,20 +0,0 @@
|
|||
{
|
||||
"name": "pi-lsp-feedback",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-coding-agent": "^0.57.1",
|
||||
"@sinclair/typebox": "^0.34.48",
|
||||
"vscode-jsonrpc": "^8.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
24
.pi/agent/extensions/pi-task/README.md
Normal file
24
.pi/agent/extensions/pi-task/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# pi-task
|
||||
|
||||
A small, session-scoped task list for Pi. It adds one agent tool, `task`, a
|
||||
compact widget above the editor, and a progress indicator in the footer.
|
||||
|
||||
The tool accepts the complete ordered task list, like Claude's todo tool or
|
||||
Codex's plan tool. The agent adds, updates, removes, or reorders tasks by
|
||||
submitting a new list. Omitting the list reads the current state; submitting an
|
||||
empty list clears it.
|
||||
|
||||
Statuses are `pending`, `in_progress`, and `completed`. At most one task can be
|
||||
in progress at a time.
|
||||
|
||||
State is stored in Pi tool-result details. It survives reloads and compaction,
|
||||
follows session branches correctly, and does not create project files. A new Pi
|
||||
session starts with an empty list.
|
||||
|
||||
While work is active, the widget uses themed symbols and colors to distinguish
|
||||
the current task (`●`) from pending tasks (`○`). The footer shows compact
|
||||
overall progress. When every task is complete, Pi displays a success
|
||||
notification and removes both the widget and footer indicator to reclaim space.
|
||||
The completed list remains available in session history.
|
||||
|
||||
Run `/tasks` to refresh the task UI manually.
|
||||
179
.pi/agent/extensions/pi-task/index.ts
Normal file
179
.pi/agent/extensions/pi-task/index.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import type {
|
||||
ExtensionAPI,
|
||||
ExtensionContext,
|
||||
Theme,
|
||||
} from "@earendil-works/pi-coding-agent";
|
||||
import { StringEnum } from "@earendil-works/pi-ai";
|
||||
import { Text } from "@earendil-works/pi-tui";
|
||||
import { Type } from "typebox";
|
||||
|
||||
type TaskStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
interface Task {
|
||||
text: string;
|
||||
status: TaskStatus;
|
||||
}
|
||||
|
||||
interface TaskDetails {
|
||||
tasks: Task[];
|
||||
}
|
||||
|
||||
const TaskParams = Type.Object({
|
||||
tasks: Type.Optional(
|
||||
Type.Array(
|
||||
Type.Object({
|
||||
text: Type.String({ description: "Short, outcome-oriented task description" }),
|
||||
status: StringEnum(["pending", "in_progress", "completed"] as const),
|
||||
}),
|
||||
{
|
||||
description:
|
||||
"Complete ordered task list. Submit the full list to add, update, reorder, or remove tasks. Omit to read it.",
|
||||
},
|
||||
),
|
||||
),
|
||||
});
|
||||
|
||||
function cloneTasks(tasks: Task[]): Task[] {
|
||||
return tasks.map((task) => ({ ...task }));
|
||||
}
|
||||
|
||||
function formatTask(task: Task): string {
|
||||
const marker = {
|
||||
pending: "○",
|
||||
in_progress: "●",
|
||||
completed: "✓",
|
||||
}[task.status];
|
||||
|
||||
return `${marker} ${task.text}`;
|
||||
}
|
||||
|
||||
function formatThemedTask(task: Task, theme: Theme): string {
|
||||
const marker = {
|
||||
pending: theme.fg("dim", "○"),
|
||||
in_progress: theme.fg("accent", "●"),
|
||||
completed: theme.fg("success", "✓"),
|
||||
}[task.status];
|
||||
const text = task.status === "pending" ? theme.fg("muted", task.text) : theme.fg("text", task.text);
|
||||
|
||||
return `${marker} ${text}`;
|
||||
}
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
let tasks: Task[] = [];
|
||||
|
||||
const refreshTaskUI = (ctx: ExtensionContext) => {
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const completed = tasks.filter((task) => task.status === "completed").length;
|
||||
const remaining = tasks.filter((task) => task.status !== "completed");
|
||||
|
||||
if (remaining.length === 0) {
|
||||
ctx.ui.setWidget("pi-task", undefined);
|
||||
ctx.ui.setStatus("pi-task", undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.ui.setStatus(
|
||||
"pi-task",
|
||||
ctx.ui.theme.fg("accent", `● Tasks ${completed}/${tasks.length}`),
|
||||
);
|
||||
ctx.ui.setWidget("pi-task", (_tui, theme) => {
|
||||
const visible = remaining.slice(0, 7);
|
||||
const lines = [
|
||||
`${theme.fg("accent", theme.bold("Tasks"))} ${theme.fg("muted", `${completed}/${tasks.length}`)}`,
|
||||
...visible.map((task) => formatThemedTask(task, theme)),
|
||||
];
|
||||
const hidden = remaining.length - visible.length;
|
||||
if (hidden > 0) lines.push(theme.fg("dim", `… ${hidden} more`));
|
||||
|
||||
return new Text(lines.join("\n"), 0, 0);
|
||||
});
|
||||
};
|
||||
|
||||
const reconstructState = (ctx: ExtensionContext) => {
|
||||
tasks = [];
|
||||
|
||||
for (const entry of ctx.sessionManager.getBranch()) {
|
||||
if (entry.type !== "message") continue;
|
||||
const message = entry.message;
|
||||
if (message.role !== "toolResult" || message.toolName !== "task") continue;
|
||||
|
||||
const saved = message.details as TaskDetails | undefined;
|
||||
if (saved && Array.isArray(saved.tasks)) tasks = cloneTasks(saved.tasks);
|
||||
}
|
||||
|
||||
refreshTaskUI(ctx);
|
||||
};
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => reconstructState(ctx));
|
||||
pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
|
||||
|
||||
pi.registerTool({
|
||||
name: "task",
|
||||
label: "Task",
|
||||
description:
|
||||
"Read or replace the complete ordered task list. Submit the full list to add, update, reorder, remove, or clear tasks. At most one task may be in_progress.",
|
||||
promptSnippet: "Maintain an ordered task list for multi-step work",
|
||||
promptGuidelines: [
|
||||
"Use task for work with multiple meaningful steps; skip it for simple one-step requests.",
|
||||
"Keep task items short and outcome-oriented, and submit the complete ordered list whenever it changes.",
|
||||
"Keep exactly one task in_progress while actively working, and complete all tasks before finishing.",
|
||||
],
|
||||
parameters: TaskParams,
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const wasComplete = tasks.length > 0 && tasks.every((task) => task.status === "completed");
|
||||
|
||||
if (params.tasks !== undefined) {
|
||||
const normalized = params.tasks.map((task) => ({
|
||||
text: task.text.trim(),
|
||||
status: task.status,
|
||||
}));
|
||||
|
||||
if (normalized.some((task) => task.text.length === 0)) {
|
||||
throw new Error("Task text cannot be empty");
|
||||
}
|
||||
|
||||
const active = normalized.filter((task) => task.status === "in_progress").length;
|
||||
if (active > 1) {
|
||||
throw new Error("Only one task may be in_progress");
|
||||
}
|
||||
|
||||
tasks = normalized;
|
||||
}
|
||||
|
||||
const isComplete = tasks.length > 0 && tasks.every((task) => task.status === "completed");
|
||||
refreshTaskUI(ctx);
|
||||
if (ctx.hasUI && isComplete && !wasComplete) {
|
||||
ctx.ui.notify(`✓ All ${tasks.length} tasks completed`, "info");
|
||||
}
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: tasks.length > 0 ? tasks.map(formatTask).join("\n") : "No tasks",
|
||||
},
|
||||
],
|
||||
details: { tasks: cloneTasks(tasks) } satisfies TaskDetails,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerCommand("tasks", {
|
||||
description: "Refresh and show the current task list",
|
||||
handler: async (_args, ctx) => {
|
||||
refreshTaskUI(ctx);
|
||||
if (ctx.hasUI) {
|
||||
const remaining = tasks.filter((task) => task.status !== "completed").length;
|
||||
const message =
|
||||
tasks.length === 0
|
||||
? "No tasks"
|
||||
: remaining === 0
|
||||
? `✓ All ${tasks.length} tasks completed; task UI is hidden`
|
||||
: `${remaining} task(s) remaining; task UI refreshed`;
|
||||
ctx.ui.notify(message, "info");
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# Plan mode extension
|
||||
|
||||
Claude Code-style plan mode for pi.
|
||||
|
||||
## Features
|
||||
|
||||
- `/plan` toggles read-only planning mode
|
||||
- `Ctrl+Alt+P` toggles plan mode on/off
|
||||
- `/plan execute` runs the last approved plan in the current session with full tools restored
|
||||
- `/plan execute fresh` starts a fresh session and carries the approved plan into it for execution
|
||||
- extracts numbered steps from a `Plan:` section
|
||||
- tracks progress with `[DONE:n]` markers during execution
|
||||
- restores your previous active tool set after plan mode ends
|
||||
- keeps `subagent` available in plan mode when you intentionally want to use helper agents during planning
|
||||
|
||||
## Commands
|
||||
|
||||
- `/plan`
|
||||
- `/plan on`
|
||||
- `/plan off`
|
||||
- `/plan execute`
|
||||
- `/plan execute fresh`
|
||||
- `/plan status`
|
||||
- `/plan clear`
|
||||
|
||||
## Shortcut
|
||||
|
||||
- `Ctrl+Alt+P` — toggle plan mode
|
||||
|
||||
## Behavior
|
||||
|
||||
When plan mode is enabled, pi switches to read-only exploration and asks the model to produce a numbered `Plan:` section.
|
||||
|
||||
After planning finishes, the extension shows a chooser so you can:
|
||||
- execute now
|
||||
- execute in a fresh session
|
||||
- send custom feedback / continue discussion
|
||||
- stay in plan mode
|
||||
|
||||
When you execute the plan in the current session, the extension restores your previous tools and asks the model to complete the steps while emitting `[DONE:n]` markers.
|
||||
|
||||
If you use `/plan execute fresh`, the extension creates a new session, carries over the approved plan and planner notes, restores the full tool set there, and starts execution in that fresh context.
|
||||
|
|
@ -1,410 +0,0 @@
|
|||
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { extractTodoItems, isSafeCommand, markCompletedSteps, type TodoItem } from "./utils.js";
|
||||
|
||||
const PLAN_FLAG = "plan-mode";
|
||||
const PLAN_TODOS_WIDGET = "plan-todos";
|
||||
const PLAN_CONTEXT_TYPE = "plan-mode-context";
|
||||
const PLAN_EXECUTION_CONTEXT_TYPE = "plan-execution-context";
|
||||
const PLAN_STATE_ENTRY = "plan-mode-state";
|
||||
const PREFERRED_PLAN_TOOLS = ["read", "bash", "grep", "find", "ls", "lsp_diagnostics", "subagent"];
|
||||
|
||||
type PlanModeState = {
|
||||
enabled: boolean;
|
||||
executing: boolean;
|
||||
savedTools: string[] | null;
|
||||
todos: TodoItem[];
|
||||
};
|
||||
|
||||
function getAssistantText(message: any): string {
|
||||
if (!message || message.role !== "assistant" || !Array.isArray(message.content)) return "";
|
||||
return message.content
|
||||
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
||||
.map((part: any) => part.text)
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export default function planModeExtension(pi: ExtensionAPI): void {
|
||||
let planModeEnabled = false;
|
||||
let executionMode = false;
|
||||
let savedTools: string[] | null = null;
|
||||
let todoItems: TodoItem[] = [];
|
||||
|
||||
function persistState() {
|
||||
const state: PlanModeState = {
|
||||
enabled: planModeEnabled,
|
||||
executing: executionMode,
|
||||
savedTools,
|
||||
todos: todoItems,
|
||||
};
|
||||
pi.appendEntry(PLAN_STATE_ENTRY, state);
|
||||
}
|
||||
|
||||
function getPlanTools(): string[] {
|
||||
const allToolNames = new Set(pi.getAllTools().map((tool) => tool.name));
|
||||
const tools = new Set<string>();
|
||||
|
||||
for (const name of PREFERRED_PLAN_TOOLS) {
|
||||
if (allToolNames.has(name)) tools.add(name);
|
||||
}
|
||||
|
||||
if (savedTools) {
|
||||
for (const name of savedTools) {
|
||||
if (allToolNames.has(name) && PREFERRED_PLAN_TOOLS.includes(name)) tools.add(name);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(tools);
|
||||
}
|
||||
|
||||
function restoreSavedTools() {
|
||||
if (savedTools && savedTools.length > 0) {
|
||||
pi.setActiveTools(savedTools);
|
||||
}
|
||||
}
|
||||
|
||||
function updateUi(ctx: ExtensionContext) {
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const completed = todoItems.filter((item) => item.completed).length;
|
||||
ctx.ui.setStatus(PLAN_FLAG, `📋 ${completed}/${todoItems.length}`);
|
||||
} else if (planModeEnabled) {
|
||||
ctx.ui.setStatus(PLAN_FLAG, "⏸ PLAN");
|
||||
} else {
|
||||
ctx.ui.setStatus(PLAN_FLAG, undefined);
|
||||
}
|
||||
|
||||
if ((planModeEnabled || executionMode) && todoItems.length > 0) {
|
||||
ctx.ui.setWidget(
|
||||
PLAN_TODOS_WIDGET,
|
||||
todoItems.map((item) => `${item.completed ? "☑" : "☐"} ${item.step}. ${item.text}`),
|
||||
);
|
||||
} else {
|
||||
ctx.ui.setWidget(PLAN_TODOS_WIDGET, undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function enablePlanMode(ctx: ExtensionContext) {
|
||||
if (!savedTools) savedTools = pi.getActiveTools();
|
||||
planModeEnabled = true;
|
||||
executionMode = false;
|
||||
pi.setActiveTools(getPlanTools());
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
ctx.ui.notify(`Plan mode enabled. Active tools: ${getPlanTools().join(", ")}`, "info");
|
||||
}
|
||||
|
||||
function disablePlanMode(ctx: ExtensionContext, notify = true) {
|
||||
planModeEnabled = false;
|
||||
executionMode = false;
|
||||
restoreSavedTools();
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
if (notify) ctx.ui.notify("Plan mode disabled. Previous tools restored.", "info");
|
||||
}
|
||||
|
||||
function clearPlan(ctx: ExtensionContext) {
|
||||
executionMode = false;
|
||||
todoItems = [];
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
ctx.ui.notify("Stored plan cleared.", "info");
|
||||
}
|
||||
|
||||
function showStatus(ctx: ExtensionContext) {
|
||||
if (!planModeEnabled && !executionMode && todoItems.length === 0) {
|
||||
ctx.ui.notify("Plan mode is off and no stored plan exists.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`plan mode: ${planModeEnabled ? "on" : "off"}`,
|
||||
`execution: ${executionMode ? "on" : "off"}`,
|
||||
`stored steps: ${todoItems.length}`,
|
||||
];
|
||||
if (todoItems.length > 0) {
|
||||
for (const item of todoItems) lines.push(`${item.step}. ${item.completed ? "✓" : "○"} ${item.text}`);
|
||||
}
|
||||
ctx.ui.notify(lines.join("\n"), "info");
|
||||
}
|
||||
|
||||
function getLatestPlannedAssistantText(ctx: ExtensionContext): string {
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
for (let i = branch.length - 1; i >= 0; i--) {
|
||||
const entry: any = branch[i];
|
||||
if (entry?.type !== "message" || !entry.message) continue;
|
||||
const text = getAssistantText(entry.message);
|
||||
if (text && extractTodoItems(text).length > 0) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function buildExecutionPrompt(ctx: ExtensionContext, fresh: boolean): string {
|
||||
const remaining = todoItems.filter((item) => !item.completed);
|
||||
const planLines = todoItems.map((item) => `${item.step}. ${item.text}${item.completed ? " [already done]" : ""}`);
|
||||
const latestPlanText = getLatestPlannedAssistantText(ctx);
|
||||
const sections = [
|
||||
fresh
|
||||
? "You are executing an approved plan in a fresh session. The earlier planning conversation is not available, so rely only on the context below."
|
||||
: "Execute the approved plan in this session.",
|
||||
"## Approved Plan",
|
||||
planLines.join("\n"),
|
||||
];
|
||||
|
||||
if (remaining.length > 0) {
|
||||
sections.push("## Remaining Steps", remaining.map((item) => `${item.step}. ${item.text}`).join("\n"));
|
||||
}
|
||||
|
||||
if (latestPlanText) {
|
||||
sections.push("## Planner Notes", latestPlanText);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
"## Execution Instructions",
|
||||
[
|
||||
"Inspect files before editing.",
|
||||
"If the approved plan needs adjustment, explain why before making a larger change.",
|
||||
"Complete the remaining steps in order.",
|
||||
"After completing a step, include a [DONE:n] marker in your response.",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
function startExecution(ctx: ExtensionContext) {
|
||||
if (todoItems.length === 0) {
|
||||
ctx.ui.notify("No stored plan found. Create one in /plan mode first.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
planModeEnabled = false;
|
||||
executionMode = true;
|
||||
restoreSavedTools();
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
ctx.ui.notify("Executing stored plan with full tools restored.", "info");
|
||||
pi.sendUserMessage(buildExecutionPrompt(ctx, false));
|
||||
}
|
||||
|
||||
async function startFreshExecution(ctx: any) {
|
||||
if (todoItems.length === 0) {
|
||||
ctx.ui.notify("No stored plan found. Create one in /plan mode first.", "warning");
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.waitForIdle();
|
||||
|
||||
const executionPrompt = buildExecutionPrompt(ctx, true);
|
||||
const executionTools = savedTools && savedTools.length > 0 ? [...savedTools] : [...pi.getActiveTools()];
|
||||
const carriedTodos = todoItems.map((item) => ({ ...item }));
|
||||
const parentSession = ctx.sessionManager.getSessionFile();
|
||||
|
||||
const result = await ctx.newSession({
|
||||
parentSession,
|
||||
setup: async (sm: any) => {
|
||||
sm.appendCustomEntry(PLAN_STATE_ENTRY, {
|
||||
enabled: false,
|
||||
executing: true,
|
||||
savedTools: executionTools,
|
||||
todos: carriedTodos,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (result.cancelled) {
|
||||
ctx.ui.notify("Fresh execution session cancelled.", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
pi.sendUserMessage(executionPrompt);
|
||||
}
|
||||
|
||||
pi.registerFlag("plan", {
|
||||
description: "Start in read-only plan mode",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
});
|
||||
|
||||
pi.registerCommand("plan", {
|
||||
description: "Toggle plan mode, or use /plan on|off|execute [fresh]|status|clear",
|
||||
handler: async (args, ctx) => {
|
||||
const action = args.trim().toLowerCase();
|
||||
switch (action) {
|
||||
case "":
|
||||
case "toggle":
|
||||
if (planModeEnabled) disablePlanMode(ctx);
|
||||
else enablePlanMode(ctx);
|
||||
return;
|
||||
case "on":
|
||||
enablePlanMode(ctx);
|
||||
return;
|
||||
case "off":
|
||||
disablePlanMode(ctx);
|
||||
return;
|
||||
case "execute":
|
||||
startExecution(ctx);
|
||||
return;
|
||||
case "execute fresh":
|
||||
await startFreshExecution(ctx);
|
||||
return;
|
||||
case "status":
|
||||
showStatus(ctx);
|
||||
return;
|
||||
case "clear":
|
||||
clearPlan(ctx);
|
||||
return;
|
||||
default:
|
||||
ctx.ui.notify("Usage: /plan [on|off|execute|execute fresh|status|clear]", "warning");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerShortcut("ctrl+alt+p", {
|
||||
description: "Toggle plan mode on/off",
|
||||
handler: async (ctx) => {
|
||||
if (planModeEnabled) disablePlanMode(ctx);
|
||||
else enablePlanMode(ctx);
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event) => {
|
||||
if (!planModeEnabled) return;
|
||||
|
||||
if (event.toolName === "edit" || event.toolName === "write") {
|
||||
return { block: true, reason: "Plan mode is read-only. Disable it before modifying files." };
|
||||
}
|
||||
|
||||
if (event.toolName === "bash") {
|
||||
const command = typeof event.input?.command === "string" ? event.input.command : "";
|
||||
if (!isSafeCommand(command)) {
|
||||
return {
|
||||
block: true,
|
||||
reason: `Plan mode blocked a non read-only bash command: ${command}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
pi.on("context", async (event) => {
|
||||
if (planModeEnabled || executionMode) return;
|
||||
return {
|
||||
messages: event.messages.filter((message: any) => {
|
||||
if (message?.customType === PLAN_CONTEXT_TYPE) return false;
|
||||
if (message?.customType === PLAN_EXECUTION_CONTEXT_TYPE) return false;
|
||||
return true;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
pi.on("before_agent_start", async () => {
|
||||
if (planModeEnabled) {
|
||||
return {
|
||||
message: {
|
||||
customType: PLAN_CONTEXT_TYPE,
|
||||
display: false,
|
||||
content: `[PLAN MODE ACTIVE]\nYou are in read-only planning mode.\n\nRules:\n- Do not modify files.\n- Do not use edit or write.\n- Use only read-only investigation.\n- You may use subagent when it helps planning. Prefer read-only helpers like librarian, reviewer, or uiux-designer when appropriate.\n- If requirements are unclear, ask clarifying questions before planning.\n- Produce a numbered plan under a \"Plan:\" header.\n- Call out risks, dependencies, and validation steps.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (executionMode && todoItems.length > 0) {
|
||||
const remaining = todoItems.filter((item) => !item.completed);
|
||||
return {
|
||||
message: {
|
||||
customType: PLAN_EXECUTION_CONTEXT_TYPE,
|
||||
display: false,
|
||||
content:
|
||||
`[EXECUTING APPROVED PLAN]\nComplete the remaining plan steps in order.\n\nRemaining steps:\n${remaining.map((item) => `${item.step}. ${item.text}`).join("\n")}\n\nAfter completing a step, include [DONE:n] in your response.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (event, ctx) => {
|
||||
if (!executionMode || todoItems.length === 0) return;
|
||||
const text = getAssistantText(event.message);
|
||||
if (!text) return;
|
||||
if (markCompletedSteps(text, todoItems) > 0) {
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
}
|
||||
if (todoItems.length > 0 && todoItems.every((item) => item.completed)) {
|
||||
executionMode = false;
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
ctx.ui.notify("Plan execution completed.", "success");
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("agent_end", async (event, ctx) => {
|
||||
if (!planModeEnabled) return;
|
||||
const lastAssistant = [...event.messages].reverse().find((message: any) => message?.role === "assistant");
|
||||
const text = getAssistantText(lastAssistant);
|
||||
if (!text) return;
|
||||
|
||||
const extracted = extractTodoItems(text);
|
||||
if (extracted.length === 0) return;
|
||||
|
||||
todoItems = extracted;
|
||||
updateUi(ctx);
|
||||
persistState();
|
||||
|
||||
if (!ctx.hasUI) return;
|
||||
|
||||
const choice = await ctx.ui.select("Plan ready - what next?", [
|
||||
"Execute the plan now",
|
||||
"Execute in a fresh session",
|
||||
"Send custom feedback / continue discussion",
|
||||
"Stay in plan mode",
|
||||
]);
|
||||
|
||||
if (choice === "Execute the plan now") {
|
||||
startExecution(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === "Execute in a fresh session") {
|
||||
await startFreshExecution(ctx);
|
||||
return;
|
||||
}
|
||||
|
||||
if (choice === "Send custom feedback / continue discussion") {
|
||||
const feedback = await ctx.ui.editor(
|
||||
"What should Pi do next?",
|
||||
"Refine the plan by ...",
|
||||
);
|
||||
if (feedback?.trim()) {
|
||||
pi.sendUserMessage(feedback.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
pi.on("session_start", async (_event, ctx) => {
|
||||
const branch = ctx.sessionManager.getBranch();
|
||||
const stateEntry = branch
|
||||
.filter((entry: any) => entry.type === "custom" && entry.customType === PLAN_STATE_ENTRY)
|
||||
.pop() as { data?: PlanModeState } | undefined;
|
||||
|
||||
if (stateEntry?.data) {
|
||||
planModeEnabled = stateEntry.data.enabled ?? false;
|
||||
executionMode = stateEntry.data.executing ?? false;
|
||||
savedTools = stateEntry.data.savedTools ?? null;
|
||||
todoItems = stateEntry.data.todos ?? [];
|
||||
} else if (pi.getFlag("--plan") === true || pi.getFlag("plan") === true) {
|
||||
planModeEnabled = true;
|
||||
executionMode = false;
|
||||
savedTools = pi.getActiveTools();
|
||||
}
|
||||
|
||||
if (!savedTools) savedTools = pi.getActiveTools();
|
||||
|
||||
if (planModeEnabled) {
|
||||
pi.setActiveTools(getPlanTools());
|
||||
} else if (executionMode) {
|
||||
restoreSavedTools();
|
||||
}
|
||||
|
||||
updateUi(ctx);
|
||||
});
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"name": "pi-plan-mode",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
export interface TodoItem {
|
||||
step: number;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
const DESTRUCTIVE_PATTERNS = [
|
||||
/\brm\b/i,
|
||||
/\brmdir\b/i,
|
||||
/\bmv\b/i,
|
||||
/\bcp\b/i,
|
||||
/\bmkdir\b/i,
|
||||
/\btouch\b/i,
|
||||
/\bchmod\b/i,
|
||||
/\bchown\b/i,
|
||||
/\bchgrp\b/i,
|
||||
/\bln\b/i,
|
||||
/\btee\b/i,
|
||||
/\btruncate\b/i,
|
||||
/\bdd\b/i,
|
||||
/\bshred\b/i,
|
||||
/(^|[^<])>(?!>)/,
|
||||
/>>/,
|
||||
/\bnpm\s+(install|uninstall|update|ci|link|publish)/i,
|
||||
/\byarn\s+(add|remove|install|publish)/i,
|
||||
/\bpnpm\s+(add|remove|install|publish)/i,
|
||||
/\bpip\s+(install|uninstall)/i,
|
||||
/\bapt(-get)?\s+(install|remove|purge|update|upgrade)/i,
|
||||
/\bbrew\s+(install|uninstall|upgrade)/i,
|
||||
/\bgit\s+(add|commit|push|pull|merge|rebase|reset|checkout|branch\s+-[dD]|stash|cherry-pick|revert|tag|init|clone)/i,
|
||||
/\bsudo\b/i,
|
||||
/\bsu\b/i,
|
||||
/\bkill\b/i,
|
||||
/\bpkill\b/i,
|
||||
/\bkillall\b/i,
|
||||
/\breboot\b/i,
|
||||
/\bshutdown\b/i,
|
||||
/\bsystemctl\s+(start|stop|restart|enable|disable)/i,
|
||||
/\bservice\s+\S+\s+(start|stop|restart)/i,
|
||||
/\b(vim?|nano|emacs|code|subl)\b/i,
|
||||
];
|
||||
|
||||
const SAFE_PATTERNS = [
|
||||
/^\s*cat\b/,
|
||||
/^\s*head\b/,
|
||||
/^\s*tail\b/,
|
||||
/^\s*less\b/,
|
||||
/^\s*more\b/,
|
||||
/^\s*grep\b/,
|
||||
/^\s*find\b/,
|
||||
/^\s*ls\b/,
|
||||
/^\s*pwd\b/,
|
||||
/^\s*echo\b/,
|
||||
/^\s*printf\b/,
|
||||
/^\s*wc\b/,
|
||||
/^\s*sort\b/,
|
||||
/^\s*uniq\b/,
|
||||
/^\s*diff\b/,
|
||||
/^\s*file\b/,
|
||||
/^\s*stat\b/,
|
||||
/^\s*du\b/,
|
||||
/^\s*df\b/,
|
||||
/^\s*tree\b/,
|
||||
/^\s*which\b/,
|
||||
/^\s*whereis\b/,
|
||||
/^\s*type\b/,
|
||||
/^\s*env\b/,
|
||||
/^\s*printenv\b/,
|
||||
/^\s*uname\b/,
|
||||
/^\s*whoami\b/,
|
||||
/^\s*id\b/,
|
||||
/^\s*date\b/,
|
||||
/^\s*cal\b/,
|
||||
/^\s*uptime\b/,
|
||||
/^\s*ps\b/,
|
||||
/^\s*top\b/,
|
||||
/^\s*htop\b/,
|
||||
/^\s*free\b/,
|
||||
/^\s*git\s+(status|log|diff|show|branch|remote|config\s+--get)/i,
|
||||
/^\s*git\s+ls-/i,
|
||||
/^\s*npm\s+(list|ls|view|info|search|outdated|audit)/i,
|
||||
/^\s*yarn\s+(list|info|why|audit)/i,
|
||||
/^\s*node\s+--version/i,
|
||||
/^\s*python\s+--version/i,
|
||||
/^\s*curl\s/i,
|
||||
/^\s*wget\s+-O\s*-/i,
|
||||
/^\s*jq\b/,
|
||||
/^\s*sed\s+-n/i,
|
||||
/^\s*awk\b/,
|
||||
/^\s*rg\b/,
|
||||
/^\s*fd\b/,
|
||||
/^\s*bat\b/,
|
||||
/^\s*exa\b/,
|
||||
];
|
||||
|
||||
export function isSafeCommand(command: string): boolean {
|
||||
const isDestructive = DESTRUCTIVE_PATTERNS.some((pattern) => pattern.test(command));
|
||||
const isSafe = SAFE_PATTERNS.some((pattern) => pattern.test(command));
|
||||
return !isDestructive && isSafe;
|
||||
}
|
||||
|
||||
function cleanStepText(text: string): string {
|
||||
let cleaned = text
|
||||
.replace(/\*{1,2}([^*]+)\*{1,2}/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (cleaned.length > 0) cleaned = cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
export function extractTodoItems(message: string): TodoItem[] {
|
||||
const items: TodoItem[] = [];
|
||||
const headerMatch = message.match(/\*{0,2}Plan:\*{0,2}\s*\n/i);
|
||||
if (!headerMatch) return items;
|
||||
|
||||
const planSection = message.slice(message.indexOf(headerMatch[0]) + headerMatch[0].length);
|
||||
const numberedPattern = /^\s*(\d+)[.)]\s+(.+)$/gm;
|
||||
|
||||
for (const match of planSection.matchAll(numberedPattern)) {
|
||||
const rawText = match[2]?.trim();
|
||||
if (!rawText || rawText.startsWith("-") || rawText.startsWith("/")) continue;
|
||||
const text = cleanStepText(rawText);
|
||||
if (text.length < 4) continue;
|
||||
items.push({
|
||||
step: items.length + 1,
|
||||
text,
|
||||
completed: false,
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export function extractDoneSteps(message: string): number[] {
|
||||
const steps: number[] = [];
|
||||
for (const match of message.matchAll(/\[DONE:(\d+)\]/gi)) {
|
||||
const step = Number(match[1]);
|
||||
if (Number.isFinite(step)) steps.push(step);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
export function markCompletedSteps(text: string, items: TodoItem[]): number {
|
||||
const doneSteps = extractDoneSteps(text);
|
||||
for (const step of doneSteps) {
|
||||
const item = items.find((todo) => todo.step === step);
|
||||
if (item) item.completed = true;
|
||||
}
|
||||
return doneSteps.length;
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
# Subagent roles for pi
|
||||
|
||||
Installed roles:
|
||||
|
||||
- `reviewer` — review changes, bugs, risks, maintainability
|
||||
- `librarian` — explore and map a codebase
|
||||
- `design-engineer` — frontend-aware UI/UX direction, design systems, and implementation-ready recommendations
|
||||
- `executor` — implement ordered coding tasks from a defined todo list
|
||||
- `planner` — interactive planning specialist that can ask clarifying questions and return an ordered executor-ready todo list
|
||||
|
||||
## Commands
|
||||
|
||||
- `/subagents` — list discovered user-level agents
|
||||
- `/subagents both` — include project-local `.pi/agents`
|
||||
|
||||
## Example prompts
|
||||
|
||||
- `Use the subagent tool with reviewer to inspect the auth refactor.`
|
||||
- `Use subagent in parallel: librarian maps the settings flow, design-engineer proposes an improved UX direction, reviewer lists product and implementation risks.`
|
||||
- `Chain librarian -> design-engineer to redesign onboarding. Use {previous} in the second task.`
|
||||
- `Use executor to implement this ordered todo list exactly as written, then summarize what was completed and validated.`
|
||||
- `Use planner to clarify ambiguity only when needed and output an ordered implementation todo list for executor handoff.`
|
||||
|
||||
## Interactive planner + ask_user
|
||||
|
||||
`planner` is marked `interactive: true` and runs through an RPC-backed child agent path.
|
||||
|
||||
- Interactive child agents use a helper extension tool: `ask_user`
|
||||
- `ask_user` supports `input`, `confirm`, and `select` modes (plus `editor`)
|
||||
- Child tool activation is applied on `session_start` via `PI_SUBAGENT_TOOLS` so custom tools like `ask_user` are reliably active
|
||||
|
||||
## Notes
|
||||
|
||||
- User agents live in `~/.pi/agent/agents`
|
||||
- Project-local agents can live in `.pi/agents`
|
||||
- Project agents require `agentScope: "project"` or `"both"`
|
||||
- Parallel mode allows up to 8 tasks and runs up to 4 concurrently
|
||||
- `reviewer` uses `openai-codex/gpt-5.4:xhigh`
|
||||
- `librarian` uses `anthropic/claude-sonnet-4-6`
|
||||
- `design-engineer` uses `google-antigravity/gemini-3-flash`
|
||||
- `executor` uses `openai-codex/gpt-5.3-codex:high`
|
||||
- `planner` uses `anthropic/claude-sonnet-4-6`
|
||||
|
||||
## Current interactive limitations
|
||||
|
||||
- Interactive subagents are currently supported only in **single mode** (`{ agent, task }`)
|
||||
- Interactive subagents are currently rejected in **parallel** and **chain** modes
|
||||
- Interactive subagents require `ctx.hasUI` (interactive TUI or RPC mode with extension UI handling)
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir, parseFrontmatter } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
export type AgentScope = "user" | "project" | "both";
|
||||
|
||||
export interface AgentConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
tools?: string[];
|
||||
model?: string;
|
||||
interactive?: boolean;
|
||||
systemPrompt: string;
|
||||
source: "user" | "project";
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface AgentDiscoveryResult {
|
||||
agents: AgentConfig[];
|
||||
projectAgentsDir: string | null;
|
||||
}
|
||||
|
||||
function normalizeString(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeTools(value: unknown): string[] | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
const tools = value
|
||||
.flatMap((entry) => {
|
||||
if (typeof entry !== "string") return [];
|
||||
return entry
|
||||
.split(",")
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
})
|
||||
.filter(Boolean);
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
const tools = value
|
||||
.split(",")
|
||||
.map((tool) => tool.trim())
|
||||
.filter(Boolean);
|
||||
return tools.length > 0 ? tools : undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeBoolean(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "number") {
|
||||
if (value === 1) return true;
|
||||
if (value === 0) return false;
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "y", "on"].includes(normalized)) return true;
|
||||
if (["false", "0", "no", "n", "off"].includes(normalized)) return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
||||
const agents: AgentConfig[] = [];
|
||||
if (!fs.existsSync(dir)) return agents;
|
||||
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return agents;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.name.endsWith(".md")) continue;
|
||||
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
||||
|
||||
const filePath = path.join(dir, entry.name);
|
||||
let content: string;
|
||||
try {
|
||||
content = fs.readFileSync(filePath, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
|
||||
const name = normalizeString(frontmatter.name);
|
||||
const description = normalizeString(frontmatter.description);
|
||||
if (!name || !description) continue;
|
||||
|
||||
agents.push({
|
||||
name,
|
||||
description,
|
||||
tools: normalizeTools(frontmatter.tools),
|
||||
model: normalizeString(frontmatter.model),
|
||||
interactive: normalizeBoolean(frontmatter.interactive),
|
||||
systemPrompt: body,
|
||||
source,
|
||||
filePath,
|
||||
});
|
||||
}
|
||||
|
||||
return agents;
|
||||
}
|
||||
|
||||
function isDirectory(p: string): boolean {
|
||||
try {
|
||||
return fs.statSync(p).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findNearestProjectAgentsDir(cwd: string): string | null {
|
||||
let currentDir = cwd;
|
||||
while (true) {
|
||||
const candidate = path.join(currentDir, ".pi", "agents");
|
||||
if (isDirectory(candidate)) return candidate;
|
||||
|
||||
const parentDir = path.dirname(currentDir);
|
||||
if (parentDir === currentDir) return null;
|
||||
currentDir = parentDir;
|
||||
}
|
||||
}
|
||||
|
||||
export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
|
||||
const userDir = path.join(getAgentDir(), "agents");
|
||||
const projectAgentsDir = findNearestProjectAgentsDir(cwd);
|
||||
|
||||
const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
|
||||
const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
|
||||
|
||||
const agentMap = new Map<string, AgentConfig>();
|
||||
if (scope === "both") {
|
||||
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
||||
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
||||
} else if (scope === "user") {
|
||||
for (const agent of userAgents) agentMap.set(agent.name, agent);
|
||||
} else {
|
||||
for (const agent of projectAgents) agentMap.set(agent.name, agent);
|
||||
}
|
||||
|
||||
return { agents: Array.from(agentMap.values()), projectAgentsDir };
|
||||
}
|
||||
|
||||
export function formatAgentList(agents: AgentConfig[], maxItems: number): { text: string; remaining: number } {
|
||||
if (agents.length === 0) return { text: "none", remaining: 0 };
|
||||
const listed = agents.slice(0, maxItems);
|
||||
const remaining = agents.length - listed.length;
|
||||
return {
|
||||
text: listed.map((agent) => `${agent.name} (${agent.source}): ${agent.description}`).join("; "),
|
||||
remaining,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,818 +0,0 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList } from "./agents.js";
|
||||
import { runInteractiveAgentRpc } from "./rpc-runner.js";
|
||||
import {
|
||||
applyAssistantUsage,
|
||||
createEmptyUsageStats,
|
||||
getDisplayItems,
|
||||
getFinalOutput,
|
||||
type DisplayItem,
|
||||
type OnUpdateCallback,
|
||||
type SingleResult,
|
||||
type SubagentDetails,
|
||||
} from "./subagent-types.js";
|
||||
|
||||
const MAX_PARALLEL_TASKS = 8;
|
||||
const MAX_CONCURRENCY = 4;
|
||||
const COLLAPSED_ITEM_COUNT = 10;
|
||||
|
||||
function formatTokens(count: number): string {
|
||||
if (count < 1000) return count.toString();
|
||||
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
||||
if (count < 1000000) return `${Math.round(count / 1000)}k`;
|
||||
return `${(count / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
|
||||
function formatUsageStats(
|
||||
usage: {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: number;
|
||||
contextTokens?: number;
|
||||
turns?: number;
|
||||
},
|
||||
model?: string,
|
||||
): string {
|
||||
const parts: string[] = [];
|
||||
if (usage.turns) parts.push(`${usage.turns} turn${usage.turns > 1 ? "s" : ""}`);
|
||||
if (usage.input) parts.push(`↑${formatTokens(usage.input)}`);
|
||||
if (usage.output) parts.push(`↓${formatTokens(usage.output)}`);
|
||||
if (usage.cacheRead) parts.push(`R${formatTokens(usage.cacheRead)}`);
|
||||
if (usage.cacheWrite) parts.push(`W${formatTokens(usage.cacheWrite)}`);
|
||||
if (usage.cost) parts.push(`$${usage.cost.toFixed(4)}`);
|
||||
if (usage.contextTokens && usage.contextTokens > 0) parts.push(`ctx:${formatTokens(usage.contextTokens)}`);
|
||||
if (model) parts.push(model);
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function formatToolCall(
|
||||
toolName: string,
|
||||
args: Record<string, unknown>,
|
||||
themeFg: (color: any, text: string) => string,
|
||||
): string {
|
||||
const shortenPath = (p: string) => {
|
||||
const home = os.homedir();
|
||||
return p.startsWith(home) ? `~${p.slice(home.length)}` : p;
|
||||
};
|
||||
|
||||
switch (toolName) {
|
||||
case "bash": {
|
||||
const command = (args.command as string) || "...";
|
||||
const preview = command.length > 60 ? `${command.slice(0, 60)}...` : command;
|
||||
return themeFg("muted", "$ ") + themeFg("toolOutput", preview);
|
||||
}
|
||||
case "read": {
|
||||
const rawPath = (args.file_path || args.path || "...") as string;
|
||||
const filePath = shortenPath(rawPath);
|
||||
const offset = args.offset as number | undefined;
|
||||
const limit = args.limit as number | undefined;
|
||||
let text = themeFg("accent", filePath);
|
||||
if (offset !== undefined || limit !== undefined) {
|
||||
const startLine = offset ?? 1;
|
||||
const endLine = limit !== undefined ? startLine + limit - 1 : "";
|
||||
text += themeFg("warning", `:${startLine}${endLine ? `-${endLine}` : ""}`);
|
||||
}
|
||||
return themeFg("muted", "read ") + text;
|
||||
}
|
||||
case "write": {
|
||||
const rawPath = (args.file_path || args.path || "...") as string;
|
||||
const filePath = shortenPath(rawPath);
|
||||
const content = (args.content || "") as string;
|
||||
const lines = content.split("\n").length;
|
||||
let text = themeFg("muted", "write ") + themeFg("accent", filePath);
|
||||
if (lines > 1) text += themeFg("dim", ` (${lines} lines)`);
|
||||
return text;
|
||||
}
|
||||
case "edit": {
|
||||
const rawPath = (args.file_path || args.path || "...") as string;
|
||||
return themeFg("muted", "edit ") + themeFg("accent", shortenPath(rawPath));
|
||||
}
|
||||
case "ls": {
|
||||
const rawPath = (args.path || ".") as string;
|
||||
return themeFg("muted", "ls ") + themeFg("accent", shortenPath(rawPath));
|
||||
}
|
||||
case "find": {
|
||||
const pattern = (args.pattern || "*") as string;
|
||||
const rawPath = (args.path || ".") as string;
|
||||
return themeFg("muted", "find ") + themeFg("accent", pattern) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
|
||||
}
|
||||
case "grep": {
|
||||
const pattern = (args.pattern || "") as string;
|
||||
const rawPath = (args.path || ".") as string;
|
||||
return themeFg("muted", "grep ") + themeFg("accent", `/${pattern}/`) + themeFg("dim", ` in ${shortenPath(rawPath)}`);
|
||||
}
|
||||
default: {
|
||||
const argsStr = JSON.stringify(args);
|
||||
const preview = argsStr.length > 50 ? `${argsStr.slice(0, 50)}...` : argsStr;
|
||||
return themeFg("accent", toolName) + themeFg("dim", ` ${preview}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mapWithConcurrencyLimit<TIn, TOut>(
|
||||
items: TIn[],
|
||||
concurrency: number,
|
||||
fn: (item: TIn, index: number) => Promise<TOut>,
|
||||
): Promise<TOut[]> {
|
||||
if (items.length === 0) return [];
|
||||
const limit = Math.max(1, Math.min(concurrency, items.length));
|
||||
const results: TOut[] = new Array(items.length);
|
||||
let nextIndex = 0;
|
||||
const workers = new Array(limit).fill(null).map(async () => {
|
||||
while (true) {
|
||||
const current = nextIndex++;
|
||||
if (current >= items.length) return;
|
||||
results[current] = await fn(items[current], current);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-"));
|
||||
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
||||
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
||||
fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
||||
return { dir: tmpDir, filePath };
|
||||
}
|
||||
|
||||
async function runSingleAgent(
|
||||
defaultCwd: string,
|
||||
agents: AgentConfig[],
|
||||
agentName: string,
|
||||
task: string,
|
||||
cwd: string | undefined,
|
||||
step: number | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: OnUpdateCallback | undefined,
|
||||
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
||||
): Promise<SingleResult> {
|
||||
const agent = agents.find((a) => a.name === agentName);
|
||||
if (!agent) {
|
||||
const available = agents.map((a) => `"${a.name}"`).join(", ") || "none";
|
||||
return {
|
||||
agent: agentName,
|
||||
agentSource: "unknown",
|
||||
task,
|
||||
exitCode: 1,
|
||||
messages: [],
|
||||
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
|
||||
usage: createEmptyUsageStats(),
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
const args: string[] = ["--mode", "json", "-p", "--no-session"];
|
||||
if (agent.model) args.push("--model", agent.model);
|
||||
if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
|
||||
|
||||
let tmpPromptDir: string | null = null;
|
||||
let tmpPromptPath: string | null = null;
|
||||
|
||||
const currentResult: SingleResult = {
|
||||
agent: agentName,
|
||||
agentSource: agent.source,
|
||||
task,
|
||||
exitCode: 0,
|
||||
messages: [],
|
||||
stderr: "",
|
||||
usage: createEmptyUsageStats(),
|
||||
model: agent.model,
|
||||
step,
|
||||
};
|
||||
|
||||
const emitUpdate = () => {
|
||||
if (!onUpdate) return;
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
|
||||
details: makeDetails([currentResult]),
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
if (agent.systemPrompt.trim()) {
|
||||
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
|
||||
tmpPromptDir = tmp.dir;
|
||||
tmpPromptPath = tmp.filePath;
|
||||
args.push("--append-system-prompt", tmpPromptPath);
|
||||
}
|
||||
|
||||
args.push(`Task: ${task}`);
|
||||
let wasAborted = false;
|
||||
|
||||
const exitCode = await new Promise<number>((resolve) => {
|
||||
const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let buffer = "";
|
||||
|
||||
const processLine = (line: string) => {
|
||||
if (!line.trim()) return;
|
||||
let event: any;
|
||||
try {
|
||||
event = JSON.parse(line);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "message_end" && event.message) {
|
||||
const msg = event.message;
|
||||
currentResult.messages.push(msg);
|
||||
applyAssistantUsage(currentResult, msg);
|
||||
emitUpdate();
|
||||
}
|
||||
|
||||
if (event.type === "tool_result_end" && event.message) {
|
||||
currentResult.messages.push(event.message);
|
||||
emitUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
proc.stdout.on("data", (data) => {
|
||||
buffer += data.toString();
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) processLine(line);
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
currentResult.stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("close", (code) => {
|
||||
if (buffer.trim()) processLine(buffer);
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
|
||||
proc.on("error", () => resolve(1));
|
||||
|
||||
if (signal) {
|
||||
const killProc = () => {
|
||||
wasAborted = true;
|
||||
proc.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!proc.killed) proc.kill("SIGKILL");
|
||||
}, 5000);
|
||||
};
|
||||
if (signal.aborted) killProc();
|
||||
else signal.addEventListener("abort", killProc, { once: true });
|
||||
}
|
||||
});
|
||||
|
||||
currentResult.exitCode = exitCode;
|
||||
if (wasAborted) throw new Error("Subagent was aborted");
|
||||
return currentResult;
|
||||
} finally {
|
||||
if (tmpPromptPath) {
|
||||
try {
|
||||
fs.unlinkSync(tmpPromptPath);
|
||||
} catch {}
|
||||
}
|
||||
if (tmpPromptDir) {
|
||||
try {
|
||||
fs.rmdirSync(tmpPromptDir);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findAgentConfig(agents: AgentConfig[], name: string): AgentConfig | undefined {
|
||||
return agents.find((agent) => agent.name === name);
|
||||
}
|
||||
|
||||
const TaskItem = Type.Object({
|
||||
agent: Type.String({ description: "Name of the agent to invoke" }),
|
||||
task: Type.String({ description: "Task to delegate to the agent" }),
|
||||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
||||
});
|
||||
|
||||
const ChainItem = Type.Object({
|
||||
agent: Type.String({ description: "Name of the agent to invoke" }),
|
||||
task: Type.String({ description: "Task with optional {previous} placeholder for prior output" }),
|
||||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process" })),
|
||||
});
|
||||
|
||||
const AgentScopeSchema = StringEnum(["user", "project", "both"] as const, {
|
||||
description: 'Which agent directories to use. Default: "user". Use "both" to include project-local agents.',
|
||||
default: "user",
|
||||
});
|
||||
|
||||
const SubagentParams = Type.Object({
|
||||
agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (single mode)" })),
|
||||
task: Type.Optional(Type.String({ description: "Task to delegate (single mode)" })),
|
||||
tasks: Type.Optional(Type.Array(TaskItem, { description: "Array of {agent, task} for parallel execution" })),
|
||||
chain: Type.Optional(Type.Array(ChainItem, { description: "Array of {agent, task} for sequential execution" })),
|
||||
agentScope: Type.Optional(AgentScopeSchema),
|
||||
confirmProjectAgents: Type.Optional(
|
||||
Type.Boolean({ description: "Prompt before running project-local agents. Default: true.", default: true }),
|
||||
),
|
||||
cwd: Type.Optional(Type.String({ description: "Working directory for the agent process (single mode)" })),
|
||||
});
|
||||
|
||||
export default function (pi: ExtensionAPI) {
|
||||
pi.registerCommand("subagents", {
|
||||
description: "List discovered subagent roles",
|
||||
handler: async (args, ctx) => {
|
||||
const requestedScope = (args.trim() as AgentScope) || "user";
|
||||
const scope: AgentScope = ["user", "project", "both"].includes(requestedScope) ? requestedScope : "user";
|
||||
const discovery = discoverAgents(ctx.cwd, scope);
|
||||
const list = discovery.agents.map((agent) => `- ${agent.name} (${agent.source}): ${agent.description}`).join("\n");
|
||||
ctx.ui.notify(list || `No agents found for scope: ${scope}`, "info");
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "subagent",
|
||||
label: "Subagent",
|
||||
description: [
|
||||
"Delegate work to specialized subagents with isolated context.",
|
||||
"Supports single, parallel, and chained execution.",
|
||||
"Useful built-in roles: reviewer, librarian, design-engineer, executor.",
|
||||
'Default scope is "user" (~/.pi/agent/agents). Set agentScope to "both" or "project" to include repo-local agents.',
|
||||
].join(" "),
|
||||
parameters: SubagentParams,
|
||||
|
||||
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
||||
const agentScope: AgentScope = params.agentScope ?? "user";
|
||||
const discovery = discoverAgents(ctx.cwd, agentScope);
|
||||
const agents = discovery.agents;
|
||||
const confirmProjectAgents = params.confirmProjectAgents ?? true;
|
||||
const availableAgents = formatAgentList(agents, 8);
|
||||
|
||||
const hasChain = (params.chain?.length ?? 0) > 0;
|
||||
const hasTasks = (params.tasks?.length ?? 0) > 0;
|
||||
const hasSingle = Boolean(params.agent && params.task);
|
||||
const modeCount = Number(hasChain) + Number(hasTasks) + Number(hasSingle);
|
||||
|
||||
const makeDetails =
|
||||
(mode: "single" | "parallel" | "chain") =>
|
||||
(results: SingleResult[]): SubagentDetails => ({
|
||||
mode,
|
||||
agentScope,
|
||||
projectAgentsDir: discovery.projectAgentsDir,
|
||||
results,
|
||||
});
|
||||
|
||||
if (modeCount !== 1) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Invalid parameters. Available agents: ${availableAgents.text}` }],
|
||||
details: makeDetails("single")([]),
|
||||
};
|
||||
}
|
||||
|
||||
if (hasTasks && params.tasks?.some((task) => findAgentConfig(agents, task.agent)?.interactive === true)) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Interactive subagents are currently supported only in single mode." }],
|
||||
details: makeDetails("parallel")([]),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasChain && params.chain?.some((step) => findAgentConfig(agents, step.agent)?.interactive === true)) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Interactive subagents are currently supported only in single mode." }],
|
||||
details: makeDetails("chain")([]),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasSingle) {
|
||||
const selectedAgent = params.agent ? findAgentConfig(agents, params.agent) : undefined;
|
||||
if (selectedAgent?.interactive === true && !ctx.hasUI) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Interactive subagents require a UI-enabled session." }],
|
||||
details: makeDetails("single")([]),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
|
||||
const requestedAgentNames = new Set<string>();
|
||||
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
|
||||
if (params.tasks) for (const task of params.tasks) requestedAgentNames.add(task.agent);
|
||||
if (params.agent) requestedAgentNames.add(params.agent);
|
||||
|
||||
const projectAgentsRequested = Array.from(requestedAgentNames)
|
||||
.map((name) => agents.find((a) => a.name === name))
|
||||
.filter((agent): agent is AgentConfig => agent?.source === "project");
|
||||
|
||||
if (projectAgentsRequested.length > 0) {
|
||||
const names = projectAgentsRequested.map((a) => a.name).join(", ");
|
||||
const dir = discovery.projectAgentsDir ?? "(unknown)";
|
||||
const ok = await ctx.ui.confirm(
|
||||
"Run project-local agents?",
|
||||
`Agents: ${names}\nSource: ${dir}\n\nProject agents are repo-controlled. Continue only for trusted repositories.`,
|
||||
);
|
||||
if (!ok) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Canceled: project-local agents were not approved." }],
|
||||
details: makeDetails(hasChain ? "chain" : hasTasks ? "parallel" : "single")([]),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (params.chain && params.chain.length > 0) {
|
||||
const results: SingleResult[] = [];
|
||||
let previousOutput = "";
|
||||
|
||||
for (let i = 0; i < params.chain.length; i++) {
|
||||
const step = params.chain[i];
|
||||
const taskWithContext = step.task.replace(/\{previous\}/g, previousOutput);
|
||||
const chainUpdate: OnUpdateCallback | undefined = onUpdate
|
||||
? (partial) => {
|
||||
const currentResult = partial.details?.results[0];
|
||||
if (!currentResult) return;
|
||||
onUpdate({
|
||||
content: partial.content,
|
||||
details: makeDetails("chain")([...results, currentResult]),
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const result = await runSingleAgent(
|
||||
ctx.cwd,
|
||||
agents,
|
||||
step.agent,
|
||||
taskWithContext,
|
||||
step.cwd,
|
||||
i + 1,
|
||||
signal,
|
||||
chainUpdate,
|
||||
makeDetails("chain"),
|
||||
);
|
||||
results.push(result);
|
||||
|
||||
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
||||
if (isError) {
|
||||
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
||||
return {
|
||||
content: [{ type: "text", text: `Chain stopped at step ${i + 1} (${step.agent}): ${errorMsg}` }],
|
||||
details: makeDetails("chain")(results),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
previousOutput = getFinalOutput(result.messages);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: getFinalOutput(results[results.length - 1].messages) || "(no output)" }],
|
||||
details: makeDetails("chain")(results),
|
||||
};
|
||||
}
|
||||
|
||||
if (params.tasks && params.tasks.length > 0) {
|
||||
if (params.tasks.length > MAX_PARALLEL_TASKS) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Too many parallel tasks (${params.tasks.length}). Max is ${MAX_PARALLEL_TASKS}.` }],
|
||||
details: makeDetails("parallel")([]),
|
||||
};
|
||||
}
|
||||
|
||||
const allResults: SingleResult[] = new Array(params.tasks.length);
|
||||
for (let i = 0; i < params.tasks.length; i++) {
|
||||
allResults[i] = {
|
||||
agent: params.tasks[i].agent,
|
||||
agentSource: "unknown",
|
||||
task: params.tasks[i].task,
|
||||
exitCode: -1,
|
||||
messages: [],
|
||||
stderr: "",
|
||||
usage: createEmptyUsageStats(),
|
||||
};
|
||||
}
|
||||
|
||||
const emitParallelUpdate = () => {
|
||||
if (!onUpdate) return;
|
||||
const running = allResults.filter((r) => r.exitCode === -1).length;
|
||||
const done = allResults.filter((r) => r.exitCode !== -1).length;
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
|
||||
details: makeDetails("parallel")([...allResults]),
|
||||
});
|
||||
};
|
||||
|
||||
const results = await mapWithConcurrencyLimit(params.tasks, MAX_CONCURRENCY, async (task, index) => {
|
||||
const result = await runSingleAgent(
|
||||
ctx.cwd,
|
||||
agents,
|
||||
task.agent,
|
||||
task.task,
|
||||
task.cwd,
|
||||
undefined,
|
||||
signal,
|
||||
(partial) => {
|
||||
if (partial.details?.results[0]) {
|
||||
allResults[index] = partial.details.results[0];
|
||||
emitParallelUpdate();
|
||||
}
|
||||
},
|
||||
makeDetails("parallel"),
|
||||
);
|
||||
allResults[index] = result;
|
||||
emitParallelUpdate();
|
||||
return result;
|
||||
});
|
||||
|
||||
const successCount = results.filter((r) => r.exitCode === 0).length;
|
||||
const summaries = results.map((r) => {
|
||||
const output = getFinalOutput(r.messages);
|
||||
const preview = output.slice(0, 100) + (output.length > 100 ? "..." : "");
|
||||
return `[${r.agent}] ${r.exitCode === 0 ? "completed" : "failed"}: ${preview || "(no output)"}`;
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Parallel: ${successCount}/${results.length} succeeded\n\n${summaries.join("\n\n")}` }],
|
||||
details: makeDetails("parallel")(results),
|
||||
};
|
||||
}
|
||||
|
||||
if (params.agent && params.task) {
|
||||
const selectedAgent = findAgentConfig(agents, params.agent);
|
||||
const result = selectedAgent?.interactive
|
||||
? await runInteractiveAgentRpc(
|
||||
ctx.cwd,
|
||||
agents,
|
||||
params.agent,
|
||||
params.task,
|
||||
params.cwd,
|
||||
undefined,
|
||||
signal,
|
||||
onUpdate as OnUpdateCallback | undefined,
|
||||
makeDetails("single"),
|
||||
ctx,
|
||||
)
|
||||
: await runSingleAgent(
|
||||
ctx.cwd,
|
||||
agents,
|
||||
params.agent,
|
||||
params.task,
|
||||
params.cwd,
|
||||
undefined,
|
||||
signal,
|
||||
onUpdate as OnUpdateCallback | undefined,
|
||||
makeDetails("single"),
|
||||
);
|
||||
const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted";
|
||||
if (isError) {
|
||||
const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)";
|
||||
return {
|
||||
content: [{ type: "text", text: `Agent ${result.stopReason || "failed"}: ${errorMsg}` }],
|
||||
details: makeDetails("single")([result]),
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
|
||||
details: makeDetails("single")([result]),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Invalid parameters. Available agents: ${availableAgents.text}` }],
|
||||
details: makeDetails("single")([]),
|
||||
};
|
||||
},
|
||||
|
||||
renderCall(args, theme) {
|
||||
const scope: AgentScope = args.agentScope ?? "user";
|
||||
if (args.chain && args.chain.length > 0) {
|
||||
let text =
|
||||
theme.fg("toolTitle", theme.bold("subagent ")) +
|
||||
theme.fg("accent", `chain (${args.chain.length} steps)`) +
|
||||
theme.fg("muted", ` [${scope}]`);
|
||||
for (let i = 0; i < Math.min(args.chain.length, 3); i++) {
|
||||
const step = args.chain[i];
|
||||
const cleanTask = step.task.replace(/\{previous\}/g, "").trim();
|
||||
const preview = cleanTask.length > 40 ? `${cleanTask.slice(0, 40)}...` : cleanTask;
|
||||
text += `\n ${theme.fg("muted", `${i + 1}.`)} ${theme.fg("accent", step.agent)}${theme.fg("dim", ` ${preview}`)}`;
|
||||
}
|
||||
if (args.chain.length > 3) text += `\n ${theme.fg("muted", `... +${args.chain.length - 3} more`)}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
if (args.tasks && args.tasks.length > 0) {
|
||||
let text =
|
||||
theme.fg("toolTitle", theme.bold("subagent ")) +
|
||||
theme.fg("accent", `parallel (${args.tasks.length} tasks)`) +
|
||||
theme.fg("muted", ` [${scope}]`);
|
||||
for (const task of args.tasks.slice(0, 3)) {
|
||||
const preview = task.task.length > 40 ? `${task.task.slice(0, 40)}...` : task.task;
|
||||
text += `\n ${theme.fg("accent", task.agent)}${theme.fg("dim", ` ${preview}`)}`;
|
||||
}
|
||||
if (args.tasks.length > 3) text += `\n ${theme.fg("muted", `... +${args.tasks.length - 3} more`)}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
const agentName = args.agent || "...";
|
||||
const preview = args.task ? (args.task.length > 60 ? `${args.task.slice(0, 60)}...` : args.task) : "...";
|
||||
let text = theme.fg("toolTitle", theme.bold("subagent ")) + theme.fg("accent", agentName) + theme.fg("muted", ` [${scope}]`);
|
||||
text += `\n ${theme.fg("dim", preview)}`;
|
||||
return new Text(text, 0, 0);
|
||||
},
|
||||
|
||||
renderResult(result, { expanded }, theme) {
|
||||
const details = result.details as SubagentDetails | undefined;
|
||||
if (!details || details.results.length === 0) {
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
||||
}
|
||||
|
||||
const mdTheme = getMarkdownTheme();
|
||||
|
||||
const renderDisplayItems = (items: DisplayItem[], limit?: number) => {
|
||||
const toShow = limit ? items.slice(-limit) : items;
|
||||
const skipped = limit && items.length > limit ? items.length - limit : 0;
|
||||
let text = "";
|
||||
if (skipped > 0) text += theme.fg("muted", `... ${skipped} earlier items\n`);
|
||||
for (const item of toShow) {
|
||||
if (item.type === "text") {
|
||||
const preview = expanded ? item.text : item.text.split("\n").slice(0, 3).join("\n");
|
||||
text += `${theme.fg("toolOutput", preview)}\n`;
|
||||
} else {
|
||||
text += `${theme.fg("muted", "→ ") + formatToolCall(item.name, item.args, theme.fg.bind(theme))}\n`;
|
||||
}
|
||||
}
|
||||
return text.trimEnd();
|
||||
};
|
||||
|
||||
const aggregateUsage = (results: SingleResult[]) => {
|
||||
const total = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
||||
for (const item of results) {
|
||||
total.input += item.usage.input;
|
||||
total.output += item.usage.output;
|
||||
total.cacheRead += item.usage.cacheRead;
|
||||
total.cacheWrite += item.usage.cacheWrite;
|
||||
total.cost += item.usage.cost;
|
||||
total.turns += item.usage.turns;
|
||||
}
|
||||
return total;
|
||||
};
|
||||
|
||||
if (details.mode === "single" && details.results.length === 1) {
|
||||
const item = details.results[0];
|
||||
const isError = item.exitCode !== 0 || item.stopReason === "error" || item.stopReason === "aborted";
|
||||
const icon = isError ? theme.fg("error", "✗") : theme.fg("success", "✓");
|
||||
const displayItems = getDisplayItems(item.messages);
|
||||
const finalOutput = getFinalOutput(item.messages);
|
||||
|
||||
if (expanded) {
|
||||
const container = new Container();
|
||||
let header = `${icon} ${theme.fg("toolTitle", theme.bold(item.agent))}${theme.fg("muted", ` (${item.agentSource})`)}`;
|
||||
if (isError && item.stopReason) header += ` ${theme.fg("error", `[${item.stopReason}]`)}`;
|
||||
container.addChild(new Text(header, 0, 0));
|
||||
if (isError && item.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${item.errorMessage}`), 0, 0));
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("muted", "─── Task ───"), 0, 0));
|
||||
container.addChild(new Text(theme.fg("dim", item.task), 0, 0));
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("muted", "─── Output ───"), 0, 0));
|
||||
if (displayItems.length === 0 && !finalOutput) {
|
||||
container.addChild(new Text(theme.fg("muted", "(no output)"), 0, 0));
|
||||
} else {
|
||||
for (const displayItem of displayItems) {
|
||||
if (displayItem.type === "toolCall") {
|
||||
container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(displayItem.name, displayItem.args, theme.fg.bind(theme)), 0, 0));
|
||||
}
|
||||
}
|
||||
if (finalOutput) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
||||
}
|
||||
}
|
||||
const usageStr = formatUsageStats(item.usage, item.model);
|
||||
if (usageStr) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
let text = `${icon} ${theme.fg("toolTitle", theme.bold(item.agent))}${theme.fg("muted", ` (${item.agentSource})`)}`;
|
||||
if (isError && item.stopReason) text += ` ${theme.fg("error", `[${item.stopReason}]`)}`;
|
||||
if (isError && item.errorMessage) text += `\n${theme.fg("error", `Error: ${item.errorMessage}`)}`;
|
||||
else if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
||||
else {
|
||||
text += `\n${renderDisplayItems(displayItems, COLLAPSED_ITEM_COUNT)}`;
|
||||
if (displayItems.length > COLLAPSED_ITEM_COUNT) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
||||
}
|
||||
const usageStr = formatUsageStats(item.usage, item.model);
|
||||
if (usageStr) text += `\n${theme.fg("dim", usageStr)}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
if (details.mode === "chain") {
|
||||
const successCount = details.results.filter((r) => r.exitCode === 0).length;
|
||||
const icon = successCount === details.results.length ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
||||
if (expanded) {
|
||||
const container = new Container();
|
||||
container.addChild(new Text(icon + " " + theme.fg("toolTitle", theme.bold("chain ")) + theme.fg("accent", `${successCount}/${details.results.length} steps`), 0, 0));
|
||||
for (const item of details.results) {
|
||||
const itemIcon = item.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
||||
const displayItems = getDisplayItems(item.messages);
|
||||
const finalOutput = getFinalOutput(item.messages);
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(`${theme.fg("muted", `─── Step ${item.step}: `)}${theme.fg("accent", item.agent)} ${itemIcon}`, 0, 0));
|
||||
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", item.task), 0, 0));
|
||||
for (const displayItem of displayItems) {
|
||||
if (displayItem.type === "toolCall") {
|
||||
container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(displayItem.name, displayItem.args, theme.fg.bind(theme)), 0, 0));
|
||||
}
|
||||
}
|
||||
if (finalOutput) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
||||
}
|
||||
const usageStr = formatUsageStats(item.usage, item.model);
|
||||
if (usageStr) container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
||||
}
|
||||
const totalUsageStr = formatUsageStats(aggregateUsage(details.results));
|
||||
if (totalUsageStr) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("dim", `Total: ${totalUsageStr}`), 0, 0));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
let text = icon + " " + theme.fg("toolTitle", theme.bold("chain ")) + theme.fg("accent", `${successCount}/${details.results.length} steps`);
|
||||
for (const item of details.results) {
|
||||
const itemIcon = item.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
||||
const displayItems = getDisplayItems(item.messages);
|
||||
text += `\n\n${theme.fg("muted", `─── Step ${item.step}: `)}${theme.fg("accent", item.agent)} ${itemIcon}`;
|
||||
if (displayItems.length === 0) text += `\n${theme.fg("muted", "(no output)")}`;
|
||||
else text += `\n${renderDisplayItems(displayItems, 5)}`;
|
||||
}
|
||||
const totalUsageStr = formatUsageStats(aggregateUsage(details.results));
|
||||
if (totalUsageStr) text += `\n\n${theme.fg("dim", `Total: ${totalUsageStr}`)}`;
|
||||
text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
if (details.mode === "parallel") {
|
||||
const running = details.results.filter((r) => r.exitCode === -1).length;
|
||||
const successCount = details.results.filter((r) => r.exitCode === 0).length;
|
||||
const failCount = details.results.filter((r) => r.exitCode > 0).length;
|
||||
const isRunning = running > 0;
|
||||
const icon = isRunning ? theme.fg("warning", "⏳") : failCount > 0 ? theme.fg("warning", "◐") : theme.fg("success", "✓");
|
||||
const status = isRunning ? `${successCount + failCount}/${details.results.length} done, ${running} running` : `${successCount}/${details.results.length} tasks`;
|
||||
|
||||
if (expanded && !isRunning) {
|
||||
const container = new Container();
|
||||
container.addChild(new Text(`${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`, 0, 0));
|
||||
for (const item of details.results) {
|
||||
const itemIcon = item.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
||||
const displayItems = getDisplayItems(item.messages);
|
||||
const finalOutput = getFinalOutput(item.messages);
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(`${theme.fg("muted", "─── ")}${theme.fg("accent", item.agent)} ${itemIcon}`, 0, 0));
|
||||
container.addChild(new Text(theme.fg("muted", "Task: ") + theme.fg("dim", item.task), 0, 0));
|
||||
for (const displayItem of displayItems) {
|
||||
if (displayItem.type === "toolCall") {
|
||||
container.addChild(new Text(theme.fg("muted", "→ ") + formatToolCall(displayItem.name, displayItem.args, theme.fg.bind(theme)), 0, 0));
|
||||
}
|
||||
}
|
||||
if (finalOutput) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Markdown(finalOutput.trim(), 0, 0, mdTheme));
|
||||
}
|
||||
const usageStr = formatUsageStats(item.usage, item.model);
|
||||
if (usageStr) container.addChild(new Text(theme.fg("dim", usageStr), 0, 0));
|
||||
}
|
||||
const totalUsageStr = formatUsageStats(aggregateUsage(details.results));
|
||||
if (totalUsageStr) {
|
||||
container.addChild(new Spacer(1));
|
||||
container.addChild(new Text(theme.fg("dim", `Total: ${totalUsageStr}`), 0, 0));
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
let text = `${icon} ${theme.fg("toolTitle", theme.bold("parallel "))}${theme.fg("accent", status)}`;
|
||||
for (const item of details.results) {
|
||||
const itemIcon = item.exitCode === -1 ? theme.fg("warning", "⏳") : item.exitCode === 0 ? theme.fg("success", "✓") : theme.fg("error", "✗");
|
||||
const displayItems = getDisplayItems(item.messages);
|
||||
text += `\n\n${theme.fg("muted", "─── ")}${theme.fg("accent", item.agent)} ${itemIcon}`;
|
||||
if (displayItems.length === 0) text += `\n${theme.fg("muted", item.exitCode === -1 ? "(running...)" : "(no output)")}`;
|
||||
else text += `\n${renderDisplayItems(displayItems, 5)}`;
|
||||
}
|
||||
if (!isRunning) {
|
||||
const totalUsageStr = formatUsageStats(aggregateUsage(details.results));
|
||||
if (totalUsageStr) text += `\n\n${theme.fg("dim", `Total: ${totalUsageStr}`)}`;
|
||||
}
|
||||
if (!expanded) text += `\n${theme.fg("muted", "(Ctrl+O to expand)")}`;
|
||||
return new Text(text, 0, 0);
|
||||
}
|
||||
|
||||
const text = result.content[0];
|
||||
return new Text(text?.type === "text" ? text.text : "(no output)", 0, 0);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
import { StringEnum } from "@mariozechner/pi-ai";
|
||||
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
|
||||
const AskUserParams = Type.Object({
|
||||
mode: Type.Optional(
|
||||
StringEnum(["input", "confirm", "select", "editor"] as const, {
|
||||
description: "Question mode",
|
||||
default: "input",
|
||||
}),
|
||||
),
|
||||
question: Type.String({ description: "Question title to show the user" }),
|
||||
message: Type.Optional(Type.String({ description: "Optional body text (confirm mode)" })),
|
||||
placeholder: Type.Optional(Type.String({ description: "Placeholder text (input mode)" })),
|
||||
options: Type.Optional(Type.Array(Type.String(), { description: "Options (select mode)" })),
|
||||
prefill: Type.Optional(Type.String({ description: "Prefilled text (editor mode)" })),
|
||||
});
|
||||
|
||||
function parseToolNames(raw: string | undefined): string[] {
|
||||
if (!raw) return [];
|
||||
const value = raw.trim();
|
||||
if (!value) return [];
|
||||
if (value.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((entry) => String(entry).trim()).filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid JSON and fall back to comma-separated parsing
|
||||
}
|
||||
}
|
||||
return value
|
||||
.split(",")
|
||||
.map((tool) => tool.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export default function interactiveTools(pi: ExtensionAPI) {
|
||||
pi.registerTool({
|
||||
name: "ask_user",
|
||||
label: "Ask User",
|
||||
description:
|
||||
"Ask the user an interactive clarification question. Supports input, confirm, select, and editor modes.",
|
||||
parameters: AskUserParams,
|
||||
promptGuidelines: [
|
||||
"Use ask_user only when required information is missing or ambiguous.",
|
||||
"Ask one focused question at a time and continue once answered.",
|
||||
],
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
if (!ctx.hasUI) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: ask_user requires an interactive UI." }],
|
||||
isError: true,
|
||||
details: { mode: params.mode ?? "input", cancelled: true },
|
||||
};
|
||||
}
|
||||
|
||||
const mode = params.mode ?? "input";
|
||||
|
||||
if (mode === "confirm") {
|
||||
const confirmed = await ctx.ui.confirm(params.question, params.message ?? "Please confirm.");
|
||||
return {
|
||||
content: [{ type: "text", text: `User confirmation: ${confirmed ? "yes" : "no"}` }],
|
||||
details: { mode, confirmed },
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "select") {
|
||||
const options = (params.options ?? []).map((option) => option.trim()).filter(Boolean);
|
||||
if (options.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Error: select mode requires a non-empty options array." }],
|
||||
isError: true,
|
||||
details: { mode, cancelled: true },
|
||||
};
|
||||
}
|
||||
const value = await ctx.ui.select(params.question, options);
|
||||
if (value === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "User cancelled the selection." }],
|
||||
details: { mode, cancelled: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `User selected: ${value}` }],
|
||||
details: { mode, value, cancelled: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (mode === "editor") {
|
||||
const value = await ctx.ui.editor(params.question, params.prefill ?? "");
|
||||
if (value === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "User cancelled editor input." }],
|
||||
details: { mode, cancelled: true },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `User input: ${value}` }],
|
||||
details: { mode, value, cancelled: false },
|
||||
};
|
||||
}
|
||||
|
||||
const value = await ctx.ui.input(params.question, params.placeholder);
|
||||
if (value === undefined) {
|
||||
return {
|
||||
content: [{ type: "text", text: "User cancelled input." }],
|
||||
details: { mode: "input", cancelled: true },
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `User input: ${value}` }],
|
||||
details: { mode: "input", value, cancelled: false },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.on("session_start", () => {
|
||||
const requestedTools = parseToolNames(process.env.PI_SUBAGENT_TOOLS);
|
||||
if (requestedTools.length === 0) return;
|
||||
|
||||
const available = new Set(pi.getAllTools().map((tool) => tool.name));
|
||||
const validTools = requestedTools.filter((tool) => available.has(tool));
|
||||
if (validTools.length > 0) {
|
||||
pi.setActiveTools(validTools);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
import { StringDecoder } from "node:string_decoder";
|
||||
|
||||
export function serializeJsonLine(value: unknown): string {
|
||||
return `${JSON.stringify(value)}\n`;
|
||||
}
|
||||
|
||||
export function attachJsonlLineReader(stream: NodeJS.ReadableStream, onLine: (line: string) => void): () => void {
|
||||
const decoder = new StringDecoder("utf8");
|
||||
let buffer = "";
|
||||
|
||||
const emitLine = (line: string) => {
|
||||
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
||||
};
|
||||
|
||||
const onData = (chunk: Buffer | string) => {
|
||||
buffer += typeof chunk === "string" ? chunk : decoder.write(chunk);
|
||||
while (true) {
|
||||
const newlineIndex = buffer.indexOf("\n");
|
||||
if (newlineIndex === -1) return;
|
||||
emitLine(buffer.slice(0, newlineIndex));
|
||||
buffer = buffer.slice(newlineIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const onEnd = () => {
|
||||
buffer += decoder.end();
|
||||
if (buffer.length > 0) {
|
||||
emitLine(buffer);
|
||||
buffer = "";
|
||||
}
|
||||
};
|
||||
|
||||
stream.on("data", onData);
|
||||
stream.on("end", onEnd);
|
||||
|
||||
return () => {
|
||||
stream.off("data", onData);
|
||||
stream.off("end", onEnd);
|
||||
};
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"name": "pi-subagent-roles",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"pi": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -1,336 +0,0 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import type { AgentConfig } from "./agents.js";
|
||||
import { attachJsonlLineReader, serializeJsonLine } from "./jsonl.js";
|
||||
import {
|
||||
applyAssistantUsage,
|
||||
createEmptyUsageStats,
|
||||
getFinalOutput,
|
||||
type OnUpdateCallback,
|
||||
type SingleResult,
|
||||
type SubagentDetails,
|
||||
} from "./subagent-types.js";
|
||||
|
||||
interface RpcResponse {
|
||||
type: "response";
|
||||
id?: string;
|
||||
command: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (response: RpcResponse) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeout: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
type RpcExtensionUIRequest =
|
||||
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }
|
||||
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }
|
||||
| { type: "extension_ui_request"; id: string; method: "input"; title: string; placeholder?: string; timeout?: number }
|
||||
| { type: "extension_ui_request"; id: string; method: "editor"; title: string; prefill?: string }
|
||||
| { type: "extension_ui_request"; id: string; method: "notify"; message: string; notifyType?: "info" | "warning" | "error" }
|
||||
| { type: "extension_ui_request"; id: string; method: "setStatus"; statusKey: string; statusText?: string }
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
id: string;
|
||||
method: "setWidget";
|
||||
widgetKey: string;
|
||||
widgetLines?: string[];
|
||||
widgetPlacement?: "aboveEditor" | "belowEditor";
|
||||
}
|
||||
| { type: "extension_ui_request"; id: string; method: "setTitle"; title: string }
|
||||
| { type: "extension_ui_request"; id: string; method: "set_editor_text"; text: string };
|
||||
|
||||
function writePromptToTempFile(agentName: string, prompt: string): { dir: string; filePath: string } {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-subagent-rpc-"));
|
||||
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
||||
const filePath = path.join(tmpDir, `prompt-${safeName}.md`);
|
||||
fs.writeFileSync(filePath, prompt, { encoding: "utf-8", mode: 0o600 });
|
||||
return { dir: tmpDir, filePath };
|
||||
}
|
||||
|
||||
function terminateProcess(proc: ReturnType<typeof spawn>): void {
|
||||
if (proc.exitCode !== null) return;
|
||||
proc.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (proc.exitCode === null) proc.kill("SIGKILL");
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
function safeJsonParse(line: string): any | undefined {
|
||||
if (!line.trim()) return undefined;
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function bridgeExtensionUiRequest(
|
||||
request: RpcExtensionUIRequest,
|
||||
ctx: ExtensionContext,
|
||||
signal: AbortSignal | undefined,
|
||||
sendJson: (obj: unknown) => void,
|
||||
): Promise<void> {
|
||||
const sendCancelled = () => sendJson({ type: "extension_ui_response", id: request.id, cancelled: true });
|
||||
|
||||
try {
|
||||
switch (request.method) {
|
||||
case "select": {
|
||||
const value = await ctx.ui.select(request.title, request.options, { signal, timeout: request.timeout });
|
||||
if (value === undefined) sendCancelled();
|
||||
else sendJson({ type: "extension_ui_response", id: request.id, value });
|
||||
return;
|
||||
}
|
||||
case "confirm": {
|
||||
const confirmed = await ctx.ui.confirm(request.title, request.message, { signal, timeout: request.timeout });
|
||||
sendJson({ type: "extension_ui_response", id: request.id, confirmed });
|
||||
return;
|
||||
}
|
||||
case "input": {
|
||||
const value = await ctx.ui.input(request.title, request.placeholder, { signal, timeout: request.timeout });
|
||||
if (value === undefined) sendCancelled();
|
||||
else sendJson({ type: "extension_ui_response", id: request.id, value });
|
||||
return;
|
||||
}
|
||||
case "editor": {
|
||||
const value = await ctx.ui.editor(request.title, request.prefill ?? "");
|
||||
if (value === undefined) sendCancelled();
|
||||
else sendJson({ type: "extension_ui_response", id: request.id, value });
|
||||
return;
|
||||
}
|
||||
case "notify":
|
||||
ctx.ui.notify(request.message, request.notifyType);
|
||||
return;
|
||||
case "setStatus":
|
||||
ctx.ui.setStatus(request.statusKey, request.statusText);
|
||||
return;
|
||||
case "setWidget":
|
||||
if (request.widgetLines === undefined || Array.isArray(request.widgetLines)) {
|
||||
ctx.ui.setWidget(request.widgetKey, request.widgetLines, { placement: request.widgetPlacement });
|
||||
}
|
||||
return;
|
||||
case "setTitle":
|
||||
ctx.ui.setTitle(request.title);
|
||||
return;
|
||||
case "set_editor_text":
|
||||
ctx.ui.setEditorText(request.text);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
if (request.method === "select" || request.method === "confirm" || request.method === "input" || request.method === "editor") {
|
||||
sendCancelled();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runInteractiveAgentRpc(
|
||||
defaultCwd: string,
|
||||
agents: AgentConfig[],
|
||||
agentName: string,
|
||||
task: string,
|
||||
cwd: string | undefined,
|
||||
step: number | undefined,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: OnUpdateCallback | undefined,
|
||||
makeDetails: (results: SingleResult[]) => SubagentDetails,
|
||||
ctx: ExtensionContext,
|
||||
): Promise<SingleResult> {
|
||||
const agent = agents.find((entry) => entry.name === agentName);
|
||||
if (!agent) {
|
||||
const available = agents.map((entry) => `"${entry.name}"`).join(", ") || "none";
|
||||
return {
|
||||
agent: agentName,
|
||||
agentSource: "unknown",
|
||||
task,
|
||||
exitCode: 1,
|
||||
messages: [],
|
||||
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
|
||||
usage: createEmptyUsageStats(),
|
||||
step,
|
||||
};
|
||||
}
|
||||
|
||||
const args: string[] = ["--mode", "rpc", "--no-session"];
|
||||
if (agent.model) args.push("--model", agent.model);
|
||||
|
||||
let tmpPromptDir: string | null = null;
|
||||
let tmpPromptPath: string | null = null;
|
||||
if (agent.systemPrompt.trim()) {
|
||||
const tmp = writePromptToTempFile(agent.name, agent.systemPrompt);
|
||||
tmpPromptDir = tmp.dir;
|
||||
tmpPromptPath = tmp.filePath;
|
||||
args.push("--append-system-prompt", tmpPromptPath);
|
||||
}
|
||||
|
||||
const extensionDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const helperExtensionPath = path.join(extensionDir, "interactive-tools.ts");
|
||||
args.push("--extension", helperExtensionPath);
|
||||
|
||||
const env = { ...process.env };
|
||||
if (agent.tools && agent.tools.length > 0) {
|
||||
env.PI_SUBAGENT_TOOLS = JSON.stringify(agent.tools);
|
||||
}
|
||||
|
||||
const currentResult: SingleResult = {
|
||||
agent: agentName,
|
||||
agentSource: agent.source,
|
||||
task,
|
||||
exitCode: 0,
|
||||
messages: [],
|
||||
stderr: "",
|
||||
usage: createEmptyUsageStats(),
|
||||
model: agent.model,
|
||||
step,
|
||||
};
|
||||
|
||||
const emitUpdate = () => {
|
||||
if (!onUpdate) return;
|
||||
onUpdate({
|
||||
content: [{ type: "text", text: getFinalOutput(currentResult.messages) || "(running...)" }],
|
||||
details: makeDetails([currentResult]),
|
||||
});
|
||||
};
|
||||
|
||||
const proc = spawn("pi", args, { cwd: cwd ?? defaultCwd, shell: false, stdio: ["pipe", "pipe", "pipe"], env });
|
||||
const pendingRequests = new Map<string, PendingRequest>();
|
||||
let requestCount = 0;
|
||||
let agentEnded = false;
|
||||
let resolveAgentEnd: (() => void) | undefined;
|
||||
const agentEndPromise = new Promise<void>((resolve) => {
|
||||
resolveAgentEnd = resolve;
|
||||
});
|
||||
const processExitPromise = new Promise<number>((resolve) => {
|
||||
proc.on("close", (code) => {
|
||||
if (!agentEnded) resolveAgentEnd?.();
|
||||
for (const [id, pending] of pendingRequests.entries()) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error(`RPC process exited before response: ${id}`));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
});
|
||||
let wasAborted = false;
|
||||
|
||||
const sendJson = (obj: unknown) => {
|
||||
if (proc.stdin.destroyed || proc.stdin.writableEnded) return;
|
||||
proc.stdin.write(serializeJsonLine(obj));
|
||||
};
|
||||
|
||||
const sendCommand = (command: Record<string, unknown>): Promise<RpcResponse> => {
|
||||
const id = `subagent_${++requestCount}`;
|
||||
return new Promise<RpcResponse>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
pendingRequests.delete(id);
|
||||
reject(new Error(`RPC command timed out: ${command.type}`));
|
||||
}, 30000);
|
||||
pendingRequests.set(id, { resolve, reject, timeout });
|
||||
sendJson({ ...command, id });
|
||||
});
|
||||
};
|
||||
|
||||
const detachStdout = attachJsonlLineReader(proc.stdout, (line) => {
|
||||
const event = safeJsonParse(line);
|
||||
if (!event) {
|
||||
currentResult.stderr += `[rpc] Ignored non-JSON output: ${line}\n`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "response" && event.id && pendingRequests.has(event.id)) {
|
||||
const pending = pendingRequests.get(event.id)!;
|
||||
pendingRequests.delete(event.id);
|
||||
clearTimeout(pending.timeout);
|
||||
pending.resolve(event as RpcResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "extension_ui_request") {
|
||||
void bridgeExtensionUiRequest(event as RpcExtensionUIRequest, ctx, signal, sendJson);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "message_end" && event.message) {
|
||||
const msg = event.message;
|
||||
currentResult.messages.push(msg);
|
||||
applyAssistantUsage(currentResult, msg);
|
||||
emitUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "agent_end") {
|
||||
agentEnded = true;
|
||||
resolveAgentEnd?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === "extension_error") {
|
||||
const extensionPath = typeof event.extensionPath === "string" ? event.extensionPath : "unknown";
|
||||
const error = typeof event.error === "string" ? event.error : "unknown extension error";
|
||||
currentResult.stderr += `[extension:${extensionPath}] ${error}\n`;
|
||||
}
|
||||
});
|
||||
|
||||
proc.stderr.on("data", (data) => {
|
||||
currentResult.stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on("error", (error) => {
|
||||
currentResult.stderr += `${error.message}\n`;
|
||||
});
|
||||
|
||||
const abortHandler = () => {
|
||||
wasAborted = true;
|
||||
try {
|
||||
sendJson({ type: "abort" });
|
||||
} catch {}
|
||||
terminateProcess(proc);
|
||||
};
|
||||
if (signal) {
|
||||
if (signal.aborted) abortHandler();
|
||||
else signal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
const promptResponse = await sendCommand({ type: "prompt", message: `Task: ${task}` });
|
||||
if (!promptResponse.success) {
|
||||
currentResult.exitCode = 1;
|
||||
currentResult.stderr += `Prompt failed: ${promptResponse.error || "unknown error"}`;
|
||||
return currentResult;
|
||||
}
|
||||
|
||||
await agentEndPromise;
|
||||
if (currentResult.stopReason === "error") currentResult.exitCode = 1;
|
||||
if (wasAborted) throw new Error("Subagent was aborted");
|
||||
return currentResult;
|
||||
} finally {
|
||||
detachStdout();
|
||||
for (const [id, pending] of pendingRequests.entries()) {
|
||||
clearTimeout(pending.timeout);
|
||||
pending.reject(new Error(`RPC command interrupted: ${id}`));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
if (signal) signal.removeEventListener("abort", abortHandler);
|
||||
terminateProcess(proc);
|
||||
const processCode = await processExitPromise;
|
||||
if (!wasAborted && currentResult.exitCode === 0 && processCode !== 0 && !agentEnded) {
|
||||
currentResult.exitCode = processCode;
|
||||
}
|
||||
if (tmpPromptPath) {
|
||||
try {
|
||||
fs.unlinkSync(tmpPromptPath);
|
||||
} catch {}
|
||||
}
|
||||
if (tmpPromptDir) {
|
||||
try {
|
||||
fs.rmdirSync(tmpPromptDir);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
import type { Message } from "@mariozechner/pi-ai";
|
||||
import type { AgentScope } from "./agents.js";
|
||||
|
||||
export interface UsageStats {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
cost: number;
|
||||
contextTokens: number;
|
||||
turns: number;
|
||||
}
|
||||
|
||||
export interface SingleResult {
|
||||
agent: string;
|
||||
agentSource: "user" | "project" | "unknown";
|
||||
task: string;
|
||||
exitCode: number;
|
||||
messages: Message[];
|
||||
stderr: string;
|
||||
usage: UsageStats;
|
||||
model?: string;
|
||||
stopReason?: string;
|
||||
errorMessage?: string;
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface SubagentDetails {
|
||||
mode: "single" | "parallel" | "chain";
|
||||
agentScope: AgentScope;
|
||||
projectAgentsDir: string | null;
|
||||
results: SingleResult[];
|
||||
}
|
||||
|
||||
export type ToolContent = Array<{ type: "text"; text: string }>;
|
||||
export type ToolUpdate = { content: ToolContent; details?: SubagentDetails; isError?: boolean };
|
||||
export type OnUpdateCallback = (partial: ToolUpdate) => void;
|
||||
export type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
|
||||
|
||||
export function createEmptyUsageStats(): UsageStats {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
cost: 0,
|
||||
contextTokens: 0,
|
||||
turns: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAssistantUsage(result: SingleResult, msg: Message): void {
|
||||
if (msg.role !== "assistant") return;
|
||||
result.usage.turns++;
|
||||
const usage = msg.usage;
|
||||
if (usage) {
|
||||
result.usage.input += usage.input || 0;
|
||||
result.usage.output += usage.output || 0;
|
||||
result.usage.cacheRead += usage.cacheRead || 0;
|
||||
result.usage.cacheWrite += usage.cacheWrite || 0;
|
||||
result.usage.cost += usage.cost?.total || 0;
|
||||
result.usage.contextTokens = usage.totalTokens || 0;
|
||||
}
|
||||
if (!result.model && msg.model) result.model = msg.model;
|
||||
if (msg.stopReason) result.stopReason = msg.stopReason;
|
||||
if (msg.errorMessage) result.errorMessage = msg.errorMessage;
|
||||
}
|
||||
|
||||
export function getFinalOutput(messages: Message[]): string {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msg = messages[i];
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") return part.text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function getDisplayItems(messages: Message[]): DisplayItem[] {
|
||||
const items: DisplayItem[] = [];
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") items.push({ type: "text", text: part.text });
|
||||
else if (part.type === "toolCall") items.push({ type: "toolCall", name: part.name, args: part.arguments });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue