From a32d6fb7b57e077d65d05073afe1c9fd6a17f5e9 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 07:47:37 -0600 Subject: [PATCH 01/60] fix: handle Windows backspace in masked input + support custom browser path (#36, #34) - wizard.ts: also check for \b (0x08) which Windows terminals send for backspace - browser-tools: read BROWSER_PATH env var and pass as executablePath to Playwright Co-Authored-By: Claude Opus 4.6 --- .../extensions/browser-tools/index.ts | 5 ++++- src/wizard.ts | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/resources/extensions/browser-tools/index.ts b/src/resources/extensions/browser-tools/index.ts index c545360db..59f407f0d 100644 --- a/src/resources/extensions/browser-tools/index.ts +++ b/src/resources/extensions/browser-tools/index.ts @@ -343,7 +343,10 @@ async function ensureBrowser(): Promise<{ browser: Browser; context: BrowserCont // Lazy import so playwright is only loaded when actually needed const { chromium } = await import("playwright"); - browser = await chromium.launch({ headless: false }); + const launchOptions: Record = { headless: false }; + const customPath = process.env.BROWSER_PATH; + if (customPath) launchOptions.executablePath = customPath; + browser = await chromium.launch(launchOptions); context = await browser.newContext({ deviceScaleFactor: 2, viewport: { width: 1280, height: 800 }, diff --git a/src/wizard.ts b/src/wizard.ts index a41fe6e57..3706f5cae 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -26,6 +26,17 @@ async function promptMasked(label: string, hint: string): Promise { process.stdin.resume() process.stdin.setEncoding('utf8') let value = '' + const redraw = () => { + process.stdout.clearLine(0) + process.stdout.cursorTo(0) + if (value.length === 0) { + process.stdout.write(' ') + } else { + const dots = '●'.repeat(Math.min(value.length, 24)) + const counter = value.length > 24 ? ` ${dim}(${value.length})${reset}` : ` ${dim}${value.length}${reset}` + process.stdout.write(` ${dots}${counter}`) + } + } const handler = (ch: string) => { if (ch === '\r' || ch === '\n') { process.stdin.setRawMode(false) @@ -37,16 +48,14 @@ async function promptMasked(label: string, hint: string): Promise { process.stdin.setRawMode(false) process.stdout.write('\n') process.exit(0) - } else if (ch === '\u007f') { + } else if (ch === '\u007f' || ch === '\b') { if (value.length > 0) { value = value.slice(0, -1) } - process.stdout.clearLine(0) - process.stdout.cursorTo(0) - process.stdout.write(' ' + '*'.repeat(value.length)) + redraw() } else { value += ch - process.stdout.write('*') + redraw() } } process.stdin.on('data', handler) From 1a052663269ad6fa12c6b664292b26f5c674792e Mon Sep 17 00:00:00 2001 From: amanape <83104063+amanape@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:29:37 +0400 Subject: [PATCH 02/60] Rename 'Get Stuff Done' to 'Get Shit Done' --- package.json | 2 +- src/resources/extensions/gsd/commands.ts | 2 +- src/resources/extensions/gsd/guided-flow.ts | 2 +- src/resources/extensions/gsd/prompts/system.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index f70129510..597d895ba 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gsd-pi", "version": "0.3.0", - "description": "GSD — Get Stuff Done coding agent", + "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { "type": "git", diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index f2d9f953f..65ac405a2 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -52,7 +52,7 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Stuff Done: /gsd auto|stop|status|queue|prefs|doctor|migrate", + description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate", getArgumentCompletions: (prefix: string) => { const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"]; diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 30bf061aa..6fc60f9d1 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -508,7 +508,7 @@ export async function showSmartEntry( )); } else { const choice = await showNextAction(ctx as any, { - title: "GSD — Get Stuff Done", + title: "GSD — Get Shit Done", summary: ["No active milestone."], actions: [ { diff --git a/src/resources/extensions/gsd/prompts/system.md b/src/resources/extensions/gsd/prompts/system.md index 1590e5b78..9d50470d4 100644 --- a/src/resources/extensions/gsd/prompts/system.md +++ b/src/resources/extensions/gsd/prompts/system.md @@ -1,4 +1,4 @@ -## GSD — Get Stuff Done +## GSD — Get Shit Done You are **GSD** — a coding agent that gets shit done. From c5c2ec4949be0fe093a4a29946d98bacb5906456 Mon Sep 17 00:00:00 2001 From: amanape <83104063+amanape@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:33:04 +0400 Subject: [PATCH 03/60] Replace remaining 'get stuff done' instances in verify script --- scripts/verify-s04.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/verify-s04.sh b/scripts/verify-s04.sh index 9d4c847cb..090b40e24 100755 --- a/scripts/verify-s04.sh +++ b/scripts/verify-s04.sh @@ -162,7 +162,7 @@ wait "$smoke_pid" 2>/dev/null || true ext_errors=$(grep "Extension load error" "$smoke_out" 2>/dev/null | wc -l | tr -d ' ') # Strip ANSI escape codes for branding check plain_out=$(sed 's/\x1b\[[0-9;]*m//g' "$smoke_out" 2>/dev/null || cat "$smoke_out") -has_gsd=$(echo "$plain_out" | grep -qi "gsd\|get stuff done" && echo "yes" || echo "no") +has_gsd=$(echo "$plain_out" | grep -qi "gsd\|get shit done" && echo "yes" || echo "no") if [ "$ext_errors" -eq 0 ]; then pass "8a — zero Extension load errors on launch" @@ -172,7 +172,7 @@ else fi if [ "$has_gsd" = "yes" ]; then - pass "8b — \"gsd\" / \"get stuff done\" branding found in launch output" + pass "8b — \"gsd\" / \"get shit done\" branding found in launch output" else # Fallback: check if binary self-identifies differently (not "pi") has_pi_only=$(echo "$plain_out" | grep -qi "^pi\b" && echo "yes" || echo "no") From 8e1ddd51f2198698185e4268896cb02f27f2e280 Mon Sep 17 00:00:00 2001 From: Marcel Reschke Date: Wed, 11 Mar 2026 15:11:54 +0100 Subject: [PATCH 04/60] fix: /gsd-run uses hardcoded ~/.pi/ path instead of GSD_WORKFLOW_PATH (#38) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: /gsd migrate — .planning to .gsd migration tool Add `/gsd migrate [path]` command that reads old get-shit-done .planning directories and writes complete .gsd directory trees for GSD-2. Pipeline: validate → parse → transform → preview → confirm → write → review Parser (S01): - 7 per-file parsers: roadmap, plan, summary, requirements, project, state, config - Handles flat, milestone-sectioned, and
-block roadmap formats - Bold phase entries, "Phase N:" format, decimal numbering, duplicate phase numbers - Bullet-format requirements (- [x] **ID**: Description) - Graceful null returns for missing files, severity-classified validation Transformer (S02): - Phases → slices, plans → tasks, milestones → milestones - Float-sorted decimal phases renumbered sequentially (S01, S02, ...) - Completion state preserved (roadmap [x] → slice done, summary → task done) - Research consolidated with fixed file-type ordering - Requirements classified with complete/done/shipped → validated normalization - Vision derived from PROJECT.md with three-level fallback - Duplicate phase numbers disambiguated by title similarity Writer (S03): - Format functions for all GSD-2 file types with round-trip verification - writeGSDDirectory produces tree that deriveState() reads correctly - generatePreview computes milestone/slice/task counts + completion % - Null research and empty requirements silently skipped Command (S04): - Default to cwd when no args given; ~/path expansion - Validation gating (fatal issues block pipeline) - Preview with showNextAction confirmation - Post-write agent review via prompts/review-migration.md template - Wired into commands.ts with tab completion Also: - .gitignore: replace granular .gsd/* entries with .gsd/ catch-all - README: add /gsd migrate to commands table + "Migrating from v1" section - files.ts: widen parseRequirementCounts regex for non-R prefixed IDs 478 assertions across 6 test suites, all passing. UAT against blade/bladeai (28 phases, 8 milestones) and aire (10 phases, 2 milestones). * fix: persist skipped API keys so wizard doesn't repeat on every launch When users skip optional API keys (Brave, Context7, Jina) by pressing Enter, the wizard stores nothing. On next launch, authStorage.has() returns false for those providers, so the wizard prompts again. Fix: store an empty-key sentinel for skipped providers. Also guard loadStoredEnvKeys against injecting empty strings into process.env. * fix: respect GSD_WORKFLOW_PATH in /gsd-run command --------- Co-authored-by: Jonathan Costin Co-authored-by: vp275 --- src/resources/extensions/slash-commands/gsd-run.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/extensions/slash-commands/gsd-run.ts b/src/resources/extensions/slash-commands/gsd-run.ts index 6aa74d2d4..21d26fa28 100644 --- a/src/resources/extensions/slash-commands/gsd-run.ts +++ b/src/resources/extensions/slash-commands/gsd-run.ts @@ -6,7 +6,7 @@ export default function gsdRun(pi: ExtensionAPI) { pi.registerCommand("gsd-run", { description: "Read GSD-WORKFLOW.md and execute — lightweight protocol-driven GSD", async handler(args: string, ctx: ExtensionCommandContext) { - const workflowPath = join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); + const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); let workflow: string; try { From 18d14185977e7b651fad2726d4a8a4455d423795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 11:56:48 -0300 Subject: [PATCH 05/60] fix: bash/bg_shell hang and kill issues on Windows (#40) - bg_shell killProcess: add Windows-specific taskkill /F /T /PID path with proper error handling (spawnSync with timeout, not stdio: "ignore") - bg_shell startProcess: use getShellConfig() instead of hardcoded "bash", disable detached mode on Windows (process groups don't apply) - GSD bash tool: wrap execute to inject 120s default timeout when the LLM omits the timeout parameter, preventing indefinite hangs Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/bg-shell/index.ts | 36 +++++++++++++++++----- src/resources/extensions/gsd/index.ts | 26 ++++++++++++++-- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/resources/extensions/bg-shell/index.ts b/src/resources/extensions/bg-shell/index.ts index a83292951..83d227dcb 100644 --- a/src/resources/extensions/bg-shell/index.ts +++ b/src/resources/extensions/bg-shell/index.ts @@ -33,6 +33,7 @@ import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, + getShellConfig, } from "@mariozechner/pi-coding-agent"; import { Text, @@ -42,7 +43,7 @@ import { Key, } from "@mariozechner/pi-tui"; import { Type } from "@sinclair/typebox"; -import { spawn, type ChildProcess } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createConnection } from "node:net"; import { randomUUID } from "node:crypto"; import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; @@ -551,11 +552,12 @@ function startProcess(opts: StartOptions): BgProcess { const env = { ...process.env, ...(opts.env || {}) }; - const proc = spawn("bash", ["-c", opts.command], { + const { shell, args: shellArgs } = getShellConfig(); + const proc = spawn(shell, [...shellArgs, opts.command], { cwd: opts.cwd, stdio: ["pipe", "pipe", "pipe"], env, - detached: true, + detached: process.platform !== "win32", }); const bg: BgProcess = { @@ -686,14 +688,32 @@ function killProcess(id: string, sig: NodeJS.Signals = "SIGTERM"): boolean { if (!bg) return false; if (!bg.alive) return true; try { - if (bg.proc.pid) { - try { - process.kill(-bg.proc.pid, sig); - } catch { + if (process.platform === "win32") { + // Windows: use taskkill /F /T to force-kill the entire process tree. + // process.kill(-pid) (Unix process groups) does not work on Windows. + if (bg.proc.pid) { + const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(bg.proc.pid)], { + timeout: 5000, + encoding: "utf-8", + }); + if (result.status !== 0 && result.status !== 128) { + // taskkill failed — try the direct kill as fallback + bg.proc.kill(sig); + } + } else { bg.proc.kill(sig); } } else { - bg.proc.kill(sig); + // Unix/macOS: kill the process group via negative PID + if (bg.proc.pid) { + try { + process.kill(-bg.proc.pid, sig); + } catch { + bg.proc.kill(sig); + } + } else { + bg.proc.kill(sig); + } } return true; } catch { diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 018843df1..caa3ed9bd 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -63,13 +63,35 @@ export default function (pi: ExtensionAPI) { registerGSDCommand(pi); registerWorktreeCommand(pi); - // ── Dynamic-cwd bash tool ────────────────────────────────────────────── + // ── Dynamic-cwd bash tool with default timeout ──────────────────────── // The built-in bash tool captures cwd at startup. This replacement uses // a spawnHook to read process.cwd() dynamically so that process.chdir() // (used by /worktree switch) propagates to shell commands. - const dynamicBash = createBashTool(process.cwd(), { + // + // The upstream SDK's bash tool has no default timeout — if the LLM omits + // the timeout parameter, commands run indefinitely, causing hangs on + // Windows where process killing is unreliable (see #40). We wrap execute + // to inject a 120-second default when no timeout is provided. + const DEFAULT_BASH_TIMEOUT_SECS = 120; + const baseBash = createBashTool(process.cwd(), { spawnHook: (ctx) => ({ ...ctx, cwd: process.cwd() }), }); + const dynamicBash = { + ...baseBash, + execute: async ( + toolCallId: string, + params: { command: string; timeout?: number }, + signal?: AbortSignal, + onUpdate?: any, + ctx?: any, + ) => { + const paramsWithTimeout = { + ...params, + timeout: params.timeout ?? DEFAULT_BASH_TIMEOUT_SECS, + }; + return baseBash.execute(toolCallId, paramsWithTimeout, signal, onUpdate, ctx); + }, + }; pi.registerTool(dynamicBash as any); // ── session_start: render branded GSD header ─────────────────────────── From 45fdf5d54db152f0caa9d0da1c3b1b02e8971efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 11:28:23 -0300 Subject: [PATCH 06/60] feat: remote user questions via Slack/Discord for headless auto-mode When ask_user_questions is called in non-interactive mode (ctx.hasUI = false), transparently route questions to a configured Slack or Discord channel and poll for the user's response. Same tool interface, automatic routing. - Add adapter pattern for Slack (Bot Token API) and Discord (HTTP API) - Add /gsd remote command for interactive setup wizard - Add SLACK_BOT_TOKEN / DISCORD_BOT_TOKEN to wizard and env hydration - Add remote_questions config to GSD preferences with merge support - Fix parseScalar to preserve large numeric IDs (Discord channel IDs) - Show remote channel status on session_start Co-Authored-By: Claude Opus 4.6 --- .../extensions/ask-user-questions.ts | 43 +- src/resources/extensions/gsd/commands.ts | 19 +- src/resources/extensions/gsd/index.ts | 13 +- src/resources/extensions/gsd/preferences.ts | 18 +- .../extensions/remote-questions/channels.ts | 36 ++ .../extensions/remote-questions/config.ts | 78 +++ .../remote-questions/discord-adapter.ts | 188 +++++++ .../extensions/remote-questions/format.ts | 216 ++++++++ .../extensions/remote-questions/index.ts | 213 ++++++++ .../remote-questions/remote-command.ts | 461 ++++++++++++++++++ .../remote-questions/slack-adapter.ts | 176 +++++++ src/wizard.ts | 16 + 12 files changed, 1469 insertions(+), 8 deletions(-) create mode 100644 src/resources/extensions/remote-questions/channels.ts create mode 100644 src/resources/extensions/remote-questions/config.ts create mode 100644 src/resources/extensions/remote-questions/discord-adapter.ts create mode 100644 src/resources/extensions/remote-questions/format.ts create mode 100644 src/resources/extensions/remote-questions/index.ts create mode 100644 src/resources/extensions/remote-questions/remote-command.ts create mode 100644 src/resources/extensions/remote-questions/slack-adapter.ts diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 4446e676c..23f97decb 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -104,7 +104,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { ], parameters: AskUserQuestionsParams, - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + async execute(_toolCallId, params, signal, _onUpdate, ctx) { // Validation if (params.questions.length === 0 || params.questions.length > 3) { return errorResult("Error: questions must contain 1-3 items", params.questions); @@ -120,6 +120,9 @@ export default function AskUserQuestions(pi: ExtensionAPI) { } if (!ctx.hasUI) { + const { tryRemoteQuestions } = await import("./remote-questions/index.js"); + const remoteResult = await tryRemoteQuestions(params.questions, signal); + if (remoteResult) return remoteResult; return errorResult("Error: UI not available (non-interactive mode)", params.questions); } @@ -165,19 +168,53 @@ export default function AskUserQuestions(pi: ExtensionAPI) { }, renderResult(result, _options, theme) { - const details = result.details as AskUserQuestionsDetails | undefined; + const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string }) | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } + // Remote channel result + if (details.remote) { + if (details.timed_out) { + const channelLabel = details.channel ?? "remote"; + return new Text( + `${theme.fg("warning", `${channelLabel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`, + 0, + 0, + ); + } + + const remoteResponse = details.response as import("./remote-questions/channels.js").RemoteAnswer | undefined; + const questions = (details.questions ?? []) as Question[]; + const lines: string[] = []; + const channelLabel = details.channel ?? "remote"; + lines.push(theme.fg("dim", channelLabel)); + if (remoteResponse) { + for (const q of questions) { + const answer = remoteResponse.answers[q.id]; + if (!answer) { + lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`); + continue; + } + const answerText = answer.answers.length > 0 ? answer.answers.join(", ") : "(custom)"; + let line = `${theme.fg("success", "✓ ")}${theme.fg("accent", q.header)}: ${answerText}`; + if (answer.user_note) { + line += ` ${theme.fg("muted", `[note: ${answer.user_note}]`)}`; + } + lines.push(line); + } + } + return new Text(lines.join("\n"), 0, 0); + } + if (details.cancelled || !details.response) { return new Text(theme.fg("warning", "Cancelled"), 0, 0); } const lines: string[] = []; for (const q of details.questions) { - const answer = details.response.answers[q.id]; + const answer = (details.response as RoundResult).answers[q.id]; if (!answer) { lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`); continue; diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 65ac405a2..d3f8f30d3 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -31,6 +31,7 @@ import { } from "./doctor.js"; import { loadPrompt } from "./prompt-loader.js"; import { handleMigrate } from "./migrate/command.js"; +import { handleRemote } from "../remote-questions/remote-command.js"; function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportText: string, structuredIssues: string): void { const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); @@ -52,10 +53,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate", + description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate|remote", getArgumentCompletions: (prefix: string) => { - const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"]; + const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate", "remote"]; const parts = prefix.trim().split(/\s+/); if (parts.length <= 1) { @@ -78,6 +79,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void { .map((cmd) => ({ value: `prefs ${cmd}`, label: cmd })); } + if (parts[0] === "remote" && parts.length <= 2) { + const subPrefix = parts[1] ?? ""; + return ["slack", "discord", "status", "disconnect"] + .filter((cmd) => cmd.startsWith(subPrefix)) + .map((cmd) => ({ value: `remote ${cmd}`, label: cmd })); + } + if (parts[0] === "doctor") { const modePrefix = parts[1] ?? ""; const modes = ["fix", "heal", "audit"]; @@ -142,13 +150,18 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed === "remote" || trimmed.startsWith("remote ")) { + await handleRemote(trimmed.replace(/^remote\s*/, "").trim(), ctx, pi); + return; + } + if (trimmed === "") { await showSmartEntry(ctx, pi, process.cwd()); return; } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate .`, + `Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], /gsd migrate , or /gsd remote [slack|discord|status|disconnect].`, "warning", ); }, diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 018843df1..9d6376b5f 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -72,7 +72,7 @@ export default function (pi: ExtensionAPI) { }); pi.registerTool(dynamicBash as any); - // ── session_start: render branded GSD header ─────────────────────────── + // ── session_start: render branded GSD header + remote channel status ── pi.on("session_start", async (_event, ctx) => { const theme = ctx.ui.theme; const version = process.env.GSD_VERSION || "0.0.0"; @@ -82,6 +82,17 @@ export default function (pi: ExtensionAPI) { const headerContent = `${logoText}\n${titleLine}`; ctx.ui.setHeader((_ui, _theme) => new Text(headerContent, 1, 0)); + + // Notify remote questions status if configured + try { + const { getRemoteConfigStatus } = await import("../remote-questions/config.js"); + const status = getRemoteConfigStatus(); + if (!status.includes("not configured")) { + ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info"); + } + } catch { + // Remote questions module not available — ignore + } }); // ── Ctrl+Alt+G shortcut — GSD dashboard overlay ──────────────────────── diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index 222fa3d03..a84fbceb7 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -31,6 +31,13 @@ export interface AutoSupervisorConfig { hard_timeout_minutes?: number; } +export interface RemoteQuestionsConfig { + channel: "slack" | "discord"; + channel_id: string; + timeout_minutes?: number; // Default: 5 + poll_interval_seconds?: number; // Default: 5 +} + export interface GSDPreferences { version?: number; always_use_skills?: string[]; @@ -43,6 +50,7 @@ export interface GSDPreferences { auto_supervisor?: AutoSupervisorConfig; uat_dispatch?: boolean; budget_ceiling?: number; + remote_questions?: RemoteQuestionsConfig; } export interface LoadedGSDPreferences { @@ -430,7 +438,12 @@ function parseFrontmatterBlock(frontmatter: string): GSDPreferences { function parseScalar(value: string): string | number | boolean { if (value === "true") return true; if (value === "false") return false; - if (/^-?\d+$/.test(value)) return Number(value); + if (/^-?\d+$/.test(value)) { + const n = Number(value); + // Keep large integers (e.g. Discord channel IDs) as strings to avoid precision loss + if (Number.isSafeInteger(n)) return n; + return value; + } return value.replace(/^['\"]|['\"]$/g, ""); } @@ -495,6 +508,9 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr auto_supervisor: { ...(base.auto_supervisor ?? {}), ...(override.auto_supervisor ?? {}) }, uat_dispatch: override.uat_dispatch ?? base.uat_dispatch, budget_ceiling: override.budget_ceiling ?? base.budget_ceiling, + remote_questions: override.remote_questions + ? { ...(base.remote_questions ?? {}), ...override.remote_questions } + : base.remote_questions, }; } diff --git a/src/resources/extensions/remote-questions/channels.ts b/src/resources/extensions/remote-questions/channels.ts new file mode 100644 index 000000000..7360c00a3 --- /dev/null +++ b/src/resources/extensions/remote-questions/channels.ts @@ -0,0 +1,36 @@ +/** + * Remote Questions — Adapter pattern interfaces + * + * Defines the contract for Slack/Discord (or any future) channel adapters. + */ + +export interface ChannelAdapter { + readonly name: string; + sendQuestions(questions: FormattedQuestion[]): Promise; + pollResponse(ref: PollReference): Promise; + validate(): Promise; +} + +export interface FormattedQuestion { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + allowMultiple: boolean; +} + +export interface SendResult { + ref: PollReference; + threadUrl?: string; +} + +export interface PollReference { + channelType: "slack" | "discord"; + messageId: string; + threadTs?: string; + channelId: string; +} + +export interface RemoteAnswer { + answers: Record; +} diff --git a/src/resources/extensions/remote-questions/config.ts b/src/resources/extensions/remote-questions/config.ts new file mode 100644 index 000000000..9c92f0fba --- /dev/null +++ b/src/resources/extensions/remote-questions/config.ts @@ -0,0 +1,78 @@ +/** + * Remote Questions — Configuration resolution + * + * Reads remote_questions config from GSD preferences and verifies + * the corresponding token exists in process.env. + */ + +import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js"; + +export interface ResolvedConfig { + channel: "slack" | "discord"; + channelId: string; + timeoutMs: number; + pollIntervalMs: number; + token: string; +} + +const ENV_KEYS: Record = { + slack: "SLACK_BOT_TOKEN", + discord: "DISCORD_BOT_TOKEN", +}; + +const DEFAULT_TIMEOUT_MINUTES = 5; +const DEFAULT_POLL_INTERVAL_SECONDS = 5; + +/** + * Resolve remote questions configuration from preferences + env. + * Returns null if not configured or token is missing. + */ +export function resolveRemoteConfig(): ResolvedConfig | null { + const prefs = loadEffectiveGSDPreferences(); + const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; + if (!rq || !rq.channel || !rq.channel_id) return null; + + const envVar = ENV_KEYS[rq.channel]; + if (!envVar) return null; + + const token = process.env[envVar]; + if (!token) return null; + + const timeoutMinutes = rq.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES; + const pollIntervalSeconds = rq.poll_interval_seconds ?? DEFAULT_POLL_INTERVAL_SECONDS; + + // Always coerce channel_id to string — parseScalar may convert large numeric + // Discord IDs to a lossy Number (exceeds Number.MAX_SAFE_INTEGER). + const channelId = String(rq.channel_id); + + return { + channel: rq.channel, + channelId, + timeoutMs: timeoutMinutes * 60 * 1000, + pollIntervalMs: pollIntervalSeconds * 1000, + token, + }; +} + +/** + * Return a human-readable status string for the remote questions config. + * Used by session_start notification and /gsd remote status. + */ +export function getRemoteConfigStatus(): string { + const prefs = loadEffectiveGSDPreferences(); + const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; + + if (!rq || !rq.channel || !rq.channel_id) { + return "Remote questions: not configured"; + } + + const envVar = ENV_KEYS[rq.channel]; + if (!envVar) return `Remote questions: unknown channel type "${rq.channel}"`; + + const token = process.env[envVar]; + if (!token) { + return `Remote questions: ${envVar} not set — remote questions disabled`; + } + + return `Remote questions: ${rq.channel} (channel ${rq.channel_id}) configured`; +} diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts new file mode 100644 index 000000000..df54ef6bd --- /dev/null +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -0,0 +1,188 @@ +/** + * Remote Questions — Discord adapter + * + * Uses Discord Bot HTTP API (no gateway/websocket): + * - Send: POST /channels/{id}/messages with embed + * - Poll: GET reactions + GET messages after the sent message + */ + +import type { + ChannelAdapter, + FormattedQuestion, + PollReference, + RemoteAnswer, + SendResult, +} from "./channels.js"; +import { formatForDiscord, parseDiscordResponse } from "./format.js"; + +const DISCORD_API = "https://discord.com/api/v10"; + +export class DiscordAdapter implements ChannelAdapter { + readonly name = "discord"; + private botUserId: string | null = null; + + constructor( + private readonly token: string, + private readonly channelId: string, + ) {} + + async validate(): Promise { + const res = await this.discordApi("GET", "/users/@me"); + if (!res.id) { + throw new Error("Discord auth failed: invalid token"); + } + this.botUserId = res.id as string; + } + + async sendQuestions(questions: FormattedQuestion[]): Promise { + const { embeds, reactionEmojis } = formatForDiscord(questions); + + const res = await this.discordApi("POST", `/channels/${this.channelId}/messages`, { + content: "**GSD needs your input** — reply to this message or react with your choice", + embeds, + }); + + if (!res.id) { + throw new Error(`Discord send failed: ${JSON.stringify(res)}`); + } + + const messageId = res.id as string; + + // Add reaction emojis as templates (best-effort, don't block on failure) + for (const emoji of reactionEmojis) { + try { + await this.discordApi( + "PUT", + `/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`, + ); + } catch { + // Non-critical — continue + } + } + + return { + ref: { + channelType: "discord", + messageId, + channelId: this.channelId, + }, + }; + } + + async pollResponse(ref: PollReference): Promise { + return this.pollResponseWithQuestions(ref, []); + } + + /** + * Poll with full question context for proper parsing. + */ + async pollResponseWithQuestions( + ref: PollReference, + questions: FormattedQuestion[], + ): Promise { + if (!this.botUserId) { + const me = await this.discordApi("GET", "/users/@me"); + if (me.id) this.botUserId = me.id as string; + } + + // Strategy 1: Check reactions on the original message + const reactionAnswer = await this.checkReactions(ref, questions); + if (reactionAnswer) return reactionAnswer; + + // Strategy 2: Check for text replies after the message + const replyAnswer = await this.checkReplies(ref, questions); + if (replyAnswer) return replyAnswer; + + return null; + } + + private async checkReactions( + ref: PollReference, + questions: FormattedQuestion[], + ): Promise { + const numberEmojis = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; + const reactions: Array<{ emoji: string; count: number }> = []; + + for (const emoji of numberEmojis) { + try { + const users = await this.discordApi( + "GET", + `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`, + ); + + if (Array.isArray(users)) { + // Filter out bot's own reactions + const humanUsers = users.filter( + (u: { id: string }) => u.id !== this.botUserId, + ); + if (humanUsers.length > 0) { + reactions.push({ emoji, count: humanUsers.length }); + } + } + } catch { + // Reaction not present or no access + } + } + + if (reactions.length === 0) return null; + + return parseDiscordResponse(reactions, null, questions); + } + + private async checkReplies( + ref: PollReference, + questions: FormattedQuestion[], + ): Promise { + const messages = await this.discordApi( + "GET", + `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`, + ); + + if (!Array.isArray(messages)) return null; + + // Only accept replies that explicitly reference our message via Discord's reply feature + const replies = messages.filter( + (m: { author: { id: string }; message_reference?: { message_id: string }; content: string }) => + m.author.id !== this.botUserId && + m.message_reference?.message_id === ref.messageId, + ); + + if (replies.length === 0) return null; + + const firstReply = replies[0] as { content: string }; + return parseDiscordResponse([], firstReply.content, questions); + } + + // ─── Internal ────────────────────────────────────────────────────────────── + + private async discordApi( + method: string, + path: string, + body?: unknown, + ): Promise> { + const url = `${DISCORD_API}${path}`; + + const headers: Record = { + Authorization: `Bot ${this.token}`, + }; + + const init: RequestInit = { method, headers }; + + if (body) { + headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + + const response = await fetch(url, init); + + // For reaction PUT, 204 No Content is success + if (response.status === 204) return {}; + + if (!response.ok) { + const text = await response.text().catch(() => ""); + throw new Error(`Discord API HTTP ${response.status}: ${text}`); + } + + return (await response.json()) as Record; + } +} diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts new file mode 100644 index 000000000..348992c1d --- /dev/null +++ b/src/resources/extensions/remote-questions/format.ts @@ -0,0 +1,216 @@ +/** + * Remote Questions — Payload formatting for Slack and Discord + * + * Converts Question[] to channel-specific payloads and parses replies + * back into RemoteAnswer objects. + */ + +import type { FormattedQuestion, RemoteAnswer } from "./channels.js"; + +// ─── Slack Block Kit ───────────────────────────────────────────────────────── + +export interface SlackBlock { + type: string; + text?: { type: string; text: string }; + elements?: Array<{ type: string; text: string }>; +} + +/** + * Format questions as Slack Block Kit blocks for chat.postMessage. + */ +export function formatForSlack(questions: FormattedQuestion[]): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: "header", + text: { type: "plain_text", text: "GSD needs your input" }, + }, + ]; + + for (const q of questions) { + // Question header + text + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: `*${q.header}*\n${q.question}`, + }, + }); + + // Numbered options + const optionLines = q.options.map( + (opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`, + ); + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: optionLines.join("\n"), + }, + }); + + // Instructions + const instruction = q.allowMultiple + ? `Reply in this thread with numbers separated by comma (e.g. \`1,3\`) or type a custom answer.` + : `Reply in this thread with the number of your choice (e.g. \`1\`) or type a custom answer.`; + + blocks.push({ + type: "context", + elements: [{ type: "mrkdwn", text: instruction }], + }); + + blocks.push({ type: "divider" }); + } + + return blocks; +} + +// ─── Discord Embed ─────────────────────────────────────────────────────────── + +export interface DiscordEmbed { + title: string; + description: string; + color: number; + fields: Array<{ name: string; value: string; inline?: boolean }>; + footer?: { text: string }; +} + +const NUMBER_EMOJIS = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; + +/** + * Format questions as a Discord embed for channel message. + */ +export function formatForDiscord(questions: FormattedQuestion[]): { embeds: DiscordEmbed[]; reactionEmojis: string[] } { + const allEmojis: string[] = []; + const embeds: DiscordEmbed[] = []; + + for (const q of questions) { + const optionLines = q.options.map((opt, i) => { + const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`; + allEmojis.push(NUMBER_EMOJIS[i] ?? ""); + return `${emoji} **${opt.label}** — ${opt.description}`; + }); + + const instruction = q.allowMultiple + ? "React with numbers or reply with comma-separated choices (e.g. `1,3`)" + : "React with a number or reply with your choice"; + + embeds.push({ + title: `${q.header}`, + description: q.question, + color: 0x7c3aed, // Purple accent + fields: [ + { name: "Options", value: optionLines.join("\n") }, + ], + footer: { text: instruction }, + }); + } + + return { embeds, reactionEmojis: allEmojis.filter(Boolean) }; +} + +// ─── Reply Parsing ─────────────────────────────────────────────────────────── + +/** + * Parse a Slack thread reply into a RemoteAnswer. + * Supports: single number, comma-separated numbers, or free text. + */ +export function parseSlackReply(text: string, questions: FormattedQuestion[]): RemoteAnswer { + const answers: RemoteAnswer["answers"] = {}; + const trimmed = text.trim(); + + // For single-question scenarios, map the reply directly + if (questions.length === 1) { + const q = questions[0]; + answers[q.id] = parseAnswerForQuestion(trimmed, q); + return { answers }; + } + + // Multi-question: try to split by lines or semicolons + const parts = trimmed.includes(";") + ? trimmed.split(";").map((s) => s.trim()) + : trimmed.split("\n").map((s) => s.trim()).filter(Boolean); + + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const part = parts[i] ?? ""; + answers[q.id] = parseAnswerForQuestion(part, q); + } + + return { answers }; +} + +/** + * Parse a Discord reaction or reply into a RemoteAnswer. + */ +export function parseDiscordResponse( + reactions: Array<{ emoji: string; count: number }>, + replyText: string | null, + questions: FormattedQuestion[], +): RemoteAnswer { + // Prefer text reply if present + if (replyText) { + return parseSlackReply(replyText, questions); + } + + // Fall back to reactions + const answers: RemoteAnswer["answers"] = {}; + + if (questions.length === 1) { + const q = questions[0]; + const picked = reactions + .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0) + .map((r) => { + const idx = NUMBER_EMOJIS.indexOf(r.emoji); + return q.options[idx]?.label; + }) + .filter(Boolean) as string[]; + + if (picked.length > 0) { + answers[q.id] = { answers: picked }; + } else { + answers[q.id] = { answers: [], user_note: "No clear response via reactions" }; + } + return { answers }; + } + + // Multi-question with reactions: map first N emojis to first question + for (const q of questions) { + answers[q.id] = { answers: [], user_note: "Reaction-based multi-question not supported — use text reply" }; + } + + return { answers }; +} + +// ─── Internal helpers ──────────────────────────────────────────────────────── + +function parseAnswerForQuestion( + text: string, + q: FormattedQuestion, +): { answers: string[]; user_note?: string } { + if (!text) { + return { answers: [], user_note: "No response provided" }; + } + + // Check for comma-separated numbers: "1,3" or "1, 3" + const numberPattern = /^[\d,\s]+$/; + if (numberPattern.test(text)) { + const nums = text + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => !isNaN(n) && n >= 1 && n <= q.options.length); + + if (nums.length > 0) { + const selected = nums.map((n) => q.options[n - 1].label); + return { answers: q.allowMultiple ? selected : [selected[0]] }; + } + } + + // Single number + const singleNum = parseInt(text, 10); + if (!isNaN(singleNum) && singleNum >= 1 && singleNum <= q.options.length) { + return { answers: [q.options[singleNum - 1].label] }; + } + + // Free text response + return { answers: [], user_note: text }; +} diff --git a/src/resources/extensions/remote-questions/index.ts b/src/resources/extensions/remote-questions/index.ts new file mode 100644 index 000000000..90d6e293b --- /dev/null +++ b/src/resources/extensions/remote-questions/index.ts @@ -0,0 +1,213 @@ +/** + * Remote Questions — Entry point + * + * Transparent routing: when ctx.hasUI is false and a remote channel is + * configured, sends questions via Slack/Discord and polls for the response. + * + * The LLM keeps calling `ask_user_questions` as normal — this module + * intercepts the non-interactive branch. + */ + +import type { FormattedQuestion, ChannelAdapter, RemoteAnswer } from "./channels.js"; +import { resolveRemoteConfig, type ResolvedConfig } from "./config.js"; +import { SlackAdapter } from "./slack-adapter.js"; +import { DiscordAdapter } from "./discord-adapter.js"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +interface Question { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + allowMultiple?: boolean; +} + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; + details?: Record; +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +/** + * Try to send questions via a remote channel (Slack/Discord). + * Returns a formatted ToolResult if successful, or null if no remote + * channel is configured (caller falls back to the original error). + */ +export async function tryRemoteQuestions( + questions: Question[], + signal?: AbortSignal, +): Promise { + const config = resolveRemoteConfig(); + if (!config) return null; + + const adapter = createAdapter(config); + const formatted = questionsToFormatted(questions); + + try { + await adapter.validate(); + } catch (err) { + return errorToolResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`); + } + + let sendResult; + try { + sendResult = await adapter.sendQuestions(formatted); + } catch (err) { + return errorToolResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`); + } + + const threadInfo = sendResult.threadUrl + ? ` Thread: ${sendResult.threadUrl}` + : ""; + + // Poll for response + const answer = await pollWithTimeout(adapter, sendResult.ref, formatted, signal, config); + + if (!answer) { + // Timeout — return structured result so the LLM knows + return { + content: [ + { + type: "text", + text: JSON.stringify({ + timed_out: true, + channel: config.channel, + timeout_minutes: config.timeoutMs / 60000, + thread_url: sendResult.threadUrl ?? null, + message: `User did not respond within ${config.timeoutMs / 60000} minutes.${threadInfo}`, + }), + }, + ], + details: { + remote: true, + channel: config.channel, + timed_out: true, + threadUrl: sendResult.threadUrl, + }, + }; + } + + // Format the answer in the same structure as formatForLLM + const formattedAnswer = formatRemoteAnswerForLLM(answer); + + return { + content: [{ type: "text", text: formattedAnswer }], + details: { + remote: true, + channel: config.channel, + timed_out: false, + threadUrl: sendResult.threadUrl, + questions, + response: answer, + }, + }; +} + +// ─── Internal ──────────────────────────────────────────────────────────────── + +function createAdapter(config: ResolvedConfig): ChannelAdapter & { + pollResponseWithQuestions?: ( + ref: import("./channels.js").PollReference, + questions: FormattedQuestion[], + ) => Promise; +} { + switch (config.channel) { + case "slack": + return new SlackAdapter(config.token, config.channelId); + case "discord": + return new DiscordAdapter(config.token, config.channelId); + default: + throw new Error(`Unknown channel type: ${config.channel}`); + } +} + +async function pollWithTimeout( + adapter: ReturnType, + ref: import("./channels.js").PollReference, + questions: FormattedQuestion[], + signal: AbortSignal | undefined, + config: ResolvedConfig, +): Promise { + const deadline = Date.now() + config.timeoutMs; + let retries = 0; + const maxNetworkRetries = 1; + + while (Date.now() < deadline && !signal?.aborted) { + try { + // Use the question-aware poll if available + const answer = adapter.pollResponseWithQuestions + ? await adapter.pollResponseWithQuestions(ref, questions) + : await adapter.pollResponse(ref); + + if (answer) return answer; + retries = 0; // Reset on successful poll + } catch { + retries++; + if (retries > maxNetworkRetries) return null; + } + + await sleep(config.pollIntervalMs, signal); + } + + return null; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (signal) signal.removeEventListener("abort", onAbort); + resolve(); + }; + + const onAbort = () => settle(); + const timer = setTimeout(() => settle(), ms); + + if (signal) { + signal.addEventListener("abort", onAbort, { once: true }); + } + }); +} + +function questionsToFormatted(questions: Question[]): FormattedQuestion[] { + return questions.map((q) => ({ + id: q.id, + header: q.header, + question: q.question, + options: q.options, + allowMultiple: q.allowMultiple ?? false, + })); +} + +/** + * Format RemoteAnswer into the same JSON structure as the local formatForLLM. + * Structure: { answers: { [id]: { answers: string[] } } } + */ +function formatRemoteAnswerForLLM(answer: RemoteAnswer): string { + const formatted: Record = {}; + for (const [id, data] of Object.entries(answer.answers)) { + const list = [...data.answers]; + if (data.user_note) { + list.push(`user_note: ${data.user_note}`); + } + formatted[id] = { answers: list }; + } + return JSON.stringify({ answers: formatted }); +} + +function errorToolResult(message: string): ToolResult { + return { + content: [{ type: "text", text: message }], + details: { remote: true, error: true }, + }; +} diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts new file mode 100644 index 000000000..a43b7bfca --- /dev/null +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -0,0 +1,461 @@ +/** + * Remote Questions — /gsd remote command + * + * Interactive wizard for configuring Slack/Discord as a remote question channel. + * Follows the patterns from wizard.ts and gsd/commands.ts. + */ + +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { homedir } from "node:os"; +import { resolveRemoteConfig, getRemoteConfigStatus } from "./config.js"; +import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "../gsd/preferences.js"; + +// ─── Public ────────────────────────────────────────────────────────────────── + +export async function handleRemote( + subcommand: string, + ctx: ExtensionCommandContext, + _pi: ExtensionAPI, +): Promise { + const trimmed = subcommand.trim(); + + if (trimmed === "slack") { + await handleSetupSlack(ctx); + return; + } + + if (trimmed === "discord") { + await handleSetupDiscord(ctx); + return; + } + + if (trimmed === "status") { + await handleRemoteStatus(ctx); + return; + } + + if (trimmed === "disconnect") { + await handleDisconnect(ctx); + return; + } + + // Default: show current status and guide + await handleRemoteMenu(ctx); +} + +// ─── Setup Slack ───────────────────────────────────────────────────────────── + +async function handleSetupSlack(ctx: ExtensionCommandContext): Promise { + // Step 1: Collect token + const token = await promptMaskedInput(ctx, "Slack Bot Token", "Paste your xoxb-... token"); + if (!token) { + ctx.ui.notify("Slack setup cancelled.", "info"); + return; + } + + if (!token.startsWith("xoxb-")) { + ctx.ui.notify("Invalid token format — Slack bot tokens start with xoxb-. Setup cancelled.", "warning"); + return; + } + + // Step 2: Validate token + ctx.ui.notify("Validating token...", "info"); + let botInfo: { ok: boolean; user?: string; team?: string; user_id?: string }; + try { + const res = await fetch("https://slack.com/api/auth.test", { + method: "GET", + headers: { Authorization: `Bearer ${token}` }, + }); + botInfo = (await res.json()) as typeof botInfo; + } catch (err) { + ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error"); + return; + } + + if (!botInfo.ok) { + ctx.ui.notify("Token validation failed — check that the token is correct and the app is installed.", "error"); + return; + } + + ctx.ui.notify(`Token valid — bot: ${botInfo.user}, workspace: ${botInfo.team}`, "info"); + + // Step 3: Collect channel ID + const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)"); + if (!channelId) { + ctx.ui.notify("Slack setup cancelled.", "info"); + return; + } + + // Step 4: Send test message + ctx.ui.notify("Sending test message...", "info"); + try { + const res = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ + channel: channelId, + text: "GSD remote questions connected! This channel will receive questions during auto-mode.", + }), + }); + const result = (await res.json()) as { ok: boolean; error?: string }; + if (!result.ok) { + ctx.ui.notify(`Could not send to channel: ${result.error}. Make sure the bot is invited to the channel.`, "error"); + return; + } + } catch (err) { + ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error"); + return; + } + + // Step 5: Save configuration + saveTokenToAuth("slack_bot", token); + process.env.SLACK_BOT_TOKEN = token; + saveRemoteQuestionsConfig("slack", channelId); + + ctx.ui.notify(`Slack connected — questions will arrive in channel ${channelId} during /gsd auto`, "info"); +} + +// ─── Setup Discord ─────────────────────────────────────────────────────────── + +async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise { + // Step 1: Collect token + const token = await promptMaskedInput(ctx, "Discord Bot Token", "Paste your bot token"); + if (!token) { + ctx.ui.notify("Discord setup cancelled.", "info"); + return; + } + + // Step 2: Validate token + ctx.ui.notify("Validating token...", "info"); + let botInfo: { id?: string; username?: string }; + try { + const res = await fetch("https://discord.com/api/v10/users/@me", { + headers: { Authorization: `Bot ${token}` }, + }); + if (!res.ok) { + ctx.ui.notify(`Token validation failed (HTTP ${res.status}) — check that the token is correct.`, "error"); + return; + } + botInfo = (await res.json()) as typeof botInfo; + } catch (err) { + ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error"); + return; + } + + ctx.ui.notify(`Token valid — bot: ${botInfo.username}`, "info"); + + // Step 3: Collect channel ID + const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)"); + if (!channelId) { + ctx.ui.notify("Discord setup cancelled.", "info"); + return; + } + + // Step 4: Send test message + ctx.ui.notify("Sending test message...", "info"); + try { + const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: "POST", + headers: { + Authorization: `Bot ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: "GSD remote questions connected! This channel will receive questions during auto-mode.", + }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + ctx.ui.notify(`Could not send to channel (HTTP ${res.status}): ${body}. Make sure the bot has access.`, "error"); + return; + } + } catch (err) { + ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error"); + return; + } + + // Step 5: Save configuration + saveTokenToAuth("discord_bot", token); + process.env.DISCORD_BOT_TOKEN = token; + saveRemoteQuestionsConfig("discord", channelId); + + ctx.ui.notify(`Discord connected — questions will arrive in channel ${channelId} during /gsd auto`, "info"); +} + +// ─── Status ────────────────────────────────────────────────────────────────── + +async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise { + const config = resolveRemoteConfig(); + + if (!config) { + ctx.ui.notify(getRemoteConfigStatus(), "info"); + return; + } + + // Test the connection + ctx.ui.notify("Checking connection...", "info"); + + try { + if (config.channel === "slack") { + const res = await fetch("https://slack.com/api/auth.test", { + headers: { Authorization: `Bearer ${config.token}` }, + }); + const data = (await res.json()) as { ok: boolean; user?: string; team?: string }; + if (data.ok) { + ctx.ui.notify( + `Remote questions: Slack connected\n Bot: ${data.user}\n Workspace: ${data.team}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, + "info", + ); + } else { + ctx.ui.notify("Remote questions: Slack token invalid — run /gsd remote slack to reconfigure", "warning"); + } + } else if (config.channel === "discord") { + const res = await fetch("https://discord.com/api/v10/users/@me", { + headers: { Authorization: `Bot ${config.token}` }, + }); + if (res.ok) { + const data = (await res.json()) as { username?: string }; + ctx.ui.notify( + `Remote questions: Discord connected\n Bot: ${data.username}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, + "info", + ); + } else { + ctx.ui.notify("Remote questions: Discord token invalid — run /gsd remote discord to reconfigure", "warning"); + } + } + } catch (err) { + ctx.ui.notify(`Remote questions: connection check failed — ${(err as Error).message}`, "error"); + } +} + +// ─── Disconnect ────────────────────────────────────────────────────────────── + +async function handleDisconnect(ctx: ExtensionCommandContext): Promise { + const prefs = loadEffectiveGSDPreferences(); + if (!prefs?.preferences.remote_questions) { + ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info"); + return; + } + + const channel = prefs.preferences.remote_questions.channel; + + // Remove from preferences file + removeRemoteQuestionsConfig(); + + // Remove token from auth storage + const provider = channel === "slack" ? "slack_bot" : "discord_bot"; + removeTokenFromAuth(provider); + + // Clear env + if (channel === "slack") delete process.env.SLACK_BOT_TOKEN; + if (channel === "discord") delete process.env.DISCORD_BOT_TOKEN; + + ctx.ui.notify(`Remote questions disconnected (${channel}).`, "info"); +} + +// ─── Menu ──────────────────────────────────────────────────────────────────── + +async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise { + const config = resolveRemoteConfig(); + + if (config) { + ctx.ui.notify( + `Remote questions: ${config.channel} (channel ${config.channelId})\n` + + ` Timeout: ${config.timeoutMs / 60000}m, poll interval: ${config.pollIntervalMs / 1000}s\n\n` + + `Commands:\n` + + ` /gsd remote status — test connection\n` + + ` /gsd remote disconnect — remove configuration\n` + + ` /gsd remote slack — reconfigure with Slack\n` + + ` /gsd remote discord — reconfigure with Discord`, + "info", + ); + } else { + ctx.ui.notify( + `No remote question channel configured.\n\n` + + `Commands:\n` + + ` /gsd remote slack — set up Slack bot\n` + + ` /gsd remote discord — set up Discord bot\n` + + ` /gsd remote status — check configuration`, + "info", + ); + } +} + +// ─── Input helpers ─────────────────────────────────────────────────────────── + +async function promptMaskedInput( + ctx: ExtensionCommandContext, + label: string, + hint: string, +): Promise { + if (!ctx.hasUI) return null; + + return ctx.ui.custom((tui, theme, _kb, done) => { + let value = ""; + + function render(width: number): string[] { + const lines: string[] = []; + lines.push(theme.fg("accent", ` ${label}`)); + lines.push(theme.fg("dim", ` ${hint}`)); + lines.push(""); + lines.push(` ${theme.fg("text", "*".repeat(Math.min(value.length, width - 4)))}`); + lines.push(""); + lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel")); + return lines; + } + + function handleInput(data: string): void { + if (data === "\r" || data === "\n") { + done(value.trim() || null); + } else if (data === "\x1b" || data === "\x03") { + done(null); + } else if (data === "\x7f") { + value = value.slice(0, -1); + tui.invalidate(); + } else if (data.length === 1 && data >= " ") { + value += data; + tui.invalidate(); + } + } + + return { render, handleInput, invalidate: () => tui.invalidate() }; + }); +} + +async function promptInput( + ctx: ExtensionCommandContext, + label: string, + hint: string, +): Promise { + if (!ctx.hasUI) return null; + + return ctx.ui.custom((tui, theme, _kb, done) => { + let value = ""; + + function render(_width: number): string[] { + const lines: string[] = []; + lines.push(theme.fg("accent", ` ${label}`)); + lines.push(theme.fg("dim", ` ${hint}`)); + lines.push(""); + lines.push(` ${theme.fg("text", value)}`); + lines.push(""); + lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel")); + return lines; + } + + function handleInput(data: string): void { + if (data === "\r" || data === "\n") { + done(value.trim() || null); + } else if (data === "\x1b" || data === "\x03") { + done(null); + } else if (data === "\x7f") { + value = value.slice(0, -1); + tui.invalidate(); + } else if (data.length === 1 && data >= " ") { + value += data; + tui.invalidate(); + } + } + + return { render, handleInput, invalidate: () => tui.invalidate() }; + }); +} + +// ─── Persistence helpers ───────────────────────────────────────────────────── + +function getAuthFilePath(): string { + return join(homedir(), ".gsd", "agent", "auth.json"); +} + +function loadAuthJson(): Record { + const path = getAuthFilePath(); + if (!existsSync(path)) return {}; + try { + return JSON.parse(readFileSync(path, "utf-8")) as Record; + } catch { + return {}; + } +} + +function saveAuthJson(data: Record): void { + const path = getAuthFilePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(data, null, 2), "utf-8"); +} + +function saveTokenToAuth(provider: string, token: string): void { + const auth = loadAuthJson(); + auth[provider] = { type: "api_key", key: token }; + saveAuthJson(auth); +} + +function removeTokenFromAuth(provider: string): void { + const auth = loadAuthJson(); + delete auth[provider]; + saveAuthJson(auth); +} + +function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void { + const prefsPath = getGlobalGSDPreferencesPath(); + let content = ""; + + if (existsSync(prefsPath)) { + content = readFileSync(prefsPath, "utf-8"); + } + + // Check if frontmatter exists + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + + const remoteBlock = [ + `remote_questions:`, + ` channel: ${channel}`, + ` channel_id: "${channelId}"`, + ` timeout_minutes: 5`, + ` poll_interval_seconds: 5`, + ].join("\n"); + + if (fmMatch) { + // Replace existing remote_questions or append to frontmatter + let fm = fmMatch[1]; + const remoteRegex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/; + if (remoteRegex.test(fm)) { + fm = fm.replace(remoteRegex, remoteBlock); + } else { + fm = fm.trimEnd() + "\n" + remoteBlock; + } + content = `---\n${fm}\n---` + content.slice(fmMatch[0].length); + } else { + // Create new frontmatter + content = `---\n${remoteBlock}\n---\n\n${content}`; + } + + mkdirSync(dirname(prefsPath), { recursive: true }); + writeFileSync(prefsPath, content, "utf-8"); +} + +function removeRemoteQuestionsConfig(): void { + const prefsPath = getGlobalGSDPreferencesPath(); + if (!existsSync(prefsPath)) return; + + let content = readFileSync(prefsPath, "utf-8"); + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) return; + + let fm = fmMatch[1]; + // Remove remote_questions block from frontmatter + fm = fm.replace(/remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/, "").trim(); + + if (fm) { + content = `---\n${fm}\n---` + content.slice(fmMatch[0].length); + } else { + // Frontmatter is now empty, remove it + content = content.slice(fmMatch[0].length).replace(/^\n+/, ""); + } + + writeFileSync(prefsPath, content, "utf-8"); +} diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts new file mode 100644 index 000000000..8b48b328e --- /dev/null +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -0,0 +1,176 @@ +/** + * Remote Questions — Slack adapter + * + * Uses Slack Bot Token API (xoxb-*) for bidirectional messaging: + * - Send: POST chat.postMessage with Block Kit + * - Poll: GET conversations.replies to read thread responses + */ + +import type { + ChannelAdapter, + FormattedQuestion, + PollReference, + RemoteAnswer, + SendResult, +} from "./channels.js"; +import { formatForSlack, parseSlackReply } from "./format.js"; + +const SLACK_API = "https://slack.com/api"; + +export class SlackAdapter implements ChannelAdapter { + readonly name = "slack"; + private botUserId: string | null = null; + + constructor( + private readonly token: string, + private readonly channelId: string, + ) {} + + async validate(): Promise { + const res = await this.slackApi("auth.test", {}); + if (!res.ok) { + throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`); + } + this.botUserId = res.user_id as string; + } + + async sendQuestions(questions: FormattedQuestion[]): Promise { + const blocks = formatForSlack(questions); + + const res = await this.slackApi("chat.postMessage", { + channel: this.channelId, + text: "GSD needs your input", + blocks, + }); + + if (!res.ok) { + throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`); + } + + const ts = res.ts as string; + const channel = res.channel as string; + + return { + ref: { + channelType: "slack", + messageId: ts, + threadTs: ts, + channelId: channel, + }, + threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`, + }; + } + + async pollResponse(ref: PollReference): Promise { + // Ensure we know our bot user ID + if (!this.botUserId) { + const authRes = await this.slackApi("auth.test", {}); + if (authRes.ok) this.botUserId = authRes.user_id as string; + } + + const res = await this.slackApi("conversations.replies", { + channel: ref.channelId, + ts: ref.threadTs!, + limit: "20", + }); + + if (!res.ok) { + // Channel not found or no access — don't throw, just return null + return null; + } + + const messages = (res.messages ?? []) as Array<{ + user: string; + text: string; + ts: string; + }>; + + // Filter out the bot's own messages — only user replies count + const userReplies = messages.filter( + (m) => m.ts !== ref.threadTs && m.user !== this.botUserId, + ); + + if (userReplies.length === 0) return null; + + // Use the first user reply + const reply = userReplies[0]; + // We need the questions for parsing — store them on the ref isn't ideal, + // so the caller will need to pass them. For now, return raw text wrapped. + return { answers: { _raw: { answers: [reply.text] } } }; + } + + /** + * Poll with full question context for proper parsing. + */ + async pollResponseWithQuestions( + ref: PollReference, + questions: FormattedQuestion[], + ): Promise { + if (!this.botUserId) { + const authRes = await this.slackApi("auth.test", {}); + if (authRes.ok) this.botUserId = authRes.user_id as string; + } + + const res = await this.slackApi("conversations.replies", { + channel: ref.channelId, + ts: ref.threadTs!, + limit: "20", + }); + + if (!res.ok) return null; + + const messages = (res.messages ?? []) as Array<{ + user: string; + text: string; + ts: string; + }>; + + const userReplies = messages.filter( + (m) => m.ts !== ref.threadTs && m.user !== this.botUserId, + ); + + if (userReplies.length === 0) return null; + + return parseSlackReply(userReplies[0].text, questions); + } + + // ─── Internal ────────────────────────────────────────────────────────────── + + private async slackApi( + method: string, + params: Record, + ): Promise> { + const url = `${SLACK_API}/${method}`; + + const isGet = method === "conversations.replies" || method === "auth.test"; + + let response: Response; + if (isGet) { + // GET params must be strings for URLSearchParams + const stringParams: Record = {}; + for (const [k, v] of Object.entries(params)) { + stringParams[k] = String(v); + } + const qs = new URLSearchParams(stringParams).toString(); + response = await fetch(`${url}?${qs}`, { + method: "GET", + headers: { Authorization: `Bearer ${this.token}` }, + }); + } else { + response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json; charset=utf-8", + }, + body: JSON.stringify(params), + }); + } + + if (!response.ok) { + throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`); + } + + return (await response.json()) as Record; + } +} diff --git a/src/wizard.ts b/src/wizard.ts index 3706f5cae..fef191e5d 100644 --- a/src/wizard.ts +++ b/src/wizard.ts @@ -83,6 +83,8 @@ export function loadStoredEnvKeys(authStorage: AuthStorage): void { ['brave_answers', 'BRAVE_ANSWERS_KEY'], ['context7', 'CONTEXT7_API_KEY'], ['jina', 'JINA_API_KEY'], + ['slack_bot', 'SLACK_BOT_TOKEN'], + ['discord_bot', 'DISCORD_BOT_TOKEN'], ] for (const [provider, envVar] of providers) { if (!process.env[envVar]) { @@ -133,6 +135,20 @@ const API_KEYS: ApiKeyConfig[] = [ hint: '(clean page extraction)', description: 'High-quality web page content extraction', }, + { + provider: 'slack_bot', + envVar: 'SLACK_BOT_TOKEN', + label: 'Slack Bot', + hint: '(remote questions in auto-mode)', + description: 'Bot token for remote questions via Slack', + }, + { + provider: 'discord_bot', + envVar: 'DISCORD_BOT_TOKEN', + label: 'Discord Bot', + hint: '(remote questions in auto-mode)', + description: 'Bot token for remote questions via Discord', + }, ] /** From c9cb8dd1eb2493467220f2966d639e7445b981e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 11:32:40 -0300 Subject: [PATCH 07/60] fix: rename remote-questions/index.ts to send.ts to avoid extension loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pi's DefaultResourceLoader auto-discovers extensions/*/index.ts and expects a default export factory. remote-questions is an internal library (consumed via dynamic import), not an extension — having an index.ts caused the loader to try loading it as one and fail. Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/ask-user-questions.ts | 2 +- src/resources/extensions/remote-questions/{index.ts => send.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/resources/extensions/remote-questions/{index.ts => send.ts} (100%) diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 23f97decb..71e09704b 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -120,7 +120,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { } if (!ctx.hasUI) { - const { tryRemoteQuestions } = await import("./remote-questions/index.js"); + const { tryRemoteQuestions } = await import("./remote-questions/send.js"); const remoteResult = await tryRemoteQuestions(params.questions, signal); if (remoteResult) return remoteResult; return errorResult("Error: UI not available (non-interactive mode)", params.questions); diff --git a/src/resources/extensions/remote-questions/index.ts b/src/resources/extensions/remote-questions/send.ts similarity index 100% rename from src/resources/extensions/remote-questions/index.ts rename to src/resources/extensions/remote-questions/send.ts From c39388b2e30f149020986c43f2373db07951acc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 11:52:16 -0300 Subject: [PATCH 08/60] fix: use Editor from pi-tui for remote command input prompts The custom input handlers called tui.invalidate() which caused infinite recursion. Rewrite promptMaskedInput and promptInput to use the Editor component (same pattern as get-secrets-from-user.ts) with proper tui.requestRender() and cache invalidation. Co-Authored-By: Claude Opus 4.6 --- .../remote-questions/remote-command.ts | 159 +++++++++++++----- 1 file changed, 115 insertions(+), 44 deletions(-) diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts index a43b7bfca..30fbd3702 100644 --- a/src/resources/extensions/remote-questions/remote-command.ts +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -6,6 +6,7 @@ */ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { homedir } from "node:os"; @@ -288,6 +289,28 @@ async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise { // ─── Input helpers ─────────────────────────────────────────────────────────── +function maskEditorLine(line: string): string { + let output = ""; + let i = 0; + while (i < line.length) { + if (line.startsWith(CURSOR_MARKER, i)) { + output += CURSOR_MARKER; + i += CURSOR_MARKER.length; + continue; + } + const ansiMatch = /^\x1b\[[0-9;]*m/.exec(line.slice(i)); + if (ansiMatch) { + output += ansiMatch[0]; + i += ansiMatch[0].length; + continue; + } + const ch = line[i] as string; + output += ch === " " ? " " : "*"; + i += 1; + } + return output; +} + async function promptMaskedInput( ctx: ExtensionCommandContext, label: string, @@ -295,35 +318,59 @@ async function promptMaskedInput( ): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui, theme, _kb, done) => { - let value = ""; + return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { + let cachedLines: string[] | undefined; + const editorTheme: EditorTheme = { + borderColor: (s: string) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - function render(width: number): string[] { - const lines: string[] = []; - lines.push(theme.fg("accent", ` ${label}`)); - lines.push(theme.fg("dim", ` ${hint}`)); - lines.push(""); - lines.push(` ${theme.fg("text", "*".repeat(Math.min(value.length, width - 4)))}`); - lines.push(""); - lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel")); - return lines; + function refresh() { + cachedLines = undefined; + tui.requestRender(); } function handleInput(data: string): void { - if (data === "\r" || data === "\n") { - done(value.trim() || null); - } else if (data === "\x1b" || data === "\x03") { - done(null); - } else if (data === "\x7f") { - value = value.slice(0, -1); - tui.invalidate(); - } else if (data.length === 1 && data >= " ") { - value += data; - tui.invalidate(); + if (matchesKey(data, Key.enter)) { + const value = editor.getText().trim(); + done(value.length > 0 ? value : null); + return; } + if (matchesKey(data, Key.escape)) { + done(null); + return; + } + editor.handleInput(data); + refresh(); } - return { render, handleInput, invalidate: () => tui.invalidate() }; + function render(width: number): string[] { + if (cachedLines) return cachedLines; + const lines: string[] = []; + const add = (s: string) => lines.push(truncateToWidth(s, width)); + add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", theme.bold(` ${label}`))); + add(theme.fg("muted", ` ${hint}`)); + lines.push(""); + add(theme.fg("muted", " Enter value:")); + for (const line of editor.render(width - 2)) { + add(theme.fg("text", maskEditorLine(line))); + } + lines.push(""); + add(theme.fg("dim", ` enter to confirm | esc to cancel`)); + add(theme.fg("accent", "\u2500".repeat(width))); + cachedLines = lines; + return lines; + } + + return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } @@ -334,35 +381,59 @@ async function promptInput( ): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui, theme, _kb, done) => { - let value = ""; + return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { + let cachedLines: string[] | undefined; + const editorTheme: EditorTheme = { + borderColor: (s: string) => theme.fg("accent", s), + selectList: { + selectedPrefix: (t: string) => theme.fg("accent", t), + selectedText: (t: string) => theme.fg("accent", t), + description: (t: string) => theme.fg("muted", t), + scrollInfo: (t: string) => theme.fg("dim", t), + noMatch: (t: string) => theme.fg("warning", t), + }, + }; + const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - function render(_width: number): string[] { - const lines: string[] = []; - lines.push(theme.fg("accent", ` ${label}`)); - lines.push(theme.fg("dim", ` ${hint}`)); - lines.push(""); - lines.push(` ${theme.fg("text", value)}`); - lines.push(""); - lines.push(theme.fg("dim", " Enter to confirm, Esc to cancel")); - return lines; + function refresh() { + cachedLines = undefined; + tui.requestRender(); } function handleInput(data: string): void { - if (data === "\r" || data === "\n") { - done(value.trim() || null); - } else if (data === "\x1b" || data === "\x03") { - done(null); - } else if (data === "\x7f") { - value = value.slice(0, -1); - tui.invalidate(); - } else if (data.length === 1 && data >= " ") { - value += data; - tui.invalidate(); + if (matchesKey(data, Key.enter)) { + const value = editor.getText().trim(); + done(value.length > 0 ? value : null); + return; } + if (matchesKey(data, Key.escape)) { + done(null); + return; + } + editor.handleInput(data); + refresh(); } - return { render, handleInput, invalidate: () => tui.invalidate() }; + function render(width: number): string[] { + if (cachedLines) return cachedLines; + const lines: string[] = []; + const add = (s: string) => lines.push(truncateToWidth(s, width)); + add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", theme.bold(` ${label}`))); + add(theme.fg("muted", ` ${hint}`)); + lines.push(""); + add(theme.fg("muted", " Enter value:")); + for (const line of editor.render(width - 2)) { + add(theme.fg("text", line)); + } + lines.push(""); + add(theme.fg("dim", ` enter to confirm | esc to cancel`)); + add(theme.fg("accent", "\u2500".repeat(width))); + cachedLines = lines; + return lines; + } + + return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } From 0643d6348039ad2db42d8c7f2878cd649588a6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 11:56:18 -0300 Subject: [PATCH 09/60] fix: desugar TypeScript parameter properties for strip-types compat Node's --experimental-strip-types doesn't support parameter properties (private readonly in constructor params). Convert to explicit field declarations + constructor assignments. Co-Authored-By: Claude Opus 4.6 --- .../extensions/remote-questions/discord-adapter.ts | 10 ++++++---- .../extensions/remote-questions/slack-adapter.ts | 10 ++++++---- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index df54ef6bd..97e145a00 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -20,11 +20,13 @@ const DISCORD_API = "https://discord.com/api/v10"; export class DiscordAdapter implements ChannelAdapter { readonly name = "discord"; private botUserId: string | null = null; + private readonly token: string; + private readonly channelId: string; - constructor( - private readonly token: string, - private readonly channelId: string, - ) {} + constructor(token: string, channelId: string) { + this.token = token; + this.channelId = channelId; + } async validate(): Promise { const res = await this.discordApi("GET", "/users/@me"); diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts index 8b48b328e..1f3beff17 100644 --- a/src/resources/extensions/remote-questions/slack-adapter.ts +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -20,11 +20,13 @@ const SLACK_API = "https://slack.com/api"; export class SlackAdapter implements ChannelAdapter { readonly name = "slack"; private botUserId: string | null = null; + private readonly token: string; + private readonly channelId: string; - constructor( - private readonly token: string, - private readonly channelId: string, - ) {} + constructor(token: string, channelId: string) { + this.token = token; + this.channelId = channelId; + } async validate(): Promise { const res = await this.slackApi("auth.test", {}); From f307923db2a09132e01925288a4a905e4b7eb8b3 Mon Sep 17 00:00:00 2001 From: vp275 Date: Wed, 11 Mar 2026 21:39:02 +0530 Subject: [PATCH 10/60] fix: scope session list to current working directory Sessions are stored in a single flat ~/.gsd/sessions/ directory, so /resume shows sessions from all projects regardless of which folder you're in. Use per-cwd subdirectories under ~/.gsd/sessions/ with the same path encoding the upstream SDK uses (--path-segments--), so /resume only lists sessions from the current working directory. --- src/cli.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 9c253fe28..59cc8ec01 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { createAgentSession, InteractiveMode, } from '@mariozechner/pi-coding-agent' +import { join } from 'path' import { agentDir, sessionsDir, authFilePath } from './app-paths.js' import { buildResourceLoader, initResources } from './resource-loader.js' import { loadStoredEnvKeys, runWizardIfNeeded } from './wizard.js' @@ -53,7 +54,12 @@ if (!settingsManager.getCollapseChangelog()) { settingsManager.setCollapseChangelog(true) } -const sessionManager = SessionManager.create(process.cwd(), sessionsDir) +// Per-directory session storage — same encoding as the upstream SDK so that +// /resume only shows sessions from the current working directory. +const cwd = process.cwd() +const safePath = `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--` +const projectSessionsDir = join(sessionsDir, safePath) +const sessionManager = SessionManager.create(cwd, projectSessionsDir) initResources(agentDir) const resourceLoader = buildResourceLoader(agentDir) From 0d251d97073253daae8a17f60f3869042df060ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Wed, 11 Mar 2026 10:52:45 -0600 Subject: [PATCH 11/60] fix: bootstrap managed tools and gh auth Preserve the original #39 fix while adding the missing hardening and regression coverage. Credit to @LuxVTZ for the original fix incorporated here. --- src/cli.ts | 8 ++- src/resources/extensions/github/gh-api.ts | 76 ++++++++++++-------- src/tests/gh-api.test.ts | 52 ++++++++++++++ src/tests/tool-bootstrap.test.ts | 73 +++++++++++++++++++ src/tool-bootstrap.ts | 85 +++++++++++++++++++++++ 5 files changed, 263 insertions(+), 31 deletions(-) create mode 100644 src/tests/gh-api.test.ts create mode 100644 src/tests/tool-bootstrap.test.ts create mode 100644 src/tool-bootstrap.ts diff --git a/src/cli.ts b/src/cli.ts index 59cc8ec01..17cf193d0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,11 +6,17 @@ import { createAgentSession, InteractiveMode, } from '@mariozechner/pi-coding-agent' -import { join } from 'path' +import { join } from 'node:path' import { agentDir, sessionsDir, authFilePath } from './app-paths.js' import { buildResourceLoader, initResources } from './resource-loader.js' +import { ensureManagedTools } from './tool-bootstrap.js' import { loadStoredEnvKeys, runWizardIfNeeded } from './wizard.js' +// Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems +// because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code. +// Provision local managed binaries first so Pi sees them without probing PATH. +ensureManagedTools(join(agentDir, 'bin')) + const authStorage = AuthStorage.create(authFilePath) loadStoredEnvKeys(authStorage) await runWizardIfNeeded(authStorage) diff --git a/src/resources/extensions/github/gh-api.ts b/src/resources/extensions/github/gh-api.ts index 6edc8b174..ccdeba8da 100644 --- a/src/resources/extensions/github/gh-api.ts +++ b/src/resources/extensions/github/gh-api.ts @@ -6,20 +6,44 @@ * Falls back to raw REST API with GITHUB_TOKEN env var. */ -import { execSync } from "node:child_process"; +import { execSync, spawnSync, type SpawnSyncReturns } from "node:child_process"; // ─── Auth detection ─────────────────────────────────────────────────────────── let _useGhCli: boolean | null = null; -function hasGhCli(): boolean { +let ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns => + spawnSync("gh", args, { + cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + input, + }); + +function ghSpawn(args: string[], input?: string, cwd?: string): SpawnSyncReturns { + return ghSpawnImpl(args, input, cwd); +} + +export function resetGhCliDetectionForTests(): void { + _useGhCli = null; + ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns => + spawnSync("gh", args, { + cwd, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + input, + }); +} + +export function setGhSpawnForTests(fn: (args: string[], input?: string, cwd?: string) => SpawnSyncReturns): void { + ghSpawnImpl = fn; + _useGhCli = null; +} + +export function hasGhCli(): boolean { if (_useGhCli !== null) return _useGhCli; - try { - execSync("gh auth status", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }); - _useGhCli = true; - } catch { - _useGhCli = false; - } + const result = ghSpawn(["auth", "token"]); + _useGhCli = result.status === 0 && !result.error && !!result.stdout?.trim(); return _useGhCli; } @@ -120,11 +144,6 @@ export async function ghApi( return fetchApi(endpoint, method, options.params, options.body, token); } -function shellEscape(s: string): string { - // Single-quote wrapping, escaping any existing single quotes - return "'" + s.replace(/'/g, "'\\''") + "'"; -} - function ghCliApi( endpoint: string, method: string, @@ -132,39 +151,36 @@ function ghCliApi( body?: Record, cwd?: string, ): T { - const parts = ["gh", "api", shellEscape(endpoint), "--method", method]; + const args = ["api", endpoint, "--method", method]; if (params) { for (const [key, val] of Object.entries(params)) { if (val === undefined) continue; if (Array.isArray(val)) { for (const v of val) { - parts.push("-f", shellEscape(`${key}[]=${v}`)); + args.push("-f", `${key}[]=${v}`); } } else { - parts.push("-f", shellEscape(`${key}=${String(val)}`)); + args.push("-f", `${key}=${String(val)}`); } } } if (body) { - parts.push("--input", "-"); + args.push("--input", "-"); } - try { - const result = execSync(parts.join(" "), { - cwd: cwd ?? process.cwd(), - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - input: body ? JSON.stringify(body) : undefined, - }); - if (!result.trim()) return {} as T; - return JSON.parse(result) as T; - } catch (e: unknown) { - const err = e as { stderr?: string; stdout?: string; message?: string }; - const msg = err.stderr?.trim() || err.stdout?.trim() || err.message || String(e); - throw new Error(`gh api error: ${msg}`); + const result = ghSpawn(args, body ? JSON.stringify(body) : undefined, cwd ?? process.cwd()); + + const stdout = result.stdout?.trim() ?? ""; + const stderr = result.stderr?.trim() ?? ""; + + if (result.status !== 0) { + throw new Error(`gh api error: ${stderr || stdout || result.error?.message || `exit code ${result.status}`}`); } + + if (!stdout) return {} as T; + return JSON.parse(stdout) as T; } async function fetchApi( diff --git a/src/tests/gh-api.test.ts b/src/tests/gh-api.test.ts new file mode 100644 index 000000000..3d2ef5b77 --- /dev/null +++ b/src/tests/gh-api.test.ts @@ -0,0 +1,52 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync as realSpawnSync } from "node:child_process"; + +import * as ghApiModule from "../resources/extensions/github/gh-api.ts"; + +function makeSpawnResult(overrides: Partial>): ReturnType { + return { + status: 0, + stdout: "", + stderr: "", + output: [null, "", ""], + pid: 1, + signal: null, + ...overrides, + } as ReturnType; +} + +test("hasGhCli treats zero-exit token output as authenticated", () => { + ghApiModule.setGhSpawnForTests(() => makeSpawnResult({ stdout: "gho_test\n" })); + + try { + assert.equal(ghApiModule.hasGhCli(), true); + assert.equal(ghApiModule.authMethod(), "gh CLI"); + } finally { + ghApiModule.resetGhCliDetectionForTests(); + } +}); + +test("hasGhCli rejects zero-exit responses with empty stdout", () => { + ghApiModule.setGhSpawnForTests(() => makeSpawnResult({ stdout: "" })); + + try { + assert.equal(ghApiModule.hasGhCli(), false); + } finally { + ghApiModule.resetGhCliDetectionForTests(); + } +}); + +test("hasGhCli rejects spawnSync error even with zero exit", () => { + ghApiModule.setGhSpawnForTests(() => makeSpawnResult({ + stdout: "gho_test\n", + stderr: "EPERM", + error: new Error("spawnSync gh EPERM"), + })); + + try { + assert.equal(ghApiModule.hasGhCli(), false); + } finally { + ghApiModule.resetGhCliDetectionForTests(); + } +}); diff --git a/src/tests/tool-bootstrap.test.ts b/src/tests/tool-bootstrap.test.ts new file mode 100644 index 000000000..b19b6740b --- /dev/null +++ b/src/tests/tool-bootstrap.test.ts @@ -0,0 +1,73 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { chmodSync, existsSync, lstatSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { ensureManagedTools, resolveToolFromPath } from "../tool-bootstrap.js"; + +function makeExecutable(dir: string, name: string, content = "#!/bin/sh\nexit 0\n"): string { + const file = join(dir, name); + writeFileSync(file, content); + chmodSync(file, 0o755); + return file; +} + +test("resolveToolFromPath finds fd via fdfind fallback", () => { + const tmp = mkdtempSync(join(tmpdir(), "gsd-tool-bootstrap-resolve-")); + try { + makeExecutable(tmp, "fdfind"); + const resolved = resolveToolFromPath("fd", tmp); + assert.equal(resolved, join(tmp, "fdfind")); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("ensureManagedTools provisions fd and rg into managed bin dir", () => { + const tmp = mkdtempSync(join(tmpdir(), "gsd-tool-bootstrap-provision-")); + const sourceBin = join(tmp, "source-bin"); + const targetBin = join(tmp, "target-bin"); + + mkdirSync(sourceBin, { recursive: true }); + mkdirSync(targetBin, { recursive: true }); + + try { + makeExecutable(sourceBin, "fdfind"); + makeExecutable(sourceBin, "rg"); + + const provisioned = ensureManagedTools(targetBin, sourceBin); + + assert.equal(provisioned.length, 2); + assert.ok(existsSync(join(targetBin, "fd"))); + assert.ok(existsSync(join(targetBin, "rg"))); + assert.ok(lstatSync(join(targetBin, "fd")).isSymbolicLink() || lstatSync(join(targetBin, "fd")).isFile()); + assert.ok(lstatSync(join(targetBin, "rg")).isSymbolicLink() || lstatSync(join(targetBin, "rg")).isFile()); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test("ensureManagedTools copies executable when symlink target already exists as a broken link", () => { + const tmp = mkdtempSync(join(tmpdir(), "gsd-tool-bootstrap-copy-")); + const sourceBin = join(tmp, "source-bin"); + const targetBin = join(tmp, "target-bin"); + const targetFd = join(targetBin, "fd"); + + mkdirSync(sourceBin, { recursive: true }); + mkdirSync(targetBin, { recursive: true }); + + try { + makeExecutable(sourceBin, "fdfind", "#!/bin/sh\necho fd\n"); + makeExecutable(sourceBin, "rg", "#!/bin/sh\necho rg\n"); + symlinkSync(join(tmp, "missing-target"), targetFd); + + const provisioned = ensureManagedTools(targetBin, sourceBin); + + assert.equal(provisioned.length, 2); + assert.ok(lstatSync(targetFd).isFile(), "fd fallback should replace broken symlink with a copied file"); + assert.match(readFileSync(targetFd, "utf8"), /echo fd/); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/src/tool-bootstrap.ts b/src/tool-bootstrap.ts new file mode 100644 index 000000000..349133250 --- /dev/null +++ b/src/tool-bootstrap.ts @@ -0,0 +1,85 @@ +import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs"; +import { delimiter, join } from "node:path"; + +type ManagedTool = "fd" | "rg"; + +interface ToolSpec { + targetName: string; + candidates: string[]; +} + +const TOOL_SPECS: Record = { + fd: { + targetName: process.platform === "win32" ? "fd.exe" : "fd", + candidates: process.platform === "win32" ? ["fd.exe", "fd", "fdfind.exe", "fdfind"] : ["fd", "fdfind"], + }, + rg: { + targetName: process.platform === "win32" ? "rg.exe" : "rg", + candidates: process.platform === "win32" ? ["rg.exe", "rg"] : ["rg"], + }, +}; + +function splitPath(pathValue: string | undefined): string[] { + if (!pathValue) return []; + return pathValue.split(delimiter).map((segment) => segment.trim()).filter(Boolean); +} + +function getCandidateNames(name: string): string[] { + if (process.platform !== "win32") return [name]; + const lower = name.toLowerCase(); + if (lower.endsWith(".exe") || lower.endsWith(".cmd") || lower.endsWith(".bat")) return [name]; + return [name, `${name}.exe`, `${name}.cmd`, `${name}.bat`]; +} + +function isRegularFile(path: string): boolean { + try { + return lstatSync(path).isFile() || lstatSync(path).isSymbolicLink(); + } catch { + return false; + } +} + +export function resolveToolFromPath(tool: ManagedTool, pathValue: string | undefined = process.env.PATH): string | null { + const spec = TOOL_SPECS[tool]; + for (const dir of splitPath(pathValue)) { + for (const candidate of spec.candidates) { + for (const name of getCandidateNames(candidate)) { + const fullPath = join(dir, name); + if (existsSync(fullPath) && isRegularFile(fullPath)) { + return fullPath; + } + } + } + } + return null; +} + +function provisionTool(targetDir: string, tool: ManagedTool, sourcePath: string): string { + const targetPath = join(targetDir, TOOL_SPECS[tool].targetName); + if (existsSync(targetPath)) return targetPath; + + mkdirSync(targetDir, { recursive: true }); + + try { + symlinkSync(sourcePath, targetPath); + } catch { + rmSync(targetPath, { force: true }); + copyFileSync(sourcePath, targetPath); + chmodSync(targetPath, 0o755); + } + + return targetPath; +} + +export function ensureManagedTools(targetDir: string, pathValue: string | undefined = process.env.PATH): string[] { + const provisioned: string[] = []; + + for (const tool of Object.keys(TOOL_SPECS) as ManagedTool[]) { + if (existsSync(join(targetDir, TOOL_SPECS[tool].targetName))) continue; + const sourcePath = resolveToolFromPath(tool, pathValue); + if (!sourcePath) continue; + provisioned.push(provisionTool(targetDir, tool, sourcePath)); + } + + return provisioned; +} From 97f27f17ce13c044ce2915828ad6ce5fa026885b Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 10:57:19 -0600 Subject: [PATCH 12/60] fix: prevent discuss prompt loop and refresh star history link --- README.md | 8 +++- .../extensions/gsd/prompts/discuss.md | 4 +- .../gsd/tests/discuss-prompt.test.ts | 38 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/discuss-prompt.test.ts diff --git a/README.md b/README.md index 998465003..b9c0e6081 100644 --- a/README.md +++ b/README.md @@ -412,7 +412,13 @@ Use expensive models where quality matters (planning, complex execution) and che ## Star History -[![Star History Chart](https://api.star-history.com/svg?repos=gsd-build/GSD-2&type=Date)](https://star-history.com/#gsd-build/GSD-2&Date) + + + + + Star History Chart + + --- diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index f315c8224..0e8f52845 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -1,6 +1,8 @@ {{preamble}} -Say exactly: "What's the vision?" — nothing else. Wait for the user's answer. +Ask: "What's the vision?" once, and then use whatever the user replies with as the vision input to continue. + +Special handling: if the user message is not a project description (for example, they ask about status, branch state, or other clarifications), treat it as the vision input and proceed with discussion logic instead of repeating "What's the vision?". ## Discussion Phase diff --git a/src/resources/extensions/gsd/tests/discuss-prompt.test.ts b/src/resources/extensions/gsd/tests/discuss-prompt.test.ts new file mode 100644 index 000000000..cab45aaca --- /dev/null +++ b/src/resources/extensions/gsd/tests/discuss-prompt.test.ts @@ -0,0 +1,38 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +let passed = 0; +let failed = 0; + +function assert(condition: boolean, message: string): void { + if (condition) passed++; + else { + failed++; + console.error(` FAIL: ${message}`); + } +} + +const promptPath = join(process.cwd(), 'src/resources/extensions/gsd/prompts/discuss.md'); +const discussPrompt = readFileSync(promptPath, 'utf-8'); + +console.log('\n=== discuss prompt: resilient vision framing ==='); +{ + const hardenedPattern = /Say exactly:\s*"What's the vision\?"/; + assert(!hardenedPattern.test(discussPrompt), 'prompt no longer uses exact-verbosity lock'); + assert( + discussPrompt.includes('Ask: "What\'s the vision?" once'), + 'prompt asks for vision exactly once', + ); + assert( + discussPrompt.includes('Special handling'), + 'prompt documents special handling for non-vision user messages', + ); + assert( + discussPrompt.includes('instead of repeating "What\'s the vision?"'), + 'prompt forbids repeating the vision question', + ); +} + +console.log(`\nResults: ${passed} passed, ${failed} failed`); +if (failed > 0) process.exit(1); +console.log('All tests passed ✓'); From 8f78079daf8eacb6cedf5586e06baa6219ba6f3d Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 11:01:26 -0600 Subject: [PATCH 13/60] docs: fix star history repo link --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b9c0e6081..265d9243d 100644 --- a/README.md +++ b/README.md @@ -412,11 +412,11 @@ Use expensive models where quality matters (planning, complex execution) and che ## Star History - + - - - Star History Chart + + + Star History Chart From aea1f8a51bb594b303bfb4eca86006ac74f9e2bd Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 11:15:02 -0600 Subject: [PATCH 14/60] docs: simplify star history embed --- README.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/README.md b/README.md index 265d9243d..57fd35bdc 100644 --- a/README.md +++ b/README.md @@ -413,11 +413,7 @@ Use expensive models where quality matters (planning, complex execution) and che ## Star History - - - - Star History Chart - + Star History Chart --- From d4a46beef758777ab6496518d428d18350600488 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 11:21:12 -0600 Subject: [PATCH 15/60] fix: support print/JSON mode in cli.js so subagents don't hang cli.ts unconditionally entered InteractiveMode, ignoring --mode, -p, --no-session and other flags the subagent extension passes to child processes. The child would wait for TTY input that never arrives (stdin is "ignore"), causing the parent to hang forever on "working". Parse CLI args to detect print/subagent mode and route to runPrintMode() with proper session, model, extension, and system prompt handling. Closes #45 Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 121 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 118 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 17cf193d0..342cb9674 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,17 +1,64 @@ import { AuthStorage, + DefaultResourceLoader, ModelRegistry, SettingsManager, SessionManager, createAgentSession, InteractiveMode, + runPrintMode, } from '@mariozechner/pi-coding-agent' +import { readFileSync } from 'node:fs' import { join } from 'node:path' import { agentDir, sessionsDir, authFilePath } from './app-paths.js' -import { buildResourceLoader, initResources } from './resource-loader.js' +import { initResources } from './resource-loader.js' import { ensureManagedTools } from './tool-bootstrap.js' import { loadStoredEnvKeys, runWizardIfNeeded } from './wizard.js' +// --------------------------------------------------------------------------- +// Minimal CLI arg parser — detects print/subagent mode flags +// --------------------------------------------------------------------------- +interface CliFlags { + mode?: 'text' | 'json' | 'rpc' + print?: boolean + noSession?: boolean + model?: string + extensions: string[] + appendSystemPrompt?: string + tools?: string[] + messages: string[] +} + +function parseCliArgs(argv: string[]): CliFlags { + const flags: CliFlags = { extensions: [], messages: [] } + const args = argv.slice(2) // skip node + script + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '--mode' && i + 1 < args.length) { + const m = args[++i] + if (m === 'text' || m === 'json' || m === 'rpc') flags.mode = m + } else if (arg === '--print' || arg === '-p') { + flags.print = true + } else if (arg === '--no-session') { + flags.noSession = true + } else if (arg === '--model' && i + 1 < args.length) { + flags.model = args[++i] + } else if (arg === '--extension' && i + 1 < args.length) { + flags.extensions.push(args[++i]) + } else if (arg === '--append-system-prompt' && i + 1 < args.length) { + flags.appendSystemPrompt = args[++i] + } else if (arg === '--tools' && i + 1 < args.length) { + flags.tools = args[++i].split(',') + } else if (!arg.startsWith('--') && !arg.startsWith('-')) { + flags.messages.push(arg) + } + } + return flags +} + +const cliFlags = parseCliArgs(process.argv) +const isPrintMode = cliFlags.print || cliFlags.mode !== undefined + // Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems // because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code. // Provision local managed binaries first so Pi sees them without probing PATH. @@ -19,7 +66,11 @@ ensureManagedTools(join(agentDir, 'bin')) const authStorage = AuthStorage.create(authFilePath) loadStoredEnvKeys(authStorage) -await runWizardIfNeeded(authStorage) + +// Skip the setup wizard in print mode — it requires TTY interaction +if (!isPrintMode) { + await runWizardIfNeeded(authStorage) +} const modelRegistry = new ModelRegistry(authStorage) const settingsManager = SettingsManager.create(agentDir) @@ -60,6 +111,70 @@ if (!settingsManager.getCollapseChangelog()) { settingsManager.setCollapseChangelog(true) } +// --------------------------------------------------------------------------- +// Print / subagent mode — single-shot execution, no TTY required +// --------------------------------------------------------------------------- +if (isPrintMode) { + const sessionManager = cliFlags.noSession + ? SessionManager.inMemory() + : SessionManager.create(process.cwd()) + + // Read --append-system-prompt file content (subagent writes agent system prompts to temp files) + let appendSystemPrompt: string | undefined + if (cliFlags.appendSystemPrompt) { + try { + appendSystemPrompt = readFileSync(cliFlags.appendSystemPrompt, 'utf-8') + } catch { + // If it's not a file path, treat it as literal text + appendSystemPrompt = cliFlags.appendSystemPrompt + } + } + + initResources(agentDir) + const resourceLoader = new DefaultResourceLoader({ + agentDir, + additionalExtensionPaths: cliFlags.extensions.length > 0 ? cliFlags.extensions : undefined, + appendSystemPrompt, + }) + await resourceLoader.reload() + + const { session, extensionsResult } = await createAgentSession({ + authStorage, + modelRegistry, + settingsManager, + sessionManager, + resourceLoader, + }) + + if (extensionsResult.errors.length > 0) { + for (const err of extensionsResult.errors) { + process.stderr.write(`[gsd] Extension load error: ${err.error}\n`) + } + } + + // Apply --model override if specified + if (cliFlags.model) { + const available = modelRegistry.getAvailable() + const match = + available.find((m) => m.id === cliFlags.model) || + available.find((m) => `${m.provider}/${m.id}` === cliFlags.model) + if (match) { + session.setModel(match) + } + } + + const mode = cliFlags.mode || 'text' + await runPrintMode(session, { + mode: mode === 'rpc' ? 'json' : mode, + messages: cliFlags.messages, + }) + process.exit(0) +} + +// --------------------------------------------------------------------------- +// Interactive mode — normal TTY session +// --------------------------------------------------------------------------- + // Per-directory session storage — same encoding as the upstream SDK so that // /resume only shows sessions from the current working directory. const cwd = process.cwd() @@ -68,7 +183,7 @@ const projectSessionsDir = join(sessionsDir, safePath) const sessionManager = SessionManager.create(cwd, projectSessionsDir) initResources(agentDir) -const resourceLoader = buildResourceLoader(agentDir) +const resourceLoader = new DefaultResourceLoader({ agentDir }) await resourceLoader.reload() const { session, extensionsResult } = await createAgentSession({ From 58ca04e7de4fb6da66156692ce683756cf632c5b Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 11:24:44 -0600 Subject: [PATCH 16/60] fix: restore Windows VT input after child processes exit (#41) Child processes (Git Bash/MSYS2) strip the ENABLE_VIRTUAL_TERMINAL_INPUT flag from the shared stdin console handle, corrupting terminal input. Re-enable the flag after every child process exits in bash.js, bg-shell, and cache FFI handles in pi-tui for cheap repeated calls. Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 657 ++++++++++++++++++ package.json | 2 + ...@mariozechner+pi-coding-agent+0.57.1.patch | 48 ++ patches/@mariozechner+pi-tui+0.57.1.patch | 47 ++ scripts/postinstall.js | 8 + src/resources/extensions/bg-shell/index.ts | 29 + 6 files changed, 791 insertions(+) create mode 100644 patches/@mariozechner+pi-coding-agent+0.57.1.patch create mode 100644 patches/@mariozechner+pi-tui+0.57.1.patch diff --git a/package-lock.json b/package-lock.json index 1bc73f6c3..b1f64f09e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "patch-package": "^8.0.1", "typescript": "^5.4.0" }, "engines": { @@ -1881,6 +1882,13 @@ "@types/node": "*" } }, + "node_modules/@yarnpkg/lockfile": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", + "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", + "dev": true, + "license": "BSD-2-Clause" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -2033,6 +2041,19 @@ "node": "18 || 20 || >=22" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -2048,6 +2069,56 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2060,6 +2131,22 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/cli-highlight": { "version": "2.1.11", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.11.tgz", @@ -2147,6 +2234,21 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -2173,6 +2275,24 @@ } } }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -2196,6 +2316,21 @@ "node": ">=0.3.1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -2220,6 +2355,39 @@ "once": "^1.4.0" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2413,6 +2581,29 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-yarn-workspace-root": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz", + "integrity": "sha512-1IMnbjt4KzsQfnhnzNd8wUEgXZ44IzZaZmnLYx7D5FZlaHt2gW20Cri8Q+E/t5tIj4+epTBub+2Zxu/vNILzqQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "micromatch": "^4.0.2" + } + }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -2425,6 +2616,21 @@ "node": ">=12.20.0" } }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", @@ -2439,6 +2645,16 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gaxios": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", @@ -2488,6 +2704,45 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", @@ -2569,6 +2824,19 @@ "node": ">=14" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2584,6 +2852,45 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/highlight.js": { "version": "10.7.3", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", @@ -2669,6 +2976,22 @@ "node": ">= 12" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2678,6 +3001,43 @@ "node": ">=8" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -2706,6 +3066,49 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stable-stringify": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", + "integrity": "sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "isarray": "^2.0.5", + "jsonify": "^0.0.1", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonify": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.1.tgz", + "integrity": "sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==", + "dev": true, + "license": "Public Domain", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -2727,6 +3130,16 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/klaw-sync": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/klaw-sync/-/klaw-sync-6.0.0.tgz", + "integrity": "sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11" + } + }, "node_modules/koffi": { "version": "2.15.1", "resolved": "https://registry.npmjs.org/koffi/-/koffi-2.15.1.tgz", @@ -2765,6 +3178,30 @@ "node": ">= 18" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2805,6 +3242,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -2887,6 +3334,16 @@ "node": ">=0.10.0" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -2896,6 +3353,23 @@ "wrappy": "1" } }, + "node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openai": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", @@ -2989,6 +3463,53 @@ "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", "license": "MIT" }, + "node_modules/patch-package": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/patch-package/-/patch-package-8.0.1.tgz", + "integrity": "sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@yarnpkg/lockfile": "^1.1.0", + "chalk": "^4.1.2", + "ci-info": "^3.7.0", + "cross-spawn": "^7.0.3", + "find-yarn-workspace-root": "^2.0.0", + "fs-extra": "^10.0.0", + "json-stable-stringify": "^1.0.2", + "klaw-sync": "^6.0.0", + "minimist": "^1.2.6", + "open": "^7.4.2", + "semver": "^7.5.3", + "slash": "^2.0.0", + "tmp": "^0.2.4", + "yaml": "^2.2.2" + }, + "bin": { + "patch-package": "index.js" + }, + "engines": { + "node": ">=14", + "npm": ">5" + } + }, + "node_modules/patch-package/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/path-expression-matcher": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.2.tgz", @@ -3004,6 +3525,16 @@ "node": ">=14.0.0" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -3026,6 +3557,19 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/playwright": { "version": "1.58.2", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", @@ -3191,12 +3735,76 @@ ], "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "license": "ISC" }, + "node_modules/slash": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", + "integrity": "sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -3362,6 +3970,29 @@ "node": ">=0.8" } }, + "node_modules/tmp": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/token-types": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", @@ -3433,6 +4064,16 @@ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "license": "MIT" }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -3442,6 +4083,22 @@ "node": ">= 8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 597d895ba..8f44f5216 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "files": [ "dist", + "patches", "pkg", "src/resources", "scripts/postinstall.js", @@ -46,6 +47,7 @@ }, "devDependencies": { "@types/node": "^22.0.0", + "patch-package": "^8.0.1", "typescript": "^5.4.0" }, "overrides": { diff --git a/patches/@mariozechner+pi-coding-agent+0.57.1.patch b/patches/@mariozechner+pi-coding-agent+0.57.1.patch new file mode 100644 index 000000000..0216c88d9 --- /dev/null +++ b/patches/@mariozechner+pi-coding-agent+0.57.1.patch @@ -0,0 +1,48 @@ +diff --git a/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js b/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js +index 27fe820..68f277f 100644 +--- a/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js ++++ b/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js +@@ -1,11 +1,35 @@ + import { randomBytes } from "node:crypto"; + import { createWriteStream, existsSync } from "node:fs"; ++import { createRequire } from "node:module"; + import { tmpdir } from "node:os"; + import { join } from "node:path"; + import { Type } from "@sinclair/typebox"; + import { spawn } from "child_process"; + import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js"; + import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateTail } from "./truncate.js"; ++// Cached Win32 FFI handles for restoring VT input after child processes ++let _vtHandles = null; ++function restoreWindowsVTInput() { ++ if (process.platform !== "win32") return; ++ try { ++ if (!_vtHandles) { ++ const cjsRequire = createRequire(import.meta.url); ++ const koffi = cjsRequire("koffi"); ++ const k32 = koffi.load("kernel32.dll"); ++ const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); ++ const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); ++ const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); ++ const handle = GetStdHandle(-10); ++ _vtHandles = { GetConsoleMode, SetConsoleMode, handle }; ++ } ++ const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; ++ const mode = new Uint32Array(1); ++ _vtHandles.GetConsoleMode(_vtHandles.handle, mode); ++ if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) { ++ _vtHandles.SetConsoleMode(_vtHandles.handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT); ++ } ++ } catch { } ++} + /** + * Generate a unique temp file path for bash output + */ +@@ -76,6 +100,7 @@ const defaultBashOperations = { + } + // Handle process exit + child.on("close", (code) => { ++ restoreWindowsVTInput(); + if (timeoutHandle) + clearTimeout(timeoutHandle); + if (signal) diff --git a/patches/@mariozechner+pi-tui+0.57.1.patch b/patches/@mariozechner+pi-tui+0.57.1.patch new file mode 100644 index 000000000..64da0cb55 --- /dev/null +++ b/patches/@mariozechner+pi-tui+0.57.1.patch @@ -0,0 +1,47 @@ +diff --git a/node_modules/@mariozechner/pi-tui/dist/terminal.js b/node_modules/@mariozechner/pi-tui/dist/terminal.js +index cd20330..e836fcd 100644 +--- a/node_modules/@mariozechner/pi-tui/dist/terminal.js ++++ b/node_modules/@mariozechner/pi-tui/dist/terminal.js +@@ -7,6 +7,7 @@ const cjsRequire = createRequire(import.meta.url); + * Real terminal using process.stdin/stdout + */ + export class ProcessTerminal { ++ static _vtHandles = null; + wasRaw = false; + inputHandler; + resizeHandler; +@@ -126,20 +127,23 @@ export class ProcessTerminal { + if (process.platform !== "win32") + return; + try { +- // Dynamic require to avoid bundling koffi's 74MB of cross-platform +- // native binaries into every compiled binary. Koffi is only needed +- // on Windows for VT input support. +- const koffi = cjsRequire("koffi"); +- const k32 = koffi.load("kernel32.dll"); +- const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); +- const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); +- const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); +- const STD_INPUT_HANDLE = -10; ++ if (!ProcessTerminal._vtHandles) { ++ const koffi = cjsRequire("koffi"); ++ const k32 = koffi.load("kernel32.dll"); ++ const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); ++ const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); ++ const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); ++ const STD_INPUT_HANDLE = -10; ++ const handle = GetStdHandle(STD_INPUT_HANDLE); ++ ProcessTerminal._vtHandles = { GetConsoleMode, SetConsoleMode, handle }; ++ } + const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; +- const handle = GetStdHandle(STD_INPUT_HANDLE); ++ const { GetConsoleMode, SetConsoleMode, handle } = ProcessTerminal._vtHandles; + const mode = new Uint32Array(1); + GetConsoleMode(handle, mode); +- SetConsoleMode(handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT); ++ if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) { ++ SetConsoleMode(handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT); ++ } + } + catch { + // koffi not available — Shift+Tab won't be distinguishable from Tab diff --git a/scripts/postinstall.js b/scripts/postinstall.js index e9450dc9e..d7eecaf0f 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -35,6 +35,14 @@ const banner = process.stderr.write(banner) +// Apply patches to upstream dependencies (non-fatal) +try { + execSync('npx patch-package', { stdio: 'inherit', cwd: resolve(__dirname, '..') }) + process.stderr.write(`\n ${green}✓${reset} Patches applied\n`) +} catch { + process.stderr.write(`\n ${yellow}⚠${reset} Failed to apply patches — run ${cyan}npx patch-package${reset} manually\n`) +} + // Install Playwright chromium for browser tools (non-fatal) const args = os.platform() === 'linux' ? '--with-deps' : '' try { diff --git a/src/resources/extensions/bg-shell/index.ts b/src/resources/extensions/bg-shell/index.ts index 83d227dcb..490ee5000 100644 --- a/src/resources/extensions/bg-shell/index.ts +++ b/src/resources/extensions/bg-shell/index.ts @@ -48,6 +48,34 @@ import { createConnection } from "node:net"; import { randomUUID } from "node:crypto"; import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; +import { createRequire } from "node:module"; + +// ── Windows VT Input Restoration ──────────────────────────────────────────── +// Child processes (esp. Git Bash / MSYS2) can strip the ENABLE_VIRTUAL_TERMINAL_INPUT +// flag from the shared stdin console handle. Re-enable it after each child exits. + +let _vtHandles: { GetConsoleMode: Function; SetConsoleMode: Function; handle: unknown } | null = null; +function restoreWindowsVTInput(): void { + if (process.platform !== "win32") return; + try { + if (!_vtHandles) { + const cjsRequire = createRequire(import.meta.url); + const koffi = cjsRequire("koffi"); + const k32 = koffi.load("kernel32.dll"); + const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)"); + const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)"); + const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)"); + const handle = GetStdHandle(-10); + _vtHandles = { GetConsoleMode, SetConsoleMode, handle }; + } + const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200; + const mode = new Uint32Array(1); + _vtHandles.GetConsoleMode(_vtHandles.handle, mode); + if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) { + _vtHandles.SetConsoleMode(_vtHandles.handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT); + } + } catch { /* koffi not available on non-Windows */ } +} // ── Types ────────────────────────────────────────────────────────────────── @@ -623,6 +651,7 @@ function startProcess(opts: StartOptions): BgProcess { }); proc.on("exit", (code, sig) => { + restoreWindowsVTInput(); bg.alive = false; bg.exitCode = code; bg.signal = sig ?? null; From 80d13379df815f3da0d7bb3e7f1436513c8375fb Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 11:24:58 -0600 Subject: [PATCH 17/60] chore: bump version to 0.3.1 Co-Authored-By: Claude Opus 4.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b1f64f09e..49dad8432 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "0.3.0", + "version": "0.3.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 8f44f5216..8577a3b39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "0.3.0", + "version": "0.3.1", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From a37ef56146af5bdc6c0998060fa6a309e73d3181 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 10:35:59 -0600 Subject: [PATCH 18/60] feat: harden remote questions flow --- .../extensions/ask-user-questions.ts | 4 +- src/resources/extensions/gsd/index.ts | 9 +- src/resources/extensions/gsd/preferences.ts | 6 +- .../gsd/tests/remote-questions.test.ts | 107 ++++ .../gsd/tests/remote-status.test.ts | 44 ++ .../extensions/remote-questions/config.ts | 62 +- .../remote-questions/discord-adapter.ts | 157 ++--- .../extensions/remote-questions/format.ts | 224 +++---- .../extensions/remote-questions/manager.ts | 171 ++++++ .../remote-questions/remote-command.ts | 549 +++++------------- .../remote-questions/slack-adapter.ts | 130 +---- .../extensions/remote-questions/status.ts | 23 + .../extensions/remote-questions/store.ts | 77 +++ .../extensions/remote-questions/types.ts | 75 +++ 14 files changed, 841 insertions(+), 797 deletions(-) create mode 100644 src/resources/extensions/gsd/tests/remote-questions.test.ts create mode 100644 src/resources/extensions/gsd/tests/remote-status.test.ts create mode 100644 src/resources/extensions/remote-questions/manager.ts create mode 100644 src/resources/extensions/remote-questions/status.ts create mode 100644 src/resources/extensions/remote-questions/store.ts create mode 100644 src/resources/extensions/remote-questions/types.ts diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 71e09704b..0f9d803e7 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -120,7 +120,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { } if (!ctx.hasUI) { - const { tryRemoteQuestions } = await import("./remote-questions/send.js"); + const { tryRemoteQuestions } = await import("./remote-questions/manager.js"); const remoteResult = await tryRemoteQuestions(params.questions, signal); if (remoteResult) return remoteResult; return errorResult("Error: UI not available (non-interactive mode)", params.questions); @@ -168,7 +168,7 @@ export default function AskUserQuestions(pi: ExtensionAPI) { }, renderResult(result, _options, theme) { - const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string }) | undefined; + const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string; promptId?: string; status?: string }) | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index 9d6376b5f..e0e491d17 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -85,10 +85,15 @@ export default function (pi: ExtensionAPI) { // Notify remote questions status if configured try { - const { getRemoteConfigStatus } = await import("../remote-questions/config.js"); + const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([ + import("../remote-questions/config.js"), + import("../remote-questions/status.js"), + ]); const status = getRemoteConfigStatus(); + const latest = getLatestPromptSummary(); if (!status.includes("not configured")) { - ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info"); + const suffix = latest ? `\nLast remote prompt: ${latest.id} (${latest.status})` : ""; + ctx.ui.notify(`${status}${suffix}`, status.includes("disabled") ? "warning" : "info"); } } catch { // Remote questions module not available — ignore diff --git a/src/resources/extensions/gsd/preferences.ts b/src/resources/extensions/gsd/preferences.ts index a84fbceb7..30b567e75 100644 --- a/src/resources/extensions/gsd/preferences.ts +++ b/src/resources/extensions/gsd/preferences.ts @@ -33,9 +33,9 @@ export interface AutoSupervisorConfig { export interface RemoteQuestionsConfig { channel: "slack" | "discord"; - channel_id: string; - timeout_minutes?: number; // Default: 5 - poll_interval_seconds?: number; // Default: 5 + channel_id: string | number; + timeout_minutes?: number; // clamped to 1-30 + poll_interval_seconds?: number; // clamped to 2-30 } export interface GSDPreferences { diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts new file mode 100644 index 000000000..f409224ce --- /dev/null +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -0,0 +1,107 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; +import { resolveRemoteConfig } from "../../remote-questions/config.ts"; + +const originalEnv = { ...process.env }; + +test("parseSlackReply handles single-number single-question answers", () => { + const result = parseSlackReply("2", [{ + id: "choice", + header: "Choice", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }]); + + assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } }); +}); + +test("parseSlackReply handles multiline multi-question answers", () => { + const result = parseSlackReply("1\ncustom note", [ + { + id: "first", + header: "First", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }, + { + id: "second", + header: "Second", + question: "Explain", + allowMultiple: false, + options: [ + { label: "Gamma", description: "G" }, + { label: "Delta", description: "D" }, + ], + }, + ]); + + assert.deepEqual(result, { + answers: { + first: { answers: ["Alpha"] }, + second: { answers: [], user_note: "custom note" }, + }, + }); +}); + +test("parseDiscordResponse handles single-question reactions", () => { + const result = parseDiscordResponse([{ emoji: "2️⃣", count: 1 }], null, [{ + id: "choice", + header: "Choice", + question: "Pick one", + allowMultiple: false, + options: [ + { label: "Alpha", description: "A" }, + { label: "Beta", description: "B" }, + ], + }]); + + assert.deepEqual(result, { answers: { choice: { answers: ["Beta"] } } }); +}); + +test("parseDiscordResponse rejects multi-question reaction parsing", () => { + const result = parseDiscordResponse([{ emoji: "1️⃣", count: 1 }], null, [ + { + id: "first", + header: "First", + question: "Pick one", + allowMultiple: false, + options: [{ label: "Alpha", description: "A" }], + }, + { + id: "second", + header: "Second", + question: "Pick one", + allowMultiple: false, + options: [{ label: "Beta", description: "B" }], + }, + ]); + + assert.match(String(result.answers.first.user_note), /single-question prompts/i); + assert.match(String(result.answers.second.user_note), /single-question prompts/i); +}); + +test("resolveRemoteConfig clamps invalid timeout and poll interval values", async () => { + process.env.SLACK_BOT_TOKEN = "token"; + const home = process.env.HOME!; + const fs = await import("node:fs"); + const path = await import("node:path"); + const prefsPath = path.join(home, ".gsd", "preferences.md"); + fs.mkdirSync(path.dirname(prefsPath), { recursive: true }); + fs.writeFileSync(prefsPath, `---\nremote_questions:\n channel: slack\n channel_id: \"C123\"\n timeout_minutes: 999\n poll_interval_seconds: 0\n---\n`, "utf-8"); + + const config = resolveRemoteConfig(); + assert.ok(config); + assert.equal(config?.timeoutMs, 30 * 60 * 1000); + assert.equal(config?.pollIntervalMs, 2 * 1000); + + process.env = { ...originalEnv }; +}); diff --git a/src/resources/extensions/gsd/tests/remote-status.test.ts b/src/resources/extensions/gsd/tests/remote-status.test.ts new file mode 100644 index 000000000..4ca3ff0ce --- /dev/null +++ b/src/resources/extensions/gsd/tests/remote-status.test.ts @@ -0,0 +1,44 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createPromptRecord, writePromptRecord } from "../../remote-questions/store.ts"; +import { getLatestPromptSummary } from "../../remote-questions/status.ts"; + +test("getLatestPromptSummary returns latest stored prompt", async () => { + const home = process.env.HOME!; + const tempHome = join(tmpdir(), `gsd-remote-status-${Date.now()}`); + mkdirSync(join(tempHome, ".gsd", "runtime", "remote-questions"), { recursive: true }); + process.env.HOME = tempHome; + + const recordA = createPromptRecord({ + id: "a-prompt", + channel: "slack", + createdAt: 1, + timeoutAt: 10, + pollIntervalMs: 5000, + questions: [], + }); + recordA.updatedAt = 1; + writePromptRecord(recordA); + + const recordB = createPromptRecord({ + id: "z-prompt", + channel: "discord", + createdAt: 2, + timeoutAt: 10, + pollIntervalMs: 5000, + questions: [], + }); + recordB.updatedAt = 2; + recordB.status = "answered"; + writePromptRecord(recordB); + + const latest = getLatestPromptSummary(); + assert.equal(latest?.id, "z-prompt"); + assert.equal(latest?.status, "answered"); + + process.env.HOME = home; + rmSync(tempHome, { recursive: true, force: true }); +}); diff --git a/src/resources/extensions/remote-questions/config.ts b/src/resources/extensions/remote-questions/config.ts index 9c92f0fba..7fe7b7d2c 100644 --- a/src/resources/extensions/remote-questions/config.ts +++ b/src/resources/extensions/remote-questions/config.ts @@ -1,78 +1,66 @@ /** - * Remote Questions — Configuration resolution - * - * Reads remote_questions config from GSD preferences and verifies - * the corresponding token exists in process.env. + * Remote Questions — configuration resolution and validation */ import { loadEffectiveGSDPreferences, type RemoteQuestionsConfig } from "../gsd/preferences.js"; +import type { RemoteChannel } from "./types.js"; export interface ResolvedConfig { - channel: "slack" | "discord"; + channel: RemoteChannel; channelId: string; timeoutMs: number; pollIntervalMs: number; token: string; } -const ENV_KEYS: Record = { +const ENV_KEYS: Record = { slack: "SLACK_BOT_TOKEN", discord: "DISCORD_BOT_TOKEN", }; const DEFAULT_TIMEOUT_MINUTES = 5; const DEFAULT_POLL_INTERVAL_SECONDS = 5; +const MIN_TIMEOUT_MINUTES = 1; +const MAX_TIMEOUT_MINUTES = 30; +const MIN_POLL_INTERVAL_SECONDS = 2; +const MAX_POLL_INTERVAL_SECONDS = 30; -/** - * Resolve remote questions configuration from preferences + env. - * Returns null if not configured or token is missing. - */ export function resolveRemoteConfig(): ResolvedConfig | null { const prefs = loadEffectiveGSDPreferences(); const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; if (!rq || !rq.channel || !rq.channel_id) return null; + if (rq.channel !== "slack" && rq.channel !== "discord") return null; - const envVar = ENV_KEYS[rq.channel]; - if (!envVar) return null; - - const token = process.env[envVar]; + const token = process.env[ENV_KEYS[rq.channel]]; if (!token) return null; - const timeoutMinutes = rq.timeout_minutes ?? DEFAULT_TIMEOUT_MINUTES; - const pollIntervalSeconds = rq.poll_interval_seconds ?? DEFAULT_POLL_INTERVAL_SECONDS; - - // Always coerce channel_id to string — parseScalar may convert large numeric - // Discord IDs to a lossy Number (exceeds Number.MAX_SAFE_INTEGER). - const channelId = String(rq.channel_id); + const timeoutMinutes = clampNumber(rq.timeout_minutes, DEFAULT_TIMEOUT_MINUTES, MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES); + const pollIntervalSeconds = clampNumber(rq.poll_interval_seconds, DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS); return { channel: rq.channel, - channelId, + channelId: String(rq.channel_id), timeoutMs: timeoutMinutes * 60 * 1000, pollIntervalMs: pollIntervalSeconds * 1000, token, }; } -/** - * Return a human-readable status string for the remote questions config. - * Used by session_start notification and /gsd remote status. - */ export function getRemoteConfigStatus(): string { const prefs = loadEffectiveGSDPreferences(); const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; - - if (!rq || !rq.channel || !rq.channel_id) { - return "Remote questions: not configured"; - } - + if (!rq || !rq.channel || !rq.channel_id) return "Remote questions: not configured"; + if (rq.channel !== "slack" && rq.channel !== "discord") return `Remote questions: unknown channel type \"${rq.channel}\"`; const envVar = ENV_KEYS[rq.channel]; - if (!envVar) return `Remote questions: unknown channel type "${rq.channel}"`; + if (!process.env[envVar]) return `Remote questions: ${envVar} not set — remote questions disabled`; - const token = process.env[envVar]; - if (!token) { - return `Remote questions: ${envVar} not set — remote questions disabled`; - } - - return `Remote questions: ${rq.channel} (channel ${rq.channel_id}) configured`; + const timeoutMinutes = clampNumber(rq.timeout_minutes, DEFAULT_TIMEOUT_MINUTES, MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES); + const pollIntervalSeconds = clampNumber(rq.poll_interval_seconds, DEFAULT_POLL_INTERVAL_SECONDS, MIN_POLL_INTERVAL_SECONDS, MAX_POLL_INTERVAL_SECONDS); + return `Remote questions: ${rq.channel} configured (timeout ${timeoutMinutes}m, poll ${pollIntervalSeconds}s)`; +} + +function clampNumber(value: unknown, fallback: number, min: number, max: number): number { + const n = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(n)) return fallback; + return Math.max(min, Math.min(max, n)); } diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index 97e145a00..e477af65a 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -1,24 +1,15 @@ /** * Remote Questions — Discord adapter - * - * Uses Discord Bot HTTP API (no gateway/websocket): - * - Send: POST /channels/{id}/messages with embed - * - Poll: GET reactions + GET messages after the sent message */ -import type { - ChannelAdapter, - FormattedQuestion, - PollReference, - RemoteAnswer, - SendResult, -} from "./channels.js"; +import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js"; import { formatForDiscord, parseDiscordResponse } from "./format.js"; const DISCORD_API = "https://discord.com/api/v10"; +const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; export class DiscordAdapter implements ChannelAdapter { - readonly name = "discord"; + readonly name = "discord" as const; private botUserId: string | null = null; private readonly token: string; private readonly channelId: string; @@ -30,161 +21,99 @@ export class DiscordAdapter implements ChannelAdapter { async validate(): Promise { const res = await this.discordApi("GET", "/users/@me"); - if (!res.id) { - throw new Error("Discord auth failed: invalid token"); - } - this.botUserId = res.id as string; + if (!res.id) throw new Error("Discord auth failed: invalid token"); + this.botUserId = String(res.id); } - async sendQuestions(questions: FormattedQuestion[]): Promise { - const { embeds, reactionEmojis } = formatForDiscord(questions); - + async sendPrompt(prompt: RemotePrompt): Promise { + const { embeds, reactionEmojis } = formatForDiscord(prompt); const res = await this.discordApi("POST", `/channels/${this.channelId}/messages`, { - content: "**GSD needs your input** — reply to this message or react with your choice", + content: "**GSD needs your input** — reply to this message with your answer", embeds, }); - if (!res.id) { - throw new Error(`Discord send failed: ${JSON.stringify(res)}`); - } + if (!res.id) throw new Error(`Discord send failed: ${JSON.stringify(res)}`); - const messageId = res.id as string; - - // Add reaction emojis as templates (best-effort, don't block on failure) - for (const emoji of reactionEmojis) { - try { - await this.discordApi( - "PUT", - `/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`, - ); - } catch { - // Non-critical — continue + const messageId = String(res.id); + if (prompt.questions.length === 1) { + for (const emoji of reactionEmojis) { + try { + await this.discordApi("PUT", `/channels/${this.channelId}/messages/${messageId}/reactions/${encodeURIComponent(emoji)}/@me`); + } catch { + // Best-effort only + } } } return { ref: { - channelType: "discord", + id: prompt.id, + channel: "discord", messageId, channelId: this.channelId, }, }; } - async pollResponse(ref: PollReference): Promise { - return this.pollResponseWithQuestions(ref, []); - } + async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + if (!this.botUserId) await this.validate(); - /** - * Poll with full question context for proper parsing. - */ - async pollResponseWithQuestions( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - if (!this.botUserId) { - const me = await this.discordApi("GET", "/users/@me"); - if (me.id) this.botUserId = me.id as string; + if (prompt.questions.length === 1) { + const reactionAnswer = await this.checkReactions(prompt, ref); + if (reactionAnswer) return reactionAnswer; } - // Strategy 1: Check reactions on the original message - const reactionAnswer = await this.checkReactions(ref, questions); - if (reactionAnswer) return reactionAnswer; - - // Strategy 2: Check for text replies after the message - const replyAnswer = await this.checkReplies(ref, questions); - if (replyAnswer) return replyAnswer; - - return null; + return this.checkReplies(prompt, ref); } - private async checkReactions( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - const numberEmojis = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; + private async checkReactions(prompt: RemotePrompt, ref: RemotePromptRef): Promise { const reactions: Array<{ emoji: string; count: number }> = []; - - for (const emoji of numberEmojis) { + for (const emoji of NUMBER_EMOJIS) { try { - const users = await this.discordApi( - "GET", - `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`, - ); - + const users = await this.discordApi("GET", `/channels/${ref.channelId}/messages/${ref.messageId}/reactions/${encodeURIComponent(emoji)}`); if (Array.isArray(users)) { - // Filter out bot's own reactions - const humanUsers = users.filter( - (u: { id: string }) => u.id !== this.botUserId, - ); - if (humanUsers.length > 0) { - reactions.push({ emoji, count: humanUsers.length }); - } + const humanUsers = users.filter((u: { id: string }) => u.id !== this.botUserId); + if (humanUsers.length > 0) reactions.push({ emoji, count: humanUsers.length }); } } catch { - // Reaction not present or no access + // ignore missing reaction } } if (reactions.length === 0) return null; - - return parseDiscordResponse(reactions, null, questions); + return parseDiscordResponse(reactions, null, prompt.questions); } - private async checkReplies( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - const messages = await this.discordApi( - "GET", - `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`, - ); - + private async checkReplies(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + const messages = await this.discordApi("GET", `/channels/${ref.channelId}/messages?after=${ref.messageId}&limit=10`); if (!Array.isArray(messages)) return null; - // Only accept replies that explicitly reference our message via Discord's reply feature const replies = messages.filter( - (m: { author: { id: string }; message_reference?: { message_id: string }; content: string }) => + (m: { author?: { id?: string }; message_reference?: { message_id?: string }; content?: string }) => + m.author?.id && m.author.id !== this.botUserId && - m.message_reference?.message_id === ref.messageId, + m.message_reference?.message_id === ref.messageId && + m.content, ); if (replies.length === 0) return null; - - const firstReply = replies[0] as { content: string }; - return parseDiscordResponse([], firstReply.content, questions); + return parseDiscordResponse([], String(replies[0].content), prompt.questions); } - // ─── Internal ────────────────────────────────────────────────────────────── - - private async discordApi( - method: string, - path: string, - body?: unknown, - ): Promise> { - const url = `${DISCORD_API}${path}`; - - const headers: Record = { - Authorization: `Bot ${this.token}`, - }; - + private async discordApi(method: string, path: string, body?: unknown): Promise { + const headers: Record = { Authorization: `Bot ${this.token}` }; const init: RequestInit = { method, headers }; - if (body) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } - const response = await fetch(url, init); - - // For reaction PUT, 204 No Content is success + const response = await fetch(`${DISCORD_API}${path}`, init); if (response.status === 204) return {}; - if (!response.ok) { const text = await response.text().catch(() => ""); throw new Error(`Discord API HTTP ${response.status}: ${text}`); } - - return (await response.json()) as Record; + return response.json(); } } diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts index 348992c1d..dd01039b8 100644 --- a/src/resources/extensions/remote-questions/format.ts +++ b/src/resources/extensions/remote-questions/format.ts @@ -1,13 +1,8 @@ /** - * Remote Questions — Payload formatting for Slack and Discord - * - * Converts Question[] to channel-specific payloads and parses replies - * back into RemoteAnswer objects. + * Remote Questions — payload formatting and parsing helpers */ -import type { FormattedQuestion, RemoteAnswer } from "./channels.js"; - -// ─── Slack Block Kit ───────────────────────────────────────────────────────── +import type { RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js"; export interface SlackBlock { type: string; @@ -15,57 +10,6 @@ export interface SlackBlock { elements?: Array<{ type: string; text: string }>; } -/** - * Format questions as Slack Block Kit blocks for chat.postMessage. - */ -export function formatForSlack(questions: FormattedQuestion[]): SlackBlock[] { - const blocks: SlackBlock[] = [ - { - type: "header", - text: { type: "plain_text", text: "GSD needs your input" }, - }, - ]; - - for (const q of questions) { - // Question header + text - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: `*${q.header}*\n${q.question}`, - }, - }); - - // Numbered options - const optionLines = q.options.map( - (opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`, - ); - blocks.push({ - type: "section", - text: { - type: "mrkdwn", - text: optionLines.join("\n"), - }, - }); - - // Instructions - const instruction = q.allowMultiple - ? `Reply in this thread with numbers separated by comma (e.g. \`1,3\`) or type a custom answer.` - : `Reply in this thread with the number of your choice (e.g. \`1\`) or type a custom answer.`; - - blocks.push({ - type: "context", - elements: [{ type: "mrkdwn", text: instruction }], - }); - - blocks.push({ type: "divider" }); - } - - return blocks; -} - -// ─── Discord Embed ─────────────────────────────────────────────────────────── - export interface DiscordEmbed { title: string; description: string; @@ -74,130 +18,130 @@ export interface DiscordEmbed { footer?: { text: string }; } -const NUMBER_EMOJIS = ["1\ufe0f\u20e3", "2\ufe0f\u20e3", "3\ufe0f\u20e3", "4\ufe0f\u20e3", "5\ufe0f\u20e3"]; +const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; -/** - * Format questions as a Discord embed for channel message. - */ -export function formatForDiscord(questions: FormattedQuestion[]): { embeds: DiscordEmbed[]; reactionEmojis: string[] } { - const allEmojis: string[] = []; - const embeds: DiscordEmbed[] = []; +export function formatForSlack(prompt: RemotePrompt): SlackBlock[] { + const blocks: SlackBlock[] = [ + { + type: "header", + text: { type: "plain_text", text: "GSD needs your input" }, + }, + ]; - for (const q of questions) { + for (const q of prompt.questions) { + blocks.push({ + type: "section", + text: { type: "mrkdwn", text: `*${q.header}*\n${q.question}` }, + }); + + blocks.push({ + type: "section", + text: { + type: "mrkdwn", + text: q.options.map((opt, i) => `${i + 1}. *${opt.label}* — ${opt.description}`).join("\n"), + }, + }); + + blocks.push({ + type: "context", + elements: [{ + type: "mrkdwn", + text: q.allowMultiple + ? "Reply in thread with comma-separated numbers (`1,3`) or free text." + : "Reply in thread with a number (`1`) or free text.", + }], + }); + + blocks.push({ type: "divider" }); + } + + return blocks; +} + +export function formatForDiscord(prompt: RemotePrompt): { embeds: DiscordEmbed[]; reactionEmojis: string[] } { + const reactionEmojis: string[] = []; + const embeds: DiscordEmbed[] = prompt.questions.map((q, questionIndex) => { + const supportsReactions = prompt.questions.length === 1; const optionLines = q.options.map((opt, i) => { const emoji = NUMBER_EMOJIS[i] ?? `${i + 1}.`; - allEmojis.push(NUMBER_EMOJIS[i] ?? ""); + if (supportsReactions && NUMBER_EMOJIS[i]) reactionEmojis.push(NUMBER_EMOJIS[i]); return `${emoji} **${opt.label}** — ${opt.description}`; }); - const instruction = q.allowMultiple - ? "React with numbers or reply with comma-separated choices (e.g. `1,3`)" - : "React with a number or reply with your choice"; + const footerText = supportsReactions + ? (q.allowMultiple + ? "Reply with comma-separated choices (`1,3`) or react with matching numbers" + : "Reply with a number or react with the matching number") + : `Question ${questionIndex + 1}/${prompt.questions.length} — reply with one line per question or use semicolons`; - embeds.push({ - title: `${q.header}`, + return { + title: q.header, description: q.question, - color: 0x7c3aed, // Purple accent - fields: [ - { name: "Options", value: optionLines.join("\n") }, - ], - footer: { text: instruction }, - }); - } + color: 0x7c3aed, + fields: [{ name: "Options", value: optionLines.join("\n") }], + footer: { text: footerText }, + }; + }); - return { embeds, reactionEmojis: allEmojis.filter(Boolean) }; + return { embeds, reactionEmojis }; } -// ─── Reply Parsing ─────────────────────────────────────────────────────────── - -/** - * Parse a Slack thread reply into a RemoteAnswer. - * Supports: single number, comma-separated numbers, or free text. - */ -export function parseSlackReply(text: string, questions: FormattedQuestion[]): RemoteAnswer { +export function parseSlackReply(text: string, questions: RemoteQuestion[]): RemoteAnswer { const answers: RemoteAnswer["answers"] = {}; const trimmed = text.trim(); - // For single-question scenarios, map the reply directly if (questions.length === 1) { - const q = questions[0]; - answers[q.id] = parseAnswerForQuestion(trimmed, q); + answers[questions[0].id] = parseAnswerForQuestion(trimmed, questions[0]); return { answers }; } - // Multi-question: try to split by lines or semicolons const parts = trimmed.includes(";") - ? trimmed.split(";").map((s) => s.trim()) + ? trimmed.split(";").map((s) => s.trim()).filter(Boolean) : trimmed.split("\n").map((s) => s.trim()).filter(Boolean); for (let i = 0; i < questions.length; i++) { - const q = questions[i]; - const part = parts[i] ?? ""; - answers[q.id] = parseAnswerForQuestion(part, q); + answers[questions[i].id] = parseAnswerForQuestion(parts[i] ?? "", questions[i]); } return { answers }; } -/** - * Parse a Discord reaction or reply into a RemoteAnswer. - */ export function parseDiscordResponse( reactions: Array<{ emoji: string; count: number }>, replyText: string | null, - questions: FormattedQuestion[], + questions: RemoteQuestion[], ): RemoteAnswer { - // Prefer text reply if present - if (replyText) { - return parseSlackReply(replyText, questions); - } + if (replyText) return parseSlackReply(replyText, questions); - // Fall back to reactions const answers: RemoteAnswer["answers"] = {}; - - if (questions.length === 1) { - const q = questions[0]; - const picked = reactions - .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0) - .map((r) => { - const idx = NUMBER_EMOJIS.indexOf(r.emoji); - return q.options[idx]?.label; - }) - .filter(Boolean) as string[]; - - if (picked.length > 0) { - answers[q.id] = { answers: picked }; - } else { - answers[q.id] = { answers: [], user_note: "No clear response via reactions" }; + if (questions.length !== 1) { + for (const q of questions) { + answers[q.id] = { answers: [], user_note: "Discord reactions are only supported for single-question prompts" }; } return { answers }; } - // Multi-question with reactions: map first N emojis to first question - for (const q of questions) { - answers[q.id] = { answers: [], user_note: "Reaction-based multi-question not supported — use text reply" }; - } + const q = questions[0]; + const picked = reactions + .filter((r) => NUMBER_EMOJIS.includes(r.emoji) && r.count > 0) + .map((r) => q.options[NUMBER_EMOJIS.indexOf(r.emoji)]?.label) + .filter(Boolean) as string[]; + + answers[q.id] = picked.length > 0 + ? { answers: q.allowMultiple ? picked : [picked[0]] } + : { answers: [], user_note: "No clear response via reactions" }; return { answers }; } -// ─── Internal helpers ──────────────────────────────────────────────────────── +function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: string[]; user_note?: string } { + if (!text) return { answers: [], user_note: "No response provided" }; -function parseAnswerForQuestion( - text: string, - q: FormattedQuestion, -): { answers: string[]; user_note?: string } { - if (!text) { - return { answers: [], user_note: "No response provided" }; - } - - // Check for comma-separated numbers: "1,3" or "1, 3" - const numberPattern = /^[\d,\s]+$/; - if (numberPattern.test(text)) { + if (/^[\d,\s]+$/.test(text)) { const nums = text .split(",") .map((s) => parseInt(s.trim(), 10)) - .filter((n) => !isNaN(n) && n >= 1 && n <= q.options.length); + .filter((n) => !Number.isNaN(n) && n >= 1 && n <= q.options.length); if (nums.length > 0) { const selected = nums.map((n) => q.options[n - 1].label); @@ -205,12 +149,10 @@ function parseAnswerForQuestion( } } - // Single number - const singleNum = parseInt(text, 10); - if (!isNaN(singleNum) && singleNum >= 1 && singleNum <= q.options.length) { - return { answers: [q.options[singleNum - 1].label] }; + const single = parseInt(text, 10); + if (!Number.isNaN(single) && single >= 1 && single <= q.options.length) { + return { answers: [q.options[single - 1].label] }; } - // Free text response return { answers: [], user_note: text }; } diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts new file mode 100644 index 000000000..9baabbd58 --- /dev/null +++ b/src/resources/extensions/remote-questions/manager.ts @@ -0,0 +1,171 @@ +/** + * Remote Questions — orchestration manager + */ + +import { randomUUID } from "node:crypto"; +import type { ChannelAdapter, RemotePrompt, RemoteQuestion, RemoteAnswer } from "./types.js"; +import { resolveRemoteConfig, type ResolvedConfig } from "./config.js"; +import { SlackAdapter } from "./slack-adapter.js"; +import { DiscordAdapter } from "./discord-adapter.js"; +import { createPromptRecord, writePromptRecord, markPromptAnswered, markPromptDispatched, markPromptStatus, updatePromptRecord } from "./store.js"; + +interface ToolResult { + content: Array<{ type: "text"; text: string }>; + details?: Record; +} + +interface QuestionInput { + id: string; + header: string; + question: string; + options: Array<{ label: string; description: string }>; + allowMultiple?: boolean; +} + +export async function tryRemoteQuestions( + questions: QuestionInput[], + signal?: AbortSignal, +): Promise { + const config = resolveRemoteConfig(); + if (!config) return null; + + const prompt = createPrompt(questions, config); + writePromptRecord(createPromptRecord(prompt)); + + const adapter = createAdapter(config); + try { + await adapter.validate(); + } catch (err) { + markPromptStatus(prompt.id, "failed", String((err as Error).message)); + return errorResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`, config.channel); + } + + let dispatch; + try { + dispatch = await adapter.sendPrompt(prompt); + markPromptDispatched(prompt.id, dispatch.ref); + } catch (err) { + markPromptStatus(prompt.id, "failed", String((err as Error).message)); + return errorResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`, config.channel); + } + + const answer = await pollUntilDone(adapter, prompt, dispatch.ref, signal); + if (!answer) { + markPromptStatus(prompt.id, signal?.aborted ? "cancelled" : "timed_out"); + return { + content: [{ + type: "text", + text: JSON.stringify({ + timed_out: true, + channel: config.channel, + prompt_id: prompt.id, + timeout_minutes: config.timeoutMs / 60000, + thread_url: dispatch.ref.threadUrl ?? null, + message: `User did not respond within ${config.timeoutMs / 60000} minutes.`, + }), + }], + details: { + remote: true, + channel: config.channel, + timed_out: true, + promptId: prompt.id, + threadUrl: dispatch.ref.threadUrl, + status: signal?.aborted ? "cancelled" : "timed_out", + }, + }; + } + + markPromptAnswered(prompt.id, answer); + return { + content: [{ type: "text", text: JSON.stringify({ answers: formatForTool(answer) }) }], + details: { + remote: true, + channel: config.channel, + timed_out: false, + promptId: prompt.id, + threadUrl: dispatch.ref.threadUrl, + questions, + response: answer, + status: "answered", + }, + }; +} + +function createPrompt(questions: QuestionInput[], config: ResolvedConfig): RemotePrompt { + const createdAt = Date.now(); + return { + id: randomUUID(), + channel: config.channel, + createdAt, + timeoutAt: createdAt + config.timeoutMs, + pollIntervalMs: config.pollIntervalMs, + context: { source: "ask_user_questions" }, + questions: questions.map((q): RemoteQuestion => ({ + id: q.id, + header: q.header, + question: q.question, + options: q.options, + allowMultiple: q.allowMultiple ?? false, + })), + }; +} + +function createAdapter(config: ResolvedConfig): ChannelAdapter { + return config.channel === "slack" + ? new SlackAdapter(config.token, config.channelId) + : new DiscordAdapter(config.token, config.channelId); +} + +async function pollUntilDone( + adapter: ChannelAdapter, + prompt: RemotePrompt, + ref: import("./types.js").RemotePromptRef, + signal?: AbortSignal, +): Promise { + while (Date.now() < prompt.timeoutAt && !signal?.aborted) { + try { + const answer = await adapter.pollAnswer(prompt, ref); + updatePromptRecord(prompt.id, { lastPollAt: Date.now() }); + if (answer) return answer; + } catch (err) { + markPromptStatus(prompt.id, "failed", String((err as Error).message)); + return null; + } + + await sleep(prompt.pollIntervalMs, signal); + } + + return null; +} + +function sleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve) => { + if (signal?.aborted) return resolve(); + const timer = setTimeout(() => { + if (signal) signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function formatForTool(answer: RemoteAnswer): Record { + const out: Record = {}; + for (const [id, data] of Object.entries(answer.answers)) { + const list = [...data.answers]; + if (data.user_note) list.push(`user_note: ${data.user_note}`); + out[id] = { answers: list }; + } + return out; +} + +function errorResult(message: string, channel: string): ToolResult { + return { + content: [{ type: "text", text: message }], + details: { remote: true, channel, error: true, status: "failed" }, + }; +} diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts index 30fbd3702..be5796ff2 100644 --- a/src/resources/extensions/remote-questions/remote-command.ts +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -1,19 +1,15 @@ /** * Remote Questions — /gsd remote command - * - * Interactive wizard for configuring Slack/Discord as a remote question channel. - * Follows the patterns from wizard.ts and gsd/commands.ts. */ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; +import { AuthStorage } from "@mariozechner/pi-coding-agent"; import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui"; import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; -import { homedir } from "node:os"; -import { resolveRemoteConfig, getRemoteConfigStatus } from "./config.js"; -import { loadEffectiveGSDPreferences, getGlobalGSDPreferencesPath } from "../gsd/preferences.js"; - -// ─── Public ────────────────────────────────────────────────────────────────── +import { getGlobalGSDPreferencesPath, loadEffectiveGSDPreferences } from "../gsd/preferences.js"; +import { getRemoteConfigStatus, resolveRemoteConfig } from "./config.js"; +import { getLatestPromptSummary } from "./status.js"; export async function handleRemote( subcommand: string, @@ -22,272 +18,186 @@ export async function handleRemote( ): Promise { const trimmed = subcommand.trim(); - if (trimmed === "slack") { - await handleSetupSlack(ctx); - return; - } + if (trimmed === "slack") return handleSetupSlack(ctx); + if (trimmed === "discord") return handleSetupDiscord(ctx); + if (trimmed === "status") return handleRemoteStatus(ctx); + if (trimmed === "disconnect") return handleDisconnect(ctx); - if (trimmed === "discord") { - await handleSetupDiscord(ctx); - return; - } - - if (trimmed === "status") { - await handleRemoteStatus(ctx); - return; - } - - if (trimmed === "disconnect") { - await handleDisconnect(ctx); - return; - } - - // Default: show current status and guide - await handleRemoteMenu(ctx); + return handleRemoteMenu(ctx); } -// ─── Setup Slack ───────────────────────────────────────────────────────────── - async function handleSetupSlack(ctx: ExtensionCommandContext): Promise { - // Step 1: Collect token const token = await promptMaskedInput(ctx, "Slack Bot Token", "Paste your xoxb-... token"); - if (!token) { - ctx.ui.notify("Slack setup cancelled.", "info"); - return; - } + if (!token) return void ctx.ui.notify("Slack setup cancelled.", "info"); + if (!token.startsWith("xoxb-")) return void ctx.ui.notify("Invalid token format — Slack bot tokens start with xoxb-.", "warning"); - if (!token.startsWith("xoxb-")) { - ctx.ui.notify("Invalid token format — Slack bot tokens start with xoxb-. Setup cancelled.", "warning"); - return; - } - - // Step 2: Validate token ctx.ui.notify("Validating token...", "info"); - let botInfo: { ok: boolean; user?: string; team?: string; user_id?: string }; - try { - const res = await fetch("https://slack.com/api/auth.test", { - method: "GET", - headers: { Authorization: `Bearer ${token}` }, - }); - botInfo = (await res.json()) as typeof botInfo; - } catch (err) { - ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error"); - return; - } + const auth = await fetchJson("https://slack.com/api/auth.test", { headers: { Authorization: `Bearer ${token}` } }); + if (!auth?.ok) return void ctx.ui.notify("Token validation failed — check the token and app install.", "error"); - if (!botInfo.ok) { - ctx.ui.notify("Token validation failed — check that the token is correct and the app is installed.", "error"); - return; - } - - ctx.ui.notify(`Token valid — bot: ${botInfo.user}, workspace: ${botInfo.team}`, "info"); - - // Step 3: Collect channel ID const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)"); - if (!channelId) { - ctx.ui.notify("Slack setup cancelled.", "info"); - return; - } + if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info"); - // Step 4: Send test message - ctx.ui.notify("Sending test message...", "info"); - try { - const res = await fetch("https://slack.com/api/chat.postMessage", { - method: "POST", - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json; charset=utf-8", - }, - body: JSON.stringify({ - channel: channelId, - text: "GSD remote questions connected! This channel will receive questions during auto-mode.", - }), - }); - const result = (await res.json()) as { ok: boolean; error?: string }; - if (!result.ok) { - ctx.ui.notify(`Could not send to channel: ${result.error}. Make sure the bot is invited to the channel.`, "error"); - return; - } - } catch (err) { - ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error"); - return; - } + const send = await fetchJson("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json; charset=utf-8" }, + body: JSON.stringify({ channel: channelId, text: "GSD remote questions connected." }), + }); + if (!send?.ok) return void ctx.ui.notify(`Could not send to channel: ${send?.error ?? "unknown error"}`, "error"); - // Step 5: Save configuration - saveTokenToAuth("slack_bot", token); + saveProviderToken("slack_bot", token); process.env.SLACK_BOT_TOKEN = token; saveRemoteQuestionsConfig("slack", channelId); - - ctx.ui.notify(`Slack connected — questions will arrive in channel ${channelId} during /gsd auto`, "info"); + ctx.ui.notify(`Slack connected — remote questions enabled for channel ${channelId}.`, "info"); } -// ─── Setup Discord ─────────────────────────────────────────────────────────── - async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise { - // Step 1: Collect token const token = await promptMaskedInput(ctx, "Discord Bot Token", "Paste your bot token"); - if (!token) { - ctx.ui.notify("Discord setup cancelled.", "info"); - return; - } + if (!token) return void ctx.ui.notify("Discord setup cancelled.", "info"); - // Step 2: Validate token ctx.ui.notify("Validating token...", "info"); - let botInfo: { id?: string; username?: string }; - try { - const res = await fetch("https://discord.com/api/v10/users/@me", { - headers: { Authorization: `Bot ${token}` }, - }); - if (!res.ok) { - ctx.ui.notify(`Token validation failed (HTTP ${res.status}) — check that the token is correct.`, "error"); - return; - } - botInfo = (await res.json()) as typeof botInfo; - } catch (err) { - ctx.ui.notify(`Network error validating token: ${(err as Error).message}`, "error"); - return; - } + const auth = await fetchJson("https://discord.com/api/v10/users/@me", { headers: { Authorization: `Bot ${token}` } }); + if (!auth?.id) return void ctx.ui.notify("Token validation failed — check the bot token.", "error"); - ctx.ui.notify(`Token valid — bot: ${botInfo.username}`, "info"); - - // Step 3: Collect channel ID const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)"); - if (!channelId) { - ctx.ui.notify("Discord setup cancelled.", "info"); - return; + if (!channelId) return void ctx.ui.notify("Discord setup cancelled.", "info"); + + const sendResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { + method: "POST", + headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ content: "GSD remote questions connected." }), + }); + if (!sendResponse.ok) { + const body = await sendResponse.text().catch(() => ""); + return void ctx.ui.notify(`Could not send to channel (HTTP ${sendResponse.status}): ${body}`, "error"); } - // Step 4: Send test message - ctx.ui.notify("Sending test message...", "info"); - try { - const res = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { - method: "POST", - headers: { - Authorization: `Bot ${token}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ - content: "GSD remote questions connected! This channel will receive questions during auto-mode.", - }), - }); - if (!res.ok) { - const body = await res.text().catch(() => ""); - ctx.ui.notify(`Could not send to channel (HTTP ${res.status}): ${body}. Make sure the bot has access.`, "error"); - return; - } - } catch (err) { - ctx.ui.notify(`Network error sending test message: ${(err as Error).message}`, "error"); - return; - } - - // Step 5: Save configuration - saveTokenToAuth("discord_bot", token); + saveProviderToken("discord_bot", token); process.env.DISCORD_BOT_TOKEN = token; saveRemoteQuestionsConfig("discord", channelId); - - ctx.ui.notify(`Discord connected — questions will arrive in channel ${channelId} during /gsd auto`, "info"); + ctx.ui.notify(`Discord connected — remote questions enabled for channel ${channelId}.`, "info"); } -// ─── Status ────────────────────────────────────────────────────────────────── - async function handleRemoteStatus(ctx: ExtensionCommandContext): Promise { + const status = getRemoteConfigStatus(); const config = resolveRemoteConfig(); - if (!config) { - ctx.ui.notify(getRemoteConfigStatus(), "info"); + ctx.ui.notify(status, status.includes("disabled") ? "warning" : "info"); return; } - // Test the connection - ctx.ui.notify("Checking connection...", "info"); - - try { - if (config.channel === "slack") { - const res = await fetch("https://slack.com/api/auth.test", { - headers: { Authorization: `Bearer ${config.token}` }, - }); - const data = (await res.json()) as { ok: boolean; user?: string; team?: string }; - if (data.ok) { - ctx.ui.notify( - `Remote questions: Slack connected\n Bot: ${data.user}\n Workspace: ${data.team}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, - "info", - ); - } else { - ctx.ui.notify("Remote questions: Slack token invalid — run /gsd remote slack to reconfigure", "warning"); - } - } else if (config.channel === "discord") { - const res = await fetch("https://discord.com/api/v10/users/@me", { - headers: { Authorization: `Bot ${config.token}` }, - }); - if (res.ok) { - const data = (await res.json()) as { username?: string }; - ctx.ui.notify( - `Remote questions: Discord connected\n Bot: ${data.username}\n Channel: ${config.channelId}\n Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, - "info", - ); - } else { - ctx.ui.notify("Remote questions: Discord token invalid — run /gsd remote discord to reconfigure", "warning"); - } - } - } catch (err) { - ctx.ui.notify(`Remote questions: connection check failed — ${(err as Error).message}`, "error"); + const latestPrompt = getLatestPromptSummary(); + const lines = [status]; + if (latestPrompt) { + lines.push(`Last prompt: ${latestPrompt.id}`); + lines.push(` status: ${latestPrompt.status}`); + if (latestPrompt.updatedAt) lines.push(` updated: ${new Date(latestPrompt.updatedAt).toLocaleString()}`); } -} -// ─── Disconnect ────────────────────────────────────────────────────────────── + ctx.ui.notify(lines.join("\n"), "info"); +} async function handleDisconnect(ctx: ExtensionCommandContext): Promise { const prefs = loadEffectiveGSDPreferences(); - if (!prefs?.preferences.remote_questions) { - ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info"); - return; - } + const channel = prefs?.preferences.remote_questions?.channel; + if (!channel) return void ctx.ui.notify("No remote channel configured — nothing to disconnect.", "info"); - const channel = prefs.preferences.remote_questions.channel; - - // Remove from preferences file removeRemoteQuestionsConfig(); - - // Remove token from auth storage - const provider = channel === "slack" ? "slack_bot" : "discord_bot"; - removeTokenFromAuth(provider); - - // Clear env + removeProviderToken(channel === "slack" ? "slack_bot" : "discord_bot"); if (channel === "slack") delete process.env.SLACK_BOT_TOKEN; if (channel === "discord") delete process.env.DISCORD_BOT_TOKEN; - ctx.ui.notify(`Remote questions disconnected (${channel}).`, "info"); } -// ─── Menu ──────────────────────────────────────────────────────────────────── - async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise { const config = resolveRemoteConfig(); + const latestPrompt = getLatestPromptSummary(); + const lines = config + ? [ + `Remote questions: ${config.channel} configured`, + ` Timeout: ${config.timeoutMs / 60000}m, poll: ${config.pollIntervalMs / 1000}s`, + latestPrompt ? ` Last prompt: ${latestPrompt.id} (${latestPrompt.status})` : " No remote prompts recorded yet", + "", + "Commands:", + " /gsd remote status", + " /gsd remote disconnect", + " /gsd remote slack", + " /gsd remote discord", + ] + : [ + "No remote question channel configured.", + "", + "Commands:", + " /gsd remote slack", + " /gsd remote discord", + " /gsd remote status", + ]; - if (config) { - ctx.ui.notify( - `Remote questions: ${config.channel} (channel ${config.channelId})\n` + - ` Timeout: ${config.timeoutMs / 60000}m, poll interval: ${config.pollIntervalMs / 1000}s\n\n` + - `Commands:\n` + - ` /gsd remote status — test connection\n` + - ` /gsd remote disconnect — remove configuration\n` + - ` /gsd remote slack — reconfigure with Slack\n` + - ` /gsd remote discord — reconfigure with Discord`, - "info", - ); - } else { - ctx.ui.notify( - `No remote question channel configured.\n\n` + - `Commands:\n` + - ` /gsd remote slack — set up Slack bot\n` + - ` /gsd remote discord — set up Discord bot\n` + - ` /gsd remote status — check configuration`, - "info", - ); + ctx.ui.notify(lines.join("\n"), "info"); +} + +async function fetchJson(url: string, init?: RequestInit): Promise { + try { + const response = await fetch(url, init); + return await response.json(); + } catch { + return null; } } -// ─── Input helpers ─────────────────────────────────────────────────────────── +function getAuthStorage(): AuthStorage { + const authPath = join(process.env.HOME ?? "", ".gsd", "agent", "auth.json"); + mkdirSync(dirname(authPath), { recursive: true }); + return AuthStorage.create(authPath); +} + +function saveProviderToken(provider: string, token: string): void { + const auth = getAuthStorage(); + auth.set(provider, { type: "api_key", key: token }); +} + +function removeProviderToken(provider: string): void { + const auth = getAuthStorage(); + auth.set(provider, { type: "api_key", key: "" }); +} + +function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void { + const prefsPath = getGlobalGSDPreferencesPath(); + const block = [ + "remote_questions:", + ` channel: ${channel}`, + ` channel_id: \"${channelId}\"`, + " timeout_minutes: 5", + " poll_interval_seconds: 5", + ].join("\n"); + + const content = existsSync(prefsPath) ? readFileSync(prefsPath, "utf-8") : ""; + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + let next = content; + + if (fmMatch) { + let frontmatter = fmMatch[1]; + const regex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/; + frontmatter = regex.test(frontmatter) ? frontmatter.replace(regex, block) : `${frontmatter.trimEnd()}\n${block}`; + next = `---\n${frontmatter}\n---${content.slice(fmMatch[0].length)}`; + } else { + next = `---\n${block}\n---\n\n${content}`; + } + + mkdirSync(dirname(prefsPath), { recursive: true }); + writeFileSync(prefsPath, next, "utf-8"); +} + +function removeRemoteQuestionsConfig(): void { + const prefsPath = getGlobalGSDPreferencesPath(); + if (!existsSync(prefsPath)) return; + const content = readFileSync(prefsPath, "utf-8"); + const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + if (!fmMatch) return; + const frontmatter = fmMatch[1].replace(/remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/, "").trim(); + const next = frontmatter ? `---\n${frontmatter}\n---${content.slice(fmMatch[0].length)}` : content.slice(fmMatch[0].length).replace(/^\n+/, ""); + writeFileSync(prefsPath, next, "utf-8"); +} function maskEditorLine(line: string): string { let output = ""; @@ -304,20 +214,14 @@ function maskEditorLine(line: string): string { i += ansiMatch[0].length; continue; } - const ch = line[i] as string; - output += ch === " " ? " " : "*"; + output += line[i] === " " ? " " : "*"; i += 1; } return output; } -async function promptMaskedInput( - ctx: ExtensionCommandContext, - label: string, - hint: string, -): Promise { +async function promptMaskedInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { let cachedLines: string[] | undefined; const editorTheme: EditorTheme = { @@ -331,56 +235,34 @@ async function promptMaskedInput( }, }; const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - - function refresh() { - cachedLines = undefined; - tui.requestRender(); - } - - function handleInput(data: string): void { - if (matchesKey(data, Key.enter)) { - const value = editor.getText().trim(); - done(value.length > 0 ? value : null); - return; - } - if (matchesKey(data, Key.escape)) { - done(null); - return; - } - editor.handleInput(data); - refresh(); - } - - function render(width: number): string[] { + const refresh = () => { cachedLines = undefined; tui.requestRender(); }; + const handleInput = (data: string) => { + if (matchesKey(data, Key.enter)) return done(editor.getText().trim() || null); + if (matchesKey(data, Key.escape)) return done(null); + editor.handleInput(data); refresh(); + }; + const render = (width: number) => { if (cachedLines) return cachedLines; const lines: string[] = []; const add = (s: string) => lines.push(truncateToWidth(s, width)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", "─".repeat(width))); add(theme.fg("accent", theme.bold(` ${label}`))); add(theme.fg("muted", ` ${hint}`)); lines.push(""); add(theme.fg("muted", " Enter value:")); - for (const line of editor.render(width - 2)) { - add(theme.fg("text", maskEditorLine(line))); - } + for (const line of editor.render(width - 2)) add(theme.fg("text", maskEditorLine(line))); lines.push(""); - add(theme.fg("dim", ` enter to confirm | esc to cancel`)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("dim", " enter to confirm | esc to cancel")); + add(theme.fg("accent", "─".repeat(width))); cachedLines = lines; return lines; - } - + }; return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } -async function promptInput( - ctx: ExtensionCommandContext, - label: string, - hint: string, -): Promise { +async function promptInput(ctx: ExtensionCommandContext, label: string, hint: string): Promise { if (!ctx.hasUI) return null; - return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => { let cachedLines: string[] | undefined; const editorTheme: EditorTheme = { @@ -394,139 +276,28 @@ async function promptInput( }, }; const editor = new Editor(tui, editorTheme, { paddingX: 1 }); - - function refresh() { - cachedLines = undefined; - tui.requestRender(); - } - - function handleInput(data: string): void { - if (matchesKey(data, Key.enter)) { - const value = editor.getText().trim(); - done(value.length > 0 ? value : null); - return; - } - if (matchesKey(data, Key.escape)) { - done(null); - return; - } - editor.handleInput(data); - refresh(); - } - - function render(width: number): string[] { + const refresh = () => { cachedLines = undefined; tui.requestRender(); }; + const handleInput = (data: string) => { + if (matchesKey(data, Key.enter)) return done(editor.getText().trim() || null); + if (matchesKey(data, Key.escape)) return done(null); + editor.handleInput(data); refresh(); + }; + const render = (width: number) => { if (cachedLines) return cachedLines; const lines: string[] = []; const add = (s: string) => lines.push(truncateToWidth(s, width)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("accent", "─".repeat(width))); add(theme.fg("accent", theme.bold(` ${label}`))); add(theme.fg("muted", ` ${hint}`)); lines.push(""); add(theme.fg("muted", " Enter value:")); - for (const line of editor.render(width - 2)) { - add(theme.fg("text", line)); - } + for (const line of editor.render(width - 2)) add(theme.fg("text", line)); lines.push(""); - add(theme.fg("dim", ` enter to confirm | esc to cancel`)); - add(theme.fg("accent", "\u2500".repeat(width))); + add(theme.fg("dim", " enter to confirm | esc to cancel")); + add(theme.fg("accent", "─".repeat(width))); cachedLines = lines; return lines; - } - + }; return { render, handleInput, invalidate: () => { cachedLines = undefined; } }; }); } - -// ─── Persistence helpers ───────────────────────────────────────────────────── - -function getAuthFilePath(): string { - return join(homedir(), ".gsd", "agent", "auth.json"); -} - -function loadAuthJson(): Record { - const path = getAuthFilePath(); - if (!existsSync(path)) return {}; - try { - return JSON.parse(readFileSync(path, "utf-8")) as Record; - } catch { - return {}; - } -} - -function saveAuthJson(data: Record): void { - const path = getAuthFilePath(); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, JSON.stringify(data, null, 2), "utf-8"); -} - -function saveTokenToAuth(provider: string, token: string): void { - const auth = loadAuthJson(); - auth[provider] = { type: "api_key", key: token }; - saveAuthJson(auth); -} - -function removeTokenFromAuth(provider: string): void { - const auth = loadAuthJson(); - delete auth[provider]; - saveAuthJson(auth); -} - -function saveRemoteQuestionsConfig(channel: "slack" | "discord", channelId: string): void { - const prefsPath = getGlobalGSDPreferencesPath(); - let content = ""; - - if (existsSync(prefsPath)) { - content = readFileSync(prefsPath, "utf-8"); - } - - // Check if frontmatter exists - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); - - const remoteBlock = [ - `remote_questions:`, - ` channel: ${channel}`, - ` channel_id: "${channelId}"`, - ` timeout_minutes: 5`, - ` poll_interval_seconds: 5`, - ].join("\n"); - - if (fmMatch) { - // Replace existing remote_questions or append to frontmatter - let fm = fmMatch[1]; - const remoteRegex = /remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/; - if (remoteRegex.test(fm)) { - fm = fm.replace(remoteRegex, remoteBlock); - } else { - fm = fm.trimEnd() + "\n" + remoteBlock; - } - content = `---\n${fm}\n---` + content.slice(fmMatch[0].length); - } else { - // Create new frontmatter - content = `---\n${remoteBlock}\n---\n\n${content}`; - } - - mkdirSync(dirname(prefsPath), { recursive: true }); - writeFileSync(prefsPath, content, "utf-8"); -} - -function removeRemoteQuestionsConfig(): void { - const prefsPath = getGlobalGSDPreferencesPath(); - if (!existsSync(prefsPath)) return; - - let content = readFileSync(prefsPath, "utf-8"); - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); - if (!fmMatch) return; - - let fm = fmMatch[1]; - // Remove remote_questions block from frontmatter - fm = fm.replace(/remote_questions:[\s\S]*?(?=\n[a-zA-Z_]|\n---|$)/, "").trim(); - - if (fm) { - content = `---\n${fm}\n---` + content.slice(fmMatch[0].length); - } else { - // Frontmatter is now empty, remove it - content = content.slice(fmMatch[0].length).replace(/^\n+/, ""); - } - - writeFileSync(prefsPath, content, "utf-8"); -} diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts index 1f3beff17..6bc69b036 100644 --- a/src/resources/extensions/remote-questions/slack-adapter.ts +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -1,24 +1,14 @@ /** * Remote Questions — Slack adapter - * - * Uses Slack Bot Token API (xoxb-*) for bidirectional messaging: - * - Send: POST chat.postMessage with Block Kit - * - Poll: GET conversations.replies to read thread responses */ -import type { - ChannelAdapter, - FormattedQuestion, - PollReference, - RemoteAnswer, - SendResult, -} from "./channels.js"; +import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, RemotePromptRef } from "./types.js"; import { formatForSlack, parseSlackReply } from "./format.js"; const SLACK_API = "https://slack.com/api"; export class SlackAdapter implements ChannelAdapter { - readonly name = "slack"; + readonly name = "slack" as const; private botUserId: string | null = null; private readonly token: string; private readonly channelId: string; @@ -30,88 +20,35 @@ export class SlackAdapter implements ChannelAdapter { async validate(): Promise { const res = await this.slackApi("auth.test", {}); - if (!res.ok) { - throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`); - } - this.botUserId = res.user_id as string; + if (!res.ok) throw new Error(`Slack auth failed: ${res.error ?? "invalid token"}`); + this.botUserId = String(res.user_id ?? ""); } - async sendQuestions(questions: FormattedQuestion[]): Promise { - const blocks = formatForSlack(questions); - + async sendPrompt(prompt: RemotePrompt): Promise { const res = await this.slackApi("chat.postMessage", { channel: this.channelId, text: "GSD needs your input", - blocks, + blocks: formatForSlack(prompt), }); - if (!res.ok) { - throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`); - } - - const ts = res.ts as string; - const channel = res.channel as string; + if (!res.ok) throw new Error(`Slack postMessage failed: ${res.error ?? "unknown"}`); + const ts = String(res.ts); + const channel = String(res.channel); return { ref: { - channelType: "slack", + id: prompt.id, + channel: "slack", messageId: ts, threadTs: ts, channelId: channel, + threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`, }, - threadUrl: `https://slack.com/archives/${channel}/p${ts.replace(".", "")}`, }; } - async pollResponse(ref: PollReference): Promise { - // Ensure we know our bot user ID - if (!this.botUserId) { - const authRes = await this.slackApi("auth.test", {}); - if (authRes.ok) this.botUserId = authRes.user_id as string; - } - - const res = await this.slackApi("conversations.replies", { - channel: ref.channelId, - ts: ref.threadTs!, - limit: "20", - }); - - if (!res.ok) { - // Channel not found or no access — don't throw, just return null - return null; - } - - const messages = (res.messages ?? []) as Array<{ - user: string; - text: string; - ts: string; - }>; - - // Filter out the bot's own messages — only user replies count - const userReplies = messages.filter( - (m) => m.ts !== ref.threadTs && m.user !== this.botUserId, - ); - - if (userReplies.length === 0) return null; - - // Use the first user reply - const reply = userReplies[0]; - // We need the questions for parsing — store them on the ref isn't ideal, - // so the caller will need to pass them. For now, return raw text wrapped. - return { answers: { _raw: { answers: [reply.text] } } }; - } - - /** - * Poll with full question context for proper parsing. - */ - async pollResponseWithQuestions( - ref: PollReference, - questions: FormattedQuestion[], - ): Promise { - if (!this.botUserId) { - const authRes = await this.slackApi("auth.test", {}); - if (authRes.ok) this.botUserId = authRes.user_id as string; - } + async pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise { + if (!this.botUserId) await this.validate(); const res = await this.slackApi("conversations.replies", { channel: ref.channelId, @@ -121,43 +58,21 @@ export class SlackAdapter implements ChannelAdapter { if (!res.ok) return null; - const messages = (res.messages ?? []) as Array<{ - user: string; - text: string; - ts: string; - }>; - - const userReplies = messages.filter( - (m) => m.ts !== ref.threadTs && m.user !== this.botUserId, - ); - + const messages = (res.messages ?? []) as Array<{ user?: string; text?: string; ts: string }>; + const userReplies = messages.filter((m) => m.ts !== ref.threadTs && m.user && m.user !== this.botUserId && m.text); if (userReplies.length === 0) return null; - return parseSlackReply(userReplies[0].text, questions); + return parseSlackReply(String(userReplies[0].text), prompt.questions); } - // ─── Internal ────────────────────────────────────────────────────────────── - - private async slackApi( - method: string, - params: Record, - ): Promise> { + private async slackApi(method: string, params: Record): Promise> { const url = `${SLACK_API}/${method}`; - const isGet = method === "conversations.replies" || method === "auth.test"; let response: Response; if (isGet) { - // GET params must be strings for URLSearchParams - const stringParams: Record = {}; - for (const [k, v] of Object.entries(params)) { - stringParams[k] = String(v); - } - const qs = new URLSearchParams(stringParams).toString(); - response = await fetch(`${url}?${qs}`, { - method: "GET", - headers: { Authorization: `Bearer ${this.token}` }, - }); + const qs = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))).toString(); + response = await fetch(`${url}?${qs}`, { method: "GET", headers: { Authorization: `Bearer ${this.token}` } }); } else { response = await fetch(url, { method: "POST", @@ -169,10 +84,7 @@ export class SlackAdapter implements ChannelAdapter { }); } - if (!response.ok) { - throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`); - } - + if (!response.ok) throw new Error(`Slack API HTTP ${response.status}: ${response.statusText}`); return (await response.json()) as Record; } } diff --git a/src/resources/extensions/remote-questions/status.ts b/src/resources/extensions/remote-questions/status.ts new file mode 100644 index 000000000..e322aeaf8 --- /dev/null +++ b/src/resources/extensions/remote-questions/status.ts @@ -0,0 +1,23 @@ +/** + * Remote Questions — status helpers + */ + +import { existsSync, readdirSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import { readPromptRecord } from "./store.js"; + +export interface LatestPromptSummary { + id: string; + status: string; + updatedAt: number; +} + +export function getLatestPromptSummary(): LatestPromptSummary | null { + const runtimeDir = join(homedir(), ".gsd", "runtime", "remote-questions"); + if (!existsSync(runtimeDir)) return null; + const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json")).sort().reverse(); + if (files.length === 0) return null; + const record = readPromptRecord(files[0].replace(/\.json$/, "")); + return record ? { id: record.id, status: record.status, updatedAt: record.updatedAt } : null; +} diff --git a/src/resources/extensions/remote-questions/store.ts b/src/resources/extensions/remote-questions/store.ts new file mode 100644 index 000000000..226ac8996 --- /dev/null +++ b/src/resources/extensions/remote-questions/store.ts @@ -0,0 +1,77 @@ +/** + * Remote Questions — durable prompt store + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { RemotePrompt, RemotePromptRecord, RemotePromptRef, RemoteAnswer, RemotePromptStatus } from "./types.js"; + +function runtimeDir(): string { + return join(homedir(), ".gsd", "runtime", "remote-questions"); +} + +function recordPath(id: string): string { + return join(runtimeDir(), `${id}.json`); +} + +export function createPromptRecord(prompt: RemotePrompt): RemotePromptRecord { + return { + version: 1, + id: prompt.id, + createdAt: prompt.createdAt, + updatedAt: Date.now(), + status: "pending", + channel: prompt.channel, + timeoutAt: prompt.timeoutAt, + pollIntervalMs: prompt.pollIntervalMs, + questions: prompt.questions, + context: prompt.context, + }; +} + +export function writePromptRecord(record: RemotePromptRecord): void { + mkdirSync(runtimeDir(), { recursive: true }); + writeFileSync(recordPath(record.id), JSON.stringify(record, null, 2) + "\n", "utf-8"); +} + +export function readPromptRecord(id: string): RemotePromptRecord | null { + const path = recordPath(id); + if (!existsSync(path)) return null; + try { + return JSON.parse(readFileSync(path, "utf-8")) as RemotePromptRecord; + } catch { + return null; + } +} + +export function updatePromptRecord( + id: string, + updates: Partial, +): RemotePromptRecord | null { + const current = readPromptRecord(id); + if (!current) return null; + const next: RemotePromptRecord = { + ...current, + ...updates, + updatedAt: Date.now(), + }; + writePromptRecord(next); + return next; +} + +export function markPromptDispatched(id: string, ref: RemotePromptRef): RemotePromptRecord | null { + return updatePromptRecord(id, { ref, status: "pending" }); +} + +export function markPromptAnswered(id: string, response: RemoteAnswer): RemotePromptRecord | null { + return updatePromptRecord(id, { response, status: "answered", lastPollAt: Date.now() }); +} + +export function markPromptStatus(id: string, status: RemotePromptStatus, lastError?: string): RemotePromptRecord | null { + return updatePromptRecord(id, { + status, + lastPollAt: Date.now(), + ...(lastError ? { lastError } : {}), + }); +} diff --git a/src/resources/extensions/remote-questions/types.ts b/src/resources/extensions/remote-questions/types.ts new file mode 100644 index 000000000..b1237fdf7 --- /dev/null +++ b/src/resources/extensions/remote-questions/types.ts @@ -0,0 +1,75 @@ +/** + * Remote Questions — shared types + */ + +export type RemoteChannel = "slack" | "discord"; + +export interface RemoteQuestionOption { + label: string; + description: string; +} + +export interface RemoteQuestion { + id: string; + header: string; + question: string; + options: RemoteQuestionOption[]; + allowMultiple: boolean; +} + +export interface RemotePrompt { + id: string; + channel: RemoteChannel; + createdAt: number; + timeoutAt: number; + pollIntervalMs: number; + questions: RemoteQuestion[]; + context?: { + source: string; + }; +} + +export interface RemotePromptRef { + id: string; + channel: RemoteChannel; + messageId: string; + channelId: string; + threadTs?: string; + threadUrl?: string; +} + +export interface RemoteAnswer { + answers: Record; +} + +export type RemotePromptStatus = "pending" | "answered" | "timed_out" | "failed" | "cancelled"; + +export interface RemotePromptRecord { + version: 1; + id: string; + createdAt: number; + updatedAt: number; + status: RemotePromptStatus; + channel: RemoteChannel; + timeoutAt: number; + pollIntervalMs: number; + questions: RemoteQuestion[]; + ref?: RemotePromptRef; + response?: RemoteAnswer; + lastPollAt?: number; + lastError?: string; + context?: { + source: string; + }; +} + +export interface RemoteDispatchResult { + ref: RemotePromptRef; +} + +export interface ChannelAdapter { + readonly name: RemoteChannel; + validate(): Promise; + sendPrompt(prompt: RemotePrompt): Promise; + pollAnswer(prompt: RemotePrompt, ref: RemotePromptRef): Promise; +} From 8a00605e51146c16b18393efb263fbd8667d99e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 14:44:42 -0300 Subject: [PATCH 19/60] fix: sort prompt store by updatedAt instead of filename getLatestPromptSummary() sorted JSON filenames alphabetically to find the most recent prompt. Since filenames are UUIDs (random, not temporal), this returned arbitrary results. Now reads updatedAt from each record and picks the highest. Also fixes test isolation on Windows (USERPROFILE) and adds a regression test that fails with the old alphabetical sort. Co-Authored-By: Claude Opus 4.6 --- .../gsd/tests/remote-status.test.ts | 71 ++++++++++++++++--- .../extensions/remote-questions/status.ts | 14 +++- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/resources/extensions/gsd/tests/remote-status.test.ts b/src/resources/extensions/gsd/tests/remote-status.test.ts index 4ca3ff0ce..507c9cf35 100644 --- a/src/resources/extensions/gsd/tests/remote-status.test.ts +++ b/src/resources/extensions/gsd/tests/remote-status.test.ts @@ -6,12 +6,25 @@ import { tmpdir } from "node:os"; import { createPromptRecord, writePromptRecord } from "../../remote-questions/store.ts"; import { getLatestPromptSummary } from "../../remote-questions/status.ts"; -test("getLatestPromptSummary returns latest stored prompt", async () => { - const home = process.env.HOME!; - const tempHome = join(tmpdir(), `gsd-remote-status-${Date.now()}`); - mkdirSync(join(tempHome, ".gsd", "runtime", "remote-questions"), { recursive: true }); - process.env.HOME = tempHome; +function withTempHome(fn: (tempHome: string) => void | Promise) { + return async () => { + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + const tempHome = join(tmpdir(), `gsd-remote-status-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(join(tempHome, ".gsd", "runtime", "remote-questions"), { recursive: true }); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + try { + await fn(tempHome); + } finally { + process.env.HOME = savedHome; + process.env.USERPROFILE = savedUserProfile; + rmSync(tempHome, { recursive: true, force: true }); + } + }; +} +test("getLatestPromptSummary returns latest stored prompt", withTempHome(() => { const recordA = createPromptRecord({ id: "a-prompt", channel: "slack", @@ -38,7 +51,49 @@ test("getLatestPromptSummary returns latest stored prompt", async () => { const latest = getLatestPromptSummary(); assert.equal(latest?.id, "z-prompt"); assert.equal(latest?.status, "answered"); +})); - process.env.HOME = home; - rmSync(tempHome, { recursive: true, force: true }); -}); +test("getLatestPromptSummary sorts by updatedAt, not filename", withTempHome(() => { + // Record with alphabetically-LAST id but OLDEST timestamp + const old = createPromptRecord({ + id: "zzz-oldest", + channel: "slack", + createdAt: 1000, + timeoutAt: 9999, + pollIntervalMs: 5000, + questions: [], + }); + old.updatedAt = 1000; + writePromptRecord(old); + + // Record with alphabetically-FIRST id but NEWEST timestamp + const newest = createPromptRecord({ + id: "aaa-newest", + channel: "discord", + createdAt: 3000, + timeoutAt: 9999, + pollIntervalMs: 5000, + questions: [], + }); + newest.updatedAt = 3000; + newest.status = "answered"; + writePromptRecord(newest); + + // Record in between + const middle = createPromptRecord({ + id: "mmm-middle", + channel: "slack", + createdAt: 2000, + timeoutAt: 9999, + pollIntervalMs: 5000, + questions: [], + }); + middle.updatedAt = 2000; + writePromptRecord(middle); + + const latest = getLatestPromptSummary(); + // Should return "aaa-newest" (updatedAt=3000), NOT "zzz-oldest" (alphabetically last) + assert.equal(latest?.id, "aaa-newest", "should pick the most recently updated prompt, not the alphabetically last filename"); + assert.equal(latest?.status, "answered"); + assert.equal(latest?.updatedAt, 3000); +})); diff --git a/src/resources/extensions/remote-questions/status.ts b/src/resources/extensions/remote-questions/status.ts index e322aeaf8..dd4593488 100644 --- a/src/resources/extensions/remote-questions/status.ts +++ b/src/resources/extensions/remote-questions/status.ts @@ -16,8 +16,16 @@ export interface LatestPromptSummary { export function getLatestPromptSummary(): LatestPromptSummary | null { const runtimeDir = join(homedir(), ".gsd", "runtime", "remote-questions"); if (!existsSync(runtimeDir)) return null; - const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json")).sort().reverse(); + const files = readdirSync(runtimeDir).filter((f) => f.endsWith(".json")); if (files.length === 0) return null; - const record = readPromptRecord(files[0].replace(/\.json$/, "")); - return record ? { id: record.id, status: record.status, updatedAt: record.updatedAt } : null; + + let latest: LatestPromptSummary | null = null; + for (const file of files) { + const record = readPromptRecord(file.replace(/\.json$/, "")); + if (!record) continue; + if (!latest || record.updatedAt > latest.updatedAt) { + latest = { id: record.id, status: record.status, updatedAt: record.updatedAt }; + } + } + return latest; } From 9b80c221ce10b87d8c6e230a9d6c399490fbb01f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 15:46:44 -0300 Subject: [PATCH 20/60] fix: isolate remote-questions config test for Windows compatibility resolveRemoteConfig test used process.env.HOME which is undefined on Windows (Node uses USERPROFILE). Use a temp directory with both HOME and USERPROFILE set, and clean up in a finally block. Co-Authored-By: Claude Opus 4.6 --- .../gsd/tests/remote-questions.test.ts | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index f409224ce..5480b15e0 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -3,8 +3,6 @@ import assert from "node:assert/strict"; import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; import { resolveRemoteConfig } from "../../remote-questions/config.ts"; -const originalEnv = { ...process.env }; - test("parseSlackReply handles single-number single-question answers", () => { const result = parseSlackReply("2", [{ id: "choice", @@ -90,18 +88,30 @@ test("parseDiscordResponse rejects multi-question reaction parsing", () => { }); test("resolveRemoteConfig clamps invalid timeout and poll interval values", async () => { - process.env.SLACK_BOT_TOKEN = "token"; - const home = process.env.HOME!; + const os = await import("node:os"); const fs = await import("node:fs"); const path = await import("node:path"); - const prefsPath = path.join(home, ".gsd", "preferences.md"); - fs.mkdirSync(path.dirname(prefsPath), { recursive: true }); - fs.writeFileSync(prefsPath, `---\nremote_questions:\n channel: slack\n channel_id: \"C123\"\n timeout_minutes: 999\n poll_interval_seconds: 0\n---\n`, "utf-8"); - const config = resolveRemoteConfig(); - assert.ok(config); - assert.equal(config?.timeoutMs, 30 * 60 * 1000); - assert.equal(config?.pollIntervalMs, 2 * 1000); + const savedHome = process.env.HOME; + const savedUserProfile = process.env.USERPROFILE; + const tempHome = path.join(os.tmpdir(), `gsd-remote-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(path.join(tempHome, ".gsd"), { recursive: true }); + process.env.HOME = tempHome; + process.env.USERPROFILE = tempHome; + process.env.SLACK_BOT_TOKEN = "token"; - process.env = { ...originalEnv }; + try { + const prefsPath = path.join(tempHome, ".gsd", "preferences.md"); + fs.writeFileSync(prefsPath, `---\nremote_questions:\n channel: slack\n channel_id: \"C123\"\n timeout_minutes: 999\n poll_interval_seconds: 0\n---\n`, "utf-8"); + + const config = resolveRemoteConfig(); + assert.ok(config); + assert.equal(config?.timeoutMs, 30 * 60 * 1000); + assert.equal(config?.pollIntervalMs, 2 * 1000); + } finally { + process.env.HOME = savedHome; + process.env.USERPROFILE = savedUserProfile; + delete process.env.SLACK_BOT_TOKEN; + fs.rmSync(tempHome, { recursive: true, force: true }); + } }); From 0a955c0b983e33ac7ce26e688ee46fd7d7e760bb Mon Sep 17 00:00:00 2001 From: Gary Trakhman Date: Wed, 11 Mar 2026 15:09:30 -0400 Subject: [PATCH 21/60] fix: support pi extensions from ~/.pi/agent/extensions/ (#51) Update buildResourceLoader to include ~/.pi/agent/extensions/ in additionalExtensionPaths, allowing GSD to discover and use extensions installed in pi's default location. This resolves extension loading issues when users have extensions installed in ~/.pi/agent/extensions/ instead of ~/.gsd/agent/extensions/. - resource-loader.ts: add piExtensionsDir to additionalExtensionPaths - app-smoke.test.ts: add test verifying the source includes .pi path --- src/resource-loader.ts | 15 +++++++++++---- src/tests/app-smoke.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/src/resource-loader.ts b/src/resource-loader.ts index f26341d5a..d7595dd4d 100644 --- a/src/resource-loader.ts +++ b/src/resource-loader.ts @@ -1,4 +1,5 @@ import { DefaultResourceLoader } from '@mariozechner/pi-coding-agent' +import { homedir } from 'node:os' import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -52,10 +53,16 @@ export function initResources(agentDir: string): void { } /** - * Constructs a DefaultResourceLoader with no additionalExtensionPaths. - * Extensions are synced to agentDir by initResources() and pi auto-discovers - * them from ~/.gsd/agent/extensions/ via its normal agentDir scan. + * Constructs a DefaultResourceLoader that loads extensions from both + * ~/.gsd/agent/extensions/ (GSD's default) and ~/.pi/agent/extensions/ (pi's default). + * This allows users to use extensions from either location. */ export function buildResourceLoader(agentDir: string): DefaultResourceLoader { - return new DefaultResourceLoader({ agentDir }) + const piAgentDir = join(homedir(), '.pi', 'agent') + const piExtensionsDir = join(piAgentDir, 'extensions') + + return new DefaultResourceLoader({ + agentDir, + additionalExtensionPaths: [piExtensionsDir], + }) } diff --git a/src/tests/app-smoke.test.ts b/src/tests/app-smoke.test.ts index a54301cff..7e8e24181 100644 --- a/src/tests/app-smoke.test.ts +++ b/src/tests/app-smoke.test.ts @@ -366,3 +366,33 @@ test("gsd launches and loads extensions without errors", async () => { "no ERR_MODULE_NOT_FOUND", ); }); +/** + * 9. buildResourceLoader includes ~/.pi/agent/extensions in additionalExtensionPaths + */ +test("buildResourceLoader source includes ~/.pi/agent/extensions path", async () => { + const { join } = await import("node:path"); + + // Verify the source code includes the pi extensions path + const loaderSrc = readFileSync(join(projectRoot, "src", "resource-loader.ts"), "utf-8"); + + // Check that buildResourceLoader references ~/.pi/agent + assert.ok( + loaderSrc.includes(".pi"), + "resource-loader.ts references .pi directory" + ); + assert.ok( + loaderSrc.includes("additionalExtensionPaths"), + "resource-loader.ts uses additionalExtensionPaths" + ); + assert.ok( + loaderSrc.includes("homedir()"), + "resource-loader.ts uses homedir() to construct paths" + ); + + // Verify the function constructs the correct path + assert.match( + loaderSrc, + /join\(homedir\(\),\s*['"]\.pi['"],\s*['"]agent['"]\)/, + "buildResourceLoader constructs ~/.pi/agent path" + ); +}); From a69a44a89065166c7636f03982c640d22a8b9e6e Mon Sep 17 00:00:00 2001 From: dan bachelder <325706+dbachelder@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:34:03 -0700 Subject: [PATCH 22/60] Add pi global install scripts (#57) --- package.json | 2 ++ scripts/install-pi-global.js | 44 +++++++++++++++++++++++ scripts/uninstall-pi-global.js | 66 ++++++++++++++++++++++++++++++++++ 3 files changed, 112 insertions(+) create mode 100644 scripts/install-pi-global.js create mode 100644 scripts/uninstall-pi-global.js diff --git a/package.json b/package.json index 8577a3b39..10574be0b 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test 'src/resources/extensions/gsd/tests/*.test.ts' 'src/resources/extensions/gsd/tests/*.test.mjs' 'src/tests/*.test.ts'", "dev": "tsc --watch", "postinstall": "node scripts/postinstall.js", + "pi:install-global": "node scripts/install-pi-global.js", + "pi:uninstall-global": "node scripts/uninstall-pi-global.js", "sync-pkg-version": "node scripts/sync-pkg-version.cjs", "prepublishOnly": "npm run sync-pkg-version && npm run build" }, diff --git a/scripts/install-pi-global.js b/scripts/install-pi-global.js new file mode 100644 index 000000000..6f4f36a3e --- /dev/null +++ b/scripts/install-pi-global.js @@ -0,0 +1,44 @@ +#!/usr/bin/env node +import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const resourcesDir = resolve(__dirname, '..', 'src', 'resources') +const piRoot = join(os.homedir(), '.pi') +const piAgentDir = join(piRoot, 'agent') + +const copyDir = (name) => { + const src = join(resourcesDir, name) + const dest = join(piAgentDir, name) + if (!existsSync(src)) return false + mkdirSync(dest, { recursive: true }) + cpSync(src, dest, { recursive: true, force: true }) + return true +} + +mkdirSync(piAgentDir, { recursive: true }) + +const copied = [] +if (copyDir('extensions')) copied.push('extensions') +if (copyDir('skills')) copied.push('skills') +if (copyDir('agents')) copied.push('agents') + +const agentsMdSrc = join(resourcesDir, 'AGENTS.md') +if (existsSync(agentsMdSrc)) { + writeFileSync(join(piAgentDir, 'AGENTS.md'), readFileSync(agentsMdSrc)) + copied.push('AGENTS.md') +} + +const workflowSrc = join(resourcesDir, 'GSD-WORKFLOW.md') +if (existsSync(workflowSrc)) { + writeFileSync(join(piRoot, 'GSD-WORKFLOW.md'), readFileSync(workflowSrc)) + copied.push('GSD-WORKFLOW.md') +} + +process.stdout.write( + `Installed GSD resources for pi in ${piRoot}\n` + + `Copied: ${copied.join(', ')}\n` + + `Extensions are now available under ${join(piAgentDir, 'extensions')}\n` +) diff --git a/scripts/uninstall-pi-global.js b/scripts/uninstall-pi-global.js new file mode 100644 index 000000000..616ec503e --- /dev/null +++ b/scripts/uninstall-pi-global.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync, rmSync, rmdirSync } from 'node:fs' +import os from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const resourcesDir = resolve(__dirname, '..', 'src', 'resources') +const piRoot = join(os.homedir(), '.pi') +const piAgentDir = join(piRoot, 'agent') + +const removed = [] +const skipped = [] + +function safeRemove(path, label) { + if (!existsSync(path)) return + rmSync(path, { recursive: true, force: true }) + removed.push(label) +} + +function removeResourceEntries(containerName) { + const srcDir = join(resourcesDir, containerName) + const destDir = join(piAgentDir, containerName) + if (!existsSync(srcDir) || !existsSync(destDir)) return + + for (const entry of readdirSync(srcDir)) { + safeRemove(join(destDir, entry), `${containerName}/${entry}`) + } + + try { + if (readdirSync(destDir).length === 0) { + rmdirSync(destDir) + removed.push(`${containerName}/`) + } + } catch { + // ignore non-empty or missing dirs + } +} + +function removeIfContentMatches(targetPath, sourcePath, label) { + if (!existsSync(targetPath) || !existsSync(sourcePath)) return + try { + const target = readFileSync(targetPath, 'utf8') + const source = readFileSync(sourcePath, 'utf8') + if (target === source) { + rmSync(targetPath, { force: true }) + removed.push(label) + } else { + skipped.push(`${label} (modified, left in place)`) + } + } catch { + skipped.push(`${label} (could not verify, left in place)`) + } +} + +removeResourceEntries('extensions') +removeResourceEntries('skills') +removeResourceEntries('agents') +removeIfContentMatches(join(piAgentDir, 'AGENTS.md'), join(resourcesDir, 'AGENTS.md'), 'agent/AGENTS.md') +removeIfContentMatches(join(piRoot, 'GSD-WORKFLOW.md'), join(resourcesDir, 'GSD-WORKFLOW.md'), 'GSD-WORKFLOW.md') + +process.stdout.write( + `Removed GSD resources from ${piRoot}\n` + + `Removed: ${removed.length ? removed.join(', ') : '(nothing)'}\n` + + (skipped.length ? `Skipped: ${skipped.join(', ')}\n` : '') +) From d1d0b1acab2c3c39b3084ed2902ae8adc767cfbc Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 14:43:29 -0600 Subject: [PATCH 23/60] fix: constrain browser screenshots to 1568px max dimension (#56) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Anthropic API rejects images exceeding 2000px in multi-image requests. With deviceScaleFactor=2, viewport screenshots were 2560x1600px, triggering 400 errors that halted execution. Add scale:"css" to all API-facing screenshot calls and a constrainScreenshot() fallback that downscales oversized images (e.g. fullPage on tall pages) via the browser's canvas — zero new dependencies. Co-Authored-By: Claude Opus 4.6 --- .../extensions/browser-tools/index.ts | 82 +++++++++++++++++-- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/src/resources/extensions/browser-tools/index.ts b/src/resources/extensions/browser-tools/index.ts index 59f407f0d..88ef292ef 100644 --- a/src/resources/extensions/browser-tools/index.ts +++ b/src/resources/extensions/browser-tools/index.ts @@ -8,7 +8,7 @@ * - Every action returns feedback (accessibility snapshot, screenshots on navigate) * - Errors include visual debugging (screenshots on failure, surfaced JS errors) * - Smart waits (domcontentloaded + best-effort settle, not blocking networkidle) - * - 2x DPI screenshots for readable text + * - Screenshots capped at 1568px max dimension (Anthropic API limit safety) * - JPEG for viewport screenshots (smaller), PNG for element crops (transparency) * - Auto-handles JS dialogs (alert/confirm/prompt) to prevent page freezes * - Auto-switches to new tabs (popups, target="_blank") @@ -731,11 +731,75 @@ async function postActionSummary(p: Page, target?: Page | Frame): Promise 2000px in multi-image requests. +// Cap at 1568px (recommended optimal size) to stay well within limits. +const MAX_SCREENSHOT_DIM = 1568; + +/** + * If either dimension of the image buffer exceeds MAX_SCREENSHOT_DIM, + * downscale proportionally using the browser's canvas (zero dependencies). + * Returns the original buffer unchanged if already within limits. + */ +async function constrainScreenshot( + page: Page, + buffer: Buffer, + mimeType: string, + quality: number, +): Promise { + let width: number; + let height: number; + + if (mimeType === "image/png") { + width = buffer.readUInt32BE(16); + height = buffer.readUInt32BE(20); + } else { + width = 0; + height = 0; + for (let i = 0; i < buffer.length - 8; i++) { + if (buffer[i] === 0xff && (buffer[i + 1] === 0xc0 || buffer[i + 1] === 0xc2)) { + height = buffer.readUInt16BE(i + 5); + width = buffer.readUInt16BE(i + 7); + break; + } + } + } + + if (width <= MAX_SCREENSHOT_DIM && height <= MAX_SCREENSHOT_DIM) { + return buffer; + } + + const b64 = buffer.toString("base64"); + const result = await page.evaluate( + async ({ b64, mime, maxDim, q }) => { + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = () => resolve(); + img.onerror = reject; + img.src = `data:${mime};base64,${b64}`; + }); + const scale = Math.min(maxDim / img.width, maxDim / img.height); + const w = Math.round(img.width * scale); + const h = Math.round(img.height * scale); + const canvas = document.createElement("canvas"); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext("2d")!; + ctx.drawImage(img, 0, 0, w, h); + return canvas.toDataURL(mime, q / 100); + }, + { b64, mime: mimeType, maxDim: MAX_SCREENSHOT_DIM, q: quality }, + ); + + const resizedB64 = result.split(",")[1]; + return Buffer.from(resizedB64, "base64"); +} + /** Capture a JPEG screenshot for error debugging. Returns base64 or null. */ async function captureErrorScreenshot(p: Page | null): Promise<{ data: string; mimeType: string } | null> { if (!p) return null; try { - const buf = await p.screenshot({ type: "jpeg", quality: 60 }); + let buf = await p.screenshot({ type: "jpeg", quality: 60, scale: "css" }); + buf = await constrainScreenshot(p, buf, "image/jpeg", 60); return { data: buf.toString("base64"), mimeType: "image/jpeg" }; } catch { return null; @@ -1602,7 +1666,8 @@ export default function (pi: ExtensionAPI) { let screenshotContent: any[] = []; try { - const buf = await p.screenshot({ type: "jpeg", quality: 80 }); + let buf = await p.screenshot({ type: "jpeg", quality: 80, scale: "css" }); + buf = await constrainScreenshot(p, buf, "image/jpeg", 80); screenshotContent = [{ type: "image", data: buf.toString("base64"), mimeType: "image/jpeg" }]; } catch {} @@ -1744,7 +1809,8 @@ export default function (pi: ExtensionAPI) { // Include screenshot like navigate does let screenshotContent: any[] = []; try { - const buf = await p.screenshot({ type: "jpeg", quality: 80 }); + let buf = await p.screenshot({ type: "jpeg", quality: 80, scale: "css" }); + buf = await constrainScreenshot(p, buf, "image/jpeg", 80); screenshotContent = [{ type: "image", data: buf.toString("base64"), @@ -1805,23 +1871,27 @@ export default function (pi: ExtensionAPI) { let screenshotBuffer: Buffer; let mimeType: string; + const quality = params.quality ?? 80; if (params.selector) { // Element screenshots: keep PNG (may have transparency) const locator = p.locator(params.selector).first(); - screenshotBuffer = await locator.screenshot({ type: "png" }); + screenshotBuffer = await locator.screenshot({ type: "png", scale: "css" }); mimeType = "image/png"; } else { // Viewport/fullpage: use JPEG (3-5x smaller, fine for AI analysis) - const quality = params.quality ?? 80; screenshotBuffer = await p.screenshot({ fullPage: params.fullPage ?? false, type: "jpeg", quality, + scale: "css", }); mimeType = "image/jpeg"; } + // Downscale if dimensions exceed API limit (1568px max) + screenshotBuffer = await constrainScreenshot(p, screenshotBuffer, mimeType, quality); + const base64Data = screenshotBuffer.toString("base64"); const title = await p.title(); const url = p.url(); From 7b28162ade0bb224d158dfd7a36c40ed0e61fa2d Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 14:45:46 -0600 Subject: [PATCH 24/60] docs: rewrite discuss prompt with reflection step, questioning philosophy, depth enforcement, and visible previews --- .../extensions/gsd/prompts/discuss.md | 96 ++++++++++++++----- 1 file changed, 70 insertions(+), 26 deletions(-) diff --git a/src/resources/extensions/gsd/prompts/discuss.md b/src/resources/extensions/gsd/prompts/discuss.md index 0e8f52845..b50e1e927 100644 --- a/src/resources/extensions/gsd/prompts/discuss.md +++ b/src/resources/extensions/gsd/prompts/discuss.md @@ -4,13 +4,20 @@ Ask: "What's the vision?" once, and then use whatever the user replies with as t Special handling: if the user message is not a project description (for example, they ask about status, branch state, or other clarifications), treat it as the vision input and proceed with discussion logic instead of repeating "What's the vision?". -## Discussion Phase +## Reflection Step -After they describe it, your job is to understand the project deeply enough to define the project's capability contract before planning slices. +After the user describes their idea, **do not ask questions yet**. First, prove you understood by reflecting back: + +1. Summarize what you understood in your own words — concretely, not abstractly. +2. Include a complexity/scale read: "This sounds like [task/project/product] scale — roughly N milestone(s)." +3. Include scope honesty — a bullet list of the major capabilities you're hearing: "Here's what I'm hearing: [bullet list of major capabilities]." +4. Ask: "Did I get that right, or did I miss something?" — plain text, not `ask_user_questions`. Let them correct freely. + +This prevents runaway questioning by forcing comprehension proof before anything else. Do not skip this step. Do not combine it with the first question round. ## Vision Mapping -Before diving into detailed Q&A, read the user's description and classify its scale: +After reflection is confirmed, classify the scale: - **Task** — a focused piece of work (single milestone, few slices) - **Project** — a coherent product with multiple major capabilities (multi-milestone likely) @@ -21,40 +28,69 @@ Before diving into detailed Q&A, read the user's description and classify its sc 2. Present this to the user for confirmation or adjustment 3. Only then begin the deep Q&A — and scope the Q&A to the full vision, not just M001 -**For Task scale:** Proceed directly to the discussion flow below (single milestone). +**For Task scale:** Proceed directly to questioning. **Anti-reduction rule:** If the user describes a big vision, plan the big vision. Do not ask "what's the minimum viable version?" or try to reduce scope unless the user explicitly asks for an MVP or minimal version. When something is complex or risky, phase it into a later milestone — do not cut it. The user's ambition is the target, and your job is to sequence it intelligently, not shrink it. ---- +## Mandatory Investigation Before First Question Round -**If the user provides a file path or pastes a large document** (spec, design doc, product plan, chat export), read it fully before asking questions. Use it as the starting point — don't ask them to re-explain what's already in the document. Your questions should fill gaps and resolve ambiguities the document doesn't cover. +Before asking your first question, do a mandatory investigation pass. This is not optional. -**Investigate between question rounds to make your questions smarter.** Before each round of questions, do enough lightweight research that your questions are grounded in reality — not guesses about what exists or what's possible. +1. **Scout the codebase** — `ls`, `find`, `rg`, or `scout` for broad unfamiliar areas. Understand what already exists, what patterns are established, what constraints current code imposes. +2. **Check library docs** — `resolve_library` / `get_library_docs` for any tech the user mentioned. Get current facts about capabilities, constraints, API shapes, version-specific behavior. +3. **Web search** — `search-the-web` if the domain is unfamiliar, if you need current best practices, or if the user referenced external services/APIs you need facts about. Use `fetch_page` for full content when snippets aren't enough. -- Check library docs (`resolve_library` / `get_library_docs`) when the user mentions tech you need current facts about — capabilities, constraints, API shapes, version-specific behavior -- Do web searches (`search-the-web`) to verify the landscape — what solutions exist, what's changed recently, what's the current best practice. Use `freshness` for recency-sensitive queries, `domain` to target specific sites. Use `fetch_page` to read the full content of promising URLs when snippets aren't enough. -- Scout the codebase (`ls`, `find`, `rg`, or `scout` for broad unfamiliar areas) to understand what already exists, what patterns are established, what constraints current code imposes +This happens ONCE, before the first round. The goal: your first questions should reflect what's actually true, not what you assume. -Don't go deep — just enough that your next question reflects what's actually true rather than what you assume. +For subsequent rounds, continue investigating between rounds — check docs, search, or scout as needed to make each round's questions smarter. But the first-round investigation is mandatory and explicit. -**Use this to actively surface:** -- The biggest technical unknowns — what could fail, what hasn't been proven, what might invalidate the plan -- Integration surfaces — external systems, APIs, libraries, or internal modules this work touches -- What needs to be proven before committing — the things that, if they don't work, mean the plan is wrong -- Product reality requirements: primary user loop, launchability expectations, continuity expectations, and failure visibility expectations -- Items that are complex, risky, or lower priority — phase these into later milestones rather than deferring or cutting them. Only truly unwanted capabilities become anti-features. +## Questioning Philosophy -**Then use ask_user_questions** to dig into gray areas — architecture choices, scope boundaries, tech preferences, what's in vs out. 1-3 questions per round. +You are a thinking partner, not an interviewer. -If a `GSD Skill Preferences` block is present in system context, use it to decide which skills to load and follow during discuss/planning work, but do not let it override the required discuss flow or artifact requirements. +**Start open, follow energy.** Let the user's enthusiasm guide where you dig deeper. If they light up about a particular aspect, explore it. If they're vague about something, that's where you probe. -**Self-regulate depth by scale:** -- **Task scale:** After about 5-10 questions total (2-3 rounds), or when you feel you have a solid understanding, offer to proceed. -- **Project/Product scale:** After about 15-25 questions total (5-8 rounds), or when you feel you have a solid understanding, offer to proceed. +**Challenge vagueness, make abstract concrete.** When the user says something abstract ("it should be smart" / "it needs to handle edge cases" / "good UX"), push for specifics. What does "smart" mean in practice? Which edge cases? What does good UX look like for this specific interaction? -Include a question like: -"I think I have a good picture. Ready to confirm requirements and milestone plan, or are there more things to discuss?" -with options: "Ready to confirm requirements and milestone plan (Recommended)", "I have more to discuss" +**Questions must be about the experience, not the implementation.** Never ask "what auth provider?" — ask "when someone logs in, what should that feel like?" Never ask "what database?" — ask "when they come back tomorrow, what should they see?" Implementation is your job. Understanding what they want to experience is the discussion's job. + +**Freeform rule:** When the user selects "Other" or clearly wants to explain something freely, stop using `ask_user_questions` and switch to plain text follow-ups. Let them talk. Resume structured questions when appropriate. + +**Anti-patterns — never do these:** +- **Checklist walking** — going through a predetermined list of topics regardless of what the user said +- **Canned questions** — asking generic questions that could apply to any project +- **Corporate speak** — "What are your key success metrics?" / "Who are the stakeholders?" +- **Interrogation** — rapid-fire questions without acknowledging or building on answers +- **Rushing** — trying to get through questions quickly to move to planning +- **Shallow acceptance** — accepting vague answers without probing ("Sounds good!" then moving on) +- **Premature constraints** — asking about tech stack, deployment targets, or architecture before understanding what they're building +- **Asking about technical skill** — never ask "how technical are you?" or "are you familiar with X?" — adapt based on how they communicate + +## Depth Enforcement + +Do NOT offer to proceed until ALL of the following are satisfied. Track these internally as a background checklist: + +- [ ] **What they're building** — concrete enough that you could explain it to a stranger +- [ ] **Why it needs to exist** — the problem it solves or the desire it fulfills +- [ ] **Who it's for** — even if just themselves +- [ ] **What "done" looks like** — observable outcomes, not abstract goals +- [ ] **The biggest technical unknowns / risks** — what could fail, what hasn't been proven +- [ ] **What external systems/services this touches** — APIs, databases, third-party services, hardware + +**Minimum round counts before the wrap-up gate is allowed:** +- **Task scale:** at least 2 full rounds (6+ questions asked and answered) +- **Project/Product scale:** at least 4 full rounds (12+ questions asked and answered) + +Do not count the reflection step as a question round. Rounds start after reflection is confirmed. + +## Wrap-up Gate + +Only after the depth checklist is fully satisfied AND minimum rounds are hit, offer to proceed. + +The wrap-up gate must include a scope reflection: +"Here's what I'm planning to build: [list of capabilities with rough complexity]. Does this match your vision, or did I miss something?" + +Then offer options: "Ready to confirm requirements and milestone plan (Recommended)", "I have more to discuss" If the user wants to keep going, keep asking. If they're ready, proceed. @@ -107,7 +143,9 @@ Rules: For multi-milestone projects, requirements should span the full vision. Requirements owned by later milestones get provisional ownership. The full requirement set captures the user's complete vision — milestones are the sequencing strategy, not the scope boundary. -If the project is new or has no `REQUIREMENTS.md`, confirm candidate requirements with the user before writing the roadmap. Keep the confirmation lightweight: confirm, defer, reject, or add. +If the project is new or has no `REQUIREMENTS.md`, confirm candidate requirements with the user before writing the roadmap. + +**Print the requirements in chat before asking for confirmation.** Do not say "here are the requirements" and then only write them to a file. The user must see them in the terminal. Print a markdown table with columns: ID, Title, Status, Owner, Source. Group by status (Active, Deferred, Out of Scope). After the table, ask: "Confirm, adjust, or add?" ## Scope Assessment @@ -117,6 +155,12 @@ If Vision Mapping classified the work as Task but discussion revealed Project-sc ## Output Phase +### Roadmap Preview + +Before writing any files, **print the planned roadmap in chat** so the user can see and approve it. Print a markdown table with columns: Slice, Title, Risk, Depends, Demo. One row per slice. Below the table, print the milestone definition of done as a bullet list. + +Ask: "Ready to write the plan, or want to adjust?" Only proceed to writing files after the user confirms. + ### Naming Convention Directories use bare IDs. Files use ID-SUFFIX format. Titles live inside file content, not in names. From 41f362841ef11372770eaec8c3a52da9a457f820 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 17:58:47 -0300 Subject: [PATCH 25/60] fix(security): validate channel ID format to prevent SSRF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slack IDs must match ^[A-Z0-9]{9,12}$, Discord snowflakes must match ^\d{17,20}$. resolveRemoteConfig() and getRemoteConfigStatus() now reject malformed IDs before they reach any URL interpolation. Also fixes pre-existing false-positive in config tests (env overrides couldn't affect module-level homedir() cache) — replaced with direct isValidChannelId() unit tests. Co-Authored-By: Claude Opus 4.6 --- .../gsd/tests/remote-questions.test.ts | 57 ++++++++++--------- .../extensions/remote-questions/config.ts | 17 +++++- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index 5480b15e0..bf37c2771 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; -import { resolveRemoteConfig } from "../../remote-questions/config.ts"; +import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts"; test("parseSlackReply handles single-number single-question answers", () => { const result = parseSlackReply("2", [{ @@ -87,31 +87,32 @@ test("parseDiscordResponse rejects multi-question reaction parsing", () => { assert.match(String(result.answers.second.user_note), /single-question prompts/i); }); -test("resolveRemoteConfig clamps invalid timeout and poll interval values", async () => { - const os = await import("node:os"); - const fs = await import("node:fs"); - const path = await import("node:path"); - - const savedHome = process.env.HOME; - const savedUserProfile = process.env.USERPROFILE; - const tempHome = path.join(os.tmpdir(), `gsd-remote-config-${Date.now()}-${Math.random().toString(36).slice(2)}`); - fs.mkdirSync(path.join(tempHome, ".gsd"), { recursive: true }); - process.env.HOME = tempHome; - process.env.USERPROFILE = tempHome; - process.env.SLACK_BOT_TOKEN = "token"; - - try { - const prefsPath = path.join(tempHome, ".gsd", "preferences.md"); - fs.writeFileSync(prefsPath, `---\nremote_questions:\n channel: slack\n channel_id: \"C123\"\n timeout_minutes: 999\n poll_interval_seconds: 0\n---\n`, "utf-8"); - - const config = resolveRemoteConfig(); - assert.ok(config); - assert.equal(config?.timeoutMs, 30 * 60 * 1000); - assert.equal(config?.pollIntervalMs, 2 * 1000); - } finally { - process.env.HOME = savedHome; - process.env.USERPROFILE = savedUserProfile; - delete process.env.SLACK_BOT_TOKEN; - fs.rmSync(tempHome, { recursive: true, force: true }); - } +test("isValidChannelId rejects invalid Slack channel IDs", () => { + // Too short + assert.equal(isValidChannelId("slack", "C123"), false); + // Contains invalid chars (URL injection) + assert.equal(isValidChannelId("slack", "https://evil.com"), false); + // Lowercase + assert.equal(isValidChannelId("slack", "c12345678"), false); + // Too long + assert.equal(isValidChannelId("slack", "C1234567890AB"), false); + // Valid: 9-12 uppercase alphanumeric + assert.equal(isValidChannelId("slack", "C12345678"), true); + assert.equal(isValidChannelId("slack", "C12345678AB"), true); + assert.equal(isValidChannelId("slack", "C1234567890A"), true); }); + +test("isValidChannelId rejects invalid Discord channel IDs", () => { + // Too short + assert.equal(isValidChannelId("discord", "12345"), false); + // Contains letters (not a snowflake) + assert.equal(isValidChannelId("discord", "abc12345678901234"), false); + // URL injection + assert.equal(isValidChannelId("discord", "https://evil.com"), false); + // Too long (21 digits) + assert.equal(isValidChannelId("discord", "123456789012345678901"), false); + // Valid: 17-20 digit snowflake + assert.equal(isValidChannelId("discord", "12345678901234567"), true); + assert.equal(isValidChannelId("discord", "11234567890123456789"), true); +}); + diff --git a/src/resources/extensions/remote-questions/config.ts b/src/resources/extensions/remote-questions/config.ts index 7fe7b7d2c..0b962c2e4 100644 --- a/src/resources/extensions/remote-questions/config.ts +++ b/src/resources/extensions/remote-questions/config.ts @@ -18,6 +18,12 @@ const ENV_KEYS: Record = { discord: "DISCORD_BOT_TOKEN", }; +// Channel ID format validation — prevents SSRF if preferences are attacker-controlled +const CHANNEL_ID_PATTERNS: Record = { + slack: /^[A-Z0-9]{9,12}$/, + discord: /^\d{17,20}$/, +}; + const DEFAULT_TIMEOUT_MINUTES = 5; const DEFAULT_POLL_INTERVAL_SECONDS = 5; const MIN_TIMEOUT_MINUTES = 1; @@ -31,6 +37,9 @@ export function resolveRemoteConfig(): ResolvedConfig | null { if (!rq || !rq.channel || !rq.channel_id) return null; if (rq.channel !== "slack" && rq.channel !== "discord") return null; + const channelId = String(rq.channel_id); + if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return null; + const token = process.env[ENV_KEYS[rq.channel]]; if (!token) return null; @@ -39,7 +48,7 @@ export function resolveRemoteConfig(): ResolvedConfig | null { return { channel: rq.channel, - channelId: String(rq.channel_id), + channelId, timeoutMs: timeoutMinutes * 60 * 1000, pollIntervalMs: pollIntervalSeconds * 1000, token, @@ -51,6 +60,8 @@ export function getRemoteConfigStatus(): string { const rq: RemoteQuestionsConfig | undefined = prefs?.preferences.remote_questions; if (!rq || !rq.channel || !rq.channel_id) return "Remote questions: not configured"; if (rq.channel !== "slack" && rq.channel !== "discord") return `Remote questions: unknown channel type \"${rq.channel}\"`; + const channelId = String(rq.channel_id); + if (!CHANNEL_ID_PATTERNS[rq.channel].test(channelId)) return `Remote questions: invalid ${rq.channel} channel ID format`; const envVar = ENV_KEYS[rq.channel]; if (!process.env[envVar]) return `Remote questions: ${envVar} not set — remote questions disabled`; @@ -59,6 +70,10 @@ export function getRemoteConfigStatus(): string { return `Remote questions: ${rq.channel} configured (timeout ${timeoutMinutes}m, poll ${pollIntervalSeconds}s)`; } +export function isValidChannelId(channel: RemoteChannel, id: string): boolean { + return CHANNEL_ID_PATTERNS[channel].test(id); +} + function clampNumber(value: unknown, fallback: number, min: number, max: number): number { const n = typeof value === "number" ? value : Number(value); if (!Number.isFinite(n)) return fallback; From c67151bef3ea0cdc47bd58111ccb6a759604f1e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 17:59:32 -0300 Subject: [PATCH 26/60] fix(security): cap user_note at 500 chars to prevent LLM context DoS Arbitrary-length free-text replies from remote channels were passed directly into the LLM context. Now truncated to 500 chars with trailing ellipsis. Co-Authored-By: Claude Opus 4.6 --- .../extensions/gsd/tests/remote-questions.test.ts | 15 +++++++++++++++ .../extensions/remote-questions/format.ts | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index bf37c2771..4175f1d38 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -87,6 +87,21 @@ test("parseDiscordResponse rejects multi-question reaction parsing", () => { assert.match(String(result.answers.second.user_note), /single-question prompts/i); }); +test("parseSlackReply truncates user_note longer than 500 chars", () => { + const longText = "x".repeat(600); + const result = parseSlackReply(longText, [{ + id: "q1", + header: "Q1", + question: "Pick", + allowMultiple: false, + options: [{ label: "A", description: "a" }], + }]); + + const note = result.answers.q1.user_note!; + assert.ok(note.length <= 502, `note should be truncated, got ${note.length} chars`); + assert.ok(note.endsWith("…"), "truncated note should end with ellipsis"); +}); + test("isValidChannelId rejects invalid Slack channel IDs", () => { // Too short assert.equal(isValidChannelId("slack", "C123"), false); diff --git a/src/resources/extensions/remote-questions/format.ts b/src/resources/extensions/remote-questions/format.ts index dd01039b8..1e03c637b 100644 --- a/src/resources/extensions/remote-questions/format.ts +++ b/src/resources/extensions/remote-questions/format.ts @@ -19,6 +19,7 @@ export interface DiscordEmbed { } const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; +const MAX_USER_NOTE_LENGTH = 500; export function formatForSlack(prompt: RemotePrompt): SlackBlock[] { const blocks: SlackBlock[] = [ @@ -154,5 +155,9 @@ function parseAnswerForQuestion(text: string, q: RemoteQuestion): { answers: str return { answers: [q.options[single - 1].label] }; } - return { answers: [], user_note: text }; + return { answers: [], user_note: truncateNote(text) }; +} + +function truncateNote(text: string): string { + return text.length > MAX_USER_NOTE_LENGTH ? text.slice(0, MAX_USER_NOTE_LENGTH) + "…" : text; } From 492daaf709b10274e73a49575c7a9d7dbfba7b07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 18:00:19 -0300 Subject: [PATCH 27/60] fix(reliability): add 15s per-request fetch timeout to adapters Individual HTTP calls to Slack/Discord APIs could hang indefinitely if the network stalls. The overall poll deadline only bounds the loop, not each request. Now each fetch() gets AbortSignal.timeout(15_000). Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/remote-questions/discord-adapter.ts | 2 ++ src/resources/extensions/remote-questions/slack-adapter.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index e477af65a..544675a1c 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -6,6 +6,7 @@ import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, import { formatForDiscord, parseDiscordResponse } from "./format.js"; const DISCORD_API = "https://discord.com/api/v10"; +const PER_REQUEST_TIMEOUT_MS = 15_000; const NUMBER_EMOJIS = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣"]; export class DiscordAdapter implements ChannelAdapter { @@ -108,6 +109,7 @@ export class DiscordAdapter implements ChannelAdapter { init.body = JSON.stringify(body); } + init.signal = AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS); const response = await fetch(`${DISCORD_API}${path}`, init); if (response.status === 204) return {}; if (!response.ok) { diff --git a/src/resources/extensions/remote-questions/slack-adapter.ts b/src/resources/extensions/remote-questions/slack-adapter.ts index 6bc69b036..42b9fcc07 100644 --- a/src/resources/extensions/remote-questions/slack-adapter.ts +++ b/src/resources/extensions/remote-questions/slack-adapter.ts @@ -6,6 +6,7 @@ import type { ChannelAdapter, RemotePrompt, RemoteDispatchResult, RemoteAnswer, import { formatForSlack, parseSlackReply } from "./format.js"; const SLACK_API = "https://slack.com/api"; +const PER_REQUEST_TIMEOUT_MS = 15_000; export class SlackAdapter implements ChannelAdapter { readonly name = "slack" as const; @@ -72,7 +73,7 @@ export class SlackAdapter implements ChannelAdapter { let response: Response; if (isGet) { const qs = new URLSearchParams(Object.fromEntries(Object.entries(params).map(([k, v]) => [k, String(v)]))).toString(); - response = await fetch(`${url}?${qs}`, { method: "GET", headers: { Authorization: `Bearer ${this.token}` } }); + response = await fetch(`${url}?${qs}`, { method: "GET", headers: { Authorization: `Bearer ${this.token}` }, signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS) }); } else { response = await fetch(url, { method: "POST", @@ -81,6 +82,7 @@ export class SlackAdapter implements ChannelAdapter { "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify(params), + signal: AbortSignal.timeout(PER_REQUEST_TIMEOUT_MS), }); } From 003cb44007006698d6fbc06c8c90d70cdac6cdc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 18:01:42 -0300 Subject: [PATCH 28/60] fix(security): sanitize error messages to prevent token leakage Error messages from adapter auth/send failures may contain token fragments. Added sanitizeError() that strips Slack token patterns (xoxb-, xoxp-, xoxa-) and long opaque secrets (20+ alphanumeric chars). Also truncates verbose Discord API error responses to 200 chars. Applied to all error paths in manager.ts and discord-adapter.ts. Co-Authored-By: Claude Opus 4.6 --- .../gsd/tests/remote-questions.test.ts | 22 +++++++++++++++++ .../remote-questions/discord-adapter.ts | 4 +++- .../extensions/remote-questions/manager.ts | 24 +++++++++++++++---- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/src/resources/extensions/gsd/tests/remote-questions.test.ts b/src/resources/extensions/gsd/tests/remote-questions.test.ts index 4175f1d38..40dbe551c 100644 --- a/src/resources/extensions/gsd/tests/remote-questions.test.ts +++ b/src/resources/extensions/gsd/tests/remote-questions.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { parseSlackReply, parseDiscordResponse } from "../../remote-questions/format.ts"; import { resolveRemoteConfig, isValidChannelId } from "../../remote-questions/config.ts"; +import { sanitizeError } from "../../remote-questions/manager.ts"; test("parseSlackReply handles single-number single-question answers", () => { const result = parseSlackReply("2", [{ @@ -131,3 +132,24 @@ test("isValidChannelId rejects invalid Discord channel IDs", () => { assert.equal(isValidChannelId("discord", "11234567890123456789"), true); }); +test("sanitizeError strips Slack token patterns from error messages", () => { + assert.equal( + sanitizeError("Auth failed: xoxb-1234-5678-abcdef"), + "Auth failed: [REDACTED]", + ); + assert.equal( + sanitizeError("Bad token xoxp-abc-def-ghi in request"), + "Bad token [REDACTED] in request", + ); +}); + +test("sanitizeError strips long opaque secrets", () => { + const fakeDiscordToken = "MTIzNDU2Nzg5MDEyMzQ1Njc4OQ.G1x2y3.abcdefghijklmnop"; + assert.ok(!sanitizeError(`Token: ${fakeDiscordToken}`).includes(fakeDiscordToken)); +}); + +test("sanitizeError preserves short safe messages", () => { + assert.equal(sanitizeError("HTTP 401: Unauthorized"), "HTTP 401: Unauthorized"); + assert.equal(sanitizeError("Connection refused"), "Connection refused"); +}); + diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index 544675a1c..a3f84e0f0 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -114,7 +114,9 @@ export class DiscordAdapter implements ChannelAdapter { if (response.status === 204) return {}; if (!response.ok) { const text = await response.text().catch(() => ""); - throw new Error(`Discord API HTTP ${response.status}: ${text}`); + // Limit error body length to avoid leaking verbose Discord error responses + const safeText = text.length > 200 ? text.slice(0, 200) + "…" : text; + throw new Error(`Discord API HTTP ${response.status}: ${safeText}`); } return response.json(); } diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts index 9baabbd58..511668deb 100644 --- a/src/resources/extensions/remote-questions/manager.ts +++ b/src/resources/extensions/remote-questions/manager.ts @@ -36,7 +36,7 @@ export async function tryRemoteQuestions( try { await adapter.validate(); } catch (err) { - markPromptStatus(prompt.id, "failed", String((err as Error).message)); + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); return errorResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`, config.channel); } @@ -45,7 +45,7 @@ export async function tryRemoteQuestions( dispatch = await adapter.sendPrompt(prompt); markPromptDispatched(prompt.id, dispatch.ref); } catch (err) { - markPromptStatus(prompt.id, "failed", String((err as Error).message)); + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); return errorResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`, config.channel); } @@ -128,7 +128,7 @@ async function pollUntilDone( updatePromptRecord(prompt.id, { lastPollAt: Date.now() }); if (answer) return answer; } catch (err) { - markPromptStatus(prompt.id, "failed", String((err as Error).message)); + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); return null; } @@ -163,9 +163,25 @@ function formatForTool(answer: RemoteAnswer): Record Date: Wed, 11 Mar 2026 18:02:20 -0300 Subject: [PATCH 29/60] fix(reliability): distinguish Discord 404 from auth errors in reactions The catch-all in checkReactions() silently swallowed auth failures (401/403), making them indistinguishable from "no reaction yet". Now: - 404: expected (no reactions for this emoji), continue - 401/403: re-thrown so the poll loop surfaces the auth failure - Other errors: best-effort skip (rate limits, network) Co-Authored-By: Claude Opus 4.6 --- .../extensions/remote-questions/discord-adapter.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/resources/extensions/remote-questions/discord-adapter.ts b/src/resources/extensions/remote-questions/discord-adapter.ts index a3f84e0f0..4c9a4960e 100644 --- a/src/resources/extensions/remote-questions/discord-adapter.ts +++ b/src/resources/extensions/remote-questions/discord-adapter.ts @@ -76,8 +76,13 @@ export class DiscordAdapter implements ChannelAdapter { const humanUsers = users.filter((u: { id: string }) => u.id !== this.botUserId); if (humanUsers.length > 0) reactions.push({ emoji, count: humanUsers.length }); } - } catch { - // ignore missing reaction + } catch (err) { + const msg = String((err as Error).message ?? ""); + // 404 = no reactions for this emoji — expected, continue + if (msg.includes("HTTP 404")) continue; + // 401/403 = auth failure — surface to caller so it can fail the poll + if (msg.includes("HTTP 401") || msg.includes("HTTP 403")) throw err; + // Other errors (rate limit, network) — skip this emoji, best-effort } } From 090554373c5f1b612b2e022c30c477283d63de53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 18:03:31 -0300 Subject: [PATCH 30/60] refactor: use discriminated union for remote vs local result details Replace the inline union cast in renderResult with a proper discriminated union (LocalResultDetails | RemoteResultDetails) keyed on the `remote` field. Improves type safety and makes the rendering logic self-documenting. Co-Authored-By: Claude Opus 4.6 --- .../extensions/ask-user-questions.ts | 36 ++++++++++++------- 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/src/resources/extensions/ask-user-questions.ts b/src/resources/extensions/ask-user-questions.ts index 0f9d803e7..d33efea4a 100644 --- a/src/resources/extensions/ask-user-questions.ts +++ b/src/resources/extensions/ask-user-questions.ts @@ -21,12 +21,27 @@ import { // ─── Types ──────────────────────────────────────────────────────────────────── -interface AskUserQuestionsDetails { +interface LocalResultDetails { + remote?: false; questions: Question[]; response: RoundResult | null; cancelled: boolean; } +interface RemoteResultDetails { + remote: true; + channel: string; + timed_out: boolean; + promptId?: string; + threadUrl?: string; + status?: string; + questions?: Question[]; + response?: import("./remote-questions/types.js").RemoteAnswer; + error?: boolean; +} + +type AskUserQuestionsDetails = LocalResultDetails | RemoteResultDetails; + // ─── Schema ─────────────────────────────────────────────────────────────────── const OptionSchema = Type.Object({ @@ -134,13 +149,13 @@ export default function AskUserQuestions(pi: ExtensionAPI) { if (!hasAnswers) { return { content: [{ type: "text", text: "ask_user_questions was cancelled before receiving a response" }], - details: { questions: params.questions, response: null, cancelled: true } as AskUserQuestionsDetails, + details: { questions: params.questions, response: null, cancelled: true } satisfies LocalResultDetails, }; } return { content: [{ type: "text", text: formatForLLM(result) }], - details: { questions: params.questions, response: result, cancelled: false } as AskUserQuestionsDetails, + details: { questions: params.questions, response: result, cancelled: false } satisfies LocalResultDetails, }; }, @@ -168,31 +183,28 @@ export default function AskUserQuestions(pi: ExtensionAPI) { }, renderResult(result, _options, theme) { - const details = result.details as (AskUserQuestionsDetails & { remote?: boolean; channel?: string; timed_out?: boolean; threadUrl?: string; promptId?: string; status?: string }) | undefined; + const details = result.details as AskUserQuestionsDetails | undefined; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } - // Remote channel result + // Remote channel result (discriminated on details.remote === true) if (details.remote) { if (details.timed_out) { - const channelLabel = details.channel ?? "remote"; return new Text( - `${theme.fg("warning", `${channelLabel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`, + `${theme.fg("warning", `${details.channel} — timed out`)}${details.threadUrl ? theme.fg("dim", ` ${details.threadUrl}`) : ""}`, 0, 0, ); } - const remoteResponse = details.response as import("./remote-questions/channels.js").RemoteAnswer | undefined; const questions = (details.questions ?? []) as Question[]; const lines: string[] = []; - const channelLabel = details.channel ?? "remote"; - lines.push(theme.fg("dim", channelLabel)); - if (remoteResponse) { + lines.push(theme.fg("dim", details.channel)); + if (details.response) { for (const q of questions) { - const answer = remoteResponse.answers[q.id]; + const answer = details.response.answers[q.id]; if (!answer) { lines.push(`${theme.fg("accent", q.header)}: ${theme.fg("dim", "(no answer)")}`); continue; From 3b9a8c1c6326bd5ece1a9e82be87d1484fe4d2d5 Mon Sep 17 00:00:00 2001 From: jonathancostin <66714927+jonathancostin@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:45:34 -0500 Subject: [PATCH 31/60] feat: add /clear as alias for /new slash command (#59) Registers /clear as a command that calls ctx.newSession(), identical to /new. Shows in autocomplete when typing /cl with 'Alias for /new' description. --- src/resources/extensions/slash-commands/clear.ts | 10 ++++++++++ src/resources/extensions/slash-commands/index.ts | 2 ++ 2 files changed, 12 insertions(+) create mode 100644 src/resources/extensions/slash-commands/clear.ts diff --git a/src/resources/extensions/slash-commands/clear.ts b/src/resources/extensions/slash-commands/clear.ts new file mode 100644 index 000000000..75e21f878 --- /dev/null +++ b/src/resources/extensions/slash-commands/clear.ts @@ -0,0 +1,10 @@ +import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; + +export default function clearCommand(pi: ExtensionAPI) { + pi.registerCommand("clear", { + description: "Alias for /new — start a new session", + async handler(_args: string, ctx: ExtensionCommandContext) { + await ctx.newSession(); + }, + }); +} diff --git a/src/resources/extensions/slash-commands/index.ts b/src/resources/extensions/slash-commands/index.ts index 6ea3c683a..8d3be0e02 100644 --- a/src/resources/extensions/slash-commands/index.ts +++ b/src/resources/extensions/slash-commands/index.ts @@ -3,10 +3,12 @@ import createSlashCommand from "./create-slash-command.js"; import createExtension from "./create-extension.js"; import auditCommand from "./audit.js"; import gsdRun from "./gsd-run.js"; +import clearCommand from "./clear.js"; export default function slashCommands(pi: ExtensionAPI) { createSlashCommand(pi); createExtension(pi); auditCommand(pi); gsdRun(pi); + clearCommand(pi); } From 09c5aa33ee95745be9ad82a7f44049cc09457a60 Mon Sep 17 00:00:00 2001 From: jonathancostin <66714927+jonathancostin@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:45:50 -0500 Subject: [PATCH 32/60] feat: add /exit command to kill GSD process immediately (#60) Registers /exit as a slash command that calls process.exit(0). Quick way to quit without typing /quit. --- src/resources/extensions/gsd/index.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index caa3ed9bd..3cd6c8d95 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -63,6 +63,14 @@ export default function (pi: ExtensionAPI) { registerGSDCommand(pi); registerWorktreeCommand(pi); + // ── /exit — kill the process immediately ────────────────────────────── + pi.registerCommand("exit", { + description: "Exit GSD immediately", + handler: async (_ctx) => { + process.exit(0); + }, + }); + // ── Dynamic-cwd bash tool with default timeout ──────────────────────── // The built-in bash tool captures cwd at startup. This replacement uses // a spawnHook to read process.cwd() dynamically so that process.chdir() From 85f60451fb84d7d5912098f53ef5a06f6048c714 Mon Sep 17 00:00:00 2001 From: jonathancostin <66714927+jonathancostin@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:46:34 -0500 Subject: [PATCH 33/60] feat: improve worktree merge, create, remove, and reload resilience (#61) Merge improvements: - Auto-detect current worktree: /worktree merge (bare) and /worktree merge main work from inside a worktree without specifying the worktree name - Full repo diffs: preview and LLM prompt show all changed files, not just .gsd/ - Accurate preview: direct diff (main vs branch) shows actual merge impact - Per-file line stats: +N/-N shown for each file in merge preview - CWD fix: chdir to main tree before dispatching merge to prevent broken CWD after worktree cleanup - Prompt includes explicit paths so the LLM knows where to read/write Create/switch: - /worktree create works as alias for create-or-switch behavior - Guard against creating a worktree when the branch is already in use Remove: - /worktree remove validates the name exists before attempting removal - /worktree remove confirms before deleting - /worktree remove all removes every worktree after confirmation prompt Reload resilience: - Detects if CWD is inside a worktree on extension init and restores originalCwd tracking, surviving /reload without losing worktree state Command descriptions: - /worktree shows '(also /wt)' in description - /wt shows 'Alias for /worktree' --- .../extensions/gsd/prompts/worktree-merge.md | 68 +++-- .../extensions/gsd/worktree-command.ts | 270 ++++++++++++++---- .../extensions/gsd/worktree-manager.ts | 122 ++++++-- 3 files changed, 377 insertions(+), 83 deletions(-) diff --git a/src/resources/extensions/gsd/prompts/worktree-merge.md b/src/resources/extensions/gsd/prompts/worktree-merge.md index a89cb8905..65f865f21 100644 --- a/src/resources/extensions/gsd/prompts/worktree-merge.md +++ b/src/resources/extensions/gsd/prompts/worktree-merge.md @@ -1,8 +1,16 @@ -You are merging GSD artifacts from worktree **{{worktreeName}}** (branch `{{worktreeBranch}}`) into target branch `{{mainBranch}}`. +You are merging changes from worktree **{{worktreeName}}** (branch `{{worktreeBranch}}`) into target branch `{{mainBranch}}`. + +## Working Directory + +Your current working directory has been set to the **main project tree** at `{{mainTreePath}}`. You are on the `{{mainBranch}}` branch. All git and file commands run from here. + +- **Main tree (CWD):** `{{mainTreePath}}` — this is where you run `git merge`, read main-branch files, and commit +- **Worktree directory:** `{{worktreePath}}` — the worktree's working copy; read files here to inspect worktree versions before merging +- **Worktree branch:** `{{worktreeBranch}}` ## Context -The worktree was created as a parallel workspace. It may contain new milestones, updated roadmaps, new plans, research, decisions, or other GSD artifacts that need to be reconciled with the main branch. +The worktree was created as a parallel workspace. It may contain code changes, new milestones, updated roadmaps, new plans, research, decisions, or other artifacts that need to be merged into the target branch. ### Commit History (worktree) @@ -10,7 +18,7 @@ The worktree was created as a parallel workspace. It may contain new milestones, {{commitLog}} ``` -### GSD Artifact Changes +### Changed Files **Added files:** {{addedFiles}} @@ -21,10 +29,16 @@ The worktree was created as a parallel workspace. It may contain new milestones, **Removed files:** {{removedFiles}} -### Full Diff +### Code Diff ```diff -{{fullDiff}} +{{codeDiff}} +``` + +### GSD Artifact Diff + +```diff +{{gsdDiff}} ``` ## Your Task @@ -33,7 +47,15 @@ Analyze the changes and guide the merge. Follow these steps exactly: ### Step 1: Categorize Changes -Classify each changed GSD artifact: +Classify each changed file: + +**Code changes:** +- **New source files** — new modules, components, utilities, tests +- **Modified source files** — changes to existing code +- **Config changes** — package.json, tsconfig, build config, etc. +- **Deleted files** — removed source or config files + +**GSD artifact changes:** - **New milestones** — entirely new M###/ directories with roadmaps - **New slices/tasks** — new planning artifacts within existing milestones - **Updated roadmaps** — modifications to existing M###-ROADMAP.md files @@ -47,7 +69,12 @@ Classify each changed GSD artifact: For each **modified** file, check whether the main branch version has also changed since the worktree branched off. Flag any files where both branches have diverged — these need manual reconciliation. -Read the current main-branch version of each modified file and compare it against both the worktree version and the common ancestor to identify: +To compare versions: +- **Main-branch version:** read the file at its normal path (your CWD is the main tree) +- **Worktree version:** read the file at `{{worktreePath}}/` +- Use `git merge-base {{mainBranch}} {{worktreeBranch}}` to find the common ancestor if needed + +Classify each modified file: - **Clean merges** — main hasn't changed, worktree changes can apply directly - **Conflicts** — both branches changed the same file; needs reconciliation - **Stale changes** — worktree modified a file that main has since replaced or removed @@ -58,28 +85,35 @@ Present a merge plan to the user: 1. For **clean merges**: list files that will merge without conflict 2. For **conflicts**: show both versions side-by-side and propose a reconciled version -3. For **new artifacts**: confirm they should be added to the main branch -4. For **removed artifacts**: confirm the removals are intentional +3. For **new files**: confirm they should be added to the main branch +4. For **removed files**: confirm the removals are intentional Ask the user to confirm the merge plan before proceeding. ### Step 4: Execute Merge -Once confirmed: +Once confirmed, run all commands from `{{mainTreePath}}` (your CWD): -1. If there are conflicts requiring manual reconciliation, apply the reconciled versions to the main branch working tree -2. Run `git merge --squash {{worktreeBranch}}` to bring in all changes -3. Review the staged changes — if any reconciled files need adjustment, apply them now -4. Commit with message: `merge(worktree/{{worktreeName}}): ` -5. Report what was merged +1. Ensure you are on the target branch: `git checkout {{mainBranch}}` +2. If there are conflicts requiring manual reconciliation, apply the reconciled versions first +3. Run `git merge --squash {{worktreeBranch}}` to bring in all changes +4. Review the staged changes — if any reconciled files need adjustment, apply them now +5. Commit with message: `merge(worktree/{{worktreeName}}): ` +6. Report what was merged ### Step 5: Cleanup Prompt After a successful merge, ask the user whether to: -- **Remove the worktree** — delete `.gsd/worktrees/{{worktreeName}}/` and the `{{worktreeBranch}}` branch +- **Remove the worktree** — delete the worktree directory and the `{{worktreeBranch}}` branch - **Keep the worktree** — leave it for continued parallel work -If the user chooses to remove it, run `/worktree remove {{worktreeName}}`. +If the user chooses to remove it, run these commands from `{{mainTreePath}}`: +``` +git worktree remove {{worktreePath}} +git branch -D {{worktreeBranch}} +``` + +**Do NOT use `/worktree remove` — the command handler may not have the correct state after the merge.** Use the git commands directly. ## Important diff --git a/src/resources/extensions/gsd/worktree-command.ts b/src/resources/extensions/gsd/worktree-command.ts index bd085df04..489976cd2 100644 --- a/src/resources/extensions/gsd/worktree-command.ts +++ b/src/resources/extensions/gsd/worktree-command.ts @@ -6,7 +6,7 @@ * Usage: * /worktree — create a new worktree * /worktree list — list existing worktrees - * /worktree merge [target] — start LLM-guided merge (default target: main) + * /worktree merge [name] [target] — start LLM-guided merge (auto-detects when inside a worktree) * /worktree remove — remove a worktree and its branch */ @@ -18,15 +18,18 @@ import { createWorktree, listWorktrees, removeWorktree, - diffWorktreeGSD, + diffWorktreeAll, + diffWorktreeNumstat, getMainBranch, getWorktreeGSDDiff, + getWorktreeCodeDiff, getWorktreeLog, worktreeBranchName, worktreePath, } from "./worktree-manager.js"; +import type { FileLineStat } from "./worktree-manager.js"; import { existsSync, realpathSync, readFileSync, utimesSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join, resolve, sep } from "node:path"; /** * Tracks the original project root so we can switch back. @@ -100,7 +103,7 @@ export function getActiveWorktreeName(): string | null { function worktreeCompletions(prefix: string) { const parts = prefix.trim().split(/\s+/); - const subcommands = ["list", "merge", "remove", "switch", "return"]; + const subcommands = ["list", "merge", "remove", "switch", "create", "return"]; if (parts.length <= 1) { const partial = parts[0] ?? ""; @@ -119,13 +122,21 @@ function worktreeCompletions(prefix: string) { } } - if ((parts[0] === "merge" || parts[0] === "remove" || parts[0] === "switch") && parts.length <= 2) { + if ((parts[0] === "merge" || parts[0] === "remove" || parts[0] === "switch" || parts[0] === "create") && parts.length <= 2) { const namePrefix = parts[1] ?? ""; try { - const existing = listWorktrees(process.cwd()); - return existing + const mainBase = getWorktreeOriginalCwd() ?? process.cwd(); + const existing = listWorktrees(mainBase); + const nameCompletions = existing .filter(wt => wt.name.startsWith(namePrefix)) .map(wt => ({ value: `${parts[0]} ${wt.name}`, label: wt.name })); + + // Add "all" option for remove + if (parts[0] === "remove" && "all".startsWith(namePrefix)) { + nameCompletions.push({ value: "remove all", label: "all" }); + } + + return nameCompletions; } catch { return []; } @@ -151,8 +162,8 @@ async function worktreeHandler( ` /${alias} switch — switch into an existing worktree`, ` /${alias} return — switch back to the main project tree`, ` /${alias} list — list all worktrees`, - ` /${alias} merge [target] — merge worktree into target branch`, - ` /${alias} remove — remove a worktree and its branch`, + ` /${alias} merge [name] [target] — merge worktree into target branch (auto-detects when inside a worktree)`, + ` /${alias} remove — remove a worktree (or all) and its branch`, ].join("\n"), "info", ); @@ -169,41 +180,76 @@ async function worktreeHandler( return; } - if (trimmed.startsWith("switch ")) { - const name = trimmed.replace(/^switch\s+/, "").trim(); + if (trimmed.startsWith("switch ") || trimmed.startsWith("create ")) { + const name = trimmed.replace(/^(?:switch|create)\s+/, "").trim(); if (!name) { - ctx.ui.notify(`Usage: /${alias} switch `, "warning"); + ctx.ui.notify(`Usage: /${alias} ${trimmed.split(" ")[0]} `, "warning"); return; } - await handleSwitch(basePath, name, ctx); + // create and switch both do the same thing: switch if exists, create if not + const mainBase = originalCwd ?? basePath; + const existing = listWorktrees(mainBase); + if (existing.some(wt => wt.name === name)) { + await handleSwitch(basePath, name, ctx); + } else { + await handleCreate(basePath, name, ctx); + } return; } - if (trimmed.startsWith("merge ")) { - const mergeArgs = trimmed.replace(/^merge\s+/, "").trim().split(/\s+/); - const name = mergeArgs[0] ?? ""; + if (trimmed === "merge" || trimmed.startsWith("merge ")) { + const mergeArgs = trimmed.replace(/^merge\s*/, "").trim().split(/\s+/).filter(Boolean); + const mainBase = originalCwd ?? basePath; + const activeWt = getActiveWorktreeName(); + + if (mergeArgs.length === 0) { + // Bare "/worktree merge" — only valid when inside a worktree + if (!activeWt) { + ctx.ui.notify(`Usage: /${alias} merge [target]`, "warning"); + return; + } + await handleMerge(mainBase, activeWt, ctx, pi, undefined); + return; + } + + const name = mergeArgs[0]!; const targetBranch = mergeArgs[1]; - if (!name) { - ctx.ui.notify(`Usage: /${alias} merge [target]`, "warning"); - return; + + // Check if 'name' is an actual worktree + const worktrees = listWorktrees(mainBase); + const isWorktree = worktrees.some(w => w.name === name); + + if (isWorktree) { + await handleMerge(mainBase, name, ctx, pi, targetBranch); + } else if (activeWt) { + // Not a worktree name — user is in a worktree and gave the target branch + // e.g. "/worktree merge main" while inside worktree "new" + await handleMerge(mainBase, activeWt, ctx, pi, name); + } else { + ctx.ui.notify(`Worktree "${name}" not found. Run /${alias} list to see available worktrees.`, "warning"); } - const mainBase = originalCwd ?? basePath; - await handleMerge(mainBase, name, ctx, pi, targetBranch); return; } - if (trimmed.startsWith("remove ")) { - const name = trimmed.replace(/^remove\s+/, "").trim(); - if (!name) { - ctx.ui.notify(`Usage: /${alias} remove `, "warning"); + if (trimmed === "remove" || trimmed.startsWith("remove ")) { + const name = trimmed.replace(/^remove\s*/, "").trim(); + const mainBase = originalCwd ?? basePath; + + if (name === "all") { + await handleRemoveAll(mainBase, ctx); return; } - const mainBase = originalCwd ?? basePath; + + if (!name) { + ctx.ui.notify(`Usage: /${alias} remove `, "warning"); + return; + } + await handleRemove(mainBase, name, ctx); return; } - const RESERVED = ["list", "return", "switch", "merge", "remove"]; + const RESERVED = ["list", "return", "switch", "create", "merge", "remove"]; if (RESERVED.includes(trimmed)) { ctx.ui.notify(`Usage: /${alias} ${trimmed}${trimmed === "list" || trimmed === "return" ? "" : " "}`, "warning"); return; @@ -225,8 +271,20 @@ async function worktreeHandler( } export function registerWorktreeCommand(pi: ExtensionAPI): void { + // Restore worktree state after /reload. + // The module-level originalCwd resets to null when extensions are re-loaded, + // but process.cwd() is still inside the worktree. Detect this and recover. + if (!originalCwd) { + const cwd = process.cwd(); + const marker = `${sep}.gsd${sep}worktrees${sep}`; + const markerIdx = cwd.indexOf(marker); + if (markerIdx !== -1) { + originalCwd = cwd.slice(0, markerIdx); + } + } + pi.registerCommand("worktree", { - description: "Git worktrees: /worktree | list | merge [target] | remove ", + description: "Git worktrees (also /wt): /worktree | list | merge | remove", getArgumentCompletions: worktreeCompletions, async handler(args: string, ctx: ExtensionCommandContext) { @@ -236,7 +294,7 @@ export function registerWorktreeCommand(pi: ExtensionAPI): void { // /wt alias — same handler, same completions pi.registerCommand("wt", { - description: "Alias for /worktree — Git worktrees: /wt | list | merge | remove", + description: "Alias for /worktree", getArgumentCompletions: worktreeCompletions, async handler(args: string, ctx: ExtensionCommandContext) { await worktreeHandler(args, ctx, pi, "wt"); @@ -362,6 +420,7 @@ const DIM = "\x1b[2m"; const RESET = "\x1b[0m"; const CYAN = "\x1b[36m"; const GREEN = "\x1b[32m"; +const RED = "\x1b[31m"; const YELLOW = "\x1b[33m"; const WHITE = "\x1b[37m"; @@ -423,9 +482,11 @@ async function handleMerge( return; } - // Gather merge context - const diffSummary = diffWorktreeGSD(basePath, name); - const fullDiff = getWorktreeGSDDiff(basePath, name); + // Gather merge context — full repo diff, not just .gsd/ + const diffSummary = diffWorktreeAll(basePath, name); + const numstat = diffWorktreeNumstat(basePath, name); + const gsdDiff = getWorktreeGSDDiff(basePath, name); + const codeDiff = getWorktreeCodeDiff(basePath, name); const commitLog = getWorktreeLog(basePath, name); const totalChanges = diffSummary.added.length + diffSummary.modified.length + diffSummary.removed.length; @@ -434,27 +495,48 @@ async function handleMerge( return; } + // Build a map of file → line stats for the preview + const statMap = new Map(); + for (const s of numstat) statMap.set(s.file, s); + + // Compute totals + let totalAdded = 0; + let totalRemoved = 0; + for (const s of numstat) { totalAdded += s.added; totalRemoved += s.removed; } + + // Split files into code vs GSD for the preview + const isGSD = (f: string) => f.startsWith(".gsd/"); + const codeChanges = diffSummary.added.filter(f => !isGSD(f)).length + + diffSummary.modified.filter(f => !isGSD(f)).length + + diffSummary.removed.filter(f => !isGSD(f)).length; + const gsdChanges = diffSummary.added.filter(isGSD).length + + diffSummary.modified.filter(isGSD).length + + diffSummary.removed.filter(isGSD).length; + + // Format a file line with +/- stats + const formatFileLine = (prefix: string, file: string): string => { + const s = statMap.get(file); + const stat = s ? ` ${GREEN}+${s.added}${RESET} ${RED}-${s.removed}${RESET}` : ""; + return ` ${prefix} ${file}${stat}`; + }; + // Preview confirmation before merge dispatch const previewLines = [ `Merge worktree "${name}" → ${mainBranch}`, "", - ` ${diffSummary.added.length} added · ${diffSummary.modified.length} modified · ${diffSummary.removed.length} removed`, + ` ${totalChanges} file${totalChanges === 1 ? "" : "s"} changed, ${GREEN}+${totalAdded}${RESET} ${RED}-${totalRemoved}${RESET} lines (${codeChanges} code, ${gsdChanges} GSD)`, ]; - if (diffSummary.added.length > 0) { - previewLines.push("", " Added:"); - for (const f of diffSummary.added.slice(0, 10)) previewLines.push(` + ${f}`); - if (diffSummary.added.length > 10) previewLines.push(` … and ${diffSummary.added.length - 10} more`); - } - if (diffSummary.modified.length > 0) { - previewLines.push("", " Modified:"); - for (const f of diffSummary.modified.slice(0, 10)) previewLines.push(` ~ ${f}`); - if (diffSummary.modified.length > 10) previewLines.push(` … and ${diffSummary.modified.length - 10} more`); - } - if (diffSummary.removed.length > 0) { - previewLines.push("", " Removed:"); - for (const f of diffSummary.removed.slice(0, 10)) previewLines.push(` - ${f}`); - if (diffSummary.removed.length > 10) previewLines.push(` … and ${diffSummary.removed.length - 10} more`); - } + + const appendFileList = (label: string, files: string[], prefix: string, limit = 10) => { + if (files.length === 0) return; + previewLines.push("", ` ${label}:`); + for (const f of files.slice(0, limit)) previewLines.push(formatFileLine(prefix, f)); + if (files.length > limit) previewLines.push(` … and ${files.length - limit} more`); + }; + + appendFileList("Added", diffSummary.added, "+"); + appendFileList("Modified", diffSummary.modified, "~"); + appendFileList("Removed", diffSummary.removed, "-"); const confirmed = await showConfirm(ctx, { title: "Worktree Merge", @@ -467,20 +549,34 @@ async function handleMerge( return; } + // Switch to the main tree before dispatching the merge. + // The LLM needs to run git merge --squash from the main branch, and if + // it later removes the worktree, the agent's CWD must not be inside it. + if (originalCwd) { + const prevCwd = process.cwd(); + process.chdir(basePath); + nudgeGitBranchCache(prevCwd); + originalCwd = null; + } + // Format file lists for the prompt const formatFiles = (files: string[]) => files.length > 0 ? files.map(f => `- \`${f}\``).join("\n") : "_(none)_"; // Load and populate the merge prompt + const wtPath = worktreePath(basePath, name); const prompt = loadPrompt("worktree-merge", { worktreeName: name, worktreeBranch: branch, mainBranch, + mainTreePath: basePath, + worktreePath: wtPath, commitLog: commitLog || "(no commits)", addedFiles: formatFiles(diffSummary.added), modifiedFiles: formatFiles(diffSummary.modified), removedFiles: formatFiles(diffSummary.removed), - fullDiff: fullDiff || "(no diff)", + gsdDiff: gsdDiff || "(no GSD artifact changes)", + codeDiff: codeDiff || "(no code changes)", }); // Dispatch to the LLM @@ -494,7 +590,7 @@ async function handleMerge( ); ctx.ui.notify( - `Merge helper started for worktree "${name}" (${totalChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`, + `Merge helper started for worktree "${name}" (${codeChanges} code + ${gsdChanges} GSD artifact change${totalChanges === 1 ? "" : "s"}).`, "info", ); } catch (error) { @@ -510,6 +606,26 @@ async function handleRemove( ): Promise { try { const mainBase = originalCwd ?? basePath; + + // Validate the worktree exists before attempting removal + const worktrees = listWorktrees(mainBase); + const wt = worktrees.find(w => w.name === name); + if (!wt) { + ctx.ui.notify(`Worktree "${name}" not found. Run /worktree list to see available worktrees.`, "warning"); + return; + } + + const confirmed = await showConfirm(ctx, { + title: "Remove Worktree", + message: `Remove worktree "${name}" and delete branch ${wt.branch}?`, + confirmLabel: "Remove", + declineLabel: "Cancel", + }); + if (!confirmed) { + ctx.ui.notify("Cancelled.", "info"); + return; + } + const prevCwd = process.cwd(); removeWorktree(mainBase, name, { deleteBranch: true }); @@ -525,3 +641,57 @@ async function handleRemove( ctx.ui.notify(`Failed to remove worktree: ${msg}`, "error"); } } + +async function handleRemoveAll( + basePath: string, + ctx: ExtensionCommandContext, +): Promise { + try { + const mainBase = originalCwd ?? basePath; + const worktrees = listWorktrees(mainBase); + + if (worktrees.length === 0) { + ctx.ui.notify("No worktrees to remove.", "info"); + return; + } + + const names = worktrees.map(w => w.name); + const confirmed = await showConfirm(ctx, { + title: "Remove All Worktrees", + message: `This will remove ${worktrees.length} worktree${worktrees.length === 1 ? "" : "s"} and delete their branches:\n\n${names.map(n => ` • ${n}`).join("\n")}`, + confirmLabel: "Remove all", + declineLabel: "Cancel", + }); + if (!confirmed) { + ctx.ui.notify("Cancelled.", "info"); + return; + } + + const prevCwd = process.cwd(); + const removed: string[] = []; + const failed: string[] = []; + + for (const wt of worktrees) { + try { + removeWorktree(mainBase, wt.name, { deleteBranch: true }); + removed.push(wt.name); + } catch { + failed.push(wt.name); + } + } + + // If we were in a worktree that got removed, clear tracking + if (originalCwd && process.cwd() !== prevCwd) { + nudgeGitBranchCache(prevCwd); + originalCwd = null; + } + + const lines: string[] = []; + if (removed.length > 0) lines.push(`Removed: ${removed.join(", ")}`); + if (failed.length > 0) lines.push(`Failed: ${failed.join(", ")}`); + ctx.ui.notify(lines.join("\n"), failed.length > 0 ? "warning" : "info"); + } catch (error) { + const msg = error instanceof Error ? error.message : String(error); + ctx.ui.notify(`Failed to remove worktrees: ${msg}`, "error"); + } +} diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index e26000644..217826d9c 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -28,6 +28,13 @@ export interface WorktreeInfo { exists: boolean; } +/** Per-file line change stats from git diff --numstat. */ +export interface FileLineStat { + file: string; + added: number; + removed: number; +} + export interface WorktreeDiffSummary { /** Files only in the worktree .gsd/ (new artifacts) */ added: string[]; @@ -109,6 +116,18 @@ export function createWorktree(basePath: string, name: string): WorktreeInfo { const mainBranch = getMainBranch(basePath); if (branchExists) { + // Check if the branch is actively used by an existing worktree. + // `git branch -f` will fail if the branch is checked out somewhere. + const worktreeUsing = runGit(basePath, ["worktree", "list", "--porcelain"], { allowFailure: true }); + const branchInUse = worktreeUsing.includes(`branch refs/heads/${branch}`); + + if (branchInUse) { + throw new Error( + `Branch "${branch}" is already in use by another worktree. ` + + `Remove the existing worktree first with /worktree remove ${name}.`, + ); + } + // Reset the stale branch to current main, then attach worktree to it runGit(basePath, ["branch", "-f", branch, mainBranch]); runGit(basePath, ["worktree", "add", wtPath, branch]); @@ -212,19 +231,17 @@ export function removeWorktree( } } -/** - * Diff the .gsd/ directory between the worktree branch and main branch. - * Returns a summary of added, modified, and removed GSD artifacts. - */ -export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSummary { - const branch = worktreeBranchName(name); - const mainBranch = getMainBranch(basePath); +/** Paths to skip in all worktree diffs (internal/runtime artifacts). */ +const SKIP_PATHS = [".gsd/worktrees/", ".gsd/runtime/", ".gsd/activity/"]; +const SKIP_EXACT = [".gsd/STATE.md", ".gsd/auto.lock", ".gsd/metrics.json"]; - // Use git diff to compare .gsd/ between branches - const diffOutput = runGit(basePath, [ - "diff", "--name-status", `${mainBranch}...${branch}`, "--", ".gsd/", - ], { allowFailure: true }); +function shouldSkipPath(filePath: string): boolean { + if (SKIP_PATHS.some(p => filePath.startsWith(p))) return true; + if (SKIP_EXACT.includes(filePath)) return true; + return false; +} +function parseDiffNameStatus(diffOutput: string): WorktreeDiffSummary { const added: string[] = []; const modified: string[] = []; const removed: string[] = []; @@ -235,11 +252,7 @@ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSum const [status, ...pathParts] = line.split("\t"); const filePath = pathParts.join("\t"); - // Skip worktree-internal paths (e.g. .gsd/worktrees/, .gsd/runtime/) - if (filePath.startsWith(".gsd/worktrees/") || filePath.startsWith(".gsd/runtime/")) continue; - // Skip gitignored runtime files - if (filePath === ".gsd/STATE.md" || filePath === ".gsd/auto.lock" || filePath === ".gsd/metrics.json") continue; - if (filePath.startsWith(".gsd/activity/")) continue; + if (shouldSkipPath(filePath)) continue; switch (status) { case "A": added.push(filePath); break; @@ -256,6 +269,68 @@ export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSum return { added, modified, removed }; } +/** + * Diff the .gsd/ directory between the worktree branch and main branch. + * Returns a summary of added, modified, and removed GSD artifacts. + */ +export function diffWorktreeGSD(basePath: string, name: string): WorktreeDiffSummary { + const branch = worktreeBranchName(name); + const mainBranch = getMainBranch(basePath); + + const diffOutput = runGit(basePath, [ + "diff", "--name-status", `${mainBranch}...${branch}`, "--", ".gsd/", + ], { allowFailure: true }); + + return parseDiffNameStatus(diffOutput); +} + +/** + * Diff ALL files between the worktree branch and main branch. + * Returns a summary of added, modified, and removed files across the entire repo. + */ +/** + * Diff ALL files between the worktree branch and main branch. + * Uses direct diff (no merge-base) to show what will actually change + * on main when the merge is applied. If both branches have identical + * content, this correctly returns an empty diff. + */ +export function diffWorktreeAll(basePath: string, name: string): WorktreeDiffSummary { + const branch = worktreeBranchName(name); + const mainBranch = getMainBranch(basePath); + + const diffOutput = runGit(basePath, [ + "diff", "--name-status", mainBranch, branch, + ], { allowFailure: true }); + + return parseDiffNameStatus(diffOutput); +} + +/** + * Get per-file line addition/deletion stats for what will change on main. + * Uses direct diff (not merge-base) so the preview matches the actual merge outcome. + */ +export function diffWorktreeNumstat(basePath: string, name: string): FileLineStat[] { + const branch = worktreeBranchName(name); + const mainBranch = getMainBranch(basePath); + + const raw = runGit(basePath, [ + "diff", "--numstat", mainBranch, branch, + ], { allowFailure: true }); + + if (!raw.trim()) return []; + + const stats: FileLineStat[] = []; + for (const line of raw.split("\n").filter(Boolean)) { + const [a, r, ...pathParts] = line.split("\t"); + const file = pathParts.join("\t"); + if (shouldSkipPath(file)) continue; + const added = a === "-" ? 0 : parseInt(a ?? "0", 10); + const removed = r === "-" ? 0 : parseInt(r ?? "0", 10); + stats.push({ file, added, removed }); + } + return stats; +} + /** * Get the full diff content for .gsd/ between the worktree branch and main. * Returns the raw unified diff for LLM consumption. @@ -269,6 +344,21 @@ export function getWorktreeGSDDiff(basePath: string, name: string): string { ], { allowFailure: true }); } +/** + * Get the full diff content for non-.gsd/ files between the worktree branch and main. + * Returns the raw unified diff for LLM consumption. + */ +export function getWorktreeCodeDiff(basePath: string, name: string): string { + const branch = worktreeBranchName(name); + const mainBranch = getMainBranch(basePath); + + // Get full diff, then exclude .gsd/ paths + // We use pathspec magic to exclude .gsd/ + return runGit(basePath, [ + "diff", `${mainBranch}...${branch}`, "--", ".", ":(exclude).gsd/", + ], { allowFailure: true }); +} + /** * Get commit log for the worktree branch since it diverged from main. */ From 8bd27f74e0d5d0a2127f6ce4a8a301502925ee9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?T=C3=82CHES?= Date: Wed, 11 Mar 2026 16:08:46 -0600 Subject: [PATCH 34/60] fix: idle watchdog false-fires on active agents (#52) (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle watchdog checked lastProgressAt to detect stalled agents, but nothing updated that timestamp during normal execution. Any task taking >10min triggered false idle recovery, steering messages, and eventually got skipped — even while actively writing code. Add detectWorkingTreeActivity() check before recovery: if git reports uncommitted changes, the agent is working. Bump lastProgressAt and skip recovery. Genuinely idle agents (clean working tree) still get recovered as before. Co-authored-by: Claude Opus 4.6 --- src/resources/extensions/gsd/auto.ts | 31 +++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index a6ab8a360..a7326bf19 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -36,7 +36,6 @@ import { clearUnitRuntimeRecord, formatExecuteTaskRecoveryStatus, inspectExecuteTaskDurability, - recordUnitProgress, readUnitRuntimeRecord, writeUnitRuntimeRecord, } from "./unit-runtime.js"; @@ -985,6 +984,17 @@ async function dispatchNextUnit( if (!runtime) return; if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return; + // Before triggering recovery, check if the agent is actually producing + // work on disk. `git status --porcelain` is cheap and catches any + // staged/unstaged/untracked changes the agent made since lastProgressAt. + if (detectWorkingTreeActivity(basePath)) { + writeUnitRuntimeRecord(basePath, unitType, unitId, currentUnit.startedAt, { + lastProgressAt: Date.now(), + lastProgressKind: "filesystem-activity", + }); + return; + } + if (currentUnit) { const modelId = ctx.model?.id ?? "unknown"; snapshotUnitMetrics(ctx, currentUnit.type, currentUnit.id, currentUnit.startedAt, modelId); @@ -2136,6 +2146,25 @@ export function skipExecuteTask( return true; } +/** + * Detect whether the agent is producing work on disk by checking git for + * any working-tree changes (staged, unstaged, or untracked). Returns true + * if there are uncommitted changes — meaning the agent is actively working, + * even though it hasn't signaled progress through runtime records. + */ +function detectWorkingTreeActivity(cwd: string): boolean { + try { + const out = execSync("git status --porcelain", { + cwd, + stdio: ["pipe", "pipe", "pipe"], + timeout: 5000, + }); + return out.toString().trim().length > 0; + } catch { + return false; + } +} + /** * Resolve the expected artifact for a non-execute-task unit to an absolute path. * Returns null for unit types that don't produce a single file (execute-task, From 5bb3229a85153df5a3b4175e9959f91eb31b5bc7 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 15:12:28 -0600 Subject: [PATCH 35/60] feat: add /gsd next (step mode), make bare /gsd default to step mode, delete /gsd-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /gsd next: same state machine as /gsd auto but pauses between units with a wizard showing what completed and what's next - /gsd (bare): now defaults to step mode instead of the old guided flow - /gsd auto: unchanged — continuous execution without pausing - Deleted /gsd-run slash command (redundant with /gsd auto) - Step mode preserves through discuss → auto-start transition - User can switch from step → auto mid-session via wizard option - Progress widget shows NEXT/AUTO based on current mode --- .gitignore | 4 +- src/resources/extensions/gsd/auto.ts | 142 +++++++++++++++++- src/resources/extensions/gsd/commands.ts | 19 ++- src/resources/extensions/gsd/guided-flow.ts | 13 +- .../extensions/slash-commands/gsd-run.ts | 34 ----- .../extensions/slash-commands/index.ts | 2 - 6 files changed, 158 insertions(+), 56 deletions(-) delete mode 100644 src/resources/extensions/slash-commands/gsd-run.ts diff --git a/.gitignore b/.gitignore index 83ccc990f..35a2ad14c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ dist/ .bg_shell .gsd*.tgz .artifacts/ -AGENTS.md \ No newline at end of file +AGENTS.md +.bg-shell/ +TODOS.md \ No newline at end of file diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index a7326bf19..03eec7354 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -64,11 +64,13 @@ import { } from "./worktree.ts"; import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; import { makeUI, GLYPH, INDENT } from "../shared/ui.js"; +import { showNextAction } from "../shared/next-action-ui.js"; // ─── State ──────────────────────────────────────────────────────────────────── let active = false; let paused = false; +let stepMode = false; let verbose = false; let cmdCtx: ExtensionCommandContext | null = null; let basePath = ""; @@ -101,6 +103,7 @@ let idleWatchdogHandle: ReturnType | null = null; export interface AutoDashboardData { active: boolean; paused: boolean; + stepMode: boolean; startTime: number; elapsed: number; currentUnit: { type: string; id: string; startedAt: number } | null; @@ -117,6 +120,7 @@ export function getAutoDashboardData(): AutoDashboardData { return { active, paused, + stepMode, startTime: autoStartTime, elapsed: (active || paused) ? Date.now() - autoStartTime : 0, currentUnit: currentUnit ? { ...currentUnit } : null, @@ -137,6 +141,10 @@ export function isAutoPaused(): boolean { return paused; } +export function isStepMode(): boolean { + return stepMode; +} + function clearUnitTimeout(): void { if (unitTimeoutHandle) { clearTimeout(unitTimeoutHandle); @@ -173,6 +181,7 @@ export async function stopAuto(ctx?: ExtensionContext, pi?: ExtensionAPI): Promi resetMetrics(); active = false; paused = false; + stepMode = false; lastUnit = null; currentUnit = null; currentMilestoneId = null; @@ -207,8 +216,9 @@ export async function pauseAuto(ctx?: ExtensionContext, _pi?: ExtensionAPI): Pro // — all needed for resume and dashboard display ctx?.ui.setStatus("gsd-auto", "paused"); ctx?.ui.setWidget("gsd-progress", undefined); + const resumeCmd = stepMode ? "/gsd next" : "/gsd auto"; ctx?.ui.notify( - "Auto-mode paused (Escape). Type to interact, or /gsd auto to resume.", + `${stepMode ? "Step" : "Auto"}-mode paused (Escape). Type to interact, or ${resumeCmd} to resume.`, "info", ); } @@ -218,19 +228,24 @@ export async function startAuto( pi: ExtensionAPI, base: string, verboseMode: boolean, + options?: { step?: boolean }, ): Promise { + const requestedStepMode = options?.step ?? false; + // If resuming from paused state, just re-activate and dispatch next unit. // The conversation is still intact — no need to reinitialize everything. if (paused) { paused = false; active = true; verbose = verboseMode; + // Allow switching between step/auto on resume + stepMode = requestedStepMode; cmdCtx = ctx; basePath = base; // Re-initialize metrics in case ledger was lost during pause if (!getLedger()) initMetrics(base); - ctx.ui.setStatus("gsd-auto", "auto"); - ctx.ui.notify("Auto-mode resumed.", "info"); + ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); + ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info"); await dispatchNextUnit(ctx, pi); return; } @@ -286,7 +301,7 @@ export async function startAuto( // No active work at all — start a new milestone via the discuss flow. if (!state.activeMilestone || state.phase === "complete") { const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base); + await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); return; } @@ -298,13 +313,14 @@ export async function startAuto( const hasContext = !!(contextFile && await loadFile(contextFile)); if (!hasContext) { const { showSmartEntry } = await import("./guided-flow.js"); - await showSmartEntry(ctx, pi, base); + await showSmartEntry(ctx, pi, base, { step: requestedStepMode }); return; } // Has context, no roadmap — auto-mode will research + plan it } active = true; + stepMode = requestedStepMode; verbose = verboseMode; cmdCtx = ctx; basePath = base; @@ -324,12 +340,13 @@ export async function startAuto( snapshotSkills(); } - ctx.ui.setStatus("gsd-auto", "auto"); + ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); + const modeLabel = stepMode ? "Step-mode" : "Auto-mode"; const pendingCount = state.registry.filter(m => m.status !== 'complete').length; const scopeMsg = pendingCount > 1 ? `Will loop through ${pendingCount} milestones.` : "Will loop until milestone complete."; - ctx.ui.notify(`Auto-mode started. ${scopeMsg}`, "info"); + ctx.ui.notify(`${modeLabel} started. ${scopeMsg}`, "info"); // Dispatch the first unit await dispatchNextUnit(ctx, pi); @@ -361,9 +378,117 @@ export async function handleAgentEnd( } } + // In step mode, pause and show a wizard instead of immediately dispatching + if (stepMode) { + await showStepWizard(ctx, pi); + return; + } + await dispatchNextUnit(ctx, pi); } +// ─── Step Mode Wizard ───────────────────────────────────────────────────── + +/** + * Show the step-mode wizard after a unit completes. + * Derives the next unit from disk state and presents it to the user. + * If the user confirms, dispatches the next unit. If not, pauses. + */ +async function showStepWizard( + ctx: ExtensionContext, + pi: ExtensionAPI, +): Promise { + if (!cmdCtx) return; + + const state = await deriveState(basePath); + const mid = state.activeMilestone?.id; + + // Build summary of what just completed + const justFinished = currentUnit + ? `${unitVerb(currentUnit.type)} ${currentUnit.id}` + : "previous unit"; + + // If no active milestone or everything is complete, stop + if (!mid || state.phase === "complete") { + await stopAuto(ctx, pi); + return; + } + + // Peek at what's next by examining state + const nextDesc = describeNextUnit(state); + + const choice = await showNextAction(cmdCtx, { + title: `GSD — ${justFinished} complete`, + summary: [ + `${mid}: ${state.activeMilestone?.title ?? mid}`, + ...(state.activeSlice ? [`${state.activeSlice.id}: ${state.activeSlice.title}`] : []), + ], + actions: [ + { + id: "continue", + label: nextDesc.label, + description: nextDesc.description, + recommended: true, + }, + { + id: "auto", + label: "Switch to auto", + description: "Continue without pausing between steps.", + }, + { + id: "status", + label: "View status", + description: "Open the dashboard.", + }, + ], + notYetMessage: "Run /gsd next when ready to continue.", + }); + + if (choice === "continue") { + await dispatchNextUnit(ctx, pi); + } else if (choice === "auto") { + stepMode = false; + ctx.ui.setStatus("gsd-auto", "auto"); + ctx.ui.notify("Switched to auto-mode.", "info"); + await dispatchNextUnit(ctx, pi); + } else if (choice === "status") { + // Show status then re-show the wizard + const { fireStatusViaCommand } = await import("./commands.js"); + await fireStatusViaCommand(ctx as ExtensionCommandContext); + await showStepWizard(ctx, pi); + } else { + // "not_yet" — pause + await pauseAuto(ctx, pi); + } +} + +/** + * Describe what the next unit will be, based on current state. + */ +function describeNextUnit(state: GSDState): { label: string; description: string } { + const sid = state.activeSlice?.id; + const sTitle = state.activeSlice?.title; + const tid = state.activeTask?.id; + const tTitle = state.activeTask?.title; + + switch (state.phase) { + case "pre-planning": + return { label: "Research & plan milestone", description: "Scout the landscape and create the roadmap." }; + case "planning": + return { label: `Plan ${sid}: ${sTitle}`, description: "Research and decompose into tasks." }; + case "executing": + return { label: `Execute ${tid}: ${tTitle}`, description: "Run the next task in a fresh session." }; + case "summarizing": + return { label: `Complete ${sid}: ${sTitle}`, description: "Write summary, UAT, and merge to main." }; + case "replanning-slice": + return { label: `Replan ${sid}: ${sTitle}`, description: "Blocker found — replan the slice." }; + case "completing-milestone": + return { label: "Complete milestone", description: "Write milestone summary." }; + default: + return { label: "Continue", description: "Execute the next step." }; + } +} + // ─── Progress Widget ────────────────────────────────────────────────────── function unitVerb(unitType: string): string { @@ -464,7 +589,8 @@ function updateProgressWidget( ? theme.fg("accent", GLYPH.statusActive) : theme.fg("dim", GLYPH.statusPending); const elapsed = formatAutoElapsed(); - const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", "AUTO")}`; + const modeTag = stepMode ? "NEXT" : "AUTO"; + const headerLeft = `${pad}${dot} ${theme.fg("accent", theme.bold("GSD"))} ${theme.fg("success", modeTag)}`; const headerRight = elapsed ? theme.fg("dim", elapsed) : ""; lines.push(rightAlign(headerLeft, headerRight, width)); diff --git a/src/resources/extensions/gsd/commands.ts b/src/resources/extensions/gsd/commands.ts index 65ac405a2..f682fe758 100644 --- a/src/resources/extensions/gsd/commands.ts +++ b/src/resources/extensions/gsd/commands.ts @@ -10,8 +10,8 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { deriveState } from "./state.js"; import { GSDDashboardOverlay } from "./dashboard-overlay.js"; -import { showSmartEntry, showQueue, showDiscuss } from "./guided-flow.js"; -import { startAuto, stopAuto, isAutoActive, isAutoPaused } from "./auto.js"; +import { showQueue, showDiscuss } from "./guided-flow.js"; +import { startAuto, stopAuto, isAutoActive, isAutoPaused, isStepMode } from "./auto.js"; import { getGlobalGSDPreferencesPath, getLegacyGlobalGSDPreferencesPath, @@ -52,10 +52,10 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT export function registerGSDCommand(pi: ExtensionAPI): void { pi.registerCommand("gsd", { - description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate", + description: "GSD — Get Shit Done: /gsd next|auto|stop|status|queue|prefs|doctor|migrate", getArgumentCompletions: (prefix: string) => { - const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"]; + const subcommands = ["next", "auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"]; const parts = prefix.trim().split(/\s+/); if (parts.length <= 1) { @@ -112,6 +112,12 @@ export function registerGSDCommand(pi: ExtensionAPI): void { return; } + if (trimmed === "next" || trimmed.startsWith("next ")) { + const verboseMode = trimmed.includes("--verbose"); + await startAuto(ctx, pi, process.cwd(), verboseMode, { step: true }); + return; + } + if (trimmed === "auto" || trimmed.startsWith("auto ")) { const verboseMode = trimmed.includes("--verbose"); await startAuto(ctx, pi, process.cwd(), verboseMode); @@ -143,12 +149,13 @@ export function registerGSDCommand(pi: ExtensionAPI): void { } if (trimmed === "") { - await showSmartEntry(ctx, pi, process.cwd()); + // Bare /gsd defaults to step mode + await startAuto(ctx, pi, process.cwd(), false, { step: true }); return; } ctx.ui.notify( - `Unknown: /gsd ${trimmed}. Use /gsd, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate .`, + `Unknown: /gsd ${trimmed}. Use /gsd, /gsd next, /gsd auto, /gsd stop, /gsd status, /gsd queue, /gsd discuss, /gsd prefs [global|project|status], /gsd doctor [audit|fix|heal] [M###/S##], or /gsd migrate .`, "warning", ); }, diff --git a/src/resources/extensions/gsd/guided-flow.ts b/src/resources/extensions/gsd/guided-flow.ts index 6fc60f9d1..775d14fd0 100644 --- a/src/resources/extensions/gsd/guided-flow.ts +++ b/src/resources/extensions/gsd/guided-flow.ts @@ -31,13 +31,14 @@ let pendingAutoStart: { pi: ExtensionAPI; basePath: string; milestoneId: string; // the milestone being discussed + step?: boolean; // preserve step mode through discuss → auto transition } | null = null; /** Called from agent_end to check if auto-mode should start after discuss */ export function checkAutoStartAfterDiscuss(): boolean { if (!pendingAutoStart) return false; - const { ctx, pi, basePath, milestoneId } = pendingAutoStart; + const { ctx, pi, basePath, milestoneId, step } = pendingAutoStart; // Don't fire until the discuss phase has actually produced a context file // for the milestone being discussed. agent_end fires after every LLM turn, @@ -47,7 +48,7 @@ export function checkAutoStartAfterDiscuss(): boolean { if (!contextFile) return false; // no context yet — keep waiting pendingAutoStart = null; - startAuto(ctx, pi, basePath, false).catch(() => {}); + startAuto(ctx, pi, basePath, false, { step }).catch(() => {}); return true; } @@ -435,7 +436,9 @@ export async function showSmartEntry( ctx: ExtensionCommandContext, pi: ExtensionAPI, basePath: string, + options?: { step?: boolean }, ): Promise { + const stepMode = options?.step; // ── Ensure git repo exists — GSD needs it for branch-per-slice ────── try { @@ -501,7 +504,7 @@ export async function showSmartEntry( if (isFirst) { // First ever — skip wizard, just ask directly - pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId }; + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New project, milestone ${nextId}. Do NOT read or explore .gsd/ — it's empty scaffolding.`, basePath @@ -522,7 +525,7 @@ export async function showSmartEntry( }); if (choice === "new_milestone") { - pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId }; + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath @@ -560,7 +563,7 @@ export async function showSmartEntry( const milestoneIds = findMilestoneIds(basePath); const nextId = `M${String(milestoneIds.length + 1).padStart(3, "0")}`; - pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId }; + pendingAutoStart = { ctx, pi, basePath, milestoneId: nextId, step: stepMode }; dispatchWorkflow(pi, buildDiscussPrompt(nextId, `New milestone ${nextId}.`, basePath diff --git a/src/resources/extensions/slash-commands/gsd-run.ts b/src/resources/extensions/slash-commands/gsd-run.ts deleted file mode 100644 index 21d26fa28..000000000 --- a/src/resources/extensions/slash-commands/gsd-run.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent"; - -export default function gsdRun(pi: ExtensionAPI) { - pi.registerCommand("gsd-run", { - description: "Read GSD-WORKFLOW.md and execute — lightweight protocol-driven GSD", - async handler(args: string, ctx: ExtensionCommandContext) { - const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md"); - - let workflow: string; - try { - workflow = readFileSync(workflowPath, "utf-8"); - } catch { - ctx.ui.notify(`Cannot read ${workflowPath}`, "error"); - return; - } - - const userNote = (typeof args === "string" ? args : "").trim(); - const noteSection = userNote - ? `\n\n## User Note\n\n${userNote}\n` - : ""; - - pi.sendMessage( - { - customType: "gsd-run", - content: `Read the following GSD workflow protocol and execute exactly.\n\n${workflow}${noteSection}`, - display: false, - }, - { triggerTurn: true }, - ); - }, - }); -} diff --git a/src/resources/extensions/slash-commands/index.ts b/src/resources/extensions/slash-commands/index.ts index 8d3be0e02..52ab77bf4 100644 --- a/src/resources/extensions/slash-commands/index.ts +++ b/src/resources/extensions/slash-commands/index.ts @@ -2,13 +2,11 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import createSlashCommand from "./create-slash-command.js"; import createExtension from "./create-extension.js"; import auditCommand from "./audit.js"; -import gsdRun from "./gsd-run.js"; import clearCommand from "./clear.js"; export default function slashCommands(pi: ExtensionAPI) { createSlashCommand(pi); createExtension(pi); auditCommand(pi); - gsdRun(pi); clearCommand(pi); } From b4ccbadd090fa95c1e7a88c3b00c11908bf8f718 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:12:08 -0600 Subject: [PATCH 36/60] feat(gsd): add post-hook bookkeeping after each auto-mode unit Run doctor (fix mode) and rebuild STATE.md after each unit completes in handleAgentEnd. Catches missed checkboxes and stub summaries the LLM may have skipped, and keeps STATE.md in sync with disk state. Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/gsd/auto.ts | 25 ++++++++++++++++++++++++- src/resources/extensions/gsd/doctor.ts | 7 +++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 03eec7354..910cdc931 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -18,7 +18,7 @@ import type { import { deriveState } from "./state.js"; import type { GSDState } from "./types.js"; -import { loadFile, parseContinue, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary } from "./files.js"; +import { loadFile, parseContinue, parsePlan, parseRoadmap, parseSummary, extractUatType, inlinePriorMilestoneSummary } from "./files.js"; export { inlinePriorMilestoneSummary }; import type { UatType } from "./files.js"; import { loadPrompt } from "./prompt-loader.js"; @@ -48,6 +48,7 @@ import { formatValidationIssues, } from "./observability-validator.js"; import { ensureGitignore } from "./gitignore.js"; +import { runGSDDoctor, rebuildState } from "./doctor.js"; import { snapshotSkills, clearSkillSnapshot } from "./skill-discovery.js"; import { initMetrics, resetMetrics, snapshotUnitMetrics, getLedger, @@ -376,6 +377,28 @@ export async function handleAgentEnd( } catch { // Non-fatal } + + // Post-hook: fix mechanical bookkeeping the LLM may have skipped. + // 1. Doctor handles: checkbox marking, stub summaries/UATs. + // 2. STATE.md is always rebuilt from disk state (purely derived, no LLM needed). + // This is more reliable than prompt instructions for mechanical tasks. + // Scope to slice level (M001/S01) so doctor checks all tasks within the slice. + try { + const scopeParts = currentUnit.id.split("/").slice(0, 2); + const doctorScope = scopeParts.join("/"); + const report = await runGSDDoctor(basePath, { fix: true, scope: doctorScope }); + if (report.fixesApplied.length > 0) { + ctx.ui.notify(`Post-hook: applied ${report.fixesApplied.length} fix(es).`, "info"); + } + } catch { + // Non-fatal — doctor failure should never block dispatch + } + try { + await rebuildState(basePath); + autoCommitCurrentBranch(basePath, currentUnit.type, currentUnit.id); + } catch { + // Non-fatal + } } // In step mode, pause and show a wizard instead of immediately dispatching diff --git a/src/resources/extensions/gsd/doctor.ts b/src/resources/extensions/gsd/doctor.ts index 9bcb434e6..55fe9f864 100644 --- a/src/resources/extensions/gsd/doctor.ts +++ b/src/resources/extensions/gsd/doctor.ts @@ -147,6 +147,13 @@ async function updateStateFile(basePath: string, fixesApplied: string[]): Promis fixesApplied.push(`updated ${path}`); } +/** Rebuild STATE.md from current disk state. Exported for auto-mode post-hooks. */ +export async function rebuildState(basePath: string): Promise { + const state = await deriveState(basePath); + const path = resolveGsdRootFile(basePath, "STATE"); + await saveFile(path, buildStateMarkdown(state)); +} + async function ensureSliceSummaryStub(basePath: string, milestoneId: string, sliceId: string, fixesApplied: string[]): Promise { const path = join(resolveSlicePath(basePath, milestoneId, sliceId) ?? relSlicePath(basePath, milestoneId, sliceId), `${sliceId}-SUMMARY.md`); const absolute = resolveSliceFile(basePath, milestoneId, sliceId, "SUMMARY") ?? join(resolveSlicePath(basePath, milestoneId, sliceId)!, `${sliceId}-SUMMARY.md`); From 0c9fb1d1da10b7261374c506f5fb785cd177fdbf Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:12:12 -0600 Subject: [PATCH 37/60] feat: add mcporter extension for lazy MCP server integration Co-Authored-By: Claude Opus 4.6 --- src/resources/extensions/mcporter/index.ts | 410 +++++++++++++++++++++ 1 file changed, 410 insertions(+) create mode 100644 src/resources/extensions/mcporter/index.ts diff --git a/src/resources/extensions/mcporter/index.ts b/src/resources/extensions/mcporter/index.ts new file mode 100644 index 000000000..a34f5681b --- /dev/null +++ b/src/resources/extensions/mcporter/index.ts @@ -0,0 +1,410 @@ +/** + * MCPorter Extension — Lazy MCP server integration for pi + * + * Provides on-demand access to all MCP servers configured on the system + * (via Claude Desktop, Cursor, VS Code, mcporter config, etc.) without + * registering every tool upfront. This keeps token usage near-zero until + * the agent actually needs an MCP tool. + * + * Three tools: + * mcp_servers — List available MCP servers (cached after first call) + * mcp_discover — Get tool signatures for a specific server + * mcp_call — Call a tool on an MCP server + * + * Requirements: + * - mcporter installed globally: npm i -g mcporter + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + truncateHead, + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, +} from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { execFile, exec } from "node:child_process"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface McpServer { + name: string; + status: string; + transport?: string; + tools: { name: string; description: string }[]; +} + +interface McpListResponse { + mode: string; + counts: { ok: number; auth: number; offline: number; http: number; error: number }; + servers: McpServer[]; +} + +interface McpToolSchema { + name: string; + description: string; + inputSchema?: Record; +} + +interface McpServerDetail { + name: string; + status: string; + tools: McpToolSchema[]; +} + +// ─── Cache ──────────────────────────────────────────────────────────────────── + +let serverListCache: McpServer[] | null = null; +const serverDetailCache = new Map(); + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function runMcporter( + args: string[], + signal?: AbortSignal, + timeoutMs = 30000, +): Promise { + // Use shell exec so PATH resolution works in all contexts + const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" "); + const { stdout } = await execAsync(`mcporter ${escaped}`, { + timeout: timeoutMs, + maxBuffer: 1024 * 1024, + signal, + env: { ...process.env }, + }); + return stdout; +} + +async function getServerList(signal?: AbortSignal): Promise { + if (serverListCache) return serverListCache; + + const raw = await runMcporter(["list", "--json"], signal, 60000); + let data: McpListResponse; + try { + data = JSON.parse(raw) as McpListResponse; + } catch (e) { + throw new Error(`Failed to parse mcporter output: ${raw.slice(0, 300)}`); + } + if (!Array.isArray(data.servers)) { + throw new Error(`Unexpected mcporter response shape: ${JSON.stringify(Object.keys(data))}`); + } + serverListCache = data.servers; + return serverListCache; +} + +async function getServerDetail( + serverName: string, + signal?: AbortSignal, +): Promise { + if (serverDetailCache.has(serverName)) return serverDetailCache.get(serverName)!; + + const raw = await runMcporter(["list", serverName, "--schema", "--json"], signal); + const data = JSON.parse(raw) as McpServerDetail; + serverDetailCache.set(serverName, data); + return data; +} + +function formatServerList(servers: McpServer[]): string { + if (servers.length === 0) return "No MCP servers found."; + + const lines: string[] = [`${servers.length} MCP servers available:\n`]; + + for (const s of servers) { + const tools = s.tools ?? []; + const status = s.status === "ok" ? "✓" : s.status === "auth" ? "🔑" : "✗"; + lines.push(`${status} ${s.name} — ${tools.length} tools (${s.status})`); + for (const t of tools) { + lines.push(` ${t.name}: ${t.description?.slice(0, 100) ?? ""}`); + } + } + + lines.push("\nUse mcp_discover to see full tool schemas for a specific server."); + lines.push("Use mcp_call to invoke a tool: mcp_call(server, tool, args)."); + return lines.join("\n"); +} + +function formatServerDetail(detail: McpServerDetail): string { + const lines: string[] = [`${detail.name} — ${detail.tools.length} tools:\n`]; + + for (const tool of detail.tools) { + lines.push(`## ${tool.name}`); + if (tool.description) lines.push(tool.description); + if (tool.inputSchema) { + lines.push("```json"); + lines.push(JSON.stringify(tool.inputSchema, null, 2)); + lines.push("```"); + } + lines.push(""); + } + + lines.push(`Call with: mcp_call(server="${detail.name}", tool="", args={...})`); + return lines.join("\n"); +} + +// ─── Extension ──────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + // ── mcp_servers ────────────────────────────────────────────────────────── + + pi.registerTool({ + name: "mcp_servers", + label: "MCP Servers", + description: + "List all available MCP servers discovered from your system (Claude Desktop, Cursor, VS Code, mcporter config). " + + "Shows server names, status, and tool counts. Use mcp_discover to get full tool schemas for a server.", + promptSnippet: + "List available MCP servers and their tools (lazy discovery via mcporter)", + promptGuidelines: [ + "Call mcp_servers to see what MCP servers are available before trying to use one.", + "MCP servers provide external integrations (Twitter, Linear, Railway, etc.) via the Model Context Protocol.", + "After listing, use mcp_discover(server) to get tool schemas, then mcp_call(server, tool, args) to invoke.", + ], + parameters: Type.Object({ + refresh: Type.Optional( + Type.Boolean({ description: "Force refresh the server list (default: use cache)" }), + ), + }), + + async execute(_id, params, signal) { + if (params.refresh) serverListCache = null; + + try { + const servers = await getServerList(signal); + return { + content: [{ type: "text", text: formatServerList(servers) }], + details: { + serverCount: servers.length, + cached: !params.refresh && serverListCache !== null, + }, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Failed to list MCP servers. Is mcporter installed? (npm i -g mcporter)\n${msg}`, + ); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("mcp_servers")); + if (args.refresh) text += theme.fg("warning", " (refresh)"); + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Discovering MCP servers..."), 0, 0); + const d = result.details as { serverCount: number } | undefined; + return new Text( + theme.fg("success", `${d?.serverCount ?? 0} servers found`), + 0, + 0, + ); + }, + }); + + // ── mcp_discover ───────────────────────────────────────────────────────── + + pi.registerTool({ + name: "mcp_discover", + label: "MCP Discover", + description: + "Get detailed tool signatures and JSON schemas for a specific MCP server. " + + "Use this to understand what tools a server provides and what arguments they accept " + + "before calling them with mcp_call.", + promptSnippet: + "Get tool schemas for a specific MCP server before calling its tools", + promptGuidelines: [ + "Call mcp_discover with a server name to see the full tool signatures before calling mcp_call.", + "The schemas show required and optional parameters with types and descriptions.", + ], + parameters: Type.Object({ + server: Type.String({ + description: + "MCP server name (from mcp_servers output), e.g. 'railway', 'twitter-mcp', 'linear'", + }), + }), + + async execute(_id, params, signal) { + try { + const detail = await getServerDetail(params.server, signal); + const text = formatServerDetail(detail); + + // Truncation guard + const truncation = truncateHead(text, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + let finalText = truncation.content; + if (truncation.truncated) { + finalText += + `\n\n[Truncated: ${truncation.outputLines}/${truncation.totalLines} lines ` + + `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } + + return { + content: [{ type: "text", text: finalText }], + details: { + server: params.server, + toolCount: detail.tools.length, + cached: serverDetailCache.has(params.server), + }, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to discover tools for "${params.server}": ${msg}`); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("mcp_discover ")); + text += theme.fg("accent", args.server); + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial }, theme) { + if (isPartial) + return new Text(theme.fg("warning", "Discovering tools..."), 0, 0); + const d = result.details as { server: string; toolCount: number } | undefined; + return new Text( + theme.fg("success", `${d?.toolCount ?? 0} tools`) + + theme.fg("dim", ` · ${d?.server}`), + 0, + 0, + ); + }, + }); + + // ── mcp_call ───────────────────────────────────────────────────────────── + + pi.registerTool({ + name: "mcp_call", + label: "MCP Call", + description: + "Call a tool on an MCP server. Provide the server name, tool name, and arguments. " + + "Use mcp_discover first to see available tools and their required arguments.", + promptSnippet: "Call a tool on an MCP server via mcporter", + promptGuidelines: [ + "Always use mcp_discover first to understand the tool's parameters before calling mcp_call.", + "Arguments are passed as a JSON object matching the tool's input schema.", + ], + parameters: Type.Object({ + server: Type.String({ + description: "MCP server name, e.g. 'railway', 'twitter-mcp'", + }), + tool: Type.String({ + description: "Tool name on that server, e.g. 'railway_list_projects'", + }), + args: Type.Optional( + Type.Record(Type.String(), Type.Unknown(), { + description: + "Tool arguments as key-value pairs matching the tool's input schema", + }), + ), + }), + + async execute(_id, params, signal) { + // Build mcporter call command: mcporter call server.tool key:value ... + const callTarget = `${params.server}.${params.tool}`; + const cliArgs = ["call", callTarget, "--output", "raw"]; + + if (params.args && Object.keys(params.args).length > 0) { + for (const [key, value] of Object.entries(params.args)) { + const strVal = + typeof value === "string" ? value : JSON.stringify(value); + cliArgs.push(`${key}:${strVal}`); + } + } + + try { + const raw = await runMcporter(cliArgs, signal, 60000); + + // Truncation guard + const truncation = truncateHead(raw, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + let finalText = truncation.content; + if (truncation.truncated) { + finalText += + `\n\n[Output truncated: ${truncation.outputLines}/${truncation.totalLines} lines ` + + `(${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } + + return { + content: [{ type: "text", text: finalText }], + details: { + server: params.server, + tool: params.tool, + charCount: finalText.length, + truncated: truncation.truncated, + }, + }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `MCP call failed: ${params.server}.${params.tool}\n${msg}`, + ); + } + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("mcp_call ")); + text += theme.fg("accent", `${args.server}.${args.tool}`); + if (args.args && Object.keys(args.args).length > 0) { + const preview = Object.entries(args.args) + .slice(0, 3) + .map(([k, v]) => { + const val = typeof v === "string" ? v : JSON.stringify(v); + return `${k}:${val.length > 30 ? val.slice(0, 30) + "…" : val}`; + }) + .join(" "); + text += " " + theme.fg("muted", preview); + } + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial, expanded }, theme) { + if (isPartial) return new Text(theme.fg("warning", "Calling MCP tool..."), 0, 0); + + const d = result.details as { + server: string; + tool: string; + charCount: number; + truncated: boolean; + } | undefined; + + let text = theme.fg("success", `✓ ${d?.server}.${d?.tool}`); + text += theme.fg("dim", ` · ${(d?.charCount ?? 0).toLocaleString()} chars`); + if (d?.truncated) text += theme.fg("warning", " · truncated"); + + if (expanded) { + const content = result.content[0]; + if (content?.type === "text") { + const preview = content.text.split("\n").slice(0, 15).join("\n"); + text += "\n\n" + theme.fg("dim", preview); + } + } + + return new Text(text, 0, 0); + }, + }); + + // ── Verify mcporter is available ───────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + try { + const ver = (await runMcporter(["--version"], undefined, 5000)).trim(); + ctx.ui.notify(`MCPorter ${ver} ready`, "info"); + } catch { + ctx.ui.notify( + "MCPorter not found. Install with: npm i -g mcporter", + "error", + ); + } + }); +} From 8d04971ac150692751248355b1677572a68af78b Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:13:49 -0600 Subject: [PATCH 38/60] feat: add /voice extension for real-time speech-to-text - macOS-only (SFSpeechRecognizer), no-op on other platforms - /voice command and Ctrl+Alt+V shortcut to toggle - Streams partial transcription results directly into editor input - Custom footer with flashing red dot + 'transcribing' indicator on row 1 - Enter to stop and keep text, Esc to cancel - Ships precompiled Swift binary (60KB) --- src/resources/extensions/voice/index.ts | 176 ++++++++++++++++++ .../extensions/voice/speech-recognizer | Bin 0 -> 60736 bytes .../extensions/voice/speech-recognizer.swift | 76 ++++++++ 3 files changed, 252 insertions(+) create mode 100644 src/resources/extensions/voice/index.ts create mode 100755 src/resources/extensions/voice/speech-recognizer create mode 100644 src/resources/extensions/voice/speech-recognizer.swift diff --git a/src/resources/extensions/voice/index.ts b/src/resources/extensions/voice/index.ts new file mode 100644 index 000000000..c99400767 --- /dev/null +++ b/src/resources/extensions/voice/index.ts @@ -0,0 +1,176 @@ +import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { AssistantMessage } from "@mariozechner/pi-ai"; +import { isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; +import { spawn, type ChildProcess } from "node:child_process"; +import * as path from "node:path"; +import * as readline from "node:readline"; + +const RECOGNIZER_BIN = path.join(__dirname, "speech-recognizer"); + +export default function (pi: ExtensionAPI) { + if (process.platform !== "darwin") return; + + let active = false; + let recognizerProcess: ChildProcess | null = null; + let finalized = ""; + let flashOn = true; + let flashTimer: ReturnType | null = null; + let footerTui: { requestRender: () => void } | null = null; + + function setVoiceFooter(ctx: ExtensionContext, on: boolean) { + if (!on) { + stopFlash(); + ctx.ui.setFooter(undefined); + return; + } + + flashOn = true; + flashTimer = setInterval(() => { + flashOn = !flashOn; + footerTui?.requestRender(); + }, 500); + + ctx.ui.setFooter((tui, theme, footerData) => { + footerTui = tui; + const branchUnsub = footerData.onBranchChange(() => tui.requestRender()); + + return { + dispose: branchUnsub, + invalidate() {}, + render(width: number): string[] { + // --- Row 1: pwd (branch) ... ● transcribing --- + let pwd = process.cwd(); + const home = process.env.HOME || process.env.USERPROFILE; + if (home && pwd.startsWith(home)) pwd = `~${pwd.slice(home.length)}`; + const branch = footerData.getGitBranch(); + if (branch) pwd = `${pwd} (${branch})`; + + const dot = flashOn ? theme.fg("error", "●") : theme.fg("dim", "●"); + const voiceTag = `${dot} ${theme.fg("error", "transcribing")}`; + const voiceTagWidth = visibleWidth(voiceTag); + + const maxPwdWidth = width - voiceTagWidth - 2; + const pwdStr = truncateToWidth(theme.fg("dim", pwd), maxPwdWidth, theme.fg("dim", "...")); + const pad1 = " ".repeat(Math.max(1, width - visibleWidth(pwdStr) - voiceTagWidth)); + const row1 = truncateToWidth(pwdStr + pad1 + voiceTag, width); + + // --- Row 2: stats ... model (replicate default) --- + let totalInput = 0, totalOutput = 0, totalCost = 0; + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "message" && entry.message.role === "assistant") { + const m = entry.message as AssistantMessage; + totalInput += m.usage.input; + totalOutput += m.usage.output; + totalCost += m.usage.cost.total; + } + } + + const fmt = (n: number) => n < 1000 ? `${n}` : n < 10000 ? `${(n / 1000).toFixed(1)}k` : `${Math.round(n / 1000)}k`; + const parts: string[] = []; + if (totalInput) parts.push(`↑${fmt(totalInput)}`); + if (totalOutput) parts.push(`↓${fmt(totalOutput)}`); + if (totalCost) parts.push(`$${totalCost.toFixed(3)}`); + + const usage = ctx.getContextUsage(); + const ctxPct = usage?.percent !== null && usage?.percent !== undefined ? `${usage.percent.toFixed(1)}%` : "?"; + const ctxWin = usage?.contextWindow ?? ctx.model?.contextWindow ?? 0; + parts.push(`${ctxPct}/${fmt(ctxWin)}`); + + const statsLeft = theme.fg("dim", parts.join(" ")); + const modelRight = theme.fg("dim", ctx.model?.id || "no-model"); + const statsLeftW = visibleWidth(statsLeft); + const modelRightW = visibleWidth(modelRight); + const pad2 = " ".repeat(Math.max(2, width - statsLeftW - modelRightW)); + const row2 = truncateToWidth(statsLeft + pad2 + modelRight, width); + + return [row1, row2]; + }, + }; + }); + } + + function stopFlash() { + if (flashTimer) { clearInterval(flashTimer); flashTimer = null; } + footerTui = null; + } + + async function toggleVoice(ctx: ExtensionContext) { + if (active) { + killRecognizer(); + active = false; + setVoiceFooter(ctx, false); + return; + } + + active = true; + finalized = ""; + setVoiceFooter(ctx, true); + await runVoiceSession(ctx); + } + + pi.registerCommand("voice", { + description: "Toggle voice mode", + handler: async (_args, ctx) => toggleVoice(ctx), + }); + + pi.registerShortcut("ctrl+alt+v", { + description: "Toggle voice mode", + handler: async (ctx) => toggleVoice(ctx), + }); + + function killRecognizer() { + if (recognizerProcess) { recognizerProcess.kill("SIGTERM"); recognizerProcess = null; } + } + + function startRecognizer( + onPartial: (text: string) => void, + onFinal: (text: string) => void, + onError: (msg: string) => void, + onReady: () => void, + ) { + recognizerProcess = spawn(RECOGNIZER_BIN, [], { stdio: ["pipe", "pipe", "pipe"] }); + const rl = readline.createInterface({ input: recognizerProcess.stdout! }); + rl.on("line", (line: string) => { + if (line === "READY") { onReady(); return; } + if (line.startsWith("PARTIAL:")) onPartial(line.slice(8)); + else if (line.startsWith("FINAL:")) onFinal(line.slice(6)); + else if (line.startsWith("ERROR:")) onError(line.slice(6)); + }); + recognizerProcess.on("error", (err) => onError(err.message)); + recognizerProcess.on("exit", () => { recognizerProcess = null; }); + } + + async function runVoiceSession(ctx: ExtensionContext): Promise { + return new Promise((resolve) => { + startRecognizer( + (text) => { + const full = finalized + (finalized && text ? " " : "") + text; + ctx.ui.setEditorText(full); + }, + (text) => { + finalized = (finalized ? finalized + " " : "") + text; + ctx.ui.setEditorText(finalized); + }, + (msg) => ctx.ui.notify(`Voice: ${msg}`, "error"), + () => {}, + ); + + ctx.ui.custom( + (_tui, _theme, _kb, done) => ({ + render(): string[] { return []; }, + handleInput(data: string) { + if (isKeyRelease(data)) return; + if (matchesKey(data, Key.escape) || matchesKey(data, Key.enter)) { + killRecognizer(); + active = false; + setVoiceFooter(ctx, false); + done(); + } + }, + invalidate() {}, + }), + { overlay: true, overlayOptions: { anchor: "bottom-center", width: "100%" } }, + ).then(() => resolve()); + }); + } +} diff --git a/src/resources/extensions/voice/speech-recognizer b/src/resources/extensions/voice/speech-recognizer new file mode 100755 index 0000000000000000000000000000000000000000..9251292d90f1b4aeb86382f43c884796c922046c GIT binary patch literal 60736 zcmeHw3w%`7wf8<5LJ|;&7|Jr-c2}} z_c-Ur&f06Awbx$zzt?`B`Elgak3K(#F@@ntL%0GVJDagPm>n_3#v?37aJiN{u3xlf zQAIhWCOFgxEi#G*Y9r7 zdf6A5CiDd*XvCWzvu;Xvxjf-WNcY!Ep;Y@$P8a%~mHLPW%!#MBzVIf!E;8Gt`6Hpm z-uV4{p3s*mH4vYwBcU7-*cb4Jdm1FwzP(c47i1>l&GwmfQC#Y)cX2Z#z3|J+7xvAS z8Hpd*K3U&LV^9leb*c7wrM^*8U!0e0F9LrN_|-&pUqtt(>f0>!<;a}G56V8+=0i)T z>bqO&%aZztALvQg=MIMho=9V=zDK3LaZ(BKgR(Ce3Pb|E{bjGz7jIMI2c?hN1Klpw zzGqYP5kIiLsDG30uXXAEI+)i>-+rm@u&gif1L>o2YA*Vc%eBx^>2MX7FLR2~N)>*` zK>3qGZAgrVY{Xrz`an;w=wIJq*>6cN<&Qr@5y4QCczXAm4w$NiLEtY~Cc>bt@kc|}L{v+)QYd&-c?jKZ7AWgnMS6KJfdZ&=)ghiep7dsrq{6>!qd3mMmIWD!iAmgHl$dTzC%h zzf6_$59u-Mn#EX*0|~kPm<(o0em^=AhH&l+G=_}(5o1q+KKdqO>KMj`BfcDg9$G3; zAir6GuneKUC-V))YWsLO5@~h>nqOx`!=V{Iy=F#jqc1Ld0n*8i8P2+Ue?4p74!$r%p$4jM?W7okTlX&+Fp zqpBnj^~0T^t8eXQ3238-%9)GMQ_fO!5tI|h5_pQLOfQ-aT=g2tCGu5+_T)Qby-~U& zTB`^8wT-_e<#r-Q@(&M$YjGf?oe6XuKYxoU4=p+Hc@8if8n^m->_O zeIKdG_|l4Sy2Fevw)KG~QFOOFQ zQuI*%B){gV{np1-B)0=~zJS}@FsXJhHWr%t)f3hmYE88ewqn%3vj$%ovRpohO!4UW3H~u$#{QSeIxT))|~axt@rC=6a)1=nLS1|SlV@O z>0=y5C(2_=)`!&-6yrk1ns*?t0zL1L4#=`})%Y3WQxR8C))B;!XHRvm!`S^EYuB$( zQ2xZOR-4l9ouU|C7OQBrP3&@~u?MR!Rg7(*?kNtV=2D089_72jVR)u12G)5ew{266 zuR~u8V})q=96Rz>k4$Sj5Ak@}-!qlH7je>q35V?7({hp0-r96Q*Un<4{ebO)uDH&7 zAxC;hr*~vg+x7yXKdy5pz5kRMEw;3Fq}`Y4E@*DGP3St%G@*-RGS3C3wsBpVYHLMn z)42Ty4vnK_;(eKu=Rj_}keQs(+}bq0>sIJGPWAd!HEKR*##F)ynB75Te6AXsK&f3m z1rDbm2N&DRw#{#DMO}~CGWSdcO;gzDQpkBHIE>kp4|J#vG)*yp4IO{vc+(t)_Y-EI zhvn3b1_mP?M)fd<(cQ$_PoNDKL(f!&UEP`nnof3rp3B<2iYR9h_~W+pJySDc74M_m z?Y3CO??GpwJm_e)Gy9wRCn~o;OJfGpL|n;IjS8}9iolTj*xStbNY=*&JGno+ts3ZG zC-o0dw$6-9q*Hx!K?xJ@kD;@Hf@)O1g7<{88-A!<_g3{QvaAtAV-?;VN*iH9w$VE- zd$W-B{uq>Wyq>JXyTj2|{kpJWGTM*wUrhOtmX!YkuAdY?bH4>7!zSv zbrs5}koF(Ern%E>|2vG2Yp;?071I7HY5$tO?LR{HuVcnB{&mv+ zTVQ_;Gk!)k)TqYah(hMCL?QDr(Rb9gUxG%`L?5mm%8ZA>|CRh`sA@#V$i6Q7^f0RP z7}YpQc|lvS#)#LwdYF`r_wRYk_!h|(GSOd$k-sxzJL*qkLG?&x?81BAmq)6`v&FXd z$P@;h=XJFWuewXNuC zX(^k>-0Qkrh!V_x)+mQC^2@K;~cGu3P>@N7^$;lDba8G9jmU<_PZpGLM>e;Ls z8)d%g399iXe8am#?KKX#A>+6jx5WP2CN_MVqKj;*Bjy@44Xq6^T@CCpe)?OY<;`3Bj}3((F*%(#il zDpHOAK@>7ui9)84DC~EEb|0d?C)So78FHM$oSTKQjoPGRI5T!PUD$Ovjr}L}*^Ug^ zzm?(5ii0(8hK>#!Gf0lwtrfCvMaZ78sRquOPTru{nmcV-dlsL5@^B{fo`5XLaec$l zR!y!h>i@8%nfgAhS59xmnzxwR8e`}R)!??kzD#LH=Y_&&j&^rHavbH*ILH0tpsjf= zY@+e_@-L>LT()N_%B8x+?LWQMv76R2ye!n;yuPM7bV3&El4hzG#;4c+jP=P8gbtJw zAE)BBp5A(3_i4!TvbU#k-;B>yG&lSj`A%?Izi7TN*IP*nRqt(tH3| zwIH+>v-Vb7ZdZ#9aSz2Ob$QVjTAOnDy8a$u(SmjUUeKL1XJhQ8^>mBvmc+XED9UJ& zYw6QluiuSwI1ceXn@M;-CFbK}Xr~vc-oOL+iusrkraj|r>!FGJsh+zaN9|4XG5HAL zcnodZ*L?hS*w=9ZGya3_G25? z0jr_w3vt_C9Gq>Z%71`15`CL&T95pywCM$Ij|1)S67Mg)Y{8rp9|w|fQdDC$bleoT z<=8)F%VOl8ByHK1Y754Rgg>A!P@n9EZ;>xhKfyS4l=d8iHQMfIeBVN26Jbs7l|?Gw z^AOKvgZK3`! z3VP4BKctf_&^_4x@CnDacYh%K*bvn?iua^1$*-Gw=7qT2D}BhBeXQGdPJ74SF~W6&X&m2g9l$>RK-0NM8;x-`7wd#vTC*nFu;cIO^Ky^fiFoJ7e0`9E zbz@G)$H4Xw`N24>d(g&LtHxKUZde~bK(_r*_=a+=YJ5ee-$Us?VMaM9%}*Uagw7+d z@eP_6$D(|kD^U6IHu*`izjPcyo4!Hg&=|;$<>hw%oEdiHVVM6qe#MNPhtk@Qy$ip= z+UhvwaT~^n%_#50&r#N|um^5LTfGOY(_#PbnX!p%dRH~L%zKdey=v^S+1fw&kjCE} ztiN)AO%=*p)wRo3+4Xho`=-Y2{}A%;DsA`Dc>b=V%|3?B;d3o)q%$7ErStnH`<{n= z9$Wjd1Hi^)-*U))_q>Lk16r9 z2b`U}tR(FjX@2??>#dIO0ORj6nv-%cU+-m4D9xRiKh5*?Ns}Da;ly`f%XbmJ$&3>> z(OT$S>}y8@bFu#DZ~4{s7ASDl-<#az4PjXWz56pSq2F z2J24SxwNjmk6}OA5ui0n?Db<|@|jpg=bywH_b146Y!x$p0eb2-wAU*51L}O@CbA3s zMVZYfG8D{d3axRD(i-;IUED{H(i--7Ju|S5Jxc4?_trDxkkoSw_I3VAu4Dg8X(pWZ zQdu5mybv$TqZ%I}enPg%@p{rh`cdwC8OVi8u8;_murb{$hE}dpig2iLv}o=8vBVt=6ggTg9Ee&JAmarP^|rP z;{HqZB46Wee6tus`MbZV#&_|q6SOrxHXz>V6l233G&VR<(=?_8>BvuOc&^tie6Xb{ncoE-n8A%%PAlS_XhVv3q77*cjC1dtOyEj!NvOQ>EROH@AffIpjKm{ zsGsS6J;L0xP96@R-=U@l_9r3>=p@b5fk4$!Y15saXg%%8iBFpg^f$sKI@T^b| zD?<^7A8#YkkbhAq6o4l4v#6q?ykegCM9Xe6#_kV9?D2-sYT3s8LiM`54Re|1pSBVM zTKs8n>;4&G{)N{xnSF~En$EvYA(@qaZF3NxMrgIZMtdE7uVwe^KKmxG?$hk}R5gSq z#Mj(*x8GjQ+s5s)Z*=>j8Y{tfiaxtWCdUf{?aN*iD9lYD-IHNs!7SLVPH|Kvd&!i! ziL^?+L7S3aDDnvXC1^6O)*cDiIe-8HUlG_ff4%P4=GhsmSmaoEBhL`m?<^4*n*2pH z0u{8($&sf*gnqW%QBhgySjy(HlG0_MiKnmnk@MN{Rfmpt7{=`Evv<2Ojf%(RDvn1v zO|b*eBsq-iO~O{n@aJ>I?-?k5klEN;P^`(=F{FDWea7hSCZ2stujf z=I-w9(JYOf1icku|A`(OvDQd>ZpVA&4g6tFow19I94zg?im1V86V$2s`o z#V4}ZaBq=TjZI?9aHdQh!Pw>zOnGwzOPhs{F@qyn+ELKYK-c24%I%|=a*}8^Q{K;p z3~*~1$CS0>8S5C&l+_d1kX!NTW$Of%{_+HdD|-}ThnbK?u@tPwLcl`6Lcl`6Lcl`6 zLcl`6Lcl`6Lcl`cpN2qoOzd*AV`4*=9TR)q?3mcgX2;UGE!i>pRSkX-5X+EM>|L{C zICJIc!z9J_k@M$Biftt4>Gyn;A6rIFu|4IKw%e3GT2g%9&Uw11r1bM8#ZL@5Prss| z^l_46yUBU+v;XWE{VtH+(;k^9{f>cXuA~=AYL|4fq*EllNYaZXy+l&_y#(pGOj5D; z&W_>x6rPUny*L$H?d;f9k{3U-%#KZ${0vF+B+ZwUez!>aXG%It(%F)VW1H;Q9Ldk+ zG%v;p{s?{e{R*#WHd`y{I9_1RdJ(Gezqu~}HUFpX>EPqf zI7H(AgFMBVivKoUrHFVaUm`sw6G14Q{$F-w9b>j<5MDv}9YT)6Y=sDG5Ml^-BRrw7 zA*`oKElKiEb9gYFT%DFY~0hJe?UkZ$+AWx%tF{ak}+3#(GA6};-wC!({-`SS>g<8 zn#b#i*6M+xXkDEas?a=vdcPjg1O5taLsSb#`pF{&w`d{eauxXkp7kzIAlR5-LYhx= zhc)oaoW%jZC!|F*XTR%cN1expNaDHA%AnLDmQ(X%R zV7-5%7K$tig#w|G8d;Nvar zbcXZtXD-z0+)-ad)T$`2DuKMyS&;9liR!+H?iX*D>d5W(<gE;)YPFT<9>wsS z$Q(WFa2A)AI-Ny9SJ9>-J>RTG(*k;0fi?!LLoQGbvJIl-c=Ng6m5#zE!%gq zYjebTW5pKdin$f;P2Bq`g7M1D4x-mYTvvy+pgZJ-tLEpynHqFI#Ty%gwJs-`fmf+9 z2;T^A((59w8Vy62!{gDy;Y1Sen&rITSHwqFDV_96^bqj|ZIi4;qp>X|yG%3ZtJlOR zD7%tShQtzdHH^5GjX|wUi?}gzVq8hQg>l8jK6oGvAOpSc;~mBXKWbY^ogl6<(f36O zm_K;u7u+V@=d1LF0-LD66fWdbNMSfq8$fpoV$hz0*`%a&>7uIZY4Eu?T%;Gya;>W3 zz3av@KKv(|%XwoZ<`4`RH_i>o7ToNt&zp(nEXrH4h4<;o4Ji_d*Bk5cjwvoSnwA(? zS1=oEnT(jL!Heli^F$+>%d5F-F;QkUP!&cn2z2B;sQA>YuG*WneBrq2`PEldU*&yN zSvYR$)YcI%gHFtun6pi>+vi?<)kMag&tfcBi7|G8SigKI`3EIGd6;|;Sz6yLle{YV zFG$`e`EP?C3BgAt|9koV@FbD{;^E@`xLm={k^EZ8mw_jHB9dPx`8OrMTk_c#iu^B1 zJ}CL$Nd5`Qk2pu@`%Ll<@MPaqyU4#mzQ0fMPsiT_XsYk?lJ`me4Jp4(^1qh+Gm=-( z2BiN@$zKbe>T~NB1JzbyIXl0PZ=)smlx&P(~L zCBIbiUdeBid{FX_OMbKDUy^)G@*hclyX4P1Pw2l-@=GNDsN_SEZ;|}hB;P9e!;;@C z`M*ehzvOdsg#Lq)Uq<|SG1d|j_-vDWtK@e|ey`-)B)?zsCnSGR^5f5kKBOI%{PmJQ zD)|P<|3dP6B;O(VBa-iw{Kt|%Dfz51Xm6ykFA96p5FMq!De#bg;x%}Tn@A!a!xm9& z_{39@!rz&~e?Nu)OA3Dul;I!WF5(rNA^0qc)1#*FO9Y=N|B;mUucq+WI+Ff`zAIAr z{1kpg3Li<~?@QtLrSQ+C@GqtCucq*JTe5vkDSTTBKYU2C{52_jwcr!@JdpDKKnnkg z;PF>H#|57r=RXxZAQaDZyb%x!zFhFIQao!@cwY+Nl)^ul!atqDw{c$0jO*_Z{IEDb z1T|HdidjuO(*%D`oL?b$%mU(x2p*%jcpeozMrH9lFL?YFPlw<|hoGbkULHm<@z@2A zQAa$B1dmZdJnICH9xR^61y4PWKW&1iZpNSEf*%{_71SJfC-BV>Jfy^PK?;9)3O_f6 zFG=B@Df}kEC(64mh5xeP6aDG26#hR`_#-KNM+$!`h0lOybAO$f!p}?L*QD?pQ}}yQ z_$N~MJt_POz)EA96QL4eB|;U#jRJaJ?ya+nNI)wEIK748R33}2M``acnINPghvn_Mff*_#}FPzcmm;T2s;ph2pbUM zP9x3?YkTNJqTo{x!yN}zXidCKp4~_^oIjs(17rd z){k(v<1niCa^A(4^Vm6P448**YJlQ}V%|4?6=h z=fp>@o@+~79eD#GeT%pi!d*x-TqQ461}%?Egd3g3tN8IT-5}KAWS{Q8`ny4J6#3=# z!61k3I!B(1PM!xl)Ym!kx%@0zn&KRMuod^ztrP_fK1-=q-{^7_m1>PejjqZH=BmN% ziMRyDdABb}7sDc{7PrWuKw}~mN9}?9xxHJYI1YcGFAMqQq6Wz8&Fk&>^=Qx4TOfaC z(%jagHYMFr)F|&;&9xrzGCZNH2iSq-d&-D6Nq(WUZh3K;xN|Rd;ikA~R;Avc72`f? zrD#H6)LRmQ4j~Bm>+4;PnuWXp`e}jb`F*#+^a6gJ;apXY2?E|V;60xdirZf>JEi5s zw*p~Yr{a3pC2xjt`(00UDd6tt3=Evhlu6bLH`{ZTi92{`9k7nG`l;h=s+zDmJR7S% zTx-IhnWp}clw29;Q+`(8^0Qn*z8_B>gtK}o%XuU8a#!v$iMMCvXno`wc)+9KtB-rH zlEl5&Dq&-f6$9%_+@Mi6#RY5pdez*)2G)>b5#RTaIk;#0M{RNH;u6s5&M$D`QnV~e zH{St&qxfRt>})IZV96oZ93idFM;~GY{4U(7hd~8eWvk214z@C$u~2-C!*hol8)^bR z-D5{#E|-1qHsbm;p4F+F7`op)OQVX;PM%t>%wg(`{0oqPc^Z%@(`WXUnqD}-QqH_s z#{yoQyY##8OJ3TYS-xi>d}i66_3W8tX+e87y5np1Gi%1u_RK3(K0va*bJ6K58s zrSO@h`d+A?IUnCjoLMZfBO9D3-){|0l(t=Gmh9QABxGqpGuRQbtnTc3m0V&j6}kFc z8I{FfrmSz`={=?myjJNgKk!g3082 z$*I_oe{J~KlaKsn^g|O~HSW4(--7Z zFWo(}Vduy{tkj;kxA+%-dwBMiqMF-3xn=u`>;G$4(PL=~rp^Du{&g3>@a-|0R0*|6ToO%Aa}7yuZ%e_2!s6Zrg@xamW`AkH4d3vcYM%Cu@h{H$`=wv Date: Wed, 11 Mar 2026 16:17:16 -0600 Subject: [PATCH 39/60] chore: bump version to 0.3.3 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 10574be0b..7bc2f7836 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "0.3.1", + "version": "0.3.3", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From 88781cb722ad3a6d3ebd3afba249466dfebcf6d0 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:20:39 -0600 Subject: [PATCH 40/60] =?UTF-8?q?docs:=20queue=20M003=20=E2=80=94=20AI-Dri?= =?UTF-8?q?ven=20Test=20Flows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gsd/DECISIONS.md | 33 +++++ .gsd/PROJECT.md | 36 +++++ .gsd/QUEUE.md | 7 + .gsd/REQUIREMENTS.md | 205 +++++++++++++++++++++++++++ .gsd/milestones/M003/M003-CONTEXT.md | 133 +++++++++++++++++ 5 files changed, 414 insertions(+) create mode 100644 .gsd/DECISIONS.md create mode 100644 .gsd/PROJECT.md create mode 100644 .gsd/QUEUE.md create mode 100644 .gsd/REQUIREMENTS.md create mode 100644 .gsd/milestones/M003/M003-CONTEXT.md diff --git a/.gsd/DECISIONS.md b/.gsd/DECISIONS.md new file mode 100644 index 000000000..ae0071c20 --- /dev/null +++ b/.gsd/DECISIONS.md @@ -0,0 +1,33 @@ +# Decisions Register + + + +| # | When | Scope | Decision | Choice | Rationale | Revisable? | +|---|------|-------|----------|--------|-----------|------------| +| D001 | M001 | arch | Embedding strategy | SDK (`createAgentSession` + `InteractiveMode`) | Type-safe, no subprocess management, full control over storage/resources, cleanest branded app path per pi docs | No | +| D002 | M001 | arch | State storage location | `~/.gsd/` (agent: `~/.gsd/agent/`, sessions: `~/.gsd/sessions/`) | Complete isolation from `~/.pi/`, clear brand identity, follows pi doc recommendation for branded apps | No | +| D003 | M001 | arch | Branding mechanism | `PI_PACKAGE_DIR` env var set before pi internals load, pointing to gsd package root; gsd `package.json` declares `piConfig: { name: "gsd", configDir: ".gsd" }` | `config.js` reads `APP_NAME` from `piConfig.name` in the package.json found at `PI_PACKAGE_DIR`. Only mechanism that renames the TUI header without patching pi source. | Yes — if pi adds a dedicated `createAgentSession` appName option | +| D004 | M001 | arch | Extension delivery | Copy extension `.ts` source into `src/resources/extensions/` at dev time; load via `DefaultResourceLoader.additionalExtensionPaths`; pi's jiti handles JIT compilation at runtime | Preserves pi's JIT compilation model, no separate build step for extensions, extensions stay readable source | Yes — if extension count grows large enough to warrant pre-compilation | +| D005 | M001 | scope | Skills in M001 | Excluded — extensions only | User decision during discussion | Yes — M002 candidate | +| D006 | M001 | scope | Plugin/install system | Deferred | Not MVP; bundled-only product for M001 | Yes — M002 candidate | +| D007 | M001 | arch | pi interop | None — GSD never reads or writes `~/.pi/` | GSD is a product, not a pi config. Interop would blur the brand boundary. | No | +| D008 | M001/S01 | verification | S01 verification strategy | Shell commands + real TTY launch (no test framework) | S01 is a pure binary launch / TUI branding check. The only meaningful assertion is whether the binary launches with "gsd" in the header — no unit-testable logic to isolate. Shell verification commands cover all must-haves. Test framework deferred to S02+ if needed. | Yes — add test framework in S02 if extension loading logic warrants it | +| D009 | M001/S01 | arch | `files` array in package.json | Set in T03 during S01 (`["dist", "package.json", "README.md"]`) | Correct npm publish manifest must be in place before S04 pack/publish. Setting it early avoids a late-stage surprise. | No | +| D010 | M001/S01/T02 | impl | ModelRegistry instantiation | Constructor `new ModelRegistry(authStorage)` — not a static factory | SDK types show no `.create()` on ModelRegistry; authStorage is passed directly to constructor. All other managers (AuthStorage, SettingsManager, SessionManager) use static `.create()` but synchronously. | No | +| D011 | M001/S01/T02 | impl | InteractiveMode.run() | Instance method: `new InteractiveMode(session); mode.run()` — not static | SDK type declarations confirm `run()` is an instance method; static call would fail at runtime. | No | +| D012 | M001/S01/T02 | impl | skipLibCheck in tsconfig | `skipLibCheck: true` added | `@google/genai` published types reference `@modelcontextprotocol/sdk` which is not installed as a type dep — causes transitive TS2307 error unrelated to gsd code. skipLibCheck is the standard fix for third-party type declaration issues. | Yes — remove if MCP types are added as a dep in the future | +| D013 | M001/S01/T03 | arch | `PI_PACKAGE_DIR` shim directory (`pkg/`) | Added `pkg/` dir with `package.json` (piConfig) + `dist/modes/interactive/theme/` (pi theme JSONs) as the `PI_PACKAGE_DIR` target | `config.js::getThemesDir()` uses `getPackageDir()` (= PI_PACKAGE_DIR) and checks if `/src` exists; if yes, uses `src/modes/interactive/theme/` instead of `dist/`. Our project has a real `src/` dir, causing themes to resolve to the wrong path. Pointing PI_PACKAGE_DIR at `pkg/` (which has no `src/`) avoids the collision while still providing `piConfig` for branding. `pkg/dist/modes/interactive/theme/` is populated by `npm run copy-themes` (build script). | Yes — if pi adds a dedicated `appName` option to createAgentSession making PI_PACKAGE_DIR unnecessary | +| D014 | M001/S02 | verification | S02 verification strategy | Shell commands + real TTY launch with stderr capture, no test framework | Extension loading is a runtime integration concern — no unit-testable logic to isolate. The meaningful assertions are: zero extension errors in stderr on launch, correct env vars in compiled loader.js, absence of `~/.pi/` refs in patched files. Shell commands cover all must-haves. Test framework deferred per D008. | Yes — add test framework if extension loading logic grows complex | +| D015 | M001/S02 | arch | subagent spawn approach | `spawn(process.execPath, [GSD_BIN_PATH, ...extensionArgs, ...args])` — no `pi` binary in PATH | Patched subagent spawns node directly with the gsd dist/loader.js entrypoint. This ensures spawned subagents always use the bundled gsd extensions, regardless of what `pi` is in PATH. `GSD_BIN_PATH` = `process.argv[1]` from loader.ts. | Yes — if pi adds a native subagent spawn API | +| D016 | M001/S02 | arch | shared/ is a library, not an extension entry point | `shared/` is NOT added to `additionalExtensionPaths` | `shared/ui.ts`, `shared/next-action-ui.ts` etc. are cross-extension imports, not independently registered extensions. They are discovered by jiti when gsd and ask-user-questions imports them via `../shared/*.js`. Adding shared/ as an extension entry point would attempt to register it as an extension (which it isn't). | No | +| D017 | M001/S02 | arch | AGENTS.md first-run write | `initResources()` writes bundled AGENTS.md to `~/.gsd/agent/AGENTS.md` on first launch | pi's `loadProjectContextFiles` discovers AGENTS.md from `agentDir` (`~/.gsd/agent/`). On fresh install this file doesn't exist. One-time write on launch (behind existsSync check) ensures spawned subagents always pick up GSD's hard rules and execution heuristics. | No | +| D018 | M001/S03 | arch | Wizard injection point | Pre-session: before `createAgentSession()`, not via `session_start` event hook | Running wizard before `createAgentSession()` ensures Anthropic key is in `authStorage` before `modelRegistry.getAvailable()` runs — avoids "No models available" fallback warning. S01 forward intelligence mentioned session_start hook; pre-session approach is strictly better because the session starts clean with a valid model. | Yes — if pi adds a native `beforeStart` or `authMissing` hook to `createAgentSession` | +| D019 | M001/S03 | verification | S03 verification strategy | Shell script (`scripts/verify-s03.sh`) for automated non-TTY/skip checks + interactive UAT for masked input and TUI launch | Wizard involves TTY interaction that cannot be meaningfully automated (masked stdin, TUI launch). Automated shell script covers all non-interactive assertions (exit codes, error text, env hydration). Interactive UAT covers the remaining visual/interactive behaviors. No test framework added — consistent with D008/D014. | Yes — add test framework if wizard logic grows complex | +| D020 | M001/S03 | arch | Wizard scope | Optional tool keys only (Brave/Context7/Jina) — Anthropic auth is pi's responsibility via OAuth | Wizard collecting Anthropic key was redundant (pi already handles it) and interfered with verify script automation. Optional-key scope satisfies R006. | Yes — if pi adds a native "no Anthropic key" callback hook | +| D021 | M001/S04 | arch | GSD_BUNDLED_EXTENSION_PATHS target | agentDir-based paths, not src/resources paths | When subagent spawns a child gsd process via --extension flags, the child also runs initResources + buildResourceLoader from agentDir. src/resources paths ≠ agentDir paths → pi deduplication fails → duplicate tool registration errors. Pointing to agentDir paths means both the --extension args and agentDir scan resolve identically → deduplication works. Safe because subagent spawning only happens after initResources has synced on first launch. | No | +| D022 | M001/S04 | verification | S04 verification strategy | 10-check `scripts/verify-s04.sh` for tarball install path; registry publish check automated; interactive UAT for wizard fire from clean install | Tarball install + launch is automatable (env isolation, background kill). Registry install check is automatable (prefix install + stderr check). Wizard TTY interaction is UAT-only. Consistent with D008/D014/D019 — shell scripts, no test framework. | Yes — add test framework if automated E2E is needed later | +| D023 | M003 | arch | Test flow execution model | Intent-based YAML specs, not deterministic scripts — agent interprets verify blocks with full adaptive intelligence | Evaluated Maestro (JVM dep, deterministic scripting, mobile-first) and decided against embedding or cloning it. GSD's advantage is AI-in-the-loop. Flows describe what to verify; the agent decides how. Faster iteration, better flakiness handling, plays to GSD's strength. | Yes — could add deterministic fast-path for simple assertions later | +| D024 | M003 | arch | Test browser isolation | test-flows runs its own Playwright instance, separate from browser-tools | Test execution must not be polluted by development browser state (cookies, auth, DOM mutations). Two Playwright instances in one process is supported. Keeps test-flows extension fully decoupled from browser-tools. | No | +| D025 | M003 | arch | Maestro integration | Not embedded — optional external tool if user installs it | Maestro requires JVM, adds ~200MB+ footprint, its YAML format is deterministic scripts not intent specs. GSD builds its own testing arm. Maestro MCP could be wired in later as an optional extension for users who want it. | Yes — could add maestro MCP wrapper extension later | diff --git a/.gsd/PROJECT.md b/.gsd/PROJECT.md new file mode 100644 index 000000000..d6afd5428 --- /dev/null +++ b/.gsd/PROJECT.md @@ -0,0 +1,36 @@ +# Project + +## What This Is + +GSD 2.0 is a branded npm CLI (`npm install -g gsd-pi`) that ships the full GSD coding agent experience as a standalone product. It embeds `@mariozechner/pi-coding-agent` via SDK, stores state in `~/.gsd/`, bundles the GSD extension, all supporting extensions, agents, and AGENTS.md context, and runs pi's `InteractiveMode` under the `gsd` brand. Users run `gsd` — not `pi`. + +## Core Value + +A single `npm install -g gsd-pi` gives any developer a fully configured, GSD-branded coding agent with the GSD extension, all supporting tools (browser, search, context7, subagent, bg-shell, etc.), and a first-run setup wizard that collects API keys — ready to use in under two minutes. + +## Current State + +M001/S01, S02, and S03 complete. `gsd` binary compiles and launches with "gsd" TUI branding. All 11 bundled extensions load without errors. State goes to `~/.gsd/`. `~/.pi/` is untouched. AGENTS.md auto-deployed to `~/.gsd/agent/` on first launch. First-run wizard fires for missing optional keys (Brave/Context7/Jina), stores them with masked input, and skips on subsequent launches. Only S04 (npm publish and install smoke test) remains. + +Key structural artifact: `pkg/` shim directory — `PI_PACKAGE_DIR` points here (not project root) to avoid pi's `getThemesDir()` collision with our real `src/` dir. Committed; `pkg/dist/modes/interactive/theme/` populated by `npm run copy-themes` at build time. + +## Architecture / Key Patterns + +- **SDK embedding**: `@mariozechner/pi-coding-agent` imported as a library via `createAgentSession` + `InteractiveMode` +- **Branded app directories**: state lives in `~/.gsd/agent/`, sessions in `~/.gsd/sessions/` (constants in `src/app-paths.ts`) +- **Branding via `PI_PACKAGE_DIR`**: env var set in `src/loader.ts` before any pi SDK loads; points to `pkg/` shim; `pkg/package.json` declares `piConfig: { name: "gsd", configDir: ".gsd" }` +- **Two-file loader pattern**: `loader.ts` (sets env vars, zero SDK imports, dynamic-imports `cli.js`) → `cli.ts` (static SDK imports, wires all managers) +- **pkg/ shim**: lean subdirectory — only `package.json` (piConfig) and `dist/modes/interactive/theme/` (pi theme assets). No `src/`. Avoids `getThemesDir()` src-check collision. +- **Bundled extensions**: GSD extension + 10 supporting extensions in `src/resources/extensions/`; loaded via `buildResourceLoader()` → `DefaultResourceLoader.additionalExtensionPaths`; all 11 load clean on launch +- **Bundled agents + AGENTS.md**: scout, researcher, worker in `src/resources/agents/`; `initResources()` writes bundled AGENTS.md to `~/.gsd/agent/` on first launch (existsSync guard) +- **4 GSD_ env vars**: set in loader.ts before cli.js loads — `GSD_CODING_AGENT_DIR`, `GSD_BIN_PATH`, `GSD_WORKFLOW_PATH`, `GSD_BUNDLED_EXTENSION_PATHS` +- **First-run wizard**: `src/wizard.ts` — detects missing optional keys (Brave/Context7/Jina), prompts with masked TTY input, writes to `~/.gsd/agent/auth.json`; `loadStoredEnvKeys` hydrates env on every launch before extensions load + +## Capability Contract + +See `.gsd/REQUIREMENTS.md` for the explicit capability contract, requirement status, and coverage mapping. + +## Milestone Sequence + +- [ ] M001: MVP CLI — `npm install -g gsd-pi` installs, launches, and runs with all bundled extensions and first-run setup +- [ ] M003: AI-Driven Test Flows — intent-based YAML test specs the agent writes during development and executes autonomously at UAT time (browser, mac, api targets) diff --git a/.gsd/QUEUE.md b/.gsd/QUEUE.md new file mode 100644 index 000000000..3160d3feb --- /dev/null +++ b/.gsd/QUEUE.md @@ -0,0 +1,7 @@ +# Queue + + + +| # | Queued | Milestone | Title | Depends On | Notes | +|---|--------|-----------|-------|------------|-------| +| 1 | 2026-03-11 | M003 | AI-Driven Test Flows | M001 (bundled extension infrastructure) | Intent-based YAML test specs — browser, mac, api targets — with flow-driven UAT type for autonomous execution at slice completion | diff --git a/.gsd/REQUIREMENTS.md b/.gsd/REQUIREMENTS.md new file mode 100644 index 000000000..cb4f7cf99 --- /dev/null +++ b/.gsd/REQUIREMENTS.md @@ -0,0 +1,205 @@ +# Requirements + +This file is the explicit capability and coverage contract for GSD 2.0. + +## Active + +### R001 — Single-command install + +- Class: primary-user-loop +- Status: validated +- Description: `npm install -g gsd-pi` installs the gsd CLI and all bundled resources in a single command with no additional manual steps required +- Why it matters: The whole product promise is zero-friction install. If install requires manual steps, the product fails its core pitch. +- Source: user +- Primary owning slice: M001/S01 +- Supporting slices: M001/S04 +- Validation: S04 — npm install -g gsd-pi from registry installs working binary; zero extension load errors; R001 fully validated + +### R002 — Branded identity + +- Class: differentiator +- Status: validated +- Description: The CLI is named `gsd`, state lives in `~/.gsd/`, the TUI header shows "gsd", and no pi branding is visible to the user in normal operation +- Why it matters: GSD 2.0 is a product, not a pi config. Users should experience a coherent branded tool. +- Source: user +- Primary owning slice: M001/S01 +- Supporting slices: none +- Validation: S01 — TUI header confirmed "gsd" via live runtime launch; piConfig.name=gsd, piConfig.configDir=.gsd verified; ~/.gsd/ confirmed created + +### R003 — Bundled GSD extension + +- Class: core-capability +- Status: validated +- Description: The `/gsd` command, auto-mode, GSD dashboard (Ctrl+Alt+G), and all GSD workflow commands work out of the box with no additional configuration +- Why it matters: The GSD extension is the primary reason users install this tool. +- Source: user +- Primary owning slice: M001/S02 +- Supporting slices: none +- Validation: S02 — gsd extension loads without errors on launch (zero stderr extension errors confirmed); interactive /gsd command use deferred to S04 UAT + +### R004 — Bundled supporting extensions + +- Class: core-capability +- Status: validated +- Description: All extensions from `~/.pi/agent/extensions/` ship bundled: browser-tools, search-the-web, context7, subagent, bg-shell, worktree, plan-mode, slash-commands, ask-user-questions, get-secrets-from-user +- Why it matters: These extensions are what make the agent useful as a coding agent. GSD without browser tools, web search, and subagent is significantly less capable. +- Source: user +- Primary owning slice: M001/S02 +- Supporting slices: none +- Validation: S02 — all 10 supporting extensions load without errors (zero stderr extension errors on launch); functional tool use (browser launch, web search) deferred to S04 UAT + +### R005 — Bundled agents and AGENTS.md + +- Class: core-capability +- Status: validated +- Description: The scout, researcher, and worker agents are bundled and available. The AGENTS.md hard rules and execution heuristics are loaded as the default agent context. +- Why it matters: Agents and AGENTS.md define how the model behaves. Without them, subagent delegation and model discipline don't work. +- Source: user +- Primary owning slice: M001/S02 +- Supporting slices: none +- Validation: S02 — scout.md, researcher.md, worker.md present in src/resources/agents/; AGENTS.md (15,070 bytes) written to ~/.gsd/agent/ on first launch via initResources() + +### R006 — First-run setup wizard + +- Class: launchability +- Status: validated +- Description: On first run, if optional tool API keys (Brave, Context7, Jina) are missing, a wizard prompts for them with masked input. Keys are stored in `~/.gsd/agent/auth.json` and hydrated into process.env on every launch. Wizard does not run on subsequent starts if keys are already configured. Anthropic auth is handled by pi's OAuth/API key flow — not the wizard. +- Why it matters: Without API keys, nothing works. A wizard that detects and collects missing keys turns a broken first run into a successful one. +- Source: user +- Primary owning slice: M001/S03 +- Supporting slices: none +- Validation: S03 — automated verify script (6/6 pass) + interactive UAT; wizard fires for missing optional keys, stores them, TUI launches, rerun skips wizard + +### R007 — Isolated state in ~/.gsd/ + +- Class: quality-attribute +- Status: validated +- Description: All GSD state (auth, sessions, settings, logs) lives in `~/.gsd/`, completely separate from `~/.pi/`. Installing gsd must not modify or read a user's existing pi configuration. +- Why it matters: Users may have an existing pi installation. GSD must not corrupt or interfere with it. +- Source: inferred +- Primary owning slice: M001/S01 +- Supporting slices: none +- Validation: S01 — ~/.gsd/agent/ and ~/.gsd/sessions/ created after launch; ~/.pi/agent/sessions/ count unchanged (28/28) before and after gsd run + +### R008 — npm update workflow + +- Class: continuity +- Status: validated +- Description: `npm update -g gsd-pi` installs a new version with updated bundled resources. The update is clean — no stale extension files from old versions. +- Why it matters: Software that can't update cleanly accumulates technical debt and breaks silently. +- Source: user +- Primary owning slice: M001/S04 +- Supporting slices: none +- Validation: S04 — cpSync force:true in initResources ensures npm update -g replaces bundled resources; tarball smoke test confirms clean install path + +### R009 — Observable failure state + +- Class: failure-visibility +- Status: validated +- Description: If optional tool API keys are missing in a non-interactive run, the warning is actionable: it names the missing providers. Extension load failures are surfaced, not silently swallowed. +- Why it matters: Silent failures are debugging nightmares. A future agent or user must be able to localize what broke without guessing. +- Source: inferred +- Primary owning slice: M001/S03 +- Supporting slices: M001/S02 +- Validation: S03 — non-TTY warning names all three missing providers (Brave Search, Context7, Jina); cat ~/.gsd/agent/auth.json shows stored state; extension load failure surface from S02 confirmed intact + +### R010 — Test flow execution + +- Class: core-capability +- Status: active +- Description: The agent can write YAML test specifications during development and execute them against browser, mac, and api targets via `run_test_flow` and `run_test_suite` tools. Flows use intent-based verification blocks (verify/given/expect) that the agent interprets adaptively. Browser tests run in a fresh isolated Playwright session. +- Why it matters: Closes the gap between "agent builds a feature" and "agent proves it works" — durable, re-runnable test artifacts that survive context wipes. +- Source: user +- Primary owning slice: M003 (TBD) +- Supporting slices: none +- Validation: unmapped + +### R011 — Flow-driven UAT + +- Class: core-capability +- Status: active +- Description: GSD auto-mode recognizes `flow-driven` as a UAT type. At slice completion, the UAT pipeline automatically executes all flow files in the slice's `flows/` directory and writes structured pass/fail results to the UAT result file. +- Why it matters: Makes UAT fully autonomous for slices with test flows — no human intervention needed for UI/API verification. +- Source: user +- Primary owning slice: M003 (TBD) +- Supporting slices: none +- Validation: unmapped + +## Deferred + +### R020 — Plugin system + +- Class: differentiator +- Status: deferred +- Description: Allow users to install additional pi packages on top of GSD via `gsd install npm:pkg` +- Why it matters: Makes GSD extensible beyond what ships in the box +- Source: inferred +- Primary owning slice: none +- Supporting slices: none +- Validation: unmapped +- Notes: Deferred — M001 ships bundled-only. Plugin support is explicitly post-MVP. + +### R021 — Skills bundle + +- Class: core-capability +- Status: deferred +- Description: Ship the skills from `~/.pi/agent/skills/` as bundled GSD skills +- Why it matters: Skills provide specialized workflows +- Source: user +- Primary owning slice: none +- Supporting slices: none +- Validation: unmapped +- Notes: User explicitly excluded skills from M001. Can add in M002. + +## Out of Scope + +### R030 — pi compatibility / interoperability + +- Class: anti-feature +- Status: out-of-scope +- Description: GSD does not read from or write to `~/.pi/`. There is no migration from pi to gsd. No `pi install npm:gsd` target. +- Why it matters: Prevents scope confusion. GSD is a product, not a pi extension. +- Source: user +- Primary owning slice: none +- Supporting slices: none +- Validation: n/a +- Notes: Explicitly out of scope by architecture decision. + +### R031 — Web/desktop UI + +- Class: constraint +- Status: out-of-scope +- Description: GSD 2.0 is terminal-only. No web UI, no Electron wrapper, no RPC mode. +- Why it matters: Keeps scope focused on the CLI product. +- Source: inferred +- Primary owning slice: none +- Supporting slices: none +- Validation: n/a +- Notes: `pi-web-ui` and RPC mode explicitly not used. + +## Traceability + +| ID | Class | Status | Primary owner | Supporting | Proof | +| ---- | ------------------ | ------------ | ------------- | ---------- | -------- | +| R001 | primary-user-loop | validated | M001/S01 | M001/S04 | S04 — npm install -g gsd-pi from registry; zero extension errors; binary confirmed | +| R002 | differentiator | validated | M001/S01 | none | S01 — TUI shows "gsd", piConfig confirmed, ~/.gsd/ confirmed | +| R003 | core-capability | validated | M001/S02 | none | S02 — gsd extension loads clean; interactive /gsd use deferred to S04 | +| R004 | core-capability | validated | M001/S02 | none | S02 — all 10 supporting extensions load without errors; functional use deferred to S04 | +| R005 | core-capability | validated | M001/S02 | none | S02 — agents present; AGENTS.md (15,070 bytes) written to ~/.gsd/agent/ on first launch | +| R006 | launchability | validated | M001/S03 | none | S03 — optional-key wizard fires, stores, skips on rerun | +| R007 | quality-attribute | validated | M001/S01 | none | S01 — ~/.gsd/ created; ~/.pi/ sessions unchanged (28/28) | +| R008 | continuity | validated | M001/S04 | none | S04 — cpSync force:true; tarball smoke confirms clean install path | +| R009 | failure-visibility | validated | M001/S03 | M001/S02 | S03 — non-TTY warning names missing providers; extension errors surface confirmed | +| R020 | differentiator | deferred | none | none | unmapped | +| R021 | core-capability | deferred | none | none | unmapped | +| R010 | core-capability | active | M003 (TBD) | none | unmapped | +| R011 | core-capability | active | M003 (TBD) | none | unmapped | +| R030 | anti-feature | out-of-scope | none | none | n/a | +| R031 | constraint | out-of-scope | none | none | n/a | + +## Coverage Summary + +- Active requirements: 11 +- Mapped to slices: 9 +- Validated: 9 (R001, R002, R003, R004, R005, R006, R007, R008, R009) +- Unmapped active requirements: 2 (R010, R011 — pending M003 planning) diff --git a/.gsd/milestones/M003/M003-CONTEXT.md b/.gsd/milestones/M003/M003-CONTEXT.md new file mode 100644 index 000000000..9363a614d --- /dev/null +++ b/.gsd/milestones/M003/M003-CONTEXT.md @@ -0,0 +1,133 @@ +# M003: AI-Driven Test Flows — Context + +**Gathered:** 2026-03-11 +**Status:** Queued — pending auto-mode execution + +## Project Description + +A new GSD extension (`test-flows`) that introduces intent-based YAML test specifications the agent writes during development and executes autonomously at UAT time. Flows describe **what to verify** (not mechanical step-by-step scripts), and the agent interprets each verification block using its full adaptive intelligence — choosing selectors, handling flakiness, retrying intelligently, and diagnosing failures. + +Supports three target surfaces: **browser** (web apps via Playwright), **mac** (native macOS apps via Accessibility APIs), and **api** (HTTP request/response verification). + +This is GSD's testing arm — the thing that closes the loop between "agent builds a feature" and "agent proves it works." + +## Why This Milestone + +GSD's current UAT pipeline has a gap: `artifact-driven` UAT runs shell commands and file checks, while `live-runtime` and `human-experience` UAT punt to the human. There is no way for the agent to write durable, re-runnable UI/API tests during development that execute automatically at UAT time. + +The agent already has the tools (`browser_*`, `mac_*`, `bash` for HTTP) — what's missing is a structured format for persisting test intent and a runner that orchestrates execution against fresh isolated sessions. This milestone fills that gap. + +The insight from Maestro evaluation: don't compete with Maestro as a standalone deterministic test runner. Instead, leverage what GSD is uniquely good at — AI-driven adaptive execution of test specifications. The YAML files are intent specs, not scripts. The AI handles the "how." + +## User-Visible Outcome + +### When this milestone is complete, the user can: + +- See the agent write `.yaml` test flow files during slice development that describe what to verify +- Have UAT run automatically at slice completion — the agent executes all flow files and writes a structured pass/fail report +- Read `S01-UAT-RESULT.md` with per-flow, per-verification pass/fail results, timing, screenshots on failure, and diagnostic context +- Manually trigger test flows via the agent calling `run_test_flow` or `run_test_suite` tools at any time +- Test web apps (browser target), macOS apps (mac target), and APIs (api target) from the same flow format + +### Entry point / environment + +- Entry point: LLM tool calls (`run_test_flow`, `run_test_suite`) + GSD auto-mode UAT pipeline +- Environment: local dev (macOS terminal running `gsd`) +- Live dependencies involved: Playwright (bundled), mac-tools Swift CLI (bundled), HTTP via Node fetch (built-in) + +## Completion Class + +- Contract complete means: flow YAML parser validates correctly, runner executes all three targets (browser/mac/api) and returns structured results, `flow-driven` UAT type is recognized by the auto-mode pipeline +- Integration complete means: agent writes flows during development, auto-mode UAT dispatches `run_test_suite`, results appear in `S01-UAT-RESULT.md`, failures include screenshots and diagnostics +- Operational complete means: the full loop works end-to-end in a real GSD auto-mode session — agent builds a web feature, writes test flows, completes the slice, UAT runs the flows, report is written + +## Final Integrated Acceptance + +To call this milestone complete, we must prove: + +- Agent can write a browser-target flow YAML during development, and `run_test_flow` executes it against a running local web app with correct pass/fail results +- Agent can write a mac-target flow YAML, and it executes against a real macOS app (e.g., TextEdit) with correct pass/fail results +- Agent can write an api-target flow YAML with HTTP request/response checks, and it executes correctly +- `flow-driven` UAT type triggers automatic test suite execution at slice completion in auto-mode, with results written to the UAT result file +- Test execution uses a fresh isolated browser session, not the agent's development browser +- Failures include actionable diagnostics: screenshots, console logs (browser), element state (mac), response bodies (api) + +## Risks and Unknowns + +- **Inter-extension isolation** — The test-flows extension must run its own Playwright browser instance, separate from browser-tools' instance. Two Playwright instances in the same process should work (Playwright supports it), but needs verification. If they conflict, the runner may need to use a subprocess. +- **Mac-tools CLI access** — The test-flows extension needs to call the mac-tools Swift CLI binary directly. The binary is compiled on first use by the mac-tools extension. test-flows must either wait for mac-tools to compile it first, or handle compilation itself. Need to determine the right approach. +- **Agent flow authoring quality** — The value depends on Claude writing good test specifications during development. If the generated flows are too vague or too brittle, the system fails in practice. This is a prompt engineering challenge, not a code challenge. The system prompt guidelines for the tool must be excellent. +- **Adaptive execution reliability** — Each `verify` block is interpreted by the LLM. Non-determinism means a flow might pass one run and fail the next. Need to design the execution model to minimize this (clear verify/expect structure, retries, good diagnostics on failure). +- **Execution model for verify blocks** — The runner tool receives a YAML flow and must execute each verify block. Since extensions can't call other extensions' tools, the runner must use Playwright/mac-tools/fetch directly (not via `browser_*` tools). This means reimplementing some of the smart waiting/settling logic from browser-tools. Alternatively, each verify block could be dispatched as an LLM sub-turn — but that's expensive and slow. The right balance needs to be found. + +## Existing Codebase / Prior Art + +- `src/resources/extensions/browser-tools/index.ts` — Full Playwright browser automation extension (~4990 lines). Reference for Playwright patterns, adaptive settling, assertion evaluation, screenshot capture. The test-flows runner will import Playwright directly rather than calling these tools. +- `src/resources/extensions/browser-tools/core.js` — Runtime-neutral helpers: action timeline, assertion evaluation (`evaluateAssertionChecks`), compact state diffing. May be importable by test-flows. +- `src/resources/extensions/mac-tools/index.ts` — macOS Accessibility API automation via Swift CLI. Reference for how to invoke the Swift CLI binary (`execFileSync` with JSON protocol). +- `src/resources/extensions/gsd/auto.ts` — GSD auto-mode engine. Contains `checkNeedsRunUat()`, `buildRunUatPrompt()`, UAT dispatch logic. Must be modified to support `flow-driven` UAT type. +- `src/resources/extensions/gsd/files.ts` — Contains `extractUatType()` which classifies UAT types from markdown content. Must be extended with `flow-driven`. +- `src/resources/extensions/gsd/prompts/run-uat.md` — UAT execution prompt template. Must be extended with `flow-driven` instructions. +- `src/resources/extensions/gsd/templates/uat.md` — UAT file template. Must include `flow-driven` as a valid UAT mode. +- Maestro (external, not embedded) — Inspiration for YAML flow format and "arm's length" testing philosophy. Not a dependency. Key takeaways: declarative YAML syntax, smart waiting, accessibility-layer interaction, cross-platform unified format. + +> See `.gsd/DECISIONS.md` for all architectural and pattern decisions — it is an append-only register; read it during planning, append to it during execution. + +## Relevant Requirements + +- R003 (Bundled GSD extension) — This extends the GSD extension's UAT pipeline with a new type +- R004 (Bundled supporting extensions) — This adds a new bundled extension (`test-flows`) +- New requirement candidates: + - R010 — Test flow execution: agent can write and execute YAML test specifications against browser, mac, and api targets + - R011 — Flow-driven UAT: auto-mode recognizes `flow-driven` UAT type and executes test suites automatically at slice completion + +## Scope + +### In Scope + +- New `test-flows` extension in `src/resources/extensions/test-flows/` +- YAML flow format: header (name, target, url/app/endpoint) + verification blocks (verify/given/expect) +- Flow parser with validation and clear error messages +- Browser target runner: own Playwright instance, fresh context per flow, smart waiting, screenshot capture +- Mac target runner: direct Swift CLI invocation, element resolution, screenshot capture +- API target runner: HTTP requests via Node fetch, status/header/body assertions +- Two LLM tools: `run_test_flow` (single flow) and `run_test_suite` (directory of flows) +- Structured result output: per-flow, per-verification pass/fail, timing, screenshots, diagnostics +- New `flow-driven` UAT type in GSD extension (`files.ts`, `auto.ts`, `run-uat.md`, `uat.md`) +- System prompt guidelines that teach the agent when and how to write good test flows +- Flow files stored alongside slices: `.gsd/milestones/M00X/slices/S0X/flows/*.yaml` + +### Out of Scope / Non-Goals + +- Maestro compatibility (not a goal — different format, different execution model) +- Visual regression testing / image diffing (future enhancement) +- Parallel flow execution / sharding (future enhancement) +- CI/CD integration or headless-only mode (future enhancement) +- Flow recording / interactive flow authoring UI (future enhancement — Maestro Studio equivalent) +- Mobile device/simulator testing (would require Maestro or Appium — out of scope) + +## Technical Constraints + +- Must be a pi extension following existing patterns (`export default function(pi: ExtensionAPI)`) +- Must use TypeBox for tool parameter schemas, StringEnum for enums +- Must truncate tool output to stay within context limits +- Browser runner must use a separate Playwright instance from browser-tools (test isolation) +- Mac runner must invoke the Swift CLI binary at the known path (`src/resources/extensions/mac-tools/swift-cli/.build/release/mac-agent`) +- No new npm dependencies beyond what's already bundled (Playwright, yaml parsing via existing means) +- Extension loads via `additionalExtensionPaths` — same mechanism as all other bundled extensions + +## Integration Points + +- `browser-tools` extension — Shares Playwright dependency but NOT browser state. test-flows runs its own Playwright instance. +- `mac-tools` extension — test-flows calls the same Swift CLI binary but independently. Must handle the case where the binary hasn't been compiled yet. +- `gsd` extension — UAT pipeline integration: `files.ts` (extractUatType), `auto.ts` (checkNeedsRunUat, buildRunUatPrompt), `prompts/run-uat.md`, `templates/uat.md` +- `src/loader.ts` / `src/cli.ts` — test-flows must be added to `GSD_BUNDLED_EXTENSION_PATHS` and `initResources()` file sync +- Playwright — Direct import for browser automation (already a dependency of the project) +- Node.js `fetch` — For API target HTTP requests (built into Node 18+) + +## Open Questions + +- **Verify block execution model** — Should each `verify` block be executed by deterministic code (parse expect clauses, run Playwright assertions) or by sending the block to the LLM as a sub-task? Deterministic is faster and cheaper but less adaptive. LLM sub-task is more flexible but slower and non-deterministic. Hybrid approach (deterministic for simple assertions, LLM for complex "verify this looks right" blocks) may be the sweet spot. Needs design decision in planning. +- **YAML parsing** — Use `js-yaml` (would need to add as dependency) or parse the simple format manually? The format is simple enough that a hand-rolled parser might suffice and avoids a new dep. +- **Mac binary compilation timing** — If test-flows needs the mac-tools binary and it hasn't been compiled yet, should test-flows trigger compilation or just fail with a clear message? Triggering compilation would duplicate logic from mac-tools extension. +- **Flow file discovery for UAT** — When `run_test_suite` is called for a slice's flows, should it discover files by convention (all `.yaml` in the `flows/` dir) or should the UAT file explicitly list which flows to run? From f8052870dffd7e907a060e5ff07e5f0eeb2fb4a9 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:26:07 -0600 Subject: [PATCH 41/60] feat: add CHANGELOG.md and project-local publish-version command CHANGELOG.md covers v0.1.6 through v0.3.3 with curated entries. Publish command uses manual npm publish instead of GitHub Action. Co-Authored-By: Claude Opus 4.6 --- .claude/commands/publish-version.md | 223 ++++++++++++++++++++++++++++ CHANGELOG.md | 119 +++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 .claude/commands/publish-version.md create mode 100644 CHANGELOG.md diff --git a/.claude/commands/publish-version.md b/.claude/commands/publish-version.md new file mode 100644 index 000000000..41bb85fc1 --- /dev/null +++ b/.claude/commands/publish-version.md @@ -0,0 +1,223 @@ +--- +description: Publish GSD updates to npm and GitHub +--- + +Publish GSD updates with automatic changelog generation. + + + + +## 1. Check for Uncommitted Changes + +```bash +git status --short +``` + +If uncommitted changes exist: +- Ask: "Uncommitted changes detected. What commit message should I use?" +- Commit with provided message +- Continue to next step + + + +## 2. Get Commits Since Last Version + +```bash +LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") +if [ -n "$LAST_TAG" ]; then + git log ${LAST_TAG}..HEAD --oneline --no-merges +else + echo "No previous tags found" +fi +``` + +Capture the commit list for changelog generation. + + + +## 3. Check Documentation Currency + +Review the commits captured above and check if README.md needs updates. + +**Check for commits that require README updates:** +- New commands or features +- Changed command behavior or flags +- New configuration options +- New workflows or processes +- Deprecations or removals + +**Review README.md against commits:** +1. Read README.md +2. For each significant commit, verify the feature/change is documented +3. Check command tables match actual commands +4. Check configuration tables match actual options + +**If updates needed:** +1. Draft specific README changes +2. Present changes for approval +3. Apply approved changes +4. Commit: `git add README.md && git commit -m "docs: update README for vX.Y.Z"` + +**If no updates needed:** +- State: "README is current with all changes" +- Continue to next step + + + +## 4. Generate Changelog Entry Draft + +Analyze the commits and draft a curated changelog entry. + +**Grouping rules:** +- **Added** — New features, commands, capabilities +- **Changed** — Modifications to existing behavior +- **Fixed** — Bug fixes +- **Removed** — Deprecated/removed features +- **BREAKING:** prefix for breaking changes + +**Writing rules:** +- Write human-readable descriptions, not raw commit messages +- Focus on user impact, not implementation details +- Group related commits into single entries +- Flag breaking changes prominently with **BREAKING:** prefix + +**Example draft:** +```markdown +## [X.Y.Z] - YYYY-MM-DD + +### Added +- New `/gsd:whats-new` command for version awareness + +### Changed +- Improved parallel execution performance + +### Fixed +- STATE.md progress bar calculation + +### Removed +- **BREAKING:** Removed deprecated ISSUES.md system +``` + +Present the draft for review. + + + +## 5. Review Changelog Draft + +**Drafted changelog entry:** +[Show the generated draft] + +**Verify:** +1. Categories are correct (Added/Changed/Fixed/Removed) +2. Descriptions are clear and user-focused +3. Breaking changes are marked with **BREAKING:** prefix +4. Nothing important is missing from commits + +**Resume signal:** Type "approved" or provide edits + + + +## 6. Update CHANGELOG.md + +After approval: + +1. **Read current CHANGELOG.md** +2. **Insert new version section** after [Unreleased] header +3. **Update version links** at bottom: + - Add new version link: `[X.Y.Z]: https://github.com/gsd-build/gsd-2/releases/tag/vX.Y.Z` + - Update [Unreleased] comparison: `[Unreleased]: https://github.com/gsd-build/gsd-2/compare/vX.Y.Z...HEAD` + +```bash +# Stage changelog +git add CHANGELOG.md +git commit -m "docs: update changelog for vX.Y.Z" +``` + + + +## 7. Bump Version + +Ask which version bump type: +- `npm version patch` — Bug fixes (default) +- `npm version minor` — New features +- `npm version major` — Breaking changes +- `npm version prerelease --preid=alpha` — Experimental features + +```bash +npm version patch # or minor/major/prerelease +``` + +This creates a version commit and tag. + + + +## 8. Push and Publish + +```bash +git push && git push --tags +``` + +Then publish to npm: + +```bash +npm publish --access public +``` + +Verify the publish succeeded by checking the output for the package URL. + + + +## 9. Create GitHub Release + +Create a GitHub Release from the tag. + +```bash +gh release create vX.Y.Z --title "vX.Y.Z" --notes "[changelog content]" --latest +``` + +Use the approved changelog content as the release notes. + + + +## 10. Post to Discord Changelog + +Post the changelog entry to the GSD Discord community. + +Use the Discord MCP server: +``` +discord_execute("messages.send", { + "channel_id": "1464128246290579469", + "content": "**vX.Y.Z Released** \n\n[changelog content here]\n\nInstall/upgrade: `npx gsd-pi@latest`" +}) +``` + +Format the message with: +- Version number as header +- The approved changelog content (Added/Changed/Fixed/Removed sections) +- Install command at the bottom + + + +## 11. Report Success + +``` +Published vX.Y.Z + +- npm: https://www.npmjs.com/package/gsd-pi +- GitHub: https://github.com/gsd-build/gsd-2/releases/tag/vX.Y.Z +``` + + + + + +- README.md checked against commits and updated if needed +- Changelog entry drafted from commits +- User reviewed and approved entry +- CHANGELOG.md updated and committed +- Version bumped via npm version +- Pushed to GitHub with tags +- Published to npm via `npm publish` +- GitHub Release created with `gh release create` +- Changelog posted to Discord #changelog channel + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..5c1ce215a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,119 @@ +# Changelog + +All notable changes to GSD are documented in this file. + +Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +## [0.3.3] - 2026-03-11 + +### Added +- `/gsd next` step mode — walk through units one at a time with a wizard between each +- `/gsd` bare command defaults to step mode +- `/exit` command to kill the GSD process immediately +- `/clear` as alias for `/new` (new session) +- MCPorter extension for lazy on-demand MCP server integration +- `/voice` extension for real-time speech-to-text +- Pi global install scripts +- Post-hook bookkeeping: auto-run doctor + rebuild STATE.md after each unit + +### Changed +- Improved worktree merge, create, remove, and reload resilience +- Discuss prompt rewritten with reflection step and depth enforcement + +### Fixed +- Idle watchdog false-firing on active agents — tasks >10min no longer get incorrectly skipped (#52) +- Browser screenshots constrained to 1568px max dimension (#56) +- Pi extensions loaded from `~/.pi/agent/extensions/` (#51) + +### Removed +- `/gsd-run` command (replaced by `/gsd` and `/gsd next`) + +## [0.3.1] - 2026-03-11 + +### Fixed +- Windows VT input restored after child processes exit (#41) +- Print/JSON mode in cli.js so subagents don't hang +- Discuss prompt loop prevention +- Managed tools bootstrap and gh auth +- Session list scoped to current working directory +- Bash/bg_shell hang and kill issues on Windows (#40) +- `/gsd-run` hardcoded `~/.pi/` path (#38) +- Windows backspace in masked input + custom browser path support (#36, #34) + +### Changed +- Renamed "Get Stuff Done" to "Get Shit Done" + +## [0.3.0] - 2026-03-11 + +### Added +- `/worktree` (`/wt`) — git worktree lifecycle management (#31) +- `/gsd migrate` — `.planning` to `.gsd` migration tool (#28) + +### Fixed +- Skipped API keys now persist so wizard doesn't repeat on every launch (#27) +- Scoped models restored from settings on new session startup (#22) +- Startup fallback no longer overwrites user's default model with Sonnet (#29) + +## [0.2.9] - 2026-03-11 + +### Fixed +- Idle recovery skips stuck units instead of silently stalling (#19) +- `pkg/package.json` version synced with pi-coding-agent to prevent false update banner +- Milestones with summary but no roadmap treated as complete (#13) + +## [0.2.8] - 2026-03-11 + +### Added +- Mac-tools extension (macOS native automation) + +## [0.2.6] - 2026-03-11 + +### Fixed +- Default model validated against full registry on every startup + +## [0.2.5] - 2026-03-11 + +### Fixed +- Circular self-dependency removed, default model set to anthropic/claude-sonnet-4-6 with thinking off + +## [0.2.4] - 2026-03-11 + +### Added +- Branded setup wizard UI with visual hierarchy, descriptions, and status feedback +- Branded banner on first launch +- Postinstall banner with version and next-step hint + +### Fixed +- All `.pi/` paths updated to `.gsd/` +- Default model matching by `id.includes('sonnet')` for dated API IDs +- Circular gsd-pi self-dependency removed +- Pi SDK version check suppressed +- Selected options stay lit when notes field is focused + +## [0.1.6] - 2026-03-11 + +### Added +- GitHub extension tool suite with confirmation gate +- Bundled skills: frontend-design, swiftui, debug-like-expert +- Skills trigger table in system prompt +- Resource loader syncs bundled skills to `~/.gsd/agent/skills/` + +### Fixed +- `~/.gsd/agent/` paths in prompt templates instead of `~/.pi/agent/` (#10) +- Guard against re-injecting discuss prompt when session already in flight + +### Changed +- License updated to MIT + +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v0.3.3...HEAD +[0.3.3]: https://github.com/gsd-build/gsd-2/compare/v0.3.1...v0.3.3 +[0.3.1]: https://github.com/gsd-build/gsd-2/compare/v0.3.0...v0.3.1 +[0.3.0]: https://github.com/gsd-build/gsd-2/compare/v0.2.9...v0.3.0 +[0.2.9]: https://github.com/gsd-build/gsd-2/compare/v0.2.8...v0.2.9 +[0.2.8]: https://github.com/gsd-build/gsd-2/compare/v0.2.6...v0.2.8 +[0.2.6]: https://github.com/gsd-build/gsd-2/compare/v0.2.5...v0.2.6 +[0.2.5]: https://github.com/gsd-build/gsd-2/compare/v0.2.4...v0.2.5 +[0.2.4]: https://github.com/gsd-build/gsd-2/compare/v0.1.6...v0.2.4 +[0.1.6]: https://github.com/gsd-build/gsd-2/releases/tag/v0.1.6 From 0d35e86549339950131c32d71544684cf744a128 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:28:01 -0600 Subject: [PATCH 42/60] feat(M001/S04): npm publish and install smoke test --- .gitignore | 10 ++++++- src/resources/extensions/voice/.gitignore | 1 + src/resources/extensions/voice/index.ts | 25 +++++++++++++++--- .../extensions/voice/speech-recognizer | Bin 60736 -> 0 bytes 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 src/resources/extensions/voice/.gitignore delete mode 100755 src/resources/extensions/voice/speech-recognizer diff --git a/.gitignore b/.gitignore index 35a2ad14c..f705cb735 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,12 @@ dist/ .artifacts/ AGENTS.md .bg-shell/ -TODOS.md \ No newline at end of file +TODOS.md + +# ── GSD baseline (auto-generated) ── +.gsd/activity/ +.gsd/runtime/ +.gsd/worktrees/ +.gsd/auto.lock +.gsd/metrics.json +.gsd/STATE.md diff --git a/src/resources/extensions/voice/.gitignore b/src/resources/extensions/voice/.gitignore new file mode 100644 index 000000000..2c61a071c --- /dev/null +++ b/src/resources/extensions/voice/.gitignore @@ -0,0 +1 @@ +speech-recognizer diff --git a/src/resources/extensions/voice/index.ts b/src/resources/extensions/voice/index.ts index c99400767..bf2be9fb1 100644 --- a/src/resources/extensions/voice/index.ts +++ b/src/resources/extensions/voice/index.ts @@ -1,12 +1,26 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import type { AssistantMessage } from "@mariozechner/pi-ai"; import { isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui"; -import { spawn, type ChildProcess } from "node:child_process"; +import { spawn, execSync, type ChildProcess } from "node:child_process"; +import * as fs from "node:fs"; import * as path from "node:path"; import * as readline from "node:readline"; +const SWIFT_SRC = path.join(__dirname, "speech-recognizer.swift"); const RECOGNIZER_BIN = path.join(__dirname, "speech-recognizer"); +function ensureBinary(): boolean { + if (fs.existsSync(RECOGNIZER_BIN)) return true; + try { + execSync(`swiftc "${SWIFT_SRC}" -o "${RECOGNIZER_BIN}" -framework Speech -framework AVFoundation`, { + timeout: 60000, + }); + return true; + } catch { + return false; + } +} + export default function (pi: ExtensionAPI) { if (process.platform !== "darwin") return; @@ -38,7 +52,7 @@ export default function (pi: ExtensionAPI) { dispose: branchUnsub, invalidate() {}, render(width: number): string[] { - // --- Row 1: pwd (branch) ... ● transcribing --- + // Row 1: pwd (branch) ... ● transcribing let pwd = process.cwd(); const home = process.env.HOME || process.env.USERPROFILE; if (home && pwd.startsWith(home)) pwd = `~${pwd.slice(home.length)}`; @@ -54,7 +68,7 @@ export default function (pi: ExtensionAPI) { const pad1 = " ".repeat(Math.max(1, width - visibleWidth(pwdStr) - voiceTagWidth)); const row1 = truncateToWidth(pwdStr + pad1 + voiceTag, width); - // --- Row 2: stats ... model (replicate default) --- + // Row 2: stats ... model let totalInput = 0, totalOutput = 0, totalCost = 0; for (const entry of ctx.sessionManager.getEntries()) { if (entry.type === "message" && entry.message.role === "assistant") { @@ -102,6 +116,11 @@ export default function (pi: ExtensionAPI) { return; } + if (!ensureBinary()) { + ctx.ui.notify("Voice: failed to compile speech recognizer (need Xcode CLI tools)", "error"); + return; + } + active = true; finalized = ""; setVoiceFooter(ctx, true); diff --git a/src/resources/extensions/voice/speech-recognizer b/src/resources/extensions/voice/speech-recognizer deleted file mode 100755 index 9251292d90f1b4aeb86382f43c884796c922046c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 60736 zcmeHw3w%`7wf8<5LJ|;&7|Jr-c2}} z_c-Ur&f06Awbx$zzt?`B`Elgak3K(#F@@ntL%0GVJDagPm>n_3#v?37aJiN{u3xlf zQAIhWCOFgxEi#G*Y9r7 zdf6A5CiDd*XvCWzvu;Xvxjf-WNcY!Ep;Y@$P8a%~mHLPW%!#MBzVIf!E;8Gt`6Hpm z-uV4{p3s*mH4vYwBcU7-*cb4Jdm1FwzP(c47i1>l&GwmfQC#Y)cX2Z#z3|J+7xvAS z8Hpd*K3U&LV^9leb*c7wrM^*8U!0e0F9LrN_|-&pUqtt(>f0>!<;a}G56V8+=0i)T z>bqO&%aZztALvQg=MIMho=9V=zDK3LaZ(BKgR(Ce3Pb|E{bjGz7jIMI2c?hN1Klpw zzGqYP5kIiLsDG30uXXAEI+)i>-+rm@u&gif1L>o2YA*Vc%eBx^>2MX7FLR2~N)>*` zK>3qGZAgrVY{Xrz`an;w=wIJq*>6cN<&Qr@5y4QCczXAm4w$NiLEtY~Cc>bt@kc|}L{v+)QYd&-c?jKZ7AWgnMS6KJfdZ&=)ghiep7dsrq{6>!qd3mMmIWD!iAmgHl$dTzC%h zzf6_$59u-Mn#EX*0|~kPm<(o0em^=AhH&l+G=_}(5o1q+KKdqO>KMj`BfcDg9$G3; zAir6GuneKUC-V))YWsLO5@~h>nqOx`!=V{Iy=F#jqc1Ld0n*8i8P2+Ue?4p74!$r%p$4jM?W7okTlX&+Fp zqpBnj^~0T^t8eXQ3238-%9)GMQ_fO!5tI|h5_pQLOfQ-aT=g2tCGu5+_T)Qby-~U& zTB`^8wT-_e<#r-Q@(&M$YjGf?oe6XuKYxoU4=p+Hc@8if8n^m->_O zeIKdG_|l4Sy2Fevw)KG~QFOOFQ zQuI*%B){gV{np1-B)0=~zJS}@FsXJhHWr%t)f3hmYE88ewqn%3vj$%ovRpohO!4UW3H~u$#{QSeIxT))|~axt@rC=6a)1=nLS1|SlV@O z>0=y5C(2_=)`!&-6yrk1ns*?t0zL1L4#=`})%Y3WQxR8C))B;!XHRvm!`S^EYuB$( zQ2xZOR-4l9ouU|C7OQBrP3&@~u?MR!Rg7(*?kNtV=2D089_72jVR)u12G)5ew{266 zuR~u8V})q=96Rz>k4$Sj5Ak@}-!qlH7je>q35V?7({hp0-r96Q*Un<4{ebO)uDH&7 zAxC;hr*~vg+x7yXKdy5pz5kRMEw;3Fq}`Y4E@*DGP3St%G@*-RGS3C3wsBpVYHLMn z)42Ty4vnK_;(eKu=Rj_}keQs(+}bq0>sIJGPWAd!HEKR*##F)ynB75Te6AXsK&f3m z1rDbm2N&DRw#{#DMO}~CGWSdcO;gzDQpkBHIE>kp4|J#vG)*yp4IO{vc+(t)_Y-EI zhvn3b1_mP?M)fd<(cQ$_PoNDKL(f!&UEP`nnof3rp3B<2iYR9h_~W+pJySDc74M_m z?Y3CO??GpwJm_e)Gy9wRCn~o;OJfGpL|n;IjS8}9iolTj*xStbNY=*&JGno+ts3ZG zC-o0dw$6-9q*Hx!K?xJ@kD;@Hf@)O1g7<{88-A!<_g3{QvaAtAV-?;VN*iH9w$VE- zd$W-B{uq>Wyq>JXyTj2|{kpJWGTM*wUrhOtmX!YkuAdY?bH4>7!zSv zbrs5}koF(Ern%E>|2vG2Yp;?071I7HY5$tO?LR{HuVcnB{&mv+ zTVQ_;Gk!)k)TqYah(hMCL?QDr(Rb9gUxG%`L?5mm%8ZA>|CRh`sA@#V$i6Q7^f0RP z7}YpQc|lvS#)#LwdYF`r_wRYk_!h|(GSOd$k-sxzJL*qkLG?&x?81BAmq)6`v&FXd z$P@;h=XJFWuewXNuC zX(^k>-0Qkrh!V_x)+mQC^2@K;~cGu3P>@N7^$;lDba8G9jmU<_PZpGLM>e;Ls z8)d%g399iXe8am#?KKX#A>+6jx5WP2CN_MVqKj;*Bjy@44Xq6^T@CCpe)?OY<;`3Bj}3((F*%(#il zDpHOAK@>7ui9)84DC~EEb|0d?C)So78FHM$oSTKQjoPGRI5T!PUD$Ovjr}L}*^Ug^ zzm?(5ii0(8hK>#!Gf0lwtrfCvMaZ78sRquOPTru{nmcV-dlsL5@^B{fo`5XLaec$l zR!y!h>i@8%nfgAhS59xmnzxwR8e`}R)!??kzD#LH=Y_&&j&^rHavbH*ILH0tpsjf= zY@+e_@-L>LT()N_%B8x+?LWQMv76R2ye!n;yuPM7bV3&El4hzG#;4c+jP=P8gbtJw zAE)BBp5A(3_i4!TvbU#k-;B>yG&lSj`A%?Izi7TN*IP*nRqt(tH3| zwIH+>v-Vb7ZdZ#9aSz2Ob$QVjTAOnDy8a$u(SmjUUeKL1XJhQ8^>mBvmc+XED9UJ& zYw6QluiuSwI1ceXn@M;-CFbK}Xr~vc-oOL+iusrkraj|r>!FGJsh+zaN9|4XG5HAL zcnodZ*L?hS*w=9ZGya3_G25? z0jr_w3vt_C9Gq>Z%71`15`CL&T95pywCM$Ij|1)S67Mg)Y{8rp9|w|fQdDC$bleoT z<=8)F%VOl8ByHK1Y754Rgg>A!P@n9EZ;>xhKfyS4l=d8iHQMfIeBVN26Jbs7l|?Gw z^AOKvgZK3`! z3VP4BKctf_&^_4x@CnDacYh%K*bvn?iua^1$*-Gw=7qT2D}BhBeXQGdPJ74SF~W6&X&m2g9l$>RK-0NM8;x-`7wd#vTC*nFu;cIO^Ky^fiFoJ7e0`9E zbz@G)$H4Xw`N24>d(g&LtHxKUZde~bK(_r*_=a+=YJ5ee-$Us?VMaM9%}*Uagw7+d z@eP_6$D(|kD^U6IHu*`izjPcyo4!Hg&=|;$<>hw%oEdiHVVM6qe#MNPhtk@Qy$ip= z+UhvwaT~^n%_#50&r#N|um^5LTfGOY(_#PbnX!p%dRH~L%zKdey=v^S+1fw&kjCE} ztiN)AO%=*p)wRo3+4Xho`=-Y2{}A%;DsA`Dc>b=V%|3?B;d3o)q%$7ErStnH`<{n= z9$Wjd1Hi^)-*U))_q>Lk16r9 z2b`U}tR(FjX@2??>#dIO0ORj6nv-%cU+-m4D9xRiKh5*?Ns}Da;ly`f%XbmJ$&3>> z(OT$S>}y8@bFu#DZ~4{s7ASDl-<#az4PjXWz56pSq2F z2J24SxwNjmk6}OA5ui0n?Db<|@|jpg=bywH_b146Y!x$p0eb2-wAU*51L}O@CbA3s zMVZYfG8D{d3axRD(i-;IUED{H(i--7Ju|S5Jxc4?_trDxkkoSw_I3VAu4Dg8X(pWZ zQdu5mybv$TqZ%I}enPg%@p{rh`cdwC8OVi8u8;_murb{$hE}dpig2iLv}o=8vBVt=6ggTg9Ee&JAmarP^|rP z;{HqZB46Wee6tus`MbZV#&_|q6SOrxHXz>V6l233G&VR<(=?_8>BvuOc&^tie6Xb{ncoE-n8A%%PAlS_XhVv3q77*cjC1dtOyEj!NvOQ>EROH@AffIpjKm{ zsGsS6J;L0xP96@R-=U@l_9r3>=p@b5fk4$!Y15saXg%%8iBFpg^f$sKI@T^b| zD?<^7A8#YkkbhAq6o4l4v#6q?ykegCM9Xe6#_kV9?D2-sYT3s8LiM`54Re|1pSBVM zTKs8n>;4&G{)N{xnSF~En$EvYA(@qaZF3NxMrgIZMtdE7uVwe^KKmxG?$hk}R5gSq z#Mj(*x8GjQ+s5s)Z*=>j8Y{tfiaxtWCdUf{?aN*iD9lYD-IHNs!7SLVPH|Kvd&!i! ziL^?+L7S3aDDnvXC1^6O)*cDiIe-8HUlG_ff4%P4=GhsmSmaoEBhL`m?<^4*n*2pH z0u{8($&sf*gnqW%QBhgySjy(HlG0_MiKnmnk@MN{Rfmpt7{=`Evv<2Ojf%(RDvn1v zO|b*eBsq-iO~O{n@aJ>I?-?k5klEN;P^`(=F{FDWea7hSCZ2stujf z=I-w9(JYOf1icku|A`(OvDQd>ZpVA&4g6tFow19I94zg?im1V86V$2s`o z#V4}ZaBq=TjZI?9aHdQh!Pw>zOnGwzOPhs{F@qyn+ELKYK-c24%I%|=a*}8^Q{K;p z3~*~1$CS0>8S5C&l+_d1kX!NTW$Of%{_+HdD|-}ThnbK?u@tPwLcl`6Lcl`6Lcl`6 zLcl`6Lcl`6Lcl`cpN2qoOzd*AV`4*=9TR)q?3mcgX2;UGE!i>pRSkX-5X+EM>|L{C zICJIc!z9J_k@M$Biftt4>Gyn;A6rIFu|4IKw%e3GT2g%9&Uw11r1bM8#ZL@5Prss| z^l_46yUBU+v;XWE{VtH+(;k^9{f>cXuA~=AYL|4fq*EllNYaZXy+l&_y#(pGOj5D; z&W_>x6rPUny*L$H?d;f9k{3U-%#KZ${0vF+B+ZwUez!>aXG%It(%F)VW1H;Q9Ldk+ zG%v;p{s?{e{R*#WHd`y{I9_1RdJ(Gezqu~}HUFpX>EPqf zI7H(AgFMBVivKoUrHFVaUm`sw6G14Q{$F-w9b>j<5MDv}9YT)6Y=sDG5Ml^-BRrw7 zA*`oKElKiEb9gYFT%DFY~0hJe?UkZ$+AWx%tF{ak}+3#(GA6};-wC!({-`SS>g<8 zn#b#i*6M+xXkDEas?a=vdcPjg1O5taLsSb#`pF{&w`d{eauxXkp7kzIAlR5-LYhx= zhc)oaoW%jZC!|F*XTR%cN1expNaDHA%AnLDmQ(X%R zV7-5%7K$tig#w|G8d;Nvar zbcXZtXD-z0+)-ad)T$`2DuKMyS&;9liR!+H?iX*D>d5W(<gE;)YPFT<9>wsS z$Q(WFa2A)AI-Ny9SJ9>-J>RTG(*k;0fi?!LLoQGbvJIl-c=Ng6m5#zE!%gq zYjebTW5pKdin$f;P2Bq`g7M1D4x-mYTvvy+pgZJ-tLEpynHqFI#Ty%gwJs-`fmf+9 z2;T^A((59w8Vy62!{gDy;Y1Sen&rITSHwqFDV_96^bqj|ZIi4;qp>X|yG%3ZtJlOR zD7%tShQtzdHH^5GjX|wUi?}gzVq8hQg>l8jK6oGvAOpSc;~mBXKWbY^ogl6<(f36O zm_K;u7u+V@=d1LF0-LD66fWdbNMSfq8$fpoV$hz0*`%a&>7uIZY4Eu?T%;Gya;>W3 zz3av@KKv(|%XwoZ<`4`RH_i>o7ToNt&zp(nEXrH4h4<;o4Ji_d*Bk5cjwvoSnwA(? zS1=oEnT(jL!Heli^F$+>%d5F-F;QkUP!&cn2z2B;sQA>YuG*WneBrq2`PEldU*&yN zSvYR$)YcI%gHFtun6pi>+vi?<)kMag&tfcBi7|G8SigKI`3EIGd6;|;Sz6yLle{YV zFG$`e`EP?C3BgAt|9koV@FbD{;^E@`xLm={k^EZ8mw_jHB9dPx`8OrMTk_c#iu^B1 zJ}CL$Nd5`Qk2pu@`%Ll<@MPaqyU4#mzQ0fMPsiT_XsYk?lJ`me4Jp4(^1qh+Gm=-( z2BiN@$zKbe>T~NB1JzbyIXl0PZ=)smlx&P(~L zCBIbiUdeBid{FX_OMbKDUy^)G@*hclyX4P1Pw2l-@=GNDsN_SEZ;|}hB;P9e!;;@C z`M*ehzvOdsg#Lq)Uq<|SG1d|j_-vDWtK@e|ey`-)B)?zsCnSGR^5f5kKBOI%{PmJQ zD)|P<|3dP6B;O(VBa-iw{Kt|%Dfz51Xm6ykFA96p5FMq!De#bg;x%}Tn@A!a!xm9& z_{39@!rz&~e?Nu)OA3Dul;I!WF5(rNA^0qc)1#*FO9Y=N|B;mUucq+WI+Ff`zAIAr z{1kpg3Li<~?@QtLrSQ+C@GqtCucq*JTe5vkDSTTBKYU2C{52_jwcr!@JdpDKKnnkg z;PF>H#|57r=RXxZAQaDZyb%x!zFhFIQao!@cwY+Nl)^ul!atqDw{c$0jO*_Z{IEDb z1T|HdidjuO(*%D`oL?b$%mU(x2p*%jcpeozMrH9lFL?YFPlw<|hoGbkULHm<@z@2A zQAa$B1dmZdJnICH9xR^61y4PWKW&1iZpNSEf*%{_71SJfC-BV>Jfy^PK?;9)3O_f6 zFG=B@Df}kEC(64mh5xeP6aDG26#hR`_#-KNM+$!`h0lOybAO$f!p}?L*QD?pQ}}yQ z_$N~MJt_POz)EA96QL4eB|;U#jRJaJ?ya+nNI)wEIK748R33}2M``acnINPghvn_Mff*_#}FPzcmm;T2s;ph2pbUM zP9x3?YkTNJqTo{x!yN}zXidCKp4~_^oIjs(17rd z){k(v<1niCa^A(4^Vm6P448**YJlQ}V%|4?6=h z=fp>@o@+~79eD#GeT%pi!d*x-TqQ461}%?Egd3g3tN8IT-5}KAWS{Q8`ny4J6#3=# z!61k3I!B(1PM!xl)Ym!kx%@0zn&KRMuod^ztrP_fK1-=q-{^7_m1>PejjqZH=BmN% ziMRyDdABb}7sDc{7PrWuKw}~mN9}?9xxHJYI1YcGFAMqQq6Wz8&Fk&>^=Qx4TOfaC z(%jagHYMFr)F|&;&9xrzGCZNH2iSq-d&-D6Nq(WUZh3K;xN|Rd;ikA~R;Avc72`f? zrD#H6)LRmQ4j~Bm>+4;PnuWXp`e}jb`F*#+^a6gJ;apXY2?E|V;60xdirZf>JEi5s zw*p~Yr{a3pC2xjt`(00UDd6tt3=Evhlu6bLH`{ZTi92{`9k7nG`l;h=s+zDmJR7S% zTx-IhnWp}clw29;Q+`(8^0Qn*z8_B>gtK}o%XuU8a#!v$iMMCvXno`wc)+9KtB-rH zlEl5&Dq&-f6$9%_+@Mi6#RY5pdez*)2G)>b5#RTaIk;#0M{RNH;u6s5&M$D`QnV~e zH{St&qxfRt>})IZV96oZ93idFM;~GY{4U(7hd~8eWvk214z@C$u~2-C!*hol8)^bR z-D5{#E|-1qHsbm;p4F+F7`op)OQVX;PM%t>%wg(`{0oqPc^Z%@(`WXUnqD}-QqH_s z#{yoQyY##8OJ3TYS-xi>d}i66_3W8tX+e87y5np1Gi%1u_RK3(K0va*bJ6K58s zrSO@h`d+A?IUnCjoLMZfBO9D3-){|0l(t=Gmh9QABxGqpGuRQbtnTc3m0V&j6}kFc z8I{FfrmSz`={=?myjJNgKk!g3082 z$*I_oe{J~KlaKsn^g|O~HSW4(--7Z zFWo(}Vduy{tkj;kxA+%-dwBMiqMF-3xn=u`>;G$4(PL=~rp^Du{&g3>@a-|0R0*|6ToO%Aa}7yuZ%e_2!s6Zrg@xamW`AkH4d3vcYM%Cu@h{H$`=wv Date: Wed, 11 Mar 2026 16:30:42 -0600 Subject: [PATCH 43/60] chore: add GitHub Sponsors funding config Co-Authored-By: Claude Opus 4.6 --- .github/FUNDING.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..1db8fe130 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: glittercowboy From 4096d5dd760408fb8773014f459b06b1b4961b44 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:44:24 -0600 Subject: [PATCH 44/60] docs: update changelog for v2.3.4 --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c1ce215a..f13321d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.3.4] - 2026-03-11 + +### Added +- CHANGELOG.md with curated history from v0.1.6 onwards +- Project-local `/publish-version` command for npm releases +- GitHub Sponsors funding configuration +- npm publish and install smoke test + ## [0.3.3] - 2026-03-11 ### Added @@ -107,7 +115,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - License updated to MIT -[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v0.3.3...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.3.4...HEAD +[2.3.4]: https://github.com/gsd-build/gsd-2/compare/v0.3.3...v2.3.4 [0.3.3]: https://github.com/gsd-build/gsd-2/compare/v0.3.1...v0.3.3 [0.3.1]: https://github.com/gsd-build/gsd-2/compare/v0.3.0...v0.3.1 [0.3.0]: https://github.com/gsd-build/gsd-2/compare/v0.2.9...v0.3.0 From 928f38f2e43fa7a1697e9a249dc8902832ec1e93 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:44:28 -0600 Subject: [PATCH 45/60] 2.3.4 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49dad8432..fe562270d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "0.3.1", + "version": "2.3.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "0.3.1", + "version": "2.3.4", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 7bc2f7836..b313ad1cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "0.3.3", + "version": "2.3.4", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From bc0049e51d2f08ec7fba9f557e96f82011b55e85 Mon Sep 17 00:00:00 2001 From: Vedant <41702642+vp275@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:18:17 +0530 Subject: [PATCH 46/60] feat: add google-search extension powered by Gemini Search grounding (#66) Provides a `google_search` tool as an alternative to Brave-based web search for users with Google Cloud / Gemini API credits. Sends queries to Gemini 3 Flash with `googleSearch: {}` grounding enabled, returning an AI-synthesized answer with source URLs from grounding metadata. Features: - In-session caching (keyed by normalized query) - Defensive truncation via truncateHead - Classified error handling (auth, rate-limit, general) - Custom TUI rendering for call/result display - Session start warning if GEMINI_API_KEY is missing --- .../extensions/google-search/index.ts | 323 ++++++++++++++++++ .../extensions/google-search/package.json | 9 + 2 files changed, 332 insertions(+) create mode 100644 src/resources/extensions/google-search/index.ts create mode 100644 src/resources/extensions/google-search/package.json diff --git a/src/resources/extensions/google-search/index.ts b/src/resources/extensions/google-search/index.ts new file mode 100644 index 000000000..66586325c --- /dev/null +++ b/src/resources/extensions/google-search/index.ts @@ -0,0 +1,323 @@ +/** + * Google Search Extension + * + * Provides a `google_search` tool that performs web searches via Gemini's + * Google Search grounding feature. Uses the user's existing GEMINI_API_KEY + * and Google Cloud GenAI credits. + * + * The tool sends queries to Gemini Flash with `googleSearch: {}` enabled. + * Gemini internally performs Google searches, synthesizes an answer, and + * returns it with source URLs from grounding metadata. + */ + +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; +import { + DEFAULT_MAX_BYTES, + DEFAULT_MAX_LINES, + formatSize, + truncateHead, +} from "@mariozechner/pi-coding-agent"; +import { Text } from "@mariozechner/pi-tui"; +import { Type } from "@sinclair/typebox"; +import { GoogleGenAI } from "@google/genai"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +interface SearchSource { + title: string; + uri: string; + domain: string; +} + +interface SearchResult { + answer: string; + sources: SearchSource[]; + searchQueries: string[]; + cached: boolean; +} + +interface SearchDetails { + query: string; + sourceCount: number; + cached: boolean; + durationMs: number; + error?: string; +} + +// ── Lazy singleton client ──────────────────────────────────────────────────── + +let client: GoogleGenAI | null = null; + +function getClient(): GoogleGenAI { + if (!client) { + client = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! }); + } + return client; +} + +// ── In-session cache ───────────────────────────────────────────────────────── + +const resultCache = new Map(); + +function cacheKey(query: string): string { + return query.toLowerCase().trim(); +} + +// ── Extension ──────────────────────────────────────────────────────────────── + +export default function (pi: ExtensionAPI) { + pi.registerTool({ + name: "google_search", + label: "Google Search", + description: + "Search the web using Google Search via Gemini. " + + "Returns an AI-synthesized answer grounded in Google Search results, plus source URLs. " + + "Use this when you need current information from the web: recent events, documentation, " + + "product details, technical references, news, etc. " + + "Requires GEMINI_API_KEY. Alternative to Brave-based search tools for users with Google Cloud credits.", + promptSnippet: "Search the web via Google Search to get current information with sources", + promptGuidelines: [ + "Use google_search when you need up-to-date web information that isn't in your training data.", + "Be specific with queries for better results, e.g. 'Next.js 15 app router migration guide' not just 'Next.js'.", + "The tool returns both an answer and source URLs. Cite sources when sharing results with the user.", + "Results are cached per-session, so repeated identical queries are free.", + "You can still use fetch_page to read a specific URL if needed after getting results from google_search.", + ], + parameters: Type.Object({ + query: Type.String({ + description: "The search query, e.g. 'latest Node.js LTS version' or 'how to configure Tailwind v4'", + }), + maxSources: Type.Optional( + Type.Number({ + description: "Maximum number of source URLs to include (default 5, max 10).", + minimum: 1, + maximum: 10, + }), + ), + }), + + async execute(_toolCallId, params, signal, _onUpdate, _ctx) { + const startTime = Date.now(); + const maxSources = Math.min(Math.max(params.maxSources ?? 5, 1), 10); + + // Check for API key + if (!process.env.GEMINI_API_KEY) { + return { + content: [ + { + type: "text", + text: "Error: GEMINI_API_KEY is not set. Please set this environment variable to use Google Search.\n\nExample: export GEMINI_API_KEY=your_key", + }, + ], + isError: true, + details: { + query: params.query, + sourceCount: 0, + cached: false, + durationMs: Date.now() - startTime, + error: "auth_error: GEMINI_API_KEY not set", + } as SearchDetails, + }; + } + + // Check cache + const key = cacheKey(params.query); + if (resultCache.has(key)) { + const cached = resultCache.get(key)!; + const output = formatOutput(cached, maxSources); + return { + content: [{ type: "text", text: output }], + details: { + query: params.query, + sourceCount: cached.sources.length, + cached: true, + durationMs: Date.now() - startTime, + } as SearchDetails, + }; + } + + // Call Gemini with Google Search grounding + let result: SearchResult; + try { + const ai = getClient(); + const response = await ai.models.generateContent({ + model: "gemini-3-flash-preview", + contents: params.query, + config: { + tools: [{ googleSearch: {} }], + abortSignal: signal, + }, + }); + + // Extract answer text + const answer = response.text ?? ""; + + // Extract grounding metadata + const candidate = response.candidates?.[0]; + const grounding = candidate?.groundingMetadata; + + // Parse sources from grounding chunks + const sources: SearchSource[] = []; + const seenTitles = new Set(); + if (grounding?.groundingChunks) { + for (const chunk of grounding.groundingChunks) { + if (chunk.web) { + const title = chunk.web.title ?? "Untitled"; + // Dedupe by title since URIs are redirect URLs that differ per call + if (seenTitles.has(title)) continue; + seenTitles.add(title); + // domain field is not available via Gemini API, use title as fallback + // (title is typically the domain name, e.g. "wikipedia.org") + const domain = chunk.web.domain ?? title; + sources.push({ + title, + uri: chunk.web.uri ?? "", + domain, + }); + } + } + } + + // Extract search queries Gemini actually performed + const searchQueries = grounding?.webSearchQueries ?? []; + + result = { answer, sources, searchQueries, cached: false }; + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + + let errorType = "api_error"; + if (msg.includes("401") || msg.includes("UNAUTHENTICATED")) { + errorType = "auth_error"; + } else if (msg.includes("429") || msg.includes("RESOURCE_EXHAUSTED") || msg.includes("quota")) { + errorType = "rate_limit"; + } + + return { + content: [ + { + type: "text", + text: `Google Search failed (${errorType}): ${msg}`, + }, + ], + isError: true, + details: { + query: params.query, + sourceCount: 0, + cached: false, + durationMs: Date.now() - startTime, + error: `${errorType}: ${msg}`, + } as SearchDetails, + }; + } + + // Cache the result + resultCache.set(key, result); + + // Format and truncate output + const rawOutput = formatOutput(result, maxSources); + const truncation = truncateHead(rawOutput, { + maxLines: DEFAULT_MAX_LINES, + maxBytes: DEFAULT_MAX_BYTES, + }); + + let finalText = truncation.content; + if (truncation.truncated) { + finalText += + `\n\n[Truncated: showing ${truncation.outputLines}/${truncation.totalLines} lines` + + ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`; + } + + return { + content: [{ type: "text", text: finalText }], + details: { + query: params.query, + sourceCount: result.sources.length, + cached: false, + durationMs: Date.now() - startTime, + } as SearchDetails, + }; + }, + + renderCall(args, theme) { + let text = theme.fg("toolTitle", theme.bold("google_search ")); + text += theme.fg("accent", `"${args.query}"`); + return new Text(text, 0, 0); + }, + + renderResult(result, { isPartial, expanded }, theme) { + const d = result.details as SearchDetails | undefined; + + if (isPartial) return new Text(theme.fg("warning", "Searching Google..."), 0, 0); + if (result.isError || d?.error) { + return new Text(theme.fg("error", `Error: ${d?.error ?? "unknown"}`), 0, 0); + } + + let text = theme.fg("success", `${d?.sourceCount ?? 0} sources`); + text += theme.fg("dim", ` (${d?.durationMs ?? 0}ms)`); + if (d?.cached) text += theme.fg("dim", " · cached"); + + if (expanded) { + const content = result.content[0]; + if (content?.type === "text") { + const preview = content.text.split("\n").slice(0, 8).join("\n"); + text += "\n\n" + theme.fg("dim", preview); + if (content.text.split("\n").length > 8) { + text += "\n" + theme.fg("muted", "..."); + } + } + } + + return new Text(text, 0, 0); + }, + }); + + // ── Startup notification ───────────────────────────────────────────────── + + pi.on("session_start", async (_event, ctx) => { + if (!process.env.GEMINI_API_KEY) { + ctx.ui.notify( + "Google Search: No GEMINI_API_KEY set. The google_search tool will not work until this is configured.", + "warning", + ); + } + }); +} + +// ── Output formatting ──────────────────────────────────────────────────────── + +function formatOutput(result: SearchResult, maxSources: number): string { + const lines: string[] = []; + + // Answer + if (result.answer) { + lines.push(result.answer); + } else { + lines.push("(No answer text returned from search)"); + } + + // Sources + if (result.sources.length > 0) { + lines.push(""); + lines.push("Sources:"); + const sourcesToShow = result.sources.slice(0, maxSources); + for (let i = 0; i < sourcesToShow.length; i++) { + const s = sourcesToShow[i]; + lines.push(`[${i + 1}] ${s.title} - ${s.domain}`); + lines.push(` ${s.uri}`); + } + if (result.sources.length > maxSources) { + lines.push(`(${result.sources.length - maxSources} more sources omitted)`); + } + } else { + lines.push(""); + lines.push("(No source URLs found in grounding metadata)"); + } + + // Search queries + if (result.searchQueries.length > 0) { + lines.push(""); + lines.push(`Searches performed: ${result.searchQueries.map((q) => `"${q}"`).join(", ")}`); + } + + return lines.join("\n"); +} diff --git a/src/resources/extensions/google-search/package.json b/src/resources/extensions/google-search/package.json new file mode 100644 index 000000000..6375e1c43 --- /dev/null +++ b/src/resources/extensions/google-search/package.json @@ -0,0 +1,9 @@ +{ + "name": "pi-extension-google-search", + "private": true, + "version": "1.0.0", + "type": "module", + "pi": { + "extensions": ["./index.ts"] + } +} From 8a4572edef02f034e1f35dfe60d3f0a38585d242 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:53:49 -0600 Subject: [PATCH 47/60] fix(voice): preserve transcription across pauses Apple's on-device speech recognition resets bestTranscription after silence gaps, discarding previous text. The Swift recognizer now detects these resets (word count drop / different starting word) and accumulates finalized segments so speech continues appending instead of overwriting. TS side simplified to pass through the already- accumulated text from the Swift process. --- src/resources/extensions/voice/index.ts | 11 ++- .../extensions/voice/speech-recognizer.swift | 88 +++++++++++++++++-- 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/src/resources/extensions/voice/index.ts b/src/resources/extensions/voice/index.ts index bf2be9fb1..39433dbf4 100644 --- a/src/resources/extensions/voice/index.ts +++ b/src/resources/extensions/voice/index.ts @@ -26,7 +26,6 @@ export default function (pi: ExtensionAPI) { let active = false; let recognizerProcess: ChildProcess | null = null; - let finalized = ""; let flashOn = true; let flashTimer: ReturnType | null = null; let footerTui: { requestRender: () => void } | null = null; @@ -122,7 +121,6 @@ export default function (pi: ExtensionAPI) { } active = true; - finalized = ""; setVoiceFooter(ctx, true); await runVoiceSession(ctx); } @@ -161,14 +159,15 @@ export default function (pi: ExtensionAPI) { async function runVoiceSession(ctx: ExtensionContext): Promise { return new Promise((resolve) => { + // The Swift recognizer handles accumulation across pause-induced + // transcription resets. Both PARTIAL and FINAL messages contain + // the full accumulated text, so we just pass them through. startRecognizer( (text) => { - const full = finalized + (finalized && text ? " " : "") + text; - ctx.ui.setEditorText(full); + ctx.ui.setEditorText(text); }, (text) => { - finalized = (finalized ? finalized + " " : "") + text; - ctx.ui.setEditorText(finalized); + ctx.ui.setEditorText(text); }, (msg) => ctx.ui.notify(`Voice: ${msg}`, "error"), () => {}, diff --git a/src/resources/extensions/voice/speech-recognizer.swift b/src/resources/extensions/voice/speech-recognizer.swift index 32735ba51..e1408f507 100644 --- a/src/resources/extensions/voice/speech-recognizer.swift +++ b/src/resources/extensions/voice/speech-recognizer.swift @@ -45,15 +45,93 @@ do { exit(1) } -var lastText = "" +// Accumulated finalized text from previous recognition segments. +// On-device recognition (especially macOS/iOS 18+) can reset +// bestTranscription.formattedString after a pause, discarding +// previous text. We detect this by tracking the last known good +// text and noticing when the new text is shorter / doesn't start +// with the previous text. When that happens we treat the previous +// text as finalized and start accumulating the new segment on top. +var accumulated = "" +var lastPartialText = "" +var lastEmitted = "" recognizer.recognitionTask(with: request) { result, error in if let result = result { let text = result.bestTranscription.formattedString - if text != lastText { - lastText = text - let prefix = result.isFinal ? "FINAL" : "PARTIAL" - print("\(prefix):\(text)") + + if result.isFinal { + // True final from the recognizer — commit everything + let full: String + // Check if the final text already includes accumulated content + // (some OS versions give cumulative finals, others reset) + if !accumulated.isEmpty && !text.lowercased().hasPrefix(accumulated.lowercased()) { + full = accumulated + " " + text + } else if !accumulated.isEmpty && text.count < accumulated.count { + // Final is shorter than what we accumulated — use accumulated + new + full = accumulated + " " + text + } else { + full = text + } + accumulated = "" + lastPartialText = "" + if full != lastEmitted { + lastEmitted = full + print("FINAL:\(full)") + } + return + } + + // Detect transcription reset: if the new partial text is significantly + // shorter than what we had, or doesn't start with the previous text, + // the recognizer has reset after a pause. Finalize what we had. + let prevText = lastPartialText + if !prevText.isEmpty && !text.isEmpty { + let prevWords = prevText.split(separator: " ") + let newWords = text.split(separator: " ") + + // Reset detection: new text has fewer words than previous AND + // the first few words don't match (i.e. it's truly new speech, + // not just the recognizer revising the last word) + let looksLikeReset: Bool + if newWords.count < prevWords.count / 2 { + // Significant drop in word count — likely a reset + looksLikeReset = true + } else if newWords.count < prevWords.count && + !prevWords.isEmpty && !newWords.isEmpty && + newWords[0] != prevWords[0] { + // Different starting word + fewer words — reset + looksLikeReset = true + } else { + looksLikeReset = false + } + + if looksLikeReset { + // Commit the previous partial text to accumulated + if accumulated.isEmpty { + accumulated = prevText + } else { + accumulated = accumulated + " " + prevText + } + // Emit a FINAL for the committed text so the TS side updates + print("FINAL:\(accumulated)") + lastEmitted = accumulated + } + } + + lastPartialText = text + + // Build the full display text + let displayText: String + if accumulated.isEmpty { + displayText = text + } else { + displayText = accumulated + " " + text + } + + if displayText != lastEmitted { + lastEmitted = displayText + print("PARTIAL:\(displayText)") } } if let error = error { From 0e5d4aa6ee29774fae07d4d83a2300e1bff740ad Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:58:57 -0600 Subject: [PATCH 48/60] docs: update changelog for v2.3.5 --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f13321d34..439be4e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.3.5] - 2026-03-11 + +### Fixed +- Voice extension: transcription no longer lost when pausing and resuming recording + ## [2.3.4] - 2026-03-11 ### Added @@ -115,7 +120,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - License updated to MIT -[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.3.4...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.3.5...HEAD +[2.3.5]: https://github.com/gsd-build/gsd-2/compare/v2.3.4...v2.3.5 [2.3.4]: https://github.com/gsd-build/gsd-2/compare/v0.3.3...v2.3.4 [0.3.3]: https://github.com/gsd-build/gsd-2/compare/v0.3.1...v0.3.3 [0.3.1]: https://github.com/gsd-build/gsd-2/compare/v0.3.0...v0.3.1 From 10c718948038cbae1e900ef640ce268a423eaef3 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 16:58:57 -0600 Subject: [PATCH 49/60] 2.3.5 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe562270d..1f4d5e8ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.3.4", + "version": "2.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.3.4", + "version": "2.3.5", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b313ad1cd..b46bce45e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.3.4", + "version": "2.3.5", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From 5eb02e9a1c091545aabce0621d6808336d4a7e31 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 17:07:54 -0600 Subject: [PATCH 50/60] fix: auto-commit before branch switch and migrate legacy flat sessions ensureSliceBranch() now auto-commits dirty files before git checkout, preventing "would be overwritten" errors when doctor/STATE.md rebuild leaves uncommitted changes between slice dispatches. (closes #63) On startup, migrate any .jsonl session files from the flat ~/.gsd/sessions/ directory into the per-cwd subdirectory so /resume can find sessions created before per-directory scoping was added. (closes #64) Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 26 +++++++++++++++++++++++- src/resources/extensions/gsd/worktree.ts | 11 ++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/cli.ts b/src/cli.ts index 342cb9674..97ed54a79 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -8,7 +8,7 @@ import { InteractiveMode, runPrintMode, } from '@mariozechner/pi-coding-agent' -import { readFileSync } from 'node:fs' +import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs' import { join } from 'node:path' import { agentDir, sessionsDir, authFilePath } from './app-paths.js' import { initResources } from './resource-loader.js' @@ -180,6 +180,30 @@ if (isPrintMode) { const cwd = process.cwd() const safePath = `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--` const projectSessionsDir = join(sessionsDir, safePath) + +// Migrate legacy flat sessions: before per-directory scoping, all .jsonl session +// files lived directly in ~/.gsd/sessions/. Move them into the correct per-cwd +// subdirectory so /resume can find them. +if (existsSync(sessionsDir)) { + try { + const entries = readdirSync(sessionsDir) + const flatJsonl = entries.filter(f => f.endsWith('.jsonl')) + if (flatJsonl.length > 0) { + const { mkdirSync } = await import('node:fs') + mkdirSync(projectSessionsDir, { recursive: true }) + for (const file of flatJsonl) { + const src = join(sessionsDir, file) + const dst = join(projectSessionsDir, file) + if (!existsSync(dst)) { + renameSync(src, dst) + } + } + } + } catch { + // Non-fatal — don't block startup if migration fails + } +} + const sessionManager = SessionManager.create(cwd, projectSessionsDir) initResources(agentDir) diff --git a/src/resources/extensions/gsd/worktree.ts b/src/resources/extensions/gsd/worktree.ts index 36c153f7c..450ffc586 100644 --- a/src/resources/extensions/gsd/worktree.ts +++ b/src/resources/extensions/gsd/worktree.ts @@ -86,6 +86,17 @@ export function ensureSliceBranch(basePath: string, milestoneId: string, sliceId created = true; } + // Auto-commit dirty files before checkout to prevent "would be overwritten" errors. + // This handles cases where doctor, STATE.md rebuild, or agent work left uncommitted changes. + const status = runGit(basePath, ["status", "--short"]); + if (status.trim()) { + runGit(basePath, ["add", "-A"]); + const staged = runGit(basePath, ["diff", "--cached", "--stat"]); + if (staged.trim()) { + runGit(basePath, ["commit", "-m", `"chore: auto-commit before switching to ${branch}"`]); + } + } + runGit(basePath, ["checkout", branch]); return created; } From a21022a3eff1645a77f6654fe08127a9a31927d8 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 17:10:51 -0600 Subject: [PATCH 51/60] docs: update README for current state, remove github extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove github extension (3 files) and its test - Fix GitHub badge/link URLs (glittercowboy/gsd-pi → gsd-build/GSD-2) - Update /gsd description: wizard → step mode (default since /gsd next) - Add missing commands: /gsd next, /worktree, /voice, /exit, /clear, keybindings - Update bundled extensions: 9 → 13 (add Google Search, Mac Tools, MCPorter, Voice) - Add Google Gemini API key to first launch, requirements, architecture tree --- README.md | 38 +- src/resources/extensions/github/formatters.ts | 207 ----- src/resources/extensions/github/gh-api.ts | 553 ------------- src/resources/extensions/github/index.ts | 778 ------------------ src/tests/gh-api.test.ts | 52 -- 5 files changed, 26 insertions(+), 1602 deletions(-) delete mode 100644 src/resources/extensions/github/formatters.ts delete mode 100644 src/resources/extensions/github/gh-api.ts delete mode 100644 src/resources/extensions/github/index.ts delete mode 100644 src/tests/gh-api.test.ts diff --git a/README.md b/README.md index 57fd35bdc..b7e63bfa5 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,11 @@ # GSD 2 -**The evolution of [Get Shit Done](https://github.com/glittercowboy/get-shit-done) — now a real coding agent.** +**The evolution of [Get Shit Done](https://github.com/gsd-build/get-shit-done) — now a real coding agent.** [![npm version](https://img.shields.io/npm/v/gsd-pi?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/gsd-pi) [![npm downloads](https://img.shields.io/npm/dm/gsd-pi?style=for-the-badge&logo=npm&logoColor=white&color=CB3837)](https://www.npmjs.com/package/gsd-pi) -[![GitHub stars](https://img.shields.io/github/stars/glittercowboy/gsd-pi?style=for-the-badge&logo=github&color=181717)](https://github.com/glittercowboy/gsd-pi) +[![GitHub stars](https://img.shields.io/github/stars/gsd-build/GSD-2?style=for-the-badge&logo=github&color=181717)](https://github.com/gsd-build/GSD-2) [![License](https://img.shields.io/badge/license-MIT-blue?style=for-the-badge)](LICENSE) The original GSD went viral as a prompt framework for Claude Code. It worked, but it was fighting the tool — injecting prompts through slash commands, hoping the LLM would follow instructions, with no actual control over context windows, sessions, or execution. @@ -122,16 +122,18 @@ Auto mode is a state machine driven by files on disk. It reads `.gsd/STATE.md`, 9. **Escape hatch** — Press Escape to pause. The conversation is preserved. Interact with the agent, inspect what happened, or just `/gsd auto` to resume from disk state. -### The `/gsd` Wizard +### `/gsd` and `/gsd next` — Step Mode -When you're not in auto mode, `/gsd` reads disk state and shows contextual options: +By default, `/gsd` runs in **step mode**: the same state machine as auto mode, but it pauses between units with a wizard showing what completed and what's next. You advance one step at a time, review the output, and continue when ready. - **No `.gsd/` directory** → Start a new project. Discussion flow captures your vision, constraints, and preferences. - **Milestone exists, no roadmap** → Discuss or research the milestone. -- **Roadmap exists, slices pending** → Plan the next slice, or jump straight to auto. +- **Roadmap exists, slices pending** → Plan the next slice, execute one task, or switch to auto. - **Mid-task** → Resume from where you left off. -The wizard is the on-ramp. Auto mode is the highway. +`/gsd next` is an explicit alias for step mode. You can switch from step → auto mid-session via the wizard. + +Step mode is the on-ramp. Auto mode is the highway. --- @@ -170,7 +172,7 @@ gsd GSD opens an interactive agent session. From there, you have two ways to work: -**`/gsd` — guided mode.** Type `/gsd` and GSD reads your project state and walks you through whatever's next. No project yet? It helps you describe what you want to build. Roadmap exists? It plans the next slice. Mid-task? It resumes. This is the hands-on mode where you work *with* the agent step by step. +**`/gsd` — step mode.** Type `/gsd` and GSD executes one unit of work at a time, pausing between each with a wizard showing what completed and what's next. Same state machine as auto mode, but you stay in the loop. No project yet? It starts the discussion flow. Roadmap exists? It plans or executes the next step. **`/gsd auto` — autonomous mode.** Type `/gsd auto` and walk away. GSD researches, plans, executes, verifies, commits, and advances through every slice until the milestone is complete. Fresh context window per task. No babysitting. @@ -196,13 +198,14 @@ Both terminals read and write the same `.gsd/` files on disk. Your decisions in ### First launch -On first run, GSD prompts for optional API keys (Brave Search, Context7, Jina) for web research and documentation tools. All optional — press Enter to skip any. +On first run, GSD prompts for optional API keys (Brave Search, Google Gemini, Context7, Jina) for web research and documentation tools. All optional — press Enter to skip any. ### Commands | Command | What it does | |---------|-------------| -| `/gsd` | Guided mode — reads project state, walks you through what's next | +| `/gsd` | Step mode — executes one unit at a time, pauses between each | +| `/gsd next` | Explicit step mode (same as bare `/gsd`) | | `/gsd auto` | Autonomous mode — researches, plans, executes, commits, repeats | | `/gsd stop` | Stop auto mode gracefully | | `/gsd discuss` | Discuss architecture and decisions (works alongside auto mode) | @@ -211,7 +214,13 @@ On first run, GSD prompts for optional API keys (Brave Search, Context7, Jina) f | `/gsd prefs` | Model selection, timeouts, budget ceiling | | `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format | | `/gsd doctor` | Validate `.gsd/` integrity, find and fix issues | +| `/worktree` (`/wt`) | Git worktree lifecycle — create, switch, merge, remove | +| `/voice` | Toggle real-time speech-to-text (macOS only) | +| `/exit` | Kill GSD process immediately | +| `/clear` | Start a new session (alias for `/new`) | | `Ctrl+Alt+G` | Toggle dashboard overlay | +| `Ctrl+Alt+V` | Toggle voice transcription | +| `Ctrl+Alt+B` | Show background shell processes | --- @@ -311,16 +320,20 @@ budget_ceiling: 50.00 ### Bundled Tools -GSD ships with 9 extensions, all loaded automatically: +GSD ships with 13 extensions, all loaded automatically: | Extension | What it provides | |-----------|-----------------| | **GSD** | Core workflow engine, auto mode, commands, dashboard | | **Browser Tools** | Playwright-based browser for UI verification | | **Search the Web** | Brave Search + Jina page extraction | +| **Google Search** | Gemini-powered web search with AI-synthesized answers | | **Context7** | Up-to-date library/framework documentation | | **Background Shell** | Long-running process management with readiness detection | | **Subagent** | Delegated tasks with isolated context windows | +| **Mac Tools** | macOS native app automation via Accessibility APIs | +| **MCPorter** | Lazy on-demand MCP server integration | +| **Voice** | Real-time speech-to-text transcription (macOS) | | **Slash Commands** | Custom command creation | | **Ask User Questions** | Structured user input with single/multi-select | | **Secure Env Collect** | Masked secret collection without manual .env editing | @@ -345,12 +358,12 @@ GSD is a TypeScript application that embeds the Pi coding agent SDK. gsd (CLI binary) └─ loader.ts Sets PI_PACKAGE_DIR, GSD env vars, dynamic-imports cli.ts └─ cli.ts Wires SDK managers, loads extensions, starts InteractiveMode - ├─ wizard.ts First-run API key collection (Brave/Context7/Jina) + ├─ wizard.ts First-run API key collection (Brave/Gemini/Context7/Jina) ├─ app-paths.ts ~/.gsd/agent/, ~/.gsd/sessions/, auth.json ├─ resource-loader.ts Syncs bundled extensions + agents to ~/.gsd/agent/ └─ src/resources/ ├─ extensions/gsd/ Core GSD extension (auto, state, commands, ...) - ├─ extensions/... 10 supporting extensions + ├─ extensions/... 12 supporting extensions ├─ agents/ scout, researcher, worker ├─ AGENTS.md Agent routing instructions └─ GSD-WORKFLOW.md Manual bootstrap protocol @@ -373,6 +386,7 @@ gsd (CLI binary) Optional: - Brave Search API key (web research) +- Google Gemini API key (web research via Gemini Search grounding) - Context7 API key (library docs) - Jina API key (page extraction) diff --git a/src/resources/extensions/github/formatters.ts b/src/resources/extensions/github/formatters.ts deleted file mode 100644 index 239ac2ce8..000000000 --- a/src/resources/extensions/github/formatters.ts +++ /dev/null @@ -1,207 +0,0 @@ -/** - * Formatters — produce text summaries for issues, PRs, comments, etc. - * - * Used by both tools (LLM context) and renderers (TUI display). - */ - -import type { GhIssue, GhPullRequest, GhComment, GhReview, GhLabel, GhMilestone } from "./gh-api.js"; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function timeAgo(dateStr: string): string { - const now = Date.now(); - const then = new Date(dateStr).getTime(); - const diff = now - then; - const mins = Math.floor(diff / 60000); - if (mins < 1) return "just now"; - if (mins < 60) return `${mins}m ago`; - const hours = Math.floor(mins / 60); - if (hours < 24) return `${hours}h ago`; - const days = Math.floor(hours / 24); - if (days < 30) return `${days}d ago`; - const months = Math.floor(days / 30); - if (months < 12) return `${months}mo ago`; - return `${Math.floor(months / 12)}y ago`; -} - -function stateIcon(state: string, draft?: boolean): string { - if (draft) return "◇"; - switch (state) { - case "open": - return "●"; - case "closed": - return "✓"; - case "merged": - return "⊕"; - default: - return "○"; - } -} - -function truncateBody(body: string | null, maxLines = 10): string { - if (!body) return "(no description)"; - const lines = body.split("\n"); - if (lines.length <= maxLines) return body; - return lines.slice(0, maxLines).join("\n") + `\n... (${lines.length - maxLines} more lines)`; -} - -// ─── Issue formatting ───────────────────────────────────────────────────────── - -export function formatIssueOneLiner(issue: GhIssue): string { - const icon = stateIcon(issue.state); - const labels = issue.labels.map((l) => l.name).join(", "); - const labelStr = labels ? ` [${labels}]` : ""; - const assignee = issue.assignees.length ? ` → ${issue.assignees.map((a) => a.login).join(", ")}` : ""; - return `${icon} #${issue.number} ${issue.title}${labelStr}${assignee} (${timeAgo(issue.updated_at)})`; -} - -export function formatIssueDetail(issue: GhIssue): string { - const lines: string[] = []; - lines.push(`# Issue #${issue.number}: ${issue.title}`); - lines.push(`State: ${issue.state} | Author: @${issue.user.login} | Created: ${timeAgo(issue.created_at)} | Updated: ${timeAgo(issue.updated_at)}`); - - if (issue.assignees.length) { - lines.push(`Assignees: ${issue.assignees.map((a) => `@${a.login}`).join(", ")}`); - } - if (issue.labels.length) { - lines.push(`Labels: ${issue.labels.map((l) => l.name).join(", ")}`); - } - if (issue.milestone) { - lines.push(`Milestone: ${issue.milestone.title}`); - } - lines.push(`Comments: ${issue.comments}`); - lines.push(`URL: ${issue.html_url}`); - lines.push(""); - lines.push(truncateBody(issue.body, 30)); - return lines.join("\n"); -} - -export function formatIssueList(issues: GhIssue[]): string { - if (!issues.length) return "No issues found."; - return issues.map(formatIssueOneLiner).join("\n"); -} - -// ─── PR formatting ──────────────────────────────────────────────────────────── - -export function formatPROneLiner(pr: GhPullRequest): string { - const icon = stateIcon(pr.merged_at ? "merged" : pr.state, pr.draft); - const labels = pr.labels.map((l) => l.name).join(", "); - const labelStr = labels ? ` [${labels}]` : ""; - const draftStr = pr.draft ? " (draft)" : ""; - const reviewers = pr.requested_reviewers.map((r) => r.login).join(", "); - const reviewerStr = reviewers ? ` ⟵ ${reviewers}` : ""; - return `${icon} #${pr.number} ${pr.title}${draftStr}${labelStr}${reviewerStr} (${timeAgo(pr.updated_at)})`; -} - -export function formatPRDetail(pr: GhPullRequest): string { - const lines: string[] = []; - const mergedState = pr.merged_at ? "merged" : pr.state; - lines.push(`# PR #${pr.number}: ${pr.title}`); - lines.push(`State: ${mergedState}${pr.draft ? " (draft)" : ""} | Author: @${pr.user.login} | Created: ${timeAgo(pr.created_at)} | Updated: ${timeAgo(pr.updated_at)}`); - lines.push(`Branch: ${pr.head.ref} → ${pr.base.ref}`); - - if (pr.assignees.length) { - lines.push(`Assignees: ${pr.assignees.map((a) => `@${a.login}`).join(", ")}`); - } - if (pr.labels.length) { - lines.push(`Labels: ${pr.labels.map((l) => l.name).join(", ")}`); - } - if (pr.milestone) { - lines.push(`Milestone: ${pr.milestone.title}`); - } - if (pr.requested_reviewers.length) { - lines.push(`Reviewers: ${pr.requested_reviewers.map((r) => `@${r.login}`).join(", ")}`); - } - - lines.push(`Mergeable: ${pr.mergeable === null ? "checking..." : pr.mergeable ? "yes" : "no"} (${pr.mergeable_state})`); - lines.push(`Comments: ${pr.comments} | Review comments: ${pr.review_comments}`); - lines.push(`URL: ${pr.html_url}`); - lines.push(""); - lines.push(truncateBody(pr.body, 30)); - return lines.join("\n"); -} - -export function formatPRList(prs: GhPullRequest[]): string { - if (!prs.length) return "No pull requests found."; - return prs.map(formatPROneLiner).join("\n"); -} - -// ─── Comment formatting ────────────────────────────────────────────────────── - -export function formatComment(comment: GhComment): string { - return `@${comment.user.login} (${timeAgo(comment.created_at)}):\n${truncateBody(comment.body, 8)}`; -} - -export function formatCommentList(comments: GhComment[]): string { - if (!comments.length) return "No comments."; - return comments.map(formatComment).join("\n\n---\n\n"); -} - -// ─── Review formatting ─────────────────────────────────────────────────────── - -function reviewStateIcon(state: string): string { - switch (state) { - case "APPROVED": - return "✓"; - case "CHANGES_REQUESTED": - return "✗"; - case "COMMENTED": - return "💬"; - case "DISMISSED": - return "—"; - case "PENDING": - return "…"; - default: - return "?"; - } -} - -export function formatReview(review: GhReview): string { - const icon = reviewStateIcon(review.state); - const body = review.body ? `\n${truncateBody(review.body, 5)}` : ""; - return `${icon} @${review.user.login}: ${review.state} (${timeAgo(review.submitted_at)})${body}`; -} - -export function formatReviewList(reviews: GhReview[]): string { - if (!reviews.length) return "No reviews."; - return reviews.map(formatReview).join("\n\n"); -} - -// ─── Label / Milestone formatting ───────────────────────────────────────────── - -export function formatLabel(label: GhLabel): string { - const desc = label.description ? ` — ${label.description}` : ""; - return `• ${label.name} (#${label.color})${desc}`; -} - -export function formatLabelList(labels: GhLabel[]): string { - if (!labels.length) return "No labels."; - return labels.map(formatLabel).join("\n"); -} - -export function formatMilestone(ms: GhMilestone): string { - const progress = ms.open_issues + ms.closed_issues > 0 ? Math.round((ms.closed_issues / (ms.open_issues + ms.closed_issues)) * 100) : 0; - const due = ms.due_on ? ` | Due: ${new Date(ms.due_on).toISOString().split("T")[0]}` : ""; - return `• ${ms.title} (${ms.state}) — ${progress}% complete (${ms.closed_issues}/${ms.open_issues + ms.closed_issues})${due}`; -} - -export function formatMilestoneList(milestones: GhMilestone[]): string { - if (!milestones.length) return "No milestones."; - return milestones.map(formatMilestone).join("\n"); -} - -// ─── File change formatting ─────────────────────────────────────────────────── - -export function formatFileChanges( - files: { filename: string; status: string; additions: number; deletions: number; changes: number }[], -): string { - if (!files.length) return "No files changed."; - const lines = files.map((f) => { - const statusIcon = f.status === "added" ? "+" : f.status === "removed" ? "-" : "~"; - return `${statusIcon} ${f.filename} (+${f.additions} -${f.deletions})`; - }); - const totalAdd = files.reduce((s, f) => s + f.additions, 0); - const totalDel = files.reduce((s, f) => s + f.deletions, 0); - lines.push(`\n${files.length} files changed, +${totalAdd} -${totalDel}`); - return lines.join("\n"); -} diff --git a/src/resources/extensions/github/gh-api.ts b/src/resources/extensions/github/gh-api.ts deleted file mode 100644 index ccdeba8da..000000000 --- a/src/resources/extensions/github/gh-api.ts +++ /dev/null @@ -1,553 +0,0 @@ -/** - * GitHub API layer — wraps `gh` CLI with fallback to GITHUB_TOKEN + fetch. - * - * All GitHub communication goes through this module. - * Prefers `gh api` when the CLI is available and authenticated. - * Falls back to raw REST API with GITHUB_TOKEN env var. - */ - -import { execSync, spawnSync, type SpawnSyncReturns } from "node:child_process"; - -// ─── Auth detection ─────────────────────────────────────────────────────────── - -let _useGhCli: boolean | null = null; - -let ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns => - spawnSync("gh", args, { - cwd, - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - input, - }); - -function ghSpawn(args: string[], input?: string, cwd?: string): SpawnSyncReturns { - return ghSpawnImpl(args, input, cwd); -} - -export function resetGhCliDetectionForTests(): void { - _useGhCli = null; - ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns => - spawnSync("gh", args, { - cwd, - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - input, - }); -} - -export function setGhSpawnForTests(fn: (args: string[], input?: string, cwd?: string) => SpawnSyncReturns): void { - ghSpawnImpl = fn; - _useGhCli = null; -} - -export function hasGhCli(): boolean { - if (_useGhCli !== null) return _useGhCli; - const result = ghSpawn(["auth", "token"]); - _useGhCli = result.status === 0 && !result.error && !!result.stdout?.trim(); - return _useGhCli; -} - -function getToken(): string | undefined { - return process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN; -} - -export function isAuthenticated(): boolean { - return hasGhCli() || !!getToken(); -} - -export function authMethod(): string { - if (hasGhCli()) return "gh CLI"; - if (getToken()) return "GITHUB_TOKEN"; - return "none"; -} - -// ─── Repo detection ─────────────────────────────────────────────────────────── - -export interface RepoInfo { - owner: string; - repo: string; - fullName: string; -} - -export function detectRepo(cwd: string): RepoInfo | null { - try { - const remote = execSync("git remote get-url origin", { - cwd, - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - - // Handle SSH: git@github.com:owner/repo.git - // Handle HTTPS: https://github.com/owner/repo.git - const sshMatch = remote.match(/github\.com[:/]([^/]+)\/([^/.]+)/); - if (sshMatch) { - return { owner: sshMatch[1], repo: sshMatch[2], fullName: `${sshMatch[1]}/${sshMatch[2]}` }; - } - - return null; - } catch { - return null; - } -} - -export function getCurrentBranch(cwd: string): string | null { - try { - return execSync("git rev-parse --abbrev-ref HEAD", { - cwd, - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - } catch { - return null; - } -} - -export function getDefaultBranch(cwd: string): string { - try { - const result = execSync("git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo refs/remotes/origin/main", { - cwd, - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - return result.replace("refs/remotes/origin/", ""); - } catch { - return "main"; - } -} - -// ─── API calls ──────────────────────────────────────────────────────────────── - -/** - * Call the GitHub REST API. Returns parsed JSON. - * - * When method is GET and params are provided, they're appended as query params. - * When method is POST/PUT/PATCH/DELETE, params are sent as JSON body. - */ -export async function ghApi( - endpoint: string, - options: { - method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; - params?: Record; - body?: Record; - cwd?: string; - } = {}, -): Promise { - const method = options.method ?? "GET"; - - if (hasGhCli()) { - return ghCliApi(endpoint, method, options.params, options.body, options.cwd); - } - - const token = getToken(); - if (!token) throw new Error("Not authenticated. Install gh CLI or set GITHUB_TOKEN."); - - return fetchApi(endpoint, method, options.params, options.body, token); -} - -function ghCliApi( - endpoint: string, - method: string, - params?: Record, - body?: Record, - cwd?: string, -): T { - const args = ["api", endpoint, "--method", method]; - - if (params) { - for (const [key, val] of Object.entries(params)) { - if (val === undefined) continue; - if (Array.isArray(val)) { - for (const v of val) { - args.push("-f", `${key}[]=${v}`); - } - } else { - args.push("-f", `${key}=${String(val)}`); - } - } - } - - if (body) { - args.push("--input", "-"); - } - - const result = ghSpawn(args, body ? JSON.stringify(body) : undefined, cwd ?? process.cwd()); - - const stdout = result.stdout?.trim() ?? ""; - const stderr = result.stderr?.trim() ?? ""; - - if (result.status !== 0) { - throw new Error(`gh api error: ${stderr || stdout || result.error?.message || `exit code ${result.status}`}`); - } - - if (!stdout) return {} as T; - return JSON.parse(stdout) as T; -} - -async function fetchApi( - endpoint: string, - method: string, - params?: Record, - body?: Record, - token?: string, -): Promise { - let url = endpoint.startsWith("http") ? endpoint : `https://api.github.com${endpoint}`; - - if (method === "GET" && params) { - const qs = new URLSearchParams(); - for (const [key, val] of Object.entries(params)) { - if (val === undefined) continue; - if (Array.isArray(val)) { - for (const v of val) qs.append(key, v); - } else { - qs.set(key, String(val)); - } - } - const qsStr = qs.toString(); - if (qsStr) url += `?${qsStr}`; - } - - const headers: Record = { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }; - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await fetch(url, { - method, - headers, - body: method !== "GET" && body ? JSON.stringify(body) : undefined, - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`GitHub API ${res.status}: ${text}`); - } - - const text = await res.text(); - if (!text.trim()) return {} as T; - return JSON.parse(text) as T; -} - -// ─── Typed API wrappers ─────────────────────────────────────────────────────── - -export interface GhIssue { - number: number; - title: string; - state: string; - body: string | null; - user: { login: string }; - labels: { name: string; color: string }[]; - assignees: { login: string }[]; - milestone: { title: string; number: number } | null; - created_at: string; - updated_at: string; - closed_at: string | null; - comments: number; - html_url: string; - pull_request?: { url: string }; -} - -export interface GhPullRequest { - number: number; - title: string; - state: string; - body: string | null; - user: { login: string }; - labels: { name: string; color: string }[]; - assignees: { login: string }[]; - milestone: { title: string; number: number } | null; - head: { ref: string; sha: string }; - base: { ref: string }; - created_at: string; - updated_at: string; - merged_at: string | null; - closed_at: string | null; - comments: number; - review_comments: number; - draft: boolean; - mergeable: boolean | null; - mergeable_state: string; - html_url: string; - diff_url: string; - requested_reviewers: { login: string }[]; -} - -export interface GhComment { - id: number; - body: string; - user: { login: string }; - created_at: string; - updated_at: string; - html_url: string; -} - -export interface GhLabel { - name: string; - color: string; - description: string | null; -} - -export interface GhMilestone { - number: number; - title: string; - description: string | null; - state: string; - open_issues: number; - closed_issues: number; - due_on: string | null; -} - -export interface GhReview { - id: number; - user: { login: string }; - state: string; - body: string | null; - submitted_at: string; - html_url: string; -} - -export interface GhCheckRun { - name: string; - status: string; - conclusion: string | null; - html_url: string; -} - -// ─── Issues ─────────────────────────────────────────────────────────────────── - -export async function listIssues( - repo: RepoInfo, - options: { - state?: "open" | "closed" | "all"; - labels?: string; - assignee?: string; - milestone?: string; - sort?: "created" | "updated" | "comments"; - direction?: "asc" | "desc"; - per_page?: number; - page?: number; - } = {}, -): Promise { - const params: Record = { - state: options.state ?? "open", - sort: options.sort ?? "updated", - direction: options.direction ?? "desc", - per_page: String(options.per_page ?? 30), - page: String(options.page ?? 1), - }; - if (options.labels) params.labels = options.labels; - if (options.assignee) params.assignee = options.assignee; - if (options.milestone) params.milestone = options.milestone; - - const issues = await ghApi(`/repos/${repo.fullName}/issues`, { params }); - // Filter out PRs (GitHub API returns PRs in issues endpoint) - return issues.filter((i) => !i.pull_request); -} - -export async function getIssue(repo: RepoInfo, number: number): Promise { - return ghApi(`/repos/${repo.fullName}/issues/${number}`); -} - -export async function createIssue( - repo: RepoInfo, - data: { title: string; body?: string; labels?: string[]; assignees?: string[]; milestone?: number }, -): Promise { - return ghApi(`/repos/${repo.fullName}/issues`, { - method: "POST", - body: data, - }); -} - -export async function updateIssue( - repo: RepoInfo, - number: number, - data: { title?: string; body?: string; state?: string; labels?: string[]; assignees?: string[]; milestone?: number | null }, -): Promise { - return ghApi(`/repos/${repo.fullName}/issues/${number}`, { - method: "PATCH", - body: data, - }); -} - -export async function addComment(repo: RepoInfo, number: number, body: string): Promise { - return ghApi(`/repos/${repo.fullName}/issues/${number}/comments`, { - method: "POST", - body: { body }, - }); -} - -export async function listComments(repo: RepoInfo, number: number): Promise { - return ghApi(`/repos/${repo.fullName}/issues/${number}/comments`); -} - -// ─── Pull Requests ──────────────────────────────────────────────────────────── - -export async function listPullRequests( - repo: RepoInfo, - options: { - state?: "open" | "closed" | "all"; - sort?: "created" | "updated" | "popularity" | "long-running"; - direction?: "asc" | "desc"; - per_page?: number; - page?: number; - head?: string; - base?: string; - } = {}, -): Promise { - const params: Record = { - state: options.state ?? "open", - sort: options.sort ?? "updated", - direction: options.direction ?? "desc", - per_page: String(options.per_page ?? 30), - page: String(options.page ?? 1), - }; - if (options.head) params.head = options.head; - if (options.base) params.base = options.base; - - return ghApi(`/repos/${repo.fullName}/pulls`, { params }); -} - -export async function getPullRequest(repo: RepoInfo, number: number): Promise { - return ghApi(`/repos/${repo.fullName}/pulls/${number}`); -} - -export async function createPullRequest( - repo: RepoInfo, - data: { title: string; body?: string; head: string; base: string; draft?: boolean }, -): Promise { - return ghApi(`/repos/${repo.fullName}/pulls`, { - method: "POST", - body: data, - }); -} - -export async function updatePullRequest( - repo: RepoInfo, - number: number, - data: { title?: string; body?: string; state?: string; base?: string }, -): Promise { - return ghApi(`/repos/${repo.fullName}/pulls/${number}`, { - method: "PATCH", - body: data, - }); -} - -export async function getPullRequestDiff(repo: RepoInfo, number: number): Promise { - if (hasGhCli()) { - try { - return execSync(`gh pr diff ${number} --repo ${repo.fullName}`, { - encoding: "utf8", - stdio: ["pipe", "pipe", "pipe"], - }).trim(); - } catch (e: unknown) { - const err = e as { stderr?: string; message?: string }; - throw new Error(err.stderr?.trim() || err.message || String(e)); - } - } - - const token = getToken(); - const headers: Record = { - Accept: "application/vnd.github.v3.diff", - "X-GitHub-Api-Version": "2022-11-28", - }; - if (token) headers.Authorization = `Bearer ${token}`; - - const res = await fetch(`https://api.github.com/repos/${repo.fullName}/pulls/${number}`, { headers }); - if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`); - return res.text(); -} - -export async function listPullRequestFiles( - repo: RepoInfo, - number: number, -): Promise<{ filename: string; status: string; additions: number; deletions: number; changes: number }[]> { - return ghApi(`/repos/${repo.fullName}/pulls/${number}/files`); -} - -// ─── Reviews ────────────────────────────────────────────────────────────────── - -export async function listReviews(repo: RepoInfo, number: number): Promise { - return ghApi(`/repos/${repo.fullName}/pulls/${number}/reviews`); -} - -export async function createReview( - repo: RepoInfo, - number: number, - data: { body?: string; event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT" }, -): Promise { - return ghApi(`/repos/${repo.fullName}/pulls/${number}/reviews`, { - method: "POST", - body: data, - }); -} - -export async function requestReviewers( - repo: RepoInfo, - number: number, - reviewers: string[], -): Promise { - return ghApi(`/repos/${repo.fullName}/pulls/${number}/requested_reviewers`, { - method: "POST", - body: { reviewers }, - }); -} - -// ─── Checks ─────────────────────────────────────────────────────────────────── - -export async function listCheckRuns(repo: RepoInfo, ref: string): Promise<{ check_runs: GhCheckRun[] }> { - return ghApi(`/repos/${repo.fullName}/commits/${ref}/check-runs`); -} - -// ─── Labels & Milestones ────────────────────────────────────────────────────── - -export async function listLabels(repo: RepoInfo): Promise { - return ghApi(`/repos/${repo.fullName}/labels`, { - params: { per_page: "100" }, - }); -} - -export async function createLabel( - repo: RepoInfo, - data: { name: string; color: string; description?: string }, -): Promise { - return ghApi(`/repos/${repo.fullName}/labels`, { - method: "POST", - body: data, - }); -} - -export async function listMilestones(repo: RepoInfo): Promise { - return ghApi(`/repos/${repo.fullName}/milestones`, { - params: { state: "all", per_page: "100" }, - }); -} - -export async function createMilestone( - repo: RepoInfo, - data: { title: string; description?: string; due_on?: string }, -): Promise { - return ghApi(`/repos/${repo.fullName}/milestones`, { - method: "POST", - body: data, - }); -} - -// ─── Search ─────────────────────────────────────────────────────────────────── - -export interface GhSearchResult { - total_count: number; - items: T[]; -} - -export async function searchIssues( - query: string, - options: { per_page?: number; page?: number } = {}, -): Promise> { - return ghApi>("/search/issues", { - params: { - q: query, - per_page: String(options.per_page ?? 30), - page: String(options.page ?? 1), - }, - }); -} diff --git a/src/resources/extensions/github/index.ts b/src/resources/extensions/github/index.ts deleted file mode 100644 index 2101832de..000000000 --- a/src/resources/extensions/github/index.ts +++ /dev/null @@ -1,778 +0,0 @@ -/** - * GitHub Extension — /gh - * - * Full-suite GitHub issues and PR tracker/helper for pi. - * Provides LLM tools + /gh slash command for managing issues, PRs, - * reviews, labels, milestones, and comments. - * - * Auth: gh CLI (preferred) → GITHUB_TOKEN env var (fallback) - * - * Tools: - * github_issues — list, view, create, update, close, search issues - * github_prs — list, view, create, update, diff, files, checks for PRs - * github_comments — list, add comments on issues/PRs - * github_reviews — list, create reviews, request reviewers - * github_labels — list, create labels; list, create milestones - * - * Commands: - * /gh issues [state] — browse issues - * /gh prs [state] — browse PRs - * /gh view — view issue or PR detail - * /gh create issue — create issue interactively - * /gh create pr — create PR from current branch - * /gh labels — list labels - * /gh milestones — list milestones - * /gh status — show auth + repo status - */ - -import { Type } from "@sinclair/typebox"; -import { StringEnum } from "@mariozechner/pi-ai"; -import { Text } from "@mariozechner/pi-tui"; -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; -import { truncateHead, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@mariozechner/pi-coding-agent"; -import { showConfirm } from "../shared/confirm-ui.js"; - -import { - isAuthenticated, - authMethod, - detectRepo, - getCurrentBranch, - getDefaultBranch, - type RepoInfo, - listIssues, - getIssue, - createIssue, - updateIssue, - addComment, - listComments, - listPullRequests, - getPullRequest, - createPullRequest, - updatePullRequest, - getPullRequestDiff, - listPullRequestFiles, - listReviews, - createReview, - requestReviewers, - listCheckRuns, - listLabels, - createLabel, - listMilestones, - createMilestone, - searchIssues, -} from "./gh-api.js"; - -import { - formatIssueList, - formatIssueDetail, - formatPRList, - formatPRDetail, - formatCommentList, - formatReviewList, - formatFileChanges, - formatLabelList, - formatMilestoneList, -} from "./formatters.js"; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function requireRepo(cwd: string): RepoInfo { - const repo = detectRepo(cwd); - if (!repo) throw new Error("Not in a GitHub repository. Run this from a git repo with a GitHub remote."); - return repo; -} - -function requireAuth(): void { - if (!isAuthenticated()) { - throw new Error("Not authenticated to GitHub. Install and authenticate `gh` CLI, or set GITHUB_TOKEN env var."); - } -} - -function truncateOutput(text: string): string { - const result = truncateHead(text, { maxLines: DEFAULT_MAX_LINES, maxBytes: DEFAULT_MAX_BYTES }); - if (result.truncated) { - return result.content + `\n\n[Output truncated: showing ${result.outputLines}/${result.totalLines} lines]`; - } - return result.content; -} - -function textResult(text: string, details?: Record) { - return { - content: [{ type: "text" as const, text: truncateOutput(text) }], - ...(details ? { details } : {}), - }; -} - -/** - * Confirmation gate for outward-facing GitHub actions. - * Shows a themed yes/no confirmation in interactive mode. - * In non-interactive mode (no UI), blocks the action. - * Returns the rejected textResult if denied, or undefined if confirmed. - */ -async function confirmAction( - ctx: ExtensionContext, - action: string, -): Promise | undefined> { - if (!ctx.hasUI) { - return textResult(`Blocked: "${action}" requires user confirmation but no UI is available.`); - } - const confirmed = await showConfirm(ctx, { - title: "GitHub", - message: action, - }); - if (!confirmed) { - return textResult(`Cancelled: user declined "${action}".`); - } - return undefined; -} - -// ─── Extension ──────────────────────────────────────────────────────────────── - -export default function (pi: ExtensionAPI) { - // ─── Tool: github_issues ──────────────────────────────────────────────── - - pi.registerTool({ - name: "github_issues", - label: "GitHub Issues", - description: "Manage GitHub issues: list, view, create, update, close, reopen, or search issues in the current repository.", - promptSnippet: "List, view, create, update, close, reopen, or search GitHub issues", - promptGuidelines: [ - "Use github_issues to interact with GitHub issues instead of running `gh` CLI commands directly.", - "When listing issues, default to state='open' and include relevant filters like labels or assignee.", - "When searching, use GitHub search syntax in the query (e.g., 'is:open label:bug').", - "Mutating actions (create, update, close, reopen) require user confirmation before executing.", - ], - parameters: Type.Object({ - action: StringEnum(["list", "view", "create", "update", "close", "reopen", "search"] as const), - number: Type.Optional(Type.Number({ description: "Issue number (for view/update/close/reopen)" })), - title: Type.Optional(Type.String({ description: "Issue title (for create)" })), - body: Type.Optional(Type.String({ description: "Issue body (for create/update)" })), - labels: Type.Optional(Type.String({ description: "Comma-separated labels (for list filter or create/update)" })), - assignee: Type.Optional(Type.String({ description: "Assignee username (for list filter or create/update)" })), - assignees: Type.Optional(Type.String({ description: "Comma-separated assignees (for create/update)" })), - milestone: Type.Optional(Type.String({ description: "Milestone number or title (for list filter)" })), - state: Type.Optional(StringEnum(["open", "closed", "all"] as const)), - query: Type.Optional(Type.String({ description: "Search query using GitHub search syntax (for search action)" })), - per_page: Type.Optional(Type.Number({ description: "Results per page (default 30, max 100)" })), - }), - - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - requireAuth(); - const repo = requireRepo(ctx.cwd); - - switch (params.action) { - case "list": { - const issues = await listIssues(repo, { - state: params.state, - labels: params.labels, - assignee: params.assignee, - milestone: params.milestone, - per_page: params.per_page, - }); - return textResult( - `Issues in ${repo.fullName} (${params.state ?? "open"}):\n\n${formatIssueList(issues)}`, - { issues: issues.map((i) => ({ number: i.number, title: i.title, state: i.state })) }, - ); - } - case "view": { - if (!params.number) return textResult("Error: 'number' is required for view action."); - const issue = await getIssue(repo, params.number); - const comments = await listComments(repo, params.number); - let text = formatIssueDetail(issue); - if (comments.length) { - text += `\n\n## Comments (${comments.length})\n\n${formatCommentList(comments)}`; - } - return textResult(text, { issue: { number: issue.number, title: issue.title, state: issue.state } }); - } - case "create": { - if (!params.title) return textResult("Error: 'title' is required for create action."); - const createGate = await confirmAction(ctx, `Create issue "${params.title}"?`); - if (createGate) return createGate; - const newIssue = await createIssue(repo, { - title: params.title, - body: params.body, - labels: params.labels?.split(",").map((l) => l.trim()), - assignees: params.assignees?.split(",").map((a) => a.trim()), - }); - return textResult( - `Created issue #${newIssue.number}: ${newIssue.title}\n${newIssue.html_url}`, - { issue: { number: newIssue.number, title: newIssue.title } }, - ); - } - case "update": { - if (!params.number) return textResult("Error: 'number' is required for update action."); - const updateGate = await confirmAction(ctx, `Update issue #${params.number}?`); - if (updateGate) return updateGate; - const updated = await updateIssue(repo, params.number, { - title: params.title, - body: params.body, - labels: params.labels?.split(",").map((l) => l.trim()), - assignees: params.assignees?.split(",").map((a) => a.trim()), - }); - return textResult( - `Updated issue #${updated.number}: ${updated.title}\n${updated.html_url}`, - { issue: { number: updated.number, title: updated.title } }, - ); - } - case "close": { - if (!params.number) return textResult("Error: 'number' is required for close action."); - const closeGate = await confirmAction(ctx, `Close issue #${params.number}?`); - if (closeGate) return closeGate; - const closed = await updateIssue(repo, params.number, { state: "closed" }); - return textResult(`Closed issue #${closed.number}: ${closed.title}`, { issue: { number: closed.number } }); - } - case "reopen": { - if (!params.number) return textResult("Error: 'number' is required for reopen action."); - const reopenGate = await confirmAction(ctx, `Reopen issue #${params.number}?`); - if (reopenGate) return reopenGate; - const reopened = await updateIssue(repo, params.number, { state: "open" }); - return textResult(`Reopened issue #${reopened.number}: ${reopened.title}`, { issue: { number: reopened.number } }); - } - case "search": { - if (!params.query) return textResult("Error: 'query' is required for search action."); - const q = `repo:${repo.fullName} ${params.query}`; - const results = await searchIssues(q, { per_page: params.per_page }); - const issuesOnly = results.items.filter((i) => !i.pull_request); - return textResult( - `Search results (${results.total_count} total, showing ${issuesOnly.length}):\n\n${formatIssueList(issuesOnly)}`, - { total: results.total_count }, - ); - } - default: - return textResult(`Unknown action: ${params.action}`); - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("github_issues ")); - text += theme.fg("muted", `${args.action ?? "?"}`); - if (args.number) text += theme.fg("accent", ` #${args.number}`); - if (args.title) text += theme.fg("dim", ` "${args.title}"`); - if (args.query) text += theme.fg("dim", ` "${args.query}"`); - return new Text(text, 0, 0); - }, - - renderResult(result, { expanded, isPartial }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Fetching from GitHub..."), 0, 0); - const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; - if (!expanded) { - const firstLine = content.split("\n")[0] ?? ""; - return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); - } - return new Text(content, 0, 0); - }, - }); - - // ─── Tool: github_prs ─────────────────────────────────────────────────── - - pi.registerTool({ - name: "github_prs", - label: "GitHub PRs", - description: "Manage GitHub pull requests: list, view, create, update, get diff, list files, and check CI status.", - promptSnippet: "List, view, create, update, diff, files, and checks for GitHub pull requests", - promptGuidelines: [ - "Use github_prs to interact with GitHub pull requests instead of running `gh` CLI commands directly.", - "Use action='diff' to see the actual code changes in a PR.", - "Use action='files' for a summary of changed files without the full diff.", - "Use action='checks' to see CI/CD status for a PR.", - "Mutating actions (create, update) require user confirmation before executing.", - ], - parameters: Type.Object({ - action: StringEnum(["list", "view", "create", "update", "diff", "files", "checks"] as const), - number: Type.Optional(Type.Number({ description: "PR number (for view/update/diff/files/checks)" })), - title: Type.Optional(Type.String({ description: "PR title (for create)" })), - body: Type.Optional(Type.String({ description: "PR body (for create/update)" })), - head: Type.Optional(Type.String({ description: "Head branch (for create, defaults to current branch)" })), - base: Type.Optional(Type.String({ description: "Base branch (for create, defaults to repo default branch)" })), - draft: Type.Optional(Type.Boolean({ description: "Create as draft PR (for create)" })), - state: Type.Optional(StringEnum(["open", "closed", "all"] as const)), - per_page: Type.Optional(Type.Number({ description: "Results per page (default 30, max 100)" })), - }), - - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - requireAuth(); - const repo = requireRepo(ctx.cwd); - - switch (params.action) { - case "list": { - const prs = await listPullRequests(repo, { - state: params.state, - per_page: params.per_page, - }); - return textResult( - `Pull requests in ${repo.fullName} (${params.state ?? "open"}):\n\n${formatPRList(prs)}`, - { prs: prs.map((p) => ({ number: p.number, title: p.title, state: p.state, draft: p.draft })) }, - ); - } - case "view": { - if (!params.number) return textResult("Error: 'number' is required for view action."); - const pr = await getPullRequest(repo, params.number); - const reviews = await listReviews(repo, params.number); - let text = formatPRDetail(pr); - if (reviews.length) { - text += `\n\n## Reviews (${reviews.length})\n\n${formatReviewList(reviews)}`; - } - return textResult(text, { pr: { number: pr.number, title: pr.title, state: pr.state } }); - } - case "create": { - if (!params.title) return textResult("Error: 'title' is required for create action."); - const head = params.head ?? getCurrentBranch(ctx.cwd); - if (!head) return textResult("Error: Could not determine current branch. Provide 'head' parameter."); - const base = params.base ?? getDefaultBranch(ctx.cwd); - const createPRGate = await confirmAction(ctx, `Create PR "${params.title}" (${head} → ${base})?`); - if (createPRGate) return createPRGate; - const newPR = await createPullRequest(repo, { - title: params.title, - body: params.body, - head, - base, - draft: params.draft, - }); - return textResult( - `Created PR #${newPR.number}: ${newPR.title}\n${newPR.head.ref} → ${newPR.base.ref}\n${newPR.html_url}`, - { pr: { number: newPR.number, title: newPR.title } }, - ); - } - case "update": { - if (!params.number) return textResult("Error: 'number' is required for update action."); - const updatePRGate = await confirmAction(ctx, `Update PR #${params.number}?`); - if (updatePRGate) return updatePRGate; - const updated = await updatePullRequest(repo, params.number, { - title: params.title, - body: params.body, - base: params.base, - }); - return textResult( - `Updated PR #${updated.number}: ${updated.title}\n${updated.html_url}`, - { pr: { number: updated.number, title: updated.title } }, - ); - } - case "diff": { - if (!params.number) return textResult("Error: 'number' is required for diff action."); - const diff = await getPullRequestDiff(repo, params.number); - return textResult(`Diff for PR #${params.number}:\n\n${diff}`); - } - case "files": { - if (!params.number) return textResult("Error: 'number' is required for files action."); - const files = await listPullRequestFiles(repo, params.number); - return textResult( - `Changed files in PR #${params.number}:\n\n${formatFileChanges(files)}`, - { files: files.map((f) => ({ filename: f.filename, status: f.status })) }, - ); - } - case "checks": { - if (!params.number) return textResult("Error: 'number' is required for checks action."); - const pr = await getPullRequest(repo, params.number); - const checks = await listCheckRuns(repo, pr.head.sha); - if (!checks.check_runs.length) { - return textResult(`No CI checks found for PR #${params.number}.`); - } - const lines = checks.check_runs.map((c) => { - const icon = c.conclusion === "success" ? "✓" : c.conclusion === "failure" ? "✗" : c.status === "in_progress" ? "⟳" : "…"; - return `${icon} ${c.name}: ${c.conclusion ?? c.status}`; - }); - return textResult( - `CI checks for PR #${params.number}:\n\n${lines.join("\n")}`, - { checks: checks.check_runs.map((c) => ({ name: c.name, conclusion: c.conclusion, status: c.status })) }, - ); - } - default: - return textResult(`Unknown action: ${params.action}`); - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("github_prs ")); - text += theme.fg("muted", `${args.action ?? "?"}`); - if (args.number) text += theme.fg("accent", ` #${args.number}`); - if (args.title) text += theme.fg("dim", ` "${args.title}"`); - return new Text(text, 0, 0); - }, - - renderResult(result, { expanded, isPartial }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Fetching from GitHub..."), 0, 0); - const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; - if (!expanded) { - const firstLine = content.split("\n")[0] ?? ""; - return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); - } - return new Text(content, 0, 0); - }, - }); - - // ─── Tool: github_comments ────────────────────────────────────────────── - - pi.registerTool({ - name: "github_comments", - label: "GitHub Comments", - description: "List or add comments on GitHub issues and pull requests.", - promptSnippet: "List or add comments on GitHub issues and PRs", - parameters: Type.Object({ - action: StringEnum(["list", "add"] as const), - number: Type.Number({ description: "Issue or PR number" }), - body: Type.Optional(Type.String({ description: "Comment body text (for add)" })), - }), - - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - requireAuth(); - const repo = requireRepo(ctx.cwd); - - switch (params.action) { - case "list": { - const comments = await listComments(repo, params.number); - return textResult( - `Comments on #${params.number} (${comments.length}):\n\n${formatCommentList(comments)}`, - { count: comments.length }, - ); - } - case "add": { - if (!params.body) return textResult("Error: 'body' is required for add action."); - const addGate = await confirmAction(ctx, `Add comment on #${params.number}?`); - if (addGate) return addGate; - const comment = await addComment(repo, params.number, params.body); - return textResult( - `Added comment on #${params.number}: ${comment.html_url}`, - { comment: { id: comment.id } }, - ); - } - default: - return textResult(`Unknown action: ${params.action}`); - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("github_comments ")); - text += theme.fg("muted", `${args.action ?? "?"}`); - text += theme.fg("accent", ` #${args.number ?? "?"}`); - return new Text(text, 0, 0); - }, - - renderResult(result, { expanded, isPartial }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Fetching..."), 0, 0); - const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; - if (!expanded) { - const firstLine = content.split("\n")[0] ?? ""; - return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); - } - return new Text(content, 0, 0); - }, - }); - - // ─── Tool: github_reviews ─────────────────────────────────────────────── - - pi.registerTool({ - name: "github_reviews", - label: "GitHub Reviews", - description: "Manage GitHub PR reviews: list reviews, submit a review (approve/request changes/comment), or request reviewers.", - promptSnippet: "List reviews, submit reviews, or request reviewers on GitHub PRs", - promptGuidelines: [ - "Use event='APPROVE' to approve, 'REQUEST_CHANGES' to request changes, 'COMMENT' for a general review comment.", - "Use action='request_reviewers' to assign reviewers to a PR.", - ], - parameters: Type.Object({ - action: StringEnum(["list", "submit", "request_reviewers"] as const), - number: Type.Number({ description: "PR number" }), - body: Type.Optional(Type.String({ description: "Review body text (for submit)" })), - event: Type.Optional(StringEnum(["APPROVE", "REQUEST_CHANGES", "COMMENT"] as const)), - reviewers: Type.Optional(Type.String({ description: "Comma-separated reviewer usernames (for request_reviewers)" })), - }), - - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - requireAuth(); - const repo = requireRepo(ctx.cwd); - - switch (params.action) { - case "list": { - const reviews = await listReviews(repo, params.number); - return textResult( - `Reviews on PR #${params.number} (${reviews.length}):\n\n${formatReviewList(reviews)}`, - { count: reviews.length }, - ); - } - case "submit": { - if (!params.event) return textResult("Error: 'event' is required for submit action (APPROVE, REQUEST_CHANGES, or COMMENT)."); - const submitGate = await confirmAction(ctx, `Submit ${params.event} review on PR #${params.number}?`); - if (submitGate) return submitGate; - const review = await createReview(repo, params.number, { - body: params.body, - event: params.event, - }); - return textResult( - `Submitted review on PR #${params.number}: ${review.state}\n${review.html_url}`, - { review: { id: review.id, state: review.state } }, - ); - } - case "request_reviewers": { - if (!params.reviewers) return textResult("Error: 'reviewers' is required for request_reviewers action."); - const reviewerList = params.reviewers.split(",").map((r) => r.trim()); - const reviewersGate = await confirmAction(ctx, `Request reviewers on PR #${params.number}: ${reviewerList.join(", ")}?`); - if (reviewersGate) return reviewersGate; - await requestReviewers(repo, params.number, reviewerList); - return textResult( - `Requested reviewers on PR #${params.number}: ${reviewerList.join(", ")}`, - { reviewers: reviewerList }, - ); - } - default: - return textResult(`Unknown action: ${params.action}`); - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("github_reviews ")); - text += theme.fg("muted", `${args.action ?? "?"}`); - text += theme.fg("accent", ` #${args.number ?? "?"}`); - if (args.event) text += theme.fg("dim", ` ${args.event}`); - return new Text(text, 0, 0); - }, - - renderResult(result, { expanded, isPartial }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Processing..."), 0, 0); - const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; - if (!expanded) { - const firstLine = content.split("\n")[0] ?? ""; - return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); - } - return new Text(content, 0, 0); - }, - }); - - // ─── Tool: github_labels ──────────────────────────────────────────────── - - pi.registerTool({ - name: "github_labels", - label: "GitHub Labels", - description: "Manage GitHub labels and milestones: list/create labels, list/create milestones.", - promptSnippet: "List or create GitHub labels and milestones", - parameters: Type.Object({ - action: StringEnum(["list_labels", "create_label", "list_milestones", "create_milestone"] as const), - name: Type.Optional(Type.String({ description: "Label or milestone name (for create)" })), - color: Type.Optional(Type.String({ description: "Label hex color without # (for create_label, e.g. 'ff0000')" })), - description: Type.Optional(Type.String({ description: "Description (for create)" })), - due_on: Type.Optional(Type.String({ description: "Milestone due date ISO 8601 (for create_milestone, e.g. '2025-12-31T00:00:00Z')" })), - }), - - async execute(_toolCallId, params, _signal, _onUpdate, ctx) { - requireAuth(); - const repo = requireRepo(ctx.cwd); - - switch (params.action) { - case "list_labels": { - const labels = await listLabels(repo); - return textResult(`Labels in ${repo.fullName}:\n\n${formatLabelList(labels)}`, { count: labels.length }); - } - case "create_label": { - if (!params.name) return textResult("Error: 'name' is required for create_label."); - const labelGate = await confirmAction(ctx, `Create label "${params.name}"?`); - if (labelGate) return labelGate; - const label = await createLabel(repo, { - name: params.name, - color: params.color ?? "ededed", - description: params.description, - }); - return textResult(`Created label: ${label.name} (#${label.color})`, { label: { name: label.name } }); - } - case "list_milestones": { - const milestones = await listMilestones(repo); - return textResult(`Milestones in ${repo.fullName}:\n\n${formatMilestoneList(milestones)}`, { count: milestones.length }); - } - case "create_milestone": { - if (!params.name) return textResult("Error: 'name' is required for create_milestone."); - const milestoneGate = await confirmAction(ctx, `Create milestone "${params.name}"?`); - if (milestoneGate) return milestoneGate; - const ms = await createMilestone(repo, { - title: params.name, - description: params.description, - due_on: params.due_on, - }); - return textResult(`Created milestone: ${ms.title} (#${ms.number})`, { milestone: { number: ms.number, title: ms.title } }); - } - default: - return textResult(`Unknown action: ${params.action}`); - } - }, - - renderCall(args, theme) { - let text = theme.fg("toolTitle", theme.bold("github_labels ")); - text += theme.fg("muted", `${args.action ?? "?"}`); - if (args.name) text += theme.fg("dim", ` "${args.name}"`); - return new Text(text, 0, 0); - }, - - renderResult(result, { expanded, isPartial }, theme) { - if (isPartial) return new Text(theme.fg("warning", "Fetching..."), 0, 0); - const content = result.content?.[0]?.type === "text" ? result.content[0].text : ""; - if (!expanded) { - const firstLine = content.split("\n")[0] ?? ""; - return new Text(theme.fg("success", "✓ ") + firstLine, 0, 0); - } - return new Text(content, 0, 0); - }, - }); - - // ─── Slash command: /gh ────────────────────────────────────────────────── - - pi.registerCommand("gh", { - description: "GitHub helper: /gh issues|prs|view|create|labels|milestones|status", - - getArgumentCompletions: (prefix: string) => { - const subcommands = ["issues", "prs", "view", "create", "labels", "milestones", "status"]; - const parts = prefix.trim().split(/\s+/); - - if (parts.length <= 1) { - return subcommands - .filter((cmd) => cmd.startsWith(parts[0] ?? "")) - .map((cmd) => ({ value: cmd, label: cmd })); - } - - if (parts[0] === "issues" || parts[0] === "prs") { - const states = ["open", "closed", "all"]; - const statePrefix = parts[1] ?? ""; - return states - .filter((s) => s.startsWith(statePrefix)) - .map((s) => ({ value: `${parts[0]} ${s}`, label: s })); - } - - if (parts[0] === "create") { - const types = ["issue", "pr"]; - const typePrefix = parts[1] ?? ""; - return types - .filter((t) => t.startsWith(typePrefix)) - .map((t) => ({ value: `create ${t}`, label: t })); - } - - return []; - }, - - handler: async (args, ctx) => { - const parts = args.trim().split(/\s+/); - const sub = parts[0]; - const rest = parts.slice(1).join(" "); - - if (!isAuthenticated()) { - ctx.ui.notify("Not authenticated to GitHub. Install `gh` CLI or set GITHUB_TOKEN.", "error"); - return; - } - - const repo = detectRepo(ctx.cwd); - if (!repo && sub !== "status") { - ctx.ui.notify("Not in a GitHub repository.", "error"); - return; - } - - try { - switch (sub) { - case "issues": { - const state = (rest as "open" | "closed" | "all") || "open"; - const issues = await listIssues(repo!, { state }); - const display = `Issues in ${repo!.fullName} (${state}):\n\n${formatIssueList(issues)}`; - pi.sendMessage({ customType: "github", content: display, display: true }); - break; - } - case "prs": { - const state = (rest as "open" | "closed" | "all") || "open"; - const prs = await listPullRequests(repo!, { state }); - const display = `Pull requests in ${repo!.fullName} (${state}):\n\n${formatPRList(prs)}`; - pi.sendMessage({ customType: "github", content: display, display: true }); - break; - } - case "view": { - const num = parseInt(rest, 10); - if (isNaN(num)) { - ctx.ui.notify("Usage: /gh view ", "error"); - return; - } - // Try as issue first, then PR - try { - const issue = await getIssue(repo!, num); - if (issue.pull_request) { - // It's a PR - const pr = await getPullRequest(repo!, num); - const reviews = await listReviews(repo!, num); - let text = formatPRDetail(pr); - if (reviews.length) text += `\n\n## Reviews\n\n${formatReviewList(reviews)}`; - pi.sendMessage({ customType: "github", content: text, display: true }); - } else { - const comments = await listComments(repo!, num); - let text = formatIssueDetail(issue); - if (comments.length) text += `\n\n## Comments\n\n${formatCommentList(comments)}`; - pi.sendMessage({ customType: "github", content: text, display: true }); - } - } catch { - ctx.ui.notify(`Could not find issue or PR #${num}`, "error"); - } - break; - } - case "create": { - const type = parts[1]; - if (type === "issue") { - ctx.ui.notify("Use the agent to create an issue: tell it the title, description, and labels you want.", "info"); - } else if (type === "pr") { - const branch = getCurrentBranch(ctx.cwd); - const base = getDefaultBranch(ctx.cwd); - ctx.ui.notify( - `Current branch: ${branch}\nBase: ${base}\n\nTell the agent the PR title and description to create it.`, - "info", - ); - } else { - ctx.ui.notify("Usage: /gh create issue|pr", "error"); - } - break; - } - case "labels": { - const labels = await listLabels(repo!); - pi.sendMessage({ customType: "github", content: `Labels in ${repo!.fullName}:\n\n${formatLabelList(labels)}`, display: true }); - break; - } - case "milestones": { - const milestones = await listMilestones(repo!); - pi.sendMessage({ - customType: "github", - content: `Milestones in ${repo!.fullName}:\n\n${formatMilestoneList(milestones)}`, - display: true, - }); - break; - } - case "status": { - const auth = authMethod(); - const repoStr = repo ? `${repo.fullName}` : "not detected"; - const branch = repo ? getCurrentBranch(ctx.cwd) ?? "unknown" : "n/a"; - const text = `GitHub Extension Status\n\nAuth: ${auth}\nRepo: ${repoStr}\nBranch: ${branch}`; - pi.sendMessage({ customType: "github", content: text, display: true }); - break; - } - default: - ctx.ui.notify("Usage: /gh issues|prs|view|create|labels|milestones|status", "info"); - } - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - ctx.ui.notify(`GitHub error: ${msg}`, "error"); - } - }, - }); - - // ─── Message renderer ─────────────────────────────────────────────────── - - pi.registerMessageRenderer("github", (message, _options, theme) => { - const content = message.content ?? ""; - // Apply some light styling to the GitHub output - const styled = content - .replace(/^(# .+)$/gm, (m: string) => theme.fg("accent", theme.bold(m))) - .replace(/(●)/g, theme.fg("success", "$1")) - .replace(/(✓)/g, theme.fg("success", "$1")) - .replace(/(✗)/g, theme.fg("error", "$1")) - .replace(/(⊕)/g, theme.fg("accent", "$1")) - .replace(/(◇)/g, theme.fg("dim", "$1")) - .replace(/(https:\/\/github\.com\S+)/g, theme.fg("mdLink", "$1")); - return new Text(styled, 0, 0); - }); - - // ─── Session start notification ───────────────────────────────────────── - - pi.on("session_start", async (_event, ctx) => { - const auth = authMethod(); - if (auth === "none") { - ctx.ui.notify("GitHub extension: not authenticated. Install `gh` CLI or set GITHUB_TOKEN.", "warning"); - } - }); -} diff --git a/src/tests/gh-api.test.ts b/src/tests/gh-api.test.ts deleted file mode 100644 index 3d2ef5b77..000000000 --- a/src/tests/gh-api.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import test from "node:test"; -import assert from "node:assert/strict"; -import { spawnSync as realSpawnSync } from "node:child_process"; - -import * as ghApiModule from "../resources/extensions/github/gh-api.ts"; - -function makeSpawnResult(overrides: Partial>): ReturnType { - return { - status: 0, - stdout: "", - stderr: "", - output: [null, "", ""], - pid: 1, - signal: null, - ...overrides, - } as ReturnType; -} - -test("hasGhCli treats zero-exit token output as authenticated", () => { - ghApiModule.setGhSpawnForTests(() => makeSpawnResult({ stdout: "gho_test\n" })); - - try { - assert.equal(ghApiModule.hasGhCli(), true); - assert.equal(ghApiModule.authMethod(), "gh CLI"); - } finally { - ghApiModule.resetGhCliDetectionForTests(); - } -}); - -test("hasGhCli rejects zero-exit responses with empty stdout", () => { - ghApiModule.setGhSpawnForTests(() => makeSpawnResult({ stdout: "" })); - - try { - assert.equal(ghApiModule.hasGhCli(), false); - } finally { - ghApiModule.resetGhCliDetectionForTests(); - } -}); - -test("hasGhCli rejects spawnSync error even with zero exit", () => { - ghApiModule.setGhSpawnForTests(() => makeSpawnResult({ - stdout: "gho_test\n", - stderr: "EPERM", - error: new Error("spawnSync gh EPERM"), - })); - - try { - assert.equal(ghApiModule.hasGhCli(), false); - } finally { - ghApiModule.resetGhCliDetectionForTests(); - } -}); From 1c5edbd309ff188fc7f1c1fe66741bab1ac4997d Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 17:18:25 -0600 Subject: [PATCH 52/60] fix: drop --with-deps from postinstall to prevent hidden sudo prompt On Linux, Playwright's --with-deps flag runs sudo to install system packages. npm's spinner hides the password prompt, making the install appear to hang. Now installs without --with-deps and directs Linux users to run it manually if browser tools fail. Closes #67 Co-Authored-By: Claude Opus 4.6 --- scripts/postinstall.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/scripts/postinstall.js b/scripts/postinstall.js index d7eecaf0f..a06519ef8 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -44,10 +44,12 @@ try { } // Install Playwright chromium for browser tools (non-fatal) -const args = os.platform() === 'linux' ? '--with-deps' : '' try { - execSync(`npx playwright install chromium ${args}`, { stdio: 'inherit' }) + execSync('npx playwright install chromium', { stdio: 'inherit' }) process.stderr.write(`\n ${green}✓${reset} Browser tools ready\n\n`) } catch { - process.stderr.write(`\n ${yellow}⚠${reset} Browser tools unavailable — run ${cyan}npx playwright install chromium${reset} to enable\n\n`) + const hint = os.platform() === 'linux' + ? `${cyan}npx playwright install --with-deps chromium${reset}` + : `${cyan}npx playwright install chromium${reset}` + process.stderr.write(`\n ${yellow}⚠${reset} Browser tools unavailable — run ${hint} to enable\n\n`) } From 237f253f838c21d5301006d806932c4d1ecf35c0 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 17:23:02 -0600 Subject: [PATCH 53/60] docs: update changelog for v2.3.6 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 439be4e0c..3ca0320b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.3.6] - 2026-03-11 + +### Fixed +- Postinstall no longer triggers hidden `sudo` prompt on Linux — Playwright's `--with-deps` flag is no longer run automatically, preventing `npm install -g` from appearing to hang (#67) +- Auto-commit dirty files before branch switch to prevent lost work during slice transitions + +### Changed +- Updated README to reflect current commands, extensions, and step mode workflow + ## [2.3.5] - 2026-03-11 ### Fixed @@ -120,7 +129,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - License updated to MIT -[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.3.5...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.3.6...HEAD +[2.3.6]: https://github.com/gsd-build/gsd-2/compare/v2.3.5...v2.3.6 [2.3.5]: https://github.com/gsd-build/gsd-2/compare/v2.3.4...v2.3.5 [2.3.4]: https://github.com/gsd-build/gsd-2/compare/v0.3.3...v2.3.4 [0.3.3]: https://github.com/gsd-build/gsd-2/compare/v0.3.1...v0.3.3 From c092ff97a96d685698deb8d4c64da65698737ceb Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 17:23:04 -0600 Subject: [PATCH 54/60] 2.3.6 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f4d5e8ab..e7c7a0a16 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.3.5", + "version": "2.3.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.3.5", + "version": "2.3.6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index b46bce45e..49b73b2a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.3.5", + "version": "2.3.6", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From f4c46516a6055f807e898b89908ad09c3be9533b Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 17:32:01 -0600 Subject: [PATCH 55/60] =?UTF-8?q?fix:=20harden=20remote=20questions=20?= =?UTF-8?q?=E2=80=94=20validate=20IDs=20before=20test-send,=20remove=20dea?= =?UTF-8?q?d=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Validate channel IDs via isValidChannelId() before URL interpolation in setup wizard, preventing SSRF during test-send - Add 15s fetch timeout to setup API calls (fetchJson, Discord test-send) - Sanitize Discord error responses before surfacing to user - Remove dead send.ts + channels.ts (unused parallel implementation) - Add poll retry tolerance in manager.ts (1 transient error before fail) Co-Authored-By: Claude Opus 4.6 --- .../extensions/remote-questions/channels.ts | 36 --- .../extensions/remote-questions/manager.ts | 9 +- .../remote-questions/remote-command.ts | 10 +- .../extensions/remote-questions/send.ts | 213 ------------------ 4 files changed, 14 insertions(+), 254 deletions(-) delete mode 100644 src/resources/extensions/remote-questions/channels.ts delete mode 100644 src/resources/extensions/remote-questions/send.ts diff --git a/src/resources/extensions/remote-questions/channels.ts b/src/resources/extensions/remote-questions/channels.ts deleted file mode 100644 index 7360c00a3..000000000 --- a/src/resources/extensions/remote-questions/channels.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Remote Questions — Adapter pattern interfaces - * - * Defines the contract for Slack/Discord (or any future) channel adapters. - */ - -export interface ChannelAdapter { - readonly name: string; - sendQuestions(questions: FormattedQuestion[]): Promise; - pollResponse(ref: PollReference): Promise; - validate(): Promise; -} - -export interface FormattedQuestion { - id: string; - header: string; - question: string; - options: Array<{ label: string; description: string }>; - allowMultiple: boolean; -} - -export interface SendResult { - ref: PollReference; - threadUrl?: string; -} - -export interface PollReference { - channelType: "slack" | "discord"; - messageId: string; - threadTs?: string; - channelId: string; -} - -export interface RemoteAnswer { - answers: Record; -} diff --git a/src/resources/extensions/remote-questions/manager.ts b/src/resources/extensions/remote-questions/manager.ts index 511668deb..f965a657c 100644 --- a/src/resources/extensions/remote-questions/manager.ts +++ b/src/resources/extensions/remote-questions/manager.ts @@ -122,14 +122,19 @@ async function pollUntilDone( ref: import("./types.js").RemotePromptRef, signal?: AbortSignal, ): Promise { + let retryCount = 0; while (Date.now() < prompt.timeoutAt && !signal?.aborted) { try { const answer = await adapter.pollAnswer(prompt, ref); updatePromptRecord(prompt.id, { lastPollAt: Date.now() }); + retryCount = 0; if (answer) return answer; } catch (err) { - markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); - return null; + retryCount++; + if (retryCount > 1) { + markPromptStatus(prompt.id, "failed", sanitizeError(String((err as Error).message))); + return null; + } } await sleep(prompt.pollIntervalMs, signal); diff --git a/src/resources/extensions/remote-questions/remote-command.ts b/src/resources/extensions/remote-questions/remote-command.ts index be5796ff2..356cab9ab 100644 --- a/src/resources/extensions/remote-questions/remote-command.ts +++ b/src/resources/extensions/remote-questions/remote-command.ts @@ -8,7 +8,8 @@ import { CURSOR_MARKER, Editor, type EditorTheme, Key, matchesKey, truncateToWid import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { dirname, join } from "node:path"; import { getGlobalGSDPreferencesPath, loadEffectiveGSDPreferences } from "../gsd/preferences.js"; -import { getRemoteConfigStatus, resolveRemoteConfig } from "./config.js"; +import { getRemoteConfigStatus, isValidChannelId, resolveRemoteConfig } from "./config.js"; +import { sanitizeError } from "./manager.js"; import { getLatestPromptSummary } from "./status.js"; export async function handleRemote( @@ -37,6 +38,7 @@ async function handleSetupSlack(ctx: ExtensionCommandContext): Promise { const channelId = await promptInput(ctx, "Channel ID", "Paste the Slack channel ID (e.g. C0123456789)"); if (!channelId) return void ctx.ui.notify("Slack setup cancelled.", "info"); + if (!isValidChannelId("slack", channelId)) return void ctx.ui.notify("Invalid Slack channel ID format — expected 9-12 uppercase alphanumeric characters.", "error"); const send = await fetchJson("https://slack.com/api/chat.postMessage", { method: "POST", @@ -61,15 +63,17 @@ async function handleSetupDiscord(ctx: ExtensionCommandContext): Promise { const channelId = await promptInput(ctx, "Channel ID", "Paste the Discord channel ID (e.g. 1234567890123456789)"); if (!channelId) return void ctx.ui.notify("Discord setup cancelled.", "info"); + if (!isValidChannelId("discord", channelId)) return void ctx.ui.notify("Invalid Discord channel ID format — expected 17-20 digit numeric ID.", "error"); const sendResponse = await fetch(`https://discord.com/api/v10/channels/${channelId}/messages`, { method: "POST", headers: { Authorization: `Bot ${token}`, "Content-Type": "application/json" }, body: JSON.stringify({ content: "GSD remote questions connected." }), + signal: AbortSignal.timeout(15_000), }); if (!sendResponse.ok) { const body = await sendResponse.text().catch(() => ""); - return void ctx.ui.notify(`Could not send to channel (HTTP ${sendResponse.status}): ${body}`, "error"); + return void ctx.ui.notify(`Could not send to channel (HTTP ${sendResponse.status}): ${sanitizeError(body).slice(0, 200)}`, "error"); } saveProviderToken("discord_bot", token); @@ -138,7 +142,7 @@ async function handleRemoteMenu(ctx: ExtensionCommandContext): Promise { async function fetchJson(url: string, init?: RequestInit): Promise { try { - const response = await fetch(url, init); + const response = await fetch(url, { ...init, signal: AbortSignal.timeout(15_000) }); return await response.json(); } catch { return null; diff --git a/src/resources/extensions/remote-questions/send.ts b/src/resources/extensions/remote-questions/send.ts deleted file mode 100644 index 90d6e293b..000000000 --- a/src/resources/extensions/remote-questions/send.ts +++ /dev/null @@ -1,213 +0,0 @@ -/** - * Remote Questions — Entry point - * - * Transparent routing: when ctx.hasUI is false and a remote channel is - * configured, sends questions via Slack/Discord and polls for the response. - * - * The LLM keeps calling `ask_user_questions` as normal — this module - * intercepts the non-interactive branch. - */ - -import type { FormattedQuestion, ChannelAdapter, RemoteAnswer } from "./channels.js"; -import { resolveRemoteConfig, type ResolvedConfig } from "./config.js"; -import { SlackAdapter } from "./slack-adapter.js"; -import { DiscordAdapter } from "./discord-adapter.js"; - -// ─── Types ─────────────────────────────────────────────────────────────────── - -interface Question { - id: string; - header: string; - question: string; - options: Array<{ label: string; description: string }>; - allowMultiple?: boolean; -} - -interface ToolResult { - content: Array<{ type: "text"; text: string }>; - details?: Record; -} - -// ─── Public API ────────────────────────────────────────────────────────────── - -/** - * Try to send questions via a remote channel (Slack/Discord). - * Returns a formatted ToolResult if successful, or null if no remote - * channel is configured (caller falls back to the original error). - */ -export async function tryRemoteQuestions( - questions: Question[], - signal?: AbortSignal, -): Promise { - const config = resolveRemoteConfig(); - if (!config) return null; - - const adapter = createAdapter(config); - const formatted = questionsToFormatted(questions); - - try { - await adapter.validate(); - } catch (err) { - return errorToolResult(`Remote auth failed (${config.channel}): ${(err as Error).message}`); - } - - let sendResult; - try { - sendResult = await adapter.sendQuestions(formatted); - } catch (err) { - return errorToolResult(`Failed to send questions via ${config.channel}: ${(err as Error).message}`); - } - - const threadInfo = sendResult.threadUrl - ? ` Thread: ${sendResult.threadUrl}` - : ""; - - // Poll for response - const answer = await pollWithTimeout(adapter, sendResult.ref, formatted, signal, config); - - if (!answer) { - // Timeout — return structured result so the LLM knows - return { - content: [ - { - type: "text", - text: JSON.stringify({ - timed_out: true, - channel: config.channel, - timeout_minutes: config.timeoutMs / 60000, - thread_url: sendResult.threadUrl ?? null, - message: `User did not respond within ${config.timeoutMs / 60000} minutes.${threadInfo}`, - }), - }, - ], - details: { - remote: true, - channel: config.channel, - timed_out: true, - threadUrl: sendResult.threadUrl, - }, - }; - } - - // Format the answer in the same structure as formatForLLM - const formattedAnswer = formatRemoteAnswerForLLM(answer); - - return { - content: [{ type: "text", text: formattedAnswer }], - details: { - remote: true, - channel: config.channel, - timed_out: false, - threadUrl: sendResult.threadUrl, - questions, - response: answer, - }, - }; -} - -// ─── Internal ──────────────────────────────────────────────────────────────── - -function createAdapter(config: ResolvedConfig): ChannelAdapter & { - pollResponseWithQuestions?: ( - ref: import("./channels.js").PollReference, - questions: FormattedQuestion[], - ) => Promise; -} { - switch (config.channel) { - case "slack": - return new SlackAdapter(config.token, config.channelId); - case "discord": - return new DiscordAdapter(config.token, config.channelId); - default: - throw new Error(`Unknown channel type: ${config.channel}`); - } -} - -async function pollWithTimeout( - adapter: ReturnType, - ref: import("./channels.js").PollReference, - questions: FormattedQuestion[], - signal: AbortSignal | undefined, - config: ResolvedConfig, -): Promise { - const deadline = Date.now() + config.timeoutMs; - let retries = 0; - const maxNetworkRetries = 1; - - while (Date.now() < deadline && !signal?.aborted) { - try { - // Use the question-aware poll if available - const answer = adapter.pollResponseWithQuestions - ? await adapter.pollResponseWithQuestions(ref, questions) - : await adapter.pollResponse(ref); - - if (answer) return answer; - retries = 0; // Reset on successful poll - } catch { - retries++; - if (retries > maxNetworkRetries) return null; - } - - await sleep(config.pollIntervalMs, signal); - } - - return null; -} - -function sleep(ms: number, signal?: AbortSignal): Promise { - return new Promise((resolve) => { - if (signal?.aborted) { - resolve(); - return; - } - - let settled = false; - const settle = () => { - if (settled) return; - settled = true; - clearTimeout(timer); - if (signal) signal.removeEventListener("abort", onAbort); - resolve(); - }; - - const onAbort = () => settle(); - const timer = setTimeout(() => settle(), ms); - - if (signal) { - signal.addEventListener("abort", onAbort, { once: true }); - } - }); -} - -function questionsToFormatted(questions: Question[]): FormattedQuestion[] { - return questions.map((q) => ({ - id: q.id, - header: q.header, - question: q.question, - options: q.options, - allowMultiple: q.allowMultiple ?? false, - })); -} - -/** - * Format RemoteAnswer into the same JSON structure as the local formatForLLM. - * Structure: { answers: { [id]: { answers: string[] } } } - */ -function formatRemoteAnswerForLLM(answer: RemoteAnswer): string { - const formatted: Record = {}; - for (const [id, data] of Object.entries(answer.answers)) { - const list = [...data.answers]; - if (data.user_note) { - list.push(`user_note: ${data.user_note}`); - } - formatted[id] = { answers: list }; - } - return JSON.stringify({ answers: formatted }); -} - -function errorToolResult(message: string): ToolResult { - return { - content: [{ type: "text", text: message }], - details: { remote: true, error: true }, - }; -} From 1872e8db78c768b62a4eb44e1317bd0471dd17ff Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 17:39:46 -0600 Subject: [PATCH 56/60] fix: prevent auto-mode model switches from persisting as global default (#30) and harden resume resilience (#16) Patch SDK setModel() to accept { persist: false } so per-unit model switching in auto-mode no longer overwrites the user's global default. Add state rebuild + doctor on resume, guard logging for silent dispatch failures, and active-state check before prompt injection. Co-Authored-By: Claude Opus 4.6 --- ...@mariozechner+pi-coding-agent+0.57.1.patch | 60 +++++++++++++++++++ src/resources/extensions/gsd/auto.ts | 20 ++++++- 2 files changed, 77 insertions(+), 3 deletions(-) diff --git a/patches/@mariozechner+pi-coding-agent+0.57.1.patch b/patches/@mariozechner+pi-coding-agent+0.57.1.patch index 0216c88d9..0b2a0a8c8 100644 --- a/patches/@mariozechner+pi-coding-agent+0.57.1.patch +++ b/patches/@mariozechner+pi-coding-agent+0.57.1.patch @@ -1,3 +1,63 @@ +diff --git a/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js b/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js +index 90622c2..cff094b 100644 +--- a/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js ++++ b/node_modules/@mariozechner/pi-coding-agent/dist/core/agent-session.js +@@ -1007,7 +1007,7 @@ export class AgentSession { + * Validates API key, saves to session and settings. + * @throws Error if no API key available for the model + */ +- async setModel(model) { ++ async setModel(model, options) { + const apiKey = await this._modelRegistry.getApiKey(model); + if (!apiKey) { + throw new Error(`No API key for ${model.provider}/${model.id}`); +@@ -1016,7 +1016,9 @@ export class AgentSession { + const thinkingLevel = this._getThinkingLevelForModelSwitch(); + this.agent.setModel(model); + this.sessionManager.appendModelChange(model.provider, model.id); +- this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); ++ if (options?.persist !== false) { ++ this.settingsManager.setDefaultModelAndProvider(model.provider, model.id); ++ } + // Re-clamp thinking level for new model's capabilities + this.setThinkingLevel(thinkingLevel); + await this._emitModelSelect(model, previousModel, "set"); +@@ -1067,7 +1069,9 @@ export class AgentSession { + // Apply model + this.agent.setModel(next.model); + this.sessionManager.appendModelChange(next.model.provider, next.model.id); +- this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); ++ if (options?.persist !== false) { ++ this.settingsManager.setDefaultModelAndProvider(next.model.provider, next.model.id); ++ } + // Apply thinking level. + // - Explicit scoped model thinking level overrides current session level + // - Undefined scoped model thinking level inherits the current session preference +@@ -1094,7 +1098,9 @@ export class AgentSession { + const thinkingLevel = this._getThinkingLevelForModelSwitch(); + this.agent.setModel(nextModel); + this.sessionManager.appendModelChange(nextModel.provider, nextModel.id); +- this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); ++ if (options?.persist !== false) { ++ this.settingsManager.setDefaultModelAndProvider(nextModel.provider, nextModel.id); ++ } + // Re-clamp thinking level for new model's capabilities + this.setThinkingLevel(thinkingLevel); + await this._emitModelSelect(nextModel, currentModel, "cycle"); +@@ -1659,11 +1665,11 @@ export class AgentSession { + setActiveTools: (toolNames) => this.setActiveToolsByName(toolNames), + refreshTools: () => this._refreshToolRegistry(), + getCommands, +- setModel: async (model) => { ++ setModel: async (model, options) => { + const key = await this.modelRegistry.getApiKey(model); + if (!key) + return false; +- await this.setModel(model); ++ await this.setModel(model, options); + return true; + }, + getThinkingLevel: () => this.thinkingLevel, diff --git a/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js b/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js index 27fe820..68f277f 100644 --- a/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 910cdc931..b628d16ec 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -247,6 +247,14 @@ export async function startAuto( if (!getLedger()) initMetrics(base); ctx.ui.setStatus("gsd-auto", stepMode ? "next" : "auto"); ctx.ui.notify(stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "info"); + // Rebuild disk state before resuming — user interaction during pause may have changed files + try { await rebuildState(base); } catch { /* non-fatal */ } + try { + const report = await runGSDDoctor(base, { fix: true }); + if (report.fixesApplied.length > 0) { + ctx.ui.notify(`Resume: applied ${report.fixesApplied.length} fix(es) to state.`, "info"); + } + } catch { /* non-fatal */ } await dispatchNextUnit(ctx, pi); return; } @@ -758,7 +766,12 @@ async function dispatchNextUnit( ctx: ExtensionContext, pi: ExtensionAPI, ): Promise { - if (!active || !cmdCtx) return; + if (!active || !cmdCtx) { + if (active && !cmdCtx) { + ctx.ui.notify("Auto-mode dispatch failed: no command context. Run /gsd auto to restart.", "error"); + } + return; + } let state = await deriveState(basePath); let mid = state.activeMilestone?.id; @@ -1086,7 +1099,7 @@ async function dispatchNextUnit( const allModels = ctx.modelRegistry.getAll(); const model = allModels.find(m => m.id === preferredModelId); if (model) { - const ok = await pi.setModel(model); + const ok = await pi.setModel(model, { persist: false }); if (ok) { ctx.ui.notify(`Model: ${preferredModelId}`, "info"); } @@ -1186,7 +1199,8 @@ async function dispatchNextUnit( await pauseAuto(ctx, pi); }, hardTimeoutMs); - // Inject prompt + // Inject prompt — verify auto-mode still active (guards against race with timeout/pause) + if (!active) return; pi.sendMessage( { customType: "gsd-auto", content: finalPrompt, display: verbose }, { triggerTurn: true }, From a9c4c93007a7e6e3c8f02f647f576825b9cc337e Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 17:43:00 -0600 Subject: [PATCH 57/60] docs: update changelog for v2.3.7 Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ca0320b8..265534bcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.3.7] - 2026-03-11 + +### Added +- Remote user questions via Slack/Discord for headless auto-mode sessions + +### Fixed +- Auto-mode model switches no longer persist as the user's global default (#30) +- Auto-mode resume now rebuilds disk state and runs doctor before dispatching, preventing inline execution after pause (#16) +- Silent dispatch failure when command context is null now surfaces an error notification +- Race condition between timeout handlers and prompt dispatch in auto-mode +- Remote questions: validate IDs before test-send, sanitize error messages to prevent token leakage +- Remote questions: cap user_note at 500 chars to prevent LLM context injection +- Remote questions: validate channel ID format to prevent SSRF +- Remote questions: add 15s per-request fetch timeout to adapters +- Remote questions: distinguish Discord 404 from auth errors in reactions +- Prompt store sorting uses `updatedAt` instead of filename +- TypeScript parameter properties desugared for `--experimental-strip-types` compatibility + +### Changed +- Remote question result details use discriminated union type + ## [2.3.6] - 2026-03-11 ### Fixed @@ -129,7 +150,8 @@ Format based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - License updated to MIT -[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.3.6...HEAD +[Unreleased]: https://github.com/gsd-build/gsd-2/compare/v2.3.7...HEAD +[2.3.7]: https://github.com/gsd-build/gsd-2/compare/v2.3.6...v2.3.7 [2.3.6]: https://github.com/gsd-build/gsd-2/compare/v2.3.5...v2.3.6 [2.3.5]: https://github.com/gsd-build/gsd-2/compare/v2.3.4...v2.3.5 [2.3.4]: https://github.com/gsd-build/gsd-2/compare/v0.3.3...v2.3.4 From 6c91434a80f6a8981e7e81ddcca9bcffbdf6b1d8 Mon Sep 17 00:00:00 2001 From: Lex Christopherson Date: Wed, 11 Mar 2026 17:43:14 -0600 Subject: [PATCH 58/60] 2.3.7 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index e7c7a0a16..d61a7fa84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gsd-pi", - "version": "2.3.6", + "version": "2.3.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gsd-pi", - "version": "2.3.6", + "version": "2.3.7", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 49b73b2a8..79ef2817c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gsd-pi", - "version": "2.3.6", + "version": "2.3.7", "description": "GSD — Get Shit Done coding agent", "license": "MIT", "repository": { From 2b9451dfd49f2c613137a9170041477e2c2c14c6 Mon Sep 17 00:00:00 2001 From: jonathancostin <66714927+jonathancostin@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:44:55 -0500 Subject: [PATCH 59/60] fix: general merge guard prevents infinite loop when complete-slice is bypassed (#71) Replace the narrow 'if currentUnit === complete-slice' merge check with a general merge guard that detects any completed slice branch and merges it to main before dispatching the next unit. The old check only triggered merges after the complete-slice unit type. When the LLM or the doctor post-hook completed slice bookkeeping during task execution, complete-slice was skipped entirely, leaving the slice branch unmerged. On milestone transition, the next slice branch (forked from main) couldn't see the prior milestone's summary, causing deriveState to oscillate between milestones in an infinite loop. The new guard checks: are we on a gsd/MID/SID branch where the roadmap entry is [x]? If so, merge to main and re-derive state before dispatching. --- src/resources/extensions/gsd/auto.ts | 74 +++++++++++++++++----------- 1 file changed, 45 insertions(+), 29 deletions(-) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index b628d16ec..16fdd3431 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -60,6 +60,8 @@ import { execSync } from "node:child_process"; import { autoCommitCurrentBranch, ensureSliceBranch, + getCurrentBranch, + getSliceBranchName, switchToMain, mergeSliceToMain, } from "./worktree.ts"; @@ -800,39 +802,53 @@ async function dispatchNextUnit( return; } - // ── Post-completion merge: merge the slice branch after complete-slice finishes ── - // The complete-slice unit writes the summary, UAT, marks roadmap [x], and commits. - // Now we switch to main and squash-merge the slice branch. - if (currentUnit?.type === "complete-slice") { - try { - const [completedMid, completedSid] = currentUnit.id.split("/"); - // Look up actual slice title from roadmap (on current branch, before switching) - const roadmapFile = resolveMilestoneFile(basePath, completedMid!, "ROADMAP"); + // ── General merge guard: merge completed slice branches before advancing ── + // If we're on a gsd/MID/SID branch and that slice is done (roadmap [x]), + // merge to main before dispatching the next unit. This handles: + // - Normal complete-slice → merge → reassess flow + // - LLM writes summary during task execution, skipping complete-slice + // - Doctor post-hook marks everything done, skipping complete-slice + // - complete-milestone runs on a slice branch (last slice bypass) + { + const currentBranch = getCurrentBranch(basePath); + const branchMatch = currentBranch.match(/^gsd\/(M\d+)\/(S\d+)$/); + if (branchMatch) { + const branchMid = branchMatch[1]!; + const branchSid = branchMatch[2]!; + // Check if this slice is marked done in the roadmap + const roadmapFile = resolveMilestoneFile(basePath, branchMid, "ROADMAP"); const roadmapContent = roadmapFile ? await loadFile(roadmapFile) : null; - let sliceTitleForMerge = completedSid!; if (roadmapContent) { const roadmap = parseRoadmap(roadmapContent); - const sliceEntry = roadmap.slices.find(s => s.id === completedSid); - if (sliceEntry) sliceTitleForMerge = sliceEntry.title; + const sliceEntry = roadmap.slices.find(s => s.id === branchSid); + if (sliceEntry?.done) { + try { + const sliceTitleForMerge = sliceEntry.title || branchSid; + switchToMain(basePath); + const mergeResult = mergeSliceToMain( + basePath, branchMid, branchSid, sliceTitleForMerge, + ); + ctx.ui.notify( + `Merged ${mergeResult.branch} → main.`, + "info", + ); + // Re-derive state from main so downstream logic sees merged state + state = await deriveState(basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + ctx.ui.notify( + `Slice merge failed: ${message}`, + "error", + ); + // Re-derive state so dispatch can figure out what to do + state = await deriveState(basePath); + mid = state.activeMilestone?.id; + midTitle = state.activeMilestone?.title; + } + } } - switchToMain(basePath); - const mergeResult = mergeSliceToMain( - basePath, completedMid!, completedSid!, sliceTitleForMerge, - ); - ctx.ui.notify( - `Merged ${mergeResult.branch} → main.`, - "info", - ); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - ctx.ui.notify( - `Slice merge failed: ${message}`, - "error", - ); - // Re-derive state so dispatch can figure out what to do - state = await deriveState(basePath); - mid = state.activeMilestone?.id; - midTitle = state.activeMilestone?.title; } } From bf6fefa16eee88624ed74b60b45ec48fb4e5495b Mon Sep 17 00:00:00 2001 From: jonathancostin <66714927+jonathancostin@users.noreply.github.com> Date: Wed, 11 Mar 2026 18:44:57 -0500 Subject: [PATCH 60/60] fix: register dynamic-cwd write/read/edit tools for worktree support (#72) The built-in write, read, and edit tools capture process.cwd() once at startup. When /worktree switch calls process.chdir() into a worktree, these tools still resolve relative paths against the original launch directory. This caused GSD auto-mode to write .gsd/ artifacts to the main project instead of the worktree. The bash tool was already patched with a spawnHook for dynamic CWD. Apply the same pattern to write, read, and edit: each execute() call creates a fresh tool instance with the current process.cwd(), so relative paths always resolve against the active working directory. --- src/resources/extensions/gsd/index.ts | 55 ++++++++++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/src/resources/extensions/gsd/index.ts b/src/resources/extensions/gsd/index.ts index bf52720b2..37c255fd0 100644 --- a/src/resources/extensions/gsd/index.ts +++ b/src/resources/extensions/gsd/index.ts @@ -22,7 +22,7 @@ import type { ExtensionAPI, ExtensionContext, } from "@mariozechner/pi-coding-agent"; -import { createBashTool } from "@mariozechner/pi-coding-agent"; +import { createBashTool, createWriteTool, createReadTool, createEditTool } from "@mariozechner/pi-coding-agent"; import { registerGSDCommand } from "./commands.js"; import { registerWorktreeCommand, getWorktreeOriginalCwd, getActiveWorktreeName } from "./worktree-command.js"; @@ -102,6 +102,59 @@ export default function (pi: ExtensionAPI) { }; pi.registerTool(dynamicBash as any); + // ── Dynamic-cwd file tools (write, read, edit) ──────────────────────── + // The built-in file tools capture cwd at startup. When process.chdir() + // moves us into a worktree, relative paths still resolve against the + // original launch directory. These replacements delegate to freshly- + // created tools on each call so that process.cwd() is read dynamically. + const baseWrite = createWriteTool(process.cwd()); + const dynamicWrite = { + ...baseWrite, + execute: async ( + toolCallId: string, + params: { path: string; content: string }, + signal?: AbortSignal, + onUpdate?: any, + ctx?: any, + ) => { + const fresh = createWriteTool(process.cwd()); + return fresh.execute(toolCallId, params, signal, onUpdate, ctx); + }, + }; + pi.registerTool(dynamicWrite as any); + + const baseRead = createReadTool(process.cwd()); + const dynamicRead = { + ...baseRead, + execute: async ( + toolCallId: string, + params: { path: string; offset?: number; limit?: number }, + signal?: AbortSignal, + onUpdate?: any, + ctx?: any, + ) => { + const fresh = createReadTool(process.cwd()); + return fresh.execute(toolCallId, params, signal, onUpdate, ctx); + }, + }; + pi.registerTool(dynamicRead as any); + + const baseEdit = createEditTool(process.cwd()); + const dynamicEdit = { + ...baseEdit, + execute: async ( + toolCallId: string, + params: { path: string; oldText: string; newText: string }, + signal?: AbortSignal, + onUpdate?: any, + ctx?: any, + ) => { + const fresh = createEditTool(process.cwd()); + return fresh.execute(toolCallId, params, signal, onUpdate, ctx); + }, + }; + pi.registerTool(dynamicEdit as any); + // ── session_start: render branded GSD header + remote channel status ── pi.on("session_start", async (_event, ctx) => { const theme = ctx.ui.theme;