kaizen-agent 1.9.1 → 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;
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";
@@ -188,6 +188,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
188
188
  function serve(port: number) { return Bun.serve({
189
189
  hostname: "127.0.0.1",
190
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,
191
195
  async fetch(req) {
192
196
  // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
193
197
  // arrives with that hostname in Host, and gets nothing.
@@ -583,7 +587,7 @@ export async function openApp(url: string) {
583
587
  } catch { /* fall through to a plain tab */ }
584
588
  // `start` is a cmd builtin, not a program, so it has to go through cmd.
585
589
  if (process.platform === "win32") {
586
- 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; }
587
591
  }
588
592
  const opener = process.platform === "darwin" ? "open" : "xdg-open";
589
593
  if (!Bun.which(opener)) return false;
@@ -644,8 +648,8 @@ export async function shortcut() {
644
648
  mkdirSync(dirname(vbs), { recursive: true });
645
649
  const vq = (s: string) => `""${s}""`; // a quoted arg inside a VBScript string
646
650
  writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${vq(bun)} ${vq(script)} web --daemon", 0, False\r\n`);
647
- 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()`;
648
- 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();
649
653
  return `${lnk} — double-click starts the board in the background and opens it; kaizen web --stop ends it`;
650
654
  }
651
655
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.9.1",
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": {