kaizen-agent 1.41.2 → 1.42.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.
- package/cli/conpty.ts +99 -0
- package/cli/install.ts +24 -5
- package/cli/pty.test.ts +26 -1
- package/cli/pty.ts +31 -5
- package/cli/state.test.ts +9 -1
- package/cli/state.ts +24 -7
- package/cli/web.ts +57 -3
- package/package.json +1 -1
- package/skills/kaizen/README.md +1 -1
- package/skills/kaizen/config.default.yml +5 -3
- package/skills/kaizen/spec-main.md +11 -0
- package/skills/kaizen-help/SKILL.md +1 -1
- package/web/board/cards.ts +1 -0
- package/web/board/dom.ts +5 -1
- package/web/board/projects.ts +40 -1
- package/web/board/update.ts +1 -0
- package/web/board.html +1 -0
- package/web/board.js +16 -16
- 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 -ExecutionPolicy Bypass -EncodedCommand ${enc}`;
|
|
99
|
+
}
|
package/cli/install.ts
CHANGED
|
@@ -540,6 +540,15 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
|
|
|
540
540
|
}
|
|
541
541
|
}
|
|
542
542
|
|
|
543
|
+
// A board on Windows hands its Update here and exits: Windows will not rename
|
|
544
|
+
// ~/kaizen while that board holds files in it. Wait for it to go, and bring a board
|
|
545
|
+
// back on its port however the upgrade ends (finish()).
|
|
546
|
+
const argAt = (flag: string) => { const i = Bun.argv.indexOf(flag); return i === -1 ? NaN : Number(Bun.argv[i + 1]); };
|
|
547
|
+
if (upgrade && argAt("--wait") > 0) {
|
|
548
|
+
for (let i = 0; i < 100; i++) {
|
|
549
|
+
try { process.kill(argAt("--wait"), 0); await Bun.sleep(200); } catch { break; }
|
|
550
|
+
}
|
|
551
|
+
}
|
|
543
552
|
if (upgrade) await clearBunxCache();
|
|
544
553
|
|
|
545
554
|
// Upgrade means the newest release on npm, whatever the install on disk is: a clone
|
|
@@ -551,16 +560,16 @@ if (upgrade) {
|
|
|
551
560
|
const why = await devCheckout(dest);
|
|
552
561
|
if (why) {
|
|
553
562
|
console.log(`\n ${c.bold("Not upgraded")} ${c.dim(`${tilde(dest)} is ${why}; update it with git`)}\n`);
|
|
554
|
-
|
|
563
|
+
finish(1);
|
|
555
564
|
}
|
|
556
565
|
const latest = await npmLatest();
|
|
557
566
|
if (!latest) {
|
|
558
567
|
console.log(`\n ${c.bold("Not upgraded")} ${c.dim(`could not reach npm (kaizen ${now})`)}\n`);
|
|
559
|
-
|
|
568
|
+
finish(1);
|
|
560
569
|
}
|
|
561
570
|
if (latest === now) {
|
|
562
571
|
console.log(`\n ${c.bold("Up to date")}${c.dim(` — kaizen ${now}`)}\n`);
|
|
563
|
-
|
|
572
|
+
finish(0);
|
|
564
573
|
}
|
|
565
574
|
step(`fetching kaizen ${latest} from npm`);
|
|
566
575
|
// --force so a stale package manifest cannot resolve the version away, and the
|
|
@@ -578,10 +587,10 @@ if (upgrade) {
|
|
|
578
587
|
console.log(`\n ${c.bold(`could not install kaizen ${latest}`)}`);
|
|
579
588
|
// npm names a new version before it serves it (its review takes minutes).
|
|
580
589
|
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`);
|
|
581
|
-
|
|
590
|
+
finish(1);
|
|
582
591
|
}
|
|
583
592
|
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`);
|
|
584
|
-
|
|
593
|
+
finish(0);
|
|
585
594
|
}
|
|
586
595
|
if (!check && !upgrade) await welcome();
|
|
587
596
|
|
|
@@ -1065,6 +1074,16 @@ if (blocked) process.exit(1);
|
|
|
1065
1074
|
|
|
1066
1075
|
// ---------------------------------------------------------------- helpers
|
|
1067
1076
|
|
|
1077
|
+
function finish(code: number): never {
|
|
1078
|
+
const port = argAt("--restart");
|
|
1079
|
+
if (port > 0) {
|
|
1080
|
+
Bun.spawn([process.execPath, join(installDir(), "cli", "install.ts"), "web", "--port", String(port), "--no-open", "--foreground"], {
|
|
1081
|
+
cwd: home, detached: true, stdio: ["ignore", "ignore", "ignore"], windowsHide: true,
|
|
1082
|
+
}).unref();
|
|
1083
|
+
}
|
|
1084
|
+
process.exit(code);
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1068
1087
|
function step(msg: string) { console.log(` ${c.green("+")} ${msg}`); }
|
|
1069
1088
|
|
|
1070
1089
|
// Runs the upgrade and reports what it actually did, version to version, so the
|
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 -ExecutionPolicy Bypass -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
|
@@ -796,7 +796,7 @@ test("findTerminal records the agent's PID: a POSIX wrapper that execs it, a Pow
|
|
|
796
796
|
process.env.SystemRoot = "D:\\Win";
|
|
797
797
|
const w = findTerminal("C:\\p", ["claude", "/kaizen x"], "C:\\p\\.kaizen\\sessions\\k.pid")!;
|
|
798
798
|
const ps = Buffer.from(w.cmd.at(-1)!, "base64").toString("utf16le");
|
|
799
|
-
expect(ps).toBe("$PID | Set-Content -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid'; Set-Location 'C:\\p'; & 'claude' '/kaizen x'");
|
|
799
|
+
expect(ps).toBe("$PID | Set-Content -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid'; Set-Location 'C:\\p'; & 'claude' '/kaizen x'; if (-not (Test-Path -LiteralPath 'C:\\p\\.kaizen\\sessions\\k.pid')) { exit 0 }");
|
|
800
800
|
} finally {
|
|
801
801
|
Object.defineProperty(process, "platform", platform);
|
|
802
802
|
keys.forEach((k, i) => (saved[i] === undefined ? delete process.env[k] : (process.env[k] = saved[i])));
|
|
@@ -1148,3 +1148,11 @@ test("parent/child: a Parent line links a run and an idea to the run that filed
|
|
|
1148
1148
|
expect(boardCards([st], Date.now()).find((k) => k.text === "next thing")!.parent).toBe("2026-01-02-b");
|
|
1149
1149
|
rmSync(dirname(st), { recursive: true, force: true });
|
|
1150
1150
|
});
|
|
1151
|
+
|
|
1152
|
+
test("winShim swaps an npm .cmd shim for the .ps1 beside it, so a multi-line prompt survives", () => {
|
|
1153
|
+
const { winShim } = stateModule;
|
|
1154
|
+
const has = (p: string) => p === "C:\\npm\\claude.ps1";
|
|
1155
|
+
expect(winShim("C:\\npm\\claude.cmd", has)).toBe("C:\\npm\\claude.ps1");
|
|
1156
|
+
expect(winShim("C:\\npm\\codex.CMD", () => false)).toBe("C:\\npm\\codex.CMD");
|
|
1157
|
+
expect(winShim("C:\\bin\\claude.exe", () => true)).toBe("C:\\bin\\claude.exe");
|
|
1158
|
+
});
|
package/cli/state.ts
CHANGED
|
@@ -130,6 +130,14 @@ export const readText = (file: string) => readFileSync(file, "utf8").replace(/^\
|
|
|
130
130
|
|
|
131
131
|
const onPath = (bin: string) => Bun.which(bin, { PATH: process.env.PATH });
|
|
132
132
|
|
|
133
|
+
// npm installs a Windows tool as x.cmd with x.ps1 beside it. PowerShell hands a .cmd to
|
|
134
|
+
// cmd.exe, which ends the command line at the first newline -- a prompt with notes lost
|
|
135
|
+
// everything after its first line. The .ps1 runs in PowerShell itself, argument intact.
|
|
136
|
+
export const winShim = (bin: string, exists = existsSync) => {
|
|
137
|
+
const ps1 = bin.replace(/\.cmd$/i, ".ps1");
|
|
138
|
+
return ps1 !== bin && exists(ps1) ? ps1 : bin;
|
|
139
|
+
};
|
|
140
|
+
|
|
133
141
|
// Windows' own programs by full path: a board started from the shortcut or a
|
|
134
142
|
// stripped-down environment can have a PATH without System32, and a bare "cmd.exe"
|
|
135
143
|
// then fails with "Executable not found in $PATH".
|
|
@@ -164,7 +172,10 @@ export function findTerminal(cwd: string, fullCmd: string[], pidFile?: string):
|
|
|
164
172
|
const q = (a: string) => `'${a.replace(/'/g, "''")}'`;
|
|
165
173
|
const shell = powershellExe();
|
|
166
174
|
// This PowerShell's PID: ending its tree closes the -NoExit tab or window.
|
|
167
|
-
|
|
175
|
+
// The pid file gone after the agent means closeSessions ended it: exit 0, which closes
|
|
176
|
+
// the tab (Windows Terminal keeps one open after a non-zero exit). An agent that ends
|
|
177
|
+
// by itself leaves the window open, -NoExit, so what it printed can still be read.
|
|
178
|
+
const ps = `${pidFile ? `$PID | Set-Content -LiteralPath ${q(pidFile)}; ` : ""}Set-Location ${q(cwd)}; & ${fullCmd.map(q).join(" ")}${pidFile ? `; if (-not (Test-Path -LiteralPath ${q(pidFile)})) { exit 0 }` : ""}`;
|
|
168
179
|
const enc = Buffer.from(ps, "utf16le").toString("base64");
|
|
169
180
|
// herdr (beta on Windows) first, as elsewhere: a new tab, and the run typed into
|
|
170
181
|
// its PowerShell. Typed text ends at a newline, so what is typed is one line, the
|
|
@@ -173,19 +184,19 @@ export function findTerminal(cwd: string, fullCmd: string[], pidFile?: string):
|
|
|
173
184
|
// closes the tab once the run's PowerShell ends.
|
|
174
185
|
const herdr = onPath("herdr");
|
|
175
186
|
if (herdr && answers([herdr, "workspace", "list"])) {
|
|
176
|
-
const typed = `& ${q(shell)} -NoProfile -EncodedCommand ${enc}; exit`;
|
|
187
|
+
const typed = `& ${q(shell)} -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${enc}; exit`;
|
|
177
188
|
const launch = `$o = & ${q(herdr)} tab create --cwd ${q(cwd)} --label kaizen --focus | Out-String
|
|
178
189
|
if ($o -match '"pane_id":"([^"]*)"') { & ${q(herdr)} pane run $Matches[1] ${q(typed)} } else { exit 1 }`;
|
|
179
190
|
return { cmd: [shell, "-NoProfile", "-EncodedCommand", Buffer.from(launch, "utf16le").toString("base64")], detached: false };
|
|
180
191
|
}
|
|
181
192
|
const wt = onPath("wt.exe");
|
|
182
193
|
if (wt) {
|
|
183
|
-
return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-EncodedCommand", enc], detached: true };
|
|
194
|
+
return { cmd: [wt, "-d", cwd, shell, "-NoExit", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc], detached: true };
|
|
184
195
|
}
|
|
185
196
|
// `start` reads a title only when quoted; an unquoted word is the command, so
|
|
186
197
|
// "kaizen" here ran kaizen's own launcher. The empty title is what Bun quotes.
|
|
187
198
|
return {
|
|
188
|
-
cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-EncodedCommand", enc],
|
|
199
|
+
cmd: [cmdExe(), "/c", "start", "", "/D", cwd, shell, "-NoExit", "-ExecutionPolicy", "Bypass", "-EncodedCommand", enc],
|
|
189
200
|
detached: true,
|
|
190
201
|
};
|
|
191
202
|
}
|
|
@@ -1297,8 +1308,14 @@ function closeOne(st: string, s: Session): boolean {
|
|
|
1297
1308
|
try {
|
|
1298
1309
|
const pid = parseInt(readText(pidFile));
|
|
1299
1310
|
if (pid > 0 && isSession(pid, s)) {
|
|
1300
|
-
if (process.platform === "win32")
|
|
1301
|
-
|
|
1311
|
+
if (process.platform === "win32") {
|
|
1312
|
+
// The pid file first, then only the PowerShell's children: the PowerShell sees the
|
|
1313
|
+
// file gone and exits 0 by itself, closing its tab. Killed, it exits 1 and the tab stays.
|
|
1314
|
+
rmSync(pidFile, { force: true });
|
|
1315
|
+
const kids = Bun.spawnSync([powershellExe(), "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ParentProcessId=${pid}').ProcessId`], { stdin: "ignore", stderr: "ignore", timeout: 10000, windowsHide: true }).stdout.toString().match(/\d+/g) ?? [];
|
|
1316
|
+
// No children (a launch from before this): the PowerShell itself, as before.
|
|
1317
|
+
for (const k of kids.length ? kids : [String(pid)]) Bun.spawnSync([sysExe("taskkill.exe"), "/T", "/F", "/PID", k], { stdin: "ignore", stdout: "ignore", stderr: "ignore", timeout: 5000, windowsHide: true });
|
|
1318
|
+
} else process.kill(pid, "SIGTERM");
|
|
1302
1319
|
closed = true;
|
|
1303
1320
|
}
|
|
1304
1321
|
} catch { /* already gone */ }
|
|
@@ -1444,7 +1461,7 @@ export function launchRun(it: Item, from: string, run?: string, pick: { agent?:
|
|
|
1444
1461
|
// agent inheriting it runs as its child and keeps no transcript, so nothing to resume.
|
|
1445
1462
|
for (const k of Object.keys(process.env)) if (CHILD_ENV.test(k)) delete process.env[k];
|
|
1446
1463
|
// onPath, not Bun.which: the board may have taken a login shell's PATH after it started.
|
|
1447
|
-
const agentBin = onPath(agent.cmd) ?? agent.cmd;
|
|
1464
|
+
const agentBin = winShim(onPath(agent.cmd) ?? agent.cmd);
|
|
1448
1465
|
// No text: a run's conversation reopened as it was, with nothing sent to it.
|
|
1449
1466
|
const prompt = !it.text ? "" : requestOf(it.where ? `/kaizen ${it.text} (${it.where})` : `/kaizen ${it.text}`, it.notes, from);
|
|
1450
1467
|
const wanted = pick.model ?? (own?.cmd === agent.cmd ? own.model : null) ?? undefined;
|
package/cli/web.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `kaizen web`: the board in a browser. A loopback HTTP server over state.ts and
|
|
2
2
|
// one HTML page; the page never sees the filesystem, only JSON.
|
|
3
|
-
import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, type FSWatcher } from "node:fs";
|
|
4
|
-
import { join, dirname, resolve, sep } from "node:path";
|
|
3
|
+
import { existsSync, readdirSync, statSync, watch, appendFileSync, mkdirSync, cpSync, rmSync, type FSWatcher } from "node:fs";
|
|
4
|
+
import { join, dirname, resolve, sep, basename } from "node:path";
|
|
5
5
|
import {
|
|
6
6
|
home, type Item, type Card, boardCards, knownProjects, remember, locate, label, tilde, searchRoots, findProjects,
|
|
7
7
|
readInbox, writeInbox, replaceIdea, moveIdea, abandonRun, launchRun, parseItem, agentChoices, projectOf, KNOWN_AGENTS, plainModel, short, setArchived, writeNotes,
|
|
@@ -550,7 +550,7 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
550
550
|
const v = await npmLatest(url.searchParams.has("fresh"));
|
|
551
551
|
const update = !!v && newer(v, VERSION);
|
|
552
552
|
const notes = update ? await releaseNotes(VERSION, url.searchParams.has("fresh")) : [];
|
|
553
|
-
return json({ version: VERSION, latest: v, update, notes });
|
|
553
|
+
return json({ version: VERSION, latest: v, update, notes, failed: upgradeFailed });
|
|
554
554
|
}
|
|
555
555
|
|
|
556
556
|
// `kaizen upgrade`, run here with no window, then a new board on this port: it
|
|
@@ -560,6 +560,21 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
560
560
|
if (req.method === "POST" && path === "/upgrade") {
|
|
561
561
|
if (!local(req)) return bad("cross-origin write refused", 403);
|
|
562
562
|
if (upgrading) return bad("an upgrade is already running", 409);
|
|
563
|
+
// Windows will not rename ~/kaizen while this board holds files in it, so the
|
|
564
|
+
// upgrade waits in its own process for this one to exit, then starts the board
|
|
565
|
+
// again; ~/.kaizen/upgrade.log keeps what it said. It runs from a copy of cli/,
|
|
566
|
+
// so nothing runs out of ~/kaizen while it is renamed.
|
|
567
|
+
if (process.platform === "win32") {
|
|
568
|
+
const helper = join(home, ".kaizen", "upgrader");
|
|
569
|
+
rmSync(helper, { recursive: true, force: true });
|
|
570
|
+
cpSync(join(repoDir, "cli"), join(helper, "cli"), { recursive: true });
|
|
571
|
+
const log = Bun.file(UPGRADE_LOG);
|
|
572
|
+
Bun.spawn([process.execPath, join(helper, "cli", "install.ts"), "upgrade", "--wait", String(process.pid), "--restart", String(srv.port)], {
|
|
573
|
+
cwd: home, detached: true, stdio: ["ignore", log, log], windowsHide: true,
|
|
574
|
+
}).unref();
|
|
575
|
+
setTimeout(() => process.exit(0), 300);
|
|
576
|
+
return json({ ok: true });
|
|
577
|
+
}
|
|
563
578
|
upgrading = true;
|
|
564
579
|
try {
|
|
565
580
|
const p = Bun.spawn([process.execPath, join(repoDir, "cli", "install.ts"), "upgrade"], { stdin: "ignore", stdout: "pipe", stderr: "pipe", windowsHide: true });
|
|
@@ -688,6 +703,33 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
688
703
|
return json({ ok: true, added: knownProjects().length - before });
|
|
689
704
|
}
|
|
690
705
|
|
|
706
|
+
// A new, empty project: a folder in the projects folder (else ~/Projects), or
|
|
707
|
+
// where the page names, set up at once. /find only sees folders that already
|
|
708
|
+
// have a .kaizen, so a folder made by hand never shows there.
|
|
709
|
+
if (path === "/new-project") {
|
|
710
|
+
const name = typeof body.name === "string" ? body.name.trim() : "";
|
|
711
|
+
// Windows' rules on every platform, so a project made here also opens there:
|
|
712
|
+
// no reserved device names (CON, NUL, COM1…), no trailing dot or space.
|
|
713
|
+
const winBad = (n: string) => !n || /[\\/:*?"<>|\x00-\x1f]/.test(n) || /[. ]$/.test(n)
|
|
714
|
+
|| /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i.test(n);
|
|
715
|
+
const t = typeof body.to === "string" ? body.to.trim() : "";
|
|
716
|
+
const to = t ? resolve(home, nativePath(t.startsWith("~") ? home + t.slice(1) : t))
|
|
717
|
+
: join((await projectsRoot()) || join(home, "Projects"), name);
|
|
718
|
+
// The page asks while the name is typed: "con" on the way to "config" is no error yet.
|
|
719
|
+
if (body.check) return json({ ok: true, to });
|
|
720
|
+
if (winBad(name)) return bad("name the project: a folder name, without / \\ : * ? \" < > |, not ending in a dot or space, and not CON, NUL, COM1 or the like");
|
|
721
|
+
if (winBad(basename(to))) return bad(`${basename(to)} cannot be a folder name on Windows`);
|
|
722
|
+
let used = false;
|
|
723
|
+
try { used = readdirSync(to).length > 0; } catch (e) { used = existsSync(to); }
|
|
724
|
+
if (used) return bad(`${to} already exists and is not empty`, 409);
|
|
725
|
+
// A drive that is not there, a read-only folder, a path too long: said in a toast, not a crash.
|
|
726
|
+
try { mkdirSync(join(to, ".kaizen"), { recursive: true }); }
|
|
727
|
+
catch (e) { return bad(`could not create ${to}: ${(e as Error).message}`); }
|
|
728
|
+
remember(to);
|
|
729
|
+
changed();
|
|
730
|
+
return json({ ok: true, dir: join(to, ".kaizen") });
|
|
731
|
+
}
|
|
732
|
+
|
|
691
733
|
// Clone a GitHub repository into the projects folder (else ~/Projects) and
|
|
692
734
|
// set it up as a project. Arguments go to git as an array: no shell.
|
|
693
735
|
if (path === "/clone") {
|
|
@@ -1124,6 +1166,18 @@ async function openDefault(url: string) {
|
|
|
1124
1166
|
let picking = false;
|
|
1125
1167
|
// One `kaizen upgrade` at a time, from any page.
|
|
1126
1168
|
let upgrading = false;
|
|
1169
|
+
// What a Windows hand-off upgrade said (see POST /upgrade). Read once at start: no
|
|
1170
|
+
// "Upgraded" or "Up to date" line means it failed, and its last lines say why.
|
|
1171
|
+
const UPGRADE_LOG = join(home, ".kaizen", "upgrade.log");
|
|
1172
|
+
const upgradeFailed = (() => {
|
|
1173
|
+
if (!existsSync(UPGRADE_LOG)) return null;
|
|
1174
|
+
const lines = readText(UPGRADE_LOG).replace(/\x1b\[[0-9;]*m/g, "").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
1175
|
+
try { rmSync(UPGRADE_LOG, { force: true }); } catch { /* shown again next start */ }
|
|
1176
|
+
if (lines.some((l) => /^(Upgraded|Up to date)\b/.test(l))) return null;
|
|
1177
|
+
// "kept ~/kaizen (in use …)" is the reason when there is one; else the closing lines.
|
|
1178
|
+
const kept = lines.find((l) => /^\+?\s*kept\b/.test(l));
|
|
1179
|
+
return kept?.replace(/^\+\s*/, "") ?? (lines.filter((l) => !l.startsWith("+")).slice(-2).join(" — ") || "kaizen upgrade said nothing");
|
|
1180
|
+
})();
|
|
1127
1181
|
|
|
1128
1182
|
// The system folder picker for this platform, printing the chosen path on stdout.
|
|
1129
1183
|
// macOS and Windows always have one; on Linux it is whichever desktop tool exists.
|
package/package.json
CHANGED
package/skills/kaizen/README.md
CHANGED
|
@@ -206,7 +206,7 @@ the defaults.
|
|
|
206
206
|
| `auto_fix.min_severity` | `high` | Lower to `medium` or `low` to have more auto-fixed; raise findings you see instead of auto-fixing |
|
|
207
207
|
| `auto_fix.max_iterations` | `2` | Fix/recheck rounds before whatever is left is escalated to you |
|
|
208
208
|
| `review.memory_max_lines` | `100` | The cap on `memory.md`, which every stage of every run reads. Raise only if the lessons are genuinely all load-bearing |
|
|
209
|
-
| `git.auto_commit` | `false` |
|
|
209
|
+
| `git.auto_commit` | `false` | `true` commits the files a run changed when it is done, by path. Never pushes |
|
|
210
210
|
| `track.default` | `auto` (inferred) | A repo that is always one kind of work, e.g. a docs repo |
|
|
211
211
|
| `backlog.enabled` | `true` | Set `false` to skip both backlog capture points; the run still executes, just without a `06-backlog.md` |
|
|
212
212
|
| `backlog.file` | `06-backlog.md` | The per-run file name. Rarely. |
|
|
@@ -125,7 +125,9 @@ state:
|
|
|
125
125
|
keep_runs: 20
|
|
126
126
|
|
|
127
127
|
git:
|
|
128
|
-
#
|
|
128
|
+
# true: when a run reaches done (approved at review, or an auto run finishing),
|
|
129
|
+
# commit the files that run changed, by path, with its ## Commit message. Never
|
|
130
|
+
# pushes. false: never commit, push, or open a PR unless the user explicitly asks.
|
|
129
131
|
# Ignored when the project is not a git repository.
|
|
130
132
|
auto_commit: false
|
|
131
133
|
# Create a branch before the implement stage when on the default branch.
|
|
@@ -168,8 +170,8 @@ agent:
|
|
|
168
170
|
# Where the web board runs the agent it launches.
|
|
169
171
|
# card - inside the board: the card's Terminal tab shows the agent live and
|
|
170
172
|
# takes your typing. The agent lives in the board's process, so
|
|
171
|
-
# stopping or upgrading the board ends it.
|
|
172
|
-
#
|
|
173
|
+
# stopping or upgrading the board ends it. On Windows it runs in
|
|
174
|
+
# a ConPTY console that Windows PowerShell hosts.
|
|
173
175
|
# terminal - a new terminal window (or tmux/herdr tab) per launch.
|
|
174
176
|
# The terminal dashboard always opens terminal windows.
|
|
175
177
|
launch: card
|
|
@@ -131,6 +131,17 @@ change is that it is written down rather than spoken and discarded.
|
|
|
131
131
|
|
|
132
132
|
Set `stage: "done"` in `state.json` once the user accepts.
|
|
133
133
|
|
|
134
|
+
**Commit at done, when `git.auto_commit` is `true`.** Setting `stage: "done"` — the
|
|
135
|
+
user approving at review, or an `auto` run finishing — is then the ask to commit, under
|
|
136
|
+
either runner. Commit only the files this run changed (the paths `03-impl.md` names,
|
|
137
|
+
that `git status` shows changed), by path: `git add -A -- <paths>` then
|
|
138
|
+
`git commit -m <message> -- <paths>`, never the whole tree, since other sessions edit
|
|
139
|
+
the same checkout. The message is `03-impl.md`'s `## Commit`. In a run with a
|
|
140
|
+
`worktree`, commit there. Then write `runs/<id>/07-commit.md` as `commit: <short sha>`
|
|
141
|
+
and `pushed: no`, so the board shows it committed and offers the push. Never push. A
|
|
142
|
+
failed commit is reported as printed; the run is still `done`. With the setting
|
|
143
|
+
`false`, nothing is committed unless the user asks.
|
|
144
|
+
|
|
134
145
|
---
|
|
135
146
|
|
|
136
147
|
## Child cards
|
|
@@ -95,7 +95,7 @@ Each iteration is written to `runs/<id>/05-iterations/`.
|
|
|
95
95
|
| `backlog.orphan_file` | `backlog.md` | Where a pruned run's open items are rescued to |
|
|
96
96
|
| `state.dir` | `.kaizen` | State directory |
|
|
97
97
|
| `state.keep_runs` | `20` | Completed runs kept before pruning oldest (rescues open backlog first) |
|
|
98
|
-
| `git.auto_commit` | `false` |
|
|
98
|
+
| `git.auto_commit` | `false` | `true`: commit the run's changed files when it is done; never pushes |
|
|
99
99
|
| `git.branch_before_implement` | `true` | Branch before implement stage when on the default branch |
|
|
100
100
|
| `subagents.model` | `inherit` | Model the stage subagents run on (opus/sonnet/haiku, Claude Code only) |
|
|
101
101
|
| `subagents.effort` | `inherit` | Reasoning effort for the stage subagents (low/medium/high, Claude Code only) |
|
package/web/board/cards.ts
CHANGED
|
@@ -467,6 +467,7 @@ export function render() {
|
|
|
467
467
|
["All projects", "all"],
|
|
468
468
|
...store.S.projects.map((p) => [p.label, p.dir]),
|
|
469
469
|
...store.S.fresh.map((p) => [p.label, p.dir, 1]),
|
|
470
|
+
["New project…", "__new"],
|
|
470
471
|
["Find projects on this PC…", "__find"],
|
|
471
472
|
["Clone from GitHub…", "__clone"],
|
|
472
473
|
];
|
package/web/board/dom.ts
CHANGED
|
@@ -27,7 +27,11 @@ export function modal(html: string) {
|
|
|
27
27
|
dl.className = "kz";
|
|
28
28
|
dl.innerHTML = `<form class="inl" method="dialog">${html}</form>`;
|
|
29
29
|
dl.onclose = () => dl.remove();
|
|
30
|
-
|
|
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
|
+
};
|
|
31
35
|
document.body.appendChild(dl);
|
|
32
36
|
dl.showModal();
|
|
33
37
|
return dl;
|
package/web/board/projects.ts
CHANGED
|
@@ -35,7 +35,7 @@ export function list() {
|
|
|
35
35
|
store.PL.map((p, i) =>
|
|
36
36
|
p[0].toLowerCase().includes(q)
|
|
37
37
|
? (p[2] && !g++ ? `<div class="grp">In your projects folder</div>` : "") +
|
|
38
|
-
`<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__find" || p[1] === "__clone" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
|
|
38
|
+
`<button role="option" data-i="${i}" title="${esc(p[0])}">${p[1] === "all" ? `<span class="plogo" aria-hidden="true">${ico("board")}</span><span>${esc(p[0])}</span>` : p[1] === "__new" || p[1] === "__find" || p[1] === "__clone" ? `<span class="plogo" aria-hidden="true">${ico("plus")}</span><span>${esc(p[0])}</span>` : prow(p[1], p[0])}</button>`
|
|
39
39
|
: "",
|
|
40
40
|
).join("") || `<div class="grp">No match</div>`;
|
|
41
41
|
const f = $("projs").querySelector("button");
|
|
@@ -55,6 +55,45 @@ async function pick(v) {
|
|
|
55
55
|
toast("Searching…");
|
|
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
|
+
} else if (v === "__new") {
|
|
59
|
+
// A name, and where it goes: the projects folder unless typed or picked.
|
|
60
|
+
const dl = modal(
|
|
61
|
+
`<b>New project</b><input name="name" placeholder="Project name" aria-label="Project name" spellcheck="false" autocomplete="off"><div class="row"><input name="to" placeholder="Create in (a new folder is made)" aria-label="Create in" 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">Create</button></div>`,
|
|
62
|
+
);
|
|
63
|
+
const f = dl.querySelector("form"),
|
|
64
|
+
name = f.querySelector("[name=name]"),
|
|
65
|
+
to = f.querySelector("[name=to]");
|
|
66
|
+
let auto = ""; // the folder this form filled in; one the user typed is left alone
|
|
67
|
+
name.oninput = async () => {
|
|
68
|
+
const n = name.value.trim();
|
|
69
|
+
if (!n || /[\\/:*?"<>|]/.test(n) || (to.value.trim() && to.value !== auto)) return; // a bad name is reported once, at Create
|
|
70
|
+
const d = await post("/new-project", { name: n, check: true });
|
|
71
|
+
if (d.ok && name.value.trim() === n) to.value = auto = d.to;
|
|
72
|
+
};
|
|
73
|
+
f.querySelector("[data-a=x]").onclick = () => dl.close();
|
|
74
|
+
f.querySelector("[data-a=pick]").onclick = async () => {
|
|
75
|
+
const p = await post("/pick-folder", {});
|
|
76
|
+
if (p.path)
|
|
77
|
+
to.value = p.path.replace(/[\\/]+$/, "") + (p.path.includes("\\") ? "\\" : "/") + (name.value.trim() || "");
|
|
78
|
+
};
|
|
79
|
+
name.focus();
|
|
80
|
+
const dir = await new Promise((done) => {
|
|
81
|
+
dl.addEventListener("close", () => done(null));
|
|
82
|
+
f.onsubmit = async (e) => {
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
if (!name.value.trim()) return name.focus();
|
|
85
|
+
const j = await post("/new-project", { name: name.value.trim(), to: to.value.trim() || undefined });
|
|
86
|
+
if (!j.ok) return;
|
|
87
|
+
done(j.dir);
|
|
88
|
+
dl.close();
|
|
89
|
+
};
|
|
90
|
+
});
|
|
91
|
+
if (!dir) return;
|
|
92
|
+
await post("/cd", { dir });
|
|
93
|
+
store.all = false;
|
|
94
|
+
store.dir = dir;
|
|
95
|
+
keep(dir);
|
|
96
|
+
toast("Project created");
|
|
58
97
|
} else if (v === "__clone") {
|
|
59
98
|
// One form: the link, then where it goes. The folder fills in once the link names a repo.
|
|
60
99
|
const dl = modal(
|
package/web/board/update.ts
CHANGED
|
@@ -30,6 +30,7 @@ export function updBar() {
|
|
|
30
30
|
: "";
|
|
31
31
|
d.innerHTML = `<span>kaizen <b>${esc(upd.latest)}</b> is available. You are running ${esc(upd.version)}.</span><span class="updact">${news}<button class="btn" id="upd-chk">Check for updates</button><button class="btn pri" id="upd-go">Update kaizen</button></span>`;
|
|
32
32
|
}
|
|
33
|
+
if (ver && ver.failed) d.insertAdjacentHTML("afterbegin", `<span class="updfail" role="alert">Last update failed: ${esc(ver.failed)}</span>`);
|
|
33
34
|
$("pbody").prepend(d);
|
|
34
35
|
const check = $("upd-chk");
|
|
35
36
|
check.onclick = async () => {
|