kaizen-agent 1.41.2 → 1.42.0

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/cli/conpty.ts ADDED
@@ -0,0 +1,99 @@
1
+ // Windows has no Bun.Terminal, so the in-card terminal there runs through a small
2
+ // ConPTY host: PowerShell compiles the C# below with Add-Type, starts the command in
3
+ // a pseudo console, and bridges it to plain pipes. Its stdout is the screen, its
4
+ // stdin the keys. A resize travels in-band on stdin as ESC ] kzr;<cols>;<rows> BEL,
5
+ // a sequence no key produces, and is taken out before the rest reaches the console.
6
+ const HOST = String.raw`
7
+ using System; using System.IO; using System.Threading; using System.Runtime.InteropServices;
8
+ public static class KzPty {
9
+ [StructLayout(LayoutKind.Sequential)] struct COORD { public short X, Y; }
10
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct STARTUPINFO {
11
+ public int cb; public string r, d, t; public int x, y, w, h, cx, cy, fa, fl; public short sw, r2; public IntPtr r3, i, o, e; }
12
+ [StructLayout(LayoutKind.Sequential)] struct STARTUPINFOEX { public STARTUPINFO si; public IntPtr attrs; }
13
+ [StructLayout(LayoutKind.Sequential)] struct PI { public IntPtr hp, ht; public int pid, tid; }
14
+ [DllImport("kernel32.dll", SetLastError = true)] static extern bool CreatePipe(out IntPtr r, out IntPtr w, IntPtr a, int s);
15
+ [DllImport("kernel32.dll")] static extern int CreatePseudoConsole(COORD c, IntPtr i, IntPtr o, uint f, out IntPtr h);
16
+ [DllImport("kernel32.dll")] static extern int ResizePseudoConsole(IntPtr h, COORD c);
17
+ [DllImport("kernel32.dll")] static extern void ClosePseudoConsole(IntPtr h);
18
+ [DllImport("kernel32.dll", SetLastError = true)] static extern bool InitializeProcThreadAttributeList(IntPtr l, int n, int f, ref IntPtr s);
19
+ [DllImport("kernel32.dll", SetLastError = true)] static extern bool UpdateProcThreadAttribute(IntPtr l, uint f, IntPtr a, IntPtr v, IntPtr s, IntPtr p, IntPtr r);
20
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool CreateProcessW(string app, string cmd, IntPtr pa, IntPtr ta, bool inh, uint f, IntPtr env, string cwd, ref STARTUPINFOEX si, out PI pi);
21
+ [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr h, uint ms);
22
+ [DllImport("kernel32.dll")] static extern bool GetExitCodeProcess(IntPtr h, out int c);
23
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
24
+ static COORD Size(int c, int r) { COORD s; s.X = (short)c; s.Y = (short)r; return s; }
25
+ static FileStream Of(IntPtr h, FileAccess a) { return new FileStream(new Microsoft.Win32.SafeHandles.SafeFileHandle(h, true), a); }
26
+ public static int Run(string cmd, int cols, int rows) {
27
+ IntPtr inR, inW, outR, outW, hpc;
28
+ if (!CreatePipe(out inR, out inW, IntPtr.Zero, 0) || !CreatePipe(out outR, out outW, IntPtr.Zero, 0)) return 1;
29
+ int hr = CreatePseudoConsole(Size(cols, rows), inR, outW, 0, out hpc);
30
+ if (hr != 0) { Console.Error.WriteLine("CreatePseudoConsole failed: " + hr); return 1; }
31
+ IntPtr sz = IntPtr.Zero;
32
+ InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref sz);
33
+ var si = new STARTUPINFOEX(); si.si.cb = Marshal.SizeOf(typeof(STARTUPINFOEX));
34
+ // STARTF_USESTDHANDLES with none: otherwise the child takes this host's piped stdio, not the console.
35
+ si.si.fl = 0x100;
36
+ si.attrs = Marshal.AllocHGlobal(sz);
37
+ if (!InitializeProcThreadAttributeList(si.attrs, 1, 0, ref sz)
38
+ || !UpdateProcThreadAttribute(si.attrs, 0, (IntPtr)0x20016, hpc, (IntPtr)IntPtr.Size, IntPtr.Zero, IntPtr.Zero)) return 1;
39
+ PI pi;
40
+ if (!CreateProcessW(null, cmd, IntPtr.Zero, IntPtr.Zero, false, 0x80000, IntPtr.Zero, null, ref si, out pi)) {
41
+ Console.Error.WriteLine("CreateProcess failed: " + Marshal.GetLastWin32Error()); return 1; }
42
+ // The console holds its own copies; ours would keep the pipes open past its end.
43
+ CloseHandle(inR); CloseHandle(outW);
44
+ var input = Of(inW, FileAccess.Write); var output = Of(outR, FileAccess.Read);
45
+ var stdout = Console.OpenStandardOutput();
46
+ var pump = new Thread(() => {
47
+ var b = new byte[16384]; int n;
48
+ try { while ((n = output.Read(b, 0, b.Length)) > 0) { stdout.Write(b, 0, n); stdout.Flush(); } } catch { }
49
+ });
50
+ pump.IsBackground = true; pump.Start();
51
+ var keys = new Thread(() => {
52
+ var stdin = Console.OpenStandardInput(); var b = new byte[4096]; int n;
53
+ var held = new MemoryStream();
54
+ try {
55
+ while ((n = stdin.Read(b, 0, b.Length)) > 0) {
56
+ // ponytail: a UTF-8 character split across two reads decodes as U+FFFD; hold incomplete tails if that shows up.
57
+ held.Write(b, 0, n);
58
+ var s = System.Text.Encoding.UTF8.GetString(held.ToArray());
59
+ // A resize cut in half by the pipe waits for its other half.
60
+ int keep = s.LastIndexOf("\x1b]kzr;");
61
+ if (keep >= 0 && s.IndexOf('\x07', keep) < 0) { held = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(s.Substring(keep))); s = s.Substring(0, keep); }
62
+ else held = new MemoryStream();
63
+ s = System.Text.RegularExpressions.Regex.Replace(s, "\x1b]kzr;(\\d+);(\\d+)\x07", m => {
64
+ ResizePseudoConsole(hpc, Size(int.Parse(m.Groups[1].Value), int.Parse(m.Groups[2].Value))); return ""; });
65
+ var o = System.Text.Encoding.UTF8.GetBytes(s);
66
+ if (o.Length > 0) { input.Write(o, 0, o.Length); input.Flush(); }
67
+ }
68
+ } catch { }
69
+ });
70
+ keys.IsBackground = true; keys.Start();
71
+ WaitForSingleObject(pi.hp, 0xFFFFFFFF);
72
+ int code; GetExitCodeProcess(pi.hp, out code);
73
+ // Closing the console flushes its last frame into the pump, then ends it.
74
+ ClosePseudoConsole(hpc);
75
+ pump.Join(2000);
76
+ return code;
77
+ }
78
+ }`;
79
+
80
+ // The PowerShell that hosts it: the child's command line, size and directory come in
81
+ // through the environment, so nothing the agent is given passes through a quoting layer.
82
+ const SCRIPT = `Add-Type -TypeDefinition @'
83
+ ${HOST}
84
+ '@
85
+ Set-Location -LiteralPath $env:KZ_PTY_CWD
86
+ exit [KzPty]::Run($env:KZ_PTY_CMD, [int]$env:KZ_PTY_COLS, [int]$env:KZ_PTY_ROWS)`;
87
+
88
+ export const conptyHost = () => Buffer.from(SCRIPT, "utf16le").toString("base64");
89
+
90
+ export const resizeSeq = (cols: number, rows: number) => `\x1b]kzr;${cols};${rows}\x07`;
91
+
92
+ // The command line the pseudo console starts: PowerShell running cmd. It finds .cmd
93
+ // shims (npm installs claude as one), which CreateProcess alone does not, and takes
94
+ // the arguments as -EncodedCommand, as findTerminal does, so newlines survive.
95
+ export function innerCommand(shell: string, cmd: string[]): string {
96
+ const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
97
+ const enc = Buffer.from(`& ${cmd.map(q).join(" ")}`, "utf16le").toString("base64");
98
+ return `"${shell}" -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${enc}`;
99
+ }
package/cli/install.ts CHANGED
@@ -540,6 +540,15 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
540
540
  }
