kaizen-agent 1.10.0 → 1.11.1

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/board.test.ts CHANGED
@@ -101,3 +101,70 @@ test("planQuestions: a bullet after a blank line is not one more option", () =>
101
101
  const qs = planQuestions("## Open questions\nQ: One?\n - A (recommended)\n - B\n\n- Note: aside\n")!;
102
102
  expect(qs[0]!.options.map((o) => o.label)).toEqual(["A", "B"]);
103
103
  });
104
+
105
+ test("card menu items: appropriate actions and distinct icons across phases", () => {
106
+ expect(html).toContain('<symbol id="i-open"');
107
+ expect(html).toContain('<symbol id="i-archive"');
108
+ expect(html).toContain('<symbol id="i-restore"');
109
+
110
+ const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
111
+ const menuSnippet = (html.slice(html.indexOf("if(k===menuKey){"), html.indexOf("el.innerHTML+=`<div class=\"menu-pop\"", html.indexOf("if(k===menuKey){"))) + "}").replace("const items=[];", "items=[];");
112
+ const getItems = new Function("c", "archive", `const ico = ${ico.toString()};\nlet k = 1, menuKey = 1, items = [];\n${menuSnippet}\nreturn items;`);
113
+
114
+ // Phase 0: Ideas (idea card)
115
+ const idea = { kind: "idea", status: "idea", column: 0 };
116
+ const ideaItems = getItems(idea, false);
117
+ expect(ideaItems.map((i: any) => i ? i[1] : null)).toEqual(["Run", "Edit", "Archive", null, "Reject", "Delete"]);
118
+ expect(ideaItems[0][2]).toBe(ico("play"));
119
+ expect(ideaItems[1][2]).toBe(ico("pen"));
120
+ expect(ideaItems[2][2]).toBe(ico("archive"));
121
+ expect(ideaItems[4][2]).toBe(ico("x"));
122
+ expect(ideaItems[5][2]).toBe(ico("trash"));
123
+
124
+ // Phase 1: Planning (starting idea)
125
+ const startingIdea = { kind: "idea", status: "starting", column: 1 };
126
+ const startItems = getItems(startingIdea, false);
127
+ expect(startItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Run again", "Archive", null, "Remove"]);
128
+
129
+ // Phase 1: Planning (run waiting for plan approval)
130
+ const planWaiting = { kind: "run", status: "waiting", awaiting: "approvals.plan", column: 1 };
131
+ const planItems = getItems(planWaiting, false);
132
+ expect(planItems.map((i: any) => i ? i[1] : null)).toEqual(["Review plan", "Archive", null, "Abandon"]);
133
+ expect(planItems[0][2]).toBe(ico("open"));
134
+ expect(planItems[1][2]).toBe(ico("archive"));
135
+ expect(planItems[3][2]).toBe(ico("x"));
136
+
137
+ // Phase 2: Building (running run)
138
+ const buildingRun = { kind: "run", status: "running", column: 2 };
139
+ const buildItems = getItems(buildingRun, false);
140
+ expect(buildItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Archive", null, "Abandon"]);
141
+
142
+ // Phase 3: Review (run waiting for review approval)
143
+ const reviewWaiting = { kind: "run", status: "waiting", awaiting: "approvals.review", column: 3 };
144
+ const reviewItems = getItems(reviewWaiting, false);
145
+ expect(reviewItems.map((i: any) => i ? i[1] : null)).toEqual(["Review work", "Archive", null, "Abandon"]);
146
+
147
+ // Phase 3: Review (run waiting on findings)
148
+ const findingsWaiting = { kind: "run", status: "waiting", awaiting: "findings", column: 3 };
149
+ const findingsItems = getItems(findingsWaiting, false);
150
+ expect(findingsItems.map((i: any) => i ? i[1] : null)).toEqual(["See findings", "Archive", null, "Abandon"]);
151
+
152
+ // Phase 4: Done (done run - no Abandon)
153
+ const doneRun = { kind: "run", status: "done", column: 4 };
154
+ const doneItems = getItems(doneRun, false);
155
+ expect(doneItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Archive"]);
156
+
157
+ // Phase 4: Done (abandoned run - no Abandon)
158
+ const abandonedRun = { kind: "run", status: "abandoned", column: 4 };
159
+ const abandItems = getItems(abandonedRun, false);
160
+ expect(abandItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Archive"]);
161
+
162
+ // Archive view
163
+ const archRun = getItems(doneRun, true);
164
+ expect(archRun.map((i: any) => i ? i[1] : null)).toEqual(["Restore", "Open"]);
165
+ expect(archRun[0][2]).toBe(ico("restore"));
166
+ expect(archRun[1][2]).toBe(ico("open"));
167
+
168
+ const archIdea = getItems(idea, true);
169
+ expect(archIdea.map((i: any) => i ? i[1] : null)).toEqual(["Restore", "Open", null, "Delete"]);
170
+ });
package/cli/dashboard.ts CHANGED
@@ -911,6 +911,7 @@ export async function dashboard(
911
911
  c.bold(r.cmd[0] === "sh" && r.cmd[2]?.includes("herdr tab create") ? "Started in a new herdr tab" : r.cmd[0] === "tmux" ? "Started in a new tmux window" : "Started in new terminal"),
912
912
  "",
913
913
  ` ${c.cyan("Agent")} ${r.agent.name} (${c.bold(r.agent.cmd)})`,
914
+ ...(r.agent.note ? [` ${c.amber(r.agent.note)}`] : []),
914
915
  ` ${c.cyan("Project")} ${tilde(r.projectDir)}`,
915
916
  ` ${c.cyan("Task")} ${it.text}`,
916
917
  ...(it.where ? [` ${c.cyan("File")} ${it.where}`] : []),
package/cli/install.ts CHANGED
@@ -115,6 +115,20 @@ if (Bun.argv[2] === "sources" || Bun.argv[2] === "gather") {
115
115
  process.exit(0);
116
116
  }
117
117
 
118
+ // `kaizen issue [what happened]` opens kaizen's GitHub bug form with this machine's
119
+ // details filled in; `--env` only prints them.
120
+ if (Bun.argv[2] === "issue") {
121
+ const { issueUrl, systemInfo } = await import("./state.ts");
122
+ if (args.has("--env")) { console.log(systemInfo()); process.exit(0); }
123
+ const what = Bun.argv.slice(3).join(" ");
124
+ const url = issueUrl({ title: what.split("\n")[0], what });
125
+ console.log(`\n ${c.cyan(url)}\n`);
126
+ console.log(await openUrl(url)
127
+ ? ` ${c.dim("Opened in your browser. Check it, then submit it on GitHub.")}\n`
128
+ : ` ${c.dim("Open that link to file the issue.")}\n`);
129
+ process.exit(0);
130
+ }
131
+
118
132
  if (args.has("--version") || args.has("-v") || args.has("version")) {
119
133
  console.log(versionOf(dirname(import.meta.dir)));
120
134
  process.exit(0);
package/cli/settings.ts CHANGED
@@ -6,7 +6,7 @@ import { homedir } from "node:os";
6
6
  import { readText } from "./state.ts";
7
7
 
8
8
  // `text` is a value typed rather than chosen: a path. It has no values to cycle.
9
- type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean };
9
+ type Setting = { key: string; label: string; group: string; values: string[]; help: string; text?: boolean; folder?: boolean };
10
10
 
11
11
  export type PresetName = "low" | "medium" | "ultra";
12
12
 
@@ -116,7 +116,7 @@ const SETTINGS: Setting[] = [
116
116
  { key: "state.keep_runs", label: "Runs to keep", group: "Project", values: ["5", "10", "20", "50"], help: "Finished runs kept before the oldest is pruned. Open backlog items are rescued first" },
117
117
  { key: "dictation.model", label: "Dictation model", group: "Board", values: ["tiny", "base", "small", "large-v3-turbo"], help: "Whisper model the web board dictates with, in the browser. tiny ~60 MB rough; base ~165 MB English-leaning; small ~410 MB accurate across languages; large-v3-turbo ~1.5 GB best, needs a capable GPU. Downloaded once on the next mic press" },
118
118
  { key: "sources.every", label: "Gather requests", group: "Board", values: ["off", "1h", "6h", "24h"], help: "How often the board checks your request sources for new ideas while it is running. off: only when you press Gather now" },
119
- { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, help: `A folder your projects live in, e.g. ${process.platform === "win32" ? join(homedir(), "Projects") : "~/Projects"}. Every folder inside it appears in the board's project list, ready to choose; choosing one sets it up. Empty: only projects kaizen already knows` },
119
+ { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: `A folder your projects live in, e.g. ${process.platform === "win32" ? join(homedir(), "Projects") : "~/Projects"}. Every folder inside it appears in the board's project list, ready to choose; choosing one sets it up. Empty: only projects kaizen already knows` },
120
120
  { key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "Click to select, click again to act. While on, the terminal cannot select text with the mouse" },
121
121
  ];
122
122
 
package/cli/state.test.ts CHANGED
@@ -4,10 +4,11 @@ import { test, expect, beforeAll, afterAll } from "bun:test";
4
4
  import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, utimesSync, existsSync, readdirSync } from "node:fs";
5
5
  import { join, dirname } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
+ import { issueUrl, systemInfo, scrubHome, REPO_URL, home as realHome } from "./state.ts";
7
8
  import {
8
9
  readRuns, readInbox, writeInbox, replaceIdea, abandonRun, allBacklog, backlog,
9
10
  statusOf, columnOf, parseItem, startedRuns, boardCards, readArchive, setArchived,
10
- requestOf, agentFlags, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
11
+ requestOf, agentFlags, plainModel, readNotes, writeNotes, manualCommand, parseNotes, formatNote, appendNote, saveAttachment,
11
12
  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
13
  } from "./state.ts";
13
14
 
@@ -246,6 +247,71 @@ test("agentFlags: per-tool model and effort from the agent block, inline or nest
246
247
  expect(agentFlags("/p", "codex", "agent:\n default: codex\n")).toEqual([]);
247
248
  });
248
249
 
250
+ test("plainModel: a typed model reaches launch lines only as a plain name", () => {
251
+ for (const m of ["opus", "gpt-5.1", "gemini-2.5-pro", "anthropic/claude-sonnet-4", "o3:high", "x@y"]) expect(plainModel(m)).toBe(true);
252
+ for (const m of ["", "x; rm -rf ~", "$(id)", "`id`", "a b", "'q'", "\"q\"", "opus[1m]", "-rf", "x".repeat(81)]) expect(plainModel(m)).toBe(false);
253
+ });
254
+
255
+ test("each tool gets the opening prompt the way it takes one: agy -i, opencode --prompt, the rest bare", () => {
256
+ const p = mkdtempSync(join(tmpdir(), "kz-")); // no .kaizen: no session record
257
+ const argv = (cmd: string) => sessionLaunch(p, cmd, [cmd], "/kaizen x", "x").fullCmd;
258
+ expect(argv("agy")).toEqual(["agy", "-i", "/kaizen x"]);
259
+ expect(argv("opencode")).toEqual(["opencode", "--prompt", "/kaizen x"]);
260
+ expect(argv("codex")).toEqual(["codex", "/kaizen x"]);
261
+ expect(argv("gemini")).toEqual(["gemini", "/kaizen x"]);
262
+ if (process.platform !== "win32") expect(manualCommand("/p", "agy", "/kaizen x", ["--model", "m"])).toBe("cd '/p' && agy --model m -i '/kaizen x'");
263
+ });
264
+
265
+ test.skipIf(process.platform === "win32")("agent choice: project then global config, a note for a missing tool, a pick, and a run keeps its own", () => {
266
+ // The global config sits at the home bound at import, so this runs in a child with a scratch HOME.
267
+ const home = mkdtempSync(join(tmpdir(), "kz-home-")), bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
268
+ for (const t of ["claude", "codex"]) writeFileSync(join(bin, t), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
269
+ const script = `const S = await import(${JSON.stringify(join(dirname(import.meta.path), "state.ts"))});
270
+ const fs = require("node:fs"), { join } = require("node:path");
271
+ const P = join(process.env.HOME, "proj"), st = join(P, ".kaizen"), G = join(process.env.HOME, ".kaizen");
272
+ fs.mkdirSync(join(st, "runs", "r1"), { recursive: true }); fs.mkdirSync(G, { recursive: true });
273
+ fs.writeFileSync(join(G, "config.yml"), "agent:\\n default: codex\\n codex: { model: gpt-5, effort: low }\\n");
274
+ fs.writeFileSync(join(st, "config.yml"), "mode: approve\\n");
275
+ const out = { chain: [S.detectDefaultAgent(P).cmd, S.agentFlags(P, "codex")] };
276
+ fs.writeFileSync(join(st, "config.yml"), "agent:\\n default: claude\\n codex:\\n model: o3\\n");
277
+ out.project = [S.detectDefaultAgent(P).cmd, S.agentFlags(P, "codex")];
278
+ out.missing = S.detectDefaultAgent(P, "gemini");
279
+ out.pick = [S.detectDefaultAgent(P, "codex").cmd, S.agentFlags(P, "codex", undefined, "gpt-5.1")];
280
+ out.choices = S.agentChoices(P);
281
+ // No terminal on PATH or display in env: the launch fails and hands back its manual line.
282
+ const idea = { severity: null, where: null, text: "x", raw: "x" };
283
+ // macOS always drives Terminal.app, which would open a real window: Linux only.
284
+ if (process.platform === "linux") out.yolo = [S.launchRun(idea, P, undefined, { agent: "codex", yolo: true }), S.launchRun(idea, P, undefined, { agent: "codex" }), S.launchRun(idea, P, undefined, { agent: "gemini", yolo: true })].map((r) => [r.ok, r.agent.cmd, /--dangerously-bypass|--dangerously-skip|--yolo/.test(r.manual)]);
285
+ fs.writeFileSync(join(st, "runs", "r1", "state.json"), JSON.stringify({ agent: "codex" }));
286
+ out.fromState = S.runAgent(st, "r1");
287
+ S.sessionLaunch(P, "codex", ["codex"], "/kaizen x", "x", "r1", "gpt-5.1");
288
+ out.fromRecord = S.runAgent(st, "r1");
289
+ out.tags = [S.boardCards([st], Date.now()).find((c) => c.id === "r1")?.tags];
290
+ S.sessionLaunch(P, "codex", ["codex"], "/kaizen x", "x", "r1", undefined, true);
291
+ out.tags.push(S.boardCards([st], Date.now()).find((c) => c.id === "r1")?.tags);
292
+ console.log(JSON.stringify(out));`;
293
+ try {
294
+ const p = Bun.spawnSync(["bun", "-e", script], { cwd: home, env: { ...process.env, HOME: home, PATH: bin + ":" + dirname(process.execPath), DISPLAY: "", WAYLAND_DISPLAY: "", TERMINAL: "", TMUX: "", HERDR_ENV: "" } });
295
+ if (p.exitCode !== 0) throw new Error(p.stderr.toString());
296
+ expect(JSON.parse(p.stdout.toString())).toEqual({
297
+ // A project config.yml with no agent block leaves the global one in force.
298
+ chain: ["codex", ["-m", "gpt-5", "-c", "model_reasoning_effort=low"]],
299
+ project: ["claude", ["-m", "o3"]],
300
+ missing: { name: "Claude Code", cmd: "claude", note: "Gemini CLI is not installed; opened Claude Code instead" },
301
+ pick: ["codex", ["-m", "gpt-5.1"]],
302
+ choices: { default: "claude", agents: [
303
+ { name: "Claude Code", cmd: "claude", model: "", models: true, yolo: true },
304
+ { name: "Codex", cmd: "codex", model: "o3", models: true, yolo: true },
305
+ ] },
306
+ // Yolo adds the tool's bypass flag only when asked for, and not to a fallback tool.
307
+ ...(process.platform === "linux" ? { yolo: [[false, "codex", true], [false, "codex", false], [false, "claude", false]] } : {}),
308
+ tags: [["codex"], ["codex", "yolo"]],
309
+ fromState: { cmd: "codex", model: null },
310
+ fromRecord: { cmd: "codex", model: "gpt-5.1" },
311
+ });
312
+ } finally { rmSync(home, { recursive: true, force: true }); rmSync(bin, { recursive: true, force: true }); }
313
+ });
314
+
249
315
  test("boardCards: a short idea is not retired by a request that merely contains the word", () => {
250
316
  const dir = join(mkdtempSync(join(tmpdir(), "kz-")), ".kaizen");
251
317
  mkdirSync(join(dir, "runs", "2026-09-01-a"), { recursive: true });
@@ -687,3 +753,48 @@ test("abandonRun closes the run's recorded sessions", () => {
687
753
  expect(abandonRun(st, "2026-10-01-z", "no")).toBe(null);
688
754
  expect(existsSync(join(st, "sessions", "k.pid"))).toBe(false);
689
755
  });
756
+
757
+ // The folder dialog cannot be opened in a test, but its script can be checked without
758
+ // showing it: compiled on a Mac, parsed on Windows. Elsewhere only the shape is pinned.
759
+ test("folder dialog: each platform's script is well formed", async () => {
760
+ const { folderDialog } = await import("./web.ts");
761
+ const mac = folderDialog("darwin")!, win = folderDialog("win32")!;
762
+ expect(mac.slice(0, 3)).toEqual(["osascript", "-e", "activate"]);
763
+ expect(win.at(-1)).toContain("FolderBrowserDialog");
764
+ if (process.platform === "darwin") {
765
+ const out = join(mkdtempSync(join(tmpdir(), "kaizen-osa-")), "x.scpt");
766
+ expect(Bun.spawnSync(["osacompile", ...mac.slice(1), "-o", out]).exitCode).toBe(0);
767
+ }
768
+ if (process.platform === "win32") {
769
+ const check = `$e=$null;[void][System.Management.Automation.Language.Parser]::ParseInput($env:KZ_PS,[ref]$null,[ref]$e);exit $e.Count`;
770
+ expect(Bun.spawnSync([win[0], "-NoProfile", "-Command", check], { env: { ...process.env, KZ_PS: win.at(-1)! } }).exitCode).toBe(0);
771
+ }
772
+ });
773
+
774
+ test("issueUrl prefills the bug form's fields, without the home path", () => {
775
+ const u = new URL(issueUrl({ title: "boom", what: `ENOENT ${realHome}/Projects/x/.kaizen/state.json` }));
776
+ expect(`${u.origin}${u.pathname}`).toBe(`${REPO_URL}/issues/new`);
777
+ expect(u.searchParams.get("what")).toBe("ENOENT ~/Projects/x/.kaizen/state.json");
778
+ expect(u.searchParams.get("env")).toBe(systemInfo());
779
+ // Every key but GitHub's own must be a field id in the form, or it is dropped silently.
780
+ const ids = [...readFileSync(join(import.meta.dir, "..", ".github", "ISSUE_TEMPLATE", "bug.yml"), "utf8").matchAll(/^\s+id: (\S+)/gm)].map((m) => m[1]);
781
+ for (const k of u.searchParams.keys()) if (!["template", "title", "body"].includes(k)) expect(ids).toContain(k);
782
+ // Without the form on GitHub only `body` is read, so it must hold the machine too.
783
+ expect(u.searchParams.get("body")).toContain(systemInfo());
784
+ expect(u.searchParams.get("body")).toContain("ENOENT ~/Projects/x/.kaizen/state.json");
785
+ expect(systemInfo()).toMatch(/^kaizen: .+\nOS: .+\nCPU: .+\nRAM: \d+ GB\nbun: /);
786
+ });
787
+
788
+ test("issueUrl stays under GitHub's URL limit for a huge error", () => {
789
+ const u = issueUrl({ title: "x".repeat(500), what: "é".repeat(20000) });
790
+ expect(u.length).toBeLessThan(8000);
791
+ expect(new URL(u).searchParams.get("what")).toContain("…(cut)");
792
+ });
793
+
794
+ test("scrubHome hides a Windows home however the error spells it", () => {
795
+ const h = ["C:\\Users\\Firman"];
796
+ expect(scrubHome(`open C:\\Users\\Firman\\x, "c:\\\\users\\\\firman\\\\y", C:/Users/Firman/z`, h, true))
797
+ .toBe(`open ~\\x, "~\\\\y", ~/z`);
798
+ // Case matters off Windows: /home/Ann is not /home/ann.
799
+ expect(scrubHome("/home/ann/a /home/Ann/b", ["/home/ann"], false)).toBe("~/a /home/Ann/b");
800
+ });
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, `'\\''`)}'`;
@@ -522,7 +562,7 @@ export type Card = {
522
562
  moved?: number; // runs only: state.json mtime
523
563
  title?: string; // runs only: first line of the request
524
564
  archived: boolean; // hidden from the board unless asked for
525
- tags?: string[]; // runs only: runner, agent, "unrechecked"
565
+ tags?: string[]; // runs only: runner, agent, "yolo", "unrechecked"
526
566
  notes?: string; // inbox notes, or runs/<id>/notes.md
527
567
  };
528
568
 
@@ -852,6 +892,9 @@ export function boardCards(states: string[], now: number): Card[] {
852
892
  ? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes }
853
893
  : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes });
854
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)));
855
898
  for (const r of readRuns(st)) {
856
899
  next.push({
857
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,
@@ -859,7 +902,7 @@ export function boardCards(states: string[], now: number): Card[] {
859
902
  awaiting: r.stage === "abandoned" ? null : r.awaiting,
860
903
  dim: r.stage === "abandoned",
861
904
  archived: archive.has(r.id),
862
- 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),
863
906
  notes: readNotes(st, r.id) || undefined,
864
907
  });
865
908
  }
