@lucascouts/claude-agent-tui 0.5.2 → 0.7.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 (108) hide show
  1. package/NOTICE +1 -1
  2. package/README.md +1 -1
  3. package/dist/acp-agent.d.ts +249 -21
  4. package/dist/acp-agent.js +573 -73
  5. package/dist/agent-catalog.d.ts +95 -0
  6. package/dist/agent-catalog.js +287 -0
  7. package/dist/ansi-mirror.d.ts +0 -1
  8. package/dist/besteffort.d.ts +0 -1
  9. package/dist/billing/entrypoint-guard.d.ts +0 -1
  10. package/dist/claude-path.d.ts +0 -1
  11. package/dist/claude-path.js +6 -0
  12. package/dist/command-catalog.d.ts +84 -0
  13. package/dist/command-catalog.js +339 -0
  14. package/dist/diff-enriched-reader.d.ts +0 -1
  15. package/dist/diff-source.d.ts +0 -1
  16. package/dist/drift-checks.d.ts +0 -1
  17. package/dist/end-of-turn.d.ts +6 -1
  18. package/dist/end-of-turn.js +8 -1
  19. package/dist/engine-lifecycle.d.ts +66 -2
  20. package/dist/engine-lifecycle.js +43 -4
  21. package/dist/engine-pty.d.ts +70 -3
  22. package/dist/engine-pty.js +80 -6
  23. package/dist/engine-watcher.d.ts +0 -1
  24. package/dist/engine.d.ts +0 -1
  25. package/dist/event-switch.d.ts +0 -1
  26. package/dist/gate/port.d.ts +0 -1
  27. package/dist/gate/settings-writer.d.ts +14 -1
  28. package/dist/gate/settings-writer.js +49 -0
  29. package/dist/image-input.d.ts +30 -0
  30. package/dist/image-input.js +79 -0
  31. package/dist/image-vision-smoke.d.ts +51 -0
  32. package/dist/image-vision-smoke.js +111 -0
  33. package/dist/index.d.ts +0 -1
  34. package/dist/index.js +6 -0
  35. package/dist/jsonl.d.ts +0 -1
  36. package/dist/lib.d.ts +0 -1
  37. package/dist/linearize.d.ts +1 -2
  38. package/dist/linearize.js +1 -1
  39. package/dist/live-diff-env.d.ts +0 -1
  40. package/dist/live-subagent-env.d.ts +0 -1
  41. package/dist/mcp-config-writer.d.ts +60 -0
  42. package/dist/mcp-config-writer.js +172 -0
  43. package/dist/model-catalog.d.ts +68 -3
  44. package/dist/model-catalog.js +123 -13
  45. package/dist/permissions/allow-inject.d.ts +0 -1
  46. package/dist/permissions/deny.d.ts +12 -1
  47. package/dist/permissions/deny.js +18 -0
  48. package/dist/permissions/elicitation-bridge.d.ts +71 -0
  49. package/dist/permissions/elicitation-bridge.js +146 -0
  50. package/dist/permissions/gate-wiring.d.ts +23 -3
  51. package/dist/permissions/gate-wiring.js +123 -1
  52. package/dist/permissions/hook-server.d.ts +11 -3
  53. package/dist/permissions/hook-server.js +10 -1
  54. package/dist/permissions/permission-mode.d.ts +0 -1
  55. package/dist/permissions/request-permission.d.ts +0 -1
  56. package/dist/settings.d.ts +0 -1
  57. package/dist/settings.js +9 -0
  58. package/dist/stop-reason-map.d.ts +0 -1
  59. package/dist/subagent-gate.d.ts +0 -1
  60. package/dist/subagent-source.d.ts +0 -1
  61. package/dist/subagent-watcher.d.ts +0 -1
  62. package/dist/tools.d.ts +0 -1
  63. package/dist/tools.js +5 -1
  64. package/dist/usage-env.d.ts +0 -1
  65. package/dist/usage.d.ts +3 -1
  66. package/dist/usage.js +3 -0
  67. package/dist/utils.d.ts +0 -1
  68. package/dist/zed-register.d.ts +0 -1
  69. package/package.json +12 -9
  70. package/dist/acp-agent.d.ts.map +0 -1
  71. package/dist/ansi-mirror.d.ts.map +0 -1
  72. package/dist/besteffort.d.ts.map +0 -1
  73. package/dist/billing/entrypoint-guard.d.ts.map +0 -1
  74. package/dist/claude-path.d.ts.map +0 -1
  75. package/dist/diff-enriched-reader.d.ts.map +0 -1
  76. package/dist/diff-source.d.ts.map +0 -1
  77. package/dist/drift-checks.d.ts.map +0 -1
  78. package/dist/end-of-turn.d.ts.map +0 -1
  79. package/dist/engine-lifecycle.d.ts.map +0 -1
  80. package/dist/engine-pty.d.ts.map +0 -1
  81. package/dist/engine-watcher.d.ts.map +0 -1
  82. package/dist/engine.d.ts.map +0 -1
  83. package/dist/event-switch.d.ts.map +0 -1
  84. package/dist/gate/port.d.ts.map +0 -1
  85. package/dist/gate/settings-writer.d.ts.map +0 -1
  86. package/dist/index.d.ts.map +0 -1
  87. package/dist/jsonl.d.ts.map +0 -1
  88. package/dist/lib.d.ts.map +0 -1
  89. package/dist/linearize.d.ts.map +0 -1
  90. package/dist/live-diff-env.d.ts.map +0 -1
  91. package/dist/live-subagent-env.d.ts.map +0 -1
  92. package/dist/model-catalog.d.ts.map +0 -1
  93. package/dist/permissions/allow-inject.d.ts.map +0 -1
  94. package/dist/permissions/deny.d.ts.map +0 -1
  95. package/dist/permissions/gate-wiring.d.ts.map +0 -1
  96. package/dist/permissions/hook-server.d.ts.map +0 -1
  97. package/dist/permissions/permission-mode.d.ts.map +0 -1
  98. package/dist/permissions/request-permission.d.ts.map +0 -1
  99. package/dist/settings.d.ts.map +0 -1
  100. package/dist/stop-reason-map.d.ts.map +0 -1
  101. package/dist/subagent-gate.d.ts.map +0 -1
  102. package/dist/subagent-source.d.ts.map +0 -1
  103. package/dist/subagent-watcher.d.ts.map +0 -1
  104. package/dist/tools.d.ts.map +0 -1
  105. package/dist/usage-env.d.ts.map +0 -1
  106. package/dist/usage.d.ts.map +0 -1
  107. package/dist/utils.d.ts.map +0 -1
  108. package/dist/zed-register.d.ts.map +0 -1
