kaizen-agent 1.9.3 → 1.10.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/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, nativePath, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
11
+ reviewFindings, pickFindings, fixPrompt, approvalPrompt, sessionLaunch, closeSessions, sweepSessions, readSessions, ownerOf, CLOSE_GRACE_MS, 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;
@@ -129,6 +129,22 @@ test("reviewFindings: numbered lines under Findings, closed by the backlog; the
129
129
  expect(fixPrompt("x", [2, 5])).toContain("fix findings 2, 5 from");
130
130
  });
131
131
 
132
+ test("approvalPrompt: approve or revise, the comment kept on one line", () => {
133
+ expect(approvalPrompt("r", "approvals.plan")).toBe("run r: the plan is approved from the board. Record it in 02-approval.md and build it, first asking any of its open questions still unanswered.");
134
+ expect(approvalPrompt("r", "approvals.review", " ")).toContain("approved from the board at the final approval");
135
+ const p = approvalPrompt("r", "approvals.plan", " drop step 3,\n\tkeep \"the rest\" ");
136
+ expect(p).toContain('to revise: "drop step 3, keep "the rest"".');
137
+ expect(p).toContain("revise 01-plan.md");
138
+ expect(p).not.toContain("\n");
139
+ expect(approvalPrompt("r", "approvals.review", "rename it")).toContain('final approval: "rename it". Record it in 02-approval.md, change the work');
140
+ });
141
+
142
+ test("approvalPrompt: a plan's answers numbered on one line", () => {
143
+ const p = approvalPrompt("r", "approvals.plan", undefined, ["Where? — Footer", "Chip?\n — No; keep it\tsmall"]);
144
+ expect(p).toBe("run r: the plan is approved from the board, with answers to its open questions: 1) Where? — Footer; 2) Chip? — No; keep it small. Record them in 02-approval.md and build the plan with them.");
145
+ expect(approvalPrompt("r", "approvals.plan", "redo it", ["x"])).toContain("to revise");
146
+ });
147
+
132
148
  test("startedRuns: normalised request bodies", () => {
133
149
  expect(startedRuns(state).sort()).toEqual(["# request add oauth login", "# request rename cli flags"]);
134
150
  });
@@ -346,6 +362,8 @@ test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, t
346
362
  stub("herdr", 0);
347
363
  const h = findTerminal("/p", full)!;
348
364
  expect([h.cmd[0], h.cmd[1], h.cmd[4], h.detached]).toEqual(["sh", "-c", "/p", false]);
365
+ expect(h.cmd[2]).toContain(`pane run "$p" "exec sh $f"`); // exec: the tab closes with the agent
366
+ expect(h.cmd[2]).toContain("[ $s = 127 ] || exit $s"); // ...unless it never started
349
367
  expect(t.cmd.slice(4)).toEqual(full); // argv: tmux runs it without its default-shell
350
368
  const back = Bun.spawnSync(["/bin/sh", "-c", `printf '%s\\n' ${h.cmd[5]}`]).stdout.toString();
351
369
  expect(back).toBe(full.join("\n") + "\n");
@@ -388,7 +406,8 @@ test("findTerminal on Windows names cmd and PowerShell by full path, so a PATH w
388
406
  expect(launch).toContain("tab create --cwd 'C:\\p' --label kaizen --focus");
389
407
  const typed = /pane run \$Matches\[1\] '(.*)' \}/.exec(launch)![1].replace(/''/g, "'");
390
408
  expect(typed).not.toContain("\n");
391
- const run = Buffer.from(typed.split(" -EncodedCommand ")[1], "base64").toString("utf16le");
409
+ expect(typed).toEndWith("; exit"); // the tab closes with the run
410
+ const run = Buffer.from(typed.split(" -EncodedCommand ")[1].replace(/; exit$/, ""), "base64").toString("utf16le");
392
411
  expect(run).toBe("Set-Location 'C:\\p'; & 'claude' '/kaizen it''s\nnote'");
393
412
  } finally {
394
413
  Object.defineProperty(process, "platform", platform);
@@ -475,3 +494,196 @@ test("files written by Windows PowerShell, with a byte order mark, still read: t
475
494
  expect(readRuns(st).map((r) => [r.id, r.stage])).toEqual([["r1", "plan"]]);
476
495
  expect(readInbox(st)).toEqual([{ status: "open", text: "first idea" }]);
477
496
  });
