claudeup 6.3.0 → 6.4.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 (59) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/cli-live.test.ts +9 -2
  3. package/src/__tests__/footer-hints.test.ts +40 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  5. package/src/__tests__/hook-import-policy.test.ts +90 -0
  6. package/src/__tests__/hook-process.test.ts +256 -0
  7. package/src/__tests__/hook-registration.test.ts +224 -0
  8. package/src/__tests__/manifest.test.ts +134 -0
  9. package/src/__tests__/model-visuals.test.tsx +789 -0
  10. package/src/__tests__/models-adapter.test.ts +317 -0
  11. package/src/__tests__/models-cli.test.ts +173 -0
  12. package/src/__tests__/models-core.test.ts +640 -0
  13. package/src/__tests__/models-manager.test.ts +497 -0
  14. package/src/__tests__/models-screen-state.test.ts +259 -0
  15. package/src/__tests__/profile-materializer.test.ts +46 -0
  16. package/src/__tests__/resolver.test.ts +38 -2
  17. package/src/__tests__/settings-file.test.ts +179 -0
  18. package/src/__tests__/symlink-manager.test.ts +65 -1
  19. package/src/__tests__/tabbar-layout.test.ts +40 -2
  20. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  21. package/src/__tests__/version-snapshot.test.ts +2 -4
  22. package/src/cli/doctor.ts +90 -0
  23. package/src/cli/hook.ts +129 -0
  24. package/src/cli/models.ts +214 -0
  25. package/src/cli/router.ts +12 -0
  26. package/src/data/gitignore-defaults.ts +4 -0
  27. package/src/data/models-presets.ts +281 -0
  28. package/src/data/predefined-profiles.ts +16 -7
  29. package/src/data/settings-catalog.ts +11 -4
  30. package/src/main.tsx +51 -82
  31. package/src/services/hook-registration.ts +218 -0
  32. package/src/services/manifest.ts +84 -0
  33. package/src/services/models-core.ts +628 -0
  34. package/src/services/models-manager.ts +606 -0
  35. package/src/services/profile-materializer.ts +17 -0
  36. package/src/services/resolver.ts +11 -0
  37. package/src/services/settings-file.ts +69 -0
  38. package/src/services/styles-manager.ts +23 -45
  39. package/src/services/symlink-manager.ts +57 -11
  40. package/src/tui.tsx +112 -0
  41. package/src/types/bun.d.ts +21 -0
  42. package/src/types/index.ts +14 -0
  43. package/src/ui/App.tsx +15 -3
  44. package/src/ui/adapters/modelsAdapter.ts +170 -0
  45. package/src/ui/components/TabBar.tsx +9 -4
  46. package/src/ui/components/layout/FooterHints.tsx +20 -3
  47. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  48. package/src/ui/components/primitives/MetaText.tsx +27 -1
  49. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  50. package/src/ui/renderers/modelVisuals.tsx +853 -0
  51. package/src/ui/renderers/skillRenderers.tsx +13 -3
  52. package/src/ui/renderers/styleRenderers.tsx +7 -3
  53. package/src/ui/screens/ModelsScreen.tsx +478 -0
  54. package/src/ui/screens/StylesScreen.tsx +8 -13
  55. package/src/ui/screens/index.ts +1 -0
  56. package/src/ui/state/reducer.ts +94 -0
  57. package/src/ui/state/types.ts +65 -2
  58. package/src/ui/theme-mode.ts +116 -0
  59. package/src/ui/theme.ts +26 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "6.3.0",
3
+ "version": "6.4.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "6.3.0",
68
- "claudeup-darwin-x64": "6.3.0",
69
- "claudeup-linux-x64": "6.3.0"
67
+ "claudeup-darwin-arm64": "6.4.0",
68
+ "claudeup-darwin-x64": "6.4.0",
69
+ "claudeup-linux-x64": "6.4.0"
70
70
  }
