kaizen-agent 1.41.0 → 1.41.3

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/conpty.ts ADDED
@@ -0,0 +1,99 @@
1
+ // Windows has no Bun.Terminal, so the in-card terminal there runs through a small
2
+ // ConPTY host: PowerShell compiles the C# below with Add-Type, starts the command in
3
+ // a pseudo console, and bridges it to plain pipes. Its stdout is the screen, its
4
+ // stdin the keys. A resize travels in-band on stdin as ESC ] kzr;<cols>;<rows> BEL,
5
+ // a sequence no key produces, and is taken out before the rest reaches the console.
6
+ const HOST = String.raw`
7
+ using System; using System.IO; using System.Threading; using System.Runtime.InteropServices;
8
+ public static class KzPty {
9
+ [StructLayout(LayoutKind.Sequential)] struct COORD { public short X, Y; }
10
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] struct STARTUPINFO {
11
+ public int cb; public string r, d, t; public int x, y, w, h, cx, cy, fa, fl; public short sw, r2; public IntPtr r3, i, o, e; }
12
+ [StructLayout(LayoutKind.Sequential)] struct STARTUPINFOEX { public STARTUPINFO si; public IntPtr attrs; }
13
+ [StructLayout(LayoutKind.Sequential)] struct PI { public IntPtr hp, ht; public int pid, tid; }
14
+ [DllImport("kernel32.dll", SetLastError = true)] static extern bool CreatePipe(out IntPtr r, out IntPtr w, IntPtr a, int s);
15
+ [DllImport("kernel32.dll")] static extern int CreatePseudoConsole(COORD c, IntPtr i, IntPtr o, uint f, out IntPtr h);
16
+ [DllImport("kernel32.dll")] static extern int ResizePseudoConsole(IntPtr h, COORD c);
17
+ [DllImport("kernel32.dll")] static extern void ClosePseudoConsole(IntPtr h);
18
+ [DllImport("kernel32.dll", SetLastError = true)] static extern bool InitializeProcThreadAttributeList(IntPtr l, int n, int f, ref IntPtr s);
19
+ [DllImport("kernel32.dll", SetLastError = true)] static extern bool UpdateProcThreadAttribute(IntPtr l, uint f, IntPtr a, IntPtr v, IntPtr s, IntPtr p, IntPtr r);
20
+ [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)] static extern bool CreateProcessW(string app, string cmd, IntPtr pa, IntPtr ta, bool inh, uint f, IntPtr env, string cwd, ref STARTUPINFOEX si, out PI pi);
21
+ [DllImport("kernel32.dll")] static extern uint WaitForSingleObject(IntPtr h, uint ms);
22
+ [DllImport("kernel32.dll")] static extern bool GetExitCodeProcess(IntPtr h, out int c);
23
+ [DllImport("kernel32.dll")] static extern bool CloseHandle(IntPtr h);
24
+ static COORD Size(int c, int r) { COORD s; s.X = (short)c; s.Y = (short)r; return s; }
25
+ static FileStream Of(IntPtr h, FileAccess a) { return new FileStream(new Microsoft.Win32.SafeHandles.SafeFileHandle(h, true), a); }
26
+ public static int Run(string cmd, int cols, int rows) {
27
+ IntPtr inR, inW, outR, outW, hpc;
28
+ if (!CreatePipe(out inR, out inW, IntPtr.Zero, 0) || !CreatePipe(out outR, out outW, IntPtr.Zero, 0)) return 1;
29
+ int hr = CreatePseudoConsole(Size(cols, rows), inR, outW, 0, out hpc);
30
+ if (hr != 0) { Console.Error.WriteLine("CreatePseudoConsole failed: " + hr); return 1; }
31
+ IntPtr sz = IntPtr.Zero;
32
+ InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref sz);
33
+ var si = new STARTUPINFOEX(); si.si.cb = Marshal.SizeOf(typeof(STARTUPINFOEX));
34
+ // STARTF_USESTDHANDLES with none: otherwise the child takes this host's piped stdio, not the console.
35
+ si.si.fl = 0x100;
36
+ si.attrs = Marshal.AllocHGlobal(sz);
37
+ if (!InitializeProcThreadAttributeList(si.attrs, 1, 0, ref sz)
38
+ || !UpdateProcThreadAttribute(si.attrs, 0, (IntPtr)0x20016, hpc, (IntPtr)IntPtr.Size, IntPtr.Zero, IntPtr.Zero)) return 1;
39
+ PI pi;
40
+ if (!CreateProcessW(null, cmd, IntPtr.Zero, IntPtr.Zero, false, 0x80000, IntPtr.Zero, null, ref si, out pi)) {
41
+ Console.Error.WriteLine("CreateProcess failed: " + Marshal.GetLastWin32Error()); return 1; }
42
+ // The console holds its own copies; ours would keep the pipes open past its end.
43
+ CloseHandle(inR); CloseHandle(outW);
44
+ var input = Of(inW, FileAccess.Write); var output = Of(outR, FileAccess.Read);
45
+ var stdout = Console.OpenStandardOutput();
46
+ var pump = new Thread(() => {
47
+ var b = new byte[16384]; int n;
48
+ try { while ((n = output.Read(b, 0, b.Length)) > 0) { stdout.Write(b, 0, n); stdout.Flush(); } } catch { }
49
+ });
50
+ pump.IsBackground = true; pump.Start();
51
+ var keys = new Thread(() => {
52
+ var stdin = Console.OpenStandardInput(); var b = new byte[4096]; int n;
53
+ var held = new MemoryStream();
54
+ try {
55
+ while ((n = stdin.Read(b, 0, b.Length)) > 0) {
56
+ // ponytail: a UTF-8 character split across two reads decodes as U+FFFD; hold incomplete tails if that shows up.
57
+ held.Write(b, 0, n);
58
+ var s = System.Text.Encoding.UTF8.GetString(held.ToArray());
59
+ // A resize cut in half by the pipe waits for its other half.
60
+ int keep = s.LastIndexOf("\x1b]kzr;");
61
+ if (keep >= 0 && s.IndexOf('\x07', keep) < 0) { held = new MemoryStream(System.Text.Encoding.UTF8.GetBytes(s.Substring(keep))); s = s.Substring(0, keep); }
62
+ else held = new MemoryStream();
63
+ s = System.Text.RegularExpressions.Regex.Replace(s, "\x1b]kzr;(\\d+);(\\d+)\x07", m => {
64
+ ResizePseudoConsole(hpc, Size(int.Parse(m.Groups[1].Value), int.Parse(m.Groups[2].Value))); return ""; });
65
+ var o = System.Text.Encoding.UTF8.GetBytes(s);
66
+ if (o.Length > 0) { input.Write(o, 0, o.Length); input.Flush(); }
67
+ }
68
+ } catch { }
69
+ });
70
+ keys.IsBackground = true; keys.Start();
71
+ WaitForSingleObject(pi.hp, 0xFFFFFFFF);
72
+ int code; GetExitCodeProcess(pi.hp, out code);
73
+ // Closing the console flushes its last frame into the pump, then ends it.
74
+ ClosePseudoConsole(hpc);
75
+ pump.Join(2000);
76
+ return code;
77
+ }
78
+ }`;
79
+
80
+ // The PowerShell that hosts it: the child's command line, size and directory come in
81
+ // through the environment, so nothing the agent is given passes through a quoting layer.
82
+ const SCRIPT = `Add-Type -TypeDefinition @'
83
+ ${HOST}
84
+ '@
85
+ Set-Location -LiteralPath $env:KZ_PTY_CWD
86
+ exit [KzPty]::Run($env:KZ_PTY_CMD, [int]$env:KZ_PTY_COLS, [int]$env:KZ_PTY_ROWS)`;
87
+
88
+ export const conptyHost = () => Buffer.from(SCRIPT, "utf16le").toString("base64");
89
+
90
+ export const resizeSeq = (cols: number, rows: number) => `\x1b]kzr;${cols};${rows}\x07`;
91
+
92
+ // The command line the pseudo console starts: PowerShell running cmd. It finds .cmd
93
+ // shims (npm installs claude as one), which CreateProcess alone does not, and takes
94
+ // the arguments as -EncodedCommand, as findTerminal does, so newlines survive.
95
+ export function innerCommand(shell: string, cmd: string[]): string {
96
+ const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
97
+ const enc = Buffer.from(`& ${cmd.map(q).join(" ")}`, "utf16le").toString("base64");
98
+ return `"${shell}" -NoProfile -EncodedCommand ${enc}`;
99
+ }
package/cli/install.ts CHANGED
@@ -576,7 +576,8 @@ if (upgrade) {
576
576
  // is not a failed upgrade: the version on disk is what decides.
577
577
  if (after !== latest) {
578
578
  console.log(`\n ${c.bold(`could not install kaizen ${latest}`)}`);
579
- console.log(` ${c.dim("try:")} ${c.cyan(`bunx kaizen-agent@${latest}`)}\n`);
579
+ // npm names a new version before it serves it (its review takes minutes).
580
+ console.log(` ${c.dim("npm may still be publishing it: try again in a few minutes, or run")} ${c.cyan(`bunx kaizen-agent@${latest}`)}\n`);
580
581
  process.exit(1);
581
582
  }
582
583
  console.log(`\n ${c.bold("Upgraded")} ${c.dim(now)} → ${c.green(after)}\n ${c.dim("Restart Claude Code to pick up any new slash commands.")}\n`);
package/cli/pty.test.ts CHANGED
@@ -5,7 +5,8 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, realpa
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import { launchMode, closeSessions, readSessions, sweepSessions, CLOSE_GRACE_MS } from "./state.ts";
8
- import { loginEnv, startPty, writePty } from "./pty.ts";
8
+ import { loginEnv, startPty, writePty, attachPty, ptyState } from "./pty.ts";
9
+ import { conptyHost, innerCommand, resizeSeq } from "./conpty.ts";
9
10
 
10
11
  const root = realpathSync(mkdtempSync(join(tmpdir(), "kaizen-pty-")));
11
12
 
@@ -194,3 +195,27 @@ test("asking: Claude Code and Codex prompts count as a question, ordinary output
194
195
  expect(asking("Do you want to make this edit to cli/pty.ts?")).toBe(true);
195
196
  expect(asking("Reading cli/pty.ts\r\n\x1b[2K✻ Thinking… 1. step one done")).toBe(false);
196
197
  });
