@pi-archimedes/subagent 2.10.4 → 2.11.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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/package.json +10 -10
  3. package/src/agent-panel.mcp.test.ts +76 -0
  4. package/src/agent-panel.tools.test.ts +162 -0
  5. package/src/agent-panel.ts +37 -8
  6. package/src/agent-panel.width-audit.test.ts +258 -0
  7. package/src/agents-parse.test.ts +252 -0
  8. package/src/agents.ts +72 -5
  9. package/src/compact.test.ts +136 -57
  10. package/src/compact.ts +57 -37
  11. package/src/dispatch.test.ts +73 -1
  12. package/src/dispatch.ts +20 -2
  13. package/src/execute.test.ts +9 -1
  14. package/src/execute.ts +26 -29
  15. package/src/expanded.test.ts +61 -0
  16. package/src/expanded.ts +96 -80
  17. package/src/format.test.ts +71 -29
  18. package/src/format.ts +71 -28
  19. package/src/frontmatter-io.test.ts +62 -0
  20. package/src/frontmatter-io.ts +16 -2
  21. package/src/handlers.test.ts +21 -3
  22. package/src/handlers.ts +22 -3
  23. package/src/index.output-schema.test.ts +317 -0
  24. package/src/index.test.ts +486 -0
  25. package/src/index.ts +168 -8
  26. package/src/render.ts +46 -36
  27. package/src/row-block.test.ts +195 -0
  28. package/src/row-block.ts +186 -0
  29. package/src/run-outcome.test.ts +71 -0
  30. package/src/run-outcome.ts +84 -0
  31. package/src/spawn.test.ts +204 -19
  32. package/src/spawn.ts +49 -17
  33. package/src/stream.test.ts +215 -17
  34. package/src/stream.ts +47 -9
  35. package/src/thinking-validation.test.ts +96 -0
  36. package/src/thinking-validation.ts +78 -0
  37. package/src/tool-declarations.test.ts +58 -0
  38. package/src/tool-row.width-audit.test.ts +720 -0
  39. package/src/tool-schema.ts +85 -0
  40. package/src/tool-validation.test.ts +321 -0
  41. package/src/tool-validation.ts +262 -0
  42. package/src/types.ts +86 -0
  43. package/src/usage.test.ts +95 -0
  44. package/src/usage.ts +87 -0
  45. package/src/cost.test.ts +0 -62
  46. package/src/cost.ts +0 -8
package/README.md CHANGED
@@ -112,6 +112,6 @@ Available with the suite (and subagent enabled), `/agents` opens the interactive
112
112
 
113
113
  ## Part of the suite
114
114
 
115
- The full suite is the supported connected setup. With the relevant components loaded together, subagent token and cost events flow over core's bus into the footer, subagent tasks get their own columns on the todo board, and subagent `ask` questions surface in your terminal. On/off is managed by the suite: toggle via `/plugins` (`archimedes.subagent.enabled`, default on).
115
+ The full suite is the supported connected setup. With the relevant components loaded together, a subagent's tokens and cost ride its own tool result into the footer's totals, subagent tasks get their own columns on the todo board, and subagent `ask` questions surface in your terminal. On/off is managed by the suite: toggle via `/plugins` (`archimedes.subagent.enabled`, default on).
116
116
 
117
117
  ← [Back to pi-archimedes](https://github.com/danielcherubini/pi-archimedes)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/subagent",
3
- "version": "2.10.4",
3
+ "version": "2.11.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -15,20 +15,20 @@
15
15
  ],
16
16
  "main": "./src/index.ts",
17
17
  "dependencies": {
18
- "@pi-archimedes/core": "2.10.4",
19
- "@pi-archimedes/todo": "2.10.4"
18
+ "@pi-archimedes/core": "2.11.0",
19
+ "@pi-archimedes/todo": "2.11.0"
20
20
  },
