kaizen-agent 1.47.0 → 1.49.0

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.
Files changed (184) hide show
  1. package/README.md +111 -84
  2. package/cli/build-web.ts +4 -2
  3. package/cli/hermes.ts +65 -0
  4. package/cli/install.ts +24 -10
  5. package/cli/localsend.test.ts +84 -0
  6. package/cli/localsend.ts +131 -0
  7. package/cli/notes.test.ts +6 -4
  8. package/cli/settings.ts +1 -0
  9. package/cli/state.test.ts +37 -0
  10. package/cli/state.ts +35 -20
  11. package/cli/video.test.ts +479 -0
  12. package/cli/video.ts +507 -0
  13. package/cli/web.ts +127 -1
  14. package/cli/worktree.test.ts +3 -1
  15. package/package.json +1 -1
  16. package/skills/kaizen/config.default.yml +4 -0
  17. package/skills/media/kaizen-book/SKILL.md +107 -0
  18. package/skills/media/kaizen-book/assets/reader.html +440 -0
  19. package/skills/media/kaizen-book/assets/styles.json +45 -0
  20. package/skills/media/kaizen-book/examples/book.json +14 -0
  21. package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
  22. package/skills/media/kaizen-book/examples/manuscript.md +127 -0
  23. package/skills/media/kaizen-book/references/interview.md +85 -0
  24. package/skills/media/kaizen-book/references/proofread.md +47 -0
  25. package/skills/media/kaizen-book/references/writing.md +53 -0
  26. package/skills/media/kaizen-book/scripts/build_book.py +279 -0
  27. package/skills/media/kaizen-book/scripts/check.sh +10 -0
  28. package/skills/media/kaizen-book/scripts/check_book.py +128 -0
  29. package/skills/media/kaizen-book/scripts/deploy.py +58 -0
  30. package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
  31. package/skills/media/kaizen-book/scripts/setup.sh +9 -0
  32. package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
  33. package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
  34. package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
  35. package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
  36. package/skills/media/kaizen-video/SKILL.md +112 -0
  37. package/skills/media/kaizen-video/assets/CREDITS.txt +16 -0
  38. package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
  39. package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
  40. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
  41. package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
  42. package/skills/media/kaizen-video/assets/fonts/Fredoka.ttf +0 -0
  43. package/skills/media/kaizen-video/assets/fonts/Montserrat.ttf +0 -0
  44. package/skills/media/kaizen-video/assets/fonts/SpecialElite.ttf +0 -0
  45. package/skills/media/kaizen-video/assets/music/investigations.mp3 +0 -0
  46. package/skills/media/kaizen-video/assets/music/lights.mp3 +0 -0
  47. package/skills/media/kaizen-video/assets/music/sunny.mp3 +0 -0
  48. package/skills/media/kaizen-video/examples/config.json +152 -0
  49. package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
  50. package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
  51. package/skills/media/kaizen-video/film.html +246 -0
  52. package/skills/media/kaizen-video/hyperframes.json +12 -0
  53. package/skills/media/kaizen-video/presets/educational-warning/CREDITS.txt +3 -0
  54. package/skills/media/kaizen-video/presets/educational-warning/README.md +26 -0
  55. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/ending.wav +0 -0
  56. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/reveal.wav +0 -0
  57. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/shutter.wav +0 -0
  58. package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/stamp.wav +0 -0
  59. package/skills/media/kaizen-video/presets/educational-warning/index.html +236 -0
  60. package/skills/media/kaizen-video/presets/educational-warning/package.json +10 -0
  61. package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
  62. package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
  63. package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
  64. package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
  65. package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
  66. package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
  67. package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
  68. package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
  69. package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
  70. package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
  71. package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
  72. package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
  73. package/skills/media/kaizen-video/presets/tiktok-cheerful/CREDITS.txt +3 -0
  74. package/skills/media/kaizen-video/presets/tiktok-cheerful/README.md +35 -0
  75. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
  76. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
  77. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
  78. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
  79. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
  80. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
  81. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
  82. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
  83. package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
  84. package/skills/media/kaizen-video/presets/tiktok-cheerful/index.html +260 -0
  85. package/skills/media/kaizen-video/presets/tiktok-cheerful/package.json +10 -0
  86. package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
  87. package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
  88. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
  89. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
  90. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
  91. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
  92. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
  93. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
  94. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
  95. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
  96. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
  97. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
  98. package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
  99. package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
  100. package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
  101. package/skills/media/kaizen-video/presets/tiktok-mystery/CREDITS.txt +12 -0
  102. package/skills/media/kaizen-video/presets/tiktok-mystery/README.md +51 -0
  103. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
  104. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
  105. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
  106. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
  107. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
  108. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
  109. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
  110. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
  111. package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
  112. package/skills/media/kaizen-video/presets/tiktok-mystery/index.html +260 -0
  113. package/skills/media/kaizen-video/presets/tiktok-mystery/package.json +10 -0
  114. package/skills/media/kaizen-video/presets/tiktok-scam-alert/CREDITS.txt +8 -0
  115. package/skills/media/kaizen-video/presets/tiktok-scam-alert/README.md +24 -0
  116. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
  117. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
  118. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
  119. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
  120. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
  121. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
  122. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
  123. package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
  124. package/skills/media/kaizen-video/presets/tiktok-scam-alert/index.html +266 -0
  125. package/skills/media/kaizen-video/presets/tiktok-scam-alert/package.json +10 -0
  126. package/skills/media/kaizen-video/references/presets.md +76 -0
  127. package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
  128. package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
  129. package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
  130. package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
  131. package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
  132. package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
  133. package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
  134. package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
  135. package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
  136. package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
  137. package/skills/media/kaizen-video/scripts/check.sh +22 -0
  138. package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
  139. package/skills/media/kaizen-video/scripts/make_film.py +263 -0
  140. package/skills/media/kaizen-video/scripts/make_tiktok.py +213 -0
  141. package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
  142. package/skills/media/kaizen-video/scripts/render_cut.py +88 -0
  143. package/skills/media/kaizen-video/scripts/setup.sh +14 -0
  144. package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
  145. package/skills/media/kaizen-video/scripts/srt.py +9 -0
  146. package/skills/media/kaizen-video/scripts/still.sh +7 -0
  147. package/skills/media/kaizen-video/scripts/transcribe.py +45 -0
  148. package/skills/media/kaizen-video/tools/carve.mjs +560 -0
  149. package/skills/media/kaizen-video/tools/lib/main-module.mjs +13 -0
  150. package/web/board/views.ts +1 -1
  151. package/web/board.html +3 -1
  152. package/web/board.js +2 -2
  153. package/web/notebook.js +1 -1
  154. package/web/notes.html +2 -0
  155. package/web/term.js +38 -36
  156. package/web/term.ts +43 -10
  157. package/web/video.html +298 -0
  158. package/web/video.js +81 -0
  159. package/web/video.ts +610 -0
  160. package/skills/story-video/SKILL.md +0 -108
  161. package/skills/story-video/engine/package.json +0 -12
  162. package/skills/story-video/engine/src/Root.tsx +0 -24
  163. package/skills/story-video/engine/src/engine/Film.tsx +0 -405
  164. package/skills/story-video/engine/src/engine/presets.json +0 -58
  165. package/skills/story-video/engine/src/index.ts +0 -3
  166. package/skills/story-video/engine/tsconfig.json +0 -1
  167. package/skills/story-video/examples/sample-film/README.md +0 -12
  168. package/skills/story-video/examples/sample-film/film.json +0 -16
  169. package/skills/story-video/references/presets.md +0 -21
  170. package/skills/story-video/scripts/check.sh +0 -19
  171. package/skills/story-video/scripts/setup.sh +0 -25
  172. package/skills/story-video/scripts/srt.py +0 -30
  173. package/skills/story-video/scripts/still.sh +0 -10
  174. /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
  175. /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
  176. /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
  177. /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
  178. /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
  179. /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
  180. /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
  181. /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
  182. /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
  183. /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
  184. /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