198
+
199
+ test("Windows ConPTY host: resize travels in-band, the agent command survives quotes and newlines", () => {
200
+ expect(resizeSeq(100, 40)).toBe("\x1b]kzr;100;40\x07");
201
+ const line = innerCommand("C:\\ps.exe", ["claude", "it's\nhere"]);
202
+ const enc = line.split(" ").pop()!;
203
+ expect(line.startsWith('"C:\\ps.exe" -NoProfile -EncodedCommand ')).toBe(true);
204
+ expect(Buffer.from(enc, "base64").toString("utf16le")).toBe("& 'claude' 'it''s\nhere'");
205
+ // The host script decodes to PowerShell carrying the C# whole.
206
+ expect(Buffer.from(conptyHost(), "base64").toString("utf16le")).toContain("CreatePseudoConsole");
207
+ });
208
+
209
+ test.skipIf(process.platform !== "win32")("on Windows a card agent runs in a pseudo console and takes typed input", async () => {
210
+ const key = `win-${Date.now()}`;
211
+ const out: string[] = [];
212
+ startPty(key, ["cmd.exe", "/q", "/k", "echo ready"], root);
213
+ attachPty(key, (d) => { if (d) out.push(d); });
214
+ const until = async (re: RegExp) => { for (let i = 0; i < 300 && !re.test(out.join("")); i++) await Bun.sleep(100); return out.join(""); };
215
+ expect(await until(/ready/)).toContain("ready");
216
+ writePty(key, "echo typed-%OS%\r");
217
+ expect(await until(/typed-Windows_NT/)).toContain("typed-Windows_NT");
218
+ writePty(key, "exit\r");
219
+ for (let i = 0; i < 100 && ptyState(key)?.alive; i++) await Bun.sleep(100);
220
+ expect(ptyState(key)?.alive).toBe(false);
221
+ }, 60_000);
package/cli/pty.ts CHANGED
@@ -1,8 +1,15 @@
1
1
  // Agents the web board runs itself, each in a pseudo-terminal the board owns, so a
2
2
  // card can show the agent's screen and pass it the user's typing (agent.launch: card).
3
3
  // They live in this process: the board stopping ends them, as closing a terminal
4
- // window ends the agent in it. Bun has no pseudo-terminal on Windows.
4
+ // window ends the agent in it. Bun has no pseudo-terminal on Windows; there the
5
+ // agent runs under a ConPTY host (conpty.ts) spoken to over pipes.
5
6
  import { writeFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { conptyHost, innerCommand, resizeSeq } from "./conpty.ts";
9
+
10
+ const WIN = process.platform === "win32";
11
+ // Windows PowerShell, which every Windows has, both hosts the console and runs the agent in it.
12
+ const winPs = () => join(process.env.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
6
13
 
7
14
  type Pty = { proc: Bun.Subprocess; out: string[]; size: number; listeners: Set<(d: string | null) => void>; exit: number | null; asking: boolean; quiet?: Timer; total: number; since: number; onChange?: () => void };
8
15
 
@@ -24,7 +31,7 @@ export const asking = (screen: string) =>
24
31
  const QUIET_MS = 700;
25
32
  // Bun gained pseudo-terminals in 1.3.5; an older one spawns without a tty, so it
26
33
  // keeps opening terminal windows.
27
- export const ptySupported = process.platform !== "win32" && typeof (Bun as any).Terminal === "function";
34
+ export const ptySupported = WIN || typeof (Bun as any).Terminal === "function";
28
35
 
29
36
  // Starts cmd in a new pseudo-terminal under `key` and returns its PID. pidFile gets
30
37
  // that PID, the agent's own, so closeSessions can end it like any launched session.
@@ -46,7 +53,21 @@ export function startPty(key: string, cmd: string[], cwd: string, pidFile?: stri
46
53
  flag(p.exit === null && n > 0 && asking(p.out.join("").slice(-n)));
47
54
  }, QUIET_MS);
48
55
  };
49
- p.proc = Bun.spawn(cmd, {
56
+ if (WIN) {
57
+ const ps = winPs();
58
+ p.proc = Bun.spawn([ps, "-NoProfile", "-NonInteractive", "-EncodedCommand", conptyHost()], {
59
+ cwd,
60
+ env: { ...process.env, KZ_PTY_CMD: innerCommand(ps, cmd), KZ_PTY_CWD: cwd, KZ_PTY_COLS: "120", KZ_PTY_ROWS: "32" },
61
+ stdin: "pipe", stdout: "pipe", stderr: "pipe", windowsHide: true,
62
+ });
63
+ const read = async (s: ReadableStream<Uint8Array>) => {
64
+ const d = new TextDecoder();
65
+ for await (const c of s) push(d.decode(c, { stream: true }));
66
+ };
67
+ read(p.proc.stdout as ReadableStream<Uint8Array>).catch(() => {});
68
+ // The host's own failures (Add-Type, CreateProcess) land on the screen, not nowhere.
69
+ read(p.proc.stderr as ReadableStream<Uint8Array>).catch(() => {});
70
+ } else p.proc = Bun.spawn(cmd, {
50
71
  cwd,
51
72
  env: { ...process.env, TERM: "xterm-256color", COLORTERM: "truecolor" },
52
73
  terminal: { cols: 120, rows: 32, data: (_t, d) => push(dec.decode(d, { stream: true })) },
@@ -82,13 +103,18 @@ export function writePty(key: string, d: string) {
82
103
  if (!p || p.exit !== null) return;
83
104
  p.since = p.total;
84
105
  if (p.asking) { p.asking = false; p.onChange?.(); }
85
- p.proc.terminal?.write(d);
106
+ send(p, d);
86
107
  }
87
108
 
109
+ const send = (p: Pty, d: string) => {
110
+ if (!WIN) return p.proc.terminal?.write(d);
111
+ try { (p.proc.stdin as Bun.FileSink).write(d); (p.proc.stdin as Bun.FileSink).flush(); } catch { /* host gone */ }
112
+ };
113
+
88
114
  export function resizePty(key: string, cols: number, rows: number) {
89
115
  const p = ptys.get(key);
90
116
  const c = Math.max(10, Math.min(500, cols | 0)), r = Math.max(5, Math.min(300, rows | 0));
91
- if (p && p.exit === null) p.proc.terminal?.resize(c, r);
117
+ if (p && p.exit === null) WIN ? send(p, resizeSeq(c, r)) : p.proc.terminal?.resize(c, r);
92
118
  }
93
119
 
94
120
  export const ptyKeys = () => [...ptys.keys()];
package/cli/state.test.ts CHANGED
@@ -93,6 +93,22 @@ test("boardCards: a started idea stays until its run has a state.json", () => {
93
93
  rmSync(join(dir, ".."), { recursive: true, force: true });
94
94
  });
95
95
 
96
+ test("boardCards: a started idea retires into the run its launch's session wrote, however reworded", () => {
97
+ const dir = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
98
+ const r = join(dir, "runs", "2026-01-01-reworded");
99
+ mkdirSync(r, { recursive: true });
100
+ mkdirSync(join(dir, "sessions"));
101
+ writeInbox(dir, [{ status: "started", text: "revision" }]);
102
+ writeFileSync(join(r, "00-request.md"), "- add a form for the icebox\n");
103
+ writeFileSync(join(dir, "sessions", "1-a.json"), JSON.stringify({ run: null, text: "revision", cmd: "claude", sessionId: "abc", started: Date.now() }));
104
+ const cards = () => boardCards([dir], Date.now()).map((k) => k.kind);
105
+ writeFileSync(join(r, "state.json"), JSON.stringify({ id: "2026-01-01-reworded", stage: "build", awaiting: null }));
106
+ expect(cards()).toEqual(["idea", "run"]);
107
+ writeFileSync(join(r, "state.json"), JSON.stringify({ id: "2026-01-01-reworded", stage: "build", awaiting: null, session: "abc" }));
108
+ expect(cards()).toEqual(["run"]);
109
+ rmSync(join(dir, ".."), { recursive: true, force: true });
110
+ });
111
+
96
112
  test("boardCards: a run older than the idea does not retire it", () => {
97
113
  const dir = join(mkdtempSync(join(tmpdir(), "kaizen-")), ".kaizen");
98
114
  const r = join(dir, "runs", "2026-01-01-old");
package/cli/state.ts CHANGED
@@ -616,6 +616,7 @@ export type Card = {
616
616
  awaiting: string | null;
617
617
  dim: boolean;
618
618
  moved?: number; // runs only: state.json mtime
619
+ added?: number; // ideas only: when the inbox line first appeared (ms)
619
620
  title?: string; // runs only: first line of the request
620
621
  archived: boolean; // hidden from the board unless asked for
621
622
  branch?: string; // runs only: the run's own branch while it has a worktree
@@ -1001,6 +1002,9 @@ export function boardCards(states: string[], now: number): Card[] {
1001
1002
  for (const l of b.split("\n")) lines.push({ at, text: normalise(l.replace(/^[\s>*"'`-]+|[\s"'`.]+$/g, "")) });
1002
1003
  }
1003
1004
  }
1005
+ // A launched idea whose run reworded the request: the board's own launch record,
1006
+ // owned by a run (session id in its state.json, see ownerOf), retires it all the same.
1007
+ const launchedInto = (st: string, t: string) => readSessions(st).some((s) => !s.run && normalise(s.text.replace(/^(lite|full|auto|plan)\s+/i, "")) === t.replace(/^(lite|full|auto|plan)\s+/i, "") && ownerOf(st, s) !== null);
1004
1008
  for (const st of states) {
1005
1009
  const where = label(st);
1006
1010
  const archive = readArchive(st);
@@ -1019,10 +1023,10 @@ export function boardCards(states: string[], now: number): Card[] {
1019
1023
  // titled "fix this bugs" must not swallow a new idea with the same words.
1020
1024
  // ponytail: 2s slack for coarse filesystem clocks.
1021
1025
  const after = (r: { at: number }) => !it.added || r.at >= it.added - 2000;
1022
- if (it.status === "started" ? requests.some((r) => after(r) && r.text.includes(t)) : lines.some((l) => after(l) && (l.text === t || l.text.endsWith(" " + t)))) continue;
1026
+ if (it.status === "started" ? requests.some((r) => after(r) && r.text.includes(t)) || launchedInto(st, t) : lines.some((l) => after(l) && (l.text === t || l.text.endsWith(" " + t)))) continue;
1023
1027
  next.push(it.status === "started"
1024
1028
  ? { kind: "idea", status: "starting", text: it.text, state: st, where, column: 1, awaiting: null, dim: true, archived: false, notes: it.notes, parent: parentOf(it.notes) }
1025
- : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes, parent: parentOf(it.notes) });
1029
+ : { kind: "idea", status: "idea", text: it.text, state: st, where, column: 0, awaiting: null, dim: false, archived: it.status === "archived", notes: it.notes, parent: parentOf(it.notes), added: it.added });
1026
1030
  }
1027
1031
  // Runs started in Yolo. Only yolo records are matched to runs, so a project that
1028
1032
  // never used it pays one directory read.
package/cli/web.ts CHANGED
@@ -815,6 +815,15 @@ export async function web(repoDir: string, opts: Opts = {}) {
815
815
  } catch (e) { return bad((e as Error).message, 500); }
816
816
  }
817
817
 
818
+ // Files added with a hand-written icebox item: saved beside the state, linked in its notes.
819
+ const iceFiles = (state: string, files: unknown) => {
820
+ const fs: { name: string; data: string }[] = Array.isArray(files) ? files : [];
821
+ if (fs.reduce((t, f) => t + (f.data?.length ?? 0), 0) > 20_000_000) throw new Error("attachments over 15MB");
822
+ return fs.map((f) => {
823
+ const rel = saveAttachment(state, null, String(f.name ?? "file"), Buffer.from(String(f.data).replace(/^data:[^,]*,/, ""), "base64"));
824
+ return (/\.(png|jpe?g|gif|webp|svg)$/i.test(rel) ? "!" : "") + `[${String(f.name ?? "file")}](${rel})`;
825
+ });
826
+ };
818
827
  // The icebox: the only way gathered text becomes an idea, one explicit click
819
828
  // (or agent call) at a time. An idea is made; no run starts.
820
829
  if (path === "/icebox") {
@@ -822,7 +831,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
822
831
  const ids = Array.isArray(body.ids) ? body.ids.filter((i: unknown) => typeof i === "string") : [];
823
832
  try {
824
833
  const why = body.op === "promote" ? promote(body.dir, ids, typeof body.text === "string" ? body.text : undefined, typeof body.details === "string" ? body.details : undefined)
825
- : body.op === "add" ? addItem(body.dir, typeof body.text === "string" ? body.text : "", typeof body.details === "string" ? body.details : "")
834
+ : body.op === "add" ? addItem(body.dir, typeof body.text === "string" ? body.text : "", [typeof body.details === "string" ? body.details : "", ...iceFiles(body.dir, body.files)].filter(Boolean).join("\n"))
826
835
  : body.op === "dismiss" ? dismiss(body.dir, ids) : body.op === "restore" ? restore(body.dir, ids) : "unknown op";
827
836
  if (why) return bad(why, why === "no such item" ? 404 : why === "unknown op" ? 400 : 409);
828
837
  } catch (e) { return bad((e as Error).message, 500); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.41.0",
3
+ "version": "1.41.3",
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": {
@@ -341,7 +341,9 @@ changing because a setting moved:
341
341
  ```
342
342
 
343
343
  Every run writes all nine keys, from intake on: `id`, `mode`, `runner`, `agent`,
344
- `track`, `stage`, `awaiting`, `iteration` (0 until the fix loop runs), `updated`. The
344
+ `track`, `stage`, `awaiting`, `iteration` (0 until the fix loop runs), `updated`. Where
345
+ the environment has `CLAUDE_CODE_SESSION_ID`, also `session` with its value: without it
346
+ a card launched from the board can stay stuck in planning beside its own run. The
345
347
  legal values of `stage` and `awaiting` are in [`spec.md`](spec.md).
346
348
 
347
349
  In a git repository, `init` also adds a gitignore entry that ignores run state but
@@ -168,8 +168,8 @@ agent:
168
168
  # Where the web board runs the agent it launches.
169
169
  # card - inside the board: the card's Terminal tab shows the agent live and
170
170
  # takes your typing. The agent lives in the board's process, so
171
- # stopping or upgrading the board ends it. Not on Windows, where it
172
- # falls back to a terminal window.
171
+ # stopping or upgrading the board ends it. On Windows it runs in
172
+ # a ConPTY console that Windows PowerShell hosts.
173
173
  # terminal - a new terminal window (or tmux/herdr tab) per launch.
174
174
  # The terminal dashboard always opens terminal windows.
175
175
  launch: card
@@ -2,7 +2,7 @@ import { store } from "./store.ts";
2
2
  import { where } from "./agent-term.ts";
3
3
  import { load, post } from "./api.ts";
4
4
  import { mic, stopDictation } from "./dictation.ts";
5
- import { $, esc, ico } from "./dom.ts";
5
+ import { $, dataUrls, esc, ico } from "./dom.ts";
6
6
  import { draft, undraft } from "./drafts.ts";
7
7
  import { COLS, ICON, MARKS, NAMES, WORD, age, d, need } from "./labels.ts";
8
8
  import { brief } from "./markdown.ts";
@@ -502,13 +502,15 @@ export function render() {
502
502
  (archive || !nested(c)) &&
503
503
  (!q || `${c.title || ""} ${c.text || ""} ${c.id || ""}`.toLowerCase().includes(q)),
504
504
  );
505
- // Runs age by their last move, ideas by inbox position (new ones are appended).
505
+ // Runs age by their last move, ideas by when they were added, across every project;
506
+ // an idea from before the stamp falls back to its inbox position.
506
507
  if (ci < 4) {
507
- const at = (c) => (c.kind === "run" ? c.moved || 0 : store.S.cards.indexOf(c)),
508
- old = order[ci] === "old";
508
+ const at = (c) => (c.kind === "run" ? c.moved || 0 : c.added || 0),
509
+ old = order[ci] === "old",
510
+ pos = (c) => store.S.cards.indexOf(c);
509
511
  // Runs waiting on you go first, then the column's own order.
510
512
  const w = (c) => (c.status === "waiting" ? 1 : 0);
511
- list.sort((a, b) => w(b) - w(a) || (old ? at(a) - at(b) : at(b) - at(a)));
513
+ list.sort((a, b) => w(b) - w(a) || (old ? at(a) - at(b) || pos(a) - pos(b) : at(b) - at(a) || pos(b) - pos(a)));
512
514
  }
513
515
  const clearable = ci === 4 && !archive && list.length;
514
516
  const olderThan = (days) => list.filter((c) => store.S.now - (c.moved || store.S.now) > days * d);
@@ -607,16 +609,7 @@ export function render() {
607
609
  toast("Idea added");
608
610
  // The idea exists first: a failed upload leaves it as plain text.
609
611
  if (files.length) {
610
- const enc = await Promise.all(
611
- files.map(
612
- (x) =>
613
- new Promise((res) => {
614
- const rd = new FileReader();
615
- rd.onload = () => res({ name: x.name, data: rd.result });
616
- rd.readAsDataURL(x);
617
- }),
618
- ),
619
- );
612
+ const enc = await dataUrls(files);
620
613
  const n = await post("/note", { dir, for: text, text: "", files: enc });
621
614
  if (!n.ok) toast("Idea added, files not attached");
622
615
  }
package/web/board/dom.ts CHANGED
@@ -9,3 +9,30 @@ export const SKEL =
9
9
  '<span class="skel" style="height:16px;width:35%;margin:20px 0 14px"></span>' +
10
10
  '<span class="skel" style="height:44px;margin-bottom:10px"></span>'.repeat(4) +
11
11
  "</div>";
12
+ // Files as {name, data-URL}, the shape /note and /icebox take.
13
+ export const dataUrls = (files: File[]) =>
14
+ Promise.all(
15
+ files.map(
16
+ (x) =>
17
+ new Promise<{ name: string; data: string }>((res) => {
18
+ const rd = new FileReader();
19
+ rd.onload = () => res({ name: x.name, data: rd.result as string });
20
+ rd.readAsDataURL(x);
21
+ }),
22
+ ),
23
+ );
24
+ // A modal form: the native <dialog>, removed from the page once closed.
25
+ export function modal(html: string) {
26
+ const dl = document.createElement("dialog");
27
+ dl.className = "kz";
28
+ dl.innerHTML = `<form class="inl" method="dialog">${html}</form>`;
29
+ dl.onclose = () => dl.remove();
30
+ // A tap on the backdrop. Braces matter: an on* handler that returns false cancels the click,
31
+ // which stopped every submit button inside the form.
32
+ dl.onclick = (e) => {
33
+ if (e.target === dl) dl.close();
34
+ };
35
+ document.body.appendChild(dl);
36
+ dl.showModal();
37
+ return dl;
38
+ }
@@ -2,9 +2,9 @@ import { store } from "./store.ts";
2
2
  import { termStop } from "./agent-term.ts";
3
3
  import { load, post } from "./api.ts";
4
4
  import { via } from "./cards.ts";
5
- import { stopDictation } from "./dictation.ts";
6
- import { $, esc, ico, SKEL } from "./dom.ts";
7
- import { plogo, setShown, short, showable } from "./projects.ts";
5
+ import { mic, stopDictation } from "./dictation.ts";
6
+ import { $, dataUrls, esc, ico, modal, SKEL } from "./dom.ts";
7
+ import { ideaCombo, newDir, plogo, setShown, short, showable } from "./projects.ts";
8
8
  import { toast } from "./toast.ts";
9
9
  import { go, view } from "./views.ts";
10
10
 
@@ -988,13 +988,57 @@ export async function openSources() {
988
988
  };
989
989
  }
990
990
  if ($("ice-new"))
991
- $("ice-new").onclick = async () => {
992
- const to = dir || showable()[0]?.dir;
993
- if (!to) return toast("Pick a project first");
994
- const text = prompt(dir ? "New request for the icebox:" : `New request for ${short(showable()[0].label)}:`)?.trim();
995
- if (!text) return;
996
- const j = await post("/icebox", { op: "add", dir: to, text });
997
- if (j.ok) toast("Added to the icebox");
991
+ $("ice-new").onclick = () => {
992
+ const all = !dir;
993
+ if (!dir && !showable().length) return toast("Pick a project first");
994
+ // The new-idea form, cut down: the request, files, dictation and, in All projects, the project.
995
+ const dl = modal(
996
+ `${all ? `<div class="combo" id="ndc"><button type="button" class="sel" id="newdir" aria-label="Project for this request" aria-haspopup="listbox" aria-expanded="false"></button><div class="menu-pop" hidden><input class="sel" aria-label="Search projects" placeholder="Search projects…" spellcheck="false" autocomplete="off"><div id="nds" role="listbox"></div></div></div>` : ""}<textarea id="newidea" placeholder="New request for the icebox" aria-label="Request"></textarea><div class="att"></div><input type="file" multiple hidden><div class="row"><button type="button" class="ib" id="newpick" aria-label="Attach files" title="Attach files">${ico("clip")}</button><button type="button" class="btn ghost" value="">Cancel</button><button class="btn pri">Add</button></div>`,
997
+ );
998
+ const f = dl.querySelector("form"),
999
+ inp = f.querySelector("textarea"),
1000
+ files = [];
1001
+ const add = (x) => {
1002
+ files.push(x);
1003
+ f.querySelector(".att").textContent = files.map((y) => y.name).join(" · ");
1004
+ };
1005
+ f.querySelector("#newpick").onclick = () => f.querySelector("input[type=file]").click();
1006
+ f.querySelector("input[type=file]").onchange = (e) => {
1007
+ [...e.target.files].forEach(add);
1008
+ e.target.value = "";
1009
+ };
1010
+ inp.onpaste = (e) => {
1011
+ for (const it of e.clipboardData.items)
1012
+ if (it.kind === "file") {
1013
+ const x = it.getAsFile();
1014
+ add(new File([x], x.name === "image.png" ? `paste-${Date.now()}.png` : x.name, { type: x.type }));
1015
+ e.preventDefault();
1016
+ }
1017
+ };
1018
+ f.querySelector(".btn.ghost").onclick = () => dl.close();
1019
+ if (all) ideaCombo(f);
1020
+ const m = mic([inp]);
1021
+ if (m) f.querySelector(".row").prepend(m);
1022
+ dl.addEventListener("close", () => stopDictation(false));
1023
+ inp.onkeydown = (e) => {
1024
+ if (e.key === "Enter" && !e.shiftKey) {
1025
+ e.preventDefault();
1026
+ f.requestSubmit();
1027
+ }
1028
+ };
1029
+ f.onsubmit = async (e) => {
1030
+ e.preventDefault();
1031
+ const text = inp.value.trim(),
1032
+ to = dir || newDir;
1033
+ if (!text) return inp.focus();
1034
+ if (!to) return toast("Pick a project first");
1035
+ const j = await post("/icebox", { op: "add", dir: to, text, files: await dataUrls(files) });
1036
+ if (j.ok) {
1037
+ toast("Added to the icebox");
1038
+ dl.close();
1039
+ }
1040
+ };
1041
+ inp.focus();
998
1042
  };
999
1043
  if ($("src-go"))
1000
1044
  $("src-go").onclick = () => {
@@ -1,7 +1,7 @@
1
1
  import { store } from "./store.ts";
2
2
  import { load, post } from "./api.ts";
3
3
  import { render } from "./cards.ts";
4
- import { $, esc, ico } from "./dom.ts";
4
+ import { $, esc, ico, modal } from "./dom.ts";
5
5
  import { openSources, srcOpen } from "./icebox.ts";
6
6
  import { toast } from "./toast.ts";
7
7
  import { view } from "./views.ts";
@@ -56,24 +56,49 @@ async function pick(v) {
56
56
  const j = await post("/find", {});
57
57
  if (j.ok) toast(j.added ? `Found ${j.added} new project${j.added === 1 ? "" : "s"}` : "No new projects found");
58
58
  } else if (v === "__clone") {
59
- const url = prompt("GitHub link to clone (https or ssh):")?.trim();
60
- if (!url) return;
61
- const d = await post("/clone", { url, check: true });
62
- if (!d.ok) return;
63
- let to = prompt("Clone into (a new folder is created). Leave empty to pick a folder:", d.to);
64
- if (to === null) return;
65
- if (!to.trim()) {
66
- const f = await post("/pick-folder", {});
67
- if (!f.path) return;
68
- to = f.path.replace(/[\\/]+$/, "") + (f.path.includes("\\") ? "\\" : "/") + d.name;
69
- }
70
- toast("Cloning…");
71
- const j = await post("/clone", { url, to });
72
- if (!j.ok) return;
73
- await post("/cd", { dir: j.dir });
59
+ // One form: the link, then where it goes. The folder fills in once the link names a repo.
60
+ const dl = modal(
61
+ `<b>Clone from GitHub</b><input name="url" placeholder="https://github.com/owner/repo or git@github.com:owner/repo" aria-label="GitHub link (https or ssh)" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Clone into (a new folder is made)" aria-label="Clone into" spellcheck="false" autocomplete="off"><button type="button" class="btn ghost" data-a="pick">Pick…</button></div><div class="row"><button type="button" class="btn ghost" data-a="x">Cancel</button><button class="btn pri">Clone</button></div>`,
62
+ );
63
+ const f = dl.querySelector("form"),
64
+ url = f.querySelector("[name=url]"),
65
+ to = f.querySelector("[name=to]");
66
+ let name = "",
67
+ auto = ""; // the folder this form filled in; one the user typed is left alone
68
+ const check = async () => {
69
+ const u = url.value.trim();
70
+ if (!u) return null;
71
+ const d = await post("/clone", { url: u, check: true });
72
+ if (!d.ok) return null;
73
+ name = d.name;
74
+ if (!to.value.trim() || to.value === auto) to.value = auto = d.to;
75
+ return d;
76
+ };
77
+ url.onchange = check;
78
+ f.querySelector("[data-a=x]").onclick = () => dl.close();
79
+ f.querySelector("[data-a=pick]").onclick = async () => {
80
+ if (!(await check())) return url.focus();
81
+ const p = await post("/pick-folder", {});
82
+ if (p.path) to.value = p.path.replace(/[\\/]+$/, "") + (p.path.includes("\\") ? "\\" : "/") + name;
83
+ };
84
+ url.focus();
85
+ const dir = await new Promise((done) => {
86
+ dl.addEventListener("close", () => done(null));
87
+ f.onsubmit = async (e) => {
88
+ e.preventDefault();
89
+ if (!(await check())) return url.focus();
90
+ toast("Cloning…");
91
+ const j = await post("/clone", { url: url.value.trim(), to: to.value.trim() || undefined });
92
+ if (!j.ok) return;
93
+ done(j.dir);
94
+ dl.close();
95
+ };
96
+ });
97
+ if (!dir) return;
98
+ await post("/cd", { dir });
74
99
  store.all = false;
75
- store.dir = j.dir;
76
- keep(j.dir);
100
+ store.dir = dir;
101
+ keep(dir);
77
102
  toast("Cloned");
78
103
  } else if (v === "all") {
79
104
  store.all = true;
package/web/board.html CHANGED
@@ -219,6 +219,7 @@ section li.hint{display:block;padding:14px;border:1px dashed var(--line);border-
219
219
  .inl input::placeholder,.inl textarea::placeholder,.notes textarea::placeholder{color:var(--dim)}
220
220
  .inl .row,.notes .row{display:flex;gap:6px;justify-content:flex-end;align-items:center}
221
221
  .inl .row .mic{margin-right:0}.inl .row #newpick{margin-right:auto}.inl .row .btn{white-space:nowrap}.inl .att{font-size:12px;color:var(--dim)}.inl .att{font-size:12px;color:var(--dim)}.inl .att:empty{display:none}
222
+ dialog.kz{border:0;padding:0;background:none;width:min(480px,calc(100vw - 32px));max-height:calc(100dvh - 32px);overflow:visible;color:var(--ink)}dialog.kz::backdrop{background:rgb(0 0 0/.45)}dialog.kz .row input{flex:1;min-width:0}dialog.kz #ndc{align-self:flex-start}
222
223
  .inl .run-actions{display:grid;gap:8px}.inl .run-actions .row{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}.inl .run-actions .pri{grid-column:1/-1}
223
224
  .notes{display:flex;flex-direction:column;gap:8px}
224
225
  #pbody img{max-width:100%;border-radius:var(--r-s)}