dotfiles: add interactive pi planner workflow

This commit is contained in:
Rasyidan Akbar F. 2026-03-12 16:54:57 +07:00
commit 5e01e2e44f
15 changed files with 1472 additions and 108 deletions

View file

@ -11,6 +11,9 @@ network_access = true
[projects."/home/rsydn"]
trust_level = "trusted"
[projects."/home/rsydn/Projects/Collab/trafalgar-law"]
trust_level = "trusted"
[notice.model_migrations]
"gpt-5.3-codex" = "gpt-5.4"

View file

@ -6,3 +6,9 @@ source /usr/share/cachyos-fish-config/cachyos-config.fish
# # smth smth
#end
alias dotfiles='git --git-dir=/home/rsydn/.dotfiles/ --work-tree=/home/rsydn'
set -gx PATH ~/.npm-global/bin $PATH
set -gx PATH ~/.npm-global/bin $PATH
if status is-interactive
direnv hook fish | source
end

View file

@ -3,6 +3,7 @@
SETUVAR __done_min_cmd_duration:10000
SETUVAR __done_notification_urgency_level:low
SETUVAR __fish_initialized:4300
SETUVAR --export fish_user_paths:/home/rsydn/\x2elocal/bin
SETUVAR pure_begin_prompt_with_current_directory:true
SETUVAR pure_check_for_new_release:false
SETUVAR pure_color_at_sign:pure_color_mute

View file

@ -0,0 +1,51 @@
---
name: executor
description: Implementation specialist for executing ordered coding tasks from a defined todo list
tools: read, bash, edit, write
model: openai-codex/gpt-5.3-codex:high
---
You are an implementation-focused coding agent.
Your job is to execute a clearly defined, ordered todo list or task list.
Assume planning has already happened. Your responsibility is to implement the requested work in the given order, keep scope tight, and deliver working code.
Focus on:
- following the provided task order unless a dependency forces a small adjustment
- implementing the requested changes precisely
- reading relevant files before editing
- making targeted, minimal edits that match existing project patterns
- validating the result with tests, type checks, builds, or lint commands when appropriate
- surfacing blockers quickly instead of improvising large unapproved scope changes
Tool rules:
- Use read first to understand the current code before editing.
- Use bash for project inspection and validation commands.
- Use edit for surgical changes and write only when creating or fully replacing files.
- Do not do a fresh high-level redesign unless the task list is clearly wrong or impossible.
- If requirements are ambiguous or the ordered tasks conflict with the codebase, stop and explain the blocker.
Execution behavior:
1. Restate the implementation goal briefly.
2. Work through the ordered tasks one by one.
3. Keep track of what is completed, in progress, or blocked.
4. Run relevant verification before finishing whenever feasible.
5. Return a concise implementation summary with files changed, checks run, and any follow-up items.
Output format:
## Goal
- Brief restatement of the requested implementation
## Progress
- Completed tasks
- Remaining tasks or blockers
## Changes Made
- Files changed and what changed in each
## Validation
- Commands run and results
## Notes
- Risks, follow-ups, or assumptions that the caller should know

View file

@ -0,0 +1,33 @@
---
name: planner
description: Interactive planning specialist that clarifies ambiguity and returns an ordered implementation todo list
tools: read, grep, find, ls, ask_user
model: anthropic/claude-sonnet-4-6
interactive: true
---
You are an implementation planner.
Your job is to produce a concrete, ordered todo list that an executor agent can implement directly.
You may inspect the codebase with read-only tools. If requirements are ambiguous or missing critical constraints, ask focused questions using ask_user. Do not ask unnecessary questions.
Rules:
- Clarify only when needed to avoid incorrect implementation.
- Ask one concise question at a time.
- Prefer assumptions only when low-risk; state any assumptions explicitly.
- Do not edit files or run destructive commands.
Output format:
## Goal
- Brief restatement of the implementation objective
## Ordered Todo List
1. Step one
2. Step two
3. Step three
## Notes for Executor
- Key files, constraints, and validation commands to run
- Any assumptions or open risks

View file

@ -0,0 +1,606 @@
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);
},
});
}

View file

@ -0,0 +1,20 @@
{
"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"
}
}

View file