@@ -0,0 +1,131 @@
1
+ // LocalSend protocol v2, the sending half: find devices on this network the way the
2
+ // LocalSend app does, and send one file to one of them. The person accepts on the
3
+ // device. https://github.com/localsend/protocol — Bun's dgram, fetch and Bun.serve only.
4
+ import { createSocket } from "node:dgram";
5
+ import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto";
6
+ import { basename } from "node:path";
7
+ import { hostname } from "node:os";
8
+
9
+ export const GROUP = "224.0.0.167", PORT = 53317;
10
+ export type Device = { alias: string; deviceModel: string | null; deviceType: string | null; ip: string; port: number; protocol: "http" | "https"; fingerprint: string };
11
+
12
+ // A device in HTTPS mode (the app's default) asks the sender for a TLS client
13
+ // certificate, and LocalSend's fingerprint is that certificate's SHA-256. So we carry a
14
+ // self-signed one, made once per board on first use: an RSA key and a minimal X.509 v3
15
+ // certificate in DER, signed with node:crypto, since Bun can sign but not issue one.
16
+ const der = (tag: number, ...parts: Buffer[]) => {
17
+ const body = Buffer.concat(parts), n = body.length;
18
+ const len = n < 128 ? Buffer.from([n]) : n < 256 ? Buffer.from([0x81, n]) : Buffer.from([0x82, n >> 8, n & 255]);
19
+ return Buffer.concat([Buffer.from([tag]), len, body]);
20
+ };
21
+ const seq = (...p: Buffer[]) => der(0x30, ...p);
22
+ const oid = (hex: string) => der(0x06, Buffer.from(hex, "hex"));
23
+ const time = (d: Date) => der(0x18, Buffer.from(d.toISOString().replace(/[-:T]|\.\d+/g, "")));
24
+ const pem = (label: string, b: Buffer) => `-----BEGIN ${label}-----\n${b.toString("base64").match(/.{1,64}/g)!.join("\n")}\n-----END ${label}-----\n`;
25
+ let id: { cert: string; key: string; fingerprint: string } | null = null;
26
+ export function identity() {
27
+ if (id) return id;
28
+ const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
29
+ const alg = seq(oid("2a864886f70d01010b"), Buffer.from([5, 0])); // sha256WithRSAEncryption
30
+ const name = seq(der(0x31, seq(oid("550403"), der(0x0c, Buffer.from("kaizen")))));
31
+ const now = Date.now();
32
+ const tbs = seq(der(0xa0, der(0x02, Buffer.from([2]))), der(0x02, Buffer.concat([Buffer.from([1]), randomBytes(8)])), alg, name,
33
+ seq(time(new Date(now - 86_400_000)), time(new Date(now + 10 * 365 * 86_400_000))), name, publicKey.export({ type: "spki", format: "der" }));
34
+ const cert = seq(tbs, alg, der(0x03, Buffer.from([0]), sign("sha256", tbs, privateKey)));
35
+ return id = { cert: pem("CERTIFICATE", cert), key: privateKey.export({ type: "pkcs8", format: "pem" }) as string, fingerprint: createHash("sha256").update(cert).digest("hex").toUpperCase() };
36
+ }
37
+ // Who we say we are; the fingerprint also lets discovery drop our own announce.
38
+ const me = () => ({ alias: `kaizen (${hostname()})`, version: "2.0", deviceModel: "kaizen", deviceType: "desktop", fingerprint: identity().fingerprint, download: false });
39
+
40
+ const device = (m: any, ip: string): Device | null => {
41
+ if (!m || typeof m.fingerprint !== "string" || !m.fingerprint || m.fingerprint === me().fingerprint || typeof m.alias !== "string") return null;
42
+ const port = Number(m.port ?? PORT);
43
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
44
+ return { alias: m.alias.slice(0, 80), deviceModel: typeof m.deviceModel === "string" ? m.deviceModel : null, deviceType: typeof m.deviceType === "string" ? m.deviceType : null,
45
+ ip: ip.replace(/^::ffff:/, ""), port, protocol: m.protocol === "https" ? "https" : "http", fingerprint: m.fingerprint };
46
+ };
47
+
48
+ // Announce on the multicast group and collect who answers for `ms`: devices reply by
49
+ // POSTing /register to the port we announced, or by multicast. The LocalSend app on this
50
+ // computer may hold UDP 53317 already; reuseAddr shares it on Linux and macOS, and where
51
+ // binding still fails we announce from any port and rely on the HTTP replies.
52
+ export async function discover(ms = 3000): Promise<{ devices: Device[]; note: string | null }> {
53
+ const found = new Map<string, Device>();
54
+ const add = (d: Device | null) => { if (d) found.set(d.fingerprint, d); };
55
+ const http = Bun.serve({ port: 0, hostname: "0.0.0.0", async fetch(req, srv) {
56
+ if (req.method === "POST" && new URL(req.url).pathname === "/api/localsend/v2/register") {
57
+ add(device(await req.json().catch(() => null), srv.requestIP(req)?.address ?? ""));
58
+ return Response.json({ ...me(), port: http.port, protocol: "http" });
59
+ }
60
+ return new Response("not found", { status: 404 });
61
+ } });
62
+ const sock = createSocket({ type: "udp4", reuseAddr: true });
63
+ let note: string | null = null;
64
+ try {
65
+ sock.on("message", (buf, r) => { try { add(device(JSON.parse(String(buf)), r.address)); } catch { /* not LocalSend */ } });
66
+ await new Promise<void>((ok) => {
67
+ sock.once("error", () => { note = "could not listen on UDP 53317; only devices that answer over HTTP are listed"; sock.bind(0, ok); });
68
+ sock.bind(PORT, () => { try { sock.addMembership(GROUP); } catch { /* no multicast route; replies still come over HTTP */ } ok(); });
69
+ });
70
+ const hello = Buffer.from(JSON.stringify({ ...me(), port: http.port, protocol: "http", announce: true }));
71
+ // Twice, as the app does: one datagram on a busy Wi-Fi is easy to lose.
72
+ for (const wait of [0, 600]) { await Bun.sleep(wait); sock.send(hello, PORT, GROUP); }
73
+ await Bun.sleep(Math.max(0, ms - 600));
74
+ } catch (e) { note = `discovery failed: ${e instanceof Error ? e.message : e}`; }
75
+ finally { try { sock.close(); } catch { /* closed */ } http.stop(true); }
76
+ return { devices: [...found.values()].sort((a, b) => a.alias.localeCompare(b.alias)), note };
77
+ }
78
+
79
+ export type Progress = { phase: "waiting" | "uploading"; pct: number };
80
+ export type Result = { ok: true } | { ok: false; error: string };
81
+
82
+ // One file to one device. The device shows its accept prompt during prepare-upload;
83
+ // "sent" is only the upload's 200. Self-signed certificates are accepted: LocalSend
84
+ // devices all use one, the same trust LocalSend itself gives a device on the LAN; ours goes
85
+ // back as the client certificate.
86
+ export async function sendFile(dev: Device, file: string, onProgress: (p: Progress) => void = () => {},
87
+ { acceptMs = 120_000, uploadMs = 30 * 60_000 } = {}): Promise<Result> {
88
+ const base = `${dev.protocol}://${dev.ip.includes(":") ? `[${dev.ip}]` : dev.ip}:${dev.port}/api/localsend/v2`;
89
+ const f = Bun.file(file), size = f.size, name = basename(file);
90
+ const type = name.endsWith(".mp4") ? "video/mp4" : name.endsWith(".jpg") ? "image/jpeg" : name.endsWith(".srt") ? "text/plain" : "application/octet-stream";
91
+ const { cert, key } = identity();
92
+ const tls = { rejectUnauthorized: false, cert, key };
93
+ onProgress({ phase: "waiting", pct: 0 });
94
+ let r: Response;
95
+ try {
96
+ r = await fetch(`${base}/prepare-upload`, { method: "POST", tls, signal: AbortSignal.timeout(acceptMs), headers: { "content-type": "application/json" },
97
+ body: JSON.stringify({ info: { ...me(), port: PORT, protocol: "http" }, files: { f1: { id: "f1", fileName: name, size, fileType: type } } }) } as RequestInit);
98
+ } catch (e) {
99
+ return { ok: false, error: e instanceof Error && e.name === "TimeoutError" ? `No answer from ${dev.alias} in ${Math.round(acceptMs / 60_000) || 1} minute${acceptMs > 60_000 ? "s" : ""}` : `Could not reach ${dev.alias}: ${e instanceof Error ? e.message : e}` };
100
+ }
101
+ if (r.status === 204) return { ok: false, error: `${dev.alias} needed nothing (it already has the file)` };
102
+ if (r.status === 403) return { ok: false, error: `${dev.alias} said no` };
103
+ if (r.status === 401) return { ok: false, error: `${dev.alias} asks for a PIN; turn the PIN off in its LocalSend settings` };
104
+ if (r.status === 409) return { ok: false, error: `${dev.alias} is busy with another transfer` };
105
+ if (r.status === 429) return { ok: false, error: `${dev.alias} says too many requests; try again in a minute` };
106
+ if (r.status !== 200) return { ok: false, error: `${dev.alias} answered ${r.status}` };
107
+ const s = await r.json().catch(() => null) as { sessionId?: string; files?: Record<string, string> } | null;
108
+ const token = s?.files?.f1;
109
+ if (!s?.sessionId || !token) return { ok: false, error: `${dev.alias} did not accept the file` };
110
+
111
+ // The bytes, streamed, counting as they go.
112
+ let sent = 0, last = -1;
113
+ const reader = f.stream().getReader();
114
+ const body = new ReadableStream<Uint8Array>({ async pull(c) {
115
+ const { done, value } = await reader.read();
116
+ if (done) return c.close();
117
+ sent += value.byteLength;
118
+ const pct = size ? Math.floor((sent / size) * 100) : 100;
119
+ if (pct !== last) { last = pct; onProgress({ phase: "uploading", pct }); }
120
+ c.enqueue(value);
121
+ } });
122
+ const q = new URLSearchParams({ sessionId: s.sessionId, fileId: "f1", token });
123
+ try {
124
+ const u = await fetch(`${base}/upload?${q}`, { method: "POST", tls, body, signal: AbortSignal.timeout(uploadMs),
125
+ headers: { "content-type": type, "content-length": String(size) } } as RequestInit);
126
+ if (u.status === 200) return { ok: true };
127
+ return { ok: false, error: u.status === 403 ? `${dev.alias} cancelled the transfer` : `${dev.alias} stopped the upload (${u.status})` };
128
+ } catch (e) {
129
+ return { ok: false, error: e instanceof Error && e.name === "TimeoutError" ? `The upload to ${dev.alias} took over ${Math.round(uploadMs / 60_000)} minutes` : `The upload to ${dev.alias} broke off: ${e instanceof Error ? e.message : e}` };
130
+ }
131
+ }
package/cli/notes.test.ts CHANGED
@@ -6,7 +6,7 @@ import { join, dirname } from "node:path";
6
6
  import { tmpdir } from "node:os";