541
541
  }
542
542
 
543
+ // A board on Windows hands its Update here and exits: Windows will not rename
544
+ // ~/kaizen while that board holds files in it. Wait for it to go, and bring a board
545
+ // back on its port however the upgrade ends (finish()).
546
+ const argAt = (flag: string) => { const i = Bun.argv.indexOf(flag); return i === -1 ? NaN : Number(Bun.argv[i + 1]); };
547
+ if (upgrade && argAt("--wait") > 0) {
548
+ for (let i = 0; i < 100; i++) {
549
+ try { process.kill(argAt("--wait"), 0); await Bun.sleep(200); } catch { break; }
550
+ }
551
+ }
543
552
  if (upgrade) await clearBunxCache();
544
553
 
545
554
  // Upgrade means the newest release on npm, whatever the install on disk is: a clone
@@ -551,16 +560,16 @@ if (upgrade) {
551
560
  const why = await devCheckout(dest);
552
561
  if (why) {
553
562
  console.log(`\n ${c.bold("Not upgraded")} ${c.dim(`${tilde(dest)} is ${why}; update it with git`)}\n`);
554
- process.exit(1);
563
+ finish(1);
555
564
  }
556
565
  const latest = await npmLatest();
557
566
  if (!latest) {
558
567
  console.log(`\n ${c.bold("Not upgraded")} ${c.dim(`could not reach npm (kaizen ${now})`)}\n`);
559
- process.exit(1);
568
+ finish(1);
560
569
  }
561
570
  if (latest === now) {
562
571
  console.log(`\n ${c.bold("Up to date")}${c.dim(` — kaizen ${now}`)}\n`);
563
- process.exit(0);
572
+ finish(0);
564
573
  }
565
574
  step(`fetching kaizen ${latest} from npm`);
566
575
  // --force so a stale package manifest cannot resolve the version away, and the
@@ -578,10 +587,10 @@ if (upgrade) {
578
587
  console.log(`\n ${c.bold(`could not install kaizen ${latest}`)}`);
579
588
  // npm names a new version before it serves it (its review takes minutes).
580
589
  console.log(` ${c.dim("npm may still be publishing it: try again in a few minutes, or run")} ${c.cyan(`bunx kaizen-agent@${latest}`)}\n`);
581
- process.exit(1);
590
+ finish(1);
582
591
  }
583
592
  console.log(`\n ${c.bold("Upgraded")} ${c.dim(now)} → ${c.green(after)}\n ${c.dim("Restart Claude Code to pick up any new slash commands.")}\n`);
584
- process.exit(0);
593
+ finish(0);
585
594
  }
