From 5e01e2e44f1643568a2acb0a4ef8239602b1d873 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Thu, 12 Mar 2026 16:54:57 +0700 Subject: [PATCH] dotfiles: add interactive pi planner workflow --- .codex/config.toml | 3 + .config/fish/config.fish | 6 + .config/fish/fish_variables | 1 + .pi/agent/agents/executor.md | 51 ++ .pi/agent/agents/planner.md | 33 + .pi/agent/extensions/lsp-feedback/index.ts | 606 ++++++++++++++++++ .../extensions/lsp-feedback/package.json | 20 + .pi/agent/extensions/subagent/README.md | 20 + .pi/agent/extensions/subagent/agents.ts | 72 ++- .pi/agent/extensions/subagent/index.ts | 168 +++-- .../extensions/subagent/interactive-tools.ts | 130 ++++ .pi/agent/extensions/subagent/jsonl.ts | 40 ++ .pi/agent/extensions/subagent/rpc-runner.ts | 336 ++++++++++ .../extensions/subagent/subagent-types.ts | 90 +++ .pi/agent/settings.json | 4 +- 15 files changed, 1472 insertions(+), 108 deletions(-) create mode 100644 .pi/agent/agents/executor.md create mode 100644 .pi/agent/agents/planner.md create mode 100644 .pi/agent/extensions/lsp-feedback/index.ts create mode 100644 .pi/agent/extensions/lsp-feedback/package.json create mode 100644 .pi/agent/extensions/subagent/interactive-tools.ts create mode 100644 .pi/agent/extensions/subagent/jsonl.ts create mode 100644 .pi/agent/extensions/subagent/rpc-runner.ts create mode 100644 .pi/agent/extensions/subagent/subagent-types.ts diff --git a/.codex/config.toml b/.codex/config.toml index 3726270..851cec1 100644 --- a/.codex/config.toml +++ b/.codex/config.toml @@ -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" diff --git a/.config/fish/config.fish b/.config/fish/config.fish index 835c13b..f06cf88 100644 --- a/.config/fish/config.fish +++ b/.config/fish/config.fish @@ -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 diff --git a/.config/fish/fish_variables b/.config/fish/fish_variables index e562d9d..a3dc2e9 100644 --- a/.config/fish/fish_variables +++ b/.config/fish/fish_variables @@ -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 diff --git a/.pi/agent/agents/executor.md b/.pi/agent/agents/executor.md new file mode 100644 index 0000000..a1e807d --- /dev/null +++ b/.pi/agent/agents/executor.md @@ -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 diff --git a/.pi/agent/agents/planner.md b/.pi/agent/agents/planner.md new file mode 100644 index 0000000..b68856a --- /dev/null +++ b/.pi/agent/agents/planner.md @@ -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 diff --git a/.pi/agent/extensions/lsp-feedback/index.ts b/.pi/agent/extensions/lsp-feedback/index.ts new file mode 100644 index 0000000..9f1f011 --- /dev/null +++ b/.pi/agent/extensions/lsp-feedback/index.ts @@ -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; + private initializePromise?: Promise; + private openDocuments = new Set(); + private documentVersions = new Map(); + private diagnostics = new Map(); + private waiters = new Map void>>(); + + constructor(private readonly rootDir: string, private readonly server: ServerConfig) {} + + private async start(): Promise { + 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 { + 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 { + 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 { + 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(); + + 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); + }, + }); +} diff --git a/.pi/agent/extensions/lsp-feedback/package.json b/.pi/agent/extensions/lsp-feedback/package.json new file mode 100644 index 0000000..74c91be --- /dev/null +++ b/.pi/agent/extensions/lsp-feedback/package.json @@ -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" + } +} diff --git a/.pi/agent/extensions/subagent/README.md b/.pi/agent/extensions/subagent/README.md index deb40cc..b25c791 100644 --- a/.pi/agent/extensions/subagent/README.md +++ b/.pi/agent/extensions/subagent/README.md @@ -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) diff --git a/.pi/agent/extensions/subagent/agents.ts b/.pi/agent/extensions/subagent/agents.ts index 2ed666e..12125c3 100644 --- a/.pi/agent/extensions/subagent/agents.ts +++ b/.pi/agent/extensions/subagent/agents.ts @@ -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>(content); - if (!frontmatter.name || !frontmatter.description) continue; - - const tools = frontmatter.tools - ?.split(",") - .map((t: string) => t.trim()) - .filter(Boolean); + const { frontmatter, body } = parseFrontmatter>(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, }; } diff --git a/.pi/agent/extensions/subagent/index.ts b/.pi/agent/extensions/subagent/index.ts index 76f937e..682b73a 100644 --- a/.pi/agent/extensions/subagent/index.ts +++ b/.pi/agent/extensions/subagent/index.ts @@ -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 }; - 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( 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(); 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,17 +537,31 @@ export default function (pi: ExtensionAPI) { } if (params.agent && params.task) { - const result = await runSingleAgent( - ctx.cwd, - agents, - params.agent, - params.task, - params.cwd, - undefined, - signal, - onUpdate as OnUpdateCallback | undefined, - makeDetails("single"), - ); + const selectedAgent = findAgentConfig(agents, params.agent); + const result = selectedAgent?.interactive + ? await runInteractiveAgentRpc( + ctx.cwd, + agents, + params.agent, + params.task, + params.cwd, + undefined, + signal, + onUpdate as OnUpdateCallback | undefined, + makeDetails("single"), + ctx, + ) + : await runSingleAgent( + ctx.cwd, + agents, + params.agent, + params.task, + params.cwd, + undefined, + signal, + onUpdate as OnUpdateCallback | undefined, + makeDetails("single"), + ); const isError = result.exitCode !== 0 || result.stopReason === "error" || result.stopReason === "aborted"; if (isError) { const errorMsg = result.errorMessage || result.stderr || getFinalOutput(result.messages) || "(no output)"; diff --git a/.pi/agent/extensions/subagent/interactive-tools.ts b/.pi/agent/extensions/subagent/interactive-tools.ts new file mode 100644 index 0000000..ce70aef --- /dev/null +++ b/.pi/agent/extensions/subagent/interactive-tools.ts @@ -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); + } + }); +} diff --git a/.pi/agent/extensions/subagent/jsonl.ts b/.pi/agent/extensions/subagent/jsonl.ts new file mode 100644 index 0000000..f3a0a7b --- /dev/null +++ b/.pi/agent/extensions/subagent/jsonl.ts @@ -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); + }; +} diff --git a/.pi/agent/extensions/subagent/rpc-runner.ts b/.pi/agent/extensions/subagent/rpc-runner.ts new file mode 100644 index 0000000..fdc0d79 --- /dev/null +++ b/.pi/agent/extensions/subagent/rpc-runner.ts @@ -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): 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 { + 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 { + 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(); + let requestCount = 0; + let agentEnded = false; + let resolveAgentEnd: (() => void) | undefined; + const agentEndPromise = new Promise((resolve) => { + resolveAgentEnd = resolve; + }); + const processExitPromise = new Promise((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): Promise => { + const id = `subagent_${++requestCount}`; + return new Promise((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 {} + } + } +} diff --git a/.pi/agent/extensions/subagent/subagent-types.ts b/.pi/agent/extensions/subagent/subagent-types.ts new file mode 100644 index 0000000..405f5cd --- /dev/null +++ b/.pi/agent/extensions/subagent/subagent-types.ts @@ -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 }; + +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; +} diff --git a/.pi/agent/settings.json b/.pi/agent/settings.json index 6493eda..e1683b0 100644 --- a/.pi/agent/settings.json +++ b/.pi/agent/settings.json @@ -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" } \ No newline at end of file