kaizen-agent 1.9.1 → 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 +1 -1
- package/cli/state.test.ts +42 -1
- package/cli/state.ts +38 -7
- package/cli/web.ts +8 -4
- 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;
|
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";
|
|
@@ -188,6 +188,10 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
188
188
|
function serve(port: number) { return Bun.serve({
|
|
189
189
|
hostname: "127.0.0.1",
|
|
190
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,
|
|
191
195
|
async fetch(req) {
|
|
192
196
|
// Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
|
|
193
197
|
// arrives with that hostname in Host, and gets nothing.
|
|
@@ -583,7 +587,7 @@ export async function openApp(url: string) {
|
|
|
583
587
|
} catch { /* fall through to a plain tab */ }
|
|
584
588
|
// `start` is a cmd builtin, not a program, so it has to go through cmd.
|
|
585
589
|
if (process.platform === "win32") {
|
|
586
|
-
try { await Bun
|
|
590
|
+
try { await Bun.$`${cmdExe()} /c start "" ${url}`.quiet(); return true; } catch { return false; }
|
|
587
591
|
}
|
|
588
592
|
const opener = process.platform === "darwin" ? "open" : "xdg-open";
|
|
589
593
|
if (!Bun.which(opener)) return false;
|
|
@@ -644,8 +648,8 @@ export async function shortcut() {
|
|
|
644
648
|
mkdirSync(dirname(vbs), { recursive: true });
|
|
645
649
|
const vq = (s: string) => `""${s}""`; // a quoted arg inside a VBScript string
|
|
646
650
|
writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${vq(bun)} ${vq(script)} web --daemon", 0, False\r\n`);
|
|
647
|
-
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()`;
|
|
648
|
-
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();
|
|
649
653
|
return `${lnk} — double-click starts the board in the background and opens it; kaizen web --stop ends it`;
|
|
650
654
|
}
|
|
651
655
|
|
package/package.json
CHANGED