586
595
  if (!check && !upgrade) await welcome();
587
596
 
@@ -1065,6 +1074,16 @@ if (blocked) process.exit(1);
1065
1074
 
1066
1075
  // ---------------------------------------------------------------- helpers
1067
1076
 
1077
+ function finish(code: number): never {
1078
+ const port = argAt("--restart");
1079
+ if (port > 0) {
1080
+ Bun.spawn([process.execPath, join(installDir(), "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], {
1081
+ cwd: home, detached: true, stdio: ["ignore", "ignore", "ignore"], windowsHide: true,
1082
+ }).unref();
1083
+ }
1084
+ process.exit(code);
1085
+ }
1086
+
1068
1087
  function step(msg: string) { console.log(` ${c.green("+")} ${msg}`); }
1069
1088
 
1070
1089
  // Runs the upgrade and reports what it actually did, version to version, so the
package/cli/pty.test.ts CHANGED
@@ -5,7 +5,8 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, realpa
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { launchMode, closeSessions, readSessions, sweepSessions, CLOSE_GRACE_MS } from "./state.ts";
8
- import { loginEnv, startPty, writePty } from "./pty.ts";
8
+ import { loginEnv, startPty, writePty, attachPty, ptyState } from "./pty.ts";
9
+ import { conptyHost, innerCommand, resizeSeq } from "./conpty.ts";
9
10
 
10
11
  const root = realpathSync(mkdtempSync(join(tmpdir(), "kaizen-pty-")));
11
12
 
@@ -194,3 +195,27 @@ test("asking: Claude Code and Codex prompts count as a question, ordinary output
194
195
  expect(asking("Do you want to make this edit to cli/pty.ts?")).toBe(true);
195
196
  expect(asking("Reading cli/pty.ts\r\n\x1b[2K✻ Thinking… 1. step one done")).toBe(false);
196
197
  });
198
+
199
+ test("Windows ConPTY host: resize travels in-band, the agent command survives quotes and newlines", () => {
200
+ expect(resizeSeq(100, 40)).toBe("\x1b]kzr;100;40\x07");
201
+ const line = innerCommand("C:\\ps.exe", ["claude", "it's\nhere"]);
202
+ const enc = line.split(" ").pop()!;
203
+ expect(line.startsWith('"C:\\ps.exe" -NoProfile -ExecutionPolicy Bypass -EncodedCommand ')).toBe(true);
204
+ expect(Buffer.from(enc, "base64").toString("utf16le")).toBe("& 'claude' 'it''s\nhere'");
205
+ // The host script decodes to PowerShell carrying the C# whole.
206
+ expect(Buffer.from(conptyHost(), "base64").toString("utf16le")).toContain("CreatePseudoConsole");
207
+ });
208
+
209
+ test.skipIf(process.platform !== "win32")("on Windows a card agent runs in a pseudo console and takes typed input", async () => {
210
+ const key = `win-${Date.now()}`;
211
+ const out: string[] = [];
212
+ startPty(key, ["cmd.exe", "/q", "/k", "echo ready"], root);
213
+ attachPty(key, (d) => { if (d) out.push(d); });
214
+ const until = async (re: RegExp) => { for (let i = 0; i < 300 && !re.test(out.join("")); i++) await Bun.sleep(100); return out.join(""); };
215
+ expect(await until(/ready/)).toContain("ready");
216
+ writePty(key, "echo typed-%OS%\r");
217
+ expect(await until(/typed-Windows_NT/)).toContain("typed-Windows_NT");
218
+ writePty(key, "exit\r");
219
+ for (let i = 0; i < 100 && ptyState(key)?.alive; i++) await Bun.sleep(100);
220
+ expect(ptyState(key)?.alive).toBe(false);
221
+ }, 60_000);
package/cli/pty.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  // Agents the web board runs itself, each in a pseudo-terminal the board owns, so a
2
2
  // card can show the agent's screen and pass it the user's typing (agent.launch: card).
3
3
  // They live in this process: the board stopping ends them, as closing a terminal
4
- // window ends the agent in it. Bun has no pseudo-terminal on Windows.
4
+ // window ends the agent in it. Bun has no pseudo-terminal on Windows; there the
5
+ // agent runs under a ConPTY host (conpty.ts) spoken to over pipes.
5
6
  import { writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { conptyHost, innerCommand, resizeSeq } from "./conpty.ts";
9
+
10
+ const WIN = process.platform === "win32";
11
+ // Windows PowerShell, which every Windows has, both hosts the console and runs the agent in it.
12
+ const winPs = () => join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
6
13
 
7
14
  type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; onChange?: () => void };
8
15
 
@@ -24,7 +31,7 @@ export const asking = (screen: string) =>
24
31
  const QUIET_MS = 700;