497
+
498
+ test("remember on Windows stores one spelling per folder, the one knownProjects returns", () => {
499
+ // REGISTRY is bound to the home at import, so this runs in a child with a scratch HOME.
500
+ const home = mkdtempSync(join(tmpdir(), "kz-home-"));
501
+ const script = `Object.defineProperty(process, "platform", { value: "win32" });
502
+ const { remember, knownProjects, REGISTRY } = await import(${JSON.stringify(join(dirname(import.meta.path), "state.ts"))});
503
+ remember("c:\\\\p"); remember("C:\\\\p"); remember("/c/p");
504
+ console.log(JSON.stringify([require("node:fs").readFileSync(REGISTRY, "utf8"), knownProjects()]));`;
505
+ try {
506
+ const p = Bun.spawnSync(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, USERPROFILE: home } });
507
+ if (p.exitCode !== 0) throw new Error(p.stderr.toString());
508
+ expect(JSON.parse(p.stdout.toString())).toEqual(["C:\\p\n", ["C:\\p"]]);
509
+ } finally { rmSync(home, { recursive: true, force: true }); }
510
+ });
511
+
512
+ test("files saved with CRLF (PowerShell, autocrlf clones) read the same as LF", () => {
513
+ const lf = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen"), crlf = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
514
+ const files = {
515
+ "config.yml": "agent:\n default: claude\n claude: { model: opus, effort: high }\n",
516
+ "inbox.md": "# Inbox\n\n- open: add retry\n- rejected: drop cache\n",
517
+ "runs/2026-10-01-a/state.json": JSON.stringify({ stage: "build", status: "running" }, null, 2) + "\n",
518
+ "runs/2026-10-01-a/00-request.md": "# Request\n\n> /kaizen add retry\n",
519
+ };
520
+ for (const [dir, eol] of [[lf, "\n"], [crlf, "\r\n"]] as const)
521
+ for (const [f, text] of Object.entries(files)) {
522
+ mkdirSync(dirname(join(dir, f)), { recursive: true });
523
+ writeFileSync(join(dir, f), text.replace(/\n/g, eol));
524
+ }
525
+ expect(agentFlags(dirname(crlf), "claude")).toEqual(["--model", "opus", "--effort", "high"]);
526
+ expect(readInbox(crlf)).toEqual(readInbox(lf));
527
+ const runs = (s: string) => readRuns(s).map(({ moved, ...r }) => ({ ...r, dir: undefined }));
528
+ expect(runs(crlf)).toEqual(runs(lf));
529
+ });
530
+
531
+ // A project whose agent sessions are recorded: close on, one run asked for "tidy logs".
532
+ const sessionProject = () => {
533
+ const p = mkdtempSync(join(tmpdir(), "kz-sess-"));
534
+ mkdirSync(join(p, ".kaizen", "runs"), { recursive: true });
535
+ writeFileSync(join(p, ".kaizen", "config.yml"), "agent:\n default: claude\n close: on\n");
536
+ return p;
537
+ };
538
+ const newRun = (p: string, id: string, request: string, s: object = { stage: "plan", awaiting: "approvals.plan" }) => {
539
+ mkdirSync(join(p, ".kaizen", "runs", id), { recursive: true });
540
+ writeFileSync(join(p, ".kaizen", "runs", id, "00-request.md"), `# Request (verbatim)\n\n${request}\n`);
541
+ writeFileSync(join(p, ".kaizen", "runs", id, "state.json"), JSON.stringify(s));
542
+ };
543
+
544
+ test("sessionLaunch: claude gets a conversation id, a decision resumes it while its transcript exists, codex starts fresh", () => {
545
+ const p = sessionProject(), st = join(p, ".kaizen");
546
+ const saved = process.env.CLAUDE_CONFIG_DIR;
547
+ process.env.CLAUDE_CONFIG_DIR = mkdtempSync(join(tmpdir(), "kz-claude-"));
548
+ try {
549
+ const idea = sessionLaunch(p, "claude", ["/bin/claude", "--model", "opus"], "/kaizen tidy logs", "tidy logs");
550
+ const [rec] = readSessions(st);
551
+ expect(rec.sessionId).toMatch(/^[0-9a-f-]{36}$/);
552
+ expect(idea.fullCmd).toEqual(["/bin/claude", "--model", "opus", "--session-id", rec.sessionId!, "/kaizen tidy logs"]);
553
+ expect(idea.pidFile).toBe(join(st, "sessions", rec.key + ".pid"));
554
+ newRun(p, "2026-10-01-tidy-logs", "/kaizen tidy logs");
555
+ expect(ownerOf(st, rec)).toBe("2026-10-01-tidy-logs");
556
+
557
+ // No transcript on disk: a fresh session, with an id of its own.
558
+ const fresh = sessionLaunch(p, "claude", ["/bin/claude"], "/kaizen run x: approved", "run x: approved", "2026-10-01-tidy-logs");
559
+ expect(fresh.fullCmd[1]).toBe("--session-id");
560
+ expect(fresh.fullCmd[2]).not.toBe(rec.sessionId);
561
+
562
+ const t = join(process.env.CLAUDE_CONFIG_DIR, "projects", p.replace(/[^A-Za-z0-9]/g, "-"));
563
+ mkdirSync(t, { recursive: true });
564
+ writeFileSync(join(t, `${rec.sessionId}.jsonl`), "{}\n");
565
+ const back = sessionLaunch(p, "claude", ["/bin/claude"], "/kaizen run x: approved", "run x: approved", "2026-10-01-tidy-logs");
566
+ expect(back.fullCmd).toEqual(["/bin/claude", "--resume", rec.sessionId!, "/kaizen run x: approved"]);
567
+ expect(readSessions(st).filter((r) => r.run === "2026-10-01-tidy-logs" && r.sessionId === rec.sessionId)).toHaveLength(1);
568
+
569
+ // Another tool's record never resumes; codex takes no id at all.
570
+ expect(sessionLaunch(p, "codex", ["/bin/codex"], "/kaizen run x: approved", "x", "2026-10-01-tidy-logs").fullCmd).toEqual(["/bin/codex", "/kaizen run x: approved"]);
571
+ } finally {
572
+ saved === undefined ? delete process.env.CLAUDE_CONFIG_DIR : (process.env.CLAUDE_CONFIG_DIR = saved);
573
+ }
574
+ // A folder with no state dir gets no record, and no .kaizen made for one.
575
+ const bare = mkdtempSync(join(tmpdir(), "kz-bare-"));
576
+ expect(sessionLaunch(bare, "claude", ["claude"], "/kaizen x", "x")).toMatchObject({ fullCmd: ["claude", "/kaizen x"], pidFile: undefined });
577
+ expect(existsSync(join(bare, ".kaizen"))).toBe(false);
578
+ });
579
+
580
+ test("ownerOf: an idea's session belongs to the first run created after it, not an older one with the same words", () => {
581
+ const p = sessionProject(), st = join(p, ".kaizen");
582
+ newRun(p, "2026-09-01-old", "/kaizen fix the login page");
583
+ const rec = { key: "k", run: null, text: "fix the login page", cmd: "claude", sessionId: null, started: Date.now() + 5000 };
584
+ expect(ownerOf(st, rec)).toBe(null);
585
+ expect(ownerOf(st, { ...rec, started: 0 })).toBe("2026-09-01-old");
586
+ expect(ownerOf(st, { ...rec, run: "2026-10-01-x" })).toBe("2026-10-01-x");
587
+ });
588
+
589
+ test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a PowerShell prefix on Windows", () => {
590
+ const keys = ["PATH", "HERDR_ENV", "TMUX", "TERMINAL", "SystemRoot", "windir", "ComSpec"];
591
+ const saved = keys.map((k) => process.env[k]);
592
+ const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
593
+ const bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
594
+ try {
595
+ process.env.PATH = bin;
596
+ for (const k of keys.slice(1)) delete process.env[k];
597
+ writeFileSync(join(bin, "tmux"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
598
+ if (process.platform !== "win32") {
599
+ const t = findTerminal("/p", ["claude", "/kaizen x"], "/s/k.pid")!;
600
+ expect(t.cmd.slice(4)).toEqual(["sh", "-c", 'echo $$ > "$0"; exec "$@"', "/s/k.pid", "claude", "/kaizen x"]);
601
+ }
602
+ Object.defineProperty(process, "platform", { value: "win32" });
603
+ process.env.SystemRoot = "D:\\Win";
604
+ const w = findTerminal("C:\\p", ["claude", "/kaizen x"], "C:\\p\\.kaizen\\sessions\\k.pid")!;
605
+ const ps = Buffer.from(w.cmd.at(-1)!, "base64").toString("utf16le");
606
+ expect(ps).toBe("$PID | Set-Content -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid'; Set-Location 'C:\\p'; & 'claude' '/kaizen x'");
607
+ } finally {
608
+ Object.defineProperty(process, "platform", platform);
609
+ keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
610
+ rmSync(bin, { recursive: true, force: true });
611
+ }
612
+ });
613
+
614
+ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own live sessions launched before the decision", async () => {
615
+ const p = sessionProject(), st = join(p, ".kaizen"), dir = join(st, "sessions");
616
+ newRun(p, "2026-10-01-a", "/kaizen a");
617
+ mkdirSync(dir, { recursive: true });
618
+ // A stand-in agent through the real wrapper: `kzagent <id>` in its arguments, as
619
+ // `claude --session-id <id>` would be. `; :` keeps sh from exec'ing sleep itself.
620
+ const start = (key: string, rec: object) => {
621
+ writeFileSync(join(dir, key + ".json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: "sid-" + key, started: Date.now() - 5000, ...rec }));
622
+ return Bun.spawn(["sh", "-c", 'echo $$ > "$0"; exec "$@"', join(dir, key + ".pid"), "sh", "-c", "sleep 30; :", "kzagent", "sid-" + key], { stdout: "ignore", stderr: "ignore" });
623
+ };
624
+ // Killed by a signal, a process keeps exitCode null; signalCode says it died.
625
+ const alive = (pr: ReturnType<typeof Bun.spawn>) => pr.exitCode === null && pr.signalCode === null;
626
+ const procs: Record<string, ReturnType<typeof Bun.spawn>> = {
627
+ mine: start("mine", {}),
628
+ later: start("later", { started: Date.now() + 60_000 }), // the decision's own successor
629
+ stranger: start("stranger", { cmd: "notkzagent" }), // a PID that is not this agent
630
+ other: start("other", { run: "2026-10-01-b" }), // another run's
631
+ };
632
+ for (let i = 0; i < 100 && Object.keys(procs).some((k) => !existsSync(join(dir, k + ".pid"))); i++) await Bun.sleep(20);
633
+ writeFileSync(join(dir, "dead.json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: null, started: 0 }));
634
+ writeFileSync(join(dir, "dead.pid"), "999999\n");
635
+ try {
636
+ expect(closeSessions(st, "2026-10-01-a", { before: Date.now() })).toBe(1);
637
+ expect(await procs.mine.exited).not.toBe(0);
638
+ for (const k of ["later", "stranger", "other"] as const) expect(alive(procs[k])).toBe(true);
639
+ expect(existsSync(join(dir, "mine.pid"))).toBe(false);
640
+ expect(existsSync(join(dir, "mine.json"))).toBe(true); // its conversation id is still needed
641
+ expect(existsSync(join(dir, "dead.pid"))).toBe(false);
642
+ expect(existsSync(join(dir, "later.pid"))).toBe(true);
643
+
644
+ // Off: nothing is ended.
645
+ writeFileSync(join(st, "config.yml"), "agent:\n default: claude\n close: off\n");
646
+ expect(closeSessions(st, "2026-10-01-b")).toBe(0);
647
+ expect(alive(procs.other)).toBe(true);
648
+ writeFileSync(join(st, "config.yml"), "agent:\n default: claude\n close: on\n");
649
+
650
+ // The sweep: run b done within the grace keeps its window, past it loses it.
651
+ newRun(p, "2026-10-01-b", "/kaizen b", { stage: "done", awaiting: null });
652
+ sweepSessions(st, Date.now());
653
+ await Bun.sleep(100);
654
+ expect(alive(procs.other)).toBe(true);
655
+ sweepSessions(st, Date.now() + CLOSE_GRACE_MS);
656
+ expect(await procs.other.exited).not.toBe(0);
657
+ expect(alive(procs.later)).toBe(true); // run a is still waiting
658
+
659
+ // A fix launched on a done run, after it last moved, is doing the work: kept.
660
+ const fix = start("fix", { run: "2026-10-01-b", started: Date.now() + 1000 });
661
+ procs.fix = fix;
662
+ for (let i = 0; i < 100 && !existsSync(join(dir, "fix.pid")); i++) await Bun.sleep(20);
663
+ sweepSessions(st, Date.now() + 2 * CLOSE_GRACE_MS);
664
+ await Bun.sleep(100);
665
+ expect(alive(fix)).toBe(true);
666
+
667
+ // An abandoned run is left to abandonRun; the sweep does not close it, even a
668
+ // session launched long before the run last moved.
669
+ const gone = start("gone", { started: 0 });
670
+ procs.gone = gone;
671
+ for (let i = 0; i < 100 && !existsSync(join(dir, "gone.pid")); i++) await Bun.sleep(20);
672
+ writeFileSync(join(st, "runs", "2026-10-01-a", "state.json"), JSON.stringify({ stage: "abandoned", awaiting: null }));
673
+ sweepSessions(st, Date.now() + 2 * CLOSE_GRACE_MS);
674
+ await Bun.sleep(100);
675
+ expect(alive(gone)).toBe(true);
676
+ } finally {
677
+ for (const pr of Object.values(procs)) pr.kill();
678
+ }
679
+ });
680
+
681
+ test("abandonRun closes the run's recorded sessions", () => {
682
+ const p = sessionProject(), st = join(p, ".kaizen");
683
+ newRun(p, "2026-10-01-z", "/kaizen z");
684
+ mkdirSync(join(st, "sessions"));
685
+ writeFileSync(join(st, "sessions", "k.json"), JSON.stringify({ run: "2026-10-01-z", text: "z", cmd: "kzagent", sessionId: null, started: 0 }));
686
+ writeFileSync(join(st, "sessions", "k.pid"), "999999\n");
687
+ expect(abandonRun(st, "2026-10-01-z", "no")).toBe(null);
688
+ expect(existsSync(join(st, "sessions", "k.pid"))).toBe(false);
689
+ });
package/cli/state.ts CHANGED
@@ -80,7 +80,9 @@ const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
80
80
  // Windows PowerShell 5.1 writes `Set-Content -Encoding utf8` with a byte order mark,
81
81
  // and agents on Windows write run files that way. JSON.parse refuses a leading BOM
82
82
  // (the run vanished from the board) and it hides a first line from every ^ regex.
83
- export const readText = (file: string) => readFileSync(file, "utf8").replace(/^\uFEFF/, "");
83
+ // PowerShell and Git for Windows clones (autocrlf) also write CRLF, which breaks
84
+ // every `key:\n` regex the same silent way, so line endings are made LF here too.
85
+ export const readText = (file: string) => readFileSync(file, "utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
84
86
 
85
87
  const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
86
88
 
@@ -98,13 +100,14 @@ function answers(cmd: string[]): boolean {
98
100
  const bin = onPath(cmd[0]);
99
101
  if (!bin) return false;
100
102
  try {
101
- return Bun.spawnSync([bin, ...cmd.slice(1)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 2000 }).exitCode === 0;
103
+ return Bun.spawnSync([bin, ...cmd.slice(1)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 2000, windowsHide: true }).exitCode === 0;
102
104
  } catch {
103
105
  return false;
104
106
  }
105
107
  }
106
108
 
107
- export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; detached: boolean } | null {
109
+ // pidFile: where the launched agent writes its own PID, so closeSessions can end it.
110
+ export function findTerminal(cwd: string, fullCmd: string[], pidFile?: string): { cmd: string[]; detached: boolean } | null {
108
111
  const hasDisplay = Boolean(process.env.WAYLAND_DISPLAY || process.env.DISPLAY);
109
112
 
110
113
  // Windows has no xdg anything, and none of the terminals below exist there.
@@ -116,15 +119,17 @@ export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; d
116
119
  if (process.platform === "win32") {
117
120
  const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
118
121
  const shell = powershellExe();
119
- const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
122
+ // This PowerShell's PID: ending its tree closes the -NoExit tab or window.
123
+ const ps = `${pidFile ? `$PID | Set-Content -LiteralPath ${q(pidFile)}; ` : ""}Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
120
124
  const enc = Buffer.from(ps, "utf16le").toString("base64");
121
125
  // herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
122
126
  // its PowerShell. Typed text ends at a newline, so what is typed is one line, the
123
127
  // same encoded command; -EncodedCommand is also not a script file, so an
124
- // execution policy that blocks .ps1 files does not stop it.
128
+ // execution policy that blocks .ps1 files does not stop it. The trailing exit
129
+ // closes the tab once the run's PowerShell ends.
125
130
  const herdr = onPath("herdr");
126
131
  if (herdr && answers([herdr, "workspace", "list"])) {
127
- const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}`;
132
+ const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}; exit`;
128
133
  const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
129
134
  if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
130
135
  return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
@@ -146,11 +151,18 @@ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(ty
146
151
  // in, so $TMUX or $HERDR_ENV only says what was running then. herdr has no "new tab
147
152
  // running this", and what it sends a pane is typed into an interactive shell, which
148
153
  // eats tabs and quotes its own way -- so the line goes into a one-shot script and
149
- // only `sh <path>` is typed.
154
+ // only `exec sh <path>` is typed. The exec is what closes the tab when the agent
155
+ // ends: left behind, the pane's own shell would sit at a prompt.
150
156
  // ponytail: the mktemp path is typed unquoted; quote it if a TMPDIR with spaces shows up.
157
+ // The wrapper writes its PID and then becomes the agent, so the PID is the agent's
158
+ // on every branch below, whatever terminal or launcher sits in front of it.
159
+ if (pidFile) fullCmd = ["sh", "-c", 'echo $$ > "$0"; exec "$@"', pidFile, ...fullCmd];
151
160
  const line = fullCmd.map(shq).join(" ");
161
+ // The one-shot scripts below end the tab when the agent ends, but a launch that
162
+ // never started (126/127: not executable, not found) holds it open on its error.
163
+ const hold = 's=$?; [ $s = 126 ] || [ $s = 127 ] || exit $s; printf "kaizen: the agent did not start. Press Enter to close. "; read _';
152
164
  if (answers(["herdr", "workspace", "list"])) {
153
- const script = `f=$(mktemp) && printf 'rm -f "$0"\\nexec %s\\n' "$2" > "$f" && p=$(herdr tab create --cwd "$1" --label kaizen --focus | sed -n 's/.*"pane_id":"\\([^"]*\\)".*/\\1/p') && [ -n "$p" ] && herdr pane run "$p" "sh $f"`;
165
+ const script = `f=$(mktemp) && printf 'rm -f "$0"\\n%s\\n${hold}\\n' "$2" > "$f" && p=$(herdr tab create --cwd "$1" --label kaizen --focus | sed -n 's/.*"pane_id":"\\([^"]*\\)".*/\\1/p') && [ -n "$p" ] && herdr pane run "$p" "exec sh $f"`;
154
166
  return { cmd: ["sh", "-c", script, "sh", cwd, line], detached: false };
155
167
  }
156
168
  if (answers(["tmux", "has-session"])) {
@@ -186,8 +198,17 @@ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(ty
186
198
 
187
199
  if (process.platform === "darwin") {
188
200
  // Terminal types what it is given into the login shell, so, as with herdr, the
189
- // line goes into a one-shot script and only `sh <path>` is typed.
190
- const script = `f=$(mktemp) && printf 'rm -f "$0"\\ncd %s && exec %s\\n' "$1" "$2" > "$f" && osascript -e "tell application \\"Terminal\\" to do script \\"sh $f\\"" -e 'tell application "Terminal" to activate'`;
201
+ // line goes into a one-shot script and only `exec sh <path>` is typed. Whether the
202
+ // window closes when that ends is a profile setting kaizen cannot reach, so the
203
+ // launcher waits for the tab to run something, then to run nothing, and closes it:
204
+ // a tab with nothing running closes without Terminal asking first.
205
+ // ponytail: untested on a real Mac; the try leaves the window as it was if any step fails.
206
+ const close = ["tell application \"Terminal\"", "set t to do script \"exec sh $f\"", "activate", "try",
207
+ "repeat 150 times", "if busy of t then exit repeat", "delay 0.2", "end repeat",
208
+ "repeat while busy of t", "delay 1", "end repeat",
209
+ "repeat with w in windows", "if tabs of w contains t then close w", "end repeat",
210
+ "end try", "end tell"].map((l) => `-e ${JSON.stringify(l)}`).join(" ");
211
+ const script = `f=$(mktemp) && printf 'rm -f "$0"\\n{ cd %s || (exit 127); } && %s\\n${hold}\\n' "$1" "$2" > "$f" && osascript ${close}`;
191
212
  return { cmd: ["/bin/sh", "-c", script, "sh", shq(cwd), line], detached: true };
192
213
  }
193
214
 
@@ -236,6 +257,7 @@ export function nativePath(p: string) {
236
257
  // dashboard to stop opening.
237
258
  export function remember(projectDir: string) {
238
259
  try {
260
+ projectDir = nativePath(projectDir);
239
261
  if (knownProjects().includes(projectDir)) return;
240
262
  mkdirSync(dirname(REGISTRY), { recursive: true });
241
263
  appendFileSync(REGISTRY, projectDir + "\n");
@@ -275,13 +297,15 @@ export function findProjects(root: string, depth = 4): string[] {
275
297
  return found;
276
298
  }
277
299
 
300
+ // The same spelling knownProjects() gives: a terminal handing out c:\... (lower-case
301
+ // drive) would otherwise make every POST from the board fail "unknown state dir".
278
302
  export function locate() {
279
- let dir = process.cwd();
303
+ let dir = nativePath(process.cwd());
280
304
  for (;;) {
281
305
  if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
282
306
  if (existsSync(join(dir, ".git"))) return null;
283
307
  const up = dirname(dir);
284
- if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
308
+ if (up === dir) return existsSync(join(home, ".kaizen")) ? nativePath(join(home, ".kaizen")) : null;
285
309
  dir = up;
286
310
  }
287
311
  }
@@ -419,6 +443,23 @@ export function fixPrompt(id: string, nums: number[]) {
419
443
  + `Leave every other finding alone, and mark the fixed ones done in its 06-backlog.md.`;
420
444
  }
421
445
 
446
+ // The board's answer to an approval the run is waiting on. The comment is collapsed to
447
+ // one line: where the prompt is typed into a shell (herdr), a newline sends it early.
448
+ // Answers are the plan's open questions answered at the approval, one per question.
449
+ export function approvalPrompt(id: string, awaiting: string, why?: string, answers?: string[]) {
450
+ const plan = awaiting === "approvals.plan";
451
+ const one = (t: string) => t.replace(/\s+/g, " ").trim();
452
+ const note = why === undefined ? undefined : one(why);
453
+ if (plan && !note && answers?.length) return `run ${id}: the plan is approved from the board, with answers to its open questions: `
454
+ + answers.map((a, i) => `${i + 1}) ${one(a).replace(/[.;]$/, "")}`).join("; ") + `. Record them in 02-approval.md and build the plan with them.`;
455
+ if (!note) return plan
456
+ ? `run ${id}: the plan is approved from the board. Record it in 02-approval.md and build it, first asking any of its open questions still unanswered.`
457
+ : `run ${id}: the work is approved from the board at the final approval. Record it, write the backlog and finish the run.`;
458
+ return plan
459
+ ? `run ${id}: the plan is sent back from the board to revise: "${note}". Record it in 02-approval.md, revise 01-plan.md with it, and stop at the plan approval again.`
460
+ : `run ${id}: the work is sent back from the board at the final approval: "${note}". Record it in 02-approval.md, change the work to address it, recheck, and stop at the final approval again.`;
461
+ }
462
+
422
463
  // Backlog items are often a reviewer's finding pasted verbatim -- a backticked
423
464
  // path, a severity, then the sentence. Split those apart so a list can show the
424
465
  // severity and the place as columns and leave the prose to speak for itself.
@@ -716,6 +757,7 @@ export function abandonRun(st: string, id: string, why: string): string | null {
716
757
  // resume contract every kaizen tool reads.
717
758
  appendFileSync(join(st, "runs", id, "02-approval.md"),
718
759
  `\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
760
+ closeSessions(st, id);
719
761
  return null;
720
762
  } catch (err: any) {
721
763
  return err?.message ?? String(err);
@@ -851,7 +893,158 @@ export function manualCommand(projectDir: string, agentCmd: string, prompt: stri
851
893
  : `cd ${q(projectDir)} && ${bin} ${q(prompt)}`;
852
894
  }
853
895
 
854
- export function launchRun(it: Item, from: string): Launch {
896
+ // Agent sessions kaizen opened. Each launch leaves `sessions/<key>.json` in the
897
+ // project's state dir ({run, text, cmd, sessionId, started}) and `<key>.pid`, which
898
+ // the agent writes itself. Only these are ever closed or resumed: a session the user
899
+ // opened by hand has no record.
900
+ export type Session = { key: string; run: string | null; text: string; cmd: string; sessionId: string | null; started: number };
901
+
902
+ // Tools that take a conversation id at launch and resume it by that id. Every other
903
+ // tool gets a fresh session on each decision, as before.
904
+ const SESSION_FLAGS: Record<string, { start: (id: string) => string[]; resume: (id: string) => string[]; transcript: (cwd: string, id: string) => string }> = {
905
+ claude: {
906
+ start: (id) => ["--session-id", id],
907
+ resume: (id) => ["--resume", id],
908
+ transcript: (cwd, id) => join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/[^A-Za-z0-9]/g, "-"), `${id}.jsonl`),
909
+ },
910
+ };
911
+
912
+ export const CLOSE_GRACE_MS = 2 * 60_000;
913
+ const sessionsDir = (st: string) => join(st, "sessions");
914
+
915
+ export function readSessions(st: string): Session[] {
916
+ const dir = sessionsDir(st);
917
+ if (!existsSync(dir)) return [];
918
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).flatMap((f) => {
919
+ try { return [{ ...JSON.parse(readText(join(dir, f))), key: f.slice(0, -5) }]; } catch { return []; }
920
+ }).sort((a, b) => a.started - b.started);
921
+ }
922
+
923
+ // Whose session a record is. A decision's launch names its run. An idea's launch came
924
+ // before its run existed, so it belongs to the first run created after it whose
925
+ // request holds the idea's text: one run per record, never every run that mentions it.
926
+ export function ownerOf(st: string, s: Session): string | null {
927
+ if (s.run) return s.run;
928
+ const t = normalise(s.text);
929
+ let best: string | null = null, at = Infinity;
930
+ try {
931
+ for (const id of readdirSync(join(st, "runs"))) {
932
+ try {
933
+ const f = join(st, "runs", id, "00-request.md");
934
+ const st2 = statSync(f), born = st2.birthtimeMs || st2.mtimeMs;
935
+ // A second of slack: file times come from a coarser clock than Date.now().
936
+ if (born >= s.started - 1000 && born < at && normalise(readText(f)).includes(t)) { best = id; at = born; }
937
+ } catch { /* no request yet */ }
938
+ }
939
+ } catch { /* no runs dir */ }
940
+ return best;
941
+ }
942
+
943
+ // agent.close in the config chain: on unless it says off.
944
+ export function closeEnabled(projectDir: string) {
945
+ for (const f of [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")]) {
946
+ try {
947
+ const m = /^agent:\n(?:[ \t]+.*\n)*?[ \t]+close:[ \t]*(\S+)/m.exec(readText(f));
948
+ if (m) return !/^(off|false|no)$/i.test(m[1]);
949
+ } catch { /* absent */ }
950
+ }
951
+ return true;
952
+ }
953
+
954
+ // Whether a live PID is still the agent a record launched, not a stranger that reused
955
+ // the number: the tool's name (and conversation id) in its arguments, started no
956
+ // earlier than the record. On Windows the PID is the PowerShell kaizen opened.
957
+ function isSession(pid: number, s: Session): boolean {
958
+ try {
959
+ if (process.platform === "win32") {
960
+ const out = Bun.spawnSync([sysExe("tasklist.exe"), "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { stdin: "ignore", timeout: 5000, windowsHide: true }).stdout.toString();
961
+ return /^"(powershell|pwsh)\.exe"/i.test(out.trim());
962
+ }
963
+ const ps = onPath("ps");
964
+ if (!ps) return false;
965
+ const m = /^\s*(\S+)\s+(.*)$/.exec(Bun.spawnSync([ps, "-o", "etime=,args=", "-p", String(pid)], { stdin: "ignore", timeout: 5000 }).stdout.toString().trim());
966
+ if (!m) return false;
967
+ // etime is [[dd-]hh:]mm:ss
968
+ const [d, rest] = m[1].includes("-") ? m[1].split("-") : ["0", m[1]];
969
+ const secs = rest.split(":").reduce((a, n) => a * 60 + Number(n), 0) + Number(d) * 86400;
970
+ return Date.now() - secs * 1000 >= s.started - 2000 && m[2].includes(s.cmd) && (!s.sessionId || m[2].includes(s.sessionId));
971
+ } catch {
972
+ return false;
973
+ }
974
+ }
975
+
976
+ // End one recorded session if it is still running, and drop its PID file either way:
977
+ // without one, the record is never looked at for closing again.
978
+ function closeOne(st: string, s: Session): boolean {
979
+ const pidFile = join(sessionsDir(st), s.key + ".pid");
980
+ let closed = false;
981
+ try {
982
+ const pid = parseInt(readText(pidFile));
983
+ if (pid > 0 && isSession(pid, s)) {
984
+ if (process.platform === "win32") Bun.spawnSync([sysExe("taskkill.exe"), "/T", "/F", "/PID", String(pid)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5000, windowsHide: true });
985
+ else process.kill(pid, "SIGTERM");
986
+ closed = true;
987
+ }
988
+ } catch { /* already gone */ }
989
+ rmSync(pidFile, { force: true });
990
+ return closed;
991
+ }
992
+
993
+ const openSessions = (st: string) => readSessions(st).filter((s) => existsSync(join(sessionsDir(st), s.key + ".pid")));
994
+
995
+ // Close every session kaizen opened for a run, those launched before `before` only,
996
+ // so a decision's own successor is never caught. The record stays: it holds the
997
+ // conversation id the next decision resumes. Never throws.
998
+ export function closeSessions(st: string, id: string, opts: { before?: number } = {}): number {
999
+ try {
1000
+ if (!closeEnabled(dirname(st))) return 0;
1001
+ return openSessions(st).filter((s) => s.started < (opts.before ?? Infinity) && ownerOf(st, s) === id).filter((s) => closeOne(st, s)).length;
1002
+ } catch { return 0; }
1003
+ }
1004
+
1005
+ // The board's minute sweep: sessions of runs done for longer than the grace, so the
1006
+ // final summary can still be read first. Only sessions launched before the run last
1007
+ // moved: a fix started on a done run leaves it done until that agent writes, and is
1008
+ // the one doing the work. Abandoned runs are closed by abandonRun, not here.
1009
+ export function sweepSessions(st: string, now: number) {
1010
+ try {
1011
+ const live = openSessions(st);
1012
+ if (!live.length || !closeEnabled(dirname(st))) return;
1013
+ const runs = new Map(readRuns(st).map((r) => [r.id, r]));
1014
+ for (const s of live) {
1015
+ const r = runs.get(ownerOf(st, s) ?? "");
1016
+ if (r && r.stage === "done" && s.started <= r.moved && now - r.moved >= CLOSE_GRACE_MS) closeOne(st, s);
1017
+ }
1018
+ } catch { /* the next sweep tries again */ }
1019
+ }
1020
+
1021
+ // The run's conversation to continue: its first recorded one, on this tool, whose
1022
+ // transcript is still on disk. None means a fresh session, never a guess.
1023
+ export function firstSession(st: string, id: string, cmd: string, cwd: string): Session | null {
1024
+ const f = SESSION_FLAGS[cmd];
1025
+ if (!f) return null;
1026
+ return readSessions(st).find((s) => s.sessionId && s.cmd === cmd && ownerOf(st, s) === id && existsSync(f.transcript(cwd, s.sessionId))) ?? null;
1027
+ }
1028
+
1029
+ // The agent's argv for a launch, and its record. With a run, a decision: it resumes
1030
+ // the run's conversation when there is one. A project without a state dir gets no
1031
+ // record; making one here would shadow the user's.
1032
+ export function sessionLaunch(projectDir: string, cmd: string, base: string[], prompt: string, text: string, run?: string) {
1033
+ const st = join(projectDir, ".kaizen");
1034
+ if (!existsSync(st)) return { fullCmd: [...base, prompt], pidFile: undefined, record: undefined };
1035
+ const f = SESSION_FLAGS[cmd];
1036
+ const prev = run ? firstSession(st, run, cmd, projectDir) : null;
1037
+ const sessionId = f ? prev?.sessionId ?? crypto.randomUUID() : null;
1038
+ const fullCmd = [...base, ...(f && sessionId ? (prev ? f.resume(sessionId) : f.start(sessionId)) : []), prompt];
1039
+ const key = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1040
+ mkdirSync(sessionsDir(st), { recursive: true });
1041
+ const record = join(sessionsDir(st), key + ".json");
1042
+ writeFileSync(record, JSON.stringify({ run: run ?? null, text, cmd, sessionId, started: Date.now() }) + "\n");
1043
+ return { fullCmd, pidFile: join(sessionsDir(st), key + ".pid"), record };
1044
+ }
1045
+
1046
+ // run: the run a decision is for. Without it, an idea starting a new run.
1047
+ export function launchRun(it: Item, from: string, run?: string): Launch {
855
1048
  const projectDir = projectOf(from);
856
1049
  // The run will be born here; the board must know the dir or the idea never retires.
857
1050
  remember(projectDir);
@@ -859,11 +1052,14 @@ export function launchRun(it: Item, from: string): Launch {
859
1052
  const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
860
1053
  const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
861
1054
  const flags = agentFlags(projectDir, agent.cmd);
862
- const fullCmd = [agentBin, ...flags, prompt];
1055
+ // The idea as its run's request will hold it: without the runner word in front.
1056
+ const s = sessionLaunch(projectDir, agent.cmd, [agentBin, ...flags], prompt, it.text.replace(/^(lite|full|auto|plan)\s+/i, ""), run);
863
1057
  const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
1058
+ const drop = () => { if (s.record) rmSync(s.record, { force: true }); };
864
1059
 
865
- const term = findTerminal(projectDir, fullCmd);
1060
+ const term = findTerminal(projectDir, s.fullCmd, s.pidFile);
866
1061
  if (!term) {
1062
+ drop();
867
1063
  return {
868
1064
  ok: false, agent, prompt, projectDir, manual,
869
1065
  why: process.platform === "darwin"
@@ -878,10 +1074,14 @@ export function launchRun(it: Item, from: string): Launch {
878
1074
  stdout: "ignore",
879
1075
  stderr: "ignore",
880
1076
  detached: term.detached,
1077
+ // A detached launch is the terminal window itself; anything else (herdr's
1078
+ // launcher) is plumbing, and must not flash a console on Windows.
1079
+ windowsHide: !term.detached,
881
1080
  });
882
1081
  if (term.detached) proc.unref();
883
1082
  return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
884
1083
  } catch (err: any) {
1084
+ drop();
885
1085
  return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
886
1086
  }
887
1087
  }
@@ -902,7 +1102,9 @@ export async function pidOnPort(port: number): Promise<number | null> {
902
1102
  // ---- git, for the board's commit and push. Always argv, never a shell: the commit
903
1103
  // message is whatever the user typed. A credential prompt has no terminal to appear
904
1104
  // in, so git is told not to ask and is given a minute at most.
905
- const gitOpts = () => ({ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, stdin: "ignore" as const, timeout: 60_000 });
1105
+ // windowsHide: a board started from the Windows shortcut has no console, so each
1106
+ // git it runs would open one of its own -- a window flashing on every refresh.
1107
+ const gitOpts = () => ({ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }, stdin: "ignore" as const, timeout: 60_000, windowsHide: true });
906
1108
  const gitDone = (code: number | null, stdout: string, stderr: string) => {
907
1109
  const out = stdout.trimEnd(), err = stderr.trim();
908
1110
  return { ok: code === 0, out, why: (err || out).split("\n").pop() || "git did not finish" };