kaizen-agent 1.42.2 → 1.43.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.
@@ -0,0 +1,133 @@
1
+ // Backlog items as cards in the board's Ideas column: numbering, hiding while a run
2
+ // started from one lives, closing in place, and the board routes that drive them.
3
+ import { test, expect } from "bun:test";
4
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, statSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { tmpdir } from "node:os";
7
+ import { backlogLines, backlogCards, startedBacklog, closeBacklog, markBacklogDone, backlogItem } from "./state.ts";
8
+ import { boardIndex } from "./notes.ts";
9
+
10
+ const scratch = () => {
11
+ const state = join(mkdtempSync(join(tmpdir(), "kz-bl-")), ".kaizen");
12
+ const run = (id: string, stage: string, files: Record<string, string> = {}) => {
13
+ const dir = join(state, "runs", id);
14
+ mkdirSync(dir, { recursive: true });
15
+ writeFileSync(join(dir, "state.json"), JSON.stringify({ id, stage, awaiting: null }));
16
+ for (const [f, t] of Object.entries(files)) writeFileSync(join(dir, f), t);
17
+ return dir;
18
+ };
19
+ return { state, run };
20
+ };
21
+
22
+ const BACKLOG = "# Backlog\n\n- open: `cli/x.ts:12`: low: first thing. Fix: do it.\n- done: closed one | run y\n- open: high: urgent thing\n a note under it\n- rejected: no | why\n- open: plain item\n";
23
+
24
+ test("backlogLines numbers every open, done and rejected line as boardIndex does", () => {
25
+ const { state, run } = scratch();
26
+ run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
27
+ const lines = backlogLines(join(state, "runs", "2026-10-01-src", "06-backlog.md"));
28
+ expect(lines.map((l) => [l.n, l.status])).toEqual([[1, "open"], [2, "done"], [3, "open"], [4, "rejected"], [5, "open"]]);
29
+ const idx = boardIndex(state).runs.find((r) => r.id === "2026-10-01-src")!.backlog;
30
+ expect(idx).toEqual(lines.map(({ n, status, text }) => ({ n, status, text })));
31
+ expect(backlogLines(join(state, "nope.md"))).toEqual([]);
32
+ });
33
+
34
+ test("backlogCards: open items of live runs and the orphanage, most severe first; abandoned runs skipped", () => {
35
+ const { state, run } = scratch();
36
+ run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
37
+ run("2026-10-02-gone", "abandoned", { "06-backlog.md": "- open: critical: from an abandoned run\n" });
38
+ writeFileSync(join(state, "backlog.md"), "- open: rescued item | from 2026-09-01-old\n");
39
+ const cards = backlogCards([state]);
40
+ expect(cards.map((c) => [c.source, c.n, c.severity])).toEqual([
41
+ ["2026-10-01-src", 3, "high"], ["2026-10-01-src", 1, "low"], ["2026-10-01-src", 5, null], ["orphan", 1, null],
42
+ ]);
43
+ expect(cards[1]).toMatchObject({ text: "first thing.", at: "x.ts:12", raw: "`cli/x.ts:12`: low: first thing. Fix: do it." });
44
+ });
45
+
46
+ test("a run started from an item hides its card; an abandoned one gives it back", () => {
47
+ const { state, run } = scratch();
48
+ run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
49
+ const kid = run("2026-10-03-kid", "plan", { "00-request.md": "/kaizen urgent thing\n\nParent: 2026-10-01-src\nBacklog: 2026-10-01-src#3\n" });
50
+ expect(startedBacklog(state).get("2026-10-01-src#3")).toEqual([{ id: "2026-10-03-kid", stage: "plan" }]);
51
+ expect(backlogCards([state]).some((c) => c.n === 3)).toBe(false);
52
+ writeFileSync(join(kid, "state.json"), JSON.stringify({ stage: "abandoned" }));
53
+ expect(backlogCards([state]).some((c) => c.n === 3)).toBe(true);
54
+ });
55
+
56
+ test("markBacklogDone closes only the line its done run names, once, and touches no state.json", () => {
57
+ const { state, run } = scratch();
58
+ const src = run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG });
59
+ run("2026-10-03-kid", "build", { "00-request.md": "x\n\nBacklog: 2026-10-01-src#3\n" });
60
+ run("2026-10-04-orph", "done", { "00-request.md": "y\n\nBacklog: orphan#1\n" });
61
+ writeFileSync(join(state, "backlog.md"), "- open: rescued item | from 2026-09-01-old\n");
62
+ markBacklogDone(state);
63
+ expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG); // still building
64
+ expect(readFileSync(join(state, "backlog.md"), "utf8")).toBe("- open: rescued item | from 2026-09-01-old | 2026-10-04-orph\n".replace("open", "done"));
65
+ const kidState = join(state, "runs", "2026-10-03-kid", "state.json");
66
+ writeFileSync(kidState, JSON.stringify({ stage: "done" }));
67
+ const before = statSync(kidState).mtimeMs;
68
+ markBacklogDone(state);
69
+ markBacklogDone(state);
70
+ expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG.replace("- open: high: urgent thing", "- done: high: urgent thing | 2026-10-03-kid"));
71
+ expect(statSync(kidState).mtimeMs).toBe(before);
72
+ expect(backlogCards([state]).map((c) => c.n)).toEqual([1, 5]);
73
+ });
74
+
75
+ test("closeBacklog rewrites one open line in place and keeps CRLF; refuses closed or missing lines", () => {
76
+ const { state, run } = scratch();
77
+ const src = run("2026-10-01-src", "done", { "06-backlog.md": BACKLOG.replace(/\n/g, "\r\n") });
78
+ expect(closeBacklog(state, "2026-10-01-src", 5, "rejected", "not worth it")).toBeNull();
79
+ expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toBe(BACKLOG.replace("- open: plain item", "- rejected: plain item | not worth it").replace(/\n/g, "\r\n"));
80
+ expect(closeBacklog(state, "2026-10-01-src", 2, "rejected", "x")).toBe("that item is already closed");
81
+ expect(closeBacklog(state, "2026-10-01-src", 9, "rejected", "x")).toBe("no such backlog item");
82
+ expect(closeBacklog(state, "../../etc", 1, "rejected", "x")).toBe("no such backlog");
83
+ expect(backlogItem(state, "2026-10-01-src", 5)?.status).toBe("rejected");
84
+ });
85
+
86
+ // The real server in a scratch home. Linux only: launching is made to fail before any
87
+ // terminal or agent opens (no display, no terminal on PATH, terminal launch mode), and
88
+ // the manual command it hands back is the request a run would have been given.
89
+ test.skipIf(process.platform !== "linux")("board routes: /state lists backlog cards, /backlog rejects, /run carries the item's source", async () => {
90
+ const homeDir = mkdtempSync(join(tmpdir(), "kz-bl-home-")), proj = join(homeDir, "p"), state = join(proj, ".kaizen");
91
+ for (const d of [state, join(homeDir, ".kaizen"), join(homeDir, ".claude")]) mkdirSync(d, { recursive: true });
92
+ writeFileSync(join(homeDir, ".kaizen", "projects"), proj + "\n");
93
+ writeFileSync(join(homeDir, ".kaizen", "config.yml"), "agent:\n launch: terminal\n");
94
+ const src = join(state, "runs", "2026-10-01-src");
95
+ mkdirSync(src, { recursive: true });
96
+ writeFileSync(join(src, "state.json"), JSON.stringify({ stage: "done", awaiting: null }));
97
+ writeFileSync(join(src, "06-backlog.md"), BACKLOG);
98
+ const port = 20000 + Math.floor(Math.random() * 20000);
99
+ const repo = join(import.meta.dir, "..");
100
+ const env: Record<string, string> = { HOME: homeDir, USERPROFILE: homeDir, KAIZEN_HOME: repo, PATH: dirname(process.execPath), DISPLAY: "", WAYLAND_DISPLAY: "", TERMINAL: "", TMUX: "" };
101
+ const proc = Bun.spawn([process.execPath, join(repo, "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], { cwd: proj, env, stdout: "ignore", stderr: "ignore" });
102
+ try {
103
+ const at = `http://127.0.0.1:${port}`;
104
+ for (let i = 0; ; i++) { try { await fetch(at + "/state"); break; } catch { if (i > 100) throw new Error("board did not start"); await Bun.sleep(50); } }
105
+ const post = async (path: string, body: object) => { const r = await fetch(at + path, { method: "POST", body: JSON.stringify({ dir: state, ...body }) }); return { status: r.status, ...(await r.json() as any) }; };
106
+ const listed = async () => ((await (await fetch(at + "/state?all=1")).json()) as any).backlog.map((b: any) => [b.source, b.n]);
107
+ expect(await listed()).toEqual([["2026-10-01-src", 3], ["2026-10-01-src", 1], ["2026-10-01-src", 5]]);
108
+
109
+ expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "" })).toMatchObject({ status: 400 });
110
+ expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "not now" })).toMatchObject({ status: 200 });
111
+ expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 5, reason: "again" })).toMatchObject({ status: 409 });
112
+ expect(await post("/backlog", { op: "reject", source: "2026-10-01-src", n: 42, reason: "x" })).toMatchObject({ status: 404 });
113
+ expect(readFileSync(join(src, "06-backlog.md"), "utf8")).toContain("- rejected: plain item | not now\n");
114
+ expect(await listed()).toEqual([["2026-10-01-src", 3], ["2026-10-01-src", 1]]);
115
+
116
+ expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 2 } })).toMatchObject({ status: 409 });
117
+ expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 42 } })).toMatchObject({ status: 404 });
118
+ const r = await post("/run", { kind: "lite", backlog: { source: "2026-10-01-src", n: 1 } });
119
+ expect(r).toMatchObject({ status: 500, ok: false });
120
+ expect(r.manual).toContain("/kaizen lite first thing. (x.ts:12)");
121
+ expect(r.manual).toContain("Parent: 2026-10-01-src");
122
+ expect(r.manual).toContain("Backlog: 2026-10-01-src#1");
123
+ expect(r.manual).toContain("Backlog item: `cli/x.ts:12`: low: first thing. Fix: do it.");
124
+
125
+ // Once a run carries the line, a second start is refused and the card is gone.
126
+ const kid = join(state, "runs", "2026-10-05-kid");
127
+ mkdirSync(kid, { recursive: true });
128
+ writeFileSync(join(kid, "state.json"), JSON.stringify({ stage: "plan", awaiting: null }));
129
+ writeFileSync(join(kid, "00-request.md"), "first thing\n\nParent: 2026-10-01-src\nBacklog: 2026-10-01-src#1\n");
130
+ expect(await post("/run", { backlog: { source: "2026-10-01-src", n: 1 } })).toMatchObject({ status: 409 });
131
+ expect(await listed()).toEqual([["2026-10-01-src", 3]]);
132
+ } finally { proc.kill(); await proc.exited; }
133
+ }, 30_000);
package/cli/board.test.ts CHANGED
@@ -22,7 +22,8 @@ test("Settings rechecks an existing update alert and clears or refreshes it", as
22
22
  getElementById: (id: string) => id === "pbody" ? body : elements.get(id),
23
23
  createElement: () => ({
24
24
  isConnected: false,
25
- set innerHTML(html: string) {
25
+ set innerHTML(v: unknown) {
26
+ const html = String(v);
26
27
  this.html = html;
27
28
  elements.clear();
28
29
  for (const [, id] of html.matchAll(/id="([^"]+)"/g)) elements.set(id, { textContent: "", disabled: false, focus() {} });
@@ -154,7 +155,7 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
154
155
 
155
156
  const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
156
157
  const from = cards.indexOf("if (k === store.menuKey) {");
157
- const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += `<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
158
+ const menuSnippet = (cards.slice(from, cards.indexOf('el.innerHTML += html`<div class="menu-pop"', from)) + "}").replace("const items = [];", "items = [];");
158
159
  const a0 = cards.indexOf("const actionOf =");
159
160
  const actionOf = cards.slice(a0, cards.indexOf("\n\n", a0));
160
161
  const getItems = new Function("c", "archive", "S", `const ico = ${ico.toString()};\nconst decided = new Set(), answer = () => "";\n${actionOf}\nconst store = { S, menuKey: 1 };\nlet k = 1, items = [];\n${menuSnippet}\nreturn items;`);
@@ -178,6 +179,10 @@ test("card menu items: appropriate actions and distinct icons across phases", ()
178
179
  const startItems = getItems(startingIdea, false);
179
180
  expect(startItems.map((i: any) => i ? i[1] : null)).toEqual(["Open", "Run again", "Archive", null, "Remove"]);
180
181
 
182
+ // Ideas column, Backlog tab: a run's leftover, run like an idea, rejected with a reason.
183
+ expect(getItems({ kind: "backlog", status: "idea", column: 0 }, false).map((i: any) => i ? i[1] : null)).toEqual(["Run", "Open its run", null, "Reject"]);
184
+ expect(getItems({ kind: "backlog", status: "starting", column: 0 }, false)[0][1]).toBe("Run again");
185
+
181
186
  // Phase 1: Planning (run waiting for plan approval)
182
187
  const planWaiting = { kind: "run", status: "waiting", awaiting: "approvals.plan", column: 1 };
183
188
  const planItems = getItems(planWaiting, false);
@@ -0,0 +1,19 @@
1
+ import { expect, test } from "bun:test";
2
+ import { html, ico, raw } from "../web/board/dom.ts";
3
+
4
+ test("html escapes interpolations once and keeps trusted markup", () => {
5
+ const bad = '<img src=x onerror=1> & "q"';
6
+ const out = String(html`<b title="${bad}">${bad}</b>`);
7
+ expect(out).not.toContain("<img");
8
+ expect(out).toContain("&lt;img src=x onerror=1&gt; &amp; &quot;q&quot;");
9
+ expect(String(html`${"a&b"}`)).toBe("a&amp;b");
10
+ expect(String(html`${raw("<i>")}${ico("x")}`)).toBe('<i><svg><use href="#i-x"/></svg>');
11
+ expect(String(html`<ul>${[1, 2].map((n) => html`<li>${n}</li>`)}</ul>`)).toBe("<ul><li>1</li><li>2</li></ul>");
12
+ expect(String(html`${["<a>"]}`)).toBe("&lt;a&gt;");
13
+ expect(String(html`[${null}${undefined}${false}]`)).toBe("[]");
14
+ expect(String(html`${html`<u>${"&"}</u>`}`)).toBe("<u>&amp;</u>");
15
+ });
16
+
17
+ test("aria states render false explicitly when stringified", () => {
18
+ expect(String(html`<b aria-pressed="${String(false)}" aria-checked="${String(true)}">`)).toBe('<b aria-pressed="false" aria-checked="true">');
19
+ });
package/cli/notes.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // the page goes through safeName before it becomes a path.
4
4
  import { existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmdirSync, statSync, writeFileSync } from "node:fs";
5
5
  import { join, dirname, resolve, sep } from "node:path";
6
- import { boardCards, reviewFindings, readText } from "./state.ts";
6
+ import { backlogLines, boardCards, reviewFindings, readText } from "./state.ts";
7
7
  import { wikilinks, boardLink, resolveRun } from "../web/notes-lib.ts";
8
8
 
9
9
  export type Note = { name: string; mtime: number; text: string };
@@ -114,11 +114,7 @@ function prune(state: string, dir: string) {
114
114
  // project keeps hundreds of runs.
115
115
  export function boardIndex(state: string) {
116
116
  const cards = boardCards([state], Date.now());
117
- const backlog = (id: string) => {
118
- let text = "";
119
- try { text = readText(join(state, "runs", id, "06-backlog.md")); } catch { /* none */ }
120
- return [...text.matchAll(/^\s*-\s*(open|done|rejected):\s*(.*)$/gm)].map((m, i) => ({ n: i + 1, status: m[1]!, text: m[2]!.trim() }));
121
- };
117
+ const backlog = (id: string) => backlogLines(join(state, "runs", id, "06-backlog.md")).map(({ n, status, text }) => ({ n, status, text }));
122
118
  return {
123
119
  runs: cards.filter((c) => c.kind === "run").map((c) => ({
124
120
  id: c.id!, short: c.id!.replace(/^\d{4}-\d{2}-\d{2}-/, ""), title: c.title ?? c.text, status: c.status,
package/cli/state.test.ts CHANGED
@@ -57,6 +57,11 @@ test("statusOf: waiting beats stage, mtime decides running vs stalled", () => {
57
57
  expect(statusOf(stale!, now)).toBe("stalled");
58
58
  expect(statusOf({ ...stale!, moved: now }, now)).toBe("running");
59
59
  expect(statusOf({ ...stale!, stage: "abandoned", awaiting: "x" }, now)).toBe("abandoned");
60
+ // A builder ticking progress.md is moving, though state.json is old.
61
+ const progress = join(state, "runs", "2026-09-03-stale", "progress.md");
62
+ writeFileSync(progress, "- doing: step 2\n");
63
+ try { expect(statusOf(readRuns(state)[0]!, now)).toBe("running"); } finally { rmSync(progress); }
64
+ expect(statusOf(readRuns(state)[0]!, now)).toBe("stalled");
60
65
  });
61
66
 
62
67
  test("columnOf: every stage word lands somewhere", () => {
@@ -501,6 +506,52 @@ test("commitPush: commits the run's files only, pushes, and records it; the mess
501
506
  expect(cardsGit(join(root, ".kaizen"), ["r1"])).toEqual({}); // not a repo
502
507
  }, 60_000); // dozens of git calls, ~250ms each on Windows
503
508
 
509
+ test("runGit, cardsGit: a run committed elsewhere is not offered the edits made since, nor another run's unpushed commit", () => {
510
+ const root = mkdtempSync(join(tmpdir(), "kz-git-"));
511
+ const bare = join(root, "remote.git"), proj = join(root, "proj"), st = join(proj, ".kaizen");
512
+ // Commit times pinned (seconds since the epoch), so each sits where the test says.
513
+ const sh = (cwd: string, at: number | null, ...a: string[]) => Bun.spawnSync(["git", "-C", cwd, ...a], { env: { ...process.env, ...(at ? { GIT_COMMITTER_DATE: `@${at} +0000`, GIT_AUTHOR_DATE: `@${at} +0000` } : {}) } }).stdout.toString().trim();
514
+ const report = (id: string, text: string, at: number) => {
515
+ mkdirSync(join(st, "runs", id), { recursive: true });
516
+ writeFileSync(join(st, "runs", id, "03-impl.md"), text);
517
+ utimesSync(join(st, "runs", id, "03-impl.md"), new Date(at * 1000), new Date(at * 1000));
518
+ };
519
+ Bun.spawnSync(["git", "init", "-q", "--bare", bare]);
520
+ Bun.spawnSync(["git", "init", "-q", proj]);
521
+ for (const kv of [["user.name", "t"], ["user.email", "t@t"], ["commit.gpgsign", "false"], ["core.hooksPath", "/dev/null"]]) sh(proj, null, "config", ...kv);
522
+ sh(proj, null, "remote", "add", "origin", bare);
523
+ writeFileSync(join(proj, ".gitignore"), ".kaizen/\n");
524
+ writeFileSync(join(proj, "x.txt"), "1");
525
+ report("old", "- `x.txt`: x\n", 1000);
526
+ sh(proj, null, "add", "-A");
527
+ sh(proj, 2000, "commit", "-qm", "feat: x");
528
+ sh(proj, null, "push", "-qu", "origin", "HEAD");
529
+ // Edited again after the run's commit, by someone with no run of their own.
530
+ writeFileSync(join(proj, "x.txt"), "2");
531
+ expect(runGit(st, "old")).toMatchObject({ files: [], ahead: 0, others: 1 });
532
+ expect(cardsGit(st, ["old"])).toEqual({ old: "pushed" });
533
+ // Another run's commit, not pushed: the branch is ahead, the old run is not.
534
+ writeFileSync(join(proj, "y.txt"), "y");
535
+ report("new", "- `y.txt`: y\n", 3000);
536
+ sh(proj, null, "add", "y.txt");
537
+ sh(proj, 4000, "commit", "-qm", "feat: y");
538
+ expect(runGit(st, "old")).toMatchObject({ files: [], ahead: 0 });
539
+ expect(runGit(st, "new")).toMatchObject({ files: [], ahead: 1 });
540
+ expect(cardsGit(st, ["old", "new"])).toEqual({ old: "pushed", new: "pending" });
541
+ // The old run reporting again after its commit: the edit is its work after all.
542
+ report("old", "- `x.txt`: x, again\n", 5000);
543
+ expect((runGit(st, "old") as any).files).toEqual([" M x.txt"]);
544
+ // A commit no run's report names still gets a push: from the run that reported last by then.
545
+ const commit = (f: string, at: number, msg: string) => { writeFileSync(join(proj, f), f); sh(proj, null, "add", f); sh(proj, at, "commit", "-qm", msg); };
546
+ commit("z.txt", 6000, "chore: z");
547
+ expect(runGit(st, "old")).toMatchObject({ ahead: 1 });
548
+ expect(runGit(st, "new")).toMatchObject({ ahead: 1 });
549
+ // A run whose report holds the commit's subject as a line owns it, over the file count.
550
+ report("new", "- `y.txt`: y\n\n## Commit\nchore: z\n", 3000);
551
+ expect(runGit(st, "old")).toMatchObject({ ahead: 0 });
552
+ expect(runGit(st, "new")).toMatchObject({ ahead: 2 });
553
+ }, 60_000);
554
+
504
555
  test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, then tmux, else neither; the prompt stays one literal argument", () => {
505
556
  const bin = mkdtempSync(join(tmpdir(), "kz-bin-"));
506
557
  const stub = (name: string, code: number) => writeFileSync(join(bin, name), `#!/bin/sh\nexit ${code}\n`, { mode: 0o755 });
@@ -921,7 +972,7 @@ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own
921
972
  const alive = (pr: ReturnType<typeof Bun.spawn>) => pr.exitCode === null && pr.signalCode === null;
922
973
  const procs: Record<string, ReturnType<typeof Bun.spawn>> = {
923
974
  mine: start("mine", {}),
924
- later: start("later", { started: Date.now() + 60_000 }), // the decision's own successor
975
+ later: start("later", { started: Date.now() - 1000 }), // the decision's own successor: after `before` below
925
976
  stranger: start("stranger", { cmd: "notkzagent" }), // a PID that is not this agent
926
977
  other: start("other", { run: "2026-10-01-b" }), // another run's
927
978
  };
@@ -929,13 +980,14 @@ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own
929
980
  writeFileSync(join(dir, "dead.json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: null, started: 0 }));
930
981
  writeFileSync(join(dir, "dead.pid"), "999999\n");
931
982
  try {
932
- expect(closeSessions(st, "2026-10-01-a", { before: Date.now() })).toBe(1);
983
+ expect(closeSessions(st, "2026-10-01-a", { before: Date.now() - 3000 })).toBe(1);
933
984
  expect(await procs.mine.exited).not.toBe(0);
934
985
  for (const k of ["later", "stranger", "other"] as const) expect(alive(procs[k])).toBe(true);
935
986
  expect(existsSync(join(dir, "mine.pid"))).toBe(false);
936
987
  expect(existsSync(join(dir, "mine.json"))).toBe(true); // its conversation id is still needed
937
988
  expect(existsSync(join(dir, "dead.pid"))).toBe(false);
938
989
  expect(existsSync(join(dir, "later.pid"))).toBe(true);
990
+ expect(existsSync(join(dir, "stranger.pid"))).toBe(false); // its number is held by something else: dropped, not killed
939
991
 
940
992
  // Off: nothing is ended.
941
993
  writeFileSync(join(st, "config.yml"), "agent:\n default: claude\n close: off\n");
@@ -951,6 +1003,13 @@ test.skipIf(process.platform === "win32")("closeSessions ends only the run's own
951
1003
  sweepSessions(st, Date.now() + CLOSE_GRACE_MS);
952
1004
  expect(await procs.other.exited).not.toBe(0);
953
1005
  expect(alive(procs.later)).toBe(true); // run a is still waiting
1006
+ // A PID file whose process is gone is dropped by the sweep, whatever its run is doing.
1007
+ writeFileSync(join(dir, "dead2.json"), JSON.stringify({ run: "2026-10-01-a", text: "a", cmd: "kzagent", sessionId: null, started: 0 }));
1008
+ writeFileSync(join(dir, "dead2.pid"), "999999\n");
1009
+ sweepSessions(st, Date.now());
1010
+ expect(existsSync(join(dir, "dead2.pid"))).toBe(false);
1011
+ expect(existsSync(join(dir, "dead2.json"))).toBe(true);
1012
+ expect(existsSync(join(dir, "later.pid"))).toBe(true);
954
1013
 
955
1014
  // A fix launched on a done run, after it last moved, is doing the work: kept.
956
1015
  const fix = start("fix", { run: "2026-10-01-b", started: Date.now() + 1000 });