kaizen-agent 1.9.0 → 1.9.2
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/install.ts +7 -5
- package/cli/state.test.ts +42 -1
- package/cli/state.ts +38 -7
- package/cli/web.ts +26 -8
- package/package.json +1 -1
package/cli/install.ts
CHANGED
|
@@ -244,7 +244,7 @@ async function noAgent() {
|
|
|
244
244
|
async function openUrl(url: string) {
|
|
245
245
|
// `start` is a cmd builtin, not a program, so it has to go through cmd.
|
|
246
246
|
if (process.platform === "win32") {
|
|
247
|
-
try { await Bun
|
|
247
|
+
try { const { cmdExe } = await import("./state.ts"); await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
|
|
248
248
|
}
|
|
249
249
|
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
250
250
|
if (!Bun.which(opener)) return false;
|
|
@@ -279,7 +279,6 @@ if (wantWeb) {
|
|
|
279
279
|
const port = at !== -1 ? Number(Bun.argv[at + 1]) || undefined : undefined;
|
|
280
280
|
const url = `http://127.0.0.1:${port ?? 7420}/`;
|
|
281
281
|
const pidFile = join(home, ".kaizen", "web.pid");
|
|
282
|
-
const alive = async () => { try { await fetch(url); return true; } catch { return false; } };
|
|
283
282
|
|
|
284
283
|
if (args.has("--stop")) {
|
|
285
284
|
// The pid file only knows a --daemon; a board started from the shortcut or a
|
|
@@ -300,7 +299,10 @@ if (wantWeb) {
|
|
|
300
299
|
if (args.has("--daemon")) {
|
|
301
300
|
// The daemon is this same command, detached, with the browser left to us:
|
|
302
301
|
// ignored stdio is what lets it outlive the terminal.
|
|
303
|
-
|
|
302
|
+
// A board running an older kaizen is not "already running": the child finds
|
|
303
|
+
// it on the port and replaces it (web() in web.ts).
|
|
304
|
+
const { current } = await import("./web.ts");
|
|
305
|
+
if (await current(url)) {
|
|
304
306
|
console.log(` kaizen web already running at ${url}`);
|
|
305
307
|
} else {
|
|
306
308
|
const argv = Bun.argv.slice(2).filter((a) => a !== "--daemon" && a !== "--no-open");
|
|
@@ -310,8 +312,8 @@ if (wantWeb) {
|
|
|
310
312
|
child.unref();
|
|
311
313
|
mkdirSync(dirname(pidFile), { recursive: true });
|
|
312
314
|
writeFileSync(pidFile, String(child.pid));
|
|
313
|
-
for (let i = 0; i < 30 && !(await
|
|
314
|
-
if (!(await
|
|
315
|
+
for (let i = 0; i < 30 && !(await current(url)); i++) await Bun.sleep(100);
|
|
316
|
+
if (!(await current(url))) { console.log(` kaizen web did not come up on ${url}`); process.exit(1); }
|
|
315
317
|
console.log(`\n kaizen web ${url} (pid ${child.pid}, detached)`);
|
|
316
318
|
console.log(` kaizen web --stop to stop it\n`);
|
|
317
319
|
}
|
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, pruneRuns, unrechecked, notice, notifier, notifyArgs, LOGO, type Card,
|
|
11
|
+
reviewFindings, pickFindings, fixPrompt, 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;
|
|
@@ -355,6 +355,47 @@ test.skipIf(process.platform === "win32")("findTerminal: a running herdr wins, t
|
|
|
355
355
|
}
|
|
356
356
|
});
|
|
357
357
|
|
|
358
|
+
test("nativePath on Windows reads the spellings Git Bash writes into the projects registry as one folder", () => {
|
|
359
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
|
360
|
+
try {
|
|
361
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
362
|
+
for (const p of ["/c/Users/me/proj", "C:/Users/me/proj", "c:\\Users\\me\\proj\\", "C:\\Users\\me\\proj"])
|
|
363
|
+
expect(nativePath(p)).toBe("C:\\Users\\me\\proj");
|
|
364
|
+
expect(nativePath("/d")).toBe("D:\\");
|
|
365
|
+
} finally { Object.defineProperty(process, "platform", platform); }
|
|
366
|
+
expect(nativePath("/c/Users/me")).toBe("/c/Users/me"); // elsewhere a path is left alone
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("findTerminal on Windows names cmd and PowerShell by full path, so a PATH without System32 still launches", () => {
|
|
370
|
+
const keys = ["PATH", "ComSpec", "SystemRoot", "windir"];
|
|
371
|
+
const saved = keys.map((k) => process.env[k]);
|
|
372
|
+
const platform = Object.getOwnPropertyDescriptor(process, "platform")!;
|
|
373
|
+
try {
|
|
374
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
375
|
+
process.env.PATH = mkdtempSync(join(tmpdir(), "kz-empty-"));
|
|
376
|
+
for (const k of keys.slice(1)) delete process.env[k];
|
|
377
|
+
process.env.SystemRoot = "D:\\Win";
|
|
378
|
+
const t = findTerminal("C:\\p", ["claude", "/kaizen x"])!;
|
|
379
|
+
expect(t.cmd[0]).toBe("D:\\Win\\System32\\cmd.exe");
|
|
380
|
+
expect(t.cmd[6]).toBe("D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe");
|
|
381
|
+
process.env.ComSpec = "E:\\cmd.exe";
|
|
382
|
+
expect(findTerminal("C:\\p", ["claude"])!.cmd[0]).toBe("E:\\cmd.exe");
|
|
383
|
+
// A running herdr: a tab, and the run typed into it as one encoded line.
|
|
384
|
+
writeFileSync(join(process.env.PATH, "herdr"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
|
385
|
+
const h = findTerminal("C:\\p", ["claude", "/kaizen it's\nnote"])!;
|
|
386
|
+
expect([h.cmd[0], h.cmd[1], h.cmd[2], h.detached]).toEqual(["D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe", "-NoProfile", "-EncodedCommand", false]);
|
|
387
|
+
const launch = Buffer.from(h.cmd[3], "base64").toString("utf16le");
|
|
388
|
+
expect(launch).toContain("tab create --cwd 'C:\\p' --label kaizen --focus");
|
|
389
|
+
const typed = /pane run \$Matches\[1\] '(.*)' \}/.exec(launch)![1].replace(/''/g, "'");
|
|
390
|
+
expect(typed).not.toContain("\n");
|
|
391
|
+
const run = Buffer.from(typed.split(" -EncodedCommand ")[1], "base64").toString("utf16le");
|
|
392
|
+
expect(run).toBe("Set-Location 'C:\\p'; & 'claude' '/kaizen it''s\nnote'");
|
|
393
|
+
} finally {
|
|
394
|
+
Object.defineProperty(process, "platform", platform);
|
|
395
|
+
keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
|
|
396
|
+
}
|
|
397
|
+
});
|
|
398
|
+
|
|
358
399
|
test("prune: only finished runs beyond keep go, open items are rescued, a dry run deletes nothing", () => {
|
|
359
400
|
const st = join(mkdtempSync(join(tmpdir(), "kaizen-prune-")), ".kaizen");
|
|
360
401
|
const mk = (id: string, stage: string, backlog = "") => {
|
package/cli/state.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `.kaizen/` directory. No terminal, no HTTP: the TUI (`dashboard.ts`) and the web
|
|
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
|
-
import { join, dirname } from "node:path";
|
|
5
|
+
import { join, dirname, win32 } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
|
|
8
8
|
// The real path: process.cwd() is always physical, so a raw $HOME that crosses a
|
|
@@ -79,6 +79,14 @@ const shq = (a: string) => `'${a.replace(/'/g, `'\\''`)}'`;
|
|
|
79
79
|
// Bun.which reads the PATH the process started with unless handed the current one.
|
|
80
80
|
const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
|
|
81
81
|
|
|
82
|
+
// Windows' own programs by full path: a board started from the shortcut or a
|
|
83
|
+
// stripped-down environment can have a PATH without System32, and a bare "cmd.exe"
|
|
84
|
+
// then fails with "Executable not found in $PATH".
|
|
85
|
+
const sysRoot = () => process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
|
|
86
|
+
export const sysExe = (name: string) => `${sysRoot()}\\System32\\${name}`;
|
|
87
|
+
export const cmdExe = () => process.env.ComSpec ?? sysExe("cmd.exe");
|
|
88
|
+
export const powershellExe = () => onPath("pwsh.exe") ?? onPath("powershell.exe") ?? sysExe("WindowsPowerShell\\v1.0\\powershell.exe");
|
|
89
|
+
|
|
82
90
|
// Whether a multiplexer's server is up: its own CLI says so by exit code. spawnSync
|
|
83
91
|
// throws on a missing binary, so the path is resolved first.
|
|
84
92
|
function answers(cmd: string[]): boolean {
|
|
@@ -102,16 +110,28 @@ export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; d
|
|
|
102
110
|
// Terminal hosts the shell when installed; otherwise `start` gives it a window.
|
|
103
111
|
if (process.platform === "win32") {
|
|
104
112
|
const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
|
|
105
|
-
const shell =
|
|
113
|
+
const shell = powershellExe();
|
|
106
114
|
const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
|
|
107
115
|
const enc = Buffer.from(ps, "utf16le").toString("base64");
|
|
108
|
-
|
|
109
|
-
|
|
116
|
+
// herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
|
|
117
|
+
// its PowerShell. Typed text ends at a newline, so what is typed is one line, the
|
|
118
|
+
// same encoded command; -EncodedCommand is also not a script file, so an
|
|
119
|
+
// execution policy that blocks .ps1 files does not stop it.
|
|
120
|
+
const herdr = onPath("herdr");
|
|
121
|
+
if (herdr && answers([herdr, "workspace", "list"])) {
|
|
122
|
+
const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}`;
|
|
123
|
+
const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
|
|
124
|
+
if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
|
|
125
|
+
return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
|
|
126
|
+
}
|
|
127
|
+
const wt = onPath("wt.exe");
|
|
128
|
+
if (wt) {
|
|
129
|
+
return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
|
|
110
130
|
}
|
|
111
131
|
// `start` reads a title only when quoted; an unquoted word is the command, so
|
|
112
132
|
// "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
|
|
113
133
|
return {
|
|
114
|
-
cmd: [
|
|
134
|
+
cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
|
|
115
135
|
detached: true,
|
|
116
136
|
};
|
|
117
137
|
}
|
|
@@ -193,7 +213,18 @@ export function knownProjects(): string[] {
|
|
|
193
213
|
if (!existsSync(REGISTRY)) return [];
|
|
194
214
|
return readFileSync(REGISTRY, "utf8").split("\n")
|
|
195
215
|
.map((l) => l.trim())
|
|
196
|
-
.filter((l) => l && !l.startsWith("#"))
|
|
216
|
+
.filter((l) => l && !l.startsWith("#"))
|
|
217
|
+
.map(nativePath)
|
|
218
|
+
.filter((d, i, all) => all.indexOf(d) === i);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// On Windows the registry is also written by agents running Git Bash, which says
|
|
222
|
+
// /c/Users/me/proj or C:/Users/me/proj. Read as is, the first is C:\c\Users\..., a
|
|
223
|
+
// folder that does not exist, and the project drops off the board. One spelling
|
|
224
|
+
// per folder, drive letter upper case, so the same project is not listed twice.
|
|
225
|
+
export function nativePath(p: string) {
|
|
226
|
+
if (process.platform !== "win32") return p;
|
|
227
|
+
return win32.resolve(p.replace(/^\/([a-zA-Z])(?=\/|$)/, "$1:/")).replace(/^[a-z]:/, (d) => d.toUpperCase());
|
|
197
228
|
}
|
|
198
229
|
|
|
199
230
|
// Never fails the command that triggered it: a read-only home is not a reason for a
|
|
@@ -854,7 +885,7 @@ export function launchRun(it: Item, from: string): Launch {
|
|
|
854
885
|
export async function pidOnPort(port: number): Promise<number | null> {
|
|
855
886
|
try {
|
|
856
887
|
if (process.platform === "win32") {
|
|
857
|
-
const out = await Bun
|
|
888
|
+
const out = await Bun.$`${sysExe("netstat.exe")} -ano -p tcp`.text();
|
|
858
889
|
const m = new RegExp(`127\\.0\\.0\\.1:${port}\\s+\\S+\\s+LISTENING\\s+(\\d+)`).exec(out);
|
|
859
890
|
return m ? Number(m[1]) : null;
|
|
860
891
|
}
|
package/cli/web.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
|
|
|
5
5
|
import {
|
|
6
6
|
home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
|
|
7
7
|
readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
|
|
8
|
-
appendNote, saveAttachment, pidOnPort, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
|
|
8
|
+
appendNote, saveAttachment, pidOnPort, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
|
|
9
9
|
reviewFindings, pickFindings, fixPrompt,
|
|
10
10
|
} from "./state.ts";
|
|
11
11
|
import { readSources, addSource, removeSource, moveSource, gatherSource, gatherAll, gatherDirs, due } from "./sources.ts";
|
|
@@ -16,6 +16,19 @@ const PAGE = join(dirname(import.meta.dir), "web", "board.html");
|
|
|
16
16
|
const NOTES_PAGE = join(dirname(import.meta.dir), "web", "notes.html");
|
|
17
17
|
const NOTES_JS = join(dirname(import.meta.dir), "web", "notebook.js");
|
|
18
18
|
const DEFAULT_PORT = 7420;
|
|
19
|
+
// Read once at start: a board keeps the code it started with, so this is what it
|
|
20
|
+
// runs, not what is installed now. `/` sends it; `current()` compares the two.
|
|
21
|
+
const VERSION = (() => {
|
|
22
|
+
try { return JSON.parse(readFileSync(join(dirname(import.meta.dir), "package.json"), "utf8")).version as string; }
|
|
23
|
+
catch { return "unknown"; }
|
|
24
|
+
})();
|
|
25
|
+
|
|
26
|
+
// A board answers at url and runs the installed version. A board from before the
|
|
27
|
+
// version header sends none, so it counts as stale too.
|
|
28
|
+
export async function current(url: string) {
|
|
29
|
+
const r = await fetch(url, { signal: AbortSignal.timeout(1500) }).catch(() => null);
|
|
30
|
+
return !!r?.ok && r.headers.get("x-kaizen-version") === VERSION;
|
|
31
|
+
}
|
|
19
32
|
|
|
20
33
|
type Opts = { port?: number; open?: boolean };
|
|
21
34
|
|
|
@@ -158,11 +171,12 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
158
171
|
if (!["EADDRINUSE", "EACCES"].includes((e as { code?: string }).code ?? "")) throw e;
|
|
159
172
|
const url = `http://127.0.0.1:${port}/`;
|
|
160
173
|
const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
|
|
161
|
-
if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
|
|
162
|
-
// A listener that does not answer is a board that hung
|
|
174
|
+
if (up && await current(url)) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
|
|
175
|
+
// A listener that does not answer is a board that hung, and one running an
|
|
176
|
+
// older kaizen serves 404 for whatever came since; both are ours to stop.
|
|
163
177
|
const pid = await pidOnPort(port);
|
|
164
178
|
if (pid && pid !== process.pid) {
|
|
165
|
-
try { process.kill(pid, "SIGTERM"); console.log(` stopped a hung board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
|
|
179
|
+
try { process.kill(pid, "SIGTERM"); console.log(` stopped a ${up ? "stale" : "hung"} board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
|
|
166
180
|
await Bun.sleep(300);
|
|
167
181
|
try { server = serve(port); continue; } catch { /* still held */ }
|
|
168
182
|
}
|
|
@@ -174,6 +188,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
174
188
|
function serve(port: number) { return Bun.serve({
|
|
175
189
|
hostname: "127.0.0.1",
|
|
176
190
|
port,
|
|
191
|
+
// Bun drops a request after 10s idle by default. A slow /state (git status in a
|
|
192
|
+
// big repo, on Windows) then comes back as nothing, and the page shows an empty
|
|
193
|
+
// board. Loopback only, so nothing waits on us but our own page.
|
|
194
|
+
idleTimeout: 0,
|
|
177
195
|
async fetch(req) {
|
|
178
196
|
// Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
|
|
179
197
|
// arrives with that hostname in Host, and gets nothing.
|
|
@@ -186,7 +204,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
186
204
|
if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
|
|
187
205
|
// The page gets the notify rule from state.ts rather than keeping its own copy.
|
|
188
206
|
return new Response(readFileSync(PAGE, "utf8").replace("/*notifier*/", () => notifier.toString()), {
|
|
189
|
-
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
|
|
207
|
+
headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store", "x-kaizen-version": VERSION },
|
|
190
208
|
});
|
|
191
209
|
}
|
|
192
210
|
|
|
@@ -569,7 +587,7 @@ export async function openApp(url: string) {
|
|
|
569
587
|
} catch { /* fall through to a plain tab */ }
|
|
570
588
|
// `start` is a cmd builtin, not a program, so it has to go through cmd.
|
|
571
589
|
if (process.platform === "win32") {
|
|
572
|
-
try { await Bun
|
|
590
|
+
try { await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
|
|
573
591
|
}
|
|
574
592
|
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
575
593
|
if (!Bun.which(opener)) return false;
|
|
@@ -630,8 +648,8 @@ export async function shortcut() {
|
|
|
630
648
|
mkdirSync(dirname(vbs), { recursive: true });
|
|
631
649
|
const vq = (s: string) => `""${s}""`; // a quoted arg inside a VBScript string
|
|
632
650
|
writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${vq(bun)} ${vq(script)} web --daemon", 0, False\r\n`);
|
|
633
|
-
const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='wscript.exe';$s.Arguments='"${vbs}"';$s.WorkingDirectory='${home}';${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
|
|
634
|
-
await Bun
|
|
651
|
+
const ps = `$s=(New-Object -ComObject WScript.Shell).CreateShortcut('${lnk}');$s.TargetPath='${sysExe("wscript.exe")}';$s.Arguments='"${vbs}"';$s.WorkingDirectory='${home}';${existsSync(ico) ? `$s.IconLocation='${ico}';` : ""}$s.Save()`;
|
|
652
|
+
await Bun.$`${powershellExe()} -NoProfile -Command ${ps}`.quiet();
|
|
635
653
|
return `${lnk} — double-click starts the board in the background and opens it; kaizen web --stop ends it`;
|
|
636
654
|
}
|
|
637
655
|
|
package/package.json
CHANGED