25
32
  // Bun gained pseudo-terminals in 1.3.5; an older one spawns without a tty, so it
26
33
  // keeps opening terminal windows.
27
- export const ptySupported = process.platform !== "win32" && typeof (Bun as any).Terminal === "function";
34
+ export const ptySupported = WIN || typeof (Bun as any).Terminal === "function";
28
35
 
29
36
  // Starts cmd in a new pseudo-terminal under `key` and returns its PID. pidFile gets
30
37
  // that PID, the agent's own, so closeSessions can end it like any launched session.
@@ -46,7 +53,21 @@ export function startPty(key: string, cmd: string[], cwd: string, pidFile?: stri
46
53
  flag(p.exit === null && n > 0 && asking(p.out.join("").slice(-n)));
47
54
  }, QUIET_MS);
48
55
  };
49
- p.proc = Bun.spawn(cmd, {
56
+ if (WIN) {
57
+ const ps = winPs();
58
+ p.proc = Bun.spawn([ps, "-NoProfile", "-NonInteractive", "-EncodedCommand", conptyHost()], {
59
+ cwd,
60
+ env: { ...process.env, KZ_PTY_CMD: innerCommand(ps, cmd), KZ_PTY_CWD: cwd, KZ_PTY_COLS: "120", KZ_PTY_ROWS: "32" },
61
+ stdin: "pipe", stdout: "pipe", stderr: "pipe", windowsHide: true,
62
+ });
63
+ const read = async (s: ReadableStream<Uint8Array>) => {
64
+ const d = new TextDecoder();
65
+ for await (const c of s) push(d.decode(c, { stream: true }));
66
+ };
67
+ read(p.proc.stdout as ReadableStream<Uint8Array>).catch(() => {});
68
+ // The host's own failures (Add-Type, CreateProcess) land on the screen, not nowhere.
69
+ read(p.proc.stderr as ReadableStream<Uint8Array>).catch(() => {});
70
+ } else p.proc = Bun.spawn(cmd, {
50
71
  cwd,
51
72
  env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" },
52
73
  terminal: { cols: 120, rows: 32, data: (_t, d) => push(dec.decode(d, { stream: true })) },
@@ -82,13 +103,18 @@ export function writePty(key: string, d: string) {
82
103
  if (!p || p.exit !== null) return;
83
104
  p.since = p.total;
84
105
  if (p.asking) { p.asking = false; p.onChange?.(); }
85
- p.proc.terminal?.write(d);
106
+ send(p, d);
86
107
  }
87
108
 
109
+ const send = (p: Pty, d: string) => {
110
+ if (!WIN) return p.proc.terminal?.write(d);
111
+ try { (p.proc.stdin as Bun.FileSink).write(d); (p.proc.stdin as Bun.FileSink).flush(); } catch { /* host gone */ }
112
+ };
113
+
88
114
  export function resizePty(key: string, cols: number, rows: number) {
89
115
  const p = ptys.get(key);
90
116
  const c = Math.max(10, Math.min(500, cols | 0)), r = Math.max(5, Math.min(300, rows | 0));
91
- if (p && p.exit === null) p.proc.terminal?.resize(c, r);
117
+ if (p && p.exit === null) WIN ? send(p, resizeSeq(c, r)) : p.proc.terminal?.resize(c, r);
92
118
  }
93
119
 
94
120
  export const ptyKeys = () => [...ptys.keys()];
package/cli/state.test.ts CHANGED
@@ -796,7 +796,7 @@ test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a Pow
796
796
  process.env.SystemRoot = "D:\\Win";
797
797
  const w = findTerminal("C:\\p", ["claude", "/kaizen x"], "C:\\p\\.kaizen\\sessions\\k.pid")!;
798
798
  const ps = Buffer.from(w.cmd.at(-1)!, "base64").toString("utf16le");
799
- expect(ps).toBe("$PID | Set-Content -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid'; Set-Location 'C:\\p'; & 'claude' '/kaizen x'");
799
+ expect(ps).toBe("$PID | Set-Content -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid'; Set-Location 'C:\\p'; & 'claude' '/kaizen x'; if (-not (Test-Path -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid')) { exit 0 }");
800
800
  } finally {
801
801
  Object.defineProperty(process, "platform", platform);
802
802
  keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
@@ -1148,3 +1148,11 @@ test("parent/child: a Parent line links a run and an idea to the run that filed
1148
1148
  expect(boardCards([st], Date.now()).find((k) => k.text === "next thing")!.parent).toBe("2026-01-02-b");
1149
1149
  rmSync(dirname(st), { recursive: true, force: true });
1150
1150
  });
1151
+
1152
+ test("winShim swaps an npm .cmd shim for the .ps1 beside it, so a multi-line prompt survives", () => {
1153
+ const { winShim } = stateModule;
1154
+ const has = (p: string) => p === "C:\\npm\\claude.ps1";
1155
+ expect(winShim("C:\\npm\\claude.cmd", has)).toBe("C:\\npm\\claude.ps1");
1156
+ expect(winShim("C:\\npm\\codex.CMD", () => false)).toBe("C:\\npm\\codex.CMD");
1157
+ expect(winShim("C:\\bin\\claude.exe", () => true)).toBe("C:\\bin\\claude.exe");
1158
+ });
package/cli/state.ts CHANGED
@@ -130,6 +130,14 @@ export const readText = (file: string) => readFileSync(file, "utf8").replace(/^\
130
130
 
131
131
  const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
132
132
 
133
+ // npm installs a Windows tool as x.cmd with x.ps1 beside it. PowerShell hands a .cmd to
134
+ // cmd.exe, which ends the command line at the first newline -- a prompt with notes lost
135
+ // everything after its first line. The .ps1 runs in PowerShell itself, argument intact.
136
+ export const winShim = (bin: string, exists = existsSync) => {
137
+ const ps1 = bin.replace(/\.cmd$/i, ".ps1");
138
+ return ps1 !== bin && exists(ps1) ? ps1 : bin;
139
+ };
140
+
133
141
  // Windows' own programs by full path: a board started from the shortcut or a
134
142
  // stripped-down environment can have a PATH without System32, and a bare "cmd.exe"
135
143
  // then fails with "Executable not found in $PATH".
@@ -164,7 +172,10 @@ export function findTerminal(cwd: string, fullCmd: string[], pidFile?: string):
164
172
  const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
165
173
  const shell = powershellExe();
166
174
  // This PowerShell's PID: ending its tree closes the -NoExit tab or window.
167
- const ps = `${pidFile ? `$PID | Set-Content -LiteralPath ${q(pidFile)}; ` : ""}Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
175
+ // The pid file gone after the agent means closeSessions ended it: exit 0, which closes
176
+ // the tab (Windows Terminal keeps one open after a non-zero exit). An agent that ends
177
+ // by itself leaves the window open, -NoExit, so what it printed can still be read.
178
+ const ps = `${pidFile ? `$PID | Set-Content -LiteralPath ${q(pidFile)}; ` : ""}Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}${pidFile ? `; if (-not (Test-Path -LiteralPath ${q(pidFile)})) { exit 0 }` : ""}`;
168
179
  const enc = Buffer.from(ps, "utf16le").toString("base64");
169
180
  // herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
170
181
  // its PowerShell. Typed text ends at a newline, so what is typed is one line, the
@@ -173,19 +184,19 @@ export function findTerminal(cwd: string, fullCmd: string[], pidFile?: string):
173
184
  // closes the tab once the run's PowerShell ends.
174
185
  const herdr = onPath("herdr");
175
186
  if (herdr && answers([herdr, "workspace", "list"])) {
176
- const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}; exit`;
187
+ const typed = `& ${q(shell)} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${enc}; exit`;
177
188
  const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
178
189
  if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
179
190
  return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
180
191
  }
181
192
  const wt = onPath("wt.exe");
182
193
  if (wt) {
183
- return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
194
+ return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc], detached: true };
184
195
  }
