kaizen-agent 1.34.5 → 1.34.7

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/README.md CHANGED
@@ -70,6 +70,20 @@ kaizen web
70
70
 
71
71
  The board opens in your browser. The side menu holds the board, notes, the icebox of gathered requests and settings, with a light and dark switch. From there you can start a run, approve or revise its plan, pick which review findings to fix, then commit and push.
72
72
 
73
+ Right after installing, `kaizen setup` walks you through your project, settings, this board and the phone app (it prints a QR code to scan).
74
+
75
+ ### Phone app
76
+
77
+ The board is a PWA: installed from the browser, it opens full screen with the kaizen icon. It needs [Tailscale](https://tailscale.com/download) on this PC and on your phone, both signed in to the same account.
78
+
79
+ ```bash
80
+ kaizen setup # guided: starts the board, runs tailscale serve, prints a QR code
81
+ ```
82
+
83
+ Scan the QR code with your phone camera, then **Add to Home screen** (iOS: Share menu; Android: browser menu, *Install app*).
84
+
85
+ By hand: run `kaizen web`, then `tailscale serve --bg 127.0.0.1:7420`, add `<pc>.<tailnet>.ts.net` to `~/.kaizen/web-hosts`, and open `https://<pc>.<tailnet>.ts.net` on the phone. Details in [docs/guide.md](docs/guide.md#from-your-phone-tailscale). Never use `tailscale funnel` — it publishes the board to the internet.
86
+
73
87
  To add a launcher (Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows), run `kaizen web --shortcut` once.
74
88
 
75
89
  ## Development
package/cli/install.ts CHANGED
@@ -340,6 +340,17 @@ async function openUrl(url: string) {
340
340
  try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
341
341
  }
342
342
 
343
+ if (args.has("setup")) {
344
+ await runSetup();
345
+ process.exit(0);
346
+ }
347
+
348
+ async function runSetup() {
349
+ const s = await import("./setup.ts");
350
+ if (!process.stdin.isTTY) { console.log(s.manual()); return; }
351
+ await s.setup({ select, c, ask: (q) => prompt(q) });
352
+ }
353
+
343
354
  if (wantSettings) {
344
355
  const { settings } = await import("./settings.ts");
345
356
  await settings(await resolveRepoQuietly());
@@ -908,7 +919,7 @@ function kaizenAbove() {
908
919
  }
909
920
 
910
921
  if (inProject && !kaizenAbove()) {
911
- const now = root === process.cwd()
922
+ const now = root === process.cwd() || args.has("--init-project")
912
923
  ? true
913
924
  : interactive
914
925
  ? await select(`Set up ${basename(process.cwd())}/ for kaizen now`, [
@@ -976,9 +987,14 @@ function writePointer() {
976
987
  ${c.bold("Next")}
977
988
  1 restart Claude Code ${c.dim("— skills load live, slash commands only at session start")}
978
989
  2 ${c.cyan("/kaizen <what you want done>")} ${c.dim("in any project — it sets itself up on first run")}
990
+ 3 ${c.cyan("kaizen setup")} ${c.dim("— settings, the board, and the phone app, any time")}
979
991
 
980
992
  ${c.dim("/kaizen-help lists every command. kaizen upgrade updates all of this.")}
981
993
  `);
994
+ if (interactive && !blocked && await select("Run setup now?", [
995
+ { label: "Not now", hint: "kaizen setup runs it later", value: false },
996
+ { label: "Yes", hint: "a project folder, settings, the board, the phone", value: true },
997
+ ])) await runSetup();
982
998
  }
983
999
  if (blocked) process.exit(1);
984
1000
 
package/cli/qr.ts ADDED
@@ -0,0 +1,243 @@
1
+ // A QR code for a short URL, drawn with terminal half-blocks. Zero dependencies, so
2
+ // this is the small encoder: byte mode, error correction L, versions 1-6 (up to 134
3
+ // bytes), which is far more than a tailnet address needs.
4
+
5
+ // [codewords in all, error-correction codewords per block, blocks] for ECC L.
6
+ const VERSIONS = [[26, 7, 1], [44, 10, 1], [70, 15, 1], [100, 20, 1], [134, 26, 1], [172, 18, 2]] as const;
7
+ const ALIGN = [[], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34]];
8
+
9
+ function gfMul(a: number, b: number) {
10
+ let r = 0;
11
+ for (; b; b >>= 1) { if (b & 1) r ^= a; a <<= 1; if (a & 0x100) a ^= 0x11d; }
12
+ return r;
13
+ }
14
+
15
+ function rsRemainder(data: number[], degree: number) {
16
+ const div = Array(degree).fill(0); div[degree - 1] = 1;
17
+ let root = 1;
18
+ for (let i = 0; i < degree; i++) {
19
+ for (let j = 0; j < degree; j++) { div[j] = gfMul(div[j], root); if (j + 1 < degree) div[j] ^= div[j + 1]; }
20
+ root = gfMul(root, 2);
21
+ }
22
+ const out: number[] = Array(degree).fill(0);
23
+ for (const b of data) {
24
+ const f = b ^ out.shift()!; out.push(0);
25
+ div.forEach((c, i) => { out[i] ^= gfMul(c, f); });
26
+ }
27
+ return out;
28
+ }
29
+
30
+ export function qrMatrix(text: string): boolean[][] {
31
+ const bytes = [...new TextEncoder().encode(text)];
32
+ const v = VERSIONS.findIndex(([total, ec, n]) => total - ec * n >= bytes.length + 2);
33
+ if (v < 0) throw new Error("text too long for qr()");
34
+ const [total, ecLen, nBlocks] = VERSIONS[v]!;
35
+ const dataLen = total - ecLen * nBlocks;
36
+ const size = 17 + 4 * (v + 1);
37
+
38
+ const bits: number[] = [];
39
+ const put = (val: number, n: number) => { for (let i = n - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
40
+ put(4, 4); put(bytes.length, 8); bytes.forEach((b) => put(b, 8));
41
+ put(0, Math.min(4, dataLen * 8 - bits.length));
42
+ while (bits.length % 8) bits.push(0);
43
+ const data: number[] = [];
44
+ for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(""), 2));
45
+ for (let pad = 0xec; data.length < dataLen; pad ^= 0xec ^ 0x11) data.push(pad);
46
+
47
+ const per = dataLen / nBlocks;
48
+ const blocks = Array.from({ length: nBlocks }, (_, i) => data.slice(i * per, (i + 1) * per));
49
+ const ecs = blocks.map((b) => rsRemainder(b, ecLen));
50
+ const words: number[] = [];
51
+ for (let i = 0; i < per; i++) blocks.forEach((b) => words.push(b[i]!));
52
+ for (let i = 0; i < ecLen; i++) ecs.forEach((e) => words.push(e[i]!));
53
+
54
+ const m: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
55
+ const fn: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
56
+ const set = (x: number, y: number, dark: boolean) => { m[y]![x] = dark; fn[y]![x] = true; };
57
+
58
+ for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
59
+ for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]] as const) {
60
+ for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
61
+ const x = cx + dx, y = cy + dy, d = Math.max(Math.abs(dx), Math.abs(dy));
62
+ if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
63
+ }
64
+ }
65
+ const al = ALIGN[v]!;
66
+ for (const cy of al) for (const cx of al) {
67
+ if ((cx === 6 && cy === 6) || (cx === 6 && cy === al.at(-1)) || (cx === al.at(-1) && cy === 6)) continue;
68
+ for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
69
+ }
70
+
71
+ const format = (mask: number) => {
72
+ const d = (1 << 3) | mask; // ECC L is 01
73
+ let rem = d;
74
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
75
+ const f = ((d << 10) | rem) ^ 0x5412;
76
+ const bit = (i: number) => ((f >>> i) & 1) === 1;
77
+ for (let i = 0; i <= 5; i++) set(8, i, bit(i));
78
+ set(8, 7, bit(6)); set(8, 8, bit(7)); set(7, 8, bit(8));
79
+ for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
80
+ for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
81
+ for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
82
+ set(8, size - 8, true);
83
+ };
84
+ format(0);
85
+
86
+ let i = 0;
87
+ for (let right = size - 1; right >= 1; right -= 2) {
88
+ if (right === 6) right = 5;
89
+ for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) {
90
+ const x = right - j, y = ((right + 1) & 2) === 0 ? size - 1 - vert : vert;
91
+ if (!fn[y]![x] && i < words.length * 8) { m[y]![x] = ((words[i >> 3]! >>> (7 - (i & 7))) & 1) === 1; i++; }
92
+ }
93
+ }
94
+
95
+ const MASKS = [
96
+ (x: number, y: number) => (x + y) % 2 === 0, (_: number, y: number) => y % 2 === 0,
97
+ (x: number) => x % 3 === 0, (x: number, y: number) => (x + y) % 3 === 0,
98
+ (x: number, y: number) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
99
+ (x: number, y: number) => ((x * y) % 2) + ((x * y) % 3) === 0,
100
+ (x: number, y: number) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
101
+ (x: number, y: number) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
102
+ ];
103
+ // ponytail: penalty counts only runs and 2x2 blocks (rules 1-2 of 4); every mask
104
+ // decodes, this just picks a calmer one. Add the other two rules if scans fail.
105
+ const penalty = (g: boolean[][]) => {
106
+ let p = 0;
107
+ for (let a = 0; a < size; a++) for (const row of [true, false]) {
108
+ let run = 1;
109
+ for (let b = 1; b < size; b++) {
110
+ const same = row ? g[a]![b] === g[a]![b - 1] : g[b]![a] === g[b - 1]![a];
111
+ run = same ? run + 1 : 1;
112
+ if (run === 5) p += 3; else if (run > 5) p++;
113
+ }
114
+ }
115
+ for (let y = 0; y < size - 1; y++) for (let x = 0; x < size - 1; x++) {
116
+ if (g[y]![x] === g[y]![x + 1] && g[y]![x] === g[y + 1]![x] && g[y]![x] === g[y + 1]![x + 1]) p += 3;
117
+ }
118
+ return p;
119
+ };
120
+ const masked = (k: number) => m.map((row, y) => row.map((d, x) => (!fn[y]![x] && MASKS[k]!(x, y) ? !d : d)));
121
+ let best = 0, bestP = Infinity;
122
+ for (let k = 0; k < 8; k++) { const p = penalty(masked(k)); if (p < bestP) { bestP = p; best = k; } }
123
+ format(best);
124
+ return masked(best);
125
+ }
126
+
127
+ export function qrMatrix(text: string): boolean[][] {
128
+ const bytes = [...new TextEncoder().encode(text)];
129
+ const v = VERSIONS.findIndex(([total, ec, n]) => total - ec * n >= bytes.length + 2);
130
+ if (v < 0) throw new Error("text too long for qr()");
131
+ const [total, ecLen, nBlocks] = VERSIONS[v]!;
132
+ const dataLen = total - ecLen * nBlocks;
133
+ const size = 17 + 4 * (v + 1);
134
+
135
+ const bits: number[] = [];
136
+ const put = (val: number, n: number) => { for (let i = n - 1; i >= 0; i--) bits.push((val >>> i) & 1); };
137
+ put(4, 4); put(bytes.length, 8); bytes.forEach((b) => put(b, 8));
138
+ put(0, Math.min(4, dataLen * 8 - bits.length));
139
+ while (bits.length % 8) bits.push(0);
140
+ const data: number[] = [];
141
+ for (let i = 0; i < bits.length; i += 8) data.push(parseInt(bits.slice(i, i + 8).join(""), 2));
142
+ for (let pad = 0xec; data.length < dataLen; pad ^= 0xec ^ 0x11) data.push(pad);
143
+
144
+ const per = dataLen / nBlocks;
145
+ const blocks = Array.from({ length: nBlocks }, (_, i) => data.slice(i * per, (i + 1) * per));
146
+ const ecs = blocks.map((b) => rsRemainder(b, ecLen));
147
+ const words: number[] = [];
148
+ for (let i = 0; i < per; i++) blocks.forEach((b) => words.push(b[i]!));
149
+ for (let i = 0; i < ecLen; i++) ecs.forEach((e) => words.push(e[i]!));
150
+
151
+ const m: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
152
+ const fn: boolean[][] = Array.from({ length: size }, () => Array(size).fill(false));
153
+ const set = (x: number, y: number, dark: boolean) => { m[y]![x] = dark; fn[y]![x] = true; };
154
+
155
+ for (let i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
156
+ for (const [cx, cy] of [[3, 3], [size - 4, 3], [3, size - 4]] as const) {
157
+ for (let dy = -4; dy <= 4; dy++) for (let dx = -4; dx <= 4; dx++) {
158
+ const x = cx + dx, y = cy + dy, d = Math.max(Math.abs(dx), Math.abs(dy));
159
+ if (x >= 0 && x < size && y >= 0 && y < size) set(x, y, d !== 2 && d !== 4);
160
+ }
161
+ }
162
+ const al = ALIGN[v]!;
163
+ for (const cy of al) for (const cx of al) {
164
+ if ((cx === 6 && cy === 6) || (cx === 6 && cy === al.at(-1)) || (cx === al.at(-1) && cy === 6)) continue;
165
+ for (let dy = -2; dy <= 2; dy++) for (let dx = -2; dx <= 2; dx++) set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
166
+ }
167
+
168
+ const format = (mask: number) => {
169
+ const d = (1 << 3) | mask; // ECC L is 01
170
+ let rem = d;
171
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
172
+ const f = ((d << 10) | rem) ^ 0x5412;
173
+ const bit = (i: number) => ((f >>> i) & 1) === 1;
174
+ for (let i = 0; i <= 5; i++) set(8, i, bit(i));
175
+ set(8, 7, bit(6)); set(8, 8, bit(7)); set(7, 8, bit(8));
176
+ for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
177
+ for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
178
+ for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
179
+ set(8, size - 8, true);
180
+ };
181
+ format(0);
182
+
183
+ let i = 0;
184
+ for (let right = size - 1; right >= 1; right -= 2) {
185
+ if (right === 6) right = 5;
186
+ for (let vert = 0; vert < size; vert++) for (let j = 0; j < 2; j++) {
187
+ const x = right - j, y = ((right + 1) & 2) === 0 ? size - 1 - vert : vert;
188
+ if (!fn[y]![x] && i < words.length * 8) { m[y]![x] = ((words[i >> 3]! >>> (7 - (i & 7))) & 1) === 1; i++; }
189
+ }
190
+ }
191
+
192
+ const MASKS = [
193
+ (x: number, y: number) => (x + y) % 2 === 0, (_: number, y: number) => y % 2 === 0,
194
+ (x: number) => x % 3 === 0, (x: number, y: number) => (x + y) % 3 === 0,
195
+ (x: number, y: number) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
196
+ (x: number, y: number) => ((x * y) % 2) + ((x * y) % 3) === 0,
197
+ (x: number, y: number) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
198
+ (x: number, y: number) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
199
+ ];
200
+ // ponytail: penalty counts only runs and 2x2 blocks (rules 1-2 of 4); every mask
201
+ // decodes, this just picks a calmer one. Add the other two rules if scans fail.
202
+ const penalty = (g: boolean[][]) => {
203
+ let p = 0;
204
+ for (let a = 0; a < size; a++) for (const row of [true, false]) {
205
+ let run = 1;
206
+ for (let b = 1; b < size; b++) {
207
+ const same = row ? g[a]![b] === g[a]![b - 1] : g[b]![a] === g[b - 1]![a];
208
+ run = same ? run + 1 : 1;
209
+ if (run === 5) p += 3; else if (run > 5) p++;
210
+ }
211
+ }
212
+ for (let y = 0; y < size - 1; y++) for (let x = 0; x < size - 1; x++) {
213
+ if (g[y]![x] === g[y]![x + 1] && g[y]![x] === g[y + 1]![x] && g[y]![x] === g[y + 1]![x + 1]) p += 3;
214
+ }
215
+ return p;
216
+ };
217
+ const masked = (k: number) => m.map((row, y) => row.map((d, x) => (!fn[y]![x] && MASKS[k]!(x, y) ? !d : d)));
218
+ let best = 0, bestP = Infinity;
219
+ for (let k = 0; k < 8; k++) { const p = penalty(masked(k)); if (p < bestP) { bestP = p; best = k; } }
220
+ format(best);
221
+ const out = masked(best);
222
+ // masked() copied the format bits drawn for mask 0; redraw them into the result.
223
+ const fm = m;
224
+ for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) if (fn[y]![x]) out[y]![x] = fm[y]![x]!;
225
+ return out;
226
+ }
227
+
228
+ // Light modules are the filled blocks, so a dark terminal shows dark modules on a
229
+ // white field with a two-module quiet zone, which is what phone cameras expect.
230
+ export function qr(text: string): string {
231
+ const g = qrMatrix(text), q = 2, n = g.length + 2 * q;
232
+ const dark = (x: number, y: number) => g[y - q]?.[x - q] === true;
233
+ const lines: string[] = [];
234
+ for (let y = 0; y < n; y += 2) {
235
+ let line = "";
236
+ for (let x = 0; x < n; x++) {
237
+ const t = dark(x, y), b = y + 1 < n && dark(x, y + 1);
238
+ line += t ? (b ? " " : "▄") : (b ? "▀" : "█");
239
+ }
240
+ lines.push(line);
241
+ }
242
+ return lines.join("\n");
243
+ }
@@ -0,0 +1,57 @@
1
+ import { test, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync, existsSync, readdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { qrMatrix, qr } from "./qr.ts";
6
+ import { tailnetName } from "./setup.ts";
7
+
8
+ test("qr: right size per version, finder squares in three corners, deterministic", () => {
9
+ const a = qrMatrix("https://pc.tail1234.ts.net"); // 26 bytes: version 2, 25 modules
10
+ expect(a.length).toBe(25);
11
+ for (const [x, y] of [[0, 0], [18, 0], [0, 18]] as const) {
12
+ expect(a[y]![x]).toBe(true); expect(a[y + 3]![x + 3]).toBe(true); expect(a[y + 1]![x + 1]).toBe(false);
13
+ }
14
+ expect(qr("https://pc.tail1234.ts.net")).toBe(qr("https://pc.tail1234.ts.net"));
15
+ expect(qrMatrix("https://a.b").length).toBe(21); // version 1
16
+ expect(qrMatrix("x".repeat(130)).length).toBe(41); // version 6
17
+ expect(() => qrMatrix("x".repeat(200))).toThrow();
18
+ const lines = qr("https://a.b").split("\n");
19
+ expect(lines.length).toBe(13); // (21 + 4 quiet) / 2 rows, rounded up
20
+ expect(new Set(lines.map((l) => l.length))).toEqual(new Set([25]));
21
+ });
22
+
23
+ test("tailnetName: the machine's DNS name only when Tailscale is running", () => {
24
+ const up = JSON.stringify({ BackendState: "Running", Self: { DNSName: "PC.tail1234.ts.net." } });
25
+ expect(tailnetName(up)).toBe("pc.tail1234.ts.net");
26
+ expect(tailnetName(JSON.stringify({ BackendState: "NeedsLogin", Self: { DNSName: "pc.x.ts.net." } }))).toBeNull();
27
+ expect(tailnetName(JSON.stringify({ BackendState: "Running" }))).toBeNull();
28
+ expect(tailnetName("not json")).toBeNull();
29
+ });
30
+
31
+ // Scratch HOME only: setup must never touch the real ~/.kaizen.
32
+ test("kaizen setup without a terminal prints the manual steps and writes nothing", async () => {
33
+ const homeDir = mkdtempSync(join(tmpdir(), "kaizen-setup-"));
34
+ try {
35
+ const p = Bun.spawn([process.execPath, join(import.meta.dir, "install.ts"), "setup"], {
36
+ cwd: homeDir, env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir }, stdin: "ignore", stdout: "pipe", stderr: "pipe",
37
+ });
38
+ const out = await new Response(p.stdout).text();
39
+ expect(await p.exited).toBe(0);
40
+ expect(out).toContain("tailscale serve --bg 127.0.0.1:7420");
41
+ expect(readdirSync(homeDir)).toEqual([]);
42
+ expect(existsSync(join(homeDir, ".kaizen"))).toBe(false);
43
+ } finally { rmSync(homeDir, { recursive: true, force: true }); }
44
+ });
45
+
46
+ test("web-hosts: names are normalized, deduped and merged with the env var", async () => {
47
+ const homeDir = mkdtempSync(join(tmpdir(), "kaizen-hosts2-"));
48
+ try {
49
+ const p = Bun.spawn([process.execPath, "-e", `
50
+ const s = await import(${JSON.stringify(join(import.meta.dir, "state.ts"))});
51
+ s.addWebHost("PC.Tail1.ts.net"); s.addWebHost("pc.tail1.ts.net");
52
+ console.log(JSON.stringify([...s.webHosts()].sort()));`], {
53
+ env: { ...process.env, HOME: homeDir, USERPROFILE: homeDir, KAIZEN_WEB_HOSTS: "Env.ts.net:443" }, stdout: "pipe",
54
+ });
55
+ expect(JSON.parse(await new Response(p.stdout).text())).toEqual(["env.ts.net", "pc.tail1.ts.net"]);
56
+ } finally { rmSync(homeDir, { recursive: true, force: true }); }
57
+ });
package/cli/setup.ts ADDED
@@ -0,0 +1,108 @@
1
+ // `kaizen setup`: the walk after install — a project, the settings, the board, the
2
+ // phone. Every step can be skipped, and nothing changes without an answer.
3
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { addWebHost, home, remember } from "./state.ts";
6
+ import { applyPreset, detectPreset } from "./settings.ts";
7
+ import { qr } from "./qr.ts";
8
+
9
+ const PORT = 7420;
10
+ const INSTALL = join(import.meta.dir, "install.ts");
11
+
12
+ export type Ui = {
13
+ select: <T>(q: string, o: { label: string; hint: string; value: T }[]) => Promise<T>;
14
+ ask: (q: string) => string | null;
15
+ c: Record<"dim" | "bold" | "cyan" | "green", (s: string) => string>;
16
+ };
17
+
18
+ // The DNS name `tailscale status --json` reports for this machine, or null when
19
+ // Tailscale is not running or not logged in.
20
+ export function tailnetName(json: string): string | null {
21
+ try {
22
+ const s = JSON.parse(json);
23
+ const name = String(s?.Self?.DNSName ?? "").replace(/\.$/, "").toLowerCase();
24
+ return s?.BackendState === "Running" && name ? name : null;
25
+ } catch { return null; }
26
+ }
27
+
28
+ // A folder already inside a project (.git or .kaizen on the way up to home).
29
+ function inProject(dir: string) {
30
+ for (let d = dir; d !== home; ) {
31
+ if (existsSync(join(d, ".git")) || existsSync(join(d, ".kaizen"))) return true;
32
+ const up = resolve(d, "..");
33
+ if (up === d) return false;
34
+ d = up;
35
+ }
36
+ return false;
37
+ }
38
+
39
+ export function manual() {
40
+ return `
41
+ kaizen setup needs a terminal. By hand:
42
+ kaizen settings pick a preset
43
+ kaizen web the board
44
+ tailscale serve --bg 127.0.0.1:${PORT} phone, then add the name it prints to ~/.kaizen/web-hosts
45
+ `;
46
+ }
47
+
48
+ export async function setup(ui: Ui) {
49
+ const { c, select } = ui;
50
+ const out = (s = "") => console.log(s);
51
+ const run = (cmd: string[], cwd?: string) => Bun.spawnSync(cmd, { cwd, stdin: "inherit", stdout: "inherit", stderr: "inherit" }).exitCode === 0;
52
+
53
+ out(`\n ${c.bold("kaizen setup")} ${c.dim("three short steps, each can be skipped")}`);
54
+
55
+ // 1 -- project
56
+ if (!inProject(process.cwd())) {
57
+ out(`\n ${c.bold("1 Project")} ${c.dim("kaizen keeps its runs in a .kaizen/ folder inside your project")}`);
58
+ for (let tries = 0; tries < 2; tries++) {
59
+ const typed = ui.ask(" Project folder (Enter to skip): ")?.trim();
60
+ if (!typed) break;
61
+ const dir = resolve(typed.replace(/^~(?=$|[\\/])/, home));
62
+ if (dir === home || !existsSync(dir) || !statSync(dir).isDirectory()) { out(` ${c.dim(`${typed}: not a folder`)}`); continue; }
63
+ const r = Bun.spawnSync([process.execPath, INSTALL, "--yes", "--init-project"], { cwd: dir });
64
+ if (r.exitCode === 0 && existsSync(join(dir, ".kaizen"))) { remember(dir); out(` ${c.green("+")} set up ${dir}`); }
65
+ else out(` ${c.dim(`could not set up ${dir}`)}`);
66
+ break;
67
+ }
68
+ }
69
+
70
+ // 2 -- settings
71
+ const defaults = readFileSync(join(import.meta.dir, "..", "skills/kaizen/config.default.yml"), "utf8");
72
+ const file = join(home, ".kaizen", "config.yml");
73
+ const current = existsSync(file) ? readFileSync(file, "utf8") : defaults;
74
+ const now = detectPreset(current, defaults);
75
+ const preset = await select(`2 Settings (now: ${now})`, [
76
+ { label: "Keep", hint: "leave them as they are", value: null },
77
+ { label: "Medium", hint: "balanced — subagents, plan approval, auto-fix high/critical", value: "medium" as const },
78
+ { label: "Low", hint: "inline and manual — lite runner, manual approvals, no auto-fix", value: "low" as const },
79
+ { label: "Ultra", hint: "autonomous — auto mode, auto-fix all findings", value: "ultra" as const },
80
+ ]);
81
+ if (preset) { writeFileSync(file, applyPreset(current, preset, defaults)); out(` ${c.green("+")} ${preset} preset in ~/.kaizen/config.yml`); }
82
+ out(` ${c.dim("every setting: kaizen settings, or the gear on the board")}`);
83
+
84
+ // 3 -- board
85
+ out(`\n ${c.bold("3 Board")}`);
86
+ run([process.execPath, INSTALL, "web", "--daemon"]);
87
+
88
+ // 4 -- phone
89
+ out(`\n ${c.bold("4 Phone")} ${c.dim("the board on your phone, over Tailscale")}`);
90
+ let name: string | null = null;
91
+ try { name = tailnetName(Bun.spawnSync(["tailscale", "status", "--json"]).stdout.toString()); } catch { /* not installed */ }
92
+ if (!name) {
93
+ out(` Tailscale is not running here. Install it on this PC and your phone (${c.cyan("https://tailscale.com/download")}),`);
94
+ out(` sign in on both, then run ${c.cyan("kaizen setup")} again.\n`);
95
+ return;
96
+ }
97
+ const yes = await select(`Run tailscale serve so your phone can reach the board?`, [
98
+ { label: "Not now", hint: "nothing changes", value: false },
99
+ { label: "Yes", hint: `tailscale serve --bg 127.0.0.1:${PORT} — private to your tailnet`, value: true },
100
+ ]);
101
+ if (!yes) { out(); return; }
102
+ if (!run(["tailscale", "serve", "--bg", `127.0.0.1:${PORT}`])) { out(` ${c.dim("tailscale serve failed — see its message above")}\n`); return; }
103
+ addWebHost(name);
104
+ const url = `https://${name}`;
105
+ out(`\n${qr(url)}\n\n Scan with your phone camera, or open ${c.cyan(url)}`);
106
+ out(` Then choose Add to Home screen: it opens full screen with the kaizen icon.`);
107
+ out(` ${c.dim("Anyone on your tailnet who can reach this PC can drive your agents; restrict it with Tailscale ACLs.")}\n`);
108
+ }
package/cli/state.test.ts CHANGED
@@ -1019,7 +1019,7 @@ test("the board refuses an upgrade from another origin", async () => {
1019
1019
  });
1020
1020
 
1021
1021
  // KAIZEN_WEB_HOSTS (tailscale serve): the listed host gets in, any other is still refused.
1022
- test("the board answers only the hosts listed in KAIZEN_WEB_HOSTS", async () => {
1022
+ test("the board answers only the hosts listed in KAIZEN_WEB_HOSTS or web-hosts", async () => {
1023
1023
  const homeDir = mkdtempSync(join(tmpdir(), "kaizen-hosts-"));
1024
1024
  for (const d of [join(homeDir, ".kaizen"), join(homeDir, ".claude")]) mkdirSync(d, { recursive: true });
1025
1025
  const port = 20000 + Math.floor(Math.random() * 20000), repo = join(import.meta.dir, "..");
@@ -1032,6 +1032,10 @@ test("the board answers only the hosts listed in KAIZEN_WEB_HOSTS", async () =>
1032
1032
  expect((await fetch(at + "/state", { headers: { host: "pc.tail1234.ts.net" } })).status).toBe(200);
1033
1033
  expect((await fetch(at + "/state", { headers: { host: "evil.example" } })).status).toBe(403);
1034
1034
  expect((await fetch(at + "/state", { headers: { host: "other.tail1234.ts.net" } })).status).toBe(403);
1035
+ // ~/.kaizen/web-hosts (written by `kaizen setup`) is read per request: no restart.
1036
+ writeFileSync(join(homeDir, ".kaizen", "web-hosts"), "# phone\nOther.tail1234.ts.net\n");
1037
+ expect((await fetch(at + "/state", { headers: { host: "other.tail1234.ts.net" } })).status).toBe(200);
1038
+ expect((await fetch(at + "/state", { headers: { host: "evil.example" } })).status).toBe(403);
1035
1039
  expect((await fetch(at + "/upgrade", { method: "POST", headers: { origin: "https://evil.example" } })).status).toBe(403);
1036
1040
  const m = await fetch(at + "/manifest.webmanifest");
1037
1041
  expect(m.status).toBe(200);
package/cli/state.ts CHANGED
@@ -277,6 +277,22 @@ if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(ty
277
277
  // read and corrected in an editor; blanks and # lines ignored.
278
278
  export const REGISTRY = join(home, ".kaizen", "projects");
279
279
 
280
+ // Names the board answers to besides loopback: KAIZEN_WEB_HOSTS and ~/.kaizen/web-hosts
281
+ // (one per line, written by `kaizen setup`). Read on every call, so a name added while
282
+ // the board runs works at once. Exact match only; a port or case is ignored.
283
+ export const WEB_HOSTS = join(home, ".kaizen", "web-hosts");
284
+ export function webHosts(): Set<string> {
285
+ const raw = (process.env.KAIZEN_WEB_HOSTS ?? "").split(",");
286
+ try { raw.push(...readFileSync(WEB_HOSTS, "utf8").split("\n")); } catch { /* none yet */ }
287
+ return new Set(raw.map((s) => s.trim().toLowerCase().replace(/:\d+$/, "")).filter((l) => l && !l.startsWith("#")));
288
+ }
289
+ export function addWebHost(name: string) {
290
+ name = name.trim().toLowerCase();
291
+ if (!name || webHosts().has(name)) return;
292
+ mkdirSync(dirname(WEB_HOSTS), { recursive: true });
293
+ appendFileSync(WEB_HOSTS, name + "\n");
294
+ }
295
+
280
296
  // Windows spreads work across drives -- the profile on C:, the projects on D: or E: --
281
297
  // and a search that only knows $HOME finds none of them. Every letter that answers is
282
298
  // a root worth walking; elsewhere there is one filesystem and $HOME is where work
package/cli/web.ts CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
8
8
  appendNote, saveAttachment, pidOnPort, readText, cmdExe, sysExe, powershellExe, runGit, cardsGit, readCommit, commitPush, notice, notifier, LOGO,
9
9
  reviewFindings, pickFindings, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
10
- launchMode, readSessions, ownerOf, resumePrompt, runAgent, firstSession,
10
+ launchMode, readSessions, ownerOf, resumePrompt, runAgent, firstSession, webHosts,
11
11
  } from "./state.ts";
12
12
  import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
13
13
  import { readSources, addSource, removeSource, moveSource, setCategory, setWriteback, readIcebox, promote, dismiss, restore, markDone, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
@@ -132,10 +132,9 @@ export async function web(repoDir: string, opts: Opts = {}) {
132
132
  : [cur]
133
133
  ).filter((d, i, arr) => arr.indexOf(d) === i && existsSync(d));
134
134
 
135
- // Hostnames the user put in KAIZEN_WEB_HOSTS (e.g. a `tailscale serve` name):
136
- // their explicit opt-in, matched exactly, empty when unset.
137
- const extraHosts = new Set((process.env.KAIZEN_WEB_HOSTS ?? "").split(",")
138
- .map((s) => s.trim().toLowerCase().replace(/:\d+$/, "")).filter(Boolean));
135
+ // Hostnames the user opted into (KAIZEN_WEB_HOSTS or ~/.kaizen/web-hosts, e.g. a
136
+ // `tailscale serve` name): matched exactly, empty when neither is set.
137
+ const extraHosts = { has: (h: string) => webHosts().has(h) };
139
138
 
140
139
  // Any tab on this machine can reach loopback. GET only reads the user's own
141
140
  // files, but a POST from a page on the wider web must not write them.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.34.5",
3
+ "version": "1.34.7",
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
@@ -24,6 +24,8 @@
24
24
  @media(prefers-color-scheme:dark){:root:not([data-theme=light]){--shade:rgb(0 0 0/.45);--canvas:color-mix(in srgb,var(--bg) 70%,black)}}
25
25
  :root[data-theme=dark]{--shade:rgb(0 0 0/.45);--canvas:color-mix(in srgb,var(--bg) 70%,black)}
26
26
  body{margin:0;background:var(--canvas);color:var(--ink);font:13px/1.5 var(--font);-webkit-font-smoothing:antialiased;height:100vh;height:100dvh;overflow:hidden;display:flex}
27
+ html{background:var(--canvas)}
28
+ @media(display-mode:standalone){body{position:fixed;inset:0;height:auto}}
27
29
  button,input,select{font:inherit;color:inherit;background:none;border:0;padding:0}
28
30
  button{cursor:pointer}
29
31
  :focus-visible{outline:2px solid var(--accent);outline-offset:2px}
@@ -189,7 +191,7 @@ section h2 .clear{margin-left:auto;min-height:22px;padding:0 8px}
189
191
  .notes{display:flex;flex-direction:column;gap:8px}
190
192
  #pbody img{max-width:100%;border-radius:var(--r-s)}
191
193
  /* the in-card agent: a wider panel, and the terminal filling its body */
192
- aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-direction:column}#pbody .term{flex:1;min-height:0;min-height:320px;background:var(--bg);border-radius:var(--r-s)}#pbody .term .xterm{height:100%;padding:4px}#pbody .term .xterm-viewport{background:transparent!important}.termkeys{padding:6px 0}.termkeys button{height:52px;padding:0;border:1px solid var(--line);border-bottom-width:4px;border-radius:10px;background:var(--bg-2);color:var(--ink-hi);font:600 17px var(--mono);touch-action:manipulation}.termkeys button:active{transform:translateY(2px);border-bottom-width:2px;background:var(--hover)}.termkeys button.on{color:var(--on-accent);background:var(--accent);border-color:var(--accent)}.keygrid{display:grid;grid-template-columns:repeat(6,1fr);gap:8px}.ctrlpad{margin-bottom:8px}.ctrlpad div{display:flex;gap:4px;margin-bottom:5px}.ctrlpad div:nth-child(2){padding:0 5%}.ctrlpad div:nth-child(3){padding:0 10%}.ctrlpad button{flex:1;min-width:0;height:44px;font-size:15px}.termkeys [hidden]{display:none}.termkeys .keyhide{display:block;width:100%;height:30px;margin-bottom:6px;border-bottom-width:1px;font-size:14px}
194
+ aside.wide{width:100%;border-left:0}#pbody:has(.term){display:flex;flex-direction:column}#pbody .term{flex:1;min-height:0;min-height:320px;background:var(--bg);border-radius:var(--r-s)}#pbody .term .xterm{height:100%;padding:4px}#pbody .term .xterm-viewport{background:transparent!important}.termkeys{padding:6px 0}.termkeys button{height:52px;padding:0;border:1px solid var(--line);border-bottom-width:4px;border-radius:10px;background:var(--bg-2);color:var(--ink-hi);font:600 17px var(--mono);touch-action:manipulation}.termkeys button:active{transform:translateY(2px);border-bottom-width:2px;background:var(--hover)}.termkeys button.on{color:var(--on-accent);background:var(--accent);border-color:var(--accent)}.keygrid{display:grid;grid-template-columns:repeat(6,1fr);gap:8px}.ctrlpad{margin-bottom:8px}.ctrlpad div{display:flex;gap:4px;margin-bottom:5px}.ctrlpad div:nth-child(2){padding:0 5%}.ctrlpad div:nth-child(3){padding:0 10%}.ctrlpad button{flex:1;min-width:0;height:44px;font-size:15px}.termkeys [hidden]{display:none}.keytop{display:flex;gap:8px;margin-bottom:6px}.keytop button{flex:1;height:30px;border-bottom-width:1px;font-size:14px}
193
195
  .desc{border-bottom:1px solid var(--line);padding-bottom:6px;margin-bottom:14px}.desc h3{margin-top:14px}
194
196
  .chat{display:flex;flex-direction:column;gap:10px;margin:0 0 14px}.msg{border:1px solid var(--line);border-radius:var(--r);padding:10px 12px;background:var(--bg)}.msg time{display:block;font-size:11px;color:var(--dim);margin-bottom:4px}.msg p:last-child{margin-bottom:0}.msg img{max-width:100%;max-height:260px;border-radius:var(--r-s);display:block;margin:4px 0}
195
197
  .notes .tools{display:flex;gap:6px;align-items:center}.notes .tools span{flex:1;font-size:11px;color:var(--dim)}.notes .att{font-size:11px;color:var(--dim)}
package/web/board.js CHANGED
@@ -1,4 +1,4 @@
1
- /* kaizen board 1188e8a22cfa1a37 -- generated by bun run build:web from web/board/main.ts; do not edit */
1
+ /* kaizen board 15a573a6194b8517 -- generated by bun run build:web from web/board/main.ts; do not edit */
2
2
  (()=>{var a={S:{project:null,projects:[],cards:[],now:Date.now(),all:!1},all:new URLSearchParams(location.search).get("all")!=="0",dir:null,openKey:null,mode:null,modeKey:null,menuKey:null,tab:"request",lastTab:"request",runDoc:null,clearMenu:!1,wantTerm:null,setQ:"",pageGen:0,PL:[],shown:new Set((()=>{try{return JSON.parse(localStorage.getItem("kz-show"))||[]}catch(e){return[]}})())};var t=(e)=>document.getElementById(e),p=(e)=>String(e).replace(/[&<>"]/g,(n)=>({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"})[n]),T=(e)=>`<svg><use href="#i-${e}"/></svg>`;var en;function S(e,n){let s=t("toast");if(s.textContent=e,s.classList.toggle("rep",!!n),n){let l=document.createElement("a");l.href="/issue?"+new URLSearchParams(n),l.target="_blank",l.rel="noopener",l.textContent="Report",s.append(" ",l)}s.classList.add("on"),clearTimeout(en),en=setTimeout(()=>s.classList.remove("on"),n?8000:4000)}function me(){try{if(typeof Notification<"u"&&Notification.permission==="default")Notification.requestPermission()}catch(e){}}function tn(e){try{if(typeof Notification<"u"&&Notification.permission==="granted")new Notification("kaizen",{body:e,icon:"/logo.png"})}catch(n){}}var Vn="https://cdn.jsdelivr.net/npm/@huggingface/transformers@4.3.0",nn={tiny:"onnx-community/whisper-tiny",base:"onnx-community/whisper-base",small:"onnx-community/whisper-small","large-v3-turbo":"onnx-community/whisper-large-v3-turbo"},$t=null,Jn=`import{pipeline}from"${Vn}";let asr=null;
3
3
  onmessage=async e=>{const m=e.data;try{
4
4
  if(m.type==="load"){const prog=p=>{if(p.status==="progress")postMessage({type:"progress",file:p.file,pct:p.progress||0})};
package/web/notebook.js CHANGED
@@ -1,4 +1,4 @@
1
- /* kaizen notebook 1188e8a22cfa1a37 -- generated by bun run build:web from web/notebook.ts; do not edit */
1
+ /* kaizen notebook 15a573a6194b8517 -- generated by bun run build:web from web/notebook.ts; do not edit */
2
2
  var Ss=[],ua=[];(()=>{let e="lc,34,7n,7,7b,19,,,,2,,2,,,20,b,1c,l,g,,2t,7,2,6,2,2,,4,z,,u,r,2j,b,1m,9,9,,o,4,,9,,3,,5,17,3,1n,9,16,o,,x,1i,3,,i,,7,a,2,t,3,1k,,,7,2,2,2,3,9,,a,2,q,,2,3,1k,,,5,4,2,2,3,3,,u,2,3,,b,3,1k,,,8,,3,,3,k,2,m,6,,3,1k,,,7,2,2,2,3,7,3,a,2,u,,1n,5,3,3,,4,9,,14,5,1j,,,7,,3,,4,7,2,b,2,t,3,1k,,,7,,3,,4,7,2,b,2,f,,c,4,1j,2,,7,,3,,4,9,,a,2,t,3,1y,,4,6,,,,8,i,2,1p,,,8,c,8,2q,,,a,b,7,21,2,r,,,,,,4,2,1d,k,,2,5,b,,10,9,,2u,b,,6,n,4,4,3,g,4,d,,,3,6,,f,,jj,3,qa,4,s,3,t,2,u,2,1s,w,9,,19,3,,,39,2,y,,3a,c,4,c,63,5,1l,a,,,,,2,o,2,,1c,1a,2,c,k,5,1b,h,12,9,c,3,u,d,1k,e,1c,k,48,3,,l,4,,6,,2,3,5i,1s,ek,,5f,x,2da,3,3x,,2o,w,fe,6,2x,2,n9w,4,,a,w,2,28,2,7k,,3,,4,,n,5,4,,2b,2,1e,i,q,i,d,,12,8,p,d,18,4,1b,e,10,,1v,e,c,,8,2,1a,,1f,,,3,2,2,5,2,,,15,5,5,2,6k,8,,2,fn4,,kh,g,g,g,a6,2,gt,,6a,,45,5,1ae,3,,2,5,4,14,3,4,,4l,2,fx,4,1t,5,8t,2,25,6,1y,b,1d,4,3e,3,1h,f,15,,2,2,a,4,19,b,7,,1p,3,10,e,g,2,18,,c,3,1c,e,8,4,,2,2k,c,6,,2,,4d,c,l,4,1j,2,,7,2,2,2,3,9,,a,2,2,7,3,5,1v,9,,,2,,,4,,5,,,e,2,2a,i,n,,29,k,6j,7,2,9,r,2,2a,h,2y,d,2t,3,2,a,74,f,6t,6,,2,2,4,,,,2,3x,7,2,7,3,,s,a,14,7,,4,8,,9,b,1a,g,5i,8,5j,8,,8,2a,m,,e,3e,6,3,,,2,,7,,,1u,5,,2,,5,9n,4,9,2,,,1c,7,3,5,n,,44l,,6,f,8ug,i,1xc,5,1n,7,t4,,,1j,7,4,29,,b,2,f57,2,3mp,1a,2,n,f2,5,3,6,8,8,2,7,u,4,44,3,1iz,1j,4,1e,8,,e,,m,5,,f,11s,7,,h,2,7,,2,,5,2s,,4g,7,af,,1p,4,e4,4,72,2,6r,,2,,7,2,5,,d6,7,31,7,240,5".split(",").map((t)=>t?parseInt(t,36):1);for(let t=0,i=0;t<e.length;t++)(t%2?ua:Ss).push(i=i+e[t])})();function op(e){if(e<768)return!1;for(let t=0,i=Ss.length;;){let n=t+i>>1;if(e<Ss[n])i=n;else if(e>=ua[n])t=n+1;else return!0;if(t==i)return!1}}function ha(e){return e>=127462&&e<=127487}var fa=8205;function da(e,t,i=!0,n=!0){return(i?Oa:lp)(e,t,n)}function Oa(e,t,i){if(t==e.length)return t;if(t&&pa(e.charCodeAt(t))&&ma(e.charCodeAt(t-1)))t--;let n=bs(e,t);t+=ca(n);while(t<e.length){let r=bs(e,t);if(n==fa||r==fa||i&&op(r))t+=ca(r),n=r;else if(ha(r)){let s=0,o=t-2;while(o>=0&&ha(bs(e,o)))s++,o-=2;if(s%2==0)break;else t+=2}else break}return t}function lp(e,t,i){while(t>1){let n=Oa(e,t-2,i);if(n<t)return n;t--}return 0}function bs(e,t){let i=e.charCodeAt(t);if(!ma(i)||t+1==e.length)return i;let n=e.charCodeAt(t+1);if(!pa(n))return i;return(i-55296<<10)+(n-56320)+65536}function pa(e){return e>=56320&&e<57344}function ma(e){return e>=55296&&e<56320}function ca(e){return e<65536?1:2}class _{lineAt(e){if(e<0||e>this.length)throw RangeError(`Invalid position ${e} in document of length ${this.length}`);return this.lineInner(e,!1,1,0)}line(e){if(e<1||e>this.lines)throw RangeError(`Invalid line number ${e} in ${this.lines}-line document`);return this.lineInner(e,!0,1,0)}replace(e,t,i){[e,t]=vi(this,e,t);let n=[];if(this.decompose(0,e,n,2),i.length)i.decompose(0,i.length,n,3);return this.decompose(t,this.length,n,1),ft.from(n,this.length-(t-e)+i.length)}append(e){return this.replace(this.length,this.length,e)}slice(e,t=this.length){[e,t]=vi(this,e,t);let i=[];return this.decompose(e,t,i,0),ft.from(i,t-e)}eq(e){if(e==this)return!0;if(e.length!=this.length||e.lines!=this.lines)return!1;let t=this.scanIdentical(e,1),i=this.length-this.scanIdentical(e,-1),n=new wi(this),r=new wi(e);for(let s=t,o=t;;){if(n.next(s),r.next(s),s=0,n.lineBreak!=r.lineBreak||n.done!=r.done||n.value!=r.value)return!1;if(o+=n.value.length,n.done||o>=i)return!0}}iter(e=1){return new wi(this,e)}iterRange(e,t=this.length){return new Ts(this,e,t)}iterLines(e,t){let i;if(e==null)i=this.iter();else{if(t==null)t=this.lines+1;let n=this.line(e).from;i=this.iterRange(n,Math.max(n,t==this.lines+1?this.length:t<=1?0:this.line(t-1).to))}return new As(i)}toString(){return this.sliceString(0)}toJSON(){let e=[];return this.flatten(e),e}constructor(){}static of(e){if(e.length==0)throw RangeError("A document must have at least one line");if(e.length==1&&!e[0])return _.empty;return e.length<=32?new oe(e):ft.from(oe.split(e,[]))}}class oe extends _{constructor(e,t=ap(e)){super();this.text=e,this.length=t}get lines(){return this.text.length}get children(){return null}lineInner(e,t,i,n){for(let r=0;;r++){let s=this.text[r],o=n+s.length;if((t?i:o)>=e)return new ka(n,o,i,s);n=o+1,i++}}decompose(e,t,i,n){let r=e<=0&&t>=this.length?this:new oe(ga(this.text,e,t),Math.min(t,this.length)-Math.max(0,e));if(n&1){let s=i.pop(),o=cr(r.text,s.text.slice(),0,r.length);if(o.length<=32)i.push(new oe(o,s.length+r.length));else{let l=o.length>>1;i.push(new oe(o.slice(0,l)),new oe(o.slice(l)))}}else i.push(r)}replace(e,t,i){if(!(i instanceof oe))return super.replace(e,t,i);[e,t]=vi(this,e,t);let n=cr(this.text,cr(i.text,ga(this.text,0,e)),t),r=this.length+i.length-(t-e);if(n.length<=32)return new oe(n,r);return ft.from(oe.split(n,[]),r)}sliceString(e,t=this.length,i=`
3
3
  `){[e,t]=vi(this,e,t);let n="";for(let r=0,s=0;r<=t&&s<this.text.length;s++){let o=this.text[s],l=r+o.length;if(r>e&&s)n+=i;if(e<l&&t>r)n+=o.slice(Math.max(0,e-r),t-r);r=l+1}return n}flatten(e){for(let t of this.text)e.push(t)}scanIdentical(){return 0}static split(e,t){let i=[],n=-1;for(let r of e)if(i.push(r),n+=r.length+1,i.length==32)t.push(new oe(i,n)),i=[],n=-1;if(n>-1)t.push(new oe(i,n));return t}}class ft extends _{constructor(e,t){super();this.children=e,this.length=t,this.lines=0;for(let i of e)this.lines+=i.lines}lineInner(e,t,i,n){for(let r=0;;r++){let s=this.children[r],o=n+s.length,l=i+s.lines-1;if((t?l:o)>=e)return s.lineInner(e,t,i,n);n=o+1,i=l+1}}decompose(e,t,i,n){for(let r=0,s=0;s<=t&&r<this.children.length;r++){let o=this.children[r],l=s+o.length;if(e<=l&&t>=s){let a=n&((s<=e?1:0)|(l>=t?2:0));if(s>=e&&l<=t&&!a)i.push(o);else o.decompose(e-s,t-s,i,a)}s=l+1}}replace(e,t,i){if([e,t]=vi(this,e,t),i.lines<this.lines)for(let n=0,r=0;n<this.children.length;n++){let s=this.children[n],o=r+s.length;if(e>=r&&t<=o){let l=s.replace(e-r,t-r,i),a=this.lines-s.lines+l.lines;if(l.lines<a>>4&&l.lines>a>>6){let h=this.children.slice();return h[n]=l,new ft(h,this.length-(t-e)+i.length)}return super.replace(r,o,l)}r=o+1}return super.replace(e,t,i)}sliceString(e,t=this.length,i=`
4
4
  `){[e,t]=vi(this,e,t);let n="";for(let r=0,s=0;r<this.children.length&&s<=t;r++){let o=this.children[r],l=s+o.length;if(s>e&&r)n+=i;if(e<l&&t>s)n+=o.sliceString(e-s,t-s,i);s=l+1}return n}flatten(e){for(let t of this.children)t.flatten(e)}scanIdentical(e,t){if(!(e instanceof ft))return 0;let i=0,[n,r,s,o]=t>0?[0,0,this.children.length,e.children.length]:[this.children.length-1,e.children.length-1,-1,-1];for(;;n+=t,r+=t){if(n==s||r==o)return i;let l=this.children[n],a=e.children[r];if(l!=a)return i+l.scanIdentical(a,t);i+=l.length+1}}static from(e,t=e.reduce((i,n)=>i+n.length+1,-1)){let i=0;for(let u of e)i+=u.lines;if(i<32){let u=[];for(let d of e)d.flatten(u);return new oe(u,t)}let n=Math.max(32,i>>5),r=n<<1,s=n>>1,o=[],l=0,a=-1,h=[];function f(u){let d;if(u.lines>r&&u instanceof ft)for(let O of u.children)f(O);else if(u.lines>s&&(l>s||!l))c(),o.push(u);else if(u instanceof oe&&l&&(d=h[h.length-1])instanceof oe&&u.lines+d.lines<=32)l+=u.lines,a+=u.length+1,h[h.length-1]=new oe(d.text.concat(u.text),d.length+1+u.length);else{if(l+u.lines>n)c();l+=u.lines,a+=u.length+1,h.push(u)}}function c(){if(l==0)return;o.push(h.length==1?h[0]:ft.from(h,a)),a=-1,l=h.length=0}for(let u of e)f(u);return c(),o.length==1?o[0]:new ft(o,t)}}_.empty=new oe([""],0);function ap(e){let t=-1;for(let i of e)t+=i.length+1;return t}function cr(e,t,i=0,n=1e9){for(let r=0,s=0,o=!0;s<e.length&&r<=n;s++){let l=e[s],a=r+l.length;if(a>=i){if(a>n)l=l.slice(0,n-r);if(r<i)l=l.slice(i-r);if(o)t[t.length-1]+=l,o=!1;else t.push(l)}r=a+1}return t}function ga(e,t,i){return cr(e,[""],t,i)}class wi{constructor(e,t=1){this.dir=t,this.done=!1,this.lineBreak=!1,this.value="",this.nodes=[e],this.offsets=[t>0?1:(e instanceof oe?e.text.length:e.children.length)<<1]}nextInner(e,t){this.done=this.lineBreak=!1;for(;;){let i=this.nodes.length-1,n=this.nodes[i],r=this.offsets[i],s=r>>1,o=n instanceof oe?n.text.length:n.children.length;if(s==(t>0?o:0)){if(i==0)return this.done=!0,this.value="",this;if(t>0)this.offsets[i-1]++;this.nodes.pop(),this.offsets.pop()}else if((r&1)==(t>0?0:1)){if(this.offsets[i]+=t,e==0)return this.lineBreak=!0,this.value=`
package/web/notes.html CHANGED
@@ -2,7 +2,7 @@
2
2
  <html lang="en">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width,initial-scale=1">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
6
  <title>kaizen notebook</title>
7
7
  <link rel="icon" href="/favicon.ico" sizes="any">
8
8
  <link rel="icon" type="image/png" href="/logo.png">
@@ -18,6 +18,8 @@
18
18
  @media(prefers-color-scheme:dark){:root:not([data-theme=light]){--shade:rgb(0 0 0/.45);--canvas:color-mix(in srgb,var(--bg) 70%,black)}}
19
19
  :root[data-theme=dark]{--shade:rgb(0 0 0/.45);--canvas:color-mix(in srgb,var(--bg) 70%,black)}
20
20
  body{margin:0;background:var(--canvas);color:var(--ink);font:14px/1.5 var(--font);-webkit-font-smoothing:antialiased;height:100vh;height:100dvh;overflow:hidden;display:flex}
21
+ html{background:var(--canvas)}
22
+ @media(display-mode:standalone){body{position:fixed;inset:0;height:auto}}
21
23
  button,input,select{font:inherit;color:inherit;background:none;border:0;padding:0}
22
24
  button{cursor:pointer}
23
25
  a{color:inherit;text-decoration:none}
@@ -52,7 +54,7 @@ svg{width:18px;height:18px;stroke:currentColor;fill:none;stroke-width:1.6;stroke
52
54
  /* end side menu */
53
55
  /* the work area: an inset rounded panel on the canvas the side menu sits on */
54
56
  .app{flex:1;min-width:0;display:flex;flex-direction:column;margin:8px 8px 8px 0;background:var(--bg);border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
55
- @media(max-width:640px){.app{margin:0;border:0;border-radius:0}}
57
+ @media(max-width:640px){.app{margin:0;border:0;border-radius:0}body{padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)}}
56
58
  /* header: the board's */
57
59
  .hdr{display:flex;align-items:center;gap:12px;padding:10px 20px 10px 28px;border-bottom:1px solid color-mix(in srgb,var(--line) 60%,transparent);flex-wrap:wrap}
58
60
  pre.mark{margin:0;font:700 8px/1 var(--mono)}