kaizen-agent 1.43.2 → 1.44.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/board.test.ts +43 -0
- package/cli/install.ts +6 -4
- package/cli/pty.test.ts +13 -1
- package/cli/pty.ts +11 -0
- package/cli/release.test.ts +12 -0
- package/cli/release.ts +67 -0
- package/cli/state.test.ts +2 -0
- package/cli/state.ts +4 -3
- package/cli/web.ts +11 -1
- package/package.json +2 -2
- package/skills/kaizen/SKILL.md +6 -0
- package/skills/kaizen/spec-main.md +4 -1
- package/web/board/chat.ts +29 -6
- package/web/board/markdown.ts +8 -3
- package/web/board/rich.ts +160 -0
- package/web/board/terms.ts +10 -4
- package/web/board.html +5 -3
- package/web/board.js +40 -25
- package/web/notebook.js +1 -1
- package/web/term.js +1 -1
package/cli/board.test.ts
CHANGED
|
@@ -254,3 +254,46 @@ test("a capitalised phrase mid-sentence is prose, not a label", () => {
|
|
|
254
254
|
test("criteria not numbered from 1 keep their own numbers", () => {
|
|
255
255
|
expect(brief("X Goal: g. Criteria: (2) two. (3) three.").parts[1].body).toBe("- (2) two.\n- (3) three.");
|
|
256
256
|
});
|
|
257
|
+
|
|
258
|
+
// pretty(): a one-line idea note splits into one paragraph per label.
|
|
259
|
+
test("pretty splits a labelled plain line in a note by its labels", async () => {
|
|
260
|
+
const { pretty } = await import("../web/board/chat.ts");
|
|
261
|
+
const t = "Parent: run-x add a timezone. OPEN QUESTION (waiting on Johan): recalc old rows? Context: PR #452.";
|
|
262
|
+
expect(pretty(t)).toBe("**Parent:** run-x add a timezone.\n\n**OPEN QUESTION (waiting on Johan):** recalc old rows?\n\n**Context:** PR #452.");
|
|
263
|
+
expect(pretty("just a note")).toBe("just a note");
|
|
264
|
+
expect(pretty("Goal: a\nScope: b")).toBe("Goal: a\nScope: b");
|
|
265
|
+
expect(pretty("Parent: x\ndo y. NOTE: a. Context: b.")).toBe("Parent: x\n\ndo y.\n\n**NOTE:** a.\n\n**Context:** b.");
|
|
266
|
+
expect(pretty("- Goal: a. Scope: b.")).toBe("- Goal: a. Scope: b.");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("the note editor turns its DOM back into the markdown md() rendered it from", async () => {
|
|
270
|
+
const { toMd } = await import("../web/board/rich.ts");
|
|
271
|
+
// A minimal DOM: element nodes from md()'s tag soup, enough for toMd.
|
|
272
|
+
const parse = (h) => {
|
|
273
|
+
const root = { nodeType: 1, nodeName: "DIV", childNodes: [], getAttribute: () => null };
|
|
274
|
+
const stack = [root];
|
|
275
|
+
for (const m of h.matchAll(/<(\/?)(\w+)([^>]*)>|([^<]+)/g)) {
|
|
276
|
+
const top = stack[stack.length - 1];
|
|
277
|
+
if (m[4]) top.childNodes.push({ nodeType: 3, nodeName: "#text", textContent: m[4].replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&") });
|
|
278
|
+
else if (m[1]) stack.pop();
|
|
279
|
+
else {
|
|
280
|
+
const attrs = Object.fromEntries([...m[3].matchAll(/(\w+)="([^"]*)"/g)].map((a) => [a[1], a[2]]));
|
|
281
|
+
const el = { nodeType: 1, nodeName: m[2].toUpperCase(), childNodes: [], getAttribute: (k) => attrs[k] ?? null };
|
|
282
|
+
Object.defineProperty(el, "textContent", { get: () => el.childNodes.map((c) => c.textContent).join("") });
|
|
283
|
+
top.childNodes.push(el);
|
|
284
|
+
if (!/^(img|br)$/.test(m[2])) stack.push(el);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return root;
|
|
288
|
+
};
|
|
289
|
+
const src = "# Title\n\nsome **bold**, *em* and `code` here \nnext\n\n- one\n- two\n\n1. first\n2. second\n\n [link](http://x)";
|
|
290
|
+
expect(toMd(parse(md(src)))).toBe(src);
|
|
291
|
+
// A fresh line typed straight into the box, then one Chrome wrapped in a div.
|
|
292
|
+
expect(toMd(parse("plain <b>b</b><div>next line</div>"))).toBe("plain **b**\n\nnext line");
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("md() renders italic and two-space hard breaks, and leaves globs in code alone", () => {
|
|
296
|
+
expect(md("an *em* word")).toBe("<p>an <i>em</i> word</p>");
|
|
297
|
+
expect(md("`*.ts` and `*.md`, a * b * c")).toBe("<p><code>*.ts</code> and <code>*.md</code>, a * b * c</p>");
|
|
298
|
+
expect(md("one \ntwo\nthree")).toBe("<p>one<br>two three</p>");
|
|
299
|
+
});
|
package/cli/install.ts
CHANGED
|
@@ -96,15 +96,17 @@ if (Bun.argv[2] === "browser") {
|
|
|
96
96
|
process.exit(0);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
// `kaizen card add "<
|
|
100
|
-
// to the run it came from.
|
|
99
|
+
// `kaizen card add "<title>" [--parent <run id>] [--notes "<details>"]`: what a run
|
|
100
|
+
// files as new work, linked to the run it came from.
|
|
101
101
|
if (Bun.argv[2] === "card") {
|
|
102
102
|
const { locate, label, addCard } = await import("./state.ts");
|
|
103
103
|
const state = locate() ?? join(home, ".kaizen");
|
|
104
104
|
const args = Bun.argv.slice(4), at = args.indexOf("--parent");
|
|
105
105
|
const parent = at >= 0 ? args.splice(at, 2)[1] : undefined;
|
|
106
|
-
|
|
107
|
-
const
|
|
106
|
+
const nt = args.indexOf("--notes");
|
|
107
|
+
const details = nt >= 0 ? args.splice(nt, 2)[1] : undefined;
|
|
108
|
+
if (Bun.argv[3] !== "add" || !args.length || (at >= 0 && !parent) || (nt >= 0 && !details)) { console.log(`\n kaizen card add "<title>" [--parent <run id>] [--notes "<details>"]\n`); process.exit(1); }
|
|
109
|
+
const r = addCard(state, args.join(" "), parent, details);
|
|
108
110
|
if (typeof r === "string") { console.log(`\n ${r}\n`); process.exit(1); }
|
|
109
111
|
console.log(`\n ${c.green("added")} ${args.join(" ")}\n ${c.dim(`parent ${r.parent} · project ${label(state)}`)}\n`);
|
|
110
112
|
process.exit(0);
|
package/cli/pty.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ 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, attachPty, ptyState } from "./pty.ts";
|
|
8
|
+
import { loginEnv, startPty, writePty, attachPty, ptyState, exitPty } from "./pty.ts";
|
|
9
9
|
import { conptyHost, innerCommand, resizeSeq } from "./conpty.ts";
|
|
10
10
|
|
|
11
11
|
const root = realpathSync(mkdtempSync(join(tmpdir(), "kaizen-pty-")));
|
|
@@ -198,6 +198,18 @@ test.skipIf(process.platform === "win32")("the done sweep keeps a session the us
|
|
|
198
198
|
expect(await gone(looked)).toBe(true);
|
|
199
199
|
});
|
|
200
200
|
|
|
201
|
+
test.skipIf(process.platform === "win32")("exitPty types /exit, and ends an agent that ignores it", async () => {
|
|
202
|
+
const wait = async (k: string) => { for (let i = 0; i < 100 && ptyState(k)!.alive; i++) await Bun.sleep(30); return ptyState(k)!.alive; };
|
|
203
|
+
startPty("polite", ["sh", "-c", "while read l; do case $l in */exit*) exit 0;; esac; done"], root);
|
|
204
|
+
expect(exitPty("polite", 60_000)).toBe(true);
|
|
205
|
+
expect(await wait("polite")).toBe(false);
|
|
206
|
+
startPty("stubborn", ["sh", "-c", "trap '' INT; while read l; do :; done"], root);
|
|
207
|
+
await Bun.sleep(100);
|
|
208
|
+
exitPty("stubborn", 300);
|
|
209
|
+
expect(await wait("stubborn")).toBe(false);
|
|
210
|
+
expect(exitPty("stubborn")).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
201
213
|
test("asking: Claude Code and Codex prompts count as a question, ordinary output does not", async () => {
|
|
202
214
|
const { asking } = await import("./pty.ts");
|
|
203
215
|
expect(asking("\x1b[1mWould you like to run the following command?\x1b[0m")).toBe(true);
|
package/cli/pty.ts
CHANGED
|
@@ -120,6 +120,17 @@ const send = (p: Pty, d: string) => {
|
|
|
120
120
|
try { (p.proc.stdin as Bun.FileSink).write(d); (p.proc.stdin as Bun.FileSink).flush(); } catch { /* host gone */ }
|
|
121
121
|
};
|
|
122
122
|
|
|
123
|
+
// The tile's ×: ask the agent to quit as the user would (/exit), then end it if it is
|
|
124
|
+
// still there after `grace`. Escape first clears whatever is half-typed at its prompt.
|
|
125
|
+
export function exitPty(key: string, grace = 5000): boolean {
|
|
126
|
+
const p = ptys.get(key);
|
|
127
|
+
if (!p || p.exit !== null) return false;
|
|
128
|
+
send(p, "\x1b");
|
|
129
|
+
setTimeout(() => send(p, "/exit\r"), 100);
|
|
130
|
+
setTimeout(() => { if (p.exit === null) p.proc.kill(); }, grace).unref();
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
export function resizePty(key: string, cols: number, rows: number) {
|
|
124
135
|
const p = ptys.get(key);
|
|
125
136
|
const c = Math.max(10, Math.min(500, cols | 0)), r = Math.max(5, Math.min(300, rows | 0));
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { test, expect } from "bun:test";
|
|
2
|
+
import { nextVersion, levelOf, summaryOf } from "./release.ts";
|
|
3
|
+
|
|
4
|
+
test("release picks the bump from the commits and names it from them", () => {
|
|
5
|
+
expect(levelOf("fix(board): x\ndocs: y")).toBe("patch");
|
|
6
|
+
expect(levelOf("docs: y\nfeat(board): x")).toBe("minor");
|
|
7
|
+
expect(levelOf("feat: x\n\nBREAKING: config moved")).toBe("major");
|
|
8
|
+
expect(nextVersion("1.43.3", "patch")).toBe("1.43.4");
|
|
9
|
+
expect(nextVersion("1.43.3", "minor")).toBe("1.44.0");
|
|
10
|
+
expect(nextVersion("1.43.3", "major")).toBe("2.0.0");
|
|
11
|
+
expect(summaryOf(["feat(board): a", "docs(notes): b", "fix: c"])).toBe("a; c");
|
|
12
|
+
});
|
package/cli/release.ts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// `bun run release [patch|minor|major] [summary]`: bump, commit, test, publish, push.
|
|
2
|
+
// With no level, the commits since the last tag decide it: a `feat` makes a new minor
|
|
3
|
+
// (1.43.3 -> 1.44.0), anything else a patch; "BREAKING" anywhere makes a major.
|
|
4
|
+
// The summary defaults to those commits' subjects, prefixes dropped. A bump commit
|
|
5
|
+
// already made but never published (HEAD is "Release <version>:" with no tag) is reused.
|
|
6
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
|
|
8
|
+
const sh = (cmd: string[]) => {
|
|
9
|
+
const r = Bun.spawnSync(cmd, { stdout: "pipe", stderr: "inherit" });
|
|
10
|
+
if (r.exitCode !== 0) throw new Error(`${cmd.join(" ")} failed`);
|
|
11
|
+
return r.stdout.toString().trim();
|
|
12
|
+
};
|
|
13
|
+
const run = (cmd: string[]) => {
|
|
14
|
+
if (Bun.spawnSync(cmd, { stdio: ["inherit", "inherit", "inherit"] }).exitCode !== 0) process.exit(1);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function nextVersion(v: string, level: string) {
|
|
18
|
+
const [a, b, c] = v.split(".").map(Number);
|
|
19
|
+
return level === "major" ? `${a + 1}.0.0` : level === "minor" ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`;
|
|
20
|
+
}
|
|
21
|
+
export function levelOf(log: string) {
|
|
22
|
+
if (/BREAKING/.test(log)) return "major";
|
|
23
|
+
return /^feat(\(.*?\))?!?:/m.test(log) ? "minor" : "patch";
|
|
24
|
+
}
|
|
25
|
+
export function summaryOf(subjects: string[]) {
|
|
26
|
+
return subjects
|
|
27
|
+
.filter((s) => /^(feat|fix|perf)(\(.*?\))?!?:/.test(s))
|
|
28
|
+
.map((s) => s.replace(/^\w+(\(.*?\))?!?:\s*/, ""))
|
|
29
|
+
.join("; ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (import.meta.main) {
|
|
33
|
+
const args = process.argv.slice(2);
|
|
34
|
+
const forced = ["patch", "minor", "major"].includes(args[0]) ? args.shift() : null;
|
|
35
|
+
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
|
|
36
|
+
const tagged = (v: string) => sh(["git", "tag", "-l", `v${v}`]) !== "";
|
|
37
|
+
const head = sh(["git", "log", "-1", "--format=%s"]);
|
|
38
|
+
if (sh(["git", "status", "--porcelain", "--untracked-files=no"])) {
|
|
39
|
+
console.error("release: commit or drop your changes first");
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
if (head.startsWith(`Release ${pkg.version}:`) && !tagged(pkg.version) && !forced) {
|
|
43
|
+
console.log(`release: reusing the unpublished bump commit for ${pkg.version}`);
|
|
44
|
+
} else {
|
|
45
|
+
const last = sh(["git", "describe", "--tags", "--abbrev=0"]);
|
|
46
|
+
const log = sh(["git", "log", "--format=%s%n%b", `${last}..HEAD`]);
|
|
47
|
+
if (!log) {
|
|
48
|
+
console.error(`release: nothing since ${last}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
const level = forced ?? levelOf(log);
|
|
52
|
+
const v = nextVersion(pkg.version, level);
|
|
53
|
+
const summary = args.join(" ") || summaryOf(sh(["git", "log", "--reverse", "--format=%s", `${last}..HEAD`]).split("\n")) || "maintenance";
|
|
54
|
+
writeFileSync("package.json", readFileSync("package.json", "utf8").replace(`"version": "${pkg.version}"`, `"version": "${v}"`));
|
|
55
|
+
const note = ".kaizen/notes/Project/Release and testing.md";
|
|
56
|
+
try {
|
|
57
|
+
writeFileSync(note, readFileSync(note, "utf8").replace(`version ${pkg.version} in this checkout`, `version ${v} in this checkout`));
|
|
58
|
+
} catch {}
|
|
59
|
+
sh(["git", "add", "package.json", note]);
|
|
60
|
+
sh(["git", "commit", "-q", "-m", `Release ${v}: ${summary}`]);
|
|
61
|
+
console.log(`release: ${pkg.version} -> ${v} (${level})`);
|
|
62
|
+
}
|
|
63
|
+
// Tests run in prepublishOnly; a failure stops here with the commit unpushed.
|
|
64
|
+
run(["bun", "publish"]);
|
|
65
|
+
run(["git", "push", "origin", "HEAD"]);
|
|
66
|
+
run(["bun", "run", "publish:gh"]);
|
|
67
|
+
}
|
package/cli/state.test.ts
CHANGED
|
@@ -1228,6 +1228,8 @@ test("parent/child: a Parent line links a run and an idea to the run that filed
|
|
|
1228
1228
|
expect(addCard(st, "next thing")).toEqual({ parent: "2026-01-02-b" });
|
|
1229
1229
|
expect(addCard(st, "next thing")).toMatch(/already/);
|
|
1230
1230
|
expect(addCard(st, "other", "nope")).toMatch(/no such run/);
|
|
1231
|
+
addCard(st, "titled", "2026-01-02-b", "the details\nline two");
|
|
1232
|
+
expect(readInbox(st).find((l) => l.text === "titled")?.notes).toContain("Parent: 2026-01-02-b\nthe details");
|
|
1231
1233
|
expect(boardCards([st], Date.now()).find((k) => k.text === "next thing")!.parent).toBe("2026-01-02-b");
|
|
1232
1234
|
rmSync(dirname(st), { recursive: true, force: true });
|
|
1233
1235
|
});
|
package/cli/state.ts
CHANGED
|
@@ -915,8 +915,9 @@ export function appendNote(state: string, target: { id: string } | { text: strin
|
|
|
915
915
|
}
|
|
916
916
|
|
|
917
917
|
// A card filed by a run: an open idea whose note names the run as its parent. The
|
|
918
|
-
// parent is the one named, else the newest run still in flight.
|
|
919
|
-
|
|
918
|
+
// parent is the one named, else the newest run still in flight. `details` go in the
|
|
919
|
+
// idea's notes below the Parent line, so the title stays a title.
|
|
920
|
+
export function addCard(state: string, text: string, parent?: string, details?: string): { parent: string } | string {
|
|
920
921
|
text = text.trim().replace(/\s+/g, " ");
|
|
921
922
|
if (!text) return "empty card";
|
|
922
923
|
const id = parent ?? readRuns(state).find((r) => r.stage !== "done" && r.stage !== "abandoned")?.id;
|
|
@@ -924,7 +925,7 @@ export function addCard(state: string, text: string, parent?: string): { parent:
|
|
|
924
925
|
if (!existsSync(join(state, "runs", id, "state.json"))) return `no such run: ${id}`;
|
|
925
926
|
const lines = readInbox(state);
|
|
926
927
|
if (lines.some((l) => l.status === "open" && l.text === text)) return "that card is already on the board";
|
|
927
|
-
writeInbox(state, [...lines, { status: "open", text, notes: `Parent: ${id}
|
|
928
|
+
writeInbox(state, [...lines, { status: "open", text, notes: [`Parent: ${id}`, details?.trim()].filter(Boolean).join("\n") }]);
|
|
928
929
|
return { parent: id };
|
|
929
930
|
}
|
|
930
931
|
|
package/cli/web.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
reviewFindings, pickFindings, backlogCards, backlogItem, startedBacklog, closeBacklog, markBacklogDone, fixPrompt, approvalPrompt, canApproveAwaiting, nativePath, closeSessions, sweepSessions, issueUrl,
|
|
10
10
|
launchMode, readSessions, ownerOf, runIndex, resumePrompt, runAgent, firstSession, webHosts,
|
|
11
11
|
} from "./state.ts";
|
|
12
|
-
import { attachPty, writePty, resizePty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
|
|
12
|
+
import { attachPty, writePty, resizePty, exitPty, ptyState, ptyKeys, adoptLoginEnv } from "./pty.ts";
|
|
13
13
|
import { readSources, addSource, removeSource, moveSource, setCategory, setWriteback, readIcebox, promote, dismiss, restore, addItem, githubRepo, markDone, markReplied, gatherSource, freshImages, gatherAll, gatherDirs, due, socialUrl, socialOf } from "./sources.ts";
|
|
14
14
|
import { projectLogo, LOGO_TYPES } from "./logo.ts";
|
|
15
15
|
import * as chatgpt from "./chatgpt.ts";
|
|
@@ -389,6 +389,16 @@ export async function web(repoDir: string, opts: Opts = {}) {
|
|
|
389
389
|
return bad("websocket expected", 426);
|
|
390
390
|
}
|
|
391
391
|
|
|
392
|
+
// The Terminal page's ×: the agent is asked to /exit, and ended if it will not.
|
|
393
|
+
if (req.method === "POST" && path === "/pty/exit") {
|
|
394
|
+
if (!local(req)) return bad("cross-origin write refused", 403);
|
|
395
|
+
let body: any;
|
|
396
|
+
try { body = await req.json(); } catch { return bad("json body expected"); }
|
|
397
|
+
const key = String(body.key ?? "");
|
|
398
|
+
if (!/^[\w-]+$/.test(key) || !ptyState(key)) return bad("no such terminal", 404);
|
|
399
|
+
return json({ ok: exitPty(key) });
|
|
400
|
+
}
|
|
401
|
+
|
|
392
402
|
// Every note of one project, text included: search, tags and backlinks are the page's.
|
|
393
403
|
if (req.method === "GET" && path === "/vault") {
|
|
394
404
|
const dir = url.searchParams.get("dir") ?? state ?? join(home, ".kaizen");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kaizen-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.44.0",
|
|
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": {
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"prepublishOnly": "bun test",
|
|
29
29
|
"postpublish": "sh cli/release.sh",
|
|
30
30
|
"publish:gh": "sh cli/publish-gh.sh",
|
|
31
|
-
"release": "bun
|
|
31
|
+
"release": "bun run cli/release.ts"
|
|
32
32
|
},
|
|
33
33
|
"repository": {
|
|
34
34
|
"type": "git",
|
package/skills/kaizen/SKILL.md
CHANGED
|
@@ -245,6 +245,12 @@ it. Everything else in this file holds unchanged.
|
|
|
245
245
|
yourself, in that order, reading only each stage's declared inputs and writing its
|
|
246
246
|
artifact before starting the next. The stage table below still applies; you are the
|
|
247
247
|
agent for every row.
|
|
248
|
+
Read `spec-build.md` before building and `spec-main.md` before moving the run to
|
|
249
|
+
`review`: being the main thread does not excuse a stage its contract. Its *Preview
|
|
250
|
+
gate* holds in full — a result that shows on a screen needs a real screenshot under
|
|
251
|
+
`runs/<id>/attachments/` embedded in `## Preview`, taken by driving a headless browser
|
|
252
|
+
against the running app; "no browser session" or "same renderer as before" is not a
|
|
253
|
+
reason, it is the gate failing. Short path or not, no screenshot means not built.
|
|
248
254
|
- `runner: full` — dispatch each stage as its own subagent, as follows.
|
|
249
255
|
|
|
250
256
|
Under `full`, each stage runs as a subagent via the Agent tool, with the agent type
|
|
@@ -183,7 +183,10 @@ failed commit is reported as printed; the run is still `done`. With the setting
|
|
|
183
183
|
|
|
184
184
|
A run may file new work as its own card instead of growing its scope — under either
|
|
185
185
|
runner. Under `full`, subagents only suggest it in their report; the main thread files it,
|
|
186
|
-
from the project folder: `kaizen card add "<
|
|
186
|
+
from the project folder: `kaizen card add "<short title>" --notes "<details>"`. The title
|
|
187
|
+
is what the card shows — a few words, like a commit subject; everything else (scope,
|
|
188
|
+
context, open questions, where it came from) goes in `--notes`, never in the title.
|
|
189
|
+
It links the card to
|
|
187
190
|
the run in flight (`--parent <run id>` names another), and the link follows the card into
|
|
188
191
|
the run it becomes. The board shows the parent's name on the child.
|
|
189
192
|
|
package/web/board/chat.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { mic, stopDictation } from "./dictation.ts";
|
|
|
4
4
|
import { $, html, raw as rawHtml } from "./dom.ts";
|
|
5
5
|
import { DK, draft } from "./drafts.ts";
|
|
6
6
|
import { attach, brief, md } from "./markdown.ts";
|
|
7
|
+
import { rich } from "./rich.ts";
|
|
7
8
|
import { toast } from "./toast.ts";
|
|
8
9
|
|
|
9
10
|
// Notes are a chat: one entry per message, files attached by paste or picker.
|
|
@@ -21,6 +22,23 @@ function parseNotes(src) {
|
|
|
21
22
|
}
|
|
22
23
|
return out.map((n) => ({ ...n, text: n.text.trim() })).filter((n) => n.text);
|
|
23
24
|
}
|
|
25
|
+
// A plain line written as "Label: … Label: …" reads as one paragraph per label.
|
|
26
|
+
// Markdown lines and fenced code are left as written.
|
|
27
|
+
export function pretty(t) {
|
|
28
|
+
let fence = false;
|
|
29
|
+
return t
|
|
30
|
+
.split("\n")
|
|
31
|
+
.map((l) => {
|
|
32
|
+
if (/^```/.test(l)) fence = !fence;
|
|
33
|
+
if (fence || /^\s*(#|```|\||[-*]\s|\d+\.\s|>)/.test(l)) return l;
|
|
34
|
+
const b = brief(l);
|
|
35
|
+
if (!b.parts.length) return l;
|
|
36
|
+
const head = l.startsWith(b.parts[0].label + ":") ? [] : [b.head];
|
|
37
|
+
return "\n" + [...head, ...b.parts.map((p) => `**${p.label}:** ${p.body}`)].join("\n\n") + "\n";
|
|
38
|
+
})
|
|
39
|
+
.join("\n")
|
|
40
|
+
.trim();
|
|
41
|
+
}
|
|
24
42
|
let nfocus = null;
|
|
25
43
|
export function chat(c, src, ro) {
|
|
26
44
|
const fix = (t) => attach(t, c);
|
|
@@ -28,9 +46,13 @@ export function chat(c, src, ro) {
|
|
|
28
46
|
const b = c.kind === "idea" ? brief(c.text) : { parts: [] };
|
|
29
47
|
let h =
|
|
30
48
|
(b.parts.length
|
|
31
|
-
? html`<section class="desc"
|
|
49
|
+
? html`<section class="desc">
|
|
50
|
+
${rawHtml(md(b.parts.map((p) => `### ${p.label}\n${p.body}`).join("\n\n")))}
|
|
51
|
+
</section>`
|
|
32
52
|
: "") +
|
|
33
|
-
html`<div class="chat"
|
|
53
|
+
html`<div class="chat">
|
|
54
|
+
${entries.map((n) => html`<div class="msg">${n.at ? html`<time>${n.at}</time>` : ""}${rawHtml(md(fix(pretty(n.text))))}</div>`)}${entries.length ? "" : html`<p style="color:var(--dim)">No notes yet.</p>`}
|
|
55
|
+
</div>`;
|
|
34
56
|
if (!ro)
|
|
35
57
|
h += `<div class="notes"><textarea placeholder="Add a note. Paste a screenshot, or attach files."></textarea><div class="att" id="natt"></div><div class="tools"><input type="file" id="nfile" multiple hidden><button class="btn ghost" id="npick">Attach</button><span>Enter sends, Shift+Enter newline</span><button class="btn pri" id="nsend">Send</button></div></div>`;
|
|
36
58
|
$("pbody").innerHTML = rawHtml(h);
|
|
@@ -38,11 +60,12 @@ export function chat(c, src, ro) {
|
|
|
38
60
|
const ta = $("pbody").querySelector("textarea"),
|
|
39
61
|
files = [];
|
|
40
62
|
draft(ta, "n:" + key(c));
|
|
63
|
+
const ed = rich(ta);
|
|
41
64
|
// Enter leaves focus in the box, and a focused box blocks the redraw: send blurs it
|
|
42
65
|
// so the note shows, and the new box takes focus back for the next note.
|
|
43
66
|
if (nfocus === key(c)) {
|
|
44
67
|
nfocus = null;
|
|
45
|
-
|
|
68
|
+
ed.focus();
|
|
46
69
|
}
|
|
47
70
|
{
|
|
48
71
|
const m = mic([ta]);
|
|
@@ -60,7 +83,7 @@ export function chat(c, src, ro) {
|
|
|
60
83
|
[...e.target.files].forEach(add);
|
|
61
84
|
e.target.value = "";
|
|
62
85
|
};
|
|
63
|
-
|
|
86
|
+
ed.onpaste = (e) => {
|
|
64
87
|
for (const it of e.clipboardData.items)
|
|
65
88
|
if (it.kind === "file") {
|
|
66
89
|
const f = it.getAsFile();
|
|
@@ -93,12 +116,12 @@ export function chat(c, src, ro) {
|
|
|
93
116
|
} catch (e) {}
|
|
94
117
|
toast("Noted");
|
|
95
118
|
nfocus = key(c);
|
|
96
|
-
|
|
119
|
+
ed.blur();
|
|
97
120
|
load();
|
|
98
121
|
}
|
|
99
122
|
};
|
|
100
123
|
$("nsend").onclick = send;
|
|
101
|
-
|
|
124
|
+
ed.onkeydown = (e) => {
|
|
102
125
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
103
126
|
e.preventDefault();
|
|
104
127
|
send();
|
package/web/board/markdown.ts
CHANGED
|
@@ -64,7 +64,8 @@ export function planQuestions(src) {
|
|
|
64
64
|
export function brief(t) {
|
|
65
65
|
// A label opens a sentence; only the first may follow the head without punctuation,
|
|
66
66
|
// and then only as one word ("… previews Problem:"), so "the Board shows: x" is prose.
|
|
67
|
-
const re =
|
|
67
|
+
const re =
|
|
68
|
+
/(^|[.!?)]\s+|\s)((?:[A-Z][A-Z-]+(?: [A-Z][A-Z-]+){1,3}|[A-Z][\w-]*(?: [a-z][\w-]*){0,3})(?: \([^)]*\))?):\s+/g,
|
|
68
69
|
cut = [];
|
|
69
70
|
let m;
|
|
70
71
|
while ((m = re.exec(t)))
|
|
@@ -88,7 +89,7 @@ export function brief(t) {
|
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// Enough markdown for kaizen's own files: headings, lists, fences, tables,
|
|
91
|
-
// inline code, bold. Everything is escaped first. With `tick`, a backlog item
|
|
92
|
+
// inline code, bold, italic, hard line breaks. Everything is escaped first. With `tick`, a backlog item
|
|
92
93
|
// (`- open: …`, `- done: …`, `- rejected: …`) shows its status as a checkbox.
|
|
93
94
|
export function md(src, tick) {
|
|
94
95
|
const lines = src.split("\n"),
|
|
@@ -99,6 +100,7 @@ export function md(src, tick) {
|
|
|
99
100
|
esc(s)
|
|
100
101
|
.replace(/`([^`]+)`/g, "<code>$1</code>")
|
|
101
102
|
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
|
|
103
|
+
.replace(/(^|[^*])\*([^*\s<][^*<]*?)\*(?!\*)/g, "$1<i>$2</i>")
|
|
102
104
|
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank"><img src="$2" alt="$1"></a>')
|
|
103
105
|
.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank">$1</a>');
|
|
104
106
|
const endList = () => {
|
|
@@ -182,7 +184,10 @@ export function md(src, tick) {
|
|
|
182
184
|
i++;
|
|
183
185
|
while (i < lines.length && lines[i].trim() && !/^(#|```|\||\s*[-*]\s|\s*\d+\.\s|---)/.test(lines[i]))
|
|
184
186
|
buf.push(lines[i++]);
|
|
185
|
-
|
|
187
|
+
// A line ending in two spaces is a hard line break.
|
|
188
|
+
out.push(
|
|
189
|
+
`<p>${inl(buf.map((l, k) => (k < buf.length - 1 && / {2}$/.test(l) ? l.trimEnd() + "\0" : l)).join(" ")).replace(/\0 /g, "<br>")}</p>`,
|
|
190
|
+
);
|
|
186
191
|
}
|
|
187
192
|
endList();
|
|
188
193
|
return out.join("");
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { esc } from "./dom.ts";
|
|
2
|
+
import { md } from "./markdown.ts";
|
|
3
|
+
|
|
4
|
+
// The card note box formats as you type: a contenteditable editor over a hidden
|
|
5
|
+
// textarea that stays the field's value, so drafts, dictation and send are unchanged.
|
|
6
|
+
// The editor is turned back into markdown on every edit; typing a markdown pattern
|
|
7
|
+
// (`**b**`, `` `c` ``, `- `, `1. `, `# `) re-renders the editor with md().
|
|
8
|
+
|
|
9
|
+
const ZW = /\u200b/g;
|
|
10
|
+
// Editor DOM to markdown. Paragraph blocks are separated by a blank line, list items
|
|
11
|
+
// by a newline, so md() renders them back as the same blocks.
|
|
12
|
+
export function toMd(root) {
|
|
13
|
+
const inl = (n) => {
|
|
14
|
+
// pre-wrap: a soft break typed in Chrome is a "\n" in the text.
|
|
15
|
+
if (n.nodeType === 3)
|
|
16
|
+
return n.textContent
|
|
17
|
+
.replace(ZW, "")
|
|
18
|
+
.replace(/\u00a0/g, " ")
|
|
19
|
+
.replace(/\n/g, " \n");
|
|
20
|
+
if (n.nodeType !== 1) return "";
|
|
21
|
+
const t = n.nodeName.toLowerCase(),
|
|
22
|
+
kids = () => [...n.childNodes].map(inl).join("");
|
|
23
|
+
if (t === "br") return " \n";
|
|
24
|
+
if (t === "b" || t === "strong") return ((s) => (s.trim() ? `**${s}**` : s))(kids());
|
|
25
|
+
if (t === "i" || t === "em") return ((s) => (s.trim() ? `*${s}*` : s))(kids());
|
|
26
|
+
if (t === "code") return ((s) => (s ? "`" + s + "`" : s))(n.textContent.replace(ZW, ""));
|
|
27
|
+
if (t === "img") return `})`;
|
|
28
|
+
if (t === "a") {
|
|
29
|
+
const img = [...n.childNodes].find((c) => c.nodeName.toLowerCase() === "img");
|
|
30
|
+
return img ? inl(img) : `[${kids()}](${n.getAttribute("href")})`;
|
|
31
|
+
}
|
|
32
|
+
return kids();
|
|
33
|
+
};
|
|
34
|
+
const out = [];
|
|
35
|
+
let buf = "";
|
|
36
|
+
const flush = () => {
|
|
37
|
+
if (buf.trim()) out.push(buf.replace(/^\n+|\s*\n+$/g, ""));
|
|
38
|
+
buf = "";
|
|
39
|
+
};
|
|
40
|
+
for (const n of root.childNodes) {
|
|
41
|
+
const t = n.nodeType === 1 ? n.nodeName.toLowerCase() : "";
|
|
42
|
+
if (/^h[1-6]$/.test(t)) {
|
|
43
|
+
flush();
|
|
44
|
+
out.push("#".repeat(+t[1]) + " " + inl(n).trim());
|
|
45
|
+
} else if (t === "ul" || t === "ol") {
|
|
46
|
+
flush();
|
|
47
|
+
const lis = [...n.childNodes].filter((c) => c.nodeName.toLowerCase() === "li");
|
|
48
|
+
out.push(lis.map((li, i) => (t === "ol" ? `${i + 1}. ` : "- ") + inl(li).trim()).join("\n"));
|
|
49
|
+
} else if (t === "pre") {
|
|
50
|
+
flush();
|
|
51
|
+
out.push("```\n" + n.textContent + "\n```");
|
|
52
|
+
} else if (t === "p" || t === "div") {
|
|
53
|
+
flush();
|
|
54
|
+
buf = toMd(n);
|
|
55
|
+
flush();
|
|
56
|
+
} else buf += inl(n);
|
|
57
|
+
}
|
|
58
|
+
flush();
|
|
59
|
+
return out.join("\n\n");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// A line just typed into a markdown pattern md() would format. Text already inside
|
|
63
|
+
// code or bold is not raw markdown: a code span showing `**` must not match.
|
|
64
|
+
const INL = /\*\*[^*]+\*\*|`[^`]+`|(^|[^*])\*[^*\s][^*]*\*(?!\*)/;
|
|
65
|
+
const typed = (line) => {
|
|
66
|
+
const raw = [];
|
|
67
|
+
const walk = (n) => {
|
|
68
|
+
if (n.nodeType === 3) raw.push(n.textContent.replace(/\u00a0/g, " "));
|
|
69
|
+
else if (!/^(CODE|B|STRONG|I|EM)$/.test(n.nodeName)) n.childNodes.forEach(walk);
|
|
70
|
+
};
|
|
71
|
+
walk(line);
|
|
72
|
+
const txt = raw.join("");
|
|
73
|
+
return (
|
|
74
|
+
INL.test(txt) ||
|
|
75
|
+
(!/^(LI|H\d)$/.test(line.nodeName) && /^\s*([-*]|\d+\.|#{1,6})\s/.test(line.textContent.replace(/\u00a0/g, " ")))
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function rich(ta) {
|
|
80
|
+
const ed = document.createElement("div");
|
|
81
|
+
ed.className = "rich";
|
|
82
|
+
ed.contentEditable = "true";
|
|
83
|
+
ed.setAttribute("role", "textbox");
|
|
84
|
+
ed.setAttribute("aria-multiline", "true");
|
|
85
|
+
ed.dataset.ph = ta.placeholder;
|
|
86
|
+
ta.hidden = true;
|
|
87
|
+
ta.after(ed);
|
|
88
|
+
// Top-level lines, list items counted one each: where the caret goes back to.
|
|
89
|
+
const lines = () => [...ed.querySelectorAll(":scope > :not(ul):not(ol), :scope > ul > li, :scope > ol > li")];
|
|
90
|
+
const render = (src) => {
|
|
91
|
+
// An empty item or heading needs a <br> to hold the caret.
|
|
92
|
+
ed.innerHTML = md(src).replace(/<(li|h\d)><\/\1>/g, "<$1><br></$1>");
|
|
93
|
+
for (const l of lines()) {
|
|
94
|
+
const last = l.lastChild;
|
|
95
|
+
if (last && last.nodeType === 1 && /^(B|I|CODE|A)$/.test(last.nodeName)) l.append("\u200b");
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const caretAt = (el) => {
|
|
99
|
+
const r = document.createRange();
|
|
100
|
+
r.selectNodeContents(el || ed);
|
|
101
|
+
r.collapse(false);
|
|
102
|
+
const s = getSelection();
|
|
103
|
+
s.removeAllRanges();
|
|
104
|
+
s.addRange(r);
|
|
105
|
+
};
|
|
106
|
+
let own = false;
|
|
107
|
+
const sync = () => {
|
|
108
|
+
own = true;
|
|
109
|
+
ta.value = toMd(ed);
|
|
110
|
+
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
|
111
|
+
own = false;
|
|
112
|
+
};
|
|
113
|
+
ed.addEventListener("input", () => {
|
|
114
|
+
const s = getSelection();
|
|
115
|
+
const all = lines();
|
|
116
|
+
const i = s.rangeCount ? all.findIndex((l) => l.contains(s.anchorNode)) : -1;
|
|
117
|
+
const line = all[i] || (s.anchorNode && s.anchorNode.parentNode === ed ? ed : null);
|
|
118
|
+
sync();
|
|
119
|
+
// Emptied: leave no stray <br>, so the placeholder shows again.
|
|
120
|
+
if (!ta.value.trim() && !ed.querySelector("img")) ed.replaceChildren();
|
|
121
|
+
else if (line && typed(line)) {
|
|
122
|
+
// ponytail: whole-editor re-render, caret to the end of the same line; per-line patching if notes grow long.
|
|
123
|
+
const at = i < 0 ? 0 : i;
|
|
124
|
+
render(ta.value);
|
|
125
|
+
caretAt(lines()[at]);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
// Dictation or a restored draft changed the value: show it.
|
|
129
|
+
ta.addEventListener("input", () => {
|
|
130
|
+
if (own) return;
|
|
131
|
+
render(ta.value);
|
|
132
|
+
if (document.activeElement === ed) caretAt(null);
|
|
133
|
+
});
|
|
134
|
+
ed.addEventListener("keydown", (e) => {
|
|
135
|
+
const mod = e.ctrlKey || e.metaKey;
|
|
136
|
+
if (mod && e.key.toLowerCase() === "b") {
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
document.execCommand("bold");
|
|
139
|
+
} else if (mod && e.key.toLowerCase() === "e") {
|
|
140
|
+
e.preventDefault();
|
|
141
|
+
const t = String(getSelection());
|
|
142
|
+
if (t) document.execCommand("insertHTML", false, `<code>${esc(t)}</code>\u200b`);
|
|
143
|
+
} else if (e.key === "Enter" && e.shiftKey) {
|
|
144
|
+
e.preventDefault();
|
|
145
|
+
// A new item in a list or after a heading; a soft line break in a paragraph.
|
|
146
|
+
const n = getSelection().anchorNode;
|
|
147
|
+
const el = n && (n.nodeType === 1 ? n : n.parentElement);
|
|
148
|
+
document.execCommand(el && el.closest("li,h1,h2,h3,h4,h5,h6") ? "insertParagraph" : "insertLineBreak");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// Files are handled by the caller; text comes in plain, never as foreign HTML.
|
|
152
|
+
ed.addEventListener("paste", (e) => {
|
|
153
|
+
if ([...e.clipboardData.items].some((it) => it.kind === "file")) return;
|
|
154
|
+
const t = e.clipboardData.getData("text/plain");
|
|
155
|
+
e.preventDefault();
|
|
156
|
+
if (t) document.execCommand("insertText", false, t);
|
|
157
|
+
});
|
|
158
|
+
if (ta.value) render(ta.value);
|
|
159
|
+
return ed;
|
|
160
|
+
}
|
package/web/board/terms.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { store } from "./store.ts";
|
|
2
2
|
import { $, html, ico } from "./dom.ts";
|
|
3
|
-
import { load } from "./api.ts";
|
|
3
|
+
import { load, post } from "./api.ts";
|
|
4
4
|
import { termLoad } from "./agent-term.ts";
|
|
5
5
|
import { open } from "./panel.ts";
|
|
6
6
|
import { resume } from "./approvals.ts";
|
|
@@ -86,7 +86,9 @@ export function openTerms() {
|
|
|
86
86
|
}
|
|
87
87
|
// An ended session closed with × has nothing to come back to: no chip for it.
|
|
88
88
|
const hid = [...all.keys()].filter((k) => lay.hidden.includes(k) && all.get(k).pty.alive);
|
|
89
|
-
|
|
89
|
+
// The layout switch sits at the right of the page header.
|
|
90
|
+
$("tmodes").innerHTML = html`${MODES.map(([v, l]) => html`<button data-mode="${v}" class="${lay.mode === v ? "on" : ""}" aria-pressed="${String(lay.mode === v)}">${l}</button>`)}`.s;
|
|
91
|
+
bar!.innerHTML = html`${hid.length ? html`<span class="dim">Hidden</span>${hid.map((k) => html`<button class="chip" data-show="${k}" title="Show this terminal">${ico("plus")}${nameOf(all.get(k))} - ${projOf(all.get(k))}</button>`)}` : ""}${phone.matches && keys.length > 1 ? html`<span class="ttabs" role="group" aria-label="Terminals">${keys.map((k) => html`<button class="${k === one ? "on" : ""}" data-pick="${k}" aria-pressed="${String(k === one)}"><span class="tdot ${tst(all.get(k))}"></span>${nameOf(all.get(k))} - ${projOf(all.get(k))}</button>`)}</span>` : ""}`.s;
|
|
90
92
|
bar!.querySelectorAll<HTMLElement>("[data-pick]").forEach(
|
|
91
93
|
(b) =>
|
|
92
94
|
(b.onclick = () => {
|
|
@@ -95,7 +97,7 @@ export function openTerms() {
|
|
|
95
97
|
}),
|
|
96
98
|
);
|
|
97
99
|
bar!.querySelector(".ttabs .on")?.scrollIntoView({ block: "nearest", inline: "nearest" });
|
|
98
|
-
|
|
100
|
+
$("tmodes").querySelectorAll<HTMLElement>("[data-mode]").forEach(
|
|
99
101
|
(b) =>
|
|
100
102
|
(b.onclick = () => {
|
|
101
103
|
lay.mode = b.dataset.mode!;
|
|
@@ -140,7 +142,7 @@ export function openTerms() {
|
|
|
140
142
|
const hd = t.el.querySelector("header")!;
|
|
141
143
|
hd.title = (c.kind === "run" ? c.id : c.text) + " · " + c.where;
|
|
142
144
|
// An ended run can be resumed from here; an idea whose agent ended has no run to resume.
|
|
143
|
-
hd.innerHTML = html`<span class="tdot ${st}" role="img" title="${st}" aria-label="${st}"></span><b>${nameOf(c)} - ${projOf(c)}</b>${off ? html`<span class="tend">ended</span>` : ""}<span class="sp"></span>${off && c.kind === "run" ? html`<button class="btn sm" data-a="resume" title="Continue this run's agent">Resume</button>` : ""}<button class="ib sm" data-a="card" title="Open card" aria-label="Open card">${ico("open")}</button><button class="ib sm" data-a="max" title="${lay.max === k ? "Restore" : "Maximise"}" aria-label="${lay.max === k ? "Restore" : "Maximise"}">${ico(lay.max === k ? "min" : "max")}</button
|
|
145
|
+
hd.innerHTML = html`<span class="tdot ${st}" role="img" title="${st}" aria-label="${st}"></span><b>${nameOf(c)} - ${projOf(c)}</b>${off ? html`<span class="tend">ended</span>` : ""}<span class="sp"></span>${off && c.kind === "run" ? html`<button class="btn sm" data-a="resume" title="Continue this run's agent">Resume</button>` : ""}<button class="ib sm" data-a="card" title="Open card" aria-label="Open card">${ico("open")}</button><button class="ib sm" data-a="max" title="${lay.max === k ? "Restore" : "Maximise"}" aria-label="${lay.max === k ? "Restore" : "Maximise"}">${ico(lay.max === k ? "min" : "max")}</button>${off ? "" : html`<button class="ib sm" data-a="hide" title="Hide" aria-label="Hide">${ico("dash")}</button>`}<button class="ib sm" data-a="${off ? "hide" : "exit"}" title="${off ? "Close" : "Exit session"}" aria-label="${off ? "Close" : "Exit session"}">${ico("close")}</button>`.s;
|
|
144
146
|
t.el.classList.toggle("ended", off);
|
|
145
147
|
t.el.classList.toggle("off", !!one && one !== k);
|
|
146
148
|
t.el.style.gridColumn = "";
|
|
@@ -180,6 +182,10 @@ function wire(el: HTMLElement) {
|
|
|
180
182
|
else if (a === "hide") {
|
|
181
183
|
lay.hidden.push(k);
|
|
182
184
|
if (lay.max === k) lay.max = null;
|
|
185
|
+
} else if (a === "exit") {
|
|
186
|
+
// The tile stays, turning "ended" with its Resume button when the agent is gone.
|
|
187
|
+
if (confirm("End this agent session? A run can be resumed from its tile or card.")) post("/pty/exit", { key: k }).then(() => load(true));
|
|
188
|
+
return;
|
|
183
189
|
} else if (a === "card") {
|
|
184
190
|
const c = sessions().get(k);
|
|
185
191
|
if (c) open(c);
|