gsd-pi 0.2.9 → 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.
Files changed (42) hide show
  1. package/README.md +26 -1
  2. package/dist/cli.js +159 -10
  3. package/dist/tool-bootstrap.d.ts +4 -0
  4. package/dist/tool-bootstrap.js +74 -0
  5. package/dist/wizard.js +17 -6
  6. package/package.json +4 -2
  7. package/patches/@mariozechner+pi-coding-agent+0.57.1.patch +48 -0
  8. package/patches/@mariozechner+pi-tui+0.57.1.patch +47 -0
  9. package/scripts/postinstall.js +8 -0
  10. package/src/resources/extensions/bg-shell/index.ts +57 -8
  11. package/src/resources/extensions/browser-tools/index.ts +4 -1
  12. package/src/resources/extensions/github/gh-api.ts +46 -30
  13. package/src/resources/extensions/gsd/commands.ts +9 -3
  14. package/src/resources/extensions/gsd/dashboard-overlay.ts +6 -1
  15. package/src/resources/extensions/gsd/files.ts +7 -7
  16. package/src/resources/extensions/gsd/gitignore.ts +1 -0
  17. package/src/resources/extensions/gsd/guided-flow.ts +1 -1
  18. package/src/resources/extensions/gsd/index.ts +58 -1
  19. package/src/resources/extensions/gsd/migrate/command.ts +215 -0
  20. package/src/resources/extensions/gsd/migrate/index.ts +42 -0
  21. package/src/resources/extensions/gsd/migrate/parser.ts +323 -0
  22. package/src/resources/extensions/gsd/migrate/parsers.ts +624 -0
  23. package/src/resources/extensions/gsd/migrate/preview.ts +48 -0
  24. package/src/resources/extensions/gsd/migrate/transformer.ts +346 -0
  25. package/src/resources/extensions/gsd/migrate/types.ts +370 -0
  26. package/src/resources/extensions/gsd/migrate/validator.ts +53 -0
  27. package/src/resources/extensions/gsd/migrate/writer.ts +539 -0
  28. package/src/resources/extensions/gsd/prompts/discuss.md +3 -1
  29. package/src/resources/extensions/gsd/prompts/review-migration.md +66 -0
  30. package/src/resources/extensions/gsd/prompts/system.md +1 -1
  31. package/src/resources/extensions/gsd/prompts/worktree-merge.md +89 -0
  32. package/src/resources/extensions/gsd/tests/discuss-prompt.test.ts +38 -0
  33. package/src/resources/extensions/gsd/tests/migrate-command.test.ts +390 -0
  34. package/src/resources/extensions/gsd/tests/migrate-parser.test.ts +786 -0
  35. package/src/resources/extensions/gsd/tests/migrate-transformer.test.ts +657 -0
  36. package/src/resources/extensions/gsd/tests/migrate-validator-parsers.test.ts +443 -0
  37. package/src/resources/extensions/gsd/tests/migrate-writer-integration.test.ts +318 -0
  38. package/src/resources/extensions/gsd/tests/migrate-writer.test.ts +420 -0
  39. package/src/resources/extensions/gsd/tests/worktree-manager.test.ts +160 -0
  40. package/src/resources/extensions/gsd/worktree-command.ts +527 -0
  41. package/src/resources/extensions/gsd/worktree-manager.ts +302 -0
  42. package/src/resources/extensions/slash-commands/gsd-run.ts +1 -1
package/README.md CHANGED
@@ -46,6 +46,28 @@ GSD v2 solves all of these because it's not a prompt framework anymore — it's
46
46
  | Roadmap reassessment | Manual | Automatic after each slice completes |
47
47
  | Skill discovery | None | Auto-detect and install relevant skills during research |
48
48
 
49
+ ### Migrating from v1
50
+
51
+ If you have projects with `.planning` directories from the original Get Shit Done, you can migrate them to GSD-2's `.gsd` format:
52
+
53
+ ```bash
54
+ # From within the project directory
55
+ /gsd migrate
56
+
57
+ # Or specify a path
58
+ /gsd migrate ~/projects/my-old-project
59
+ ```
60
+
61
+ The migration tool:
62
+ - Parses your old `PROJECT.md`, `ROADMAP.md`, `REQUIREMENTS.md`, phase directories, plans, summaries, and research
63
+ - Maps phases → slices, plans → tasks, milestones → milestones
64
+ - Preserves completion state (`[x]` phases stay done, summaries carry over)
65
+ - Consolidates research files into the new structure
66
+ - Shows a preview before writing anything
67
+ - Optionally runs an agent-driven review of the output for quality assurance
68
+
69
+ Supports format variations including milestone-sectioned roadmaps with `<details>` blocks, bold phase entries, bullet-format requirements, decimal phase numbering, and duplicate phase numbers across milestones.
70
+
49
71
  ---
50
72
 
51
73
  ## How It Works
@@ -187,6 +209,7 @@ On first run, GSD prompts for optional API keys (Brave Search, Context7, Jina) f
187
209
  | `/gsd status` | Progress dashboard |
188
210
  | `/gsd queue` | Queue future milestones (safe during auto mode) |
189
211
  | `/gsd prefs` | Model selection, timeouts, budget ceiling |
212
+ | `/gsd migrate` | Migrate a v1 `.planning` directory to `.gsd` format |
190
213
  | `/gsd doctor` | Validate `.gsd/` integrity, find and fix issues |
191
214
  | `Ctrl+Alt+G` | Toggle dashboard overlay |
192
215
 
