kaizen-agent 1.9.4 → 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/README.md +11 -6
- package/agents/kaizen-builder.md +4 -1
- package/cli/board.test.ts +48 -0
- package/cli/install.test.ts +85 -0
- package/cli/install.ts +46 -10
- package/cli/notes.test.ts +1 -1
- package/cli/settings.test.ts +50 -0
- package/cli/settings.ts +90 -35
- package/cli/sources.test.ts +44 -5
- package/cli/sources.ts +11 -6
- package/cli/state.test.ts +214 -2
- package/cli/state.ts +211 -14
- package/cli/web.ts +70 -14
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +1 -0
- package/skills/kaizen/config.default.yml +10 -4
- package/skills/kaizen/spec-build.md +17 -0
- package/skills/kaizen/spec-plan.md +6 -1
- package/web/board.html +214 -49
- package/web/notebook.js +2 -2
- package/web/notes-lib.ts +1 -1
- package/web/notes.html +1 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -104,7 +106,8 @@ function answers(cmd: string[]): boolean {
|
|
|
104
106
|
}
|
|
105
107
|
}
|
|
106
108
|
|
|
107
|
-
|
|
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
|
-
|
|
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"\\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
@@ -885,6 +1081,7 @@ export function launchRun(it: Item, from: string): Launch {
|
|
|
885
1081
|
if (term.detached) proc.unref();
|
|
886
1082
|
return { ok: true, agent, prompt, projectDir, cmd: term.cmd };
|
|
887
1083
|
} catch (err: any) {
|
|
1084
|
+
drop();
|
|
888
1085
|
return { ok: false, agent, prompt, projectDir, manual, why: err?.message ?? String(err) };
|
|
889
1086
|
}
|
|
890
1087
|
}
|