185
196
  // `start` reads a title only when quoted; an unquoted word is the command, so
186
197
  // "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
187
198
  return {
188
- cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
199
+ cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc],
189
200
  detached: true,
190
201
  };
191
202
  }
@@ -1297,8 +1308,14 @@ function closeOne(st: string, s: Session): boolean {
1297
1308
  try {
1298
1309
  const pid = parseInt(readText(pidFile));
1299
1310
  if (pid > 0 && isSession(pid, s)) {
1300
- if (process.platform === "win32") Bun.spawnSync([sysExe("taskkill.exe"), "/T", "/F", "/PID", String(pid)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5000, windowsHide: true });
1301
- else process.kill(pid, "SIGTERM");
1311
+ if (process.platform === "win32") {
1312
+ // The pid file first, then only the PowerShell's children: the PowerShell sees the
1313
+ // file gone and exits 0 by itself, closing its tab. Killed, it exits 1 and the tab stays.
1314
+ rmSync(pidFile, { force: true });
1315
+ const kids = Bun.spawnSync([powershellExe(), "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}').ProcessId`], { stdin: "ignore", stderr: "ignore", timeout: 10000, windowsHide: true }).stdout.toString().match(/\d+/g) ?? [];
1316
+ // No children (a launch from before this): the PowerShell itself, as before.
1317
+ for (const k of kids.length ? kids : [String(pid)]) Bun.spawnSync([sysExe("taskkill.exe"), "/T", "/F", "/PID", k], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5000, windowsHide: true });
1318
+ } else process.kill(pid, "SIGTERM");
1302
1319
  closed = true;
1303
1320
  }
1304
1321
  } catch { /* already gone */ }
@@ -1444,7 +1461,7 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
1444
1461
  // agent inheriting it runs as its child and keeps no transcript, so nothing to resume.
1445
1462
  for (const k of Object.keys(process.env)) if (CHILD_ENV.test(k)) delete process.env[k];
1446
1463
  // onPath, not Bun.which: the board may have taken a login shell's PATH after it started.
1447
- const agentBin = onPath(agent.cmd) ?? agent.cmd;
1464
+ const agentBin = winShim(onPath(agent.cmd) ?? agent.cmd);
1448
1465
  // No text: a run's conversation reopened as it was, with nothing sent to it.
1449
1466
  const prompt = !it.text ? "" : requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
1450
1467
  const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
package/cli/web.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
2
2
  // one HTML page; the page never sees the filesystem, only JSON.
3
- import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, type FSWatcher } from "node:fs";
4
- import { join, dirname, resolve, sep } from "node:path";
3
+ import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, cpSync, rmSync, type FSWatcher } from "node:fs";
4
+ import { join, dirname, resolve, sep, basename } from "node:path";
5
5
  import {
6
6
  home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
7
7
  readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
@@ -550,7 +550,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
550
550
  const v = await npmLatest(url.searchParams.has("fresh"));
551
551
  const update = !!v && newer(v, VERSION);
552
552
  const notes = update ? await releaseNotes(VERSION, url.searchParams.has("fresh")) : [];
553
- return json({ version: VERSION, latest: v, update, notes });
553
+ return json({ version: VERSION, latest: v, update, notes, failed: upgradeFailed });
554
554
  }
