kaizen-agent 1.43.3 → 1.45.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/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
+ }
@@ -86,3 +86,11 @@ test("a config still holding git.branch_before_implement keeps its preset", () =
86
86
  expect(detectPreset(low, text)).toBe("low");
87
87
  expect(detectPreset(text, text)).toBe("medium");
88
88
  });
89
+
90
+ test("a low-preset config saved before review.executor existed still reads as low", () => {
91
+ const text = readFileSync(defaults, "utf8");
92
+ const low = applyPreset("", "low", text);
93
+ const old = low.replace(/(review:\n(?:\s*#.*\n)*)\s*executor: inline\n/, "$1");
94
+ expect(old).not.toContain("executor: inline\n # Let");
95
+ expect(detectPreset(old, text)).toBe("low");
96
+ });
package/cli/settings.ts CHANGED
@@ -25,6 +25,7 @@ export const PRESETS: Record<PresetName, {
25
25
  mode: "approve",
26
26
  runner: "lite",
27
27
  "build.executor": "inline",
28
+ "review.executor": "inline",
28
29
  "approvals.plan": "true",
29
30
  "approvals.review": "true",
30
31
  "approvals.each_file": "false",
@@ -105,6 +106,7 @@ const SETTINGS: Setting[] = [
105
106
  { key: "approvals.each_file", label: "Confirm every file edit", group: "Approvals", values: ["true", "false"], help: "", when: ["mode", "approve"] },
106
107
  { key: "runner", label: "Stage isolation", group: "Agents", values: ["full", "lite"], help: "full: separate agent per stage. lite: one session" },
107
108
  { key: "build.executor", label: "Who builds", group: "Agents", values: ["subagent", "inline", "ask"], help: "subagent: its own agent · inline: this session · ask: pick at plan approval", when: ["runner", "full"] },
109
+ { key: "review.executor", label: "Reviewer", group: "Agents", values: ["inline", "subagent", "ask"], help: "subagent: its own cold agent · inline: this session · ask: pick before review", when: ["runner", "lite"] },
108
110
  { key: "agent.default", label: "Tool the board launches", group: "Agents", values: ["auto", "claude", "codex", "agy", "opencode", "gemini"], help: "" },
109
111
  { key: "agent.launch", label: "Where the board runs agents", group: "Agents", values: ["card", "terminal"], help: "" },
110
112
  { key: "subagents.model", label: "Model for subagents", group: "Agents", values: ["inherit", "opus", "sonnet", "haiku"], help: "Planner, builder, reviewer", when: ["runner", "full"] },
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");
@@ -1,5 +1,5 @@
1
1
  ---
2
- description: Run kaizen with every stage in this session — cheaper, and the reviewer has seen the work
2
+ description: Run kaizen with every stage in this session — cheaper; the reviewer has seen the work unless review.executor is subagent
3
3
  ---
4
4
 
5
5
  Invoke the `kaizen` skill with argument `lite $ARGUMENTS`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaizen-agent",
3
- "version": "1.43.3",
3
+ "version": "1.45.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 publish && bun run publish:gh"
31
+ "release": "bun run cli/release.ts"
32
32
  },
33
33
  "repository": {
34
34
  "type": "git",
@@ -42,7 +42,7 @@ Porting the workflow to Codex or Antigravity is covered in [`adapters.md`](adapt
42
42
  | `/kaizen <request>` | Start a new run in the configured mode (default: `approve`) |
43
43
  | `/kaizen plan <request>` | Start a run and stop after the plan (`plan-only`) |
44
44
  | `/kaizen auto <request>` | Start a run in `auto` mode: every stage back to back, no approval stops |
45
- | `/kaizen lite <request>` | Run every stage in this session — cheap, and the reviewer has seen the work |
45
+ | `/kaizen lite <request>` | Run every stage in this session — cheap, and the reviewer has seen the work unless `review.executor` is `subagent` |
46
46
  | `/kaizen full <request>` | Dispatch every stage as its own agent — the reviewer starts cold |
47
47
  | `/kaizen run` | Resume the current run and execute the approved plan |
48
48
  | `/kaizen review [target]` | Review-only: audit existing code, no plan, no implementation (`review-only`) |
@@ -210,11 +210,13 @@ would cost in one session and several times that. Set it for the project with
210
210
  The reviewer has never seen how the work was done, so it has nothing to defend.
211
211
  That is the entire reason its findings are worth reading, and it is what catches the
212
212
  bug in a fix rather than the bug in the plan.
213
- - **`lite`** — this session runs every stage itself, in order, as
213
+ - **`lite`** — this session runs every stage itself (the review too, unless `review.executor` says otherwise), in order, as
214
214
  [`adapters.md`](adapters.md) already describes for tools that have no subagents.
215
215
  Read only the stage's declared inputs, write its artifact before starting the next,
216
216
  and when reviewing, judge the work on its own terms rather than defending the
217
217
  reasoning you used producing it.
218
+ `review.executor: subagent` hands just the review to a cold agent, which buys
219
+ back the independence for the price of one stage.
218
220
 
219
221
  Everything else holds in both: the plan, the approval stop, the numbered gate answered
220
222
  with evidence, numbered findings, the files, and a run another tool can pick up.
@@ -227,7 +229,7 @@ produced it — and a fix that is worse than the finding it closes is exactly th
227
229
 
228
230
  So: `lite` where being wrong is cheap and obvious, which is also where the short path
229
231
  above applies. `full` where being wrong is expensive. When a run is going to
230
- touch money, a migration, or anything hard to undo, say which runner it used at the
232
+ touch money, a migration, or anything hard to undo, say which runner (and, for `lite`, which reviewer) it used at the
231
233
  final report, so nobody reads a same-session review as an independent one.
232
234
 
233
235
  ## Running a stage
@@ -241,7 +243,7 @@ it. Everything else in this file holds unchanged.
241
243
 
242
244
  **Check `runner` first.**
243
245
 
244
- - `runner: lite` — do not call the Agent tool. You run plan, build and review
246
+ - `runner: lite` — do not call the Agent tool, except for the review as below. You run plan, build and review
245
247
  yourself, in that order, reading only each stage's declared inputs and writing its
246
248
  artifact before starting the next. The stage table below still applies; you are the
247
249
  agent for every row.
@@ -251,6 +253,9 @@ it. Everything else in this file holds unchanged.
251
253
  `runs/<id>/attachments/` embedded in `## Preview`, taken by driving a headless browser
252
254
  against the running app; "no browser session" or "same renderer as before" is not a
253
255
  reason, it is the gate failing. Short path or not, no screenshot means not built.
256
+ The review stage follows `review.executor`: `inline` (default) reviews in this session; `subagent` dispatches
257
+ `kaizen-reviewer` cold, as under `full`, for the review and every recheck; `ask` puts the choice to the user when the build is done.
258
+ Record the answer as `review_executor` in `state.json` so a resumed run keeps it.
254
259
  - `runner: full` — dispatch each stage as its own subagent, as follows.
255
260
 
256
261
  Under `full`, each stage runs as a subagent via the Agent tool, with the agent type
@@ -271,7 +276,7 @@ the choice at the plan approval. Building inline does not make the review less
271
276
  independent: the reviewer is a separate agent in every case, and never sees the
272
277
  conversation the work happened in.
273
278
 
274
- `subagents.model` and `subagents.effort` apply to every stage dispatched this way.
279
+ `subagents.model` and `subagents.effort` apply to every stage dispatched this way, including a lite run's reviewer under `review.executor: subagent`.
275
280
  Pass `model` on the Agent call when it is not `inherit`. Effort has no call-time
276
281
  knob: when it is not `inherit`, make sure the frontmatter of the agent's definition
277
282
  (`~/.claude/agents/kaizen-<stage>.md`, or the project's `.claude/agents/` copy)
@@ -59,6 +59,14 @@ auto_fix:
59
59
  max_iterations: 2
60
60
 
61
61
  review:
62
+ # Who reviews a lite run. Only read when runner is lite; under full the reviewer is
63
+ # always its own agent.
64
+ # inline - this session reviews its own work. Cheapest; expect it to miss what
65
+ # it just did. The default, so a lite run costs what it always has.
66
+ # subagent - plan and build stay in this session, the review (and every recheck in
67
+ # the fix loop) goes to a cold reviewer agent that never saw the work.
68
+ # ask - offer the choice when the build is done, before the review.
69
+ executor: inline
62
70
  # Let the reviewer append durable lessons to .kaizen/memory.md.
63
71
  write_memory: true
64
72
  # Every stage of every future run reads memory.md, so cap it. Over this, the reviewer
@@ -15,6 +15,7 @@ nearest first, so a change lands globally unless this project keeps its own conf
15
15
  | `mode` | `approve`, `auto`, `plan-only`, `review-only` | Where a run stops |
16
16
  | `runner` | `full`, `lite` | Whether each stage is its own cold agent, or this session runs them all |
17
17
  | `build.executor` | `subagent`, `inline`, `ask` | Who carries out the approved plan |
18
+ | `review.executor` | `inline`, `subagent`, `ask` | Who reviews a lite run: this session, a cold reviewer agent, or ask before the review |
18
19
  | `approvals.plan` | `true`, `false` | Stop and show the plan before anything is built |
19
20
  | `approvals.review` | `true`, `false` | Stop after the review, before the run is called done |
20
21
  | `approvals.each_file` | `true`, `false` | Confirm every individual edit |
@@ -34,7 +35,8 @@ nearest first, so a change lands globally unless this project keeps its own conf
34
35
  | `agent.default` | `auto`, `claude`, `codex`, `agy`, `opencode`, `gemini` | Coding agent to launch from dashboard backlog |
35
36
 
36
37
  Some settings only have an effect while another stands a certain way: the approvals
37
- while `mode` is `approve`, `build.executor` and `subagents.*` while `runner` is `full`,
38
+ while `mode` is `approve`, `build.executor` and `subagents.*` while `runner` is `full` (`subagents.*` also reaches a lite
39
+ run's reviewer under `review.executor: subagent`), `review.executor` while `runner` is `lite`,
38
40
  `auto_fix.min_severity` and `auto_fix.max_iterations` while `auto_fix.enabled` is
39
41
  `true`, `sources.model` and `sources.effort` while `sources.agent` is `chatgpt`. When
40
42
  printing one of these that has no effect now, say so beside its value. Still offer it:
@@ -138,7 +138,7 @@ Controlled by `approvals.review`, skipped in `auto`.
138
138
  Report: what was built (for a code run, show its Preview proof), verification results, findings fixed, findings still open,
139
139
  every criterion the reviewer answered `CANNOT VERIFY` with the check it says a human
140
140
  must make, anything the builder noted as out of scope, and the notes the *Docs gate*
141
- touched. Where the run used `runner: lite`, say
141
+ touched. Where the run used `runner: lite` and reviewed inline (`review.executor: inline`), say
142
142
  so in that report — the reviewer saw the work, and nobody should read a same-session
143
143
  review as an independent one because the output looked the same.
144
144
  Later build requests in the same session follow *Follow-ups are runs too* in [`spec.md`](spec.md): a revise of this run until it is `done`, then a new linked run.
@@ -111,6 +111,7 @@ Write into `00-request.md`: the verbatim request, the deliverable, what done mea
111
111
  and what wrong means. Record in `state.json`: `id`, `mode`, `runner`, `agent`, `track`,
112
112
  `stage: "plan"`, `awaiting: null`, `iteration: 0`, and `updated` — the time of this
113
113
  write, refreshed on every later one.
114
+ A `lite` run also records `review_executor` (`inline` or `subagent`, from `review.executor`, `ask` resolved) once it is known, and a resumed run keeps it.
114
115
  Where the environment has `CLAUDE_CODE_SESSION_ID`, also record it as `session`: the
115
116
  board keeps the run on the card it was launched from by that id.
116
117
 
@@ -23,7 +23,7 @@ anything.
23
23
  | `/kaizen <request>` | Start a new run in the configured mode (default: `approve`) |
24
24
  | `/kaizen plan <request>` | Start a run and stop after the plan (`plan-only`) |
25
25
  | `/kaizen auto <request>` | Start a run in `auto` mode: every stage back to back, no approval stops |
26
- | `/kaizen lite <request>` | Run the stages in this session — cheap, reviewer has seen the work |
26
+ | `/kaizen lite <request>` | Run the stages in this session — cheap, reviewer has seen the work (unless `review.executor: subagent`) |
27
27
  | `/kaizen full <request>` | Dispatch each stage as its own cold agent |
28
28
  | `/kaizen run` | Resume the current run and execute the approved plan |
29
29
  | `/kaizen review [target]` | Review-only: audit existing code, no plan, no implementation |
@@ -85,6 +85,7 @@ Each iteration is written to `runs/<id>/05-iterations/`.
85
85
  | `approvals.review` | `false` | Stop after the review |
86
86
  | `approvals.each_file` | `false` | Confirm every file edit |
87
87
  | `build.executor` | `subagent` | Who builds: `subagent`, `inline` (main thread), or `ask` at the approval |
88
+ | `review.executor` | `inline` | Lite only — who reviews: `inline` (this session), `subagent` (cold agent), or `ask` |
88
89
  | `auto_fix.enabled` | `true` | Auto-fix findings vs. escalate everything |
89
90
  | `auto_fix.min_severity` | `high` | Threshold for auto-fix (`critical\|high\|medium\|low`) |
90
91
  | `auto_fix.max_iterations` | `2` | Fix/recheck rounds before escalating remaining findings |
@@ -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";
@@ -13,7 +13,7 @@ import { resume } from "./approvals.ts";
13
13
  // ponytail: a √n grid with draggable borders, not an i3-style split tree; add one if a grid falls short.
14
14
  // mode: grid (√n), cols (side by side) or rows (stacked, so borders drag up and down).
15
15
  type Lay = { mode: string; order: string[]; hidden: string[]; max: string | null; sizes: Record<string, { c: number[]; r: number[] }> };
16
- const MODES = [["grid", "Grid"], ["cols", "Side by side"], ["rows", "Stacked"]];
16
+ const MODES = [["grid", "Grid", "lgrid"], ["cols", "Side by side", "lcols"], ["rows", "Stacked", "lrows"]];
17
17
  const GAP = 6;
18
18
  let lay: Lay = { mode: "grid", order: [], hidden: [], max: null, sizes: {} };
19
19
  try {
@@ -51,11 +51,32 @@ function sessions() {
51
51
 
52
52
  export function termsStop() {
53
53
  showing = false;
54
+ document.body.classList.remove("tmax");
54
55
  for (const t of tiles.values()) t.off?.();
55
56
  tiles.clear();
56
57
  $("terms").innerHTML = "";
57
58
  }
58
59
 
60
+ // A header button took focus off the terminal; give it back so typing carries on.
61
+ const focusTerm = (k: string) => tiles.get(k)?.el.querySelector<HTMLElement>(".xterm-helper-textarea")?.focus({ preventScroll: true });
62
+ // Ctrl+Shift+M maximises or restores the tile being typed in. Escape belongs to the agent,
63
+ // so the key is read in capture, before xterm sends it on.
64
+ if (typeof window !== "undefined") addEventListener(
65
+ "keydown",
66
+ (e) => {
67
+ if (!showing || !e.ctrlKey || !e.shiftKey || e.altKey || e.metaKey || e.code !== "KeyM" || phone?.matches) return;
68
+ const k = (e.target as HTMLElement).closest?.<HTMLElement>(".tile")?.dataset.k || lay.max;
69
+ if (!k) return;
70
+ e.preventDefault();
71
+ e.stopPropagation();
72
+ lay.max = lay.max === k ? null : k;
73
+ save();
74
+ openTerms();
75
+ focusTerm(k);
76
+ },
77
+ true,
78
+ );
79
+
59
80
  export function openTerms() {
60
81
  showing = true;
61
82
  if (!phone) (phone = matchMedia("(max-width:640px)")).onchange = () => showing && openTerms();
@@ -86,7 +107,9 @@ export function openTerms() {
86
107
  }
87
108
  // An ended session closed with × has nothing to come back to: no chip for it.
88
109
  const hid = [...all.keys()].filter((k) => lay.hidden.includes(k) && all.get(k).pty.alive);
89
- bar!.innerHTML = html`<span class="bar" role="group" aria-label="Layout">${MODES.map(([v, l]) => html`<button data-mode="${v}" class="${lay.mode === v ? "on" : ""}" aria-pressed="${String(lay.mode === v)}">${l}</button>`)}</span>${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;
110
+ // The layout switch sits at the right of the page header.
111
+ $("tmodes").innerHTML = html`${MODES.map(([v, l, i]) => html`<button data-mode="${v}" class="${lay.mode === v ? "on" : ""}" aria-pressed="${String(lay.mode === v)}" title="${l}" aria-label="${l}">${ico(i)}</button>`)}`.s;
112
+ 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
113
  bar!.querySelectorAll<HTMLElement>("[data-pick]").forEach(
91
114
  (b) =>
92
115
  (b.onclick = () => {
@@ -95,7 +118,7 @@ export function openTerms() {
95
118
  }),
96
119
  );
97
120
  bar!.querySelector(".ttabs .on")?.scrollIntoView({ block: "nearest", inline: "nearest" });
98
- bar!.querySelectorAll<HTMLElement>("[data-mode]").forEach(
121
+ $("tmodes").querySelectorAll<HTMLElement>("[data-mode]").forEach(
99
122
  (b) =>
100
123
  (b.onclick = () => {
101
124
  lay.mode = b.dataset.mode!;
@@ -122,6 +145,8 @@ export function openTerms() {
122
145
  );
123
146
  return;
124
147
  }
148
+ const had = document.activeElement;
149
+ let i = 0;
125
150
  for (const k of keys) {
126
151
  const c = all.get(k);
127
152
  let t = tiles.get(k);
@@ -140,12 +165,19 @@ export function openTerms() {
140
165
  const hd = t.el.querySelector("header")!;
141
166
  hd.title = (c.kind === "run" ? c.id : c.text) + " · " + c.where;
142
167
  // 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><button class="ib sm" data-a="hide" title="${off ? "Close" : "Hide"}" aria-label="${off ? "Close" : "Hide"}">${ico("close")}</button>`.s;
168
+ 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"} (Ctrl+Shift+M)" 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
169
  t.el.classList.toggle("ended", off);
145
170
  t.el.classList.toggle("off", !!one && one !== k);
171
+ t.el.classList.toggle("max", !phone.matches && lay.max === k);
146
172
  t.el.style.gridColumn = "";
147
- grid.append(t.el); // in order
173
+ // In order, moving only what is out of place: a moved tile loses its terminal's focus,
174
+ // and every /state tick came here, so typed keys fell to the page (Firefox's find bar).
175
+ if (grid.children[i] !== t.el) grid.insertBefore(t.el, grid.children[i] || null);
176
+ i++;
148
177
  }
178
+ if (had instanceof HTMLElement && had !== document.activeElement && had.isConnected) had.focus({ preventScroll: true });
179
+ // Maximised fills the window, over the sidebar and header: with one terminal it was already the whole grid.
180
+ document.body.classList.toggle("tmax", !!lay.max && !phone.matches);
149
181
  // Grid is √n, the last tile spanning what its row leaves empty; side by side is one row, stacked one column.
150
182
  const n = one ? 1 : keys.length,
151
183
  cols = lay.mode === "cols" ? n : lay.mode === "rows" ? 1 : Math.ceil(Math.sqrt(n)),
@@ -180,6 +212,10 @@ function wire(el: HTMLElement) {
180
212
  else if (a === "hide") {
181
213
  lay.hidden.push(k);
182
214
  if (lay.max === k) lay.max = null;
215
+ } else if (a === "exit") {
216
+ // The tile stays, turning "ended" with its Resume button when the agent is gone.
217
+ if (confirm("End this agent session? A run can be resumed from its tile or card.")) post("/pty/exit", { key: k }).then(() => load(true));
218
+ return;
183
219
  } else if (a === "card") {
184
220
  const c = sessions().get(k);
185
221
  if (c) open(c);
@@ -192,6 +228,7 @@ function wire(el: HTMLElement) {
192
228
  } else return;
193
229
  save();
194
230
  openTerms();
231
+ focusTerm(k);
195
232
  };
196
233
  el.querySelector("header")!.ondblclick = (e) => {
197
234
  // A phone shows one tile anyway: a double-tap there would only leave desktop maximised.
@@ -199,6 +236,7 @@ function wire(el: HTMLElement) {
199
236
  lay.max = lay.max === k ? null : k;
200
237
  save();
201
238
  openTerms();
239
+ focusTerm(k);
202
240
  };
203
241
  // Drag a header onto another tile: the two swap places.
204
242
  el.querySelector("header")!.ondragstart = (e) => {
package/web/board.html CHANGED
@@ -272,7 +272,7 @@ body.paged:not(.ice) .hdr,body.ice aside.docked>header{display:none}body.ice #ne
272
272
  /* The Terminal page: the project bar stays (it picks whose terminals show), the board gives way to the tiles */
273
273
  #terms{display:none}body.termv.paged .hdr{display:flex}body.termv #newtop,body.termv #fbtn{display:none!important}
274
274
  body.termv #terms{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:10px 20px 14px 28px}
275
- .tbar{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12px}.tbar .bar{margin-right:8px}.tbar .bar button{padding:0 10px;font-size:12px}.tbar .chip{cursor:pointer;background:none;border:0;font:inherit;font-size:11px}.tbar .chip:hover{color:var(--ink)}.tbar svg{width:12px;height:12px}
275
+ .tbar{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:12px}#tmodes{display:none}body.termv #tmodes{display:inline-flex}#tmodes button{display:inline-grid;place-items:center;padding:0 8px}#tmodes svg{width:16px;height:16px}.tbar .chip{cursor:pointer;background:none;border:0;font:inherit;font-size:11px}.tbar .chip:hover{color:var(--ink)}.tbar svg{width:12px;height:12px}
276
276
  .tgrid{position:relative;flex:1;min-height:0;display:grid;gap:6px}
277
277
  .tile{display:flex;flex-direction:column;min-width:0;min-height:0;border:1px solid var(--line);border-radius:var(--r-s);background:var(--bg);overflow:hidden}.tile.off{display:none}.tile.drop{outline:2px solid var(--accent);outline-offset:-2px}
278
278
  .tile header{display:flex;align-items:center;gap:8px;min-height:30px;padding:0 4px 0 10px;border-bottom:1px solid var(--line);background:var(--bg-2);cursor:grab;user-select:none}
@@ -281,11 +281,12 @@ body.termv #terms{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px
281
281
  .tdot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--run)}.tdot.asking{background:var(--warn)}.tdot.ended{background:var(--dim);opacity:.6}
282
282
  .tile .term{flex:1;min-height:0}.tile .term .xterm{height:100%;padding:4px}.tile .term .xterm-viewport{background:transparent!important}
283
283
  .tsplit{position:absolute;z-index:2;touch-action:none}.tsplit.c{top:0;width:10px;margin-left:-5px;cursor:col-resize}.tsplit.r{left:0;right:0;height:10px;margin-top:-5px;cursor:row-resize}.tsplit:hover,.tdrag .tsplit:active{background:color-mix(in srgb,var(--accent) 35%,transparent)}
284
+ .tile.max{position:fixed;inset:8px;z-index:10;box-shadow:var(--shadow-1)}body.tmax .tsplit{display:none}
284
285
  body.tdrag{user-select:none}body.tdrag .tile{pointer-events:none}
285
286
  @media(max-width:900px){body.termv #terms{padding-inline:16px}}
286
287
  /* A phone has no room for a grid or a stack: one terminal fills the page, and tabs above pick which */
287
288
  .ttabs{display:none}
288
- @media(max-width:640px){.tbar .bar,.tile [data-a=max],.tsplit{display:none}.ttabs{display:flex;order:-1;flex:1 0 100%;min-width:0;gap:6px;overflow-x:auto;scrollbar-width:none}.ttabs button{flex:none;display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px;border:1px solid var(--line);border-radius:var(--r-pill);background:none;color:var(--dim);font:inherit;font-size:12px;white-space:nowrap}.ttabs button.on{color:var(--ink);background:var(--hover);border-color:var(--accent)}.tgrid{display:flex;flex-direction:column}.tile{flex:1}.tile.off{display:none}}
289
+ @media(max-width:640px){body.termv #tmodes,.tile [data-a=max],.tsplit{display:none}.ttabs{display:flex;order:-1;flex:1 0 100%;min-width:0;gap:6px;overflow-x:auto;scrollbar-width:none}.ttabs button{flex:none;display:inline-flex;align-items:center;gap:6px;height:30px;padding:0 10px;border:1px solid var(--line);border-radius:var(--r-pill);background:none;color:var(--dim);font:inherit;font-size:12px;white-space:nowrap}.ttabs button.on{color:var(--ink);background:var(--hover);border-color:var(--accent)}.tgrid{display:flex;flex-direction:column}.tile{flex:1}.tile.off{display:none}}
289
290
  aside.docked{position:static;z-index:auto;flex:1;min-height:0;width:auto;transform:none;visibility:visible;transition:none;box-shadow:none;border:0;background:none}
290
291
  aside.docked>header,aside.docked>.body{padding-inline:28px}
291
292
  aside.docked>header{border-bottom:0;padding-top:28px;padding-bottom:12px}
@@ -535,6 +536,10 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
535
536
  <symbol id="i-term" viewBox="0 0 24 24"><path d="M4 17l6-5-6-5M12 19h8"/></symbol>
536
537
  <symbol id="i-max" viewBox="0 0 24 24"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></symbol>
537
538
  <symbol id="i-min" viewBox="0 0 24 24"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></symbol>
539
+ <symbol id="i-lgrid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></symbol>
540
+ <symbol id="i-lcols" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M12 3v18"/></symbol>
541
+ <symbol id="i-lrows" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 12h18"/></symbol>
542
+ <symbol id="i-dash" viewBox="0 0 24 24"><path d="M5 12h14"/></symbol>
538
543
  <symbol id="i-close" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></symbol>
539
544
  <symbol id="i-more" viewBox="0 0 24 24"><circle cx="5" cy="12" r="1.2" fill="currentColor"/><circle cx="12" cy="12" r="1.2" fill="currentColor"/><circle cx="19" cy="12" r="1.2" fill="currentColor"/></symbol>
540
545
  <symbol id="i-open" viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M15 3v18"/></symbol>
@@ -606,6 +611,7 @@ a.schip:hover{color:var(--ink);border-color:color-mix(in srgb,var(--line) 60%,va
606
611
  <button class="ib" id="fbtn" aria-label="Filter by status" title="Filter by status" aria-expanded="false" hidden><svg><use href="#i-filter"/></svg></button>
607
612
  <input type="search" class="sel" id="bq" placeholder="Filter cards (press /)" aria-label="Filter cards" spellcheck="false" autocomplete="off">
608
613
  <span class="sp"></span>
614
+ <span class="bar" id="tmodes" role="group" aria-label="Layout"></span>
609
615
  <button class="btn pri" id="newtop">New idea</button>
610
616
  </div>
611
617
  <main id="board" aria-live="polite" aria-busy="true"><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section><section aria-hidden="true"><span class="skel" style="height:14px;width:40%;margin:4px 0 12px"></span><span class="skel" style="height:72px;margin-bottom:8px"></span><span class="skel" style="height:72px"></span></section></main>