@@ -874,8 +917,8 @@ export function boardCards(states: string[], now: number): Card[] {
874
917
  // What starting a run needs, minus the screens: the TUI and the web board both
875
918
  // launch the same way and only differ in how they tell the user.
876
919
  export type Launch =
877
- | { ok: true; agent: { name: string; cmd: string }; prompt: string; projectDir: string; cmd: string[] }
878
- | { 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 };
879
922
 
880
923
  export function projectOf(from: string) {
881
924
  return from === join(home, ".kaizen")
@@ -884,7 +927,7 @@ export function projectOf(from: string) {
884
927
  }
885
928
 
886
929
  export function manualCommand(projectDir: string, agentCmd: string, prompt: string, flags: string[] = []) {
887
- const bin = [agentCmd, ...flags].join(" ");
930
+ const bin = [agentCmd, ...flags, ...(PROMPT_FLAGS[agentCmd] ?? [])].join(" ");
888
931
  // Pasted into whatever shell the user has. fish reads \' and \\ inside single
889
932
  // quotes as escapes, so ' and \ each go in double quotes, which sh and fish read alike.
890
933
  const q = (a: string) => `'${a.replace(/['\\]/g, (ch) => (ch === "'" ? `'"'"'` : `'"\\\\"'`))}'`;
@@ -894,10 +937,10 @@ export function manualCommand(projectDir: string, agentCmd: string, prompt: stri
894
937
  }
895
938
 
896
939
  // 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
940
+ // project's state dir ({run, text, cmd, model, sessionId, started}) and `<key>.pid`, which
898
941
  // the agent writes itself. Only these are ever closed or resumed: a session the user
899
942
  // opened by hand has no record.
900
- export type Session = { key: string; run: string | null; text: string; cmd: string; sessionId: string | null; started: number };
943
+ export type Session = { key: string; run: string | null; text: string; cmd: string; model?: string | null; yolo?: boolean; sessionId: string | null; started: number };
901
944
 
902
945
  // Tools that take a conversation id at launch and resume it by that id. Every other
903
946
  // tool gets a fresh session on each decision, as before.
@@ -1026,34 +1069,53 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
1026
1069
  return readSessions(st).find((s) => s.sessionId && s.cmd === cmd && ownerOf(st, s) === id && existsSync(f.transcript(cwd, s.sessionId))) ?? null;
1027
1070
  }
1028
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
+
1029
1084
  // The agent's argv for a launch, and its record. With a run, a decision: it resumes
1030
1085
  // the run's conversation when there is one. A project without a state dir gets no
1031
1086
  // 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) {
1087
+ export function sessionLaunch(projectDir: string, cmd: string, base: string[], prompt: string, text: string, run?: string, model?: string, yolo?: boolean) {
1033
1088
  const st = join(projectDir, ".kaizen");
1034
- if (!existsSync(st)) return { fullCmd: [...base, prompt], pidFile: undefined, record: undefined };
1089
+ if (!existsSync(st)) return { fullCmd: [...base, ...promptArgs(cmd, prompt)], pidFile: undefined, record: undefined };
1035
1090
  const f = SESSION_FLAGS[cmd];
1036
1091
  const prev = run ? firstSession(st, run, cmd, projectDir) : null;
1037
1092
  const sessionId = f ? prev?.sessionId ?? crypto.randomUUID() : null;
1038
- const fullCmd = [...base, ...(f && sessionId ? (prev ? f.resume(sessionId) : f.start(sessionId)) : []), prompt];
1093
+ const fullCmd = [...base, ...(f && sessionId ? (prev ? f.resume(sessionId) : f.start(sessionId)) : []), ...promptArgs(cmd, prompt)];
1039
1094
  const key = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1040
1095
  mkdirSync(sessionsDir(st), { recursive: true });
1041
1096
  const record = join(sessionsDir(st), key + ".json");
1042
- writeFileSync(record, JSON.stringify({ run: run ?? null, text, cmd, sessionId, started: Date.now() }) + "\n");
1097
+ writeFileSync(record, JSON.stringify({ run: run ?? null, text, cmd, model: model ?? null, ...(yolo ? { yolo: true } : {}), sessionId, started: Date.now() }) + "\n");
1043
1098
  return { fullCmd, pidFile: join(sessionsDir(st), key + ".pid"), record };
1044
1099
  }
1045
1100
 
1046
1101
  // 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 {
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 {
1048
1105
  const projectDir = projectOf(from);
1049
1106
  // The run will be born here; the board must know the dir or the idea never retires.
1050
1107
  remember(projectDir);
1051
- 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);
1052
1110
  const agentBin = Bun.which(agent.cmd) ?? agent.cmd;
1053
1111
  const prompt = requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
1054
- const flags = agentFlags(projectDir, agent.cmd);
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] : [])];
1055
1117
  // 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);
1118
+ const s = sessionLaunch(projectDir, agent.cmd, [agentBin, ...flags], prompt, it.text.replace(/^(lite|full|auto|plan)\s+/i, ""), run, model, yolo);
1057
1119
  const manual = manualCommand(projectDir, agent.cmd, prompt, flags);
1058
1120
  const drop = () => { if (s.record) rmSync(s.record, { force: true }); };
1059
1121
 
@@ -1249,3 +1311,58 @@ export async function commitPush(state: string, id: string, message: string, sho
1249
1311
  record(true);
1250
1312
  return { ok: true, sha };
1251
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
+ }