555
555
 
556
556
  // `kaizen upgrade`, run here with no window, then a new board on this port: it
@@ -560,6 +560,21 @@ export async function web(repoDir: string, opts: Opts = {}) {
560
560
  if (req.method === "POST" && path === "/upgrade") {
561
561
  if (!local(req)) return bad("cross-origin write refused", 403);
562
562
  if (upgrading) return bad("an upgrade is already running", 409);
563
+ // Windows will not rename ~/kaizen while this board holds files in it, so the
564
+ // upgrade waits in its own process for this one to exit, then starts the board
565
+ // again; ~/.kaizen/upgrade.log keeps what it said. It runs from a copy of cli/,
566
+ // so nothing runs out of ~/kaizen while it is renamed.
567
+ if (process.platform === "win32") {
568
+ const helper = join(home, ".kaizen", "upgrader");
569
+ rmSync(helper, { recursive: true, force: true });
570
+ cpSync(join(repoDir, "cli"), join(helper, "cli"), { recursive: true });
571
+ const log = Bun.file(UPGRADE_LOG);
572
+ Bun.spawn([process.execPath, join(helper, "cli", "install.ts"), "upgrade", "--wait", String(process.pid), "--restart", String(srv.port)], {
573
+ cwd: home, detached: true, stdio: ["ignore", log, log], windowsHide: true,
574
+ }).unref();
575
+ setTimeout(() => process.exit(0), 300);
576
+ return json({ ok: true });
577
+ }
563
578
  upgrading = true;
564
579
  try {
565
580
  const p = Bun.spawn([process.execPath, join(repoDir, "cli", "install.ts"), "upgrade"], { stdin: "ignore", stdout: "pipe", stderr: "pipe", windowsHide: true });
@@ -688,6 +703,33 @@ export async function web(repoDir: string, opts: Opts = {}) {
688
703
  return json({ ok: true, added: knownProjects().length - before });
689
704
  }
690
705
 
706
+ // A new, empty project: a folder in the projects folder (else ~/Projects), or
707
+ // where the page names, set up at once. /find only sees folders that already
708
+ // have a .kaizen, so a folder made by hand never shows there.
709
+ if (path === "/new-project") {
710
+ const name = typeof body.name === "string" ? body.name.trim() : "";
711
+ // Windows' rules on every platform, so a project made here also opens there:
712
+ // no reserved device names (CON, NUL, COM1…), no trailing dot or space.
713
+ const winBad = (n: string) => !n || /[\\/:*?"<>|\x00-\x1f]/.test(n) || /[. ]$/.test(n)
714
+ || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(n);
715
+ const t = typeof body.to === "string" ? body.to.trim() : "";
716
+ const to = t ? resolve(home, nativePath(t.startsWith("~") ? home + t.slice(1) : t))
717
+ : join((await projectsRoot()) || join(home, "Projects"), name);
718
+ // The page asks while the name is typed: "con" on the way to "config" is no error yet.
719
+ if (body.check) return json({ ok: true, to });
720
+ if (winBad(name)) return bad("name the project: a folder name, without / \\ : * ? \" < > |, not ending in a dot or space, and not CON, NUL, COM1 or the like");
721
+ if (winBad(basename(to))) return bad(`${basename(to)} cannot be a folder name on Windows`);
722
+ let used = false;
723
+ try { used = readdirSync(to).length > 0; } catch (e) { used = existsSync(to); }
724
+ if (used) return bad(`${to} already exists and is not empty`, 409);
725
+ // A drive that is not there, a read-only folder, a path too long: said in a toast, not a crash.
726
+ try { mkdirSync(join(to, ".kaizen"), { recursive: true }); }
727
+ catch (e) { return bad(`could not create ${to}: ${(e as Error).message}`); }
728
+ remember(to);
729
+ changed();
730
+ return json({ ok: true, dir: join(to, ".kaizen") });
731
+ }
732
+
691
733
  // Clone a GitHub repository into the projects folder (else ~/Projects) and
692
734
  // set it up as a project. Arguments go to git as an array: no shell.
693
735
  if (path === "/clone") {
@@ -1124,6 +1166,18 @@ async function openDefault(url: string) {
1124
1166
  let picking = false;
1125
1167
  // One `kaizen upgrade` at a time, from any page.
1126
1168
  let upgrading = false;
1169
+ // What a Windows hand-off upgrade said (see POST /upgrade). Read once at start: no
1170
+ // "Upgraded" or "Up to date" line means it failed, and its last lines say why.
1171
+ const UPGRADE_LOG = join(home, ".kaizen", "upgrade.log");
1172
+ const upgradeFailed = (() => {
1173
+ if (!existsSync(UPGRADE_LOG)) return null;
1174
+ const lines = readText(UPGRADE_LOG).replace(/\x1b\[[0-9;]*m/g, "").split("\n").map((l) => l.trim()).filter(Boolean);
1175
+ try { rmSync(UPGRADE_LOG, { force: true }); } catch { /* shown again next start */ }
1176
+ if (lines.some((l) => /^(Upgraded|Up to date)\b/.test(l))) return null;
1177
+ // "kept ~/kaizen (in use …)" is the reason when there is one; else the closing lines.
1178
+ const kept = lines.find((l) => /^\+?\s*kept\b/.test(l));
1179
+ return kept?.replace(/^\+\s*/, "") ?? (lines.filter((l) => !l.startsWith("+")).slice(-2).join(" — ") || "kaizen upgrade said nothing");
1180
+ })();
1127
1181
 
1128
1182
  // The system folder picker for this platform, printing the chosen path on stdout.
1129
1183
  // macOS and Windows always have one; on Linux it is whichever desktop tool exists.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.41.2",
3
+ "version": "1.42.0",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -206,7 +206,7 @@ the defaults.
206
206
  | `auto_fix.min_severity` | `high` | Lower to `medium` or `low` to have more auto-fixed; raise findings you see instead of auto-fixing |
207
207
  | `auto_fix.max_iterations` | `2` | Fix/recheck rounds before whatever is left is escalated to you |
208
208
  | `review.memory_max_lines` | `100` | The cap on `memory.md`, which every stage of every run reads. Raise only if the lessons are genuinely all load-bearing |
209
- | `git.auto_commit` | `false` | Leave it. Kaizen does not commit or push unless you ask |
209
+ | `git.auto_commit` | `false` | `true` commits the files a run changed when it is done, by path. Never pushes |
210
210
  | `track.default` | `auto` (inferred) | A repo that is always one kind of work, e.g. a docs repo |
211
211
  | `backlog.enabled` | `true` | Set `false` to skip both backlog capture points; the run still executes, just without a `06-backlog.md` |
212
212
  | `backlog.file` | `06-backlog.md` | The per-run file name. Rarely. |
@@ -125,7 +125,9 @@ state:
125
125
  keep_runs: 20
126
126
 
127
127
  git:
128
- # Never commit, push, or open a PR unless the user explicitly asks.
128
+ # true: when a run reaches done (approved at review, or an auto run finishing),
129
+ # commit the files that run changed, by path, with its ## Commit message. Never
130
+ # pushes. false: never commit, push, or open a PR unless the user explicitly asks.
129
131
  # Ignored when the project is not a git repository.
130
132
  auto_commit: false
131
133
  # Create a branch before the implement stage when on the default branch.
@@ -168,8 +170,8 @@ agent:
168
170
  # Where the web board runs the agent it launches.
169
171
  # card - inside the board: the card's Terminal tab shows the agent live and
170
172
  # takes your typing. The agent lives in the board's process, so
171
- # stopping or upgrading the board ends it. Not on Windows, where it
172
- # falls back to a terminal window.
173
+ # stopping or upgrading the board ends it. On Windows it runs in
174
+ # a ConPTY console that Windows PowerShell hosts.
173
175
  # terminal - a new terminal window (or tmux/herdr tab) per launch.
174
176
  # The terminal dashboard always opens terminal windows.
175
177
  launch: card
@@ -131,6 +131,17 @@ change is that it is written down rather than spoken and discarded.
131
131
 
132
132
  Set `stage: "done"` in `state.json` once the user accepts.
133
133
 
134
+ **Commit at done, when `git.auto_commit` is `true`.** Setting `stage: "done"` — the
135
+ user approving at review, or an `auto` run finishing — is then the ask to commit, under
136
+ either runner. Commit only the files this run changed (the paths `03-impl.md` names,
137
+ that `git status` shows changed), by path: `git add -A -- <paths>` then
138
+ `git commit -m <message> -- <paths>`, never the whole tree, since other sessions edit
139
+ the same checkout. The message is `03-impl.md`'s `## Commit`. In a run with a
140
+ `worktree`, commit there. Then write `runs/<id>/07-commit.md` as `commit: <short sha>`
141
+ and `pushed: no`, so the board shows it committed and offers the push. Never push. A
142
+ failed commit is reported as printed; the run is still `done`. With the setting
143
+ `false`, nothing is committed unless the user asks.
144
+
134
145
  ---
135
146
 
136
147
  ## Child cards
@@ -95,7 +95,7 @@ Each iteration is written to `runs/<id>/05-iterations/`.
95
95
  | `backlog.orphan_file` | `backlog.md` | Where a pruned run's open items are rescued to |
96
96
  | `state.dir` | `.kaizen` | State directory |
97
97
  | `state.keep_runs` | `20` | Completed runs kept before pruning oldest (rescues open backlog first) |
98
- | `git.auto_commit` | `false` | Never commit/push/PR unless the user explicitly asks |
98
+ | `git.auto_commit` | `false` | `true`: commit the run's changed files when it is done; never pushes |
99
99
  | `git.branch_before_implement` | `true` | Branch before implement stage when on the default branch |
100
100
  | `subagents.model` | `inherit` | Model the stage subagents run on (opus/sonnet/haiku, Claude Code only) |
101
101
  | `subagents.effort` | `inherit` | Reasoning effort for the stage subagents (low/medium/high, Claude Code only) |
@@ -467,6 +467,7 @@ export function render() {
467
467
  ["All projects", "all"],
468
468
  ...store.S.projects.map((p) => [p.label, p.dir]),
469
469
  ...store.S.fresh.map((p) => [p.label, p.dir, 1]),
470
+ ["New project…", "__new"],
470
471
  ["Find projects on this PC…", "__find"],
471
472
  ["Clone from GitHub…", "__clone"],
472
473
  ];
package/web/board/dom.ts CHANGED
@@ -27,7 +27,11 @@ export function modal(html: string) {
27
27
  dl.className = "kz";
28
28
  dl.innerHTML = `<form class="inl" method="dialog">${html}</form>`;
29
29
  dl.onclose = () => dl.remove();
30
- dl.onclick = (e) => e.target === dl && dl.close(); // a tap on the backdrop
30
+ // A tap on the backdrop. Braces matter: an on* handler that returns false cancels the click,
31
+ // which stopped every submit button inside the form.
32
+ dl.onclick = (e) => {
33
+ if (e.target === dl) dl.close();
34
+ };
31
35
  document.body.appendChild(dl);
32
36
  dl.showModal();
33
37
  return dl;
@@ -35,7 +35,7 @@ export function list() {
35
35
  store.PL.map((p, i) =>
36
36
  p[0].toLowerCase().includes(q)
37
37
  ? (p[2] && !g++ ? `<div class="grp">In your projects folder</div>` : "") +
38
- `<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__find" || p[1] === "__clone" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
38
+ `<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__new" || p[1] === "__find" || p[1] === "__clone" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
39
39
  : "",
40
40
  ).join("") || `<div class="grp">No match</div>`;
41
41
  const f = $("projs").querySelector("button");
@@ -55,6 +55,45 @@ async function pick(v) {
55
55
  toast("Searching…");
56
56
  const j = await post("/find", {});
57
57
  if (j.ok) toast(j.added ? `Found ${j.added} new project${j.added === 1 ? "" : "s"}` : "No new projects found");
58
+ } else if (v === "__new") {
59
+ // A name, and where it goes: the projects folder unless typed or picked.
60
+ const dl = modal(
61
+ `<b>New project</b><input name="name" placeholder="Project name" aria-label="Project name" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Create in (a new folder is made)" aria-label="Create in" spellcheck="false" autocomplete="off"><button type="button" class="btn ghost" data-a="pick">Pick…</button></div><div class="row"><button type="button" class="btn ghost" data-a="x">Cancel</button><button class="btn pri">Create</button></div>`,
62
+ );
63
+ const f = dl.querySelector("form"),
64
+ name = f.querySelector("[name=name]"),
65
+ to = f.querySelector("[name=to]");
66
+ let auto = ""; // the folder this form filled in; one the user typed is left alone
67
+ name.oninput = async () => {
68
+ const n = name.value.trim();
69
+ if (!n || /[\\/:*?"<>|]/.test(n) || (to.value.trim() && to.value !== auto)) return; // a bad name is reported once, at Create
70
+ const d = await post("/new-project", { name: n, check: true });
71
+ if (d.ok && name.value.trim() === n) to.value = auto = d.to;
72
+ };
73
+ f.querySelector("[data-a=x]").onclick = () => dl.close();
74
+ f.querySelector("[data-a=pick]").onclick = async () => {
75
+ const p = await post("/pick-folder", {});
76
+ if (p.path)
77
+ to.value = p.path.replace(/[\\/]+$/, "") + (p.path.includes("\\") ? "\\" : "/") + (name.value.trim() || "");
78
+ };
79
+ name.focus();
80
+ const dir = await new Promise((done) => {
81
+ dl.addEventListener("close", () => done(null));
82
+ f.onsubmit = async (e) => {
83
+ e.preventDefault();
84
+ if (!name.value.trim()) return name.focus();
85
+ const j = await post("/new-project", { name: name.value.trim(), to: to.value.trim() || undefined });
86
+ if (!j.ok) return;
87
+ done(j.dir);
88
+ dl.close();
89
+ };
90
+ });
91
+ if (!dir) return;
92
+ await post("/cd", { dir });
93
+ store.all = false;
94
+ store.dir = dir;
95
+ keep(dir);
96
+ toast("Project created");
58
97
  } else if (v === "__clone") {
59
98
  // One form: the link, then where it goes. The folder fills in once the link names a repo.
60
99
  const dl = modal(
@@ -30,6 +30,7 @@ export function updBar() {
30
30
  : "";
31
31
  d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn" id="upd-chk">Check for updates</button><button class="btn pri" id="upd-go">Update kaizen</button></span>`;
32
32
  }
33
+ if (ver && ver.failed) d.insertAdjacentHTML("afterbegin", `<span class="updfail" role="alert">Last update failed: ${esc(ver.failed)}</span>`);
33
34
  $("pbody").prepend(d);
34
35
  const check = $("upd-chk");
35
36
  check.onclick = async () => {