21
21
  "peerDependencies": {
22
- "@earendil-works/pi-ai": ">=0.1.0",
23
- "@earendil-works/pi-coding-agent": ">=0.1.0",
24
- "@earendil-works/pi-tui": ">=0.1.0",
22
+ "@earendil-works/pi-ai": ">=1.2.0",
23
+ "@earendil-works/pi-coding-agent": ">=1.2.0",
24
+ "@earendil-works/pi-tui": ">=1.2.0",
25
25
  "typebox": ">=1.1.0"
26
26
  },
27
27
  "devDependencies": {
28
- "@earendil-works/pi-ai": "^0.87.0",
29
- "@earendil-works/pi-coding-agent": "^0.87.0",
30
- "@earendil-works/pi-tui": "^0.87.0",
31
- "@types/node": "^22.0.0",
28
+ "@earendil-works/pi-ai": "1.2.0",
29
+ "@earendil-works/pi-coding-agent": "1.2.0",
30
+ "@earendil-works/pi-tui": "1.2.0",
31
+ "@types/node": "22.19.19",
32
32
  "typebox": "^1.1.38",
33
33
  "typescript": "^6.0.3"
34
34
  },
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The `/agents` panel's half of the tool-allowlist contract: the detail screen
3
+ * has to say whether an agent's child runs with MCP tools, and the tools field
4
+ * has to refuse entries the `pi` CLI cannot take as a name.
5
+ *
6
+ * The panel is driven through its own `handleInput` so the assertions run on the
7
+ * frames a user would actually see (same approach as the width audit).
8
+ */
9
+
10
+ import { describe, it, expect } from "vitest";
11
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
12
+ import { createAgentPanel } from "./agent-panel.js";
13
+ import type { AgentConfig } from "./agents.js";
14
+
15
+ const theme = { fg: (_token: string, text: string) => text, bold: (text: string) => text };
16
+
17
+ const ENTER = "\r";
18
+
19
+ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
20
+ return {
21
+ name: "reviewer",
22
+ description: "reviews things",
23
+ systemPrompt: "be terse",
24
+ source: "user",
25
+ filePath: "/tmp/agents/reviewer.md",
26
+ tools: ["read", "bash"],
27
+ ...overrides,
28
+ };
29
+ }
30
+
31
+ function makePanel(agents: AgentConfig[]) {
32
+ return createAgentPanel(
33
+ agents,
34
+ [],
35
+ [],
36
+ null,
37
+ "/tmp/agents",
38
+ null,
39
+ { requestRender: () => {} },
40
+ theme,
41
+ () => {},
42
+ [],
43
+ [
44
+ { name: "read", description: "read a file" },
45
+ { name: "bash", description: "run a command" },
46
+ ],
47
+ );
48
+ }
49
+
50
+ /** The frame as drawn text (ANSI stripped), one entry per row. */
51
+ function drawn(panel: ReturnType<typeof makePanel>): string[] {
52
+ return panel.render(84).map((l) => stripTerminalSequences(l));
53
+ }
54
+
55
+ function rowContaining(lines: string[], needle: string): string | undefined {
56
+ return lines.find((l) => l.includes(needle));
57
+ }
58
+
59
+ describe("/agents detail screen: the mcp opt-in is visible", () => {
60
+ it("shows mcp: true for an agent that opted in", () => {
61
+ const panel = makePanel([makeAgent({ mcp: true })]);
62
+ panel.handleInput(ENTER);
63
+ const lines = drawn(panel);
64
+ expect(rowContaining(lines, "Agent:")).toBeTruthy();
65
+ expect(rowContaining(lines, "mcp: true")).toBeTruthy();
66
+ });
67
+
68
+ it("shows the closed default for an agent with no mcp key", () => {
69
+ const panel = makePanel([makeAgent()]);
70
+ panel.handleInput(ENTER);
71
+ const lines = drawn(panel);
72
+ const row = rowContaining(lines, "mcp:");
73
+ expect(row).toBeTruthy();
74
+ expect(row).not.toContain("true");
75
+ });
76
+ });
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The `/agents` panel's tools edit gate.
3
+ *
4
+ * The panel edits tools through the multi-select picker (`[t]`), whose list is
5
+ * `pi.getAllTools()`. Its commit is the only place a tools list is written from
6
+ * the panel, so that is where the **lexical** rules are enforced: an entry pi
7
+ * cannot read as a name is refused with the entry named, and the value the agent
8
+ * had before the picker opened stays exactly as it was.
9
+ *
10
+ * Membership (is this a tool right now?) is deliberately NOT checked here — see
11
+ * `tool-validation.ts` for why that belongs at dispatch.
12
+ */
13
+
14
+ import { describe, it, expect } from "vitest";
15
+ import { stripTerminalSequences } from "@earendil-works/pi-tui";
16
+ import { createAgentPanel } from "./agent-panel.js";
17
+ import type { AgentConfig } from "./agents.js";
18
+
19
+ const theme = { fg: (_token: string, text: string) => text, bold: (text: string) => text };
20
+
21
+ const ENTER = "\r";
22
+ const DOWN = "\x1b[B";
23
+ const ESCAPE = "\x1b";
24
+ const SPACE = " ";
25
+
26
+ function makeAgent(overrides: Partial<AgentConfig> = {}): AgentConfig {
27
+ return {
28
+ name: "reviewer",
29
+ description: "reviews things",
30
+ systemPrompt: "be terse",
31
+ source: "user",
32
+ filePath: "/tmp/agents/reviewer.md",
33
+ tools: ["read"],
34
+ ...overrides,
35
+ };
36
+ }
37
+
38
+ function makePanel(agents: AgentConfig[], tools: Array<{ name: string; description: string }>) {
39
+ return createAgentPanel(
40
+ agents,
41
+ [],
42
+ [],
43
+ null,
44
+ "/tmp/agents",
45
+ null,
46
+ { requestRender: () => {} },
47
+ theme,
48
+ () => {},
49
+ [],
50
+ tools,
51
+ );
52
+ }
53
+
54
+ /** list → detail → edit → cursor on the tools row → picker open. */
55
+ function openToolPicker(panel: ReturnType<typeof makePanel>): void {
56
+ panel.handleInput(ENTER); // detail
57
+ panel.handleInput("e"); // edit (mutable copy)
58
+ panel.handleInput(DOWN); // name → description
59
+ panel.handleInput(DOWN); // description → tools
60
+ panel.handleInput("t"); // open the tool picker
61
+ }
62
+
63
+ function drawn(panel: ReturnType<typeof makePanel>): string[] {
64
+ return panel.render(90).map((l) => stripTerminalSequences(l));
65
+ }
66
+
67
+ function rowContaining(lines: string[], needle: string): string | undefined {
68
+ return lines.find((l) => l.includes(needle));
69
+ }
70
+
71
+ describe("/agents tool picker: lexical entries are refused at commit", () => {
72
+ it("a selected entry that is not a legal name is refused, naming the entry", () => {
73
+ const panel = makePanel([makeAgent()], [
74
+ { name: "+danger", description: "an extension tool whose name reads as a modifier" },
75
+ { name: "read", description: "read" },
76
+ ]);
77
+ openToolPicker(panel);
78
+ panel.handleInput(SPACE); // select "+danger" (first row)
79
+ panel.handleInput(ENTER); // commit
80
+ const lines = drawn(panel);
81
+ expect(rowContaining(lines, "Error:")).toBeTruthy();
82
+ // The entry has to be legible, not clipped off the end of one row.
83
+ expect(lines.some((l) => l.includes('+danger"'))).toBe(true);
84
+ });
85
+
86
+ it("a refused commit leaves the previously selected tools exactly as they were", () => {
87
+ const panel = makePanel([makeAgent({ tools: ["read", "bash"] })], [
88
+ { name: "-minus", description: "reads as a removal" },
89
+ { name: "read", description: "read" },
90
+ ]);
91
+ openToolPicker(panel);
92
+ panel.handleInput(SPACE); // select "-minus" on top of the pre-selected pair
93
+ panel.handleInput(ENTER);
94
+ const lines = drawn(panel);
95
+ expect(rowContaining(lines, "tools: read, bash")).toBeTruthy();
96
+ expect(rowContaining(lines, "tools: read, bash, -minus")).toBeUndefined();
97
+ });
98
+
99
+ it("a legal selection still commits (the gate does not eat valid input)", () => {
100
+ const panel = makePanel([makeAgent({ tools: ["read"] })], [
101
+ { name: "read", description: "read" },
102
+ { name: "bash", description: "bash" },
103
+ ]);
104
+ openToolPicker(panel);
105
+ panel.handleInput(DOWN); // to bash
106
+ panel.handleInput(SPACE);
107
+ panel.handleInput(ENTER);
108
+ const lines = drawn(panel);
109
+ expect(rowContaining(lines, "Error:")).toBeUndefined();
110
+ expect(rowContaining(lines, "tools: read, bash")).toBeTruthy();
111
+ });
112
+
113
+ it("clearing every tool commits an empty allowlist, which is not a lexical error", () => {
114
+ const panel = makePanel([makeAgent({ tools: ["read"] })], [
115
+ { name: "read", description: "read" },
116
+ ]);
117
+ openToolPicker(panel);
118
+ panel.handleInput(SPACE); // deselect the only entry
119
+ panel.handleInput(ENTER);
120
+ const lines = drawn(panel);
121
+ expect(rowContaining(lines, "Error:")).toBeUndefined();
122
+ expect(rowContaining(lines, "tools: (not set)")).toBeTruthy();
123
+ });
124
+
125
+ it("discarding after a commit leaves the original agent's preserved line intact", () => {
126
+ // The edit copy is `{ ...agent }`, so `extraFields` is shared with the
127
+ // original until something copies it. A commit that deleted the preserved
128
+ // raw `tools` value in place therefore deleted it from the ORIGINAL agent as
129
+ // well: commit, then discard, and the agent's own record no longer carried
130
+ // the line the config read promised to keep — so the next save wrote no
131
+ // `tools:` key and the allowlist vanished from the file.
132
+ const broken = makeAgent({
133
+ extraFields: { tools: "read, +web_search" },
134
+ });
135
+ delete broken.tools; // the read path leaves no allowlist in force
136
+ broken.toolsError = 'Agent "reviewer" has a tools entry starting with "+": "+web_search".';
137
+ const panel = makePanel([broken], [{ name: "read", description: "read" }]);
138
+ openToolPicker(panel);
139
+ panel.handleInput(SPACE); // select read on top of the rejected config's empty set
140
+ panel.handleInput(ENTER); // commit (marks the edit dirty)
141
+ panel.handleInput(ESCAPE); // escape a dirty edit → discard prompt
142
+ panel.handleInput("y"); // discard
143
+ panel.handleInput(ESCAPE); // back to the detail screen
144
+ const lines = drawn(panel);
145
+ expect(rowContaining(lines, "tools: read, +web_search")).toBeTruthy();
146
+ // And the rejection it belongs to, since this agent is unchanged on disk.
147
+ expect(lines.some((l) => l.includes('"+web_search"'))).toBe(true);
148
+ });
149
+ });
150
+
151
+ describe("/agents detail screen: a rejected tools list says so", () => {
152
+ it("shows the config-read rejection on the agent that carries it", () => {
153
+ const broken = makeAgent();
154
+ delete broken.tools; // the read path leaves no allowlist in force
155
+ broken.toolsError = 'Agent "reviewer" lists the tool "read" twice (in /tmp/agents/reviewer.md).';
156
+ const panel = makePanel([broken], []);
157
+ panel.handleInput(ENTER); // detail
158
+ const lines = drawn(panel);
159
+ expect(rowContaining(lines, '"read" twice')).toBeTruthy();
160
+ expect(rowContaining(lines, "/tmp/agents/reviewer.md")).toBeTruthy();
161
+ });
162
+ });
@@ -14,6 +14,7 @@ import {
14
14
  import type { AgentConfig } from "./agents.js";
15
15
  import { discoverAgentsAll } from "./agents.js";
16
16
  import { validateAgentName } from "./frontmatter-io.js";
17
+ import { applyToolEntries } from "./tool-validation.js";
17
18
  import {
18
19
  visibleWidth,
19
20
  padEnd,
@@ -298,6 +299,11 @@ function renderDetail(state: ManagerState, width: number, theme: Theme): string[
298
299
  } else {
299
300
  fieldLines.push(theme.fg("accent", `tools:`) + ` ${theme.fg("dim", "(none)")}`);
300
301
  }
302
+ // The allowlist is closed unless this says true (ADR 0028): with a `tools:`
303
+ // list the child runs `--no-mcp`, so the panel has to say which reading applies.
304
+ fieldLines.push(
305
+ theme.fg("accent", `mcp:`) + " " + (agent.mcp === true ? "true" : theme.fg("dim", "(off)")),
306
+ );
301
307
  fieldLines.push(theme.fg("accent", `model:`) + ` ${agentModel(agent)}`);
302
308
  fieldLines.push(theme.fg("accent", `thinking:`) + ` ${agent.thinking ?? theme.fg("dim", "(none)")}`);
303
309
 
@@ -305,6 +311,13 @@ function renderDetail(state: ManagerState, width: number, theme: Theme): string[
305
311
  lines.push(padEnd(fl, width));
306
312
  }
307
313
 
314
+ // The config read rejected this agent's tools list: show why, because the
315
+ // alternative is an agent that silently runs with no allowlist at all.
316
+ if (agent.toolsError) {
317
+ for (const line of wrapText(agent.toolsError, width)) {
318
+ lines.push(padEnd(theme.fg("error", line), width));
319
+ }
320
+ }
308
321
  // Extra fields
309
322
  if (agent.extraFields && Object.keys(agent.extraFields).length > 0) {
310
323
  lines.push(padEnd(theme.fg("dim", "─".repeat(width)), width));
@@ -429,9 +442,13 @@ function renderEdit(state: ManagerState, width: number, theme: Theme): string[]
429
442
  const dirtyMark = state.editDirty ? " *" : "";
430
443
  lines.push(renderHeader(` Edit: ${agent.name}${dirtyMark} `, width, theme));
431
444
 
432
- // Error line
445
+ // Error line — wrapped, not truncated: the messages this panel can produce
446
+ // name the offending entry at the END of the sentence, so a single clipped row
447
+ // would report a problem without saying what it is.
433
448
  if (state.editError) {
434
- lines.push(padEnd(theme.fg("error", `Error: ${state.editError}`), width));
449
+ for (const line of wrapText(`Error: ${state.editError}`, width)) {
450
+ lines.push(padEnd(theme.fg("error", line), width));
451
+ }
435
452
  }
436
453
 
437
454
  // System prompt edit mode
@@ -740,14 +757,21 @@ function handleEditInput(
740
757
  state.toolSearch = "";
741
758
  requestRender();
742
759
  } else if (matchesKey(data, Key.enter)) {
743
- const names = [...state.toolSelected];
744
- if (names.length > 0) {
745
- state.editAgent.tools = names;
746
- } else {
747
- delete state.editAgent.tools;
748
- }
760
+ // The picker's commit is the only way the panel writes a tools list, so it
761
+ // is where the lexical rules apply. A refusal leaves the previous list
762
+ // untouched (`applyToolEntries`) and does not mark the agent dirty — the
763
+ // user must not be able to save a list pi would reject.
764
+ const error = applyToolEntries(state.editAgent, [...state.toolSelected], {
765
+ agentName: state.editAgent.name,
766
+ agentFilePath: state.editAgent.filePath,
767
+ });
749
768
  state.toolPickerOpen = false;
750
769
  state.toolSearch = "";
770
+ if (error) {
771
+ state.editError = error;
772
+ requestRender();
773
+ return;
774
+ }
751
775
  state.editDirty = true;
752
776
  requestRender();
753
777
  } else if (matchesKey(data, Key.up)) {
@@ -1013,6 +1037,11 @@ function setFieldValue(agent: AgentConfig, field: EditField, value: string): voi
1013
1037
  agent.description = value;
1014
1038
  break;
1015
1039
  case "tools": {
1040
+ // A typing buffer, not a commit: the tools field is edited through the
1041
+ // picker (an `enter` on that row opens it), so this path is not reachable
1042
+ // from the keyboard today. It stays permissive on purpose — an unfinished
1043
+ // comma is a keystroke, not a mistake. Everything that leaves the panel
1044
+ // goes through `applyToolEntries`, which is where the lexical rules live.
1016
1045
  const parsed = value
1017
1046
  ? value.split(",").map((t) => t.trim()).filter(Boolean)
1018
1047
  : [];
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Frame-width audit for the `/agents` overlay.
3
+ *
4
+ * Automated stand-in for a `PI_TUI_WRITE_LOG` capture: drive the real panel
5
+ * factory through every screen with hostile content, measure **every produced
6
+ * line** with pi-tui's own `visibleWidth` (what the terminal draws), and require
7
+ * it to fit the frame. A line wider than the frame is hard-wrapped by the
8
+ * terminal, which pushes the closing border to the next row and shifts the whole
9
+ * overlay — the exact failure the old `.length`-based width maths produced for
10
+ * cursor markers, OSC 8 hyperlinks and east-asian text.
11
+ */
12
+
13
+ import { describe, it, expect } from "vitest";
14
+ import {
15
+ CURSOR_MARKER,
16
+ stripTerminalSequences,
17
+ visibleWidth as tuiVisibleWidth,
18
+ } from "@earendil-works/pi-tui";
19
+ import { createAgentPanel } from "./agent-panel.js";
20
+ import type { AgentConfig } from "./agents.js";
21
+
22
+ // One entry per failure mode of the deleted width maths.
23
+ const URL = `https://example.com/${"segment/".repeat(12)}`;
24
+
25
+ function agent(name: string, description: string, systemPrompt: string): AgentConfig {
26
+ return {
27
+ name,
28
+ description,
29
+ systemPrompt,
30
+ model: "anthropic/claude-opus-4.5",
31
+ tools: ["read", "bash"],
32
+ source: "user",
33
+ filePath: `/tmp/agents/${name}.md`,
34
+ };
35
+ }
36
+
37
+ const AGENTS: AgentConfig[] = [
38
+ agent("検証エージェント", "日本語の説明テキスト".repeat(8), "説".repeat(200)),
39
+ agent(`\x1b]8;;${URL}\x07doc-link\x1b]8;;\x07`, `see \x1b]8;;${URL}\x07here\x1b]8;;\x07 for details`, "plain body"),
40
+ agent("\x1b[31mred-name\x1b[0m", "\x1b[33mcoloured description\x1b[0m tail", "\x1b[32mgreen body line\x1b[0m\nsecond"),
41
+ agent("wide-mixed", `説明 \x1b]8;;${URL}\x07link\x1b]8;;\x07 ${"語".repeat(120)}`, `${"文".repeat(90)}\nmore`),
42
+ ];
43
+
44
+ /** A theme that actually colourises, so SGR sequences are in the measured lines. */
45
+ const theme = {
46
+ fg: (token: string, text: string) =>
47
+ `\x1b[38;2;120;160;200m${text}\x1b[0m`.replace("120;160;200", token === "accent" ? "80;120;255" : "120;160;200"),
48
+ bold: (text: string) => `\x1b[1m${text}\x1b[0m`,
49
+ };
50
+
51
+ const RAW = {
52
+ enter: "\r",
53
+ escape: "\x1b",
54
+ slash: "/",
55
+ n: "n",
56
+ e: "e",
57
+ d: "d",
58
+ down: "\x1b[B",
59
+ };
60
+
61
+ function makePanel() {
62
+ return createAgentPanel(
63
+ AGENTS,
64
+ [],
65
+ [],
66
+ null,
67
+ "/tmp/agents",
68
+ null,
69
+ { requestRender: () => {} },
70
+ theme,
71
+ () => {},
72
+ [{ id: "claude-opus-4.5", provider: "anthropic", fullId: "anthropic/claude-opus-4.5" }],
73
+ [{ name: "read", description: "Read a file" }],
74
+ );
75
+ }
76
+
77
+ interface Violation {
78
+ screen: string;
79
+ frameWidth: number;
80
+ row: number;
81
+ measured: number;
82
+ }
83
+
84
+ /** Every produced line of a bordered overlay must land on the frame exactly. */
85
+ function audit(screen: string, frameWidth: number, lines: string[]): Violation[] {
86
+ const bad: Violation[] = [];
87
+ lines.forEach((line, row) => {
88
+ const measured = tuiVisibleWidth(line);
89
+ if (measured !== frameWidth) bad.push({ screen, frameWidth, row, measured });
90
+ });
91
+ return bad;
92
+ }
93
+
94
+ // ── Capture content checks ───────────────────────────────────────────────────
95
+ //
96
+ // A width audit only proves something if the captured frame is the frame it
97
+ // means to measure. The panel's input gates are strict — list search and the name
98
+ // field accept printable ASCII only (`handleListInput` / `handleNameInput` test
99
+ // `data.length === 1 && data >= " " && data <= "~"`), and in list mode `n`, `c`
100
+ // and `d` are commands even while filtering — so a keystroke can be silently
101
+ // dropped and leave the capture showing a placeholder instead of typed content.
102
+ // Every capture therefore declares the content it expects, and a capture that
103
+ // does not contain it fails instead of auditing the wrong frame.
104
+
105
+ /** Drawn text of the list's search bar, or undefined when there is no bar. */
106
+ function searchRow(raw: string[]): string | undefined {
107
+ return raw.map((l) => stripTerminalSequences(l)).find((l) => l.includes("◎"));
108
+ }
109
+
110
+ function hasMarker(raw: string[]): boolean {
111
+ return raw.some((l) => l.includes(CURSOR_MARKER));
112
+ }
113
+
114
+ function drawnIncludes(raw: string[], needle: string): boolean {
115
+ return raw.some((l) => stripTerminalSequences(l).includes(needle));
116
+ }
117
+
118
+ describe("/agents frame-width audit", () => {
119
+ const FRAME_WIDTHS = [40, 60, 84, 100];
120
+
121
+ it("keeps every produced line on the frame, on every screen", () => {
122
+ const violations: Violation[] = [];
123
+ const over: Violation[] = [];
124
+
125
+ for (const frameWidth of FRAME_WIDTHS) {
126
+ // Each screen is reached through the panel's own input handler, so the
127
+ // captured frames are what the user would see on that screen. `expect`
128
+ // runs before the width measurement: measure the frame we claim to measure.
129
+ const capture = (
130
+ screen: string,
131
+ drive: (panel: ReturnType<typeof makePanel>) => void,
132
+ expectContent?: (raw: string[]) => string | null,
133
+ ) => {
134
+ const panel = makePanel();
135
+ drive(panel);
136
+ const lines = panel.render(frameWidth);
137
+ const problem = expectContent?.(lines);
138
+ expect(problem ?? null, `${screen}@${frameWidth} is not the capture this audit measures`).toBeNull();
139
+ const bad = audit(screen, frameWidth, lines);
140
+ violations.push(...bad);
141
+ over.push(...bad.filter((v) => v.measured > frameWidth));
142
+ };
143
+
144
+ capture("list", () => {});
145
+
146
+ // The row that carries the cursor marker: `/` opens the search bar with the
147
+ // placeholder, and the wide keystrokes that follow are dropped by the
148
+ // ASCII gate.
149
+ capture(
150
+ "list:searching",
151
+ (p) => {
152
+ p.handleInput(RAW.slash);
153
+ for (const ch of "検証") p.handleInput(ch);
154
+ },
155
+ (raw) => {
156
+ // Tripwire for that gate: if list search ever accepts wide input, this
157
+ // capture has to feed the wide query — otherwise a CJK search row goes
158
+ // unmeasured and the frame-width claim goes stale again.
159
+ if (!hasMarker(raw)) return "search bar (and its CURSOR_MARKER) not rendered";
160
+ const row = searchRow(raw);
161
+ if (!row?.includes("type to filter..."))
162
+ return `search bar is not the empty-query placeholder: ${JSON.stringify(row?.trim())}`;
163
+ return null;
164
+ },
165
+ );
166
+
167
+ // A populated search row with a filtered result set. `k` is printable ASCII
168
+ // (so the gate accepts it) and is not a list command, unlike `doc` / `link`
169
+ // / `new`, whose first letter switches screen before it reaches the query.
170
+ capture(
171
+ "list:filter",
172
+ (p) => {
173
+ p.handleInput(RAW.slash);
174
+ for (const ch of "k") p.handleInput(ch);
175
+ },
176
+ (raw) => {
177
+ const row = searchRow(raw);
178
+ if (!row?.includes("◎ k")) return `search bar does not show the query: ${JSON.stringify(row?.trim())}`;
179
+ if (row.includes("type to filter...")) return "search bar still shows the placeholder";
180
+ if (!drawnIncludes(raw, "doc-link")) return "filtered list lost the OSC 8 fixture row";
181
+ if (drawnIncludes(raw, "検証エージェント")) return "filter did not narrow the list";
182
+ if (drawnIncludes(raw, "No agents found")) return "query matched nothing, so the populated row is unmeasured";
183
+ return null;
184
+ },
185
+ );
186
+
187
+ // The empty-state rows a live filter can produce.
188
+ capture(
189
+ "list:no-matches",
190
+ (p) => {
191
+ p.handleInput(RAW.slash);
192
+ for (const ch of "zzq") p.handleInput(ch);
193
+ },
194
+ (raw) => {
195
+ if (!drawnIncludes(raw, "◎ zzq")) return "search bar does not show the query";
196
+ if (!drawnIncludes(raw, "No agents found")) return "empty-state row missing: the query filtered nothing";
197
+ return null;
198
+ },
199
+ );
200
+
201
+ // The name field takes ASCII too, so the echo has to be asserted with ASCII.
202
+ capture(
203
+ "name-input",
204
+ (p) => {
205
+ p.handleInput(RAW.n);
206
+ for (const ch of "new-agent") p.handleInput(ch);
207
+ },
208
+ (raw) => {
209
+ if (!hasMarker(raw)) return "name input box (and its CURSOR_MARKER) not rendered";
210
+ if (!drawnIncludes(raw, "new-agent")) return "typed name not echoed into the input box";
211
+ return null;
212
+ },
213
+ );
214
+
215
+ capture(
216
+ "detail",
217
+ (p) => {
218
+ p.handleInput(RAW.enter);
219
+ p.handleInput(RAW.down);
220
+ p.handleInput(RAW.enter);
221
+ },
222
+ (raw) => (drawnIncludes(raw, "Agent:") ? null : "detail screen not rendered"),
223
+ );
224
+
225
+ capture(
226
+ "edit",
227
+ (p) => {
228
+ p.handleInput(RAW.enter);
229
+ p.handleInput(RAW.e);
230
+ },
231
+ (raw) => (drawnIncludes(raw, "Edit:") ? null : "edit screen not rendered"),
232
+ );
233
+
234
+ capture(
235
+ "confirm-delete",
236
+ (p) => {
237
+ p.handleInput(RAW.d);
238
+ },
239
+ (raw) => (drawnIncludes(raw, "Delete") ? null : "confirm-delete screen not rendered"),
240
+ );
241
+ }
242
+
243
+ // The marker line is the shape that used to measure ten columns too wide;
244
+ // assert one is actually in the captured frames, so a passing audit cannot
245
+ // mean the fixtures silently stopped containing one.
246
+ const probe = makePanel();
247
+ probe.handleInput(RAW.slash);
248
+ expect(probe.render(84).some((line) => line.includes(CURSOR_MARKER))).toBe(true);
249
+
250
+ const summary = (list: Violation[]) =>
251
+ list.slice(0, 6).map((v) => `${v.screen}@${v.frameWidth}:row${v.row}=${v.measured}`).join(", ");
252
+ expect(
253
+ over,
254
+ `${over.length}/${violations.length} off-frame line(s) [wider than the frame]: ${summary(over)}`,
255
+ ).toEqual([]);
256
+ expect(violations, `${violations.length} off-frame line(s) total: ${summary(violations)}`).toEqual([]);
257
+ });
258
+ });