kaizen-agent 1.4.0 → 1.5.1

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/web.ts ADDED
@@ -0,0 +1,235 @@
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, 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,
8
+ } from "./state.ts";
9
+
10
+ const PAGE = join(dirname(import.meta.dir), "web", "board.html");
11
+ const DEFAULT_PORT = 7420;
12
+
13
+ type Opts = { port?: number; open?: boolean };
14
+
15
+ export async function web(repoDir: string, opts: Opts = {}) {
16
+ const state = locate();
17
+ if (state && dirname(state) !== home) remember(dirname(state));
18
+ const project = state ? label(state) : null;
19
+
20
+ const states = (all: boolean) => (all || !state
21
+ ? [...knownProjects().map((d) => join(d, ".kaizen")), join(home, ".kaizen")]
22
+ : [state]
23
+ ).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
24
+
25
+ // Any tab on this machine can reach loopback. GET only reads the user's own
26
+ // files, but a POST from a page on the wider web must not write them.
27
+ const local = (req: Request) => {
28
+ const origin = req.headers.get("origin");
29
+ if (!origin) return true; // curl, same-origin fetch on old browsers
30
+ try {
31
+ const h = new URL(origin).hostname;
32
+ return h === "127.0.0.1" || h === "localhost" || h === "[::1]" || h === "::1";
33
+ } catch { return false; }
34
+ };
35
+ const json = (body: unknown, status = 200) =>
36
+ new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
37
+ const bad = (why: string, status = 400) => json({ error: why }, status);
38
+
39
+ // Change notification: one SSE stream per open board, fed by fs.watch where it
40
+ // works and a slow poll where it does not.
41
+ const clients = new Set<ReadableStreamDefaultController>();
42
+ let pending: ReturnType<typeof setTimeout> | null = null;
43
+ const changed = () => {
44
+ if (pending) return;
45
+ pending = setTimeout(() => {
46
+ pending = null;
47
+ for (const c of clients) { try { c.enqueue("data: changed\n\n"); } catch { clients.delete(c); } }
48
+ }, 200);
49
+ };
50
+ const watchers: FSWatcher[] = [];
51
+ const watchAll = () => {
52
+ for (const w of watchers) { try { w.close(); } catch { /* gone */ } }
53
+ watchers.length = 0;
54
+ for (const dir of states(true)) {
55
+ try { watchers.push(watch(dir, { recursive: true }, changed)); } catch { /* poll covers it */ }
56
+ }
57
+ };
58
+ watchAll();
59
+ // The poll only speaks when something moved: a board reloading every two
60
+ // seconds for nothing is worse than no fallback at all.
61
+ const fingerprint = () => {
62
+ let sum = 0;
63
+ for (const dir of states(true)) {
64
+ for (const f of ["inbox.md", "backlog.md"]) { try { sum += statSync(join(dir, f)).mtimeMs; } catch { /* absent */ } }
65
+ const runs = join(dir, "runs");
66
+ if (!existsSync(runs)) continue;
67
+ for (const id of readdirSync(runs)) { try { sum += statSync(join(runs, id, "state.json")).mtimeMs; } catch { /* absent */ } }
68
+ }
69
+ return sum;
70
+ };
71
+ let last = fingerprint();
72
+ const poll = setInterval(() => { const now = fingerprint(); if (now !== last) { last = now; changed(); } }, 2000);
73
+
74
+ const server = Bun.serve({
75
+ hostname: "127.0.0.1",
76
+ port: opts.port ?? DEFAULT_PORT,
77
+ async fetch(req) {
78
+ // Host first: a hostname rebound to 127.0.0.1 by an attacker's DNS still
79
+ // arrives with that hostname in Host, and gets nothing.
80
+ const hostname = (req.headers.get("host") ?? "").replace(/:\d+$/, "");
81
+ if (hostname !== "127.0.0.1" && hostname !== "localhost") return new Response("forbidden", { status: 403 });
82
+ const url = new URL(req.url);
83
+ const path = url.pathname;
84
+
85
+ if (req.method === "GET" && path === "/") {
86
+ if (!existsSync(PAGE)) return new Response("web/board.html missing", { status: 500 });
87
+ return new Response(readFileSync(PAGE), {
88
+ headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" },
89
+ });
90
+ }
91
+
92
+ if (req.method === "GET" && path === "/state") {
93
+ const all = url.searchParams.get("all") === "1";
94
+ const dirs = states(all);
95
+ return json({
96
+ project, all,
97
+ projects: states(true).map((d) => ({ dir: d, label: label(d) })),
98
+ cards: boardCards(dirs, Date.now()),
99
+ now: Date.now(),
100
+ });
101
+ }
102
+
103
+ if (req.method === "GET" && path.startsWith("/run/")) {
104
+ const id = decodeURIComponent(path.slice(5));
105
+ const dir = url.searchParams.get("dir") ?? state;
106
+ if (!dir || !states(true).includes(dir) || !/^[\w.-]+$/.test(id)) return bad("no such run", 404);
107
+ const base = join(dir, "runs", id);
108
+ if (!existsSync(base)) return bad("no such run", 404);
109
+ const read = (f: string) => existsSync(join(base, f)) ? readFileSync(join(base, f), "utf8") : null;
110
+ return json({
111
+ id, short: short(id), state: read("state.json"),
112
+ request: read("00-request.md"), plan: read("01-plan.md"), approval: read("02-approval.md"),
113
+ impl: read("03-impl.md"), review: read("04-review.md"), backlog: read("06-backlog.md"),
114
+ });
115
+ }
116
+
117
+ if (req.method === "GET" && path === "/events") {
118
+ let ctrl: ReadableStreamDefaultController;
119
+ const stream = new ReadableStream({
120
+ start(c) { ctrl = c; clients.add(c); c.enqueue("data: hello\n\n"); },
121
+ cancel() { clients.delete(ctrl); },
122
+ });
123
+ return new Response(stream, {
124
+ headers: { "content-type": "text/event-stream", "cache-control": "no-store", "connection": "keep-alive" },
125
+ });
126
+ }
127
+
128
+ if (req.method === "POST") {
129
+ if (!local(req)) return bad("cross-origin write refused", 403);
130
+ let body: any;
131
+ try { body = await req.json(); } catch { return bad("json body expected"); }
132
+ // No project here means the global inbox, which is where the TUI puts ideas too.
133
+ const dir: string = body.dir ?? state ?? join(home, ".kaizen");
134
+ if (!dir || !states(true).includes(dir)) return bad("unknown state dir", 404);
135
+
136
+ if (path === "/inbox") {
137
+ const text = String(body.text ?? "").trim();
138
+ if (body.op === "add") {
139
+ if (!text) return bad("empty idea");
140
+ writeInbox(dir, [...readInbox(dir), { status: "open", text }]);
141
+ } else if (body.op === "edit") {
142
+ const next = String(body.next ?? "").trim();
143
+ if (!text || !next) return bad("empty idea");
144
+ replaceIdea(dir, text, { status: "open", text: next });
145
+ } else if (body.op === "reject") {
146
+ const why = String(body.reason ?? "").trim();
147
+ if (!why) return bad("a rejection needs a reason");
148
+ replaceIdea(dir, text, { status: "rejected", text: `${text} | ${why}` });
149
+ } else if (body.op === "delete") {
150
+ replaceIdea(dir, text, null);
151
+ } else return bad("unknown op");
152
+ changed();
153
+ return json({ ok: true });
154
+ }
155
+
156
+ if (path === "/run") {
157
+ const text = String(body.text ?? "").trim();
158
+ const kind = body.kind === "full" || body.kind === "lite" ? body.kind : "";
159
+ if (!text) return bad("empty idea");
160
+ const it: Item = parseItem(`${kind} ${text}`.trim());
161
+ const r = launchRun(it, dir);
162
+ if (r.ok) replaceIdea(dir, text, { status: "started", text });
163
+ changed();
164
+ return json(r.ok
165
+ ? { ok: true, agent: r.agent, project: tilde(r.projectDir) }
166
+ : { ok: false, why: r.why, manual: r.manual, agent: r.agent }, r.ok ? 200 : 500);
167
+ }
168
+
169
+ if (path === "/abort") {
170
+ const id = String(body.id ?? "");
171
+ const why = String(body.why ?? "").trim() || "abandoned from the web board";
172
+ if (!/^[\w.-]+$/.test(id)) return bad("bad run id");
173
+ const failed = abandonRun(dir, id, why);
174
+ changed();
175
+ return failed ? bad(failed, 409) : json({ ok: true });
176
+ }
177
+ }
178
+
179
+ return new Response("not found", { status: 404 });
180
+ },
181
+ });
182
+
183
+ const url = `http://127.0.0.1:${server.port}/`;
184
+ console.log(`\n kaizen web ${url}`);
185
+ console.log(` ${project ? `project ${project}` : "no .kaizen here; showing every known project"}`);
186
+ console.log(` ctrl-c to stop\n`);
187
+ if (opts.open !== false) await openApp(url);
188
+
189
+ await new Promise<void>((resolve) => {
190
+ const stop = () => { clearInterval(poll); for (const w of watchers) { try { w.close(); } catch { /* gone */ } } server.stop(true); resolve(); };
191
+ process.on("SIGINT", stop);
192
+ process.on("SIGTERM", stop);
193
+ });
194
+ }
195
+
196
+ // A Chromium-family browser in --app mode is a window with no tabs or address bar,
197
+ // which is as close to a desktop app as a web page gets. Anything else gets a tab.
198
+ async function openApp(url: string) {
199
+ const chromes = process.platform === "darwin"
200
+ ? ["Google Chrome", "Chromium", "Microsoft Edge", "Brave Browser"]
201
+ : process.platform === "win32"
202
+ ? ["chrome.exe", "msedge.exe", "chromium.exe", "brave.exe"]
203
+ : ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable", "microsoft-edge", "brave", "brave-browser"];
204
+ try {
205
+ if (process.platform === "darwin") {
206
+ for (const app of chromes) {
207
+ const r = await Bun.$`open -a ${app} --args --app=${url}`.quiet().nothrow();
208
+ if (r.exitCode === 0) return true;
209
+ }
210
+ } else {
211
+ for (const bin of chromes) {
212
+ const found = Bun.which(bin) ?? (process.platform === "win32" ? winChrome(bin) : null);
213
+ if (!found) continue;
214
+ Bun.spawn([found, `--app=${url}`], { stdin: "ignore", stdout: "ignore", stderr: "ignore", detached: true }).unref();
215
+ return true;
216
+ }
217
+ }
218
+ } catch { /* fall through to a plain tab */ }
219
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
220
+ if (!Bun.which(opener)) return false;
221
+ try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
222
+ }
223
+
224
+ // Windows keeps browsers out of PATH; the usual install roots are the next best guess.
225
+ function winChrome(exe: string) {
226
+ const roots = [process.env["ProgramFiles"], process.env["ProgramFiles(x86)"], process.env["LOCALAPPDATA"]].filter(Boolean) as string[];
227
+ const sub: Record<string, string> = {
228
+ "chrome.exe": "Google\\Chrome\\Application\\chrome.exe",
229
+ "msedge.exe": "Microsoft\\Edge\\Application\\msedge.exe",
230
+ "chromium.exe": "Chromium\\Application\\chrome.exe",
231
+ "brave.exe": "BraveSoftware\\Brave-Browser\\Application\\brave.exe",
232
+ };
233
+ for (const r of roots) { const p = join(r, sub[exe] ?? exe); if (existsSync(p)) return p; }
234
+ return null;
235
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
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": {
@@ -12,13 +12,16 @@
12
12
  "skills",
13
13
  "agents",
14
14
  "commands",
15
+ "web",
15
16
  "README.md",
16
17
  "LICENSE"
17
18
  ],
18
19
  "scripts": {
19
20
  "install-links": "bun run cli/install.ts",
20
21
  "check": "bun run cli/install.ts --check",
21
- "postpublish": "sh cli/release.sh"
22
+ "postpublish": "sh cli/release.sh",
23
+ "publish:gh": "sh cli/publish-gh.sh",
24
+ "release": "bun publish && bun run publish:gh"
22
25
  },
23
26
  "repository": {
24
27
  "type": "git",