@ -5,6 +5,8 @@ Installed roles:
- `reviewer` — review changes, bugs, risks, maintainability
- `librarian` — explore and map a codebase
- `uiux-designer` — UI/UX design direction, flows, states, and implementation guidance
- `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
@ -16,6 +18,16 @@ Installed roles:
- `Use the subagent tool with reviewer to inspect the auth refactor.`
- `Use subagent in parallel: librarian maps the settings flow, uiux-designer proposes an improved UX direction, reviewer lists product and implementation risks.`
- `Chain librarian -> uiux-designer 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
@ -26,3 +38,11 @@ Installed roles:
- `reviewer` uses `openai-codex/gpt-5.4:xhigh`
- `librarian` uses `anthropic/claude-sonnet-4-6`
- `uiux-designer` uses `google-antigravity/gemini-3.1-pro-high`
- `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)

View file

@ -9,6 +9,7 @@ export interface AgentConfig {
description: string;
tools?: string[];
model?: string;
interactive?: boolean;
systemPrompt: string;
source: "user" | "project";
filePath: string;
@ -19,6 +20,55 @@ export interface AgentDiscoveryResult {
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;
@ -42,19 +92,17 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
continue;
}
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
if (!frontmatter.name || !frontmatter.description) continue;
const tools = frontmatter.tools
?.split(",")
.map((t: string) => t.trim())
.filter(Boolean);
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: frontmatter.name,
description: frontmatter.description,
tools: tools && tools.length > 0 ? tools : undefined,
model: frontmatter.model,
name,
description,
tools: normalizeTools(frontmatter.tools),
model: normalizeString(frontmatter.model),
interactive: normalizeBoolean(frontmatter.interactive),
systemPrompt: body,
source,
filePath,
@ -109,7 +157,7 @@ export function formatAgentList(agents: AgentConfig[], maxItems: number): { text
const listed = agents.slice(0, maxItems);
const remaining = agents.length - listed.length;
return {
text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
text: listed.map((agent) => `${agent.name} (${agent.source}): ${agent.description}`).join("; "),
remaining,
};
}

View file

@ -2,53 +2,27 @@ 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 type { Message } from "@mariozechner/pi-ai";
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;
interface UsageStats {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
cost: number;
contextTokens: number;
turns: number;
}
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;
}
interface SubagentDetails {
mode: "single" | "parallel" | "chain";
agentScope: AgentScope;
projectAgentsDir: string | null;
results: SingleResult[];
}
type ToolContent = Array<{ type: "text"; text: string }>;
type ToolUpdate = { content: ToolContent; details?: SubagentDetails; isError?: boolean };
type OnUpdateCallback = (partial: ToolUpdate) => void;
type DisplayItem = { type: "text"; text: string } | { type: "toolCall"; name: string; args: Record<string, any> };
function formatTokens(count: number): string {
if (count < 1000) return count.toString();
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
@ -144,31 +118,6 @@ function formatToolCall(
}
}
function getFinalOutput(messages: Message[]): string {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === "assistant") {
for (const part of msg.content) {
if (part.type === "text") return part.text;
}
}
}
return "";
}
function getDisplayItems(messages: Message[]): DisplayItem[] {
const items: DisplayItem[] = [];
for (const msg of messages) {
if (msg.role === "assistant") {
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;
}
async function mapWithConcurrencyLimit<TIn, TOut>(
items: TIn[],
concurrency: number,
@ -218,7 +167,7 @@ async function runSingleAgent(
exitCode: 1,
messages: [],
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
usage: createEmptyUsageStats(),
step,
};
}
@ -237,7 +186,7 @@ async function runSingleAgent(
exitCode: 0,
messages: [],
stderr: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
usage: createEmptyUsageStats(),
model: agent.model,
step,
};
@ -275,28 +224,14 @@ async function runSingleAgent(
}
if (event.type === "message_end" && event.message) {
const msg = event.message as Message;
const msg = event.message;
currentResult.messages.push(msg);
if (msg.role === "assistant") {
currentResult.usage.turns++;
const usage = msg.usage;
if (usage) {
currentResult.usage.input += usage.input || 0;
currentResult.usage.output += usage.output || 0;
currentResult.usage.cacheRead += usage.cacheRead || 0;
currentResult.usage.cacheWrite += usage.cacheWrite || 0;
currentResult.usage.cost += usage.cost?.total || 0;
currentResult.usage.contextTokens = usage.totalTokens || 0;
}
if (!currentResult.model && msg.model) currentResult.model = msg.model;
if (msg.stopReason) currentResult.stopReason = msg.stopReason;
if (msg.errorMessage) currentResult.errorMessage = msg.errorMessage;
}
applyAssistantUsage(currentResult, msg);
emitUpdate();
}
if (event.type === "tool_result_end" && event.message) {
currentResult.messages.push(event.message as Message);
currentResult.messages.push(event.message);
emitUpdate();
}
};
@ -349,6 +284,10 @@ async function runSingleAgent(
}
}
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" }),
@ -396,7 +335,7 @@ export default function (pi: ExtensionAPI) {
description: [
"Delegate work to specialized subagents with isolated context.",
"Supports single, parallel, and chained execution.",
"Useful built-in roles: reviewer, librarian, uiux-designer.",
"Useful built-in roles: reviewer, librarian, uiux-designer, executor.",
'Default scope is "user" (~/.pi/agent/agents). Set agentScope to "both" or "project" to include repo-local agents.',
].join(" "),
parameters: SubagentParams,
@ -429,6 +368,33 @@ export default function (pi: ExtensionAPI) {
};
}
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);
@ -521,7 +487,7 @@ export default function (pi: ExtensionAPI) {
exitCode: -1,
messages: [],
stderr: "",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
usage: createEmptyUsageStats(),
};
}
@ -571,7 +537,21 @@ export default function (pi: ExtensionAPI) {
}
if (params.agent && params.task) {
const result = await runSingleAgent(
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,

View file

@ -0,0 +1,130 @@
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);
}
});
}

View file

@ -0,0 +1,40 @@
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);
};
}

View file

@ -0,0 +1,336 @@
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 {}
}
}
}

View file

@ -0,0 +1,90 @@
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;
}

View file

@ -1,7 +1,7 @@
{
"lastChangelogVersion": "0.57.1",
"defaultProvider": "anthropic",
"defaultModel": "claude-opus-4-6",
"defaultProvider": "openai-codex",
"defaultModel": "gpt-5.4",
"defaultThinkingLevel": "high",
"theme": "catppuccin-mocha"
}