@@ -0,0 +1,95 @@
1
+ /** A single discoverable main-thread agent persona, as advertised to the Zed `agent` selector. */
2
+ export interface AgentCatalogEntry {
3
+ /**
4
+ * The persona reference passed to `claude --agent <value>`. GUARANTEED to match
5
+ * {@link SAFE_AGENT_REF} (a single safe segment, or a namespaced `plugin:name`) — entries whose
6
+ * resolved name fails this are dropped at discovery, so this string is always safe to double-quote
7
+ * into a `-lc` shell command.
8
+ */
9
+ value: string;
10
+ /**
11
+ * Human-facing label. From the probe path it is the raw reference (e.g. `epic:analyst`); from the
12
+ * glob path it is the frontmatter `name`, else a humanized form of the filename.
13
+ */
14
+ displayName: string;
15
+ /** The frontmatter `description`, when present (glob path only — the probe lists names, not docs). */
16
+ description?: string;
17
+ }
18
+ /**
19
+ * Injectable seams for {@link discoverAgents} — defaults wire the `node:` stdlib. Tests pass fakes so
20
+ * discovery is exercised against an in-memory fs / canned probe output, never the real disk, the real
21
+ * `~/.claude`, or the real `claude` binary.
22
+ */
23
+ export interface DiscoverAgentsDeps {
24
+ /**
25
+ * Probe `claude` for its canonical agent list (the PRIMARY source). Returns the raw combined
26
+ * stdout+stderr of `claude --agent <invalid>` (which contains the `Available agents: …` line), or
27
+ * `null` when the probe cannot run (missing binary / timeout) so discovery falls back to the glob.
28
+ * Default: {@link defaultProbeClaudeAgents}. Tests inject a canned string (or `null`) to stay
29
+ * hermetic — a glob-path test MUST pass `() => null` to force the fallback.
30
+ */
31
+ probeClaudeAgents?: () => string | null;
32
+ /** Resolve the user's home directory (default: `os.homedir`). Glob path only. */
33
+ homedir?: () => string;
34
+ /** List the `*.md` filenames in `dir`; MUST return `[]` when `dir` is missing/unreadable. */
35
+ readdirMd?: (dir: string) => string[];
36
+ /** Read a file's UTF-8 contents (default: `fs.readFileSync(p, "utf8")`). */
37
+ readFile?: (path: string) => string;
38
+ }
39
+ /**
40
+ * The persona-SEGMENT allowlist (R3.3). A single un-namespaced segment: letters/digits/underscore/
41
+ * hyphen only (no spaces, no shell metacharacters, no path separators, no `:`). Guards the glob path
42
+ * (on-disk `.md` names) and is the per-segment building block of {@link SAFE_AGENT_REF}.
43
+ */
44
+ export declare const SAFE_AGENT_NAME: RegExp;
45
+ /** True iff `name` is a non-empty string matching {@link SAFE_AGENT_NAME} (R3.3). */
46
+ export declare function isSafeAgentName(name: unknown): name is string;
47
+ /**
48
+ * The persona-REFERENCE allowlist (R3.6). A `--agent "<value>"` reference is either a single safe
49
+ * segment (a built-in like `Explore`, or a user/project `.md` persona) OR a namespaced plugin persona
50
+ * `plugin:name` — exactly ONE optional `:`-separated second segment, each segment a
51
+ * {@link SAFE_AGENT_NAME}. The `:` is inert inside the double-quoted shell argument.
52
+ */
53
+ export declare const SAFE_AGENT_REF: RegExp;
54
+ /** True iff `name` is a non-empty string matching {@link SAFE_AGENT_REF} (R3.6). */
55
+ export declare function isSafeAgentRef(name: unknown): name is string;
56
+ /**
57
+ * Extract the agent names from a `claude --agent <invalid>` probe output. Matches the
58
+ * `Available agents: a, b, c` line (tolerant of leading text and a trailing newline — `.` stops at the
59
+ * newline so only that line is captured), splits on commas, trims, and drops empties. Returns `[]` when
60
+ * the line is absent (format changed → the caller falls back to the glob).
61
+ */
62
+ export declare function parseAvailableAgents(probeOutput: string): string[];
63
+ /** Frontmatter fields we extract — only `name`/`description` matter for the catalog. */
64
+ interface Frontmatter {
65
+ name?: string;
66
+ description?: string;
67
+ }
68
+ /**
69
+ * Minimal `---`-fenced frontmatter line-parser (NOT a full YAML dep). Reads the leading
70
+ * `---\n … \n---` block and pulls `key: value` lines for the keys we care about; surrounding quotes on
71
+ * a value are stripped. A file without an opening `---` fence yields `{}` (filename-fallback applies).
72
+ * Tiny + pure on purpose — the persona frontmatter we consume is flat `key: value`.
73
+ */
74
+ export declare function parseFrontmatter(content: string): Frontmatter;
75
+ /**
76
+ * Discover the main-thread agent personas selectable via `claude --agent <name>`.
77
+ *
78
+ * PRIMARY (Task 7, R3.5): the {@link DiscoverAgentsDeps.probeClaudeAgents} probe asks `claude` for its
79
+ * canonical list (enabled plugin personas + built-ins, exactly as `claude --agent` resolves them) and
80
+ * we parse the `Available agents:` line; the bare `claude` default and any name failing the R3.6
81
+ * reference allowlist are dropped, and the rest are deduped + sorted by `value`. When the probe yields
82
+ * at least one persona, that is the result.
83
+ *
84
+ * FALLBACK (R3.1): when the probe returns `null` (binary missing / timeout) OR its output has no
85
+ * `Available agents:` line OR every listed name was dropped, discovery degrades to the on-disk glob
86
+ * ({@link globDiscoverAgents}).
87
+ *
88
+ * Never throws: a failed probe, a missing/unreadable dir or file, or an empty/all-unsafe result yields
89
+ * `[]` (the empty-state that hides the picker in a later sub-task).
90
+ *
91
+ * @param cwd the SESSION cwd (project dir) whose `.claude/agents` takes precedence in the fallback.
92
+ * @param deps injectable probe/fs/home seams (default: `node:` stdlib + the real `claude` probe).
93
+ */
94
+ export declare function discoverAgents(cwd: string, deps?: DiscoverAgentsDeps): AgentCatalogEntry[];
95
+ export {};
@@ -0,0 +1,287 @@
1
+ // Story 056 / Task 3.1 + Task 7 (R3.1, R3.3, R3.5, R3.6) — main-thread agent-persona discovery for
2
+ // the Zed Agent Panel's `agent` selector.
3
+ //
4
+ // HYBRID DISCOVERY (refined in Task 7 — was glob-only):
5
+ // PRIMARY (R3.5): ask `claude` itself. We spawn `claude --agent <invalid-sentinel>` through a SAFE
6
+ // args-array child process (NO shell, 8s timeout, EMPTY stdin ⇒ no prompt ⇒ NO tokens/billing).
7
+ // `claude` rejects the sentinel and prints `Available agents: a, b, c` — listing EXACTLY what
8
+ // `claude --agent` accepts: enabled plugin personas (namespaced `plugin:name`) AND the built-ins
9
+ // (Explore, Plan, general-purpose, …). This is the same source the upstream SDK engine uses, so
10
+ // the panel reaches real parity with the original. LIVE-VERIFIED against 2.1.195 (deterministic,
11
+ // cwd-independent). The bare `claude` built-in = the no-persona default and is dropped (it maps to
12
+ // the option's existing "default" sentinel). The spawn mirrors claude-path.ts (execFileSync,
13
+ // args-list, NEVER a shell — C5); the only argument is a CONSTANT sentinel, never user input.
14
+ // FALLBACK (R3.1): when the probe cannot run (binary missing / timeout) OR the error format changes
15
+ // (no `Available agents:` line on a future version), discovery degrades to a glob of the on-disk
16
+ // persona files: `<cwd>/.claude/agents/*.md` (project — PRECEDENCE) + `~/.claude/agents/*.md`
17
+ // (user). Both dirs empty (the common case) yields `[]` and a later sub-task's gate hides the
18
+ // picker entirely.
19
+ //
20
+ // NOTE: `claude agents` (the subcommand) is the *background-agents* manager — it lists dispatched
21
+ // cloud SESSIONS, NOT persona definitions — so we never call it; the persona list comes from the
22
+ // `--agent` rejection message above.
23
+ //
24
+ // SECURITY (R3.3 / R3.6 — the highest-severity item). The `value` we return is interpolated into a
25
+ // `-lc` shell string via `--agent "<name>"` (Task 3.3). A plugin persona is NAMESPACED (`plugin:name`),
26
+ // so the boundary allowlist is {@link SAFE_AGENT_REF} = `/^[A-Za-z0-9_-]+(?::[A-Za-z0-9_-]+)?$/`: one
27
+ // optional `:`-separated segment, each segment letters/digits/`_`/`-` only (the `:` is inert inside the
28
+ // double-quoted argument). Every resolved name is checked and DROPPED if it fails — a name with spaces,
29
+ // quotes, path separators, command substitution, chaining, … is NEVER returned. The second layer is
30
+ // double-quoting at spawn time. {@link SAFE_AGENT_NAME} (single segment) still guards the glob path,
31
+ // where on-disk `.md` persona names are always un-namespaced.
32
+ //
33
+ // PURE + dependency-injectable (cf. claude-path.ts / usage-env.ts): all fs/home/probe access goes
34
+ // through the {@link DiscoverAgentsDeps} seams so the unit tests inject fakes and never touch the real
35
+ // disk or spawn the real `claude` binary. The only `node:` builtins are os/fs/path/child_process; no
36
+ // shell is ever invoked. Dependency-free: a minimal inline frontmatter line-parser stands in for a YAML
37
+ // dependency (FORK.md pins node-pty + the SDK as the only runtime deps; this adds none).
38
+ //
39
+ // node:test runner: `node --experimental-strip-types --test test/agent-catalog.test.ts`
40
+ import { homedir as osHomedir } from "node:os";
41
+ import { readdirSync, readFileSync } from "node:fs";
42
+ import { execFileSync } from "node:child_process";
43
+ import { join } from "node:path";
44
+ /**
45
+ * The persona-SEGMENT allowlist (R3.3). A single un-namespaced segment: letters/digits/underscore/
46
+ * hyphen only (no spaces, no shell metacharacters, no path separators, no `:`). Guards the glob path
47
+ * (on-disk `.md` names) and is the per-segment building block of {@link SAFE_AGENT_REF}.
48
+ */
49
+ export const SAFE_AGENT_NAME = /^[A-Za-z0-9_-]+$/;
50
+ /** True iff `name` is a non-empty string matching {@link SAFE_AGENT_NAME} (R3.3). */
51
+ export function isSafeAgentName(name) {
52
+ return typeof name === "string" && SAFE_AGENT_NAME.test(name);
53
+ }
54
+ /**
55
+ * The persona-REFERENCE allowlist (R3.6). A `--agent "<value>"` reference is either a single safe
56
+ * segment (a built-in like `Explore`, or a user/project `.md` persona) OR a namespaced plugin persona
57
+ * `plugin:name` — exactly ONE optional `:`-separated second segment, each segment a
58
+ * {@link SAFE_AGENT_NAME}. The `:` is inert inside the double-quoted shell argument.
59
+ */
60
+ export const SAFE_AGENT_REF = /^[A-Za-z0-9_-]+(?::[A-Za-z0-9_-]+)?$/;
61
+ /** True iff `name` is a non-empty string matching {@link SAFE_AGENT_REF} (R3.6). */
62
+ export function isSafeAgentRef(name) {
63
+ return typeof name === "string" && SAFE_AGENT_REF.test(name);
64
+ }
65
+ /** A sentinel `--agent` value that can never be a real persona — forces the "not found" + list. */
66
+ const PROBE_SENTINEL = "__acp_agent_probe_sentinel__";
67
+ /** The bare built-in `claude` = the no-persona default (the option's existing "default" sentinel). */
68
+ const DEFAULT_PERSONA = "claude";
69
+ /**
70
+ * Default {@link DiscoverAgentsDeps.probeClaudeAgents}: spawn `claude --agent <sentinel>` and return
71
+ * its combined output. `execFileSync` (args-array — NO shell, mirroring claude-path.ts / C5) throws on
72
+ * the expected non-zero exit; the `Available agents: …` line is carried on the error's stdout/stderr.
73
+ * Empty stdin + an 8s timeout keep it cheap and tokenless. Any failure (missing binary, timeout,
74
+ * unexpected shape) → `null`, which routes the caller to the glob fallback. The sole argument is the
75
+ * CONSTANT {@link PROBE_SENTINEL} — never user input — so there is no injection surface.
76
+ *
77
+ * OPT-IN (`FORK_AGENT_PROBE=1`): the spawn runs ONLY when production enables it (the fork entrypoint
78
+ * `index.ts` sets the flag). Unit tests import this module directly WITHOUT the flag, so the default
79
+ * probe is a no-op (`null` → glob fallback) — they never spawn the real `claude` and stay hermetic and
80
+ * fast. Tests that DO exercise the probe path inject {@link DiscoverAgentsDeps.probeClaudeAgents}
81
+ * directly, bypassing this gate.
82
+ */
83
+ function defaultProbeClaudeAgents() {
84
+ if (process.env.FORK_AGENT_PROBE !== "1")
85
+ return null; // opt-in — production (index.ts) enables it
86
+ try {
87
+ // stdio: stdin IGNORED (claude reads EOF → no hang, no prompt → no tokens); stdout+stderr PIPED so
88
+ // the `Available agents:` line (claude writes it to stderr) is captured, NEVER leaked to the fork's
89
+ // own stdout (the ACP wire) or stderr (the log). The list lands on the throw's stderr below.
90
+ const out = execFileSync("claude", ["--agent", PROBE_SENTINEL], {
91
+ stdio: ["ignore", "pipe", "pipe"],
92
+ timeout: 8000,
93
+ encoding: "utf8",
94
+ maxBuffer: 1024 * 1024,
95
+ });
96
+ return out.length > 0 ? out : null;
97
+ }
98
+ catch (err) {
99
+ const e = err;
100
+ const out = `${e?.stdout ?? ""}${e?.stderr ?? ""}`;
101
+ return out.length > 0 ? out : null;
102
+ }
103
+ }
104
+ /**
105
+ * Extract the agent names from a `claude --agent <invalid>` probe output. Matches the
106
+ * `Available agents: a, b, c` line (tolerant of leading text and a trailing newline — `.` stops at the
107
+ * newline so only that line is captured), splits on commas, trims, and drops empties. Returns `[]` when
108
+ * the line is absent (format changed → the caller falls back to the glob).
109
+ */
110
+ export function parseAvailableAgents(probeOutput) {
111
+ const m = /Available agents:\s*(.+)/.exec(probeOutput);
112
+ if (!m)
113
+ return [];
114
+ return m[1]
115
+ .split(",")
116
+ .map((s) => s.trim())
117
+ .filter((s) => s.length > 0);
118
+ }
119
+ /**
120
+ * Resolve a probe-listed agent name into an entry, or `null` to drop it. The bare `claude` built-in is
121
+ * the no-persona default (mapped to the option's existing "default" sentinel) so it is dropped; any
122
+ * name failing the R3.6 reference allowlist is dropped. `displayName` is the raw reference (e.g.
123
+ * `epic:analyst`), which is what the panel shows.
124
+ */
125
+ function entryFromProbeName(name) {
126
+ if (name === DEFAULT_PERSONA)
127
+ return null;
128
+ if (!isSafeAgentRef(name))
129
+ return null;
130
+ return { value: name, displayName: name };
131
+ }
132
+ /**
133
+ * Default `readdirMd`: list `*.md` filenames in `dir`, returning `[]` when the directory is missing or
134
+ * unreadable (the empty-state — never throws). Sorted for a stable first-wins ordering within a dir.
135
+ */
136
+ function defaultReaddirMd(dir) {
137
+ try {
138
+ return readdirSync(dir)
139
+ .filter((f) => f.toLowerCase().endsWith(".md"))
140
+ .sort();
141
+ }
142
+ catch {
143
+ return [];
144
+ }
145
+ }
146
+ /** Default `readFile`: UTF-8 file read via the `node:fs` stdlib. */
147
+ function defaultReadFile(path) {
148
+ return readFileSync(path, "utf8");
149
+ }
150
+ /** The leading `name` (sans `.md`) of a persona file, used as the value/displayName fallback. */
151
+ function baseName(filename) {
152
+ return filename.replace(/\.md$/i, "");
153
+ }
154
+ /**
155
+ * Humanize a filename stem into a display label when the frontmatter has no `name`:
156
+ * `code-reviewer` → `Code Reviewer`, `db_admin` → `Db Admin`. Pure string cosmetics.
157
+ */
158
+ function humanize(stem) {
159
+ return stem
160
+ .split(/[-_]+/)
161
+ .filter((w) => w.length > 0)
162
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
163
+ .join(" ");
164
+ }
165
+ /**
166
+ * Minimal `---`-fenced frontmatter line-parser (NOT a full YAML dep). Reads the leading
167
+ * `---\n … \n---` block and pulls `key: value` lines for the keys we care about; surrounding quotes on
168
+ * a value are stripped. A file without an opening `---` fence yields `{}` (filename-fallback applies).
169
+ * Tiny + pure on purpose — the persona frontmatter we consume is flat `key: value`.
170
+ */
171
+ export function parseFrontmatter(content) {
172
+ // The opening fence must be the very first line (allowing a leading BOM / blank lines is overkill
173
+ // for this format). Bail to `{}` when there is no fenced block.
174
+ const lines = content.split(/\r?\n/);
175
+ if (lines[0]?.trim() !== "---")
176
+ return {};
177
+ const out = {};
178
+ for (let i = 1; i < lines.length; i++) {
179
+ const line = lines[i];
180
+ if (line.trim() === "---")
181
+ break; // closing fence — stop
182
+ const m = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(line);
183
+ if (!m)
184
+ continue; // not a `key: value` line — skip (lists/comments/blanks)
185
+ const key = m[1].toLowerCase();
186
+ if (key !== "name" && key !== "description")
187
+ continue;
188
+ let value = m[2].trim();
189
+ // Strip a single pair of matching surrounding quotes.
190
+ if (value.length >= 2 &&
191
+ ((value.startsWith('"') && value.endsWith('"')) ||
192
+ (value.startsWith("'") && value.endsWith("'")))) {
193
+ value = value.slice(1, -1);
194
+ }
195
+ if (value.length > 0)
196
+ out[key] = value;
197
+ }
198
+ return out;
199
+ }
200
+ /**
201
+ * Resolve a single persona `*.md` file into an {@link AgentCatalogEntry}, or `null` when it cannot
202
+ * contribute a SAFE entry. The resolved name is the frontmatter `name` if present, else the filename
203
+ * stem; it is then run through the R3.3 allowlist and the file is DROPPED (→ `null`) on any failure —
204
+ * including an unreadable file. `displayName` falls back to a humanized stem.
205
+ */
206
+ function resolveEntry(dir, filename, readFile) {
207
+ let fm;
208
+ try {
209
+ fm = parseFrontmatter(readFile(join(dir, filename)));
210
+ }
211
+ catch {
212
+ return null; // unreadable file — skip it, never throw (R3.1 graceful)
213
+ }
214
+ const stem = baseName(filename);
215
+ const resolvedName = fm.name ?? stem;
216
+ // SECURITY (R3.3): drop any entry whose name escapes the single-segment allowlist — never returned.
217
+ if (!isSafeAgentName(resolvedName))
218
+ return null;
219
+ return {
220
+ value: resolvedName,
221
+ displayName: fm.name ?? humanize(stem),
222
+ ...(fm.description ? { description: fm.description } : {}),
223
+ };
224
+ }
225
+ /** Sort entries by `value` for a stable, scope-independent ordering. */
226
+ function sortByValue(entries) {
227
+ return entries.sort((a, b) => (a.value < b.value ? -1 : a.value > b.value ? 1 : 0));
228
+ }
229
+ /**
230
+ * FALLBACK glob discovery (R3.1): scan `<cwd>/.claude/agents/*.md` (project — PRECEDENCE) and
231
+ * `~/.claude/agents/*.md` (user), parsing frontmatter `name`/`description` (filename stem as the
232
+ * fallback name), sanitizing against the R3.3 allowlist, deduping by `value` (project beats user;
233
+ * first-wins within a dir), and sorting by `value`. Never throws.
234
+ */
235
+ function globDiscoverAgents(cwd, deps) {
236
+ const homedir = deps.homedir ?? osHomedir;
237
+ const readdirMd = deps.readdirMd ?? defaultReaddirMd;
238
+ const readFile = deps.readFile ?? defaultReadFile;
239
+ const projectDir = join(cwd, ".claude", "agents");
240
+ const userDir = join(homedir(), ".claude", "agents");
241
+ const byValue = new Map();
242
+ for (const dir of [projectDir, userDir]) {
243
+ for (const filename of readdirMd(dir)) {
244
+ const entry = resolveEntry(dir, filename, readFile);
245
+ if (entry && !byValue.has(entry.value)) {
246
+ byValue.set(entry.value, entry);
247
+ }
248
+ }
249
+ }
250
+ return sortByValue([...byValue.values()]);
251
+ }
252
+ /**
253
+ * Discover the main-thread agent personas selectable via `claude --agent <name>`.
254
+ *
255
+ * PRIMARY (Task 7, R3.5): the {@link DiscoverAgentsDeps.probeClaudeAgents} probe asks `claude` for its
256
+ * canonical list (enabled plugin personas + built-ins, exactly as `claude --agent` resolves them) and
257
+ * we parse the `Available agents:` line; the bare `claude` default and any name failing the R3.6
258
+ * reference allowlist are dropped, and the rest are deduped + sorted by `value`. When the probe yields
259
+ * at least one persona, that is the result.
260
+ *
261
+ * FALLBACK (R3.1): when the probe returns `null` (binary missing / timeout) OR its output has no
262
+ * `Available agents:` line OR every listed name was dropped, discovery degrades to the on-disk glob
263
+ * ({@link globDiscoverAgents}).
264
+ *
265
+ * Never throws: a failed probe, a missing/unreadable dir or file, or an empty/all-unsafe result yields
266
+ * `[]` (the empty-state that hides the picker in a later sub-task).
267
+ *
268
+ * @param cwd the SESSION cwd (project dir) whose `.claude/agents` takes precedence in the fallback.
269
+ * @param deps injectable probe/fs/home seams (default: `node:` stdlib + the real `claude` probe).
270
+ */
271
+ export function discoverAgents(cwd, deps = {}) {
272
+ // PRIMARY: ask `claude` — the source of truth for what `--agent` accepts.
273
+ const probe = deps.probeClaudeAgents ?? defaultProbeClaudeAgents;
274
+ const probeOut = probe();
275
+ if (probeOut) {
276
+ const byValue = new Map();
277
+ for (const name of parseAvailableAgents(probeOut)) {
278
+ const entry = entryFromProbeName(name);
279
+ if (entry && !byValue.has(entry.value))
280
+ byValue.set(entry.value, entry);
281
+ }
282
+ if (byValue.size > 0)
283
+ return sortByValue([...byValue.values()]);
284
+ }
285
+ // FALLBACK: the probe is unavailable or its format changed — glob the on-disk personas.
286
+ return globDiscoverAgents(cwd, deps);
287
+ }
@@ -39,4 +39,3 @@ export interface AnsiMirrorOptions {
39
39
  * sequence the JSONL tail produces.
40
40
  */
41
41
  export declare function attachAnsiMirror(p: Pick<IPty, "onData">, opts?: AnsiMirrorOptions): IDisposable | undefined;
42
- //# sourceMappingURL=ansi-mirror.d.ts.map
@@ -41,4 +41,3 @@ export declare function translateBestEffort(content: string | ContentBlock[], ro
41
41
  translate?: TranslateFn;
42
42
  }): SessionNotification[];