@@ -389,7 +412,9 @@ Use expensive models where quality matters (planning, complex execution) and che
389
412
 
390
413
  ## Star History
391
414
 
392
- [![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>
393
418
 
394
419
  ---
395
420
 
package/dist/cli.js CHANGED
@@ -1,24 +1,71 @@
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
- // Always ensure defaults: anthropic/claude-sonnet-4-6, thinking off.
11
- // Validates on every startup — catches stale settings from prior installs
56
+ // Validate configured model on startup — catches stale settings from prior installs
12
57
  // (e.g. grok-2 which no longer exists) and fresh installs with no settings.
58
+ // Only resets the default when the configured model no longer exists in the registry;
59
+ // never overwrites a valid user choice.
13
60
  const configuredProvider = settingsManager.getDefaultProvider();
14
61
  const configuredModel = settingsManager.getDefaultModel();
15
62
  const allModels = modelRegistry.getAll();
16
63
  const configuredExists = configuredProvider && configuredModel &&
17
64
  allModels.some((m) => m.provider === configuredProvider && m.id === configuredModel);
18
65
  if (!configuredModel || !configuredExists) {
19
- // Preferred default: anthropic/claude-sonnet-4-6
20
- const preferred = allModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-sonnet-4-6') ||
21
- allModels.find((m) => m.provider === 'anthropic' && m.id.includes('sonnet')) ||
66
+ // Fallback: pick the best available Anthropic model
67
+ const preferred = allModels.find((m) => m.provider === 'anthropic' && m.id === 'claude-opus-4-6') ||
68
+ allModels.find((m) => m.provider === 'anthropic' && m.id.includes('opus')) ||
22
69
  allModels.find((m) => m.provider === 'anthropic');
23
70
  if (preferred) {
24
71
  settingsManager.setDefaultModelAndProvider(preferred.provider, preferred.id);
@@ -36,9 +83,70 @@ if (!settingsManager.getQuietStartup()) {
36
83
  if (!settingsManager.getCollapseChangelog()) {
37
84
  settingsManager.setCollapseChangelog(true);
38
85
  }
39
- 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);
40
148
  initResources(agentDir);
41
- const resourceLoader = buildResourceLoader(agentDir);
149
+ const resourceLoader = new DefaultResourceLoader({ agentDir });
42
150
  await resourceLoader.reload();
43
151
  const { session, extensionsResult } = await createAgentSession({
44
152
  authStorage,
@@ -52,5 +160,46 @@ if (extensionsResult.errors.length > 0) {
52
160
  process.stderr.write(`[gsd] Extension load error: ${err.error}\n`);
53
161
  }
54
162
  }
163
+ // Restore scoped models from settings on startup.
164
+ // The upstream InteractiveMode reads enabledModels from settings when /scoped-models is opened,
165
+ // but doesn't apply them to the session at startup — so Ctrl+P cycles all models instead of
166
+ // just the saved selection until the user re-runs /scoped-models.
167
+ const enabledModelPatterns = settingsManager.getEnabledModels();
168
+ if (enabledModelPatterns && enabledModelPatterns.length > 0) {
169
+ const availableModels = modelRegistry.getAvailable();
170
+ const scopedModels = [];
171
+ const seen = new Set();
172
+ for (const pattern of enabledModelPatterns) {
173
+ // Patterns are "provider/modelId" exact strings saved by /scoped-models
174
+ const slashIdx = pattern.indexOf('/');
175
+ if (slashIdx !== -1) {
176
+ const provider = pattern.substring(0, slashIdx);
177
+ const modelId = pattern.substring(slashIdx + 1);
178
+ const model = availableModels.find((m) => m.provider === provider && m.id === modelId);
179
+ if (model) {
180
+ const key = `${model.provider}/${model.id}`;
181
+ if (!seen.has(key)) {
182
+ seen.add(key);
183
+ scopedModels.push({ model });
184
+ }
185
+ }
186
+ }
187
+ else {
188
+ // Fallback: match by model id alone
189
+ const model = availableModels.find((m) => m.id === pattern);
190
+ if (model) {
191
+ const key = `${model.provider}/${model.id}`;
192
+ if (!seen.has(key)) {
193
+ seen.add(key);
194
+ scopedModels.push({ model });
195
+ }
196
+ }
197
+ }
198
+ }
199
+ // Only apply if we resolved some models and it's a genuine subset
200
+ if (scopedModels.length > 0 && scopedModels.length < availableModels.length) {
201
+ session.setScopedModels(scopedModels);
202
+ }
203
+ }
55
204
  const interactiveMode = new InteractiveMode(session);
56
205
  await interactiveMode.run();
@@ -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);
@@ -75,7 +85,7 @@ export function loadStoredEnvKeys(authStorage) {
75
85
  for (const [provider, envVar] of providers) {
76
86
  if (!process.env[envVar]) {
77
87
  const cred = authStorage.get(provider);
78
- if (cred?.type === 'api_key') {
88
+ if (cred?.type === 'api_key' && cred.key) {
79
89
  process.env[envVar] = cred.key;
80
90
  }
81
91
  }
@@ -143,6 +153,7 @@ export async function runWizardIfNeeded(authStorage) {
143
153
  savedCount++;
144
154
  }
145
155
  else {
156
+ authStorage.set(key.provider, { type: 'api_key', key: '' });
146
157
  process.stdout.write(` ${dim}↷ ${key.label} skipped${reset}\n\n`);
147
158
  }
148
159
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "gsd-pi",
3
- "version": "0.2.9",
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 },