kaizen-agent 1.9.4 → 1.11.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.ts CHANGED
@@ -3,7 +3,7 @@
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
5
  import { join, dirname, win32 } from "node:path";
6
- import { homedir } from "node:os";
6
+ import { homedir, arch, cpus, release, totalmem, version as osVersion } from "node:os";
7
7
 
8
8
  // The real path: process.cwd() is always physical, so a raw $HOME that crosses a
9
9
  // symlink (/home -> var/home) would never compare equal to it.
@@ -20,25 +20,34 @@ export const KNOWN_AGENTS = [
20
20
  { name: "Cursor", dir: ".cursor", cmd: "cursor" },
21
21
  ];
22
22
 
23
- export function detectDefaultAgent(projectDir: string): { name: string; cmd: string } {
24
- const cfgFile = existsSync(join(projectDir, ".kaizen", "config.yml"))
25
- ? join(projectDir, ".kaizen", "config.yml")
26
- : join(home, ".kaizen", "config.yml");
27
- if (existsSync(cfgFile)) {
28
- const text = readText(cfgFile);
29
- const m = /^\s*agent:\s*\n\s*default:\s*(\S+)/m.exec(text) || /^\s*agent\.default:\s*(\S+)/m.exec(text);
30
- if (m && m[1] && m[1] !== "auto") {
31
- const found = KNOWN_AGENTS.find((a) => a.cmd === m[1] || a.name.toLowerCase() === m[1].toLowerCase());
32
- if (found && Bun.which(found.cmd)) return found;
33
- }
23
+ export type Agent = { name: string; cmd: string; note?: string };
24
+
25
+ // Each config.yml in the chain, project first: the first one that sets a key decides
26
+ // it, so a project file that is silent about `agent` leaves the global one in force.
27
+ const configTexts = (projectDir: string) =>
28
+ [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")].filter(existsSync).map(readText);
29
+
30
+ // `want` is a per-run pick; without one, agent.default from the config chain. A
31
+ // wanted tool that is not installed is swapped for the auto choice, and said so.
32
+ export function detectDefaultAgent(projectDir: string, want?: string): Agent {
33
+ if (!want) for (const t of configTexts(projectDir)) {
34
+ const m = /^agent:\n(?:[ \t]+.*\n)*?[ \t]+default:[ \t]*["']?([^"'\s]+)/m.exec(t) || /^agent\.default:[ \t]*["']?([^"'\s]+)/m.exec(t);
35
+ if (m) { want = m[1]; break; }
34
36
  }
37
+ if (!want || want === "auto") return autoAgent(projectDir);
38
+ const found = KNOWN_AGENTS.find((a) => a.cmd === want || a.name.toLowerCase() === want!.toLowerCase());
39
+ if (found && onPath(found.cmd)) return found;
40
+ const a = autoAgent(projectDir);
41
+ return { ...a, note: `${found?.name ?? want} is not installed; opened ${a.name} instead` };
42
+ }
35
43
 
44
+ function autoAgent(projectDir: string): Agent {
36
45
  for (const a of KNOWN_AGENTS) {
37
- if (existsSync(join(projectDir, a.dir)) && Bun.which(a.cmd)) return a;
46
+ if (existsSync(join(projectDir, a.dir)) && onPath(a.cmd)) return a;
38
47
  }
39
- if (Bun.which("claude")) return { name: "Claude Code", cmd: "claude" };
48
+ if (onPath("claude")) return { name: "Claude Code", cmd: "claude" };
40
49
  for (const a of KNOWN_AGENTS) {
41
- if (Bun.which(a.cmd)) return a;
50
+ if (onPath(a.cmd)) return a;
42
51
  }
43
52
  return { name: "Claude Code", cmd: "claude" };
44
53
  }
@@ -56,22 +65,53 @@ const EFFORT_FLAGS: Record<string, (e: string) => string[]> = {
56
65
  codex: (e) => ["-c", `model_reasoning_effort=${e}`],
57
66
  };
58
67
 
59
- export function agentFlags(projectDir: string, cmd: string, configText?: string): string[] {
60
- if (configText === undefined) {
61
- const f = [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")].find(existsSync);
62
- configText = f ? readText(f) : "";
63
- }
68
+ // Each tool's own skip-every-permission-prompt mode, taken only when the Run form's
69
+ // Yolo is picked for that launch. Flags as each tool's --help spells them.
70
+ export const YOLO_FLAGS: Record<string, string[]> = {
71
+ claude: ["--dangerously-skip-permissions"], agy: ["--dangerously-skip-permissions"],
72
+ codex: ["--dangerously-bypass-approvals-and-sandbox"], gemini: ["--yolo"], opencode: ["--auto"],
73
+ };
74
+
75
+ // How each tool takes the opening prompt and stays interactive. agy refuses a bare
76
+ // argument and exits; opencode reads one as the project folder to open.
77
+ const PROMPT_FLAGS: Record<string, string[]> = { agy: ["-i"], opencode: ["--prompt"] };
78
+ export const promptArgs = (cmd: string, prompt: string) => [...(PROMPT_FLAGS[cmd] ?? []), prompt];
79
+
80
+ // A model typed on the board reaches launch lines and the manual command as text, so
81
+ // only a plain name is taken: no spaces, quotes, `$`, `;`, backticks or globs.
82
+ export const plainModel = (m: string) => /^[A-Za-z0-9][\w.:\/@-]{0,79}$/.test(m);
83
+
84
+ // The tool's own block in the first config.yml of the chain that has one.
85
+ function agentConfig(projectDir: string, cmd: string, configText?: string) {
64
86
  // ponytail: regex over the yaml, like detectDefaultAgent; a parser when a third key needs it
65
- const block = new RegExp(`^agent:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${cmd}:[ \\t]*(\\{[^}]*\\}|\\n(?:[ \\t]+[a-z]+:.*\\n?)+)`, "m").exec(configText);
66
- if (!block) return [];
67
- const get = (k: string) => new RegExp(`\\b${k}:[ \\t]*["']?([^"',}\\s]+)`).exec(block[1])?.[1];
68
- const model = get("model"), effort = get("effort");
87
+ const re = new RegExp(`^agent:\\n(?:[ \\t]+.*\\n)*?[ \\t]+${cmd}:[ \\t]*(\\{[^}]*\\}|\\n(?:[ \\t]+[a-z]+:.*\\n?)+)`, "m");
88
+ let block: RegExpExecArray | null = null;
89
+ for (const t of configText === undefined ? configTexts(projectDir) : [configText]) if ((block = re.exec(t))) break;
90
+ const get = (k: string) => block && new RegExp(`\\b${k}:[ \\t]*["']?([^"',}\\s]+)`).exec(block[1])?.[1];
91
+ return { model: get("model") || undefined, effort: get("effort") || undefined };
92
+ }
93
+
94
+ // `model`: a per-run pick, in place of the configured one. Effort stays configured.
95
+ export function agentFlags(projectDir: string, cmd: string, configText?: string, model?: string): string[] {
96
+ const cfg = agentConfig(projectDir, cmd, configText);
97
+ const m = model || cfg.model;
69
98
  return [
70
- ...(model && MODEL_FLAGS[cmd] ? MODEL_FLAGS[cmd](model) : []),
71
- ...(effort && EFFORT_FLAGS[cmd] ? EFFORT_FLAGS[cmd](effort) : []),
99
+ ...(m && MODEL_FLAGS[cmd] ? MODEL_FLAGS[cmd](m) : []),
100
+ ...(cfg.effort && EFFORT_FLAGS[cmd] ? EFFORT_FLAGS[cmd](cfg.effort) : []),
72
101
  ];
73
102
  }
74
103
 
104
+ // What the board's Run form offers: the installed tools, the one it would pick, and
105
+ // each one's configured model to start the field from.
106
+ export function agentChoices(projectDir: string) {
107
+ return {
108
+ default: detectDefaultAgent(projectDir).cmd,
109
+ agents: KNOWN_AGENTS.filter((a) => onPath(a.cmd)).map((a) => ({
110
+ name: a.name, cmd: a.cmd, model: agentConfig(projectDir, a.cmd).model ?? "", models: !!MODEL_FLAGS[a.cmd], yolo: !!YOLO_FLAGS[a.cmd],
111
+ })),
112
+ };
113
+ }
114
+
75
115
  // One argument as a POSIX shell reads it literally: a prompt carries quotes, `$`,
76
116
  // backticks and, with notes, newlines.
77
117
  const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
@@ -80,7 +120,9 @@ const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
80
120
  // Windows PowerShell 5.1 writes `Set-Content -Encoding utf8` with a byte order mark,
81
121
  // and agents on Windows write run files that way. JSON.parse refuses a leading BOM
82
122
  // (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/, "");
123
+ // PowerShell and Git for Windows clones (autocrlf) also write CRLF, which breaks
124
+ // every `key:\n` regex the same silent way, so line endings are made LF here too.
125
+ export const readText = (file: string) => readFileSync(file, "utf8").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
84
126
 
85
127
  const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
86
128
 
@@ -104,7 +146,8 @@ function answers(cmd: string[]): boolean {
104
146
  }
105
147
  }
106
148
 
107
- export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; detached: boolean } | null {
149
+ // pidFile: where the launched agent writes its own PID, so closeSessions can end it.
150
+ export function findTerminal(cwd: string, fullCmd: string[], pidFile?: string): { cmd: string[]; detached: boolean } | null {
108
151
  const hasDisplay = Boolean(process.env.WAYLAND_DISPLAY || process.env.DISPLAY);
109
152
 
110
153
  // Windows has no xdg anything, and none of the terminals below exist there.
@@ -116,15 +159,17 @@ export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; d
116
159
  if (process.platform === "win32") {
117
160
  const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
118
161
  const shell = powershellExe();
119
- const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
162
+ // This PowerShell's PID: ending its tree closes the -NoExit tab or window.
163
+ const ps = `${pidFile ? `$PID | Set-Content -LiteralPath ${q(pidFile)}; ` : ""}Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
120
164
  const enc = Buffer.from(ps, "utf16le").toString("base64");
121
165
  // herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
122
166
  // its PowerShell. Typed text ends at a newline, so what is typed is one line, the
123
167
  // same encoded command; -EncodedCommand is also not a script file, so an
124
- // execution policy that blocks .ps1 files does not stop it.
168
+ // execution policy that blocks .ps1 files does not stop it. The trailing exit
169
+ // closes the tab once the run's PowerShell ends.
125
170
  const herdr = onPath("herdr");
126
171
  if (herdr && answers([herdr, "workspace", "list"])) {
127
- const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}`;
172
+ const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}; exit`;
128
173
  const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
129
174
  if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
130
175
  return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
@@ -146,11 +191,18 @@ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(ty
146
191
  // in, so $TMUX or $HERDR_ENV only says what was running then. herdr has no "new tab
147
192
  // running this", and what it sends a pane is typed into an interactive shell, which
148
193
  // eats tabs and quotes its own way -- so the line goes into a one-shot script and
149
- // only `sh <path>` is typed.
194
+ // only `exec sh <path>` is typed. The exec is what closes the tab when the agent
195
+ // ends: left behind, the pane's own shell would sit at a prompt.
150
196
  // ponytail: the mktemp path is typed unquoted; quote it if a TMPDIR with spaces shows up.
197
+ // The wrapper writes its PID and then becomes the agent, so the PID is the agent's
198
+ // on every branch below, whatever terminal or launcher sits in front of it.
199
+ if (pidFile) fullCmd = ["sh", "-c", 'echo $$ > "$0"; exec "$@"', pidFile, ...fullCmd];
151
200
  const line = fullCmd.map(shq).join(" ");
201
+ // The one-shot scripts below end the tab when the agent ends, but a launch that
202
+ // never started (126/127: not executable, not found) holds it open on its error.
203
+ const hold = 's=$?; [ $s = 126 ] || [ $s = 127 ] || exit $s; printf "kaizen: the agent did not start. Press Enter to close. "; read _';
152
204
  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"`;
205
+ 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
206
  return { cmd: ["sh", "-c", script, "sh", cwd, line], detached: false };
155
207
  }
156
208
  if (answers(["tmux", "has-session"])) {
@@ -186,8 +238,17 @@ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(ty
186
238
 
187
239
  if (process.platform === "darwin") {
188
240
  // 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'`;
241
+ // line goes into a one-shot script and only `exec sh <path>` is typed. Whether the
242
+ // window closes when that ends is a profile setting kaizen cannot reach, so the
243
+ // launcher waits for the tab to run something, then to run nothing, and closes it:
244
+ // a tab with nothing running closes without Terminal asking first.
245
+ // ponytail: untested on a real Mac; the try leaves the window as it was if any step fails.
246
+ const close = ["tell application \"Terminal\"", "set t to do script \"exec sh $f\"", "activate", "try",
247
+ "repeat 150 times", "if busy of t then exit repeat", "delay 0.2", "end repeat",
248
+ "repeat while busy of t", "delay 1", "end repeat",
249
+ "repeat with w in windows", "if tabs of w contains t then close w", "end repeat",
250
+ "end try", "end tell"].map((l) => `-e ${JSON.stringify(l)}`).join(" ");
251
+ const script = `f=$(mktemp) && printf 'rm -f "$0"\\n{ cd %s || (exit 127); } && %s\\n${hold}\\n' "$1" "$2" > "$f" && osascript ${close}`;
191
252
  return { cmd: ["/bin/sh", "-c", script, "sh", shq(cwd), line], detached: true };
192
253
  }
193
254
 
@@ -236,6 +297,7 @@ export function nativePath(p: string) {
236
297
  // dashboard to stop opening.
237
298
  export function remember(projectDir: string) {
238
299
  try {
300
+ projectDir = nativePath(projectDir);
239
301
  if (knownProjects().includes(projectDir)) return;
240
302
  mkdirSync(dirname(REGISTRY), { recursive: true });
241
303
  appendFileSync(REGISTRY, projectDir + "\n");
@@ -275,13 +337,15 @@ export function findProjects(root: string, depth = 4): string[] {
275
337
  return found;
276
338
  }
277
339
 
340
+ // The same spelling knownProjects() gives: a terminal handing out c:\... (lower-case
341
+ // drive) would otherwise make every POST from the board fail "unknown state dir".
278
342
  export function locate() {
279
- let dir = process.cwd();
343
+ let dir = nativePath(process.cwd());
280
344
  for (;;) {
281
345
  if (existsSync(join(dir, ".kaizen"))) return join(dir, ".kaizen");
282
346
  if (existsSync(join(dir, ".git"))) return null;
283
347
  const up = dirname(dir);
284
- if (up === dir) return existsSync(join(home, ".kaizen")) ? join(home, ".kaizen") : null;
348
+ if (up === dir) return existsSync(join(home, ".kaizen")) ? nativePath(join(home, ".kaizen")) : null;
285
349
  dir = up;
286
350
  }
287
351
  }
@@ -419,6 +483,23 @@ export function fixPrompt(id: string, nums: number[]) {
419
483
  + `Leave every other finding alone, and mark the fixed ones done in its 06-backlog.md.`;
420
484
  }
421
485
 
486
+ // The board's answer to an approval the run is waiting on. The comment is collapsed to
487
+ // one line: where the prompt is typed into a shell (herdr), a newline sends it early.
488
+ // Answers are the plan's open questions answered at the approval, one per question.
489
+ export function approvalPrompt(id: string, awaiting: string, why?: string, answers?: string[]) {
490
+ const plan = awaiting === "approvals.plan";
491
+ const one = (t: string) => t.replace(/\s+/g, " ").trim();
492
+ const note = why === undefined ? undefined : one(why);
493
+ if (plan && !note && answers?.length) return `run ${id}: the plan is approved from the board, with answers to its open questions: `
494
+ + answers.map((a, i) => `${i + 1}) ${one(a).replace(/[.;]$/, "")}`).join("; ") + `. Record them in 02-approval.md and build the plan with them.`;
495
+ if (!note) return plan
496
+ ? `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.`
497
+ : `run ${id}: the work is approved from the board at the final approval. Record it, write the backlog and finish the run.`;
498
+ return plan
499
+ ? `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.`
500
+ : `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.`;
501
+ }
502
+
422
503
  // Backlog items are often a reviewer's finding pasted verbatim -- a backticked
423
504
  // path, a severity, then the sentence. Split those apart so a list can show the
424
505
  // severity and the place as columns and leave the prose to speak for itself.
@@ -481,7 +562,7 @@ export type Card = {
481
562
  moved?: number; // runs only: state.json mtime
482
563
  title?: string; // runs only: first line of the request
483
564
  archived: boolean; // hidden from the board unless asked for
484
- tags?: string[]; // runs only: runner, agent, "unrechecked"
565
+ tags?: string[]; // runs only: runner, agent, "yolo", "unrechecked"
485
566
  notes?: string; // inbox notes, or runs/<id>/notes.md
486
567
  };
487
568
 
@@ -716,6 +797,7 @@ export function abandonRun(st: string, id: string, why: string): string | null {
716
797
  // resume contract every kaizen tool reads.
717
798
  appendFileSync(join(st, "runs", id, "02-approval.md"),
718
799
  `\n---\n\n## Abandoned\n\n${new Date().toISOString()} — abandoned from the board.\n\n${why}\n`);
800
+ closeSessions(st, id);
719
801
  return null;
720
802
  } catch (err: any) {
721
803
  return err?.message ?? String(err);
@@ -810,6 +892,9 @@ export function boardCards(states: string[], now: number): Card[] {
810
892
  ? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
811
893
  : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
812
894
  }
895
+ // Runs started in Yolo. Only yolo records are matched to runs, so a project that
896
+ // never used it pays one directory read.
897
+ const yolos = new Set(readSessions(st).filter((s) => s.yolo).map((s) => ownerOf(st, s)));
813
898
  for (const r of readRuns(st)) {
814
899
  next.push({
815
900
  kind: "run", id: r.id, status: statusOf(r, now), text: short(r.id), state: st, where, column: columnOf(r.stage, r.awaiting), moved: r.moved,
@@ -817,7 +902,7 @@ export function boardCards(states: string[], now: number): Card[] {
817
902
  awaiting: r.stage === "abandoned" ? null : r.awaiting,
818
903
  dim: r.stage === "abandoned",
819
904
  archived: archive.has(r.id),
820
- tags: [r.runner, r.agent, unrechecked(st, r.id) ? "unrechecked" : undefined].filter((t): t is string => !!t),
905
+ tags: [r.runner, r.agent, yolos.has(r.id) ? "yolo" : undefined, unrechecked(st, r.id) ? "unrechecked" : undefined].filter((t): t is string => !!t),
821
906
  notes: readNotes(st, r.id) || undefined,
822
907
  });
823
908
  }
@@ -832,8 +917,8 @@ export function boardCards(states: string[], now: number): Card[] {
832
917
  // What starting a run needs, minus the screens: the TUI and the web board both
833
918
  // launch the same way and only differ in how they tell the user.
834
919
  export type Launch =
835
- | { ok: true; agent: { name: string; cmd: string }; prompt: string; projectDir: string; cmd: string[] }
836
- | { ok: false; agent: { name: string; cmd: string }; prompt: string; projectDir: string; manual: string; why: string };
920
+ | { ok: true; agent: Agent; prompt: string; projectDir: string; cmd: string[] }
921
+ | { ok: false; agent: Agent; prompt: string; projectDir: string; manual: string; why: string };
837
922
 
838
923
  export function projectOf(from: string) {
839
924
  return from === join(home, ".kaizen")
@@ -842,7 +927,7 @@ export function projectOf(from: string) {
842
927
  }
843
928
 
844
929
  export function manualCommand(projectDir: string, agentCmd: string, prompt: string, flags: string[] = []) {
845
- const bin = [agentCmd, ...flags].join(" ");
930
+ const bin = [agentCmd, ...flags, ...(PROMPT_FLAGS[agentCmd] ?? [])].join(" ");
846
931
  // Pasted into whatever shell the user has. fish reads \' and \\ inside single
847
932
  // quotes as escapes, so ' and \ each go in double quotes, which sh and fish read alike.
848
933
  const q = (a: string) => `'${a.replace(/['\\]/g, (ch) => (ch === "'" ? `'"'"'` : `'"\\\\"'`))}'`;
@@ -851,19 +936,192 @@ export function manualCommand(projectDir: string, agentCmd: string, prompt: stri
851
936
  : `cd ${q(projectDir)} && ${bin} ${q(prompt)}`;
852
937
  }
853
938
 
854
- export function launchRun(it: Item, from: string): Launch {
939
+ // Agent sessions kaizen opened. Each launch leaves `sessions/<key>.json` in the
940
+ // project's state dir ({run, text, cmd, model, sessionId, started}) and `<key>.pid`, which
941
+ // the agent writes itself. Only these are ever closed or resumed: a session the user
942
+ // opened by hand has no record.
943
+ export type Session = { key: string; run: string | null; text: string; cmd: string; model?: string | null; yolo?: boolean; sessionId: string | null; started: number };
944
+
945
+ // Tools that take a conversation id at launch and resume it by that id. Every other
946
+ // tool gets a fresh session on each decision, as before.
947
+ const SESSION_FLAGS: Record<string, { start: (id: string) => string[]; resume: (id: string) => string[]; transcript: (cwd: string, id: string) => string }> = {
948
+ claude: {
949
+ start: (id) => ["--session-id", id],
950
+ resume: (id) => ["--resume", id],
951
+ transcript: (cwd, id) => join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "projects", cwd.replace(/[^A-Za-z0-9]/g, "-"), `${id}.jsonl`),
952
+ },
953
+ };
954
+
955
+ export const CLOSE_GRACE_MS = 2 * 60_000;
956
+ const sessionsDir = (st: string) => join(st, "sessions");
957
+
958
+ export function readSessions(st: string): Session[] {
959
+ const dir = sessionsDir(st);
960
+ if (!existsSync(dir)) return [];
961
+ return readdirSync(dir).filter((f) => f.endsWith(".json")).flatMap((f) => {
962
+ try { return [{ ...JSON.parse(readText(join(dir, f))), key: f.slice(0, -5) }]; } catch { return []; }
963
+ }).sort((a, b) => a.started - b.started);
964
+ }
965
+
966
+ // Whose session a record is. A decision's launch names its run. An idea's launch came
967
+ // before its run existed, so it belongs to the first run created after it whose
968
+ // request holds the idea's text: one run per record, never every run that mentions it.
969
+ export function ownerOf(st: string, s: Session): string | null {
970
+ if (s.run) return s.run;
971
+ const t = normalise(s.text);
972
+ let best: string | null = null, at = Infinity;
973
+ try {
974
+ for (const id of readdirSync(join(st, "runs"))) {
975
+ try {
976
+ const f = join(st, "runs", id, "00-request.md");
977
+ const st2 = statSync(f), born = st2.birthtimeMs || st2.mtimeMs;
978
+ // A second of slack: file times come from a coarser clock than Date.now().
979
+ if (born >= s.started - 1000 && born < at && normalise(readText(f)).includes(t)) { best = id; at = born; }
980
+ } catch { /* no request yet */ }
981
+ }
982
+ } catch { /* no runs dir */ }
983
+ return best;
984
+ }
985
+
986
+ // agent.close in the config chain: on unless it says off.
987
+ export function closeEnabled(projectDir: string) {
988
+ for (const f of [join(projectDir, ".kaizen", "config.yml"), join(home, ".kaizen", "config.yml")]) {
989
+ try {
990
+ const m = /^agent:\n(?:[ \t]+.*\n)*?[ \t]+close:[ \t]*(\S+)/m.exec(readText(f));
991
+ if (m) return !/^(off|false|no)$/i.test(m[1]);
992
+ } catch { /* absent */ }
993
+ }
994
+ return true;
995
+ }
996
+
997
+ // Whether a live PID is still the agent a record launched, not a stranger that reused
998
+ // the number: the tool's name (and conversation id) in its arguments, started no
999
+ // earlier than the record. On Windows the PID is the PowerShell kaizen opened.
1000
+ function isSession(pid: number, s: Session): boolean {
1001
+ try {
1002
+ if (process.platform === "win32") {
1003
+ const out = Bun.spawnSync([sysExe("tasklist.exe"), "/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], { stdin: "ignore", timeout: 5000, windowsHide: true }).stdout.toString();
1004
+ return /^"(powershell|pwsh)\.exe"/i.test(out.trim());
1005
+ }
1006
+ const ps = onPath("ps");
1007
+ if (!ps) return false;
1008
+ const m = /^\s*(\S+)\s+(.*)$/.exec(Bun.spawnSync([ps, "-o", "etime=,args=", "-p", String(pid)], { stdin: "ignore", timeout: 5000 }).stdout.toString().trim());
1009
+ if (!m) return false;
1010
+ // etime is [[dd-]hh:]mm:ss
1011
+ const [d, rest] = m[1].includes("-") ? m[1].split("-") : ["0", m[1]];
1012
+ const secs = rest.split(":").reduce((a, n) => a * 60 + Number(n), 0) + Number(d) * 86400;
1013
+ return Date.now() - secs * 1000 >= s.started - 2000 && m[2].includes(s.cmd) && (!s.sessionId || m[2].includes(s.sessionId));
1014
+ } catch {
1015
+ return false;
1016
+ }
1017
+ }
1018
+
1019
+ // End one recorded session if it is still running, and drop its PID file either way:
1020
+ // without one, the record is never looked at for closing again.
1021
+ function closeOne(st: string, s: Session): boolean {
1022
+ const pidFile = join(sessionsDir(st), s.key + ".pid");
1023
+ let closed = false;
1024
+ try {
1025
+ const pid = parseInt(readText(pidFile));
1026
+ if (pid > 0 && isSession(pid, s)) {
1027
+ if (process.platform === "win32") Bun.spawnSync([sysExe("taskkill.exe"), "/T", "/F", "/PID", String(pid)], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5000, windowsHide: true });
1028
+ else process.kill(pid, "SIGTERM");
1029
+ closed = true;
1030
+ }
1031
+ } catch { /* already gone */ }
1032
+ rmSync(pidFile, { force: true });
1033
+ return closed;
1034
+ }
1035
+
1036
+ const openSessions = (st: string) => readSessions(st).filter((s) => existsSync(join(sessionsDir(st), s.key + ".pid")));
1037
+
1038
+ // Close every session kaizen opened for a run, those launched before `before` only,
1039
+ // so a decision's own successor is never caught. The record stays: it holds the
1040
+ // conversation id the next decision resumes. Never throws.
1041
+ export function closeSessions(st: string, id: string, opts: { before?: number } = {}): number {
1042
+ try {
1043
+ if (!closeEnabled(dirname(st))) return 0;
1044
+ return openSessions(st).filter((s) => s.started < (opts.before ?? Infinity) && ownerOf(st, s) === id).filter((s) => closeOne(st, s)).length;
1045
+ } catch { return 0; }
1046
+ }
1047
+
1048
+ // The board's minute sweep: sessions of runs done for longer than the grace, so the
1049
+ // final summary can still be read first. Only sessions launched before the run last
1050
+ // moved: a fix started on a done run leaves it done until that agent writes, and is
1051
+ // the one doing the work. Abandoned runs are closed by abandonRun, not here.
1052
+ export function sweepSessions(st: string, now: number) {
1053
+ try {
1054
+ const live = openSessions(st);
1055
+ if (!live.length || !closeEnabled(dirname(st))) return;
1056
+ const runs = new Map(readRuns(st).map((r) => [r.id, r]));
1057
+ for (const s of live) {
1058
+ const r = runs.get(ownerOf(st, s) ?? "");
1059
+ if (r && r.stage === "done" && s.started <= r.moved && now - r.moved >= CLOSE_GRACE_MS) closeOne(st, s);
1060
+ }
1061
+ } catch { /* the next sweep tries again */ }
1062
+ }
1063
+
1064
+ // The run's conversation to continue: its first recorded one, on this tool, whose
1065
+ // transcript is still on disk. None means a fresh session, never a guess.
1066
+ export function firstSession(st: string, id: string, cmd: string, cwd: string): Session | null {
1067
+ const f = SESSION_FLAGS[cmd];
1068
+ if (!f) return null;
1069
+ return readSessions(st).find((s) => s.sessionId && s.cmd === cmd && ownerOf(st, s) === id && existsSync(f.transcript(cwd, s.sessionId))) ?? null;
1070
+ }
1071
+
1072
+ // The tool and model a run was started with: its latest recorded launch, else the
1073
+ // agent its state.json names. A decision continues on these whatever the config says now.
1074
+ export function runAgent(st: string, id: string): { cmd: string; model: string | null } | null {
1075
+ const s = readSessions(st).filter((x) => ownerOf(st, x) === id).pop();
1076
+ if (s) return { cmd: s.cmd, model: s.model ?? null };
1077
+ try {
1078
+ const a = JSON.parse(readText(join(st, "runs", id, "state.json"))).agent;
1079
+ if (typeof a === "string" && a) return { cmd: a, model: null };
1080
+ } catch { /* no run */ }
1081
+ return null;
1082
+ }
1083
+
1084
+ // The agent's argv for a launch, and its record. With a run, a decision: it resumes
1085
+ // the run's conversation when there is one. A project without a state dir gets no
1086
+ // record; making one here would shadow the user's.
1087
+ export function sessionLaunch(projectDir: string, cmd: string, base: string[], prompt: string, text: string, run?: string, model?: string, yolo?: boolean) {
1088
+ const st = join(projectDir, ".kaizen");
1089
+ if (!existsSync(st)) return { fullCmd: [...base, ...promptArgs(cmd, prompt)], pidFile: undefined, record: undefined };
1090
+ const f = SESSION_FLAGS[cmd];
1091
+ const prev = run ? firstSession(st, run, cmd, projectDir) : null;
1092
+ const sessionId = f ? prev?.sessionId ?? crypto.randomUUID() : null;
1093
+ const fullCmd = [...base, ...(f && sessionId ? (prev ? f.resume(sessionId) : f.start(sessionId)) : []), ...promptArgs(cmd, prompt)];
1094
+ const key = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1095
+ mkdirSync(sessionsDir(st), { recursive: true });
1096
+ const record = join(sessionsDir(st), key + ".json");
1097
+ writeFileSync(record, JSON.stringify({ run: run ?? null, text, cmd, model: model ?? null, ...(yolo ? { yolo: true } : {}), sessionId, started: Date.now() }) + "\n");
1098
+ return { fullCmd, pidFile: join(sessionsDir(st), key + ".pid"), record };
1099
+ }
1100
+
1101
+ // run: the run a decision is for. Without it, an idea starting a new run.
1102
+ // pick: the tool, model and yolo chosen for a new run; a decision takes the run's own
1103
+ // tool and model, and never yolo.
1104
+ export function launchRun(it: Item, from: string, run?: string, pick: { agent?: string; model?: string; yolo?: boolean } = {}): Launch {
855
1105
  const projectDir = projectOf(from);
856
1106
  // The run will be born here; the board must know the dir or the idea never retires.
857
1107
  remember(projectDir);
858
- const agent = detectDefaultAgent(projectDir);
1108
+ const own = run && !pick.agent ? runAgent(join(projectDir, ".kaizen"), run) : null;
1109
+ const agent = detectDefaultAgent(projectDir, pick.agent ?? own?.cmd);
859
1110
  const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
860
1111
  const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
861
- const flags = agentFlags(projectDir, agent.cmd);
862
- const fullCmd = [agentBin, ...flags, prompt];
1112
+ const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
1113
+ const model = wanted && plainModel(wanted) ? wanted : undefined;
1114
+ // Yolo was picked for one tool; a fallback to another opens in its normal mode.
1115
+ const yolo = !!pick.yolo && agent.cmd === pick.agent && !!YOLO_FLAGS[agent.cmd];
1116
+ const flags = [...agentFlags(projectDir, agent.cmd, undefined, model), ...(yolo ? YOLO_FLAGS[agent.cmd] : [])];
1117
+ // The idea as its run's request will hold it: without the runner word in front.
1118
+ const s = sessionLaunch(projectDir, agent.cmd, [agentBin, ...flags], prompt, it.text.replace(/^(lite|full|auto|plan)\s+/i, ""), run, model, yolo);
863
1119
  const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
1120
+ const drop = () => { if (s.record) rmSync(s.record, { force: true }); };
864
1121
 
865
- const term = findTerminal(projectDir, fullCmd);
1122
+ const term = findTerminal(projectDir, s.fullCmd, s.pidFile);
866
1123
  if (!term) {
1124
+ drop();
867
1125
  return {
868
1126
  ok: false, agent, prompt, projectDir, manual,
869
1127
  why: process.platform === "darwin"
@@ -885,6 +1143,7 @@ export function launchRun(it: Item, from: string): Launch {
885
1143
  if (term.detached) proc.unref();
886
1144
  return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
887
1145
  } catch (err: any) {
1146
+ drop();
888
1147
  return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
889
1148
  }
890
1149
  }
@@ -1052,3 +1311,58 @@ export async function commitPush(state: string, id: string, message: string, sho
1052
1311
  record(true);
1053
1312
  return { ok: true, sha };
1054
1313
  }
1314
+
1315
+ // ---- issue reports
1316
+
1317
+ // Where a bug report goes: package.json's repository, so a fork reports to itself.
1318
+ export const REPO_URL = (() => {
1319
+ try {
1320
+ const u = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).repository.url as string;
1321
+ return u.replace(/^git\+/, "").replace(/\.git$/, "");
1322
+ } catch { return "https://github.com/hfadhlullah/kaizen"; }
1323
+ })();
1324
+
1325
+ // The machine, as an issue's Environment field. Nothing that names the person: no
1326
+ // hostname, no username, no paths.
1327
+ export function systemInfo(): string {
1328
+ let osName = osVersion();
1329
+ try {
1330
+ if (process.platform === "linux") osName = /^PRETTY_NAME="?([^"\n]*)/m.exec(readFileSync("/etc/os-release", "utf8"))?.[1] || osName;
1331
+ else if (process.platform === "darwin") osName = `macOS ${Bun.spawnSync(["sw_vers", "-productVersion"]).stdout.toString().trim()}`;
1332
+ } catch { /* the kernel's own version string is still an answer */ }
1333
+ let kaizen = "?";
1334
+ try { kaizen = JSON.parse(readFileSync(join(import.meta.dir, "..", "package.json"), "utf8")).version; } catch {}
1335
+ const cpu = cpus();
1336
+ return [
1337
+ `kaizen: ${kaizen}`,
1338
+ `OS: ${osName} (${process.platform} ${release()}, ${arch()})`,
1339
+ `CPU: ${cpu[0]?.model.trim() ?? "?"} × ${cpu.length}`,
1340
+ `RAM: ${Math.round(totalmem() / 2 ** 30)} GB`,
1341
+ `bun: ${Bun.version}`,
1342
+ `terminal: ${process.env.TERM_PROGRAM ?? process.env.TERM ?? "?"}`,
1343
+ ].join("\n");
1344
+ }
1345
+
1346
+ // The home folder as `~`, so a pasted error does not carry the username. Windows paths
1347
+ // also arrive JSON-escaped (`C:\\Users\\x`) or with forward slashes, in any case.
1348
+ export function scrubHome(s: string, homes = [home, homedir()], win = process.platform === "win32"): string {
1349
+ const forms = [...new Set(homes.filter((h) => h.length > 1).flatMap((h) => [h, h.replaceAll("\\", "\\\\"), h.replaceAll("\\", "/")]))]
1350
+ .sort((a, b) => b.length - a.length);
1351
+ return forms.reduce((t, h) => t.replace(new RegExp(h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), win ? "gi" : "g"), "~"), s);
1352
+ }
1353
+
1354
+ // A new-issue link with the bug form filled in. The user reads and submits it on
1355
+ // GitHub; nothing is sent from here. GitHub refuses URLs much past 8 KB, so the error
1356
+ // text gives way first.
1357
+ export function issueUrl(o: { title?: string; what?: string } = {}): string {
1358
+ let what = scrubHome(o.what ?? "");
1359
+ const title = scrubHome(o.title ?? "").slice(0, 120), env = systemInfo();
1360
+ // `what` and `env` fill the form's fields; `body` carries the same text for when GitHub
1361
+ // has no bug.yml (not pushed yet, a fork without it) and ignores the field params.
1362
+ const build = () => `${REPO_URL}/issues/new?` + new URLSearchParams({
1363
+ template: "bug.yml", title, what, env, body: `### What happened\n\n${what}\n\n### Environment\n\n\`\`\`text\n${env}\n\`\`\``,
1364
+ }).toString();
1365
+ let url = build();
1366
+ while (url.length > 7500 && what) { what = what.slice(0, Math.floor(what.length * 0.8)) + "\n…(cut)"; url = build(); if (what.length < 20) what = ""; }
1367
+ return url;
1368
+ }