gsd-pi 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -412,7 +412,9 @@ Use expensive models where quality matters (planning, complex execution) and che
412
412
 
413
413
  ## Star History
414
414
 
415
- [![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)
415
+ <a href="https://star-history.com/#gsd-build/gsd-2&Date">
416
+ <img alt="Star History Chart" src="https://api.star-history.com/svg?repos=gsd-build/gsd-2&type=Date" />
417
+ </a>
416
418
 
417
419
  ---
418
420
 
package/dist/cli.js CHANGED
@@ -1,10 +1,56 @@
1
- import { AuthStorage, ModelRegistry, SettingsManager, SessionManager, createAgentSession, InteractiveMode, } from '@mariozechner/pi-coding-agent';
1
+ import { AuthStorage, DefaultResourceLoader, ModelRegistry, SettingsManager, SessionManager, createAgentSession, InteractiveMode, runPrintMode, } from '@mariozechner/pi-coding-agent';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
2
4
  import { agentDir, sessionsDir, authFilePath } from './app-paths.js';
3
- import { buildResourceLoader, initResources } from './resource-loader.js';
5
+ import { initResources } from './resource-loader.js';
6
+ import { ensureManagedTools } from './tool-bootstrap.js';
4
7
  import { loadStoredEnvKeys, runWizardIfNeeded } from './wizard.js';
8
+ function parseCliArgs(argv) {
9
+ const flags = { extensions: [], messages: [] };
10
+ const args = argv.slice(2); // skip node + script
11
+ for (let i = 0; i < args.length; i++) {
12
+ const arg = args[i];
13
+ if (arg === '--mode' && i + 1 < args.length) {
14
+ const m = args[++i];
15
+ if (m === 'text' || m === 'json' || m === 'rpc')
16
+ flags.mode = m;
17
+ }
18
+ else if (arg === '--print' || arg === '-p') {
19
+ flags.print = true;
20
+ }
21
+ else if (arg === '--no-session') {
22
+ flags.noSession = true;
23
+ }
24
+ else if (arg === '--model' && i + 1 < args.length) {
25
+ flags.model = args[++i];
26
+ }
27
+ else if (arg === '--extension' && i + 1 < args.length) {
28
+ flags.extensions.push(args[++i]);
29
+ }
30
+ else if (arg === '--append-system-prompt' && i + 1 < args.length) {
31
+ flags.appendSystemPrompt = args[++i];
32
+ }
33
+ else if (arg === '--tools' && i + 1 < args.length) {
34
+ flags.tools = args[++i].split(',');
35
+ }
36
+ else if (!arg.startsWith('--') && !arg.startsWith('-')) {
37
+ flags.messages.push(arg);
38
+ }
39
+ }
40
+ return flags;
41
+ }
42
+ const cliFlags = parseCliArgs(process.argv);
43
+ const isPrintMode = cliFlags.print || cliFlags.mode !== undefined;
44
+ // Pi's tool bootstrap can mis-detect already-installed fd/rg on some systems
45
+ // because spawnSync(..., ["--version"]) returns EPERM despite a zero exit code.
46
+ // Provision local managed binaries first so Pi sees them without probing PATH.
47
+ ensureManagedTools(join(agentDir, 'bin'));
5
48
  const authStorage = AuthStorage.create(authFilePath);
6
49
  loadStoredEnvKeys(authStorage);
7
- await runWizardIfNeeded(authStorage);
50
+ // Skip the setup wizard in print mode — it requires TTY interaction
51
+ if (!isPrintMode) {
52
+ await runWizardIfNeeded(authStorage);
53
+ }
8
54
  const modelRegistry = new ModelRegistry(authStorage);
9
55
  const settingsManager = SettingsManager.create(agentDir);
10
56
  // Validate configured model on startup — catches stale settings from prior installs
@@ -37,9 +83,70 @@ if (!settingsManager.getQuietStartup()) {
37
83
  if (!settingsManager.getCollapseChangelog()) {
38
84
  settingsManager.setCollapseChangelog(true);
39
85
  }
40
- const sessionManager = SessionManager.create(process.cwd(), sessionsDir);
86
+ // ---------------------------------------------------------------------------
87
+ // Print / subagent mode — single-shot execution, no TTY required
88
+ // ---------------------------------------------------------------------------
89
+ if (isPrintMode) {
90
+ const sessionManager = cliFlags.noSession
91
+ ? SessionManager.inMemory()
92
+ : SessionManager.create(process.cwd());
93
+ // Read --append-system-prompt file content (subagent writes agent system prompts to temp files)
94
+ let appendSystemPrompt;
95
+ if (cliFlags.appendSystemPrompt) {
96
+ try {
97
+ appendSystemPrompt = readFileSync(cliFlags.appendSystemPrompt, 'utf-8');
98
+ }
99
+ catch {
100
+ // If it's not a file path, treat it as literal text
101
+ appendSystemPrompt = cliFlags.appendSystemPrompt;
102
+ }
103
+ }
104
+ initResources(agentDir);
105
+ const resourceLoader = new DefaultResourceLoader({
106
+ agentDir,
107
+ additionalExtensionPaths: cliFlags.extensions.length > 0 ? cliFlags.extensions : undefined,
108
+ appendSystemPrompt,
109
+ });
110
+ await resourceLoader.reload();
111
+ const { session, extensionsResult } = await createAgentSession({
112
+ authStorage,
113
+ modelRegistry,
114
+ settingsManager,
115
+ sessionManager,
116
+ resourceLoader,
117
+ });
118
+ if (extensionsResult.errors.length > 0) {
119
+ for (const err of extensionsResult.errors) {
120
+ process.stderr.write(`[gsd] Extension load error: ${err.error}\n`);
121
+ }
122
+ }
123
+ // Apply --model override if specified
124
+ if (cliFlags.model) {
125
+ const available = modelRegistry.getAvailable();
126
+ const match = available.find((m) => m.id === cliFlags.model) ||
127
+ available.find((m) => `${m.provider}/${m.id}` === cliFlags.model);
128
+ if (match) {
129
+ session.setModel(match);
130
+ }
131
+ }
132
+ const mode = cliFlags.mode || 'text';
133
+ await runPrintMode(session, {
134
+ mode: mode === 'rpc' ? 'json' : mode,
135
+ messages: cliFlags.messages,
136
+ });
137
+ process.exit(0);
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // Interactive mode — normal TTY session
141
+ // ---------------------------------------------------------------------------
142
+ // Per-directory session storage — same encoding as the upstream SDK so that
143
+ // /resume only shows sessions from the current working directory.
144
+ const cwd = process.cwd();
145
+ const safePath = `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
146
+ const projectSessionsDir = join(sessionsDir, safePath);
147
+ const sessionManager = SessionManager.create(cwd, projectSessionsDir);
41
148
  initResources(agentDir);
42
- const resourceLoader = buildResourceLoader(agentDir);
149
+ const resourceLoader = new DefaultResourceLoader({ agentDir });
43
150
  await resourceLoader.reload();
44
151
  const { session, extensionsResult } = await createAgentSession({
45
152
  authStorage,
@@ -0,0 +1,4 @@
1
+ type ManagedTool = "fd" | "rg";
2
+ export declare function resolveToolFromPath(tool: ManagedTool, pathValue?: string | undefined): string | null;
3
+ export declare function ensureManagedTools(targetDir: string, pathValue?: string | undefined): string[];
4
+ export {};
@@ -0,0 +1,74 @@
1
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
2
+ import { delimiter, join } from "node:path";
3
+ const TOOL_SPECS = {
4
+ fd: {
5
+ targetName: process.platform === "win32" ? "fd.exe" : "fd",
6
+ candidates: process.platform === "win32" ? ["fd.exe", "fd", "fdfind.exe", "fdfind"] : ["fd", "fdfind"],
7
+ },
8
+ rg: {
9
+ targetName: process.platform === "win32" ? "rg.exe" : "rg",
10
+ candidates: process.platform === "win32" ? ["rg.exe", "rg"] : ["rg"],
11
+ },
12
+ };
13
+ function splitPath(pathValue) {
14
+ if (!pathValue)
15
+ return [];
16
+ return pathValue.split(delimiter).map((segment) => segment.trim()).filter(Boolean);
17
+ }
18
+ function getCandidateNames(name) {
19
+ if (process.platform !== "win32")
20
+ return [name];
21
+ const lower = name.toLowerCase();
22
+ if (lower.endsWith(".exe") || lower.endsWith(".cmd") || lower.endsWith(".bat"))
23
+ return [name];
24
+ return [name, `${name}.exe`, `${name}.cmd`, `${name}.bat`];
25
+ }
26
+ function isRegularFile(path) {
27
+ try {
28
+ return lstatSync(path).isFile() || lstatSync(path).isSymbolicLink();
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }
34
+ export function resolveToolFromPath(tool, pathValue = process.env.PATH) {
35
+ const spec = TOOL_SPECS[tool];
36
+ for (const dir of splitPath(pathValue)) {
37
+ for (const candidate of spec.candidates) {
38
+ for (const name of getCandidateNames(candidate)) {
39
+ const fullPath = join(dir, name);
40
+ if (existsSync(fullPath) && isRegularFile(fullPath)) {
41
+ return fullPath;
42
+ }
43
+ }
44
+ }
45
+ }
46
+ return null;
47
+ }
48
+ function provisionTool(targetDir, tool, sourcePath) {
49
+ const targetPath = join(targetDir, TOOL_SPECS[tool].targetName);
50
+ if (existsSync(targetPath))
51
+ return targetPath;
52
+ mkdirSync(targetDir, { recursive: true });
53
+ try {
54
+ symlinkSync(sourcePath, targetPath);
55
+ }
56
+ catch {
57
+ rmSync(targetPath, { force: true });
58
+ copyFileSync(sourcePath, targetPath);
59
+ chmodSync(targetPath, 0o755);
60
+ }
61
+ return targetPath;
62
+ }
63
+ export function ensureManagedTools(targetDir, pathValue = process.env.PATH) {
64
+ const provisioned = [];
65
+ for (const tool of Object.keys(TOOL_SPECS)) {
66
+ if (existsSync(join(targetDir, TOOL_SPECS[tool].targetName)))
67
+ continue;
68
+ const sourcePath = resolveToolFromPath(tool, pathValue);
69
+ if (!sourcePath)
70
+ continue;
71
+ provisioned.push(provisionTool(targetDir, tool, sourcePath));
72
+ }
73
+ return provisioned;
74
+ }
package/dist/wizard.js CHANGED
@@ -21,6 +21,18 @@ async function promptMasked(label, hint) {
21
21
  process.stdin.resume();
22
22
  process.stdin.setEncoding('utf8');
23
23
  let value = '';
24
+ const redraw = () => {
25
+ process.stdout.clearLine(0);
26
+ process.stdout.cursorTo(0);
27
+ if (value.length === 0) {
28
+ process.stdout.write(' ');
29
+ }
30
+ else {
31
+ const dots = '●'.repeat(Math.min(value.length, 24));
32
+ const counter = value.length > 24 ? ` ${dim}(${value.length})${reset}` : ` ${dim}${value.length}${reset}`;
33
+ process.stdout.write(` ${dots}${counter}`);
34
+ }
35
+ };
24
36
  const handler = (ch) => {
25
37
  if (ch === '\r' || ch === '\n') {
26
38
  process.stdin.setRawMode(false);
@@ -34,17 +46,15 @@ async function promptMasked(label, hint) {
34
46
  process.stdout.write('\n');
35
47
  process.exit(0);
36
48
  }
37
- else if (ch === '\u007f') {
49
+ else if (ch === '\u007f' || ch === '\b') {
38
50
  if (value.length > 0) {
39
51
  value = value.slice(0, -1);
40
52
  }
41
- process.stdout.clearLine(0);
42
- process.stdout.cursorTo(0);
43
- process.stdout.write(' ' + '*'.repeat(value.length));
53
+ redraw();
44
54
  }
45
55
  else {
46
56
  value += ch;
47
- process.stdout.write('*');
57
+ redraw();
48
58
  }
49
59
  };
50
60
  process.stdin.on('data', handler);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "0.3.0",
4
- "description": "GSD — Get Stuff Done coding agent",
3
+ "version": "0.3.1",
4
+ "description": "GSD — Get Shit Done coding agent",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -18,6 +18,7 @@
18
18
  },
19
19
  "files": [
20
20
  "dist",
21
+ "patches",
21
22
  "pkg",
22
23
  "src/resources",
23
24
  "scripts/postinstall.js",
@@ -46,6 +47,7 @@
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^22.0.0",
50
+ "patch-package": "^8.0.1",
49
51
  "typescript": "^5.4.0"
50
52
  },
51
53
  "overrides": {
@@ -0,0 +1,48 @@
1
+ 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
2
+ index 27fe820..68f277f 100644
3
+ --- a/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js
4
+ +++ b/node_modules/@mariozechner/pi-coding-agent/dist/core/tools/bash.js
5
+ @@ -1,11 +1,35 @@
6
+ import { randomBytes } from "node:crypto";
7
+ import { createWriteStream, existsSync } from "node:fs";
8
+ +import { createRequire } from "node:module";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { Type } from "@sinclair/typebox";
12
+ import { spawn } from "child_process";
13
+ import { getShellConfig, getShellEnv, killProcessTree } from "../../utils/shell.js";
14
+ import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize, truncateTail } from "./truncate.js";
15
+ +// Cached Win32 FFI handles for restoring VT input after child processes
16
+ +let _vtHandles = null;
17
+ +function restoreWindowsVTInput() {
18
+ + if (process.platform !== "win32") return;
19
+ + try {
20
+ + if (!_vtHandles) {
21
+ + const cjsRequire = createRequire(import.meta.url);
22
+ + const koffi = cjsRequire("koffi");
23
+ + const k32 = koffi.load("kernel32.dll");
24
+ + const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)");
25
+ + const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)");
26
+ + const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)");
27
+ + const handle = GetStdHandle(-10);
28
+ + _vtHandles = { GetConsoleMode, SetConsoleMode, handle };
29
+ + }
30
+ + const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
31
+ + const mode = new Uint32Array(1);
32
+ + _vtHandles.GetConsoleMode(_vtHandles.handle, mode);
33
+ + if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) {
34
+ + _vtHandles.SetConsoleMode(_vtHandles.handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT);
35
+ + }
36
+ + } catch { }
37
+ +}
38
+ /**
39
+ * Generate a unique temp file path for bash output
40
+ */
41
+ @@ -76,6 +100,7 @@ const defaultBashOperations = {
42
+ }
43
+ // Handle process exit
44
+ child.on("close", (code) => {
45
+ + restoreWindowsVTInput();
46
+ if (timeoutHandle)
47
+ clearTimeout(timeoutHandle);
48
+ if (signal)
@@ -0,0 +1,47 @@
1
+ diff --git a/node_modules/@mariozechner/pi-tui/dist/terminal.js b/node_modules/@mariozechner/pi-tui/dist/terminal.js
2
+ index cd20330..e836fcd 100644
3
+ --- a/node_modules/@mariozechner/pi-tui/dist/terminal.js
4
+ +++ b/node_modules/@mariozechner/pi-tui/dist/terminal.js
5
+ @@ -7,6 +7,7 @@ const cjsRequire = createRequire(import.meta.url);
6
+ * Real terminal using process.stdin/stdout
7
+ */
8
+ export class ProcessTerminal {
9
+ + static _vtHandles = null;
10
+ wasRaw = false;
11
+ inputHandler;
12
+ resizeHandler;
13
+ @@ -126,20 +127,23 @@ export class ProcessTerminal {
14
+ if (process.platform !== "win32")
15
+ return;
16
+ try {
17
+ - // Dynamic require to avoid bundling koffi's 74MB of cross-platform
18
+ - // native binaries into every compiled binary. Koffi is only needed
19
+ - // on Windows for VT input support.
20
+ - const koffi = cjsRequire("koffi");
21
+ - const k32 = koffi.load("kernel32.dll");
22
+ - const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)");
23
+ - const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)");
24
+ - const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)");
25
+ - const STD_INPUT_HANDLE = -10;
26
+ + if (!ProcessTerminal._vtHandles) {
27
+ + const koffi = cjsRequire("koffi");
28
+ + const k32 = koffi.load("kernel32.dll");
29
+ + const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)");
30
+ + const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)");
31
+ + const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)");
32
+ + const STD_INPUT_HANDLE = -10;
33
+ + const handle = GetStdHandle(STD_INPUT_HANDLE);
34
+ + ProcessTerminal._vtHandles = { GetConsoleMode, SetConsoleMode, handle };
35
+ + }
36
+ const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
37
+ - const handle = GetStdHandle(STD_INPUT_HANDLE);
38
+ + const { GetConsoleMode, SetConsoleMode, handle } = ProcessTerminal._vtHandles;
39
+ const mode = new Uint32Array(1);
40
+ GetConsoleMode(handle, mode);
41
+ - SetConsoleMode(handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT);
42
+ + if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) {
43
+ + SetConsoleMode(handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT);
44
+ + }
45
+ }
46
+ catch {
47
+ // koffi not available — Shift+Tab won't be distinguishable from Tab
@@ -35,6 +35,14 @@ const banner =
35
35
 
36
36
  process.stderr.write(banner)
37
37
 
38
+ // Apply patches to upstream dependencies (non-fatal)
39
+ try {
40
+ execSync('npx patch-package', { stdio: 'inherit', cwd: resolve(__dirname, '..') })
41
+ process.stderr.write(`\n ${green}✓${reset} Patches applied\n`)
42
+ } catch {
43
+ process.stderr.write(`\n ${yellow}⚠${reset} Failed to apply patches — run ${cyan}npx patch-package${reset} manually\n`)
44
+ }
45
+
38
46
  // Install Playwright chromium for browser tools (non-fatal)
39
47
  const args = os.platform() === 'linux' ? '--with-deps' : ''
40
48
  try {
@@ -33,6 +33,7 @@ import {
33
33
  truncateHead,
34
34
  DEFAULT_MAX_BYTES,
35
35
  DEFAULT_MAX_LINES,
36
+ getShellConfig,
36
37
  } from "@mariozechner/pi-coding-agent";
37
38
  import {
38
39
  Text,
@@ -42,11 +43,39 @@ import {
42
43
  Key,
43
44
  } from "@mariozechner/pi-tui";
44
45
  import { Type } from "@sinclair/typebox";
45
- import { spawn, type ChildProcess } from "node:child_process";
46
+ import { spawn, spawnSync, type ChildProcess } from "node:child_process";
46
47
  import { createConnection } from "node:net";
47
48
  import { randomUUID } from "node:crypto";
48
49
  import { writeFileSync, readFileSync, existsSync, mkdirSync } from "node:fs";
49
50
  import { join } from "node:path";
51
+ import { createRequire } from "node:module";
52
+
53
+ // ── Windows VT Input Restoration ────────────────────────────────────────────
54
+ // Child processes (esp. Git Bash / MSYS2) can strip the ENABLE_VIRTUAL_TERMINAL_INPUT
55
+ // flag from the shared stdin console handle. Re-enable it after each child exits.
56
+
57
+ let _vtHandles: { GetConsoleMode: Function; SetConsoleMode: Function; handle: unknown } | null = null;
58
+ function restoreWindowsVTInput(): void {
59
+ if (process.platform !== "win32") return;
60
+ try {
61
+ if (!_vtHandles) {
62
+ const cjsRequire = createRequire(import.meta.url);
63
+ const koffi = cjsRequire("koffi");
64
+ const k32 = koffi.load("kernel32.dll");
65
+ const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)");
66
+ const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)");
67
+ const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)");
68
+ const handle = GetStdHandle(-10);
69
+ _vtHandles = { GetConsoleMode, SetConsoleMode, handle };
70
+ }
71
+ const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
72
+ const mode = new Uint32Array(1);
73
+ _vtHandles.GetConsoleMode(_vtHandles.handle, mode);
74
+ if (!(mode[0] & ENABLE_VIRTUAL_TERMINAL_INPUT)) {
75
+ _vtHandles.SetConsoleMode(_vtHandles.handle, mode[0] | ENABLE_VIRTUAL_TERMINAL_INPUT);
76
+ }
77
+ } catch { /* koffi not available on non-Windows */ }
78
+ }
50
79
 
51
80
  // ── Types ──────────────────────────────────────────────────────────────────
52
81
 
@@ -551,11 +580,12 @@ function startProcess(opts: StartOptions): BgProcess {
551
580
 
552
581
  const env = { ...process.env, ...(opts.env || {}) };
553
582
 
554
- const proc = spawn("bash", ["-c", opts.command], {
583
+ const { shell, args: shellArgs } = getShellConfig();
584
+ const proc = spawn(shell, [...shellArgs, opts.command], {
555
585
  cwd: opts.cwd,
556
586
  stdio: ["pipe", "pipe", "pipe"],
557
587
  env,
558
- detached: true,
588
+ detached: process.platform !== "win32",
559
589
  });
560
590
 
561
591
  const bg: BgProcess = {
@@ -621,6 +651,7 @@ function startProcess(opts: StartOptions): BgProcess {
621
651
  });
622
652
 
623
653
  proc.on("exit", (code, sig) => {
654
+ restoreWindowsVTInput();
624
655
  bg.alive = false;
625
656
  bg.exitCode = code;
626
657
  bg.signal = sig ?? null;
@@ -686,14 +717,32 @@ function killProcess(id: string, sig: NodeJS.Signals = "SIGTERM"): boolean {
686
717
  if (!bg) return false;
687
718
  if (!bg.alive) return true;
688
719
  try {
689
- if (bg.proc.pid) {
690
- try {
691
- process.kill(-bg.proc.pid, sig);
692
- } catch {
720
+ if (process.platform === "win32") {
721
+ // Windows: use taskkill /F /T to force-kill the entire process tree.
722
+ // process.kill(-pid) (Unix process groups) does not work on Windows.
723
+ if (bg.proc.pid) {
724
+ const result = spawnSync("taskkill", ["/F", "/T", "/PID", String(bg.proc.pid)], {
725
+ timeout: 5000,
726
+ encoding: "utf-8",
727
+ });
728
+ if (result.status !== 0 && result.status !== 128) {
729
+ // taskkill failed — try the direct kill as fallback
730
+ bg.proc.kill(sig);
731
+ }
732
+ } else {
693
733
  bg.proc.kill(sig);
694
734
  }
695
735
  } else {
696
- bg.proc.kill(sig);
736
+ // Unix/macOS: kill the process group via negative PID
737
+ if (bg.proc.pid) {
738
+ try {
739
+ process.kill(-bg.proc.pid, sig);
740
+ } catch {
741
+ bg.proc.kill(sig);
742
+ }
743
+ } else {
744
+ bg.proc.kill(sig);
745
+ }
697
746
  }
698
747
  return true;
699
748
  } catch {
@@ -343,7 +343,10 @@ async function ensureBrowser(): Promise<{ browser: Browser; context: BrowserCont
343
343
  // Lazy import so playwright is only loaded when actually needed
344
344
  const { chromium } = await import("playwright");
345
345
 
346
- browser = await chromium.launch({ headless: false });
346
+ const launchOptions: Record<string, unknown> = { headless: false };
347
+ const customPath = process.env.BROWSER_PATH;
348
+ if (customPath) launchOptions.executablePath = customPath;
349
+ browser = await chromium.launch(launchOptions);
347
350
  context = await browser.newContext({
348
351
  deviceScaleFactor: 2,
349
352
  viewport: { width: 1280, height: 800 },
@@ -6,20 +6,44 @@
6
6
  * Falls back to raw REST API with GITHUB_TOKEN env var.
7
7
  */
8
8
 
9
- import { execSync } from "node:child_process";
9
+ import { execSync, spawnSync, type SpawnSyncReturns } from "node:child_process";
10
10
 
11
11
  // ─── Auth detection ───────────────────────────────────────────────────────────
12
12
 
13
13
  let _useGhCli: boolean | null = null;
14
14
 
15
- function hasGhCli(): boolean {
15
+ let ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns<string> =>
16
+ spawnSync("gh", args, {
17
+ cwd,
18
+ encoding: "utf8",
19
+ stdio: ["pipe", "pipe", "pipe"],
20
+ input,
21
+ });
22
+
23
+ function ghSpawn(args: string[], input?: string, cwd?: string): SpawnSyncReturns<string> {
24
+ return ghSpawnImpl(args, input, cwd);
25
+ }
26
+
27
+ export function resetGhCliDetectionForTests(): void {
28
+ _useGhCli = null;
29
+ ghSpawnImpl = (args: string[], input?: string, cwd?: string): SpawnSyncReturns<string> =>
30
+ spawnSync("gh", args, {
31
+ cwd,
32
+ encoding: "utf8",
33
+ stdio: ["pipe", "pipe", "pipe"],
34
+ input,
35
+ });
36
+ }
37
+
38
+ export function setGhSpawnForTests(fn: (args: string[], input?: string, cwd?: string) => SpawnSyncReturns<string>): void {
39
+ ghSpawnImpl = fn;
40
+ _useGhCli = null;
41
+ }
42
+
43
+ export function hasGhCli(): boolean {
16
44
  if (_useGhCli !== null) return _useGhCli;
17
- try {
18
- execSync("gh auth status", { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] });
19
- _useGhCli = true;
20
- } catch {
21
- _useGhCli = false;
22
- }
45
+ const result = ghSpawn(["auth", "token"]);
46
+ _useGhCli = result.status === 0 && !result.error && !!result.stdout?.trim();
23
47
  return _useGhCli;
24
48
  }
25
49
 
@@ -120,11 +144,6 @@ export async function ghApi<T = unknown>(
120
144
  return fetchApi<T>(endpoint, method, options.params, options.body, token);
121
145
  }
122
146
 
123
- function shellEscape(s: string): string {
124
- // Single-quote wrapping, escaping any existing single quotes
125
- return "'" + s.replace(/'/g, "'\\''") + "'";
126
- }
127
-
128
147
  function ghCliApi<T>(
129
148
  endpoint: string,
130
149
  method: string,
@@ -132,39 +151,36 @@ function ghCliApi<T>(
132
151
  body?: Record<string, unknown>,
133
152
  cwd?: string,
134
153
  ): T {
135
- const parts = ["gh", "api", shellEscape(endpoint), "--method", method];
154
+ const args = ["api", endpoint, "--method", method];
136
155
 
137
156
  if (params) {
138
157
  for (const [key, val] of Object.entries(params)) {
139
158
  if (val === undefined) continue;
140
159
  if (Array.isArray(val)) {
141
160
  for (const v of val) {
142
- parts.push("-f", shellEscape(`${key}[]=${v}`));
161
+ args.push("-f", `${key}[]=${v}`);
143
162
  }
144
163
  } else {
145
- parts.push("-f", shellEscape(`${key}=${String(val)}`));
164
+ args.push("-f", `${key}=${String(val)}`);
146
165
  }
147
166
  }
148
167
  }
149
168
 
150
169
  if (body) {
151
- parts.push("--input", "-");
170
+ args.push("--input", "-");
152
171
  }
153
172
 
154
- try {
155
- const result = execSync(parts.join(" "), {
156
- cwd: cwd ?? process.cwd(),
157
- encoding: "utf8",
158
- stdio: ["pipe", "pipe", "pipe"],
159
- input: body ? JSON.stringify(body) : undefined,
160
- });
161
- if (!result.trim()) return {} as T;
162
- return JSON.parse(result) as T;
163
- } catch (e: unknown) {
164
- const err = e as { stderr?: string; stdout?: string; message?: string };
165
- const msg = err.stderr?.trim() || err.stdout?.trim() || err.message || String(e);
166
- throw new Error(`gh api error: ${msg}`);
173
+ const result = ghSpawn(args, body ? JSON.stringify(body) : undefined, cwd ?? process.cwd());
174
+
175
+ const stdout = result.stdout?.trim() ?? "";
176
+ const stderr = result.stderr?.trim() ?? "";
177
+
178
+ if (result.status !== 0) {
179
+ throw new Error(`gh api error: ${stderr || stdout || result.error?.message || `exit code ${result.status}`}`);
167
180
  }
181
+
182
+ if (!stdout) return {} as T;
183
+ return JSON.parse(stdout) as T;
168
184
  }
169
185
 
170
186
  async function fetchApi<T>(
@@ -52,7 +52,7 @@ function dispatchDoctorHeal(pi: ExtensionAPI, scope: string | undefined, reportT
52
52
 
53
53
  export function registerGSDCommand(pi: ExtensionAPI): void {
54
54
  pi.registerCommand("gsd", {
55
- description: "GSD — Get Stuff Done: /gsd auto|stop|status|queue|prefs|doctor|migrate",
55
+ description: "GSD — Get Shit Done: /gsd auto|stop|status|queue|prefs|doctor|migrate",
56
56
 
57
57
  getArgumentCompletions: (prefix: string) => {
58
58
  const subcommands = ["auto", "stop", "status", "queue", "discuss", "prefs", "doctor", "migrate"];
@@ -508,7 +508,7 @@ export async function showSmartEntry(
508
508
  ));
509
509
  } else {
510
510
  const choice = await showNextAction(ctx as any, {
511
- title: "GSD — Get Stuff Done",
511
+ title: "GSD — Get Shit Done",
512
512
  summary: ["No active milestone."],
513
513
  actions: [
514
514
  {
@@ -63,13 +63,35 @@ export default function (pi: ExtensionAPI) {
63
63
  registerGSDCommand(pi);
64
64
  registerWorktreeCommand(pi);
65
65
 
66
- // ── Dynamic-cwd bash tool ──────────────────────────────────────────────
66
+ // ── Dynamic-cwd bash tool with default timeout ────────────────────────
67
67
  // The built-in bash tool captures cwd at startup. This replacement uses
68
68
  // a spawnHook to read process.cwd() dynamically so that process.chdir()
69
69
  // (used by /worktree switch) propagates to shell commands.
70
- const dynamicBash = createBashTool(process.cwd(), {
70
+ //
71
+ // The upstream SDK's bash tool has no default timeout — if the LLM omits
72
+ // the timeout parameter, commands run indefinitely, causing hangs on
73
+ // Windows where process killing is unreliable (see #40). We wrap execute
74
+ // to inject a 120-second default when no timeout is provided.
75
+ const DEFAULT_BASH_TIMEOUT_SECS = 120;
76
+ const baseBash = createBashTool(process.cwd(), {
71
77
  spawnHook: (ctx) => ({ ...ctx, cwd: process.cwd() }),
72
78
  });
79
+ const dynamicBash = {
80
+ ...baseBash,
81
+ execute: async (
82
+ toolCallId: string,
83
+ params: { command: string; timeout?: number },
84
+ signal?: AbortSignal,
85
+ onUpdate?: any,
86
+ ctx?: any,
87
+ ) => {
88
+ const paramsWithTimeout = {
89
+ ...params,
90
+ timeout: params.timeout ?? DEFAULT_BASH_TIMEOUT_SECS,
91
+ };
92
+ return baseBash.execute(toolCallId, paramsWithTimeout, signal, onUpdate, ctx);
93
+ },
94
+ };
73
95
  pi.registerTool(dynamicBash as any);
74
96
 
75
97
  // ── session_start: render branded GSD header ───────────────────────────
@@ -1,6 +1,8 @@
1
1
  {{preamble}}
2
2
 
3
- Say exactly: "What's the vision?" nothing else. Wait for the user's answer.
3
+ Ask: "What's the vision?" once, and then use whatever the user replies with as the vision input to continue.
4
+
5
+ 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?".
4
6
 
5
7
  ## Discussion Phase
6
8
 
@@ -1,4 +1,4 @@
1
- ## GSD — Get Stuff Done
1
+ ## GSD — Get Shit Done
2
2
 
3
3
  You are **GSD** — a coding agent that gets shit done.
4
4
 
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ let passed = 0;
5
+ let failed = 0;
6
+
7
+ function assert(condition: boolean, message: string): void {
8
+ if (condition) passed++;
9
+ else {
10
+ failed++;
11
+ console.error(` FAIL: ${message}`);
12
+ }
13
+ }
14
+
15
+ const promptPath = join(process.cwd(), 'src/resources/extensions/gsd/prompts/discuss.md');
16
+ const discussPrompt = readFileSync(promptPath, 'utf-8');
17
+
18
+ console.log('\n=== discuss prompt: resilient vision framing ===');
19
+ {
20
+ const hardenedPattern = /Say exactly:\s*"What's the vision\?"/;
21
+ assert(!hardenedPattern.test(discussPrompt), 'prompt no longer uses exact-verbosity lock');
22
+ assert(
23
+ discussPrompt.includes('Ask: "What\'s the vision?" once'),
24
+ 'prompt asks for vision exactly once',
25
+ );
26
+ assert(
27
+ discussPrompt.includes('Special handling'),
28
+ 'prompt documents special handling for non-vision user messages',
29
+ );
30
+ assert(
31
+ discussPrompt.includes('instead of repeating "What\'s the vision?"'),
32
+ 'prompt forbids repeating the vision question',
33
+ );
34
+ }
35
+
36
+ console.log(`\nResults: ${passed} passed, ${failed} failed`);
37
+ if (failed > 0) process.exit(1);
38
+ console.log('All tests passed ✓');
@@ -6,7 +6,7 @@ export default function gsdRun(pi: ExtensionAPI) {
6
6
  pi.registerCommand("gsd-run", {
7
7
  description: "Read GSD-WORKFLOW.md and execute — lightweight protocol-driven GSD",
8
8
  async handler(args: string, ctx: ExtensionCommandContext) {
9
- const workflowPath = join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
9
+ const workflowPath = process.env.GSD_WORKFLOW_PATH ?? join(process.env.HOME ?? "~", ".pi", "GSD-WORKFLOW.md");
10
10
 
11
11
  let workflow: string;
12
12
  try {