7
7
  import { safeName, listNotes, saveNote, renameNote, deleteNote, boardIndex, linksTo } from "./notes.ts";
8
8
  import { wikilinks, tags, resolve, safeUrl, tree, uncode, freshName, parseTable, boardLink, resolveRun, anchorWords } from "../web/notes-lib.ts";
9
- import { sourceHash, OUT, TERM_OUT, BOARD_OUT, BOT_OUT } from "./build-web.ts";
9
+ import { sourceHash, OUT, TERM_OUT, BOARD_OUT, BOT_OUT, VIDEO_OUT } from "./build-web.ts";
10
10
 
11
11
  let state = "";
12
12
  beforeEach(() => { state = join(mkdtempSync(join(tmpdir(), "kaizen-notes-")), ".kaizen"); mkdirSync(state); });
@@ -223,15 +223,17 @@ test("the side menu is the board's on the notebook, line for line", () => {
223
223
  return s.slice(at, s.indexOf(to, at) + to.length);
224
224
  };
225
225
  for (const [from, to] of [["/* side menu:", "/* end side menu */"], ['<nav class="rail"', "</script>"]] as const)
226
- expect(between("notes.html", from, to)).toBe(between("board.html", from, to));
227
- const syms = (f: string) => ["board", "note", "link", "gear", "side", "sun", "moon"].map((n) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(`<symbol id="i-${n}"`)));
226
+ for (const page of ["notes.html", "video.html"]) expect(between(page, from, to)).toBe(between("board.html", from, to));
227
+ const syms = (f: string) => ["board", "note", "video", "link", "gear", "side", "sun", "moon"].map((n) => readFileSync(join(root, "web", f), "utf8").split("\n").find((l) => l.startsWith(`<symbol id="i-${n}"`)));
228
228
  expect(syms("notes.html")).toEqual(syms("board.html"));
