kaizen-agent 1.7.7 → 1.7.8

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/state.ts CHANGED
@@ -75,27 +75,23 @@ export function findTerminal(cwd: string, fullCmd: string[]): { cmd: string[]; d
75
75
  const inTmux = Boolean(process.env.TMUX);
76
76
 
77
77
  // Windows has no xdg anything, and none of the terminals below exist there.
78
- // PowerShell is what a Windows user has open anyway: one -Command string,
79
- // where a single quote is doubled rather than escaped. Windows Terminal hosts
80
- // it when installed; otherwise `start` gives it a window of its own.
78
+ // PowerShell is what a Windows user has open anyway. The command reaches it as
79
+ // -EncodedCommand (base64 of UTF-16LE): a prompt with notes has newlines, which
80
+ // end a cmd.exe command line, and semicolons, which Windows Terminal splits its
81
+ // own arguments on -- neither survives as a plain -Command string. Windows
82
+ // Terminal hosts the shell when installed; otherwise `start` gives it a window.
81
83
  if (process.platform === "win32") {
82
84
  const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
83
85
  const shell = Bun.which("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
84
- // No Set-Location in the command: both launchers set the directory themselves,
85
- // and the semicolon that would separate the two statements is what Windows
86
- // Terminal splits its own arguments on. Any semicolon still left in the prompt
87
- // is escaped for wt, which reads it before PowerShell ever sees the quoting.
88
- const ps = `& ${fullCmd.map(q).join(" ")}`;
86
+ const ps = `Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}`;
87
+ const enc = Buffer.from(ps, "utf16le").toString("base64");
89
88
  if (Bun.which("wt.exe")) {
90
- return {
91
- cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-Command", ps.replace(/;/g, "\\;")],
92
- detached: true,
93
- };
89
+ return { cmd: ["wt.exe", "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
94
90
  }
95
91
  // `start` reads a title only when quoted; an unquoted word is the command, so
96
92
  // "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
97
93
  return {
98
- cmd: ["cmd.exe", "/c", "start", "", "/D", cwd, shell, "-NoExit", "-Command", ps],
94
+ cmd: ["cmd.exe", "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
99
95
  detached: true,
100
96
  };
101
97
  }
package/cli/web.ts CHANGED
@@ -1,432 +1,434 @@
1
- // `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
2
- // one HTML page; the page never sees the filesystem, only JSON.
3
- import { existsSync, readFileSync, readdirSync, statSync, watch, appendFileSync, type FSWatcher } from "node:fs";
4
- import { join, dirname } from "node:path";
5
- import {
6
- home, type Item, boardCards, knownProjects, remember, locate, label, tilde,
7
- readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
8
- appendNote, saveAttachment, pidOnPort,
9
- } from "./state.ts";
10
-
11
- const PAGE = join(dirname(import.meta.dir), "web", "board.html");
12
- const DEFAULT_PORT = 7420;
13
-
14
- type Opts = { port?: number; open?: boolean };
15
-
16
- export async function web(repoDir: string, opts: Opts = {}) {
17
- // A board started from the shortcut has no terminal to show a crash in, so any
18
- // crash is written where it can be read afterwards.
19
- const log = join(home, ".kaizen", "web.log");
20
- for (const ev of ["uncaughtException", "unhandledRejection"] as const) {
21
- process.on(ev, (err: unknown) => {
22
- const line = `${new Date().toISOString()} ${ev}: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`;
23
- try { appendFileSync(log, line); } catch { /* nowhere to write */ }
24
- console.error(line);
25
- if (ev === "uncaughtException") process.exit(1);
26
- });
27
- }
28
- const state = locate();
29
- if (state && dirname(state) !== home) remember(dirname(state));
30
- const project = state ? label(state) : null;
31
-
32
- const states = (all: boolean) => (all || !state
33
- ? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
34
- : [state]
35
- ).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
36
-
37
- // Any tab on this machine can reach loopback. GET only reads the user's own
38
- // files, but a POST from a page on the wider web must not write them.
39
- const local = (req: Request) => {
40
- const origin = req.headers.get("origin");
41
- if (!origin) return true; // curl, same-origin fetch on old browsers
42
- try {
43
- const h = new URL(origin).hostname;
44
- return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
45
- } catch { return false; }
46
- };
47
- const json = (body: unknown, status = 200) =>
48
- new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
49
- const bad = (why: string, status = 400) => json({ error: why }, status);
50
-
51
- // Change notification: one SSE stream per open board, fed by fs.watch where it
52
- // works and a slow poll where it does not.
53
- const clients = new Set<ReadableStreamDefaultController>();
54
- let pending: ReturnType<typeof setTimeout> | null = null;
55
- const changed = () => {
56
- if (pending) return;
57
- pending = setTimeout(() => {
58
- pending = null;
59
- for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
60
- }, 200);
61
- };
62
- // Dev: the page reloads itself when board.html is saved. Server code needs
63
- // `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
64
- try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
65
- const watchers: FSWatcher[] = [];
66
- const watchAll = () => {
67
- for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
68
- watchers.length = 0;
69
- for (const dir of states(true)) {
70
- // An unhandled 'error' on a watcher (Windows: a watched dir renamed, EPERM on
71
- // a locked file) would end the process; the poll covers what the watch misses.
72
- try { watchers.push(watch(dir, { recursive: true }, changed).on("error", () => {})); } catch { /* poll covers it */ }
73
- }
74
- };
75
- watchAll();
76
- // The poll only speaks when something moved: a board reloading every two
77
- // seconds for nothing is worse than no fallback at all.
78
- const fingerprint = () => {
79
- let sum = 0;
80
- for (const dir of states(true)) {
81
- for (const f of ["inbox.md", "backlog.md", "archive.md"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
82
- const runs = join(dir, "runs");
83
- if (!existsSync(runs)) continue;
84
- for (const id of readdirSync(runs)) { try { sum += statSync(join(runs, id, "state.json")).mtimeMs; } catch { /* absent */ } }
85
- }
86
- return sum;
87
- };
88
- let last = fingerprint();
89
- let seen = states(true).join("\n");
90
- const poll = setInterval(() => {
91
- // A project registered since start (a run launched into a fresh dir) gets a watcher too.
92
- const now = states(true).join("\n"); if (now !== seen) { seen = now; watchAll(); }
93
- const fp = fingerprint(); if (fp !== last) { last = fp; changed(); }
94
- }, 2000);
95
-
96
- const BOOT = Date.now().toString(36);
97
- const ping = setInterval(() => { for (const c of clients) { try { c.enqueue(": ping\n\n"); } catch { clients.delete(c); } } }, 8000);
98
- // A launcher clicked while a board is already up should show that board, not die.
99
- // A bind can fail because a board is already there, or because the OS will not
100
- // hand out the port at all (Windows reserves whole ranges for Hyper-V and WSL,
101
- // and reports either as "in use"). Only a board that answers is "already
102
- // running"; otherwise walk up to the next port that binds.
103
- let port = opts.port ?? DEFAULT_PORT;
104
- let server: ReturnType<typeof serve> | null = null;
105
- for (let tries = 0; tries < 10 && !server; tries++, port++) {
106
- try { server = serve(port); } catch (e) {
107
- if (!["EADDRINUSE", "EACCES"].includes((e as { code?: string }).code ?? "")) throw e;
108
- const url = `http://127.0.0.1:${port}/`;
109
- const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
110
- if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
111
- // A listener that does not answer is a board that hung; it is ours to stop.
112
- const pid = await pidOnPort(port);
113
- if (pid && pid !== process.pid) {
114
- try { process.kill(pid, "SIGTERM"); console.log(` stopped a hung board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
115
- await Bun.sleep(300);
116
- try { server = serve(port); continue; } catch { /* still held */ }
117
- }
118
- console.log(` port ${port} refused by the OS and nothing answers there; trying ${port + 1}`);
119
- }
120
- }
121
- if (!server) { console.log(`\n kaizen web could not bind any port from ${opts.port ?? DEFAULT_PORT}\n`); return; }
122
-
123
- function serve(port: number) { return Bun.serve({
124
- hostname: "127.0.0.1",
125
- port,
126
- async fetch(req) {
127
- // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
128
- // arrives with that hostname in Host, and gets nothing.
129
- const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
130
- if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
131
- const url = new URL(req.url);
132
- const path = url.pathname;
133
-
134
- if (req.method === "GET" && path === "/") {
135
- if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
136
- return new Response(readFileSync(PAGE), {
137
- headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
138
- });
139
- }
140
-
141
- if (req.method === "GET" && path === "/state") {
142
- const all = url.searchParams.get("all") === "1";
143
- const dirs = states(all);
144
- return json({
145
- project, all,
146
- projects: states(true).map((d) => ({ dir: d, label: label(d) })),
147
- cards: boardCards(dirs, Date.now()),
148
- now: Date.now(),
149
- });
150
- }
151
-
152
- if (req.method === "GET" && path.startsWith("/run/")) {
153
- const id = decodeURIComponent(path.slice(5));
154
- const dir = url.searchParams.get("dir") ?? state;
155
- if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
156
- const base = join(dir, "runs", id);
157
- if (!existsSync(base)) return bad("no such run", 404);
158
- const read = (f: string) => existsSync(join(base, f)) ? readFileSync(join(base, f), "utf8") : null;
159
- return json({
160
- id, short: short(id), state: read("state.json"),
161
- request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
162
- impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
163
- notes: read("notes.md"),
164
- });
165
- }
166
-
167
- if (req.method === "GET" && path === "/settings") {
168
- const { listSettings } = await import("./settings.ts");
169
- return json(listSettings(repoDir));
170
- }
171
-
172
- if (req.method === "POST" && path === "/settings") {
173
- if (!local(req)) return bad("cross-origin write refused", 403);
174
- let body: any;
175
- try { body = await req.json(); } catch { return bad("json body expected"); }
176
- const { setSetting } = await import("./settings.ts");
177
- const r = setSetting(repoDir, String(body.key ?? ""), String(body.value ?? ""));
178
- return "error" in r ? bad(r.error) : json(r);
179
- }
180
-
181
- // Attachments only, never a run's own files: the path must sit under an
182
- // attachments/ directory of a known state dir, with no way back up.
183
- if (req.method === "GET" && path === "/file") {
184
- const dir = url.searchParams.get("dir") ?? state;
185
- const p = url.searchParams.get("p") ?? "";
186
- if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
187
- if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
188
- const f = join(dir, p);
189
- if (!existsSync(f)) return bad("no such file", 404);
190
- return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
191
- }
192
-
193
- if (req.method === "GET" && path === "/events") {
194
- let ctrl: ReadableStreamDefaultController;
195
- const stream = new ReadableStream({
196
- // The boot id lets the page tell a server restart from a dropped
197
- // connection; only the former means new code. The ping keeps Bun's
198
- // idle timeout from closing the stream every ten seconds.
199
- start(c) { ctrl = c; clients.add(c); c.enqueue(`data: hello ${BOOT}\n\n`); },
200
- cancel() { clients.delete(ctrl); },
201
- });
202
- return new Response(stream, {
203
- headers: { "content-type": "text/event-stream", "cache-control": "no-store", "connection": "keep-alive" },
204
- });
205
- }
206
-
207
- if (req.method === "POST") {
208
- if (!local(req)) return bad("cross-origin write refused", 403);
209
- let body: any;
210
- try { body = await req.json(); } catch { return bad("json body expected"); }
211
- // No project here means the global inbox, which is where the TUI puts ideas too.
212
- const dir: string = body.dir ?? state ?? join(home, ".kaizen");
213
- if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
214
-
215
- if (path === "/inbox") {
216
- const text = String(body.text ?? "").trim();
217
- // Absent means untouched; an empty string clears. replaceIdea keeps the
218
- // old notes for any op that does not send them.
219
- const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
220
- if (body.op === "add") {
221
- if (!text) return bad("empty idea");
222
- writeInbox(dir, [...readInbox(dir), { status: "open", text, notes }]);
223
- } else if (body.op === "edit") {
224
- const next = String(body.next ?? "").trim();
225
- if (!text || !next) return bad("empty idea");
226
- replaceIdea(dir, text, notes === undefined ? { status: "open", text: next } : { status: "open", text: next, notes });
227
- } else if (body.op === "reject") {
228
- const why = String(body.reason ?? "").trim();
229
- if (!why) return bad("a rejection needs a reason");
230
- replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
231
- } else if (body.op === "delete") {
232
- replaceIdea(dir, text, null);
233
- } else return bad("unknown op");
234
- changed();
235
- return json({ ok: true });
236
- }
237
-
238
- if (path === "/run") {
239
- const text = String(body.text ?? "").trim();
240
- const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
241
- if (!text) return bad("empty idea");
242
- const it: Item = parseItem(`${kind} ${text}`.trim());
243
- it.notes = readInbox(dir).find((l) => l.status === "open" && l.text === text)?.notes;
244
- const r = launchRun(it, dir);
245
- if (r.ok) replaceIdea(dir, text, { status: "started", text });
246
- changed();
247
- return json(r.ok
248
- ? { ok: true, agent: r.agent, project: tilde(r.projectDir) }
249
- : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
250
- }
251
-
252
- // Archive is a list beside the runs, never a move: a run keeps its directory
253
- // and its state, and the board simply stops showing it.
254
- if (path === "/archive") {
255
- const archived = body.archived !== false;
256
- if (typeof body.id === "string") {
257
- if (!/^[\w.-]+$/.test(body.id)) return bad("bad run id");
258
- setArchived(dir, body.id, archived);
259
- } else if (typeof body.text === "string") {
260
- const text = body.text.trim();
261
- const lines = readInbox(dir);
262
- const at = lines.findIndex((l) => l.text === text && (archived ? l.status === "open" : l.status === "archived"));
263
- if (at < 0) return bad("no such idea", 404);
264
- lines[at] = { ...lines[at]!, status: archived ? "archived" : "open" };
265
- writeInbox(dir, lines);
266
- } else return bad("id or text required");
267
- changed();
268
- return json({ ok: true });
269
- }
270
-
271
- // One chat message: text plus files (base64), each file saved and linked
272
- // from the message so the note carries its own attachments.
273
- if (path === "/note") {
274
- const id = typeof body.id === "string" ? body.id : null;
275
- if (id !== null && !/^[\w.-]+$/.test(id)) return bad("bad run id");
276
- const text = typeof body.text === "string" ? body.text.trim() : "";
277
- const files: { name: string; data: string }[] = Array.isArray(body.files) ? body.files : [];
278
- let total = 0;
279
- for (const f of files) total += (f.data?.length ?? 0);
280
- if (total > 20_000_000) return bad("attachments over 15MB");
281
- const links = files.map((f) => {
282
- const bytes = Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64");
283
- const rel = saveAttachment(dir, id, String(f.name ?? "file"), bytes).replace(/\\/g, "/");
284
- return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel.replace(/^runs\/[^/]+\//, "")})`;
285
- });
286
- const note = [text, ...links].filter(Boolean).join("\n");
287
- const failed = appendNote(dir, id !== null ? { id } : { text: String(body.for ?? "").trim() }, note);
288
- changed();
289
- return failed ? bad(failed, 404) : json({ ok: true });
290
- }
291
-
292
- if (path === "/notes") {
293
- const id = String(body.id ?? "");
294
- if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
295
- const failed = writeNotes(dir, id, String(body.text ?? ""));
296
- changed();
297
- return failed ? bad(failed, 404) : json({ ok: true });
298
- }
299
-
300
- if (path === "/abort") {
301
- const id = String(body.id ?? "");
302
- const why = String(body.why ?? "").trim() || "abandoned from the web board";
303
- if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
304
- const failed = abandonRun(dir, id, why);
305
- changed();
306
- return failed ? bad(failed, 409) : json({ ok: true });
307
- }
308
- }
309
-
310
- return new Response("not found", { status: 404 });
311
- },
312
- }); }
313
-
314
- const url = `http://127.0.0.1:${server.port}/`;
315
- console.log(`\n kaizen web ${url}`);
316
- console.log(` ${project ? `project ${project}` : "no .kaizen here; showing every known project"}`);
317
- console.log(` ctrl-c to stop\n`);
318
- if (opts.open !== false) await openApp(url);
319
-
320
- await new Promise<void>((resolve) => {
321
- const stop = () => { clearInterval(poll); clearInterval(ping); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
322
- process.on("SIGINT", stop);
323
- process.on("SIGTERM", stop);
324
- });
325
- }
326
-
327
- // A Chromium-family browser in --app mode is a window with no tabs or address bar,
328
- // which is as close to a desktop app as a web page gets. Anything else gets a tab.
329
- export async function openApp(url: string) {
330
- const chromes = process.platform === "darwin"
331
- ? ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"]
332
- : process.platform === "win32"
333
- ? ["chrome.exe", "msedge.exe", "chromium.exe", "brave.exe"]
334
- : ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "microsoft-edge", "brave", "brave-browser"];
335
- try {
336
- if (process.platform === "darwin") {
337
- for (const app of chromes) {
338
- const r = await Bun.$`open -a ${app} --args --app=${url}`.quiet().nothrow();
339
- if (r.exitCode === 0) return true;
340
- }
341
- } else {
342
- for (const bin of chromes) {
343
- const found = Bun.which(bin) ?? (process.platform === "win32" ? winChrome(bin) : null);
344
- if (!found) continue;
345
- Bun.spawn([found, `--app=${url}`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true }).unref();
346
- return true;
347
- }
348
- }
349
- } catch { /* fall through to a plain tab */ }
350
- // `start` is a cmd builtin, not a program, so it has to go through cmd.
351
- if (process.platform === "win32") {
352
- try { await Bun.$`cmd /c start "" ${url}`.quiet(); return true; } catch { return false; }
353
- }
354
- const opener = process.platform === "darwin" ? "open" : "xdg-open";
355
- if (!Bun.which(opener)) return false;
356
- try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
357
- }
358
-
359
- // Windows keeps browsers out of PATH; the usual install roots are the next best guess.
360
- function winChrome(exe: string) {
361
- const roots = [process.env["ProgramFiles"], process.env["ProgramFiles(x86)"], process.env["LOCALAPPDATA"]].filter(Boolean) as string[];
362
- const sub: Record<string, string> = {
363
- "chrome.exe": "Google\\Chrome\\Application\\chrome.exe",
364
- "msedge.exe": "Microsoft\\Edge\\Application\\msedge.exe",
365
- "chromium.exe": "Chromium\\Application\\chrome.exe",
366
- "brave.exe": "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
367
- };
368
- for (const r of roots) { const p = join(r, sub[exe] ?? exe); if (existsSync(p)) return p; }
369
- return null;
370
- }
371
-
372
- // `kaizen web --shortcut`: a launcher the OS can find — Spotlight on macOS, the app
373
- // menu / super-space on Linux, the Desktop on Windows. Each one runs `kaizen web` with
374
- // the bun and script that ran this command, so it works for a git checkout and a
375
- // bunx install alike.
376
- export async function shortcut() {
377
- const bun = process.execPath;
378
- const script = Bun.argv[1]!;
379
- const icon = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
380
- const hasIcon = existsSync(icon);
381
- const { mkdirSync, writeFileSync, chmodSync } = await import("node:fs");
382
-
383
- if (process.platform === "darwin") {
384
- const app = join(home, "Applications", "Kaizen.app", "Contents");
385
- mkdirSync(join(app, "MacOS"), { recursive: true });
386
- writeFileSync(join(app, "MacOS", "kaizen"), `#!/bin/sh\nexec ${q(bun)} ${q(script)} web --daemon\n`);
387
- chmodSync(join(app, "MacOS", "kaizen"), 0o755);
388
- writeFileSync(join(app, "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
389
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
390
- <plist version="1.0"><dict>
391
- <key>CFBundleName</key><string>Kaizen</string>
392
- <key>CFBundleIdentifier</key><string>dev.kaizen.web</string>
393
- <key>CFBundleExecutable</key><string>kaizen</string>
394
- <key>CFBundlePackageType</key><string>APPL</string>
395
- <key>LSUIElement</key><true/>
396
- </dict></plist>
397
- `);
398
- return `${tilde(join(home, "Applications", "Kaizen.app"))} — Spotlight finds it once indexed`;
399
- }
400
-
401
- if (process.platform === "win32") {
402
- // bun.exe is a console program, so a .lnk aimed straight at it always opens a
403
- // console window. The shortcut therefore aims at wscript, whose Run with window
404
- // style 0 starts `kaizen web --daemon` with no window at all; the daemon outlives
405
- // that launcher and `kaizen web --stop` ends it. A crash has no console to land
406
- // in, so web() writes it to ~/.kaizen/web.log instead. A .lnk icon must be an .ico.
407
- const lnk = join(home, "Desktop", "Kaizen.lnk");
408
- const ico = join(dirname(import.meta.dir), "assets", "kaizen.ico");
409
- const vbs = join(home, ".kaizen", "kaizen-web.vbs");
410
- mkdirSync(dirname(vbs), { recursive: true });
411
- const vq = (s: string) => `""${s}""`; // a quoted arg inside a VBScript string
412
- writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${vq(bun)} ${vq(script)} web --daemon", 0, False\r\n`);
413
- 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()`;
414
- await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
415
- return `${lnk} — double-click starts the board in the background and opens it; kaizen web --stop ends it`;
416
- }
417
-
418
- const dir = join(process.env["XDG_DATA_HOME"] ?? join(home, ".local", "share"), "applications");
419
- mkdirSync(dir, { recursive: true });
420
- const file = join(dir, "kaizen.desktop");
421
- writeFileSync(file, `[Desktop Entry]
422
- Type=Application
423
- Name=Kaizen
424
- Comment=Kaizen board
425
- Exec=${q(bun)} ${q(script)} web --daemon
426
- ${hasIcon ? `Icon=${icon}\n` : ""}Terminal=false
427
- Categories=Development;
428
- `);
429
- return tilde(file);
430
- }
431
-
432
- const q = (s: string) => (/[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
1
+ // `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
2
+ // one HTML page; the page never sees the filesystem, only JSON.
3
+ import { existsSync, readFileSync, readdirSync, statSync, watch, appendFileSync, type FSWatcher } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import {
6
+ home, type Item, boardCards, knownProjects, remember, locate, label, tilde,
7
+ readInbox, writeInbox, replaceIdea, abandonRun, launchRun, parseItem, short, setArchived, writeNotes,
8
+ appendNote, saveAttachment, pidOnPort,
9
+ } from "./state.ts";
10
+
11
+ const PAGE = join(dirname(import.meta.dir), "web", "board.html");
12
+ const DEFAULT_PORT = 7420;
13
+
14
+ type Opts = { port?: number; open?: boolean };
15
+
16
+ export async function web(repoDir: string, opts: Opts = {}) {
17
+ // A board started from the shortcut has no terminal to show a crash in, so any
18
+ // crash is written where it can be read afterwards.
19
+ const log = join(home, ".kaizen", "web.log");
20
+ for (const ev of ["uncaughtException", "unhandledRejection"] as const) {
21
+ process.on(ev, (err: unknown) => {
22
+ const line = `${new Date().toISOString()} ${ev}: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`;
23
+ try { appendFileSync(log, line); } catch { /* nowhere to write */ }
24
+ console.error(line);
25
+ if (ev === "uncaughtException") process.exit(1);
26
+ });
27
+ }
28
+ const state = locate();
29
+ if (state && dirname(state) !== home) remember(dirname(state));
30
+ const project = state ? label(state) : null;
31
+
32
+ const states = (all: boolean) => (all || !state
33
+ ? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
34
+ : [state]
35
+ ).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
36
+
37
+ // Any tab on this machine can reach loopback. GET only reads the user's own
38
+ // files, but a POST from a page on the wider web must not write them.
39
+ const local = (req: Request) => {
40
+ const origin = req.headers.get("origin");
41
+ if (!origin) return true; // curl, same-origin fetch on old browsers
42
+ try {
43
+ const h = new URL(origin).hostname;
44
+ return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
45
+ } catch { return false; }
46
+ };
47
+ const json = (body: unknown, status = 200) =>
48
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
49
+ const bad = (why: string, status = 400) => json({ error: why }, status);
50
+
51
+ // Change notification: one SSE stream per open board, fed by fs.watch where it
52
+ // works and a slow poll where it does not.
53
+ const clients = new Set<ReadableStreamDefaultController>();
54
+ let pending: ReturnType<typeof setTimeout> | null = null;
55
+ const changed = () => {
56
+ if (pending) return;
57
+ pending = setTimeout(() => {
58
+ pending = null;
59
+ for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
60
+ }, 200);
61
+ };
62
+ // Dev: the page reloads itself when board.html is saved. Server code needs
63
+ // `bun --watch cli/install.ts web`; the page reconnects and reloads on hello.
64
+ try { watch(PAGE, () => { for (const c of clients) { try { c.enqueue("data: reload\n\n"); } catch { clients.delete(c); } } }); } catch { /* no dev reload */ }
65
+ const watchers: FSWatcher[] = [];
66
+ const watchAll = () => {
67
+ for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
68
+ watchers.length = 0;
69
+ for (const dir of states(true)) {
70
+ // An unhandled 'error' on a watcher (Windows: a watched dir renamed, EPERM on
71
+ // a locked file) would end the process; the poll covers what the watch misses.
72
+ try { watchers.push(watch(dir, { recursive: true }, changed).on("error", () => {})); } catch { /* poll covers it */ }
73
+ }
74
+ };
75
+ watchAll();
76
+ // The poll only speaks when something moved: a board reloading every two
77
+ // seconds for nothing is worse than no fallback at all.
78
+ const fingerprint = () => {
79
+ let sum = 0;
80
+ for (const dir of states(true)) {
81
+ for (const f of ["inbox.md", "backlog.md", "archive.md"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
82
+ const runs = join(dir, "runs");
83
+ if (!existsSync(runs)) continue;
84
+ for (const id of readdirSync(runs)) { try { sum += statSync(join(runs, id, "state.json")).mtimeMs; } catch { /* absent */ } }
85
+ }
86
+ return sum;
87
+ };
88
+ let last = fingerprint();
89
+ let seen = states(true).join("\n");
90
+ const poll = setInterval(() => {
91
+ // A project registered since start (a run launched into a fresh dir) gets a watcher too.
92
+ const now = states(true).join("\n"); if (now !== seen) { seen = now; watchAll(); }
93
+ const fp = fingerprint(); if (fp !== last) { last = fp; changed(); }
94
+ }, 2000);
95
+
96
+ const BOOT = Date.now().toString(36);
97
+ const ping = setInterval(() => { for (const c of clients) { try { c.enqueue(": ping\n\n"); } catch { clients.delete(c); } } }, 8000);
98
+ // A launcher clicked while a board is already up should show that board, not die.
99
+ // A bind can fail because a board is already there, or because the OS will not
100
+ // hand out the port at all (Windows reserves whole ranges for Hyper-V and WSL,
101
+ // and reports either as "in use"). Only a board that answers is "already
102
+ // running"; otherwise walk up to the next port that binds.
103
+ let port = opts.port ?? DEFAULT_PORT;
104
+ let server: ReturnType<typeof serve> | null = null;
105
+ for (let tries = 0; tries < 10 && !server; tries++, port++) {
106
+ try { server = serve(port); } catch (e) {
107
+ if (!["EADDRINUSE", "EACCES"].includes((e as { code?: string }).code ?? "")) throw e;
108
+ const url = `http://127.0.0.1:${port}/`;
109
+ const up = await fetch(url, { signal: AbortSignal.timeout(1500) }).then((r) => r.ok, () => false);
110
+ if (up) { console.log(`\n kaizen web already at ${url}\n`); if (opts.open !== false) await openApp(url); return; }
111
+ // A listener that does not answer is a board that hung; it is ours to stop.
112
+ const pid = await pidOnPort(port);
113
+ if (pid && pid !== process.pid) {
114
+ try { process.kill(pid, "SIGTERM"); console.log(` stopped a hung board on ${port} (pid ${pid})`); } catch { /* not ours to kill */ }
115
+ await Bun.sleep(300);
116
+ try { server = serve(port); continue; } catch { /* still held */ }
117
+ }
118
+ console.log(` port ${port} refused by the OS and nothing answers there; trying ${port + 1}`);
119
+ }
120
+ }
121
+ if (!server) { console.log(`\n kaizen web could not bind any port from ${opts.port ?? DEFAULT_PORT}\n`); return; }
122
+
123
+ function serve(port: number) { return Bun.serve({
124
+ hostname: "127.0.0.1",
125
+ port,
126
+ async fetch(req) {
127
+ // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
128
+ // arrives with that hostname in Host, and gets nothing.
129
+ const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
130
+ if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
131
+ const url = new URL(req.url);
132
+ const path = url.pathname;
133
+
134
+ if (req.method === "GET" && path === "/") {
135
+ if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
136
+ return new Response(readFileSync(PAGE), {
137
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
138
+ });
139
+ }
140
+
141
+ if (req.method === "GET" && path === "/state") {
142
+ const all = url.searchParams.get("all") === "1";
143
+ const dirs = states(all);
144
+ return json({
145
+ project, all,
146
+ projects: states(true).map((d) => ({ dir: d, label: label(d) })),
147
+ cards: boardCards(dirs, Date.now()),
148
+ now: Date.now(),
149
+ });
150
+ }
151
+
152
+ if (req.method === "GET" && path.startsWith("/run/")) {
153
+ const id = decodeURIComponent(path.slice(5));
154
+ const dir = url.searchParams.get("dir") ?? state;
155
+ if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
156
+ const base = join(dir, "runs", id);
157
+ if (!existsSync(base)) return bad("no such run", 404);
158
+ const read = (f: string) => existsSync(join(base, f)) ? readFileSync(join(base, f), "utf8") : null;
159
+ return json({
160
+ id, short: short(id), state: read("state.json"),
161
+ request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
162
+ impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
163
+ notes: read("notes.md"),
164
+ });
165
+ }
166
+
167
+ if (req.method === "GET" && path === "/settings") {
168
+ const { listSettings } = await import("./settings.ts");
169
+ return json(listSettings(repoDir));
170
+ }
171
+
172
+ if (req.method === "POST" && path === "/settings") {
173
+ if (!local(req)) return bad("cross-origin write refused", 403);
174
+ let body: any;
175
+ try { body = await req.json(); } catch { return bad("json body expected"); }
176
+ const { setSetting } = await import("./settings.ts");
177
+ const r = setSetting(repoDir, String(body.key ?? ""), String(body.value ?? ""));
178
+ return "error" in r ? bad(r.error) : json(r);
179
+ }
180
+
181
+ // Attachments only, never a run's own files: the path must sit under an
182
+ // attachments/ directory of a known state dir, with no way back up.
183
+ if (req.method === "GET" && path === "/file") {
184
+ const dir = url.searchParams.get("dir") ?? state;
185
+ const p = url.searchParams.get("p") ?? "";
186
+ if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
187
+ if (!/^(runs\/[\w.-]+\/)?attachments\/[\w.-]+$/.test(p)) return bad("bad path");
188
+ const f = join(dir, p);
189
+ if (!existsSync(f)) return bad("no such file", 404);
190
+ return new Response(Bun.file(f), { headers: { "cache-control": "private, max-age=3600" } });
191
+ }
192
+
193
+ if (req.method === "GET" && path === "/events") {
194
+ let ctrl: ReadableStreamDefaultController;
195
+ const stream = new ReadableStream({
196
+ // The boot id lets the page tell a server restart from a dropped
197
+ // connection; only the former means new code. The ping keeps Bun's
198
+ // idle timeout from closing the stream every ten seconds.
199
+ start(c) { ctrl = c; clients.add(c); c.enqueue(`data: hello ${BOOT}\n\n`); },
200
+ cancel() { clients.delete(ctrl); },
201
+ });
202
+ return new Response(stream, {
203
+ headers: { "content-type": "text/event-stream", "cache-control": "no-store", "connection": "keep-alive" },
204
+ });
205
+ }
206
+
207
+ if (req.method === "POST") {
208
+ if (!local(req)) return bad("cross-origin write refused", 403);
209
+ let body: any;
210
+ try { body = await req.json(); } catch { return bad("json body expected"); }
211
+ // No project here means the global inbox, which is where the TUI puts ideas too.
212
+ const dir: string = body.dir ?? state ?? join(home, ".kaizen");
213
+ if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
214
+
215
+ if (path === "/inbox") {
216
+ const text = String(body.text ?? "").trim();
217
+ // Absent means untouched; an empty string clears. replaceIdea keeps the
218
+ // old notes for any op that does not send them.
219
+ const notes = typeof body.notes === "string" ? body.notes.trim() : undefined;
220
+ if (body.op === "add") {
221
+ if (!text) return bad("empty idea");
222
+ writeInbox(dir, [...readInbox(dir), { status: "open", text, notes }]);
223
+ } else if (body.op === "edit") {
224
+ const next = String(body.next ?? "").trim();
225
+ if (!text || !next) return bad("empty idea");
226
+ replaceIdea(dir, text, notes === undefined ? { status: "open", text: next } : { status: "open", text: next, notes });
227
+ } else if (body.op === "reject") {
228
+ const why = String(body.reason ?? "").trim();
229
+ if (!why) return bad("a rejection needs a reason");
230
+ replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
231
+ } else if (body.op === "delete") {
232
+ replaceIdea(dir, text, null);
233
+ } else return bad("unknown op");
234
+ changed();
235
+ return json({ ok: true });
236
+ }
237
+
238
+ if (path === "/run") {
239
+ const text = String(body.text ?? "").trim();
240
+ const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
241
+ if (!text) return bad("empty idea");
242
+ const it: Item = parseItem(`${kind} ${text}`.trim());
243
+ // A `started` idea may be run again: the terminal the first launch opened
244
+ // can come up empty, and nothing else tells the board a run never began.
245
+ it.notes = readInbox(dir).find((l) => (l.status === "open" || l.status === "started") && l.text === text)?.notes;
246
+ const r = launchRun(it, dir);
247
+ if (r.ok) replaceIdea(dir, text, { status: "started", text });
248
+ changed();
249
+ return json(r.ok
250
+ ? { ok: true, agent: r.agent, project: tilde(r.projectDir) }
251
+ : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
252
+ }
253
+
254
+ // Archive is a list beside the runs, never a move: a run keeps its directory
255
+ // and its state, and the board simply stops showing it.
256
+ if (path === "/archive") {
257
+ const archived = body.archived !== false;
258
+ if (typeof body.id === "string") {
259
+ if (!/^[\w.-]+$/.test(body.id)) return bad("bad run id");
260
+ setArchived(dir, body.id, archived);
261
+ } else if (typeof body.text === "string") {
262
+ const text = body.text.trim();
263
+ const lines = readInbox(dir);
264
+ const at = lines.findIndex((l) => l.text === text && (archived ? l.status === "open" : l.status === "archived"));
265
+ if (at < 0) return bad("no such idea", 404);
266
+ lines[at] = { ...lines[at]!, status: archived ? "archived" : "open" };
267
+ writeInbox(dir, lines);
268
+ } else return bad("id or text required");
269
+ changed();
270
+ return json({ ok: true });
271
+ }
272
+
273
+ // One chat message: text plus files (base64), each file saved and linked
274
+ // from the message so the note carries its own attachments.
275
+ if (path === "/note") {
276
+ const id = typeof body.id === "string" ? body.id : null;
277
+ if (id !== null && !/^[\w.-]+$/.test(id)) return bad("bad run id");
278
+ const text = typeof body.text === "string" ? body.text.trim() : "";
279
+ const files: { name: string; data: string }[] = Array.isArray(body.files) ? body.files : [];
280
+ let total = 0;
281
+ for (const f of files) total += (f.data?.length ?? 0);
282
+ if (total > 20_000_000) return bad("attachments over 15MB");
283
+ const links = files.map((f) => {
284
+ const bytes = Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64");
285
+ const rel = saveAttachment(dir, id, String(f.name ?? "file"), bytes).replace(/\\/g, "/");
286
+ return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel.replace(/^runs\/[^/]+\//, "")})`;
287
+ });
288
+ const note = [text, ...links].filter(Boolean).join("\n");
289
+ const failed = appendNote(dir, id !== null ? { id } : { text: String(body.for ?? "").trim() }, note);
290
+ changed();
291
+ return failed ? bad(failed, 404) : json({ ok: true });
292
+ }
293
+
294
+ if (path === "/notes") {
295
+ const id = String(body.id ?? "");
296
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
297
+ const failed = writeNotes(dir, id, String(body.text ?? ""));
298
+ changed();
299
+ return failed ? bad(failed, 404) : json({ ok: true });
300
+ }
301
+
302
+ if (path === "/abort") {
303
+ const id = String(body.id ?? "");
304
+ const why = String(body.why ?? "").trim() || "abandoned from the web board";
305
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
306
+ const failed = abandonRun(dir, id, why);
307
+ changed();
308
+ return failed ? bad(failed, 409) : json({ ok: true });
309
+ }
310
+ }
311
+
312
+ return new Response("not found", { status: 404 });
313
+ },
314
+ }); }
315
+
316
+ const url = `http://127.0.0.1:${server.port}/`;
317
+ console.log(`\n kaizen web ${url}`);
318
+ console.log(` ${project ? `project ${project}` : "no .kaizen here; showing every known project"}`);
319
+ console.log(` ctrl-c to stop\n`);
320
+ if (opts.open !== false) await openApp(url);
321
+
322
+ await new Promise<void>((resolve) => {
323
+ const stop = () => { clearInterval(poll); clearInterval(ping); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
324
+ process.on("SIGINT", stop);
325
+ process.on("SIGTERM", stop);
326
+ });
327
+ }
328
+
329
+ // A Chromium-family browser in --app mode is a window with no tabs or address bar,
330
+ // which is as close to a desktop app as a web page gets. Anything else gets a tab.
331
+ export async function openApp(url: string) {
332
+ const chromes = process.platform === "darwin"
333
+ ? ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"]
334
+ : process.platform === "win32"
335
+ ? ["chrome.exe", "msedge.exe", "chromium.exe", "brave.exe"]
336
+ : ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "microsoft-edge", "brave", "brave-browser"];
337
+ try {
338
+ if (process.platform === "darwin") {
339
+ for (const app of chromes) {
340
+ const r = await Bun.$`open -a ${app} --args --app=${url}`.quiet().nothrow();
341
+ if (r.exitCode === 0) return true;
342
+ }
343
+ } else {
344
+ for (const bin of chromes) {
345
+ const found = Bun.which(bin) ?? (process.platform === "win32" ? winChrome(bin) : null);
346
+ if (!found) continue;
347
+ Bun.spawn([found, `--app=${url}`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true }).unref();
348
+ return true;
349
+ }
350
+ }
351
+ } catch { /* fall through to a plain tab */ }
352
+ // `start` is a cmd builtin, not a program, so it has to go through cmd.
353
+ if (process.platform === "win32") {
354
+ try { await Bun.$`cmd /c start "" ${url}`.quiet(); return true; } catch { return false; }
355
+ }
356
+ const opener = process.platform === "darwin" ? "open" : "xdg-open";
357
+ if (!Bun.which(opener)) return false;
358
+ try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
359
+ }
360
+
361
+ // Windows keeps browsers out of PATH; the usual install roots are the next best guess.
362
+ function winChrome(exe: string) {
363
+ const roots = [process.env["ProgramFiles"], process.env["ProgramFiles(x86)"], process.env["LOCALAPPDATA"]].filter(Boolean) as string[];
364
+ const sub: Record<string, string> = {
365
+ "chrome.exe": "Google\\Chrome\\Application\\chrome.exe",
366
+ "msedge.exe": "Microsoft\\Edge\\Application\\msedge.exe",
367
+ "chromium.exe": "Chromium\\Application\\chrome.exe",
368
+ "brave.exe": "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
369
+ };
370
+ for (const r of roots) { const p = join(r, sub[exe] ?? exe); if (existsSync(p)) return p; }
371
+ return null;
372
+ }
373
+
374
+ // `kaizen web --shortcut`: a launcher the OS can find — Spotlight on macOS, the app
375
+ // menu / super-space on Linux, the Desktop on Windows. Each one runs `kaizen web` with
376
+ // the bun and script that ran this command, so it works for a git checkout and a
377
+ // bunx install alike.
378
+ export async function shortcut() {
379
+ const bun = process.execPath;
380
+ const script = Bun.argv[1]!;
381
+ const icon = join(dirname(import.meta.dir), "assets", "kaizen-logo.png");
382
+ const hasIcon = existsSync(icon);
383
+ const { mkdirSync, writeFileSync, chmodSync } = await import("node:fs");
384
+
385
+ if (process.platform === "darwin") {
386
+ const app = join(home, "Applications", "Kaizen.app", "Contents");
387
+ mkdirSync(join(app, "MacOS"), { recursive: true });
388
+ writeFileSync(join(app, "MacOS", "kaizen"), `#!/bin/sh\nexec ${q(bun)} ${q(script)} web --daemon\n`);
389
+ chmodSync(join(app, "MacOS", "kaizen"), 0o755);
390
+ writeFileSync(join(app, "Info.plist"), `<?xml version="1.0" encoding="UTF-8"?>
391
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
392
+ <plist version="1.0"><dict>
393
+ <key>CFBundleName</key><string>Kaizen</string>
394
+ <key>CFBundleIdentifier</key><string>dev.kaizen.web</string>
395
+ <key>CFBundleExecutable</key><string>kaizen</string>
396
+ <key>CFBundlePackageType</key><string>APPL</string>
397
+ <key>LSUIElement</key><true/>
398
+ </dict></plist>
399
+ `);
400
+ return `${tilde(join(home, "Applications", "Kaizen.app"))} — Spotlight finds it once indexed`;
401
+ }
402
+
403
+ if (process.platform === "win32") {
404
+ // bun.exe is a console program, so a .lnk aimed straight at it always opens a
405
+ // console window. The shortcut therefore aims at wscript, whose Run with window
406
+ // style 0 starts `kaizen web --daemon` with no window at all; the daemon outlives
407
+ // that launcher and `kaizen web --stop` ends it. A crash has no console to land
408
+ // in, so web() writes it to ~/.kaizen/web.log instead. A .lnk icon must be an .ico.
409
+ const lnk = join(home, "Desktop", "Kaizen.lnk");
410
+ const ico = join(dirname(import.meta.dir), "assets", "kaizen.ico");
411
+ const vbs = join(home, ".kaizen", "kaizen-web.vbs");
412
+ mkdirSync(dirname(vbs), { recursive: true });
413
+ const vq = (s: string) => `""${s}""`; // a quoted arg inside a VBScript string
414
+ writeFileSync(vbs, `CreateObject("WScript.Shell").Run "${vq(bun)} ${vq(script)} web --daemon", 0, False\r\n`);
415
+ 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()`;
416
+ await Bun.$`powershell -NoProfile -Command ${ps}`.quiet();
417
+ return `${lnk} — double-click starts the board in the background and opens it; kaizen web --stop ends it`;
418
+ }
419
+
420
+ const dir = join(process.env["XDG_DATA_HOME"] ?? join(home, ".local", "share"), "applications");
421
+ mkdirSync(dir, { recursive: true });
422
+ const file = join(dir, "kaizen.desktop");
423
+ writeFileSync(file, `[Desktop Entry]
424
+ Type=Application
425
+ Name=Kaizen
426
+ Comment=Kaizen board
427
+ Exec=${q(bun)} ${q(script)} web --daemon
428
+ ${hasIcon ? `Icon=${icon}\n` : ""}Terminal=false
429
+ Categories=Development;
430
+ `);
431
+ return tilde(file);
432
+ }
433
+
434
+ const q = (s: string) => (/[\s"']/.test(s) ? `"${s.replace(/"/g, '\\"')}"` : s);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.7.7",
3
+ "version": "1.7.8",
4
4
  "description": "Staged workflow for AI agents — code, writing, research, operations: plan, human approval, build, independent review, bounded fix loop.",
5
5
  "type": "module",
6
6
  "bin": {
package/web/board.html CHANGED
@@ -348,7 +348,7 @@ function card(c){
348
348
  const items=[];
349
349
  if(archive){items.push(["restore","Restore",ico("play")]);if(c.kind==="run")items.push(["open","Open",ico("play")]);}
350
350
  else if(c.kind==="idea"&&c.status==="idea")items.push(["run","Run",ico("play")],["edit","Edit",ico("pen")],["reject","Reject",ico("x")],null,["delete","Delete",ico("trash"),"danger"]);
351
- else if(c.kind==="idea")items.push(["delete","Remove",ico("trash"),"danger"]);
351
+ else if(c.kind==="idea")items.push(["run","Run again",ico("play")],null,["delete","Remove",ico("trash"),"danger"]);
352
352
  else if(c.status==="waiting"||c.status==="running"||c.status==="stalled")items.push(["open","Open",ico("play")],null,["reject","Abandon",ico("x"),"danger"]);
353
353
  else items.push(["open","Open",ico("play")]);
354
354
  if(!archive&&!(c.kind==="idea"&&c.status!=="idea"))items.push(null,["archive","Archive",ico("x")]);
@@ -428,7 +428,7 @@ function open(c){
428
428
  stopDictation(true); // the panel body is about to be replaced
429
429
  openKey=key(c);$("panel").classList.add("open");$("scrim").classList.add("on");$("ptitle").textContent=c.text;render();
430
430
  if(c.kind==="idea"){runDoc=null;$("psub").textContent="idea · inbox.md · "+c.where;$("ptabs").innerHTML="";chat(c,c.notes,c.archived||c.status!=="idea");
431
- const foot=$("pfoot");foot.innerHTML=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:c.status==="idea"?`<button class="btn pri" data-a="run">Run</button><button class="btn" data-a="edit">Edit</button><button class="btn" data-a="reject">Reject</button><button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost danger" data-a="delete">Delete</button>`:`<span style="color:var(--dim);font-size:12px">Starting: a run was launched for this idea.</span>`;
431
+ const foot=$("pfoot");foot.innerHTML=c.archived?`<button class="btn pri" data-a="restore">Restore</button>`:c.status==="idea"?`<button class="btn pri" data-a="run">Run</button><button class="btn" data-a="edit">Edit</button><button class="btn" data-a="reject">Reject</button><button class="btn ghost" data-a="archive">Archive</button><button class="btn ghost danger" data-a="delete">Delete</button>`:`<span style="color:var(--dim);font-size:12px;flex:1">Starting: a run was launched for this idea. No terminal with your agent? Run it again.</span><button class="btn" data-a="run">Run again</button>`;
432
432
  foot.querySelectorAll("[data-a]").forEach(b=>b.onclick=()=>{const a=b.dataset.a;if(a==="archive"||a==="restore"){toggleArchive(c,a==="archive");return}const k=key(c);close();mode=a;modeKey=k;render()});return}
433
433
  openRun(c,false)}
434
434
  // Notes are a chat: one entry per message, files attached by paste or picker.