71
71
  }
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { afterEach, describe, expect, test } from "bun:test";
22
+ import { width } from "../cli/ansi.js";
22
23
  import { LiveRegion, withPaused } from "../cli/live.js";
23
24
 
24
25
  /** A write stream that records instead of drawing. */
@@ -157,7 +158,12 @@ describe("animated mode", () => {
157
158
  const { live, writes } = region({ columns: 20 });
158
159
  live.start(() => ["x".repeat(40)]);
159
160
  const painted = writes.at(-1) ?? "";
160
- expect(painted.replace(/\n$/, "")).toHaveLength(19);
161
+ // DISPLAY width, not `.length`. `truncate` appends a reset when colour is
162
+ // on, and colour is on exactly when stdout is a real terminal — so a
163
+ // `.length` assertion passed piped and failed the moment anyone ran the
164
+ // suite in a terminal, over four zero-width bytes. The claim being made
165
+ // here is about columns, so measure columns.
166
+ expect(width(painted.replace(/\n$/, ""))).toBe(19);
161
167
  live.stop();
162
168
  });
163
169
 
@@ -202,7 +208,8 @@ describe("resize", () => {
202
208
  // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
203
209
  const CURSOR_UP = /\x1b\[\d+A/;
204
210
  expect(writes.some((w) => CURSOR_UP.test(w))).toBe(false);
205
- expect(writes.at(-1)?.replace(/\n$/, "")).toHaveLength(39);
211
+ // Display width, for the reason given on the clipping test above.
212
+ expect(width((writes.at(-1) ?? "").replace(/\n$/, ""))).toBe(39);
206
213
  live.stop();
207
214
  });
208
215
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `joinKeys` decides how a key group reads in the footer chip.
3
+ *
4
+ * Found on screen, not in review: the Models tab advertised apply as `Entera`, and the
5
+ * layout's own scroll hint had been rendering as `^U/^DPgUp/Dn` on EVERY screen in the app.
6
+ * Both are two key names concatenated with nothing between them, which reads as one key that
7
+ * does not exist.
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import { joinKeys } from "../ui/components/layout/FooterHints.js";
11
+
12
+ describe("joinKeys", () => {
13
+ test("single-character keys butt together, the way an arrow pair is written", () => {
14
+ expect(joinKeys(["↑", "↓"])).toBe("↑↓");
15
+ expect(joinKeys(["j", "k"])).toBe("jk");
16
+ });
17
+
18
+ test("one key is itself", () => {
19
+ expect(joinKeys(["U"])).toBe("U");
20
+ expect(joinKeys(["Enter"])).toBe("Enter");
21
+ });
22
+
23
+ test("named keys get a gap — the regression this exists for", () => {
24
+ expect(joinKeys(["Enter", "a"])).toBe("Enter a");
25
+ expect(joinKeys(["^U/^D", "PgUp/Dn"])).toBe("^U/^D PgUp/Dn");
26
+ });
27
+
28
+ test("a space, not a slash: several key names already contain one", () => {
29
+ expect(joinKeys(["^U/^D", "PgUp/Dn"])).not.toContain("//");
30
+ });
31
+
32
+ test("a multi-byte single character still counts as one", () => {
33
+ // "↑".length is 1 but an emoji's is 2 — count code points, not UTF-16 units.
34
+ expect(joinKeys(["⌘", "⇧"])).toBe("⌘⇧");
35
+ });
36
+
37
+ test("an empty group is empty rather than a stray separator", () => {
38
+ expect(joinKeys([])).toBe("");
39
+ });
40
+ });
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults.js";
6
7
  import {
7
8
  checkGitignore,
8
9
  formatPrerunWarning,
@@ -40,21 +41,13 @@ describe("checkGitignore", () => {
40
41
  git(projectDir, "init", "-q");
41
42
  git(projectDir, "config", "user.email", "t@t");
42
43
  git(projectDir, "config", "user.name", "t");
43
- // Suppress all built-in ignore rules
44
+ // Suppress all built-in ignore rules. DERIVED from the constant, not a
45
+ // hand-copied list: a copy silently becomes a different assertion the
46
+ // moment a pattern is added, and then fails for a reason that has nothing
47
+ // to do with what this test is about.
44
48
  await writeFile(
45
49
  join(projectDir, ".gitignore"),
46
- `${[
47
- ".claude/settings.local.json",
48
- ".claude/scheduled_tasks.lock",
49
- ".claude/cache/",
50
- ".claude/.statusline-worktree-*",
51
- ".claude/_profiles/",
52
- "ai-docs/sessions/",
53
- ".mnemex/",
54
- ".claudemem/",
55
- "gtd/sessions/",
56
- ".agents/",
57
- ].join("\n")}\n`,
50
+ `${BUILTIN_DEFAULTS.ignore.join("\n")}\n`,
58
51
  );
59
52
  // Tracked files don't have to exist for the absent-track-path case to be ignored
60
53
  const r = await checkGitignore(projectDir);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The hook path must never load the TUI.
3
+ *
4
+ * Claude Code runs `claudeup hook …` on every matching tool call, so anything
5
+ * the entry point imports is paid for on every tool call — and `@opentui/*`
6
+ * plus the `src/ui/` tree is the expensive half of this program. Two guards,
7
+ * because they fail differently:
8
+ *
9
+ * - H-1 BUNDLES `src/cli/hook.ts` and inspects the output. A transitive import
10
+ * added three modules deep still shows up here.
11
+ * - H-2 reads `src/main.tsx` as text. Bundling cannot see this one: the TUI is
12
+ * reachable from main.tsx by design, just only through a dynamic import that
13
+ * the hook branch returns before. A *static* import would re-couple them
14
+ * while every runtime test still passed, so the source is what is pinned —
15
+ * the same technique dual-write-prevention.test.ts uses.
16
+ */
17
+
18
+ import { describe, expect, test } from "bun:test";
19
+ import { readFileSync } from "node:fs";
20
+ import path from "node:path";
21
+
22
+ const HOOK_ENTRY = path.join(import.meta.dir, "..", "cli", "hook.ts");
23
+ const MAIN_TSX = path.join(import.meta.dir, "..", "main.tsx");
24
+
25
+ /** Drop `//` line comments and `/* *\/` blocks so the guards match code only. */
26
+ function stripComments(source: string): string {
27
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
28
+ }
29
+
30
+ describe("hook entry point", () => {
31
+ test("H-1: bundling src/cli/hook.ts pulls in neither @opentui nor src/ui", async () => {
32
+ const build = await Bun.build({
33
+ entrypoints: [HOOK_ENTRY],
34
+ target: "bun",
35
+ });
36
+
37
+ expect(build.logs).toEqual([]);
38
+ expect(build.success).toBe(true);
39
+ expect(build.outputs.length).toBeGreaterThan(0);
40
+
41
+ const bundle = (
42
+ await Promise.all(build.outputs.map((output) => output.text()))
43
+ ).join("\n");
44
+
45
+ expect(bundle).not.toContain("@opentui");
46
+ expect(bundle).not.toContain("/ui/");
47
+ });
48
+ });
49
+
50
+ describe("main.tsx import policy", () => {
51
+ const source = stripComments(readFileSync(MAIN_TSX, "utf-8"));
52
+
53
+ /**
54
+ * Every STATIC import/export specifier. `from "…"` only, which is exactly
55
+ * what a dynamic `import("…")` does not have — so the dynamic loads main.tsx
56
+ * relies on are invisible here, and that is the point.
57
+ */
58
+ const staticSpecifiers = [
59
+ ...source.matchAll(/\bfrom\s+["']([^"']+)["']/g),
60
+ ].map((match) => match[1] as string);
61
+
62
+ test("H-2: no static import of @opentui/*", () => {
63
+ expect(staticSpecifiers.filter((s) => s.startsWith("@opentui"))).toEqual(
64
+ [],
65
+ );
66
+ });
67
+
68
+ test("H-2b: no static import of ./ui/*", () => {
69
+ expect(staticSpecifiers.filter((s) => s.startsWith("./ui/"))).toEqual([]);
70
+ });
71
+
72
+ test("H-2c: the guard sees the specifiers it is meant to see", () => {
73
+ // Negative control: a regex that matched nothing would pass H-2 and H-2b
74
+ // forever. main.tsx does still carry static imports — just harmless ones.
75
+ expect(staticSpecifiers.length).toBeGreaterThan(0);
76
+ expect(staticSpecifiers).toContain("./services/dotenv.js");
77
+ });
78
+
79
+ test("H-3: the hook branch is dispatched before the .env load", () => {
80
+ // Ordering, at source level, for the same reason theme-env.test.ts pins
81
+ // the snapshot: a hook must never open a project .env — it may be a
82
+ // symlink to a FIFO, and reading one blocks on every tool call.
83
+ const hookAt = source.indexOf('await import("./cli/hook.js")');
84
+ const dotenvAt = source.indexOf("loadProjectDotenv(");
85
+
86
+ expect(hookAt).toBeGreaterThan(-1);
87
+ expect(dotenvAt).toBeGreaterThan(-1);
88
+ expect(hookAt).toBeLessThan(dotenvAt);
89
+ });
90
+ });
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The hook as Claude Code actually runs it: a spawned process, a JSON payload on stdin, and
3
+ * whatever lands on stdout.
4
+ *
5
+ * `models-core.test.ts` covers the decision logic in-process. This file covers the things only
6
+ * a real spawn can show — that the binary starts, that it reads stdin, that it finds a config
7
+ * by walking up from the payload's cwd, and above all that EVERY failure mode is silent and
8
+ * exit 0. Claude Code spawns this on every Agent call in every session on the machine, so a
9
+ * hook that throws, hangs or exits non-zero would break delegation everywhere at once.
10
+ *
11
+ * The negative control is the point of the file: without a config, the same invocation must
12
+ * produce nothing at all. A test that only checks the happy path cannot tell a working hook
13
+ * from one that injects unconditionally.
14
+ */
15
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
16
+ import {
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ rmSync,
20
+ symlinkSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const MAIN = join(dirname(fileURLToPath(import.meta.url)), "..", "main.tsx");
28
+
29
+ let dir: string;
30
+
31
+ interface RunResult {
32
+ stdout: string;
33
+ stderr: string;
34
+ exitCode: number;
35
+ ms: number;
36
+ }
37
+
38
+ /** Spawn the hook exactly as a settings.json `command` entry would. */
39
+ async function runHook(
40
+ payload: unknown,
41
+ opts: { cwd?: string; args?: string[] } = {},
42
+ ): Promise<RunResult> {
43
+ const started = Date.now();
44
+ const proc = Bun.spawn(
45
+ ["bun", "--no-env-file", MAIN, "hook", ...(opts.args ?? ["agent-model"])],
46
+ {
47
+ cwd: opts.cwd ?? dir,
48
+ stdin: new TextEncoder().encode(
49
+ typeof payload === "string" ? payload : JSON.stringify(payload),
50
+ ),
51
+ stdout: "pipe",
52
+ stderr: "pipe",
53
+ },
54
+ );
55
+ const [stdout, stderr, exitCode] = await Promise.all([
56
+ new Response(proc.stdout).text(),
57
+ new Response(proc.stderr).text(),
58
+ proc.exited,
59
+ ]);
60
+ return { stdout, stderr, exitCode, ms: Date.now() - started };
61
+ }
62
+
63
+ const CONFIG = {
64
+ version: 1,
65
+ preset: "test",
66
+ main: { model: "opus", effort: "medium" },
67
+ grades: {
68
+ smart: { model: "fable", effort: "xhigh" },
69
+ normal: { model: "opus", effort: "medium" },
70
+ cheap: { model: "sonnet", effort: "low" },
71
+ },
72
+ agents: {
73
+ "dev:architect": "smart",
74
+ Explore: "cheap",
75
+ "seo:editor": { model: "inherit" },
76
+ },
77
+ fallback: "normal",
78
+ };
79
+
80
+ function writeConfig(at: string, config: unknown = CONFIG): void {
81
+ mkdirSync(join(at, ".claude"), { recursive: true });
82
+ writeFileSync(
83
+ join(at, ".claude", "models.json"),
84
+ JSON.stringify(config, null, 2),
85
+ );
86
+ }
87
+
88
+ const agentPayload = (input: Record<string, unknown> = {}, cwd = dir) => ({
89
+ hook_event_name: "PreToolUse",
90
+ tool_name: "Agent",
91
+ cwd,
92
+ tool_input: { subagent_type: "dev:architect", prompt: "go", ...input },
93
+ });
94
+
95
+ beforeEach(() => {
96
+ dir = mkdtempSync(join(tmpdir(), "claudeup-hook-"));
97
+ });
98
+ afterEach(() => {
99
+ rmSync(dir, { recursive: true, force: true });
100
+ });
101
+
102
+ describe("claudeup hook agent-model", () => {
103
+ test("injects the routed model, and emits hookEventName", async () => {
104
+ writeConfig(dir);
105
+ const r = await runHook(agentPayload());
106
+ expect(r.exitCode).toBe(0);
107
+ expect(r.stderr).toBe("");
108
+ expect(JSON.parse(r.stdout)).toEqual({
109
+ hookSpecificOutput: {
110
+ hookEventName: "PreToolUse",
111
+ updatedInput: {
112
+ subagent_type: "dev:architect",
113
+ prompt: "go",
114
+ model: "fable",
115
+ },
116
+ },
117
+ });
118
+ });
119
+
120
+ test("NEGATIVE CONTROL: no config means no output at all", async () => {
121
+ // Without this the happy path above cannot distinguish routing from injecting always.
122
+ const r = await runHook(agentPayload());
123
+ expect(r.exitCode).toBe(0);
124
+ expect(r.stdout).toBe("");
125
+ expect(r.stderr).toBe("");
126
+ });
127
+
128
+ test("walks up to a config in a parent directory", async () => {
129
+ writeConfig(dir);
130
+ const nested = join(dir, "packages", "api", "src");
131
+ mkdirSync(nested, { recursive: true });
132
+ const r = await runHook(agentPayload({}, nested));
133
+ expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
134
+ "fable",
135
+ );
136
+ });
137
+
138
+ test("uses the payload's cwd, not the process's", async () => {
139
+ // A session's directory is not where this process was started, and the payload is the
140
+ // only thing that knows the difference.
141
+ const elsewhere = mkdtempSync(join(tmpdir(), "claudeup-hook-other-"));
142
+ try {
143
+ writeConfig(elsewhere);
144
+ const r = await runHook(agentPayload({}, elsewhere), { cwd: dir });
145
+ expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
146
+ "fable",
147
+ );
148
+ } finally {
149
+ rmSync(elsewhere, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ test("respects a model the caller already set", async () => {
154
+ writeConfig(dir);
155
+ const r = await runHook(agentPayload({ model: "haiku" }));
156
+ expect(r.stdout).toBe("");
157
+ });
158
+
159
+ test("`inherit` leaves the call alone", async () => {
160
+ writeConfig(dir);
161
+ const r = await runHook(agentPayload({ subagent_type: "seo:editor" }));
162
+ expect(r.stdout).toBe("");
163
+ });
164
+
165
+ test("an unknown agent takes the fallback grade", async () => {
166
+ writeConfig(dir);
167
+ const r = await runHook(agentPayload({ subagent_type: "brand-new:agent" }));
168
+ expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
169
+ "opus",
170
+ );
171
+ });
172
+
173
+ test("ignores a tool that is not Agent", async () => {
174
+ writeConfig(dir);
175
+ const r = await runHook({
176
+ tool_name: "Bash",
177
+ cwd: dir,
178
+ tool_input: { command: "ls" },
179
+ });
180
+ expect(r.stdout).toBe("");
181
+ expect(r.exitCode).toBe(0);
182
+ });
183
+
184
+ describe("fails open", () => {
185
+ test("garbage on stdin", async () => {
186
+ writeConfig(dir);
187
+ const r = await runHook("not json at all {{{");
188
+ expect(r.exitCode).toBe(0);
189
+ expect(r.stdout).toBe("");
190
+ });
191
+
192
+ test("empty stdin", async () => {
193
+ writeConfig(dir);
194
+ const r = await runHook("");
195
+ expect(r.exitCode).toBe(0);
196
+ expect(r.stdout).toBe("");
197
+ });
198
+
199
+ test("an unparseable config", async () => {
200
+ mkdirSync(join(dir, ".claude"), { recursive: true });
201
+ writeFileSync(join(dir, ".claude", "models.json"), "{ broken");
202
+ const r = await runHook(agentPayload());
203
+ expect(r.exitCode).toBe(0);
204
+ expect(r.stdout).toBe("");
205
+ });
206
+
207
+ test("an INVALID config routes nothing rather than injecting a bad model", async () => {
208
+ // Measured (AMR-1 bogus-model): a non-alias refuses the spawn outright, so injecting
209
+ // one would break every delegation for everyone who pulled the config.
210
+ writeConfig(dir, {
211
+ ...CONFIG,
212
+ grades: { ...CONFIG.grades, smart: { model: "kimi-k2" } },
213
+ });
214
+ const r = await runHook(agentPayload());
215
+ expect(r.exitCode).toBe(0);
216
+ expect(r.stdout).toBe("");
217
+ });
218
+
219
+ test("a config from a future version", async () => {
220
+ writeConfig(dir, { ...CONFIG, version: 99 });
221
+ const r = await runHook(agentPayload());
222
+ expect(r.exitCode).toBe(0);
223
+ expect(r.stdout).toBe("");
224
+ });
225
+
226
+ test("an unrecognised hook name", async () => {
227
+ writeConfig(dir);
228
+ const r = await runHook(agentPayload(), { args: ["something-else"] });
229
+ expect(r.exitCode).toBe(0);
230
+ expect(r.stdout).toBe("");
231
+ });
232
+ });
233
+
234
+ test("a .env symlinked to a FIFO does not hang the hook", async () => {
235
+ // In this repo `.env` is often a symlink to a 1Password FIFO. Bun autoloads the cwd's
236
+ // .env before user code unless told not to, and reading a FIFO with no writer blocks
237
+ // forever — which would look exactly like Claude Code freezing on every Agent call.
238
+ writeConfig(dir);
239
+ const fifo = join(dir, "fifo-target");
240
+ Bun.spawnSync(["mkfifo", fifo]);
241
+ symlinkSync(fifo, join(dir, ".env"));
242
+ const r = await runHook(agentPayload());
243
+ expect(r.exitCode).toBe(0);
244
+ expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
245
+ "fable",
246
+ );
247
+ });
248
+
249
+ test("stays well inside a hook timeout", async () => {
250
+ writeConfig(dir);
251
+ const r = await runHook(agentPayload());
252
+ // Registered with timeout: 5s. From source (not the compiled binary) this measures
253
+ // ~0.02-0.3s; a regression that pulls the TUI back onto this path would blow past it.
254
+ expect(r.ms).toBeLessThan(3000);
255
+ });
256
+ });