229
+ expect(syms("video.html")).toEqual(syms("board.html"));
229
230
  expect(syms("board.html").every(Boolean)).toBe(true);
230
231
  });
231
232
 
232
- test("web/notebook.js, web/term.js, web/board.js and bot/web/app.js were built from the sources as they are (bun run build:web)", () => {
233
+ test("web/notebook.js, web/term.js, web/board.js, web/video.js and bot/web/app.js were built from the sources as they are (bun run build:web)", () => {
233
234
  expect(readFileSync(OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen notebook ${sourceHash()} `);
234
235
  expect(readFileSync(TERM_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen terminal ${sourceHash()} `);
235
236
  expect(readFileSync(BOARD_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen board ${sourceHash()} `);
236
237
  expect(readFileSync(BOT_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen bot page ${sourceHash()} `);
238
+ expect(readFileSync(VIDEO_OUT, "utf8").split("\n", 1)[0]!).toContain(`kaizen video page ${sourceHash()} `);
237
239
  });
package/cli/settings.ts CHANGED
@@ -125,6 +125,7 @@ const SETTINGS: Setting[] = [
125
125
  { key: "sources.model", label: "ChatGPT model for gathering", group: "Board", values: [], text: true, help: "Empty: plan default", when: ["sources.agent", "chatgpt"] },
126
126
  { key: "sources.effort", label: "ChatGPT effort for gathering", group: "Board", values: ["low", "medium", "high"], help: "", when: ["sources.agent", "chatgpt"] },
127
127
  { key: "board.projects_dir", label: "Projects folder", group: "Board", values: [], text: true, folder: true, help: "Empty: known projects only" },
128
+ { key: "board.video_dir", label: "Video library folder", group: "Board", values: [], text: true, folder: true, help: "Empty: ~/.kaizen" },
128
129
  { key: "board.open", label: "Open the board in", group: "Board", values: ["browser", "app"], help: "" },
129
130
  { key: "ui.mouse", label: "Enable mouse", group: "Terminal", values: ["false", "true"], help: "" },
130
131
  ];
package/cli/state.test.ts CHANGED
@@ -854,6 +854,43 @@ test("ownerOf: a run that recorded the launch's session id owns it, however its
854
854
  expect(ownerOf(st, { ...rec, sessionId: "other" })).toBe(null);
855
855
  });
856
856
 
857
+ test("ownerOf: a conversation that opens a follow-up run moves to it, even from a decision on its parent", () => {
858
+ const p = sessionProject(), st = join(p, ".kaizen");
859
+ const tag = (id: string, more: object = {}) => {
860
+ const f = join(st, "runs", id, "state.json");
861
+ writeFileSync(f, JSON.stringify({ ...JSON.parse(readFileSync(f, "utf8")), session: "abc", ...more }));
862
+ };
863
+ const rec = { key: "k", run: null, text: "tidy logs", cmd: "claude", sessionId: "abc", started: Date.now() - 2000 };
864
+ newRun(p, "2026-10-01-parent", "/kaizen tidy logs");
865
+ tag("2026-10-01-parent");
866
+ expect(ownerOf(st, rec)).toBe("2026-10-01-parent");
867
+ // A parent still in flight keeps its terminal.
868
+ newRun(p, "2026-10-02-child", "/kaizen rotate them too\nFollow-up of: 2026-10-01-parent");
869
+ tag("2026-10-02-child");
870
+ expect(ownerOf(st, rec)).toBe("2026-10-01-parent");
871
+ expect(ownerOf(st, { ...rec, run: "2026-10-01-parent" })).toBe("2026-10-01-parent");
872
+ tag("2026-10-01-parent", { stage: "done", awaiting: null });
873
+ expect(ownerOf(st, rec)).toBe("2026-10-02-child");
874
+ expect(ownerOf(st, { ...rec, run: "2026-10-01-parent" })).toBe("2026-10-02-child");
875
+ // A decision launched after both: the run it names, as before.
876
+ expect(ownerOf(st, { ...rec, run: "2026-10-01-parent", started: Date.now() + 5000 })).toBe("2026-10-01-parent");
877
+
878
+ // The parent's next decision still continues the conversation the child took over.
879
+ const saved = process.env.CLAUDE_CONFIG_DIR;
880
+ process.env.CLAUDE_CONFIG_DIR = mkdtempSync(join(tmpdir(), "kz-claude-"));
881
+ try {
882
+ mkdirSync(join(st, "sessions"), { recursive: true });
883
+ const { key, ...body } = rec;
884
+ writeFileSync(join(st, "sessions", key + ".json"), JSON.stringify(body) + "\n");
885
+ const t = join(process.env.CLAUDE_CONFIG_DIR, "projects", p.replace(/[^A-Za-z0-9]/g, "-"));
886
+ mkdirSync(t, { recursive: true });
887
+ writeFileSync(join(t, "abc.jsonl"), "{}\n");
888
+ expect(sessionLaunch(p, "claude", ["/bin/claude"], "/kaizen run x", "x", "2026-10-01-parent").fullCmd.slice(0, 3)).toEqual(["/bin/claude", "--resume", "abc"]);
889
+ } finally {
890
+ saved === undefined ? delete process.env.CLAUDE_CONFIG_DIR : (process.env.CLAUDE_CONFIG_DIR = saved);
891
+ }
892
+ });
893
+
857
894
  test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a PowerShell prefix on Windows", () => {
858
895
  const keys = ["PATH", "HERDR_ENV", "TMUX", "TERMINAL", "SystemRoot", "windir", "ComSpec"];
859
896
  const saved = keys.map((k) => process.env[k]);
package/cli/state.ts CHANGED
@@ -1356,21 +1356,23 @@ export function readSessions(st: string): Session[] {
1356
1356
  }
1357
1357
 
1358
1358
  // What ownerOf matches a record against, read once: each conversation id a run recorded
1359
- // at intake, to the first run (by id) that recorded it, and every run's request with
1360
- // when it was written.
1361
- type RunIndex = { bySession: Map<string, string>; requests: { id: string; born: number; text: string }[] };
1359
+ // at intake, to every run (by id) that recorded it with when its request was written,
1360
+ // and every run's request with that time.
1361
+ type RunIndex = { bySession: Map<string, { id: string; born: number; open: boolean }[]>; requests: { id: string; born: number; text: string }[] };
1362
1362
  export function runIndex(st: string): RunIndex {
1363
- const bySession = new Map<string, string>(), requests: RunIndex["requests"] = [];
1363
+ const bySession: RunIndex["bySession"] = new Map(), requests: RunIndex["requests"] = [];
1364
1364
  try {
1365
1365
  for (const id of readdirSync(join(st, "runs")).sort()) {
1366
- try {
1367
- const sid = JSON.parse(readText(join(st, "runs", id, "state.json"))).session;
1368
- if (sid && !bySession.has(sid)) bySession.set(sid, id);
1369
- } catch { /* no state yet */ }
1366
+ let born = 0;
1370
1367
  try {
1371
1368
  const f = join(st, "runs", id, "00-request.md"), st2 = statSync(f);
1372
- requests.push({ id, born: st2.birthtimeMs || st2.mtimeMs, text: normalise(readText(f)) });
1369
+ born = st2.birthtimeMs || st2.mtimeMs;
1370
+ requests.push({ id, born, text: normalise(readText(f)) });
1373
1371
  } catch { /* no request yet */ }
1372
+ try {
1373
+ const { session: sid, stage } = JSON.parse(readText(join(st, "runs", id, "state.json")));
1374
+ if (sid) bySession.set(sid, [...(bySession.get(sid) ?? []), { id, born, open: stage !== "done" && stage !== "abandoned" }]);
1375
+ } catch { /* no state yet */ }
1374
1376
  }
1375
1377
  } catch { /* no runs dir */ }
1376
1378
  return { bySession, requests };
@@ -1382,14 +1384,19 @@ export function runIndex(st: string): RunIndex {
1382
1384
  // A caller asking about many sessions passes runIndex(st) once: built per call, it
1383
1385
  // reads every run's state.json and request each time.
1384
1386
  export function ownerOf(st: string, s: Session, idx?: RunIndex): string | null {
1385
- if (s.run) return s.run;
1386
1387
  const { bySession, requests } = idx ?? runIndex(st);
1387
1388
  // The launch's own conversation id, recorded by the run at intake: holds however the
1388
- // request was worded. Text matching below is for tools that expose no id.
1389
- if (s.sessionId) {
1390
- const mine = bySession.get(s.sessionId);
1391
- if (mine) return mine;
1392
- }
1389
+ // request was worded. A conversation that opened a run after this launch (a follow-up)
1390
+ // has moved on to the newest such run, even from a decision on its parent: otherwise
1391
+ // that run never gets the terminal or a Resume. A parent still in flight keeps it: its
1392
+ // stage is not over, so the terminal is still its. Text matching below is for tools
1393
+ // that expose no id.
1394
+ const rec = s.sessionId ? bySession.get(s.sessionId) ?? [] : [];
1395
+ const later = rec.filter((r) => r.born >= s.started - 1000).sort((a, b) => a.born - b.born);
1396
+ const base = s.run ?? later[0]?.id ?? rec[0]?.id ?? null;
1397
+ const newest = later.pop();
1398
+ if (newest && !(base && base !== newest.id && rec.find((r) => r.id === base)?.open)) return newest.id;
1399
+ if (base) return base;
1393
1400
  const t = normalise(s.text);
1394
1401
  let best: string | null = null, at = Infinity;
1395
1402
  // A second of slack: file times come from a coarser clock than Date.now().
@@ -1537,8 +1544,11 @@ export function firstSession(st: string, id: string, cmd: string, cwd: string):
1537
1544
  if (!f) return null;
1538
1545
  const all = readSessions(st);
1539
1546
  const taken = new Set(all.flatMap((s) => (s.sessionId ? [s.sessionId] : []))), idx = runIndex(st);
1547
+ // Conversations this run recorded at intake: still its to continue after a follow-up
1548
+ // run it opened took the session's ownership.
1549
+ const mine = new Set([...idx.bySession].filter(([, rs]) => rs.some((r) => r.id === id)).map(([sid]) => sid));
1540
1550
  for (const s of all) {
1541
- if (s.cmd !== cmd || ownerOf(st, s, idx) !== id) continue;
1551
+ if (s.cmd !== cmd || (ownerOf(st, s, idx) !== id && !(s.sessionId && mine.has(s.sessionId)))) continue;
1542
1552
  if (!s.sessionId && f.find) {
1543
1553
  // Before the tool's next launch here, any run's: from then on a conversation may be that launch's.
1544
1554
  const next = all.find((o) => o.cmd === cmd && o.started > s.started)?.started ?? Infinity;
@@ -1610,10 +1620,13 @@ export function trustFolder(dir: string, cfg = join(process.env.CLAUDE_CONFIG_DI
1610
1620
  // where a pseudo-terminal exists; onChange is told when that agent ends.
1611
1621
  const CHILD_ENV = /^(CLAUDECODE|CLAUDE_PID|CLAUDE_CODE_(SESSION_ID|CHILD_SESSION|SESSION_ATTENDED|MESSAGING_SOCKET|MESSAGING_TOKEN))$/;
1612
1622
 
1613
- export function launchRun(it: Item, from: string, run?: string, pick: { agent?: string; model?: string; yolo?: boolean; card?: boolean; onChange?: () => void } = {}): Launch {
1614
- const projectDir = projectOf(from);
1623
+ // skill: start that skill instead of /kaizen (the Video page starts kaizen-video), in cwd:
1624
+ // a work folder that may not be a project, so it is not remembered as one. A session record is
1625
+ // written only where that folder has a .kaizen (a project place does; the library usually not).
1626
+ export function launchRun(it: Item, from: string, run?: string, pick: { agent?: string; model?: string; yolo?: boolean; card?: boolean; onChange?: () => void; skill?: "kaizen-video"; cwd?: string } = {}): Launch {
1627
+ const projectDir = pick.cwd ?? projectOf(from);
1615
1628
  // The run will be born here; the board must know the dir or the idea never retires.
1616
- remember(projectDir);
1629
+ if (!pick.cwd) remember(projectDir);
1617
1630
  const own = run && !pick.agent ? runAgent(join(projectDir, ".kaizen"), run) : null;
1618
1631
  const agent = detectDefaultAgent(projectDir, pick.agent ?? own?.cmd);
1619
1632
  if (agent.cmd === "claude") trustFolder(projectDir);
@@ -1623,7 +1636,9 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
1623
1636
  // onPath, not Bun.which: the board may have taken a login shell's PATH after it started.
1624
1637
  const agentBin = winShim(onPath(agent.cmd) ?? agent.cmd);
1625
1638
  // No text: a run's conversation reopened as it was, with nothing sent to it.
1626
- const prompt = !it.text ? "" : requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
1639
+ // A skill other than kaizen: Claude Code takes it as a slash command; other agents are told to use it.
1640
+ const lead = !pick.skill ? "/kaizen" : agent.cmd === "claude" ? `/${pick.skill}` : `Use the ${pick.skill} skill.`;
1641
+ const prompt = !it.text ? "" : requestOf(it.where ? `${lead} ${it.text} (${it.where})` : `${lead} ${it.text}`, it.notes, from);
1627
1642
  const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
1628
1643
  const model = wanted && plainModel(wanted) ? wanted : undefined;
1629
1644
  // Yolo was picked for one tool; a fallback to another opens in its normal mode.