43
43
  export {};
44
- //# sourceMappingURL=besteffort.d.ts.map
@@ -94,4 +94,3 @@ export declare function guardEvent(event: WatchedMessage, hooks: GuardHooks): Gu
94
94
  * is the spawn helper's anti-recusa concern, distinct from this billing check — §10).
95
95
  */
96
96
  export declare function assertCleanBillingEnv(env: Record<string, string | undefined>): void;
97
- //# sourceMappingURL=entrypoint-guard.d.ts.map
@@ -52,4 +52,3 @@ export interface ResolveOptions {
52
52
  * @throws Error naming the PATH lookup attempt if no executable `claude` is found.
53
53
  */
54
54
  export declare function resolveClaudePath(opts?: ResolveOptions): string;
55
- //# sourceMappingURL=claude-path.d.ts.map
@@ -26,6 +26,8 @@
26
26
  import { execFileSync } from "node:child_process";
27
27
  import { accessSync, constants } from "node:fs";
28
28
  import { delimiter, join } from "node:path";
29
+ // Story 058 R3.2 — version-aware image-vision smoke (observability only; NEVER gates image:true, R3.1).
30
+ import { reportImageVisionSmoke } from "./image-vision-smoke.js";
29
31
  /**
30
32
  * Provenance pin the live `claude` version is measured against — kept in lockstep
31
33
  * with `fork/.fork-provenance.json`'s externalRuntimeDeps `claude` entry. A live
@@ -121,6 +123,10 @@ export function resolveClaudePath(opts = {}) {
121
123
  log(`[claude-path] resolved claude ${candidate} (version ${detected})`);
122
124
  }
123
125
  reportVersionDrift(detected, PROVENANCE_CLAUDE_VERSION, log);
126
+ // Story 058 R3.2 — version-aware @image vision smoke: warn ONCE (one-liner) when this claude
127
+ // is NOT a version confirmed to vision-encode @image via Read. Observability only — it never
128
+ // blocks and never touches promptCapabilities.image (stays `image: true`, R3.1).
129
+ reportImageVisionSmoke(detected, log);
124
130
  }
125
131
  catch {
126
132
  // Observability is best-effort; never let it break resolution.
@@ -0,0 +1,84 @@
1
+ import type { AvailableCommand } from "@agentclientprotocol/sdk";
2
+ /**
3
+ * The curated built-in slash-command tier (R9) — the interactive `claude` TUI's own built-ins, which are
4
+ * baked into the binary and therefore NOT disk-discoverable. This is a CURATED APPROXIMATION (C1), NOT a
5
+ * live probe: it stands beside the same static-curation idea as `MODEL_CATALOG`. It is the LOWEST-
6
+ * precedence tier, so any disk command/skill of the same name shadows a built-in (R1/R9). Names are
7
+ * lowercase (R3) and every entry carries a non-empty `description` (the SDK requires it). The list is
8
+ * intentionally small and conservative — a documented approximation, not an exhaustive enumeration.
9
+ */
10
+ export declare const BUILTIN_COMMANDS: readonly AvailableCommand[];
11
+ /**
12
+ * Injectable seams for {@link discoverCommands} — defaults wire the `node:` stdlib and the built-in
13
+ * tier. Tests pass fakes so discovery is exercised against an in-memory fs and an isolated (`[]`)
14
+ * built-in tier, never the real disk or the real `~/.claude` (R5).
15
+ */
16
+ export interface DiscoverCommandsDeps {
17
+ /** Resolve the user's home directory (default: `os.homedir`). */
18
+ homedir?: () => string;
19
+ /** List the `*.md` filenames in `dir`; MUST return `[]` when `dir` is missing/unreadable. */
20
+ readdirMd?: (dir: string) => string[];
21
+ /**
22
+ * List the IMMEDIATE sub-directory names of `dir` (for the skills tier — R7); MUST return `[]` when
23
+ * `dir` is missing/unreadable. Default: {@link defaultReaddirDirs}.
24
+ */
25
+ readdirDirs?: (dir: string) => string[];
26
+ /** Read a file's UTF-8 contents (default: `fs.readFileSync(p, "utf8")`). */
27
+ readFile?: (path: string) => string;
28
+ /** The built-in command tier (lowest precedence). Default: {@link BUILTIN_COMMANDS}. */
29
+ builtins?: readonly AvailableCommand[];
30
+ }
31
+ /**
32
+ * The command-name allowlist (R3). A single un-namespaced segment: LOWERCASE letters/digits/underscore/
33
+ * hyphen only (no spaces, no uppercase, no shell metacharacters, no path separators). Slash-command
34
+ * names are conventionally lowercase, so — unlike `agent-catalog.ts`'s {@link SAFE_AGENT_NAME} — this
35
+ * allowlist excludes uppercase.
36
+ */
37
+ export declare const SAFE_COMMAND_NAME: RegExp;
38
+ /** True iff `name` is a non-empty string matching {@link SAFE_COMMAND_NAME} (R3). */
39
+ export declare function isSafeCommandName(name: unknown): name is string;
40
+ /** The frontmatter fields we extract — `description` and (hyphenated) `argument-hint`. */
41
+ interface CommandFrontmatter {
42
+ description?: string;
43
+ argumentHint?: string;
44
+ }
45
+ /**
46
+ * Minimal `---`-fenced frontmatter line-parser (NOT a full YAML dep). Reads the leading `---\n … \n---`
47
+ * block and pulls the `description` and `argument-hint` `key: value` lines; a single pair of surrounding
48
+ * quotes on a value is stripped. The frontmatter key `argument-hint` (a HYPHEN) is returned as the
49
+ * camelCase {@link CommandFrontmatter.argumentHint}. A file whose first line is not `---` yields `{}`.
50
+ * Tiny + pure on purpose, mirroring `agent-catalog.ts`'s `parseFrontmatter` — the command frontmatter we
51
+ * consume is flat `key: value`.
52
+ */
53
+ export declare function parseCommandFrontmatter(content: string): CommandFrontmatter;
54
+ /**
55
+ * Discover the custom slash-commands to advertise via ACP `available_commands` — FULLY OFFLINE (no
56
+ * subprocess, no network).
57
+ *
58
+ * TIERS, highest precedence first:
59
+ * 1. `<cwd>/.claude/commands/*.md` (cwd commands — R1.1 precedence)
60
+ * 2. `<cwd>/.claude/skills/<name>/SKILL.md` (cwd skills — R7)
61
+ * 3. `~/.claude/commands/*.md` (user commands)
62
+ * 4. `~/.claude/skills/<name>/SKILL.md` (user skills — R7)
63
+ * 5. `~/.claude/plugins/marketplaces/<m>/{commands,skills}` (ENABLED-plugin tier — R8; a plugin
64
+ * surface loses a name collision to any higher tier — R8.1)
65
+ * 6. {@link DiscoverCommandsDeps.builtins} (the R9 built-in tier — LOWEST; a built-in loses a name
66
+ * collision to any disk entry)
67
+ *
68
+ * Each command `*.md` file yields `{ name, description, input? }` (R2): `name` = basename sans `.md`,
69
+ * `description` = frontmatter `description` (else `""`), `input: { hint }` from `argument-hint` (omitted
70
+ * when absent). Each skill `<name>/SKILL.md` yields `{ name, description }` (no `input`): `name` = the
71
+ * DIRECTORY name, `description` = the `SKILL.md` frontmatter (R7 / R7.1). Every name is dropped if it
72
+ * fails the R3 {@link SAFE_COMMAND_NAME} allowlist. Names are deduped FIRST-WINS across the ordered
73
+ * surfaces — so a cwd command out-ranks a cwd skill (cmd > skill, same scope), any cwd surface
74
+ * out-ranks any user surface, an enabled-plugin surface out-ranks the built-ins but LOSES to any
75
+ * cwd/user surface (R8.1) — then the merged set is returned FLAT ALPHABETICAL by `name`.
76
+ *
77
+ * Never throws (R3 / R5): a missing/unreadable dir or file yields `[]` for that surface; a skills subdir
78
+ * without a readable `SKILL.md` is skipped.
79
+ *
80
+ * @param cwd the SESSION cwd (project dir) whose `.claude/{commands,skills}` take precedence.
81
+ * @param deps injectable fs/home/builtins seams (default: `node:` stdlib + {@link BUILTIN_COMMANDS}).
82
+ */
83
+ export declare function discoverCommands(cwd: string, deps?: DiscoverCommandsDeps): AvailableCommand[];
84
+ export {};