dotfiles: add interactive pi planner workflow
This commit is contained in:
parent
7ec9fe1099
commit
5e01e2e44f
15 changed files with 1472 additions and 108 deletions
|
|
@ -11,6 +11,9 @@ network_access = true
|
||||||
[projects."/home/rsydn"]
|
[projects."/home/rsydn"]
|
||||||
trust_level = "trusted"
|
trust_level = "trusted"
|
||||||
|
|
||||||
|
[projects."/home/rsydn/Projects/Collab/trafalgar-law"]
|
||||||
|
trust_level = "trusted"
|
||||||
|
|
||||||
[notice.model_migrations]
|
[notice.model_migrations]
|
||||||
"gpt-5.3-codex" = "gpt-5.4"
|
"gpt-5.3-codex" = "gpt-5.4"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,9 @@ source /usr/share/cachyos-fish-config/cachyos-config.fish
|
||||||
# # smth smth
|
# # smth smth
|
||||||
#end
|
#end
|
||||||
alias dotfiles='git --git-dir=/home/rsydn/.dotfiles/ --work-tree=/home/rsydn'
|
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
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
SETUVAR __done_min_cmd_duration:10000
|
SETUVAR __done_min_cmd_duration:10000
|
||||||
SETUVAR __done_notification_urgency_level:low
|
SETUVAR __done_notification_urgency_level:low
|
||||||
SETUVAR __fish_initialized:4300
|
SETUVAR __fish_initialized:4300
|
||||||
|
SETUVAR --export fish_user_paths:/home/rsydn/\x2elocal/bin
|
||||||
SETUVAR pure_begin_prompt_with_current_directory:true
|
SETUVAR pure_begin_prompt_with_current_directory:true
|
||||||
SETUVAR pure_check_for_new_release:false
|
SETUVAR pure_check_for_new_release:false
|
||||||
SETUVAR pure_color_at_sign:pure_color_mute
|
SETUVAR pure_color_at_sign:pure_color_mute
|
||||||
|
|
|
||||||
51
.pi/agent/agents/executor.md
Normal file
51
.pi/agent/agents/executor.md
Normal 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
|
||||||
33
.pi/agent/agents/planner.md
Normal file
33
.pi/agent/agents/planner.md
Normal 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
|
||||||
606
.pi/agent/extensions/lsp-feedback/index.ts
Normal file
606
.pi/agent/extensions/lsp-feedback/index.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
20
.pi/agent/extensions/lsp-feedback/package.json
Normal file
20
.pi/agent/extensions/lsp-feedback/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@ Installed roles:
|
||||||
- `reviewer` — review changes, bugs, risks, maintainability
|
- `reviewer` — review changes, bugs, risks, maintainability
|
||||||
- `librarian` — explore and map a codebase
|
- `librarian` — explore and map a codebase
|
||||||
- `uiux-designer` — UI/UX design direction, flows, states, and implementation guidance
|
- `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
|
## Commands
|
||||||
|
|
||||||
|
|
@ -16,6 +18,16 @@ Installed roles:
|
||||||
- `Use the subagent tool with reviewer to inspect the auth refactor.`
|
- `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.`
|
- `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.`
|
- `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
|
## Notes
|
||||||
|
|
||||||
|
|
@ -26,3 +38,11 @@ Installed roles:
|
||||||
- `reviewer` uses `openai-codex/gpt-5.4:xhigh`
|
- `reviewer` uses `openai-codex/gpt-5.4:xhigh`
|
||||||
- `librarian` uses `anthropic/claude-sonnet-4-6`
|
- `librarian` uses `anthropic/claude-sonnet-4-6`
|
||||||
- `uiux-designer` uses `google-antigravity/gemini-3.1-pro-high`
|
- `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)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ export interface AgentConfig {
|
||||||
description: string;
|
description: string;
|
||||||
tools?: string[];
|
tools?: string[];
|
||||||
model?: string;
|
model?: string;
|
||||||
|
interactive?: boolean;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
source: "user" | "project";
|
source: "user" | "project";
|
||||||
filePath: string;
|
filePath: string;
|
||||||
|
|
@ -19,6 +20,55 @@ export interface AgentDiscoveryResult {
|
||||||
projectAgentsDir: string | null;
|
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[] {
|
function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
|
||||||
const agents: AgentConfig[] = [];
|
const agents: AgentConfig[] = [];
|
||||||
if (!fs.existsSync(dir)) return agents;
|
if (!fs.existsSync(dir)) return agents;
|
||||||
|
|
@ -42,19 +92,17 @@ function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
|
const { frontmatter, body } = parseFrontmatter<Record<string, unknown>>(content);
|
||||||
if (!frontmatter.name || !frontmatter.description) continue;
|
const name = normalizeString(frontmatter.name);
|
||||||
|
const description = normalizeString(frontmatter.description);
|
||||||
const tools = frontmatter.tools
|
if (!name || !description) continue;
|
||||||
?.split(",")
|
|
||||||
.map((t: string) => t.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
agents.push({
|
agents.push({
|
||||||
name: frontmatter.name,
|
name,
|
||||||
description: frontmatter.description,
|
description,
|
||||||
tools: tools && tools.length > 0 ? tools : undefined,
|
tools: normalizeTools(frontmatter.tools),
|
||||||
model: frontmatter.model,
|
model: normalizeString(frontmatter.model),
|
||||||
|
interactive: normalizeBoolean(frontmatter.interactive),
|
||||||
systemPrompt: body,
|
systemPrompt: body,
|
||||||
source,
|
source,
|
||||||
filePath,
|
filePath,
|
||||||
|
|
@ -109,7 +157,7 @@ export function formatAgentList(agents: AgentConfig[], maxItems: number): { text
|
||||||
const listed = agents.slice(0, maxItems);
|
const listed = agents.slice(0, maxItems);
|
||||||
const remaining = agents.length - listed.length;
|
const remaining = agents.length - listed.length;
|
||||||
return {
|
return {
|
||||||
text: listed.map((a) => `${a.name} (${a.source}): ${a.description}`).join("; "),
|
text: listed.map((agent) => `${agent.name} (${agent.source}): ${agent.description}`).join("; "),
|
||||||
remaining,
|
remaining,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,53 +2,27 @@ import { spawn } from "node:child_process";
|
||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import type { Message } from "@mariozechner/pi-ai";
|
|
||||||
import { StringEnum } from "@mariozechner/pi-ai";
|
import { StringEnum } from "@mariozechner/pi-ai";
|
||||||
import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
import { type ExtensionAPI, getMarkdownTheme } from "@mariozechner/pi-coding-agent";
|
||||||
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
|
||||||
import { Type } from "@sinclair/typebox";
|
import { Type } from "@sinclair/typebox";
|
||||||
import { type AgentConfig, type AgentScope, discoverAgents, formatAgentList } from "./agents.js";
|
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_PARALLEL_TASKS = 8;
|
||||||
const MAX_CONCURRENCY = 4;
|
const MAX_CONCURRENCY = 4;
|
||||||
const COLLAPSED_ITEM_COUNT = 10;
|
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 {
|
function formatTokens(count: number): string {
|
||||||
if (count < 1000) return count.toString();
|
if (count < 1000) return count.toString();
|
||||||
if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
|
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>(
|
async function mapWithConcurrencyLimit<TIn, TOut>(
|
||||||
items: TIn[],
|
items: TIn[],
|
||||||
concurrency: number,
|
concurrency: number,
|
||||||
|
|
@ -218,7 +167,7 @@ async function runSingleAgent(
|
||||||
exitCode: 1,
|
exitCode: 1,
|
||||||
messages: [],
|
messages: [],
|
||||||
stderr: `Unknown agent: "${agentName}". Available agents: ${available}.`,
|
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,
|
step,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -237,7 +186,7 @@ async function runSingleAgent(
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
messages: [],
|
messages: [],
|
||||||
stderr: "",
|
stderr: "",
|
||||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
|
usage: createEmptyUsageStats(),
|
||||||
model: agent.model,
|
model: agent.model,
|
||||||
step,
|
step,
|
||||||
};
|
};
|
||||||
|
|
@ -275,28 +224,14 @@ async function runSingleAgent(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "message_end" && event.message) {
|
if (event.type === "message_end" && event.message) {
|
||||||
const msg = event.message as Message;
|
const msg = event.message;
|
||||||
currentResult.messages.push(msg);
|
currentResult.messages.push(msg);
|
||||||
if (msg.role === "assistant") {
|
applyAssistantUsage(currentResult, msg);
|
||||||
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;
|
|
||||||
}
|
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.type === "tool_result_end" && event.message) {
|
if (event.type === "tool_result_end" && event.message) {
|
||||||
currentResult.messages.push(event.message as Message);
|
currentResult.messages.push(event.message);
|
||||||
emitUpdate();
|
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({
|
const TaskItem = Type.Object({
|
||||||
agent: Type.String({ description: "Name of the agent to invoke" }),
|
agent: Type.String({ description: "Name of the agent to invoke" }),
|
||||||
task: Type.String({ description: "Task to delegate to the agent" }),
|
task: Type.String({ description: "Task to delegate to the agent" }),
|
||||||
|
|
@ -396,7 +335,7 @@ export default function (pi: ExtensionAPI) {
|
||||||
description: [
|
description: [
|
||||||
"Delegate work to specialized subagents with isolated context.",
|
"Delegate work to specialized subagents with isolated context.",
|
||||||
"Supports single, parallel, and chained execution.",
|
"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.',
|
'Default scope is "user" (~/.pi/agent/agents). Set agentScope to "both" or "project" to include repo-local agents.',
|
||||||
].join(" "),
|
].join(" "),
|
||||||
parameters: SubagentParams,
|
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) {
|
if ((agentScope === "project" || agentScope === "both") && confirmProjectAgents && ctx.hasUI) {
|
||||||
const requestedAgentNames = new Set<string>();
|
const requestedAgentNames = new Set<string>();
|
||||||
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
|
if (params.chain) for (const step of params.chain) requestedAgentNames.add(step.agent);
|
||||||
|
|
@ -521,7 +487,7 @@ export default function (pi: ExtensionAPI) {
|
||||||
exitCode: -1,
|
exitCode: -1,
|
||||||
messages: [],
|
messages: [],
|
||||||
stderr: "",
|
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) {
|
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,
|
ctx.cwd,
|
||||||
agents,
|
agents,
|
||||||
params.agent,
|
params.agent,
|
||||||
|
|
|
||||||
130
.pi/agent/extensions/subagent/interactive-tools.ts
Normal file
130
.pi/agent/extensions/subagent/interactive-tools.ts
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
40
.pi/agent/extensions/subagent/jsonl.ts
Normal file
40
.pi/agent/extensions/subagent/jsonl.ts
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
336
.pi/agent/extensions/subagent/rpc-runner.ts
Normal file
336
.pi/agent/extensions/subagent/rpc-runner.ts
Normal 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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
90
.pi/agent/extensions/subagent/subagent-types.ts
Normal file
90
.pi/agent/extensions/subagent/subagent-types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"lastChangelogVersion": "0.57.1",
|
"lastChangelogVersion": "0.57.1",
|
||||||
"defaultProvider": "anthropic",
|
"defaultProvider": "openai-codex",
|
||||||
"defaultModel": "claude-opus-4-6",
|
"defaultModel": "gpt-5.4",
|
||||||
"defaultThinkingLevel": "high",
|
"defaultThinkingLevel": "high",
|
||||||
"theme": "catppuccin-mocha"
|
"theme": "catppuccin-mocha"
|
||||||
}
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue