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 +99 -0
- package/cli/install.ts +2 -1
- package/cli/pty.test.ts +26 -1
- package/cli/pty.ts +31 -5
- package/cli/state.test.ts +16 -0
- package/cli/state.ts +6 -2
- package/cli/web.ts +10 -1
- package/package.json +1 -1
- package/skills/kaizen/SKILL.md +3 -1
- package/skills/kaizen/config.default.yml +2 -2
- package/web/board/cards.ts +8 -15
- package/web/board/dom.ts +27 -0
- package/web/board/icebox.ts +54 -10
- package/web/board/projects.ts +43 -18
- package/web/board.html +1 -0
- package/web/board.js +18 -18
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -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`.
|
|
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.
|
|
172
|
-
#
|
|
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
|
package/web/board/cards.ts
CHANGED
|
@@ -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
|
|
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 :
|
|
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
|
|
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
|
+
}
|
package/web/board/icebox.ts
CHANGED
|
@@ -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 =
|
|
992
|
-
const
|
|
993
|
-
if (!
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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 = () => {
|
package/web/board/projects.ts
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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 =
|
|
76
|
-
keep(
|
|
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)}
|