kaizen-agent 1.9.0 → 1.9.2

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/install.ts CHANGED
@@ -244,7 +244,7 @@ async function noAgent() {
244
244
  async function openUrl(url: string) {
245
245
  // `start` is a cmd builtin, not a program, so it has to go through cmd.
246
246
  if (process.platform === "win32") {
247
- try { await Bun.$`cmd /c start "" ${url}`.quiet(); return true; } catch { return false; }
247
+ try { const { cmdExe } = await import("./state.ts"); await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
248
248
  }
249
249
  const opener = process.platform === "darwin" ? "open" : "xdg-open";
250
250
  if (!Bun.which(opener)) return false;
@@ -279,7 +279,6 @@ if (wantWeb) {
279
279
  const port = at !== -1 ? Number(Bun.argv[at + 1]) || undefined : undefined;
280
280
  const url = `http://127.0.0.1:${port ?? 7420}/`;
281
281
  const pidFile = join(home, ".kaizen", "web.pid");
282
- const alive = async () => { try { await fetch(url); return true; } catch { return false; } };
283
282
 
284
283
  if (args.has("--stop")) {
285
284
  // The pid file only knows a --daemon; a board started from the shortcut or a
@@ -300,7 +299,10 @@ if (wantWeb) {
300
299
  if (args.has("--daemon")) {
301
300
  // The daemon is this same command, detached, with the browser left to us:
302
301
  // ignored stdio is what lets it outlive the terminal.
303
- if (await alive()) {
302
+ // A board running an older kaizen is not "already running": the child finds
303
+ // it on the port and replaces it (web() in web.ts).
304
+ const { current } = await import("./web.ts");
305
+ if (await current(url)) {
304
306
  console.log(` kaizen web already running at ${url}`);
305
307
  } else {
306
308
  const argv = Bun.argv.slice(2).filter((a) => a !== "--daemon" && a !== "--no-open");
@@ -310,8 +312,8 @@ if (wantWeb) {
310
312
  child.unref();
311
313
  mkdirSync(dirname(pidFile), { recursive: true });
312
314
  writeFileSync(pidFile, String(child.pid));
313
- for (let i = 0; i < 30 && !(await alive()); i++) await Bun.sleep(100);
314
- if (!(await alive())) { console.log(` kaizen web did not come up on ${url}`); process.exit(1); }
315
+ for (let i = 0; i < 30 && !(await current(url)); i++) await Bun.sleep(100);
316
+ if (!(await current(url))) { console.log(` kaizen web did not come up on ${url}`); process.exit(1); }
315
317
  console.log(`\n kaizen web ${url} (pid ${child.pid}, detached)`);
316
318
  console.log(` kaizen web --stop to stop it\n`);
317
319
  }
package/cli/state.test.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
9
9
  statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
10
10
  requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
11
- reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
11
+ reviewFindings, pickFindings, fixPrompt, gitStatus, runGit, cardsGit, commitPush, readCommit, findTerminal, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
12
12
  } from "./state.ts";
13
13
 
14
14
  let state: string;
@@ -355,6 +355,47 @@ test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, t
355
355
  }
356
356
  });
357
357
 
358
+ test("nativePath on Windows reads the spellings Git Bash writes into the projects registry as one folder", () => {
359
+ const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
360
+ try {
361
+ Object.defineProperty(process, "platform", { value: "win32" });
362
+ for (const p of ["/c/Users/me/proj", "C:/Users/me/proj", "c:\\Users\\me\\proj\\", "C:\\Users\\me\\proj"])
363
+ expect(nativePath(p)).toBe("C:\\Users\\me\\proj");
364
+ expect(nativePath("/d")).toBe("D:\\");
365
+ } finally { Object.defineProperty(process, "platform", platform); }
366
+ expect(nativePath("/c/Users/me")).toBe("/c/Users/me"); // elsewhere a path is left alone
367
+ });
368
+
369
+ test("findTerminal on Windows names cmd and PowerShell by full path, so a PATH without System32 still launches", () => {
370
+ const keys = ["PATH", "ComSpec", "SystemRoot", "windir"];
371
+ const saved = keys.map((k) => process.env[k]);
372
+ const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
373
+ try {
374
+ Object.defineProperty(process, "platform", { value: "win32" });
375
+ process.env.PATH = mkdtempSync(join(tmpdir(), "kz-empty-"));
376
+ for (const k of keys.slice(1)) delete process.env[k];
377
+ process.env.SystemRoot = "D:\\Win";
378
+ const t = findTerminal("C:\\p", ["claude", "/kaizen x"])!;
379
+ expect(t.cmd[0]).toBe("D:\\Win\\System32\\cmd.exe");
380
+ expect(t.cmd[6]).toBe("D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
381
+ process.env.ComSpec = "E:\\cmd.exe";
382
+ expect(findTerminal("C:\\p", ["claude"])!.cmd[0]).toBe("E:\\cmd.exe");
383
+ // A running herdr: a tab, and the run typed into it as one encoded line.
384
+ writeFileSync(join(process.env.PATH, "herdr"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
385
+ const h = findTerminal("C:\\p", ["claude", "/kaizen it's\nnote"])!;
386
+ expect([h.cmd[0], h.cmd[1], h.cmd[2], h.detached]).toEqual(["D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-NoProfile", "-EncodedCommand", false]);
387
+ const launch = Buffer.from(h.cmd[3], "base64").toString("utf16le");
388
+ expect(launch).toContain("tab create --cwd 'C:\\p' --label kaizen --focus");
389
+ const typed = /pane run \$Matches\[1\] '(.*)' \}/.exec(launch)![1].replace(/''/g, "'");
390
+ expect(typed).not.toContain("\n");
391
+ const run = Buffer.from(typed.split(" -EncodedCommand ")[1], "base64").toString("utf16le");
392
+ expect(run).toBe("Set-Location 'C:\\p'; & 'claude' '/kaizen it''s\nnote'");
393
+ } finally {
394
+ Object.defineProperty(process, "platform", platform);
395
+ keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
396
+ }
397
+ });
398
+
358
399
  test("prune: only finished runs beyond keep go, open items are rescued, a dry run deletes nothing", () => {
359
400
  const st = join(mkdtempSync(join(tmpdir(), "kaizen-prune-")), ".kaizen");
360
401
  const mk = (id: string, stage: string, backlog = "") => {
package/cli/state.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // `.kaizen/` directory. No terminal, no HTTP: the TUI (`dashboard.ts`) and the web
3
3
  // board (`web.ts`) both import this and draw it their own way.
4
4
  import { existsSync, readFileSync, readdirSync, appendFileSync, writeFileSync, mkdirSync, statSync, realpathSync, rmSync } from "node:fs";
5
- import { join, dirname } from "node:path";
5
+ import { join, dirname, win32 } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
 
8
8
  // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
@@ -79,6 +79,14 @@ const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
79
79
  // Bun.which reads the PATH the process started with unless handed the current one.
80
80
  const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
81
81
 
82
+ // Windows' own programs by full path: a board started from the shortcut or a
83
+ // stripped-down environment can have a PATH without System32, and a bare "cmd.exe"
84
+ // then fails with "Executable not found in $PATH".
85
+ const sysRoot = () => process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
86
+ export const sysExe = (name: string) => `${sysRoot()}\\System32\\${name}`;
87
+ export const cmdExe = () => process.env.ComSpec ?? sysExe("cmd.exe");
88
+ export const powershellExe = () => onPath("pwsh.exe") ?? onPath("powershell.exe") ?? sysExe("WindowsPowerShell\\v1.0\\powershell.exe");
89
+
82
90
  // Whether a multiplexer's server is up: its own CLI says so by exit code. spawnSync
83
91
  // throws on a missing binary, so the path is resolved first.
84
92
  function answers(cmd: string[]): boolean {
@@ -102,16 +110,28 @@ export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; d
102
110
  // Terminal hosts the shell when installed; otherwise `start` gives it a window.
103
111
  if (process.platform === "win32") {
104
112
  const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
105
- const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
113
+ const shell = powershellExe();
106
114
  const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
107
115
  const enc = Buffer.from(ps, "utf16le").toString("base64");
108
- if (Bun.which("wt.exe")) {
109
- return { cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
116
+ // herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
117
+ // its PowerShell. Typed text ends at a newline, so what is typed is one line, the
118
+ // same encoded command; -EncodedCommand is also not a script file, so an
119
+ // execution policy that blocks .ps1 files does not stop it.
120
+ const herdr = onPath("herdr");
121
+ if (herdr && answers([herdr, "workspace", "list"])) {
122
+ const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}`;
123
+ const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
124
+ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
125
+ return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
126
+ }
127
+ const wt = onPath("wt.exe");
128
+ if (wt) {
129
+ return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
110
130
  }
111
131
  // `start` reads a title only when quoted; an unquoted word is the command, so
112
132
  // "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
113
133
  return {
114
- cmd: ["cmd.exe", "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
134
+ cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
115
135
  detached: true,
116
136
  };
117
137
  }
@@ -193,7 +213,18 @@ export function knownProjects(): string[] {
193
213
  if (!existsSync(REGISTRY)) return [];
194
214
  return readFileSync(REGISTRY, "utf8").split("\n")
195
215
  .map((l) => l.trim())
196
- .filter((l) => l && !l.startsWith("#"));
216
+ .filter((l) => l && !l.startsWith("#"))
217
+ .map(nativePath)
218
+ .filter((d, i, all) => all.indexOf(d) === i);
219
+ }
220
+
221
+ // On Windows the registry is also written by agents running Git Bash, which says
222
+ // /c/Users/me/proj or C:/Users/me/proj. Read as is, the first is C:\c\Users\..., a
223
+ // folder that does not exist, and the project drops off the board. One spelling
224
+ // per folder, drive letter upper case, so the same project is not listed twice.
225
+ export function nativePath(p: string) {
226
+ if (process.platform !== "win32") return p;
227
+ return win32.resolve(p.replace(/^\/([a-zA-Z])(?=\/|$)/, "$1:/")).replace(/^[a-z]:/, (d) => d.toUpperCase());
197
228
  }
198
229
 
199
230
  // Never fails the command that triggered it: a read-only home is not a reason for a
@@ -854,7 +885,7 @@ export function launchRun(it: Item, from: string): Launch {
854
885
  export async function pidOnPort(port: number): Promise<number | null> {
855
886
  try {
856
887
  if (process.platform === "win32") {
857
- const out = await Bun.$`netstat -ano -p tcp`.text();
888
+ const out = await Bun.$`${sysExe("netstat.exe")} -ano -p tcp`.text();
858
889
  const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
859
890
  return m ? Number(m[1]) : null;
860
891
  }
package/cli/web.ts CHANGED
@@ -5,7 +5,7 @@ import { join, dirname } 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, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
8
- appendNote, saveAttachment, pidOnPort, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
8
+ appendNote, saveAttachment, pidOnPort, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
9
9
  reviewFindings, pickFindings, fixPrompt,
10
10
  } from "./state.ts";
11
11
  import { readSources, addSource, removeSource, moveSource, gatherSource, gatherAll, gatherDirs, due } from "./sources.ts";
@@ -16,6 +16,19 @@ const PAGE = join(dirname(import.meta.dir), "web", "board.html");
16
16
  const NOTES_PAGE = join(dirname(import.meta.dir), "web", "notes.html");
17
17
  const NOTES_JS = join(dirname(import.meta.dir), "web", "notebook.js");
18
18
  const DEFAULT_PORT = 7420;
19
+ // Read once at start: a board keeps the code it started with, so this is what it
20
+ // runs, not what is installed now. `/` sends it; `current()` compares the two.
21
+ const VERSION = (() => {
22
+ try { return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version as string; }
23
+ catch { return "unknown"; }
24
+ })();
25
+
26
+ // A board answers at url and runs the installed version. A board from before the
27
+ // version header sends none, so it counts as stale too.
28
+ export async function current(url: string) {
29
+ const r = await fetch(url, { signal: AbortSignal.timeout(1500) }).catch(() => null);
30
+ return !!r?.ok && r.headers.get("x-kaizen-version") === VERSION;
31
+ }
19
32
 
20
33
  type Opts = { port?: number; open?: boolean };
21
34
 
@@ -158,11 +171,12 @@ export async function web(repoDir: string, opts: Opts = {}) {
158
171
  if (!["EADDRINUSE", "EACCES"].includes((e as { code?: string }).code ?? "")) throw e;
159
172
  const url = `http://127.0.0.1:${port}/`;
160
173
  const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
161
- if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
162
- // A listener that does not answer is a board that hung; it is ours to stop.
174
+ if (up && await current(url)) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
175
+ // A listener that does not answer is a board that hung, and one running an
176
+ // older kaizen serves 404 for whatever came since; both are ours to stop.
163
177
  const pid = await pidOnPort(port);
164
178
  if (pid && pid !== process.pid) {
165
- try { process.kill(pid, "SIGTERM"); console.log(` stopped a hung board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
179
+ try { process.kill(pid, "SIGTERM"); console.log(` stopped a ${up ? "stale" : "hung"} board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
166
180
  await Bun.sleep(300);
167
181
  try { server = serve(port); continue; } catch { /* still held */ }
168
182
  }
@@ -174,6 +188,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
174
188
  function serve(port: number) { return Bun.serve({
175
189
  hostname: "127.0.0.1",
176
190
  port,
191
+ // Bun drops a request after 10s idle by default. A slow /state (git status in a
192
+ // big repo, on Windows) then comes back as nothing, and the page shows an empty
193
+ // board. Loopback only, so nothing waits on us but our own page.
194
+ idleTimeout: 0,
177
195
  async fetch(req) {
178
196
  // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
179
197
  // arrives with that hostname in Host, and gets nothing.
@@ -186,7 +204,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
186
204
  if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
187
205
  // The page gets the notify rule from state.ts rather than keeping its own copy.
188
206
  return new Response(readFileSync(PAGE, "utf8").replace("/*notifier*/", () => notifier.toString()), {
189
- headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
207
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-kaizen-version": VERSION },
190
208
  });
191
209
  }
192
210
 
@@ -569,7 +587,7 @@ export async function openApp(url: string) {
569
587
  } catch { /* fall through to a plain tab */ }
570
588
  // `start` is a cmd builtin, not a program, so it has to go through cmd.
571
589
  if (process.platform === "win32") {
572
- try { await Bun.$`cmd /c start "" ${url}`.quiet(); return true; } catch { return false; }
590
+ try { await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
573
591
  }
574
592
  const opener = process.platform === "darwin" ? "open" : "xdg-open";
575
593
  if (!Bun.which(opener)) return false;
@@ -630,8 +648,8 @@ export async function shortcut() {
630
648
  mkdirSync(dirname(vbs), { recursive: true });
631
649
  const vq = (s: string) => `""${s}""`; // a quoted arg inside a VBScript string
632
650
  writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${vq(bun)} ${vq(script)} web --daemon", 0, False\r\n`);
633
- const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='wscript.exe';$s.Arguments='"${vbs}"';$s.WorkingDirectory='${home}';${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
634
- await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
651
+ const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${sysExe("wscript.exe")}';$s.Arguments='"${vbs}"';$s.WorkingDirectory='${home}';${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
652
+ await Bun.$`${powershellExe()} -NoProfile -Command ${ps}`.quiet();
635
653
  return `${lnk} — double-click starts the board in the background and opens it; kaizen web --stop ends it`;
636
654
  }
637
655
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.9.0",
3
+ "version": "1.9.2",
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": {