@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,339 @@
1
+ // Story 063 / Task 1.2 (R1.1, R2, R3, R5) — OFFLINE discovery of custom slash-commands for the ACP
2
+ // fork's `available_commands` advertisement.
3
+ //
4
+ // The command analog of {@link file://./agent-catalog.ts}: PURE + dependency-injectable, sharing the
5
+ // exact style — a minimal inline frontmatter line-parser (NO YAML dep), graceful `readdirMd`/`readFile`
6
+ // seams (try/catch → the empty-state, NEVER a throw), an ordered `Map<name, entry>` first-wins dedup,
7
+ // and an alphabetical sort. Unlike `agent-catalog.ts` this surface is FULLY OFFLINE — no subprocess,
8
+ // no probe, no network: slash-commands live on disk (`<cwd>/.claude/commands/*.md`, `~/.claude/commands/
9
+ // *.md`) plus a curated built-in tier. The only `node:` builtins are os/fs/path; the single non-`node:`
10
+ // import is TYPE-ONLY (`AvailableCommand`), so it is erased at runtime and adds no runtime dependency
11
+ // (FORK.md pins node-pty + the SDK as the only runtime deps; this file adds none).
12
+ //
13
+ // PRECEDENCE (R1.1 / R7 / R8.1). Four disk surfaces are consulted, highest precedence first — commands
14
+ // out-scope skills at the SAME scope, and any cwd surface out-scopes any user surface:
15
+ // 1. `<cwd>/.claude/commands/*.md` (cwd commands)
16
+ // 2. `<cwd>/.claude/skills/*/SKILL.md` (cwd skills — R7)
17
+ // 3. `~/.claude/commands/*.md` (user commands)
18
+ // 4. `~/.claude/skills/*/SKILL.md` (user skills — R7)
19
+ // then the built-in tier (R9, populated in Task 1.5) is the LOWEST precedence: a built-in whose name
20
+ // collides with a disk entry loses. One entry per name (first-wins across the ordered surfaces). The
21
+ // visible result is FLAT ALPHABETICAL by `name` — tiers govern collision dedup ONLY, not grouping
22
+ // (determinism + parity with `agent-catalog.ts`'s `sortByValue`).
23
+ //
24
+ // SKILLS (R7 / R7.1): a skill lives at `<scope>/.claude/skills/<name>/SKILL.md`; the command `name` is
25
+ // the DIRECTORY name (same R3 allowlist as a command) and `description` comes from the `SKILL.md`
26
+ // frontmatter. A skills subdir WITHOUT a `SKILL.md` is NOT a command (the `readFile` throw skips it).
27
+ // Skills carry no `argument-hint`, so a skill entry is `{ name, description }` with no `input`.
28
+ //
29
+ // SECURITY (R3): a command `name` is the file's basename sans `.md`; it is checked against
30
+ // {@link SAFE_COMMAND_NAME} = `/^[a-z0-9_-]+$/` and the entry is DROPPED on any failure (a name with
31
+ // spaces, quotes, path separators, uppercase, … never renders). A missing/unreadable dir or file yields
32
+ // `[]` for that surface — discovery NEVER throws or crashes the session (R3 / R5).
33
+ //
34
+ // PURE + dependency-injectable (R5): all fs/home access goes through the {@link DiscoverCommandsDeps}
35
+ // seams so the unit tests inject an in-memory fs and never touch the real `~/.claude`; the built-in tier
36
+ // is likewise an injectable seam (`builtins`) defaulting to {@link BUILTIN_COMMANDS}.
37
+ //
38
+ // node:test runner: `node --experimental-strip-types --test test/command-catalog.test.ts`
39
+ import { homedir as osHomedir } from "node:os";
40
+ import { readdirSync, readFileSync } from "node:fs";
41
+ import { join } from "node:path";
42
+ /**
43
+ * The curated built-in slash-command tier (R9) — the interactive `claude` TUI's own built-ins, which are
44
+ * baked into the binary and therefore NOT disk-discoverable. This is a CURATED APPROXIMATION (C1), NOT a
45
+ * live probe: it stands beside the same static-curation idea as `MODEL_CATALOG`. It is the LOWEST-
46
+ * precedence tier, so any disk command/skill of the same name shadows a built-in (R1/R9). Names are
47
+ * lowercase (R3) and every entry carries a non-empty `description` (the SDK requires it). The list is
48
+ * intentionally small and conservative — a documented approximation, not an exhaustive enumeration.
49
+ */
50
+ export const BUILTIN_COMMANDS = [
51
+ { name: "clear", description: "Clear the conversation history and free up context" },
52
+ { name: "compact", description: "Summarize and compact the current conversation" },
53
+ { name: "config", description: "Open the settings / configuration panel" },
54
+ { name: "cost", description: "Show token usage and cost for the current session" },
55
+ { name: "help", description: "List available slash commands and usage help" },
56
+ { name: "init", description: "Initialize a CLAUDE.md with codebase documentation" },
57
+ { name: "memory", description: "Edit the project or user memory (CLAUDE.md) files" },
58
+ { name: "model", description: "Switch the active model for the session" },
59
+ { name: "resume", description: "Resume a previous conversation" },
60
+ { name: "review", description: "Review a pull request or the pending changes" },
61
+ ];
62
+ /**
63
+ * The command-name allowlist (R3). A single un-namespaced segment: LOWERCASE letters/digits/underscore/
64
+ * hyphen only (no spaces, no uppercase, no shell metacharacters, no path separators). Slash-command
65
+ * names are conventionally lowercase, so — unlike `agent-catalog.ts`'s {@link SAFE_AGENT_NAME} — this
66
+ * allowlist excludes uppercase.
67
+ */
68
+ export const SAFE_COMMAND_NAME = /^[a-z0-9_-]+$/;
69
+ /** True iff `name` is a non-empty string matching {@link SAFE_COMMAND_NAME} (R3). */
70
+ export function isSafeCommandName(name) {
71
+ return typeof name === "string" && SAFE_COMMAND_NAME.test(name);
72
+ }
73
+ /**
74
+ * Default `readdirMd`: list `*.md` filenames in `dir`, returning `[]` when the directory is missing or
75
+ * unreadable (the empty-state — never throws). Sorted for a stable first-wins ordering within a dir.
76
+ * Mirrors `agent-catalog.ts`'s `defaultReaddirMd`.
77
+ */
78
+ function defaultReaddirMd(dir) {
79
+ try {
80
+ return readdirSync(dir)
81
+ .filter((f) => f.toLowerCase().endsWith(".md"))
82
+ .sort();
83
+ }
84
+ catch {
85
+ return [];
86
+ }
87
+ }
88
+ /**
89
+ * Default `readdirDirs`: list the IMMEDIATE sub-directory names of `dir`, returning `[]` when the
90
+ * directory is missing or unreadable (the empty-state — never throws). Sorted for a stable first-wins
91
+ * ordering within the skills tier. Mirrors the graceful try/catch style of {@link defaultReaddirMd}.
92
+ */
93
+ function defaultReaddirDirs(dir) {
94
+ try {
95
+ return readdirSync(dir, { withFileTypes: true })
96
+ .filter((d) => d.isDirectory())
97
+ .map((d) => d.name)
98
+ .sort();
99
+ }
100
+ catch {
101
+ return [];
102
+ }
103
+ }
104
+ /** Default `readFile`: UTF-8 file read via the `node:fs` stdlib. */
105
+ function defaultReadFile(path) {
106
+ return readFileSync(path, "utf8");
107
+ }
108
+ /** The leading `name` (sans `.md`) of a command file — the command's name. */
109
+ function baseName(filename) {
110
+ return filename.replace(/\.md$/i, "");
111
+ }
112
+ /**
113
+ * Minimal `---`-fenced frontmatter line-parser (NOT a full YAML dep). Reads the leading `---\n … \n---`
114
+ * block and pulls the `description` and `argument-hint` `key: value` lines; a single pair of surrounding
115
+ * quotes on a value is stripped. The frontmatter key `argument-hint` (a HYPHEN) is returned as the
116
+ * camelCase {@link CommandFrontmatter.argumentHint}. A file whose first line is not `---` yields `{}`.
117
+ * Tiny + pure on purpose, mirroring `agent-catalog.ts`'s `parseFrontmatter` — the command frontmatter we
118
+ * consume is flat `key: value`.
119
+ */
120
+ export function parseCommandFrontmatter(content) {
121
+ // The opening fence must be the very first line. Bail to `{}` when there is no fenced block.
122
+ const lines = content.split(/\r?\n/);
123
+ if (lines[0]?.trim() !== "---")
124
+ return {};
125
+ const out = {};
126
+ for (let i = 1; i < lines.length; i++) {
127
+ const line = lines[i];
128
+ if (line.trim() === "---")
129
+ break; // closing fence — stop
130
+ const m = /^([A-Za-z][\w-]*)\s*:\s*(.*)$/.exec(line);
131
+ if (!m)
132
+ continue; // not a `key: value` line — skip (lists/comments/blanks)
133
+ const key = m[1].toLowerCase();
134
+ if (key !== "description" && key !== "argument-hint")
135
+ continue;
136
+ let value = m[2].trim();
137
+ // Strip a single pair of matching surrounding quotes.
138
+ if (value.length >= 2 &&
139
+ ((value.startsWith('"') && value.endsWith('"')) ||
140
+ (value.startsWith("'") && value.endsWith("'")))) {
141
+ value = value.slice(1, -1);
142
+ }
143
+ if (value.length === 0)
144
+ continue;
145
+ if (key === "description")
146
+ out.description = value;
147
+ else
148
+ out.argumentHint = value; // `argument-hint` → camelCase `argumentHint`
149
+ }
150
+ return out;
151
+ }
152
+ /**
153
+ * Resolve a single command `*.md` file into an {@link AvailableCommand}, or `null` when it cannot
154
+ * contribute a SAFE entry. The name is the filename stem, run through the R3 allowlist — the file is
155
+ * DROPPED (→ `null`) on any failure, including an unreadable file (NEVER throws). `description` defaults
156
+ * to `""` (the SDK REQUIRES it) when absent; an `argument-hint` maps to `input: { hint }`, which is
157
+ * OMITTED entirely when there is none.
158
+ */
159
+ function resolveEntry(dir, filename, readFile) {
160
+ const name = baseName(filename);
161
+ // SECURITY (R3): drop any entry whose name escapes the allowlist — optional debug log, never a throw.
162
+ if (!isSafeCommandName(name)) {
163
+ if (process.env.FORK_COMMAND_DEBUG === "1") {
164
+ process.stderr.write(`[command-catalog] dropped unsafe command name: ${JSON.stringify(name)}\n`);
165
+ }
166
+ return null;
167
+ }
168
+ let fm;
169
+ try {
170
+ fm = parseCommandFrontmatter(readFile(join(dir, filename)));
171
+ }
172
+ catch {
173
+ return null; // unreadable file — skip it, never throw (R3 / R5 graceful)
174
+ }
175
+ const description = fm.description ?? ""; // AvailableCommand.description is REQUIRED
176
+ return {
177
+ name,
178
+ description,
179
+ ...(fm.argumentHint ? { input: { hint: fm.argumentHint } } : {}), // OMIT `input` when no hint
180
+ };
181
+ }
182
+ /**
183
+ * Resolve a single skill sub-directory into an {@link AvailableCommand}, or `null` when it cannot
184
+ * contribute a SAFE entry (R7 / R7.1). The name is the DIRECTORY name `subdirName`, run through the R3
185
+ * allowlist — DROPPED (→ `null`, same optional `FORK_COMMAND_DEBUG` log as {@link resolveEntry}) on any
186
+ * failure. The skill's `SKILL.md` is REQUIRED: `join(skillsDir, subdirName, "SKILL.md")` is read inside
187
+ * a try/catch and a subdir WITHOUT one (`readFile` throws) is NOT a skill (→ `null`, never throws).
188
+ * `description` is the `SKILL.md` frontmatter `description` (default `""` when absent). Skills carry NO
189
+ * `argument-hint`, so the entry is `{ name, description }` — no `input`.
190
+ */
191
+ function resolveSkillEntry(skillsDir, subdirName, readFile) {
192
+ const name = subdirName;
193
+ // SECURITY (R3): drop any skill whose dir name escapes the allowlist — optional debug log, never a throw.
194
+ if (!isSafeCommandName(name)) {
195
+ if (process.env.FORK_COMMAND_DEBUG === "1") {
196
+ process.stderr.write(`[command-catalog] dropped unsafe skill name: ${JSON.stringify(name)}\n`);
197
+ }
198
+ return null;
199
+ }
200
+ let fm;
201
+ try {
202
+ fm = parseCommandFrontmatter(readFile(join(skillsDir, subdirName, "SKILL.md")));
203
+ }
204
+ catch {
205
+ return null; // no readable SKILL.md — NOT a skill, skip it, never throw (R7 guard / R5 graceful)
206
+ }
207
+ const description = fm.description ?? ""; // AvailableCommand.description is REQUIRED
208
+ return { name, description }; // skills have no argument-hint → no `input`
209
+ }
210
+ /** Sort entries by `name` for a stable, scope-independent ordering (parity with `sortByValue`). */
211
+ function sortByName(entries) {
212
+ return entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
213
+ }
214
+ /**
215
+ * Read the set of ENABLED plugin MARKETPLACES from `~/.claude/plugins/installed_plugins.json` (R8). The
216
+ * gate is intentionally PRESENCE-in-the-manifest: per R8's explicitly-named source, an INSTALLED plugin
217
+ * == an ENABLED plugin for this story (a finer `settings.json` → `enabledPlugins` gate is out of scope).
218
+ *
219
+ * The real manifest shape is `{ version, plugins: { "<name>@<marketplace>": [ { installPath, … }, … ] } }`
220
+ * — a marketplace is enabled iff at least one `<name>@<marketplace>` key maps to a NON-EMPTY install
221
+ * array. Everything here is DEFENSIVE against manifest-shape drift (R8 risk): a MISSING or MALFORMED
222
+ * manifest, a non-object `plugins`, or any key/value that doesn't fit is treated as "disabled" and simply
223
+ * contributes nothing — this NEVER throws (an unreadable/parse failure degrades to the empty set → all
224
+ * plugins skipped).
225
+ *
226
+ * @param home the resolved home dir (the manifest lives under `home/.claude/plugins`).
227
+ * @param readFile the injectable UTF-8 file reader (throws on a missing manifest — caught here).
228
+ * @returns the set of enabled marketplace names (EMPTY when the manifest is absent/malformed).
229
+ */
230
+ function readEnabledMarketplaces(home, readFile) {
231
+ const path = join(home, ".claude", "plugins", "installed_plugins.json");
232
+ let parsed;
233
+ try {
234
+ parsed = JSON.parse(readFile(path));
235
+ }
236
+ catch {
237
+ return new Set(); // missing or malformed manifest → no enabled marketplaces (plugins skipped)
238
+ }
239
+ const enabled = new Set();
240
+ // `plugins` must be a plain, non-null object; anything else (array, primitive, null, absent) → empty.
241
+ const plugins = parsed?.plugins;
242
+ if (typeof plugins !== "object" || plugins === null || Array.isArray(plugins))
243
+ return enabled;
244
+ for (const [key, value] of Object.entries(plugins)) {
245
+ // An installed plugin: a `<name>@<marketplace>` key mapping to a NON-EMPTY install array.
246
+ if (!Array.isArray(value) || value.length === 0)
247
+ continue; // treat "not installed" as disabled
248
+ const at = key.lastIndexOf("@"); // marketplace = substring AFTER the last `@`
249
+ if (at < 0)
250
+ continue;
251
+ const marketplace = key.slice(at + 1);
252
+ if (marketplace.length > 0)
253
+ enabled.add(marketplace);
254
+ }
255
+ return enabled;
256
+ }
257
+ /**
258
+ * Discover the custom slash-commands to advertise via ACP `available_commands` — FULLY OFFLINE (no
259
+ * subprocess, no network).
260
+ *
261
+ * TIERS, highest precedence first:
262
+ * 1. `<cwd>/.claude/commands/*.md` (cwd commands — R1.1 precedence)
263
+ * 2. `<cwd>/.claude/skills/<name>/SKILL.md` (cwd skills — R7)
264
+ * 3. `~/.claude/commands/*.md` (user commands)
265
+ * 4. `~/.claude/skills/<name>/SKILL.md` (user skills — R7)
266
+ * 5. `~/.claude/plugins/marketplaces/<m>/{commands,skills}` (ENABLED-plugin tier — R8; a plugin
267
+ * surface loses a name collision to any higher tier — R8.1)
268
+ * 6. {@link DiscoverCommandsDeps.builtins} (the R9 built-in tier — LOWEST; a built-in loses a name
269
+ * collision to any disk entry)
270
+ *
271
+ * Each command `*.md` file yields `{ name, description, input? }` (R2): `name` = basename sans `.md`,
272
+ * `description` = frontmatter `description` (else `""`), `input: { hint }` from `argument-hint` (omitted
273
+ * when absent). Each skill `<name>/SKILL.md` yields `{ name, description }` (no `input`): `name` = the
274
+ * DIRECTORY name, `description` = the `SKILL.md` frontmatter (R7 / R7.1). Every name is dropped if it
275
+ * fails the R3 {@link SAFE_COMMAND_NAME} allowlist. Names are deduped FIRST-WINS across the ordered
276
+ * surfaces — so a cwd command out-ranks a cwd skill (cmd > skill, same scope), any cwd surface
277
+ * out-ranks any user surface, an enabled-plugin surface out-ranks the built-ins but LOSES to any
278
+ * cwd/user surface (R8.1) — then the merged set is returned FLAT ALPHABETICAL by `name`.
279
+ *
280
+ * Never throws (R3 / R5): a missing/unreadable dir or file yields `[]` for that surface; a skills subdir
281
+ * without a readable `SKILL.md` is skipped.
282
+ *
283
+ * @param cwd the SESSION cwd (project dir) whose `.claude/{commands,skills}` take precedence.
284
+ * @param deps injectable fs/home/builtins seams (default: `node:` stdlib + {@link BUILTIN_COMMANDS}).
285
+ */
286
+ export function discoverCommands(cwd, deps = {}) {
287
+ const homedir = deps.homedir ?? osHomedir;
288
+ const readdirMd = deps.readdirMd ?? defaultReaddirMd;
289
+ const readdirDirs = deps.readdirDirs ?? defaultReaddirDirs;
290
+ const readFile = deps.readFile ?? defaultReadFile;
291
+ const builtins = deps.builtins ?? BUILTIN_COMMANDS;
292
+ const home = homedir();
293
+ // Ordered disk surfaces (highest precedence first) — commands scanned via `readdirMd`+`resolveEntry`,
294
+ // skills via `readdirDirs`+`resolveSkillEntry`. A cwd command outranks a cwd skill; any cwd surface
295
+ // outranks any user surface (R1.1 / R7). The plugin tier (R8) slots in AFTER these, before builtins.
296
+ const userSources = [
297
+ { kind: "commands", dir: join(cwd, ".claude", "commands") }, // cwd commands — highest
298
+ { kind: "skills", dir: join(cwd, ".claude", "skills") }, // cwd skills
299
+ { kind: "commands", dir: join(home, ".claude", "commands") }, // user commands
300
+ { kind: "skills", dir: join(home, ".claude", "skills") }, // user skills
301
+ ];
302
+ const byName = new Map();
303
+ const add = (entry) => {
304
+ if (entry && !byName.has(entry.name))
305
+ byName.set(entry.name, entry);
306
+ };
307
+ // Fold one ordered disk surface into `byName` (first-wins): a commands dir via `readdirMd`, a skills
308
+ // dir via `readdirDirs`. Shared by the user tier and the per-marketplace plugin tier.
309
+ const foldSource = (source) => {
310
+ if (source.kind === "commands") {
311
+ for (const filename of readdirMd(source.dir))
312
+ add(resolveEntry(source.dir, filename, readFile));
313
+ }
314
+ else {
315
+ for (const subdir of readdirDirs(source.dir))
316
+ add(resolveSkillEntry(source.dir, subdir, readFile));
317
+ }
318
+ };
319
+ for (const source of userSources)
320
+ foldSource(source);
321
+ // Plugin tier (R8), AFTER the user surfaces and BEFORE builtins — gated to ENABLED marketplaces per
322
+ // `installed_plugins.json`. For each enabled marketplace (dirs already sorted), fold its `commands`
323
+ // then its `skills`; first-wins means any plugin name already claimed by a cwd/user surface is DROPPED
324
+ // (R8.1). A missing marketplaces dir / manifest degrades to skipping plugins (never throws).
325
+ const marketplacesDir = join(home, ".claude", "plugins", "marketplaces");
326
+ const enabled = readEnabledMarketplaces(home, readFile);
327
+ for (const m of readdirDirs(marketplacesDir)) {
328
+ if (!enabled.has(m))
329
+ continue; // treat a not-installed marketplace as disabled
330
+ foldSource({ kind: "commands", dir: join(marketplacesDir, m, "commands") });
331
+ foldSource({ kind: "skills", dir: join(marketplacesDir, m, "skills") });
332
+ }
333
+ // Built-in tier LAST (lowest precedence): a built-in whose name already appeared on disk is dropped.
334
+ for (const cmd of builtins) {
335
+ if (isSafeCommandName(cmd.name) && !byName.has(cmd.name))
336
+ byName.set(cmd.name, cmd);
337
+ }
338
+ return sortByName([...byName.values()]);
339
+ }
@@ -38,4 +38,3 @@ export interface DiffEnrichedReaderOptions {
38
38
  * @returns a {@link GetMessages} that yields the base messages with `toolUseResult` hydrated by uuid.
39
39
  */
40
40
  export declare function createDiffEnrichedReader(base: GetMessages, opts?: DiffEnrichedReaderOptions): GetMessages;
41
- //# sourceMappingURL=diff-enriched-reader.d.ts.map
@@ -101,4 +101,3 @@ export interface DiffToolCallUpdate {
101
101
  * diff content (an unsupported/not-a-diff-source source, or a translation with no `content`).
102
102
  */
103
103
  export declare function diffToolCallUpdate(source: DiffSource, toolCallId: string): DiffToolCallUpdate | null;
104
- //# sourceMappingURL=diff-source.d.ts.map
@@ -54,4 +54,3 @@ export declare function checkJsonlShape(lines: ReadonlyArray<Record<string, unkn
54
54
  * allow-inject.ts:29 `KEYSTROKE_YES`. Pure: depends only on `keystroke`.
55
55
  */
56
56
  export declare function checkAllowKeystroke(keystroke: string): CheckResult;
57
- //# sourceMappingURL=drift-checks.d.ts.map
@@ -144,6 +144,12 @@ export interface TurnResolverOptions {
144
144
  logger?: StopReasonLogger;
145
145
  deltaTMs?: number;
146
146
  watchdogMs?: number;
147
+ /**
148
+ * Story 056 (#812) — fired EXACTLY ONCE, ONLY on a real end-of-turn boundary (NOT on cancel, NOT
149
+ * on watchdog timeout), AFTER the prompt resolves. Fire-and-forget; the callback MUST NOT
150
+ * throw/block.
151
+ */
152
+ onTurnResolved?: () => void;
147
153
  }
148
154
  /** A detector paired with the awaitable {@link PromptResponse} the prompt() loop returns. */
149
155
  export interface TurnResolver {
@@ -169,4 +175,3 @@ export interface TurnResolver {
169
175
  * late boundary cannot produce a second settlement).
170
176
  */
171
177
  export declare function createTurnResolver(opts?: TurnResolverOptions): TurnResolver;
172
- //# sourceMappingURL=end-of-turn.d.ts.map
@@ -394,7 +394,14 @@ export function createTurnResolver(opts = {}) {
394
394
  action();
395
395
  }
396
396
  const detector = createEndOfTurnDetector({
397
- onEndOfTurn: (boundary) => settle(() => resolveFn({ stopReason: mapStopReason(readStopReason(boundary), opts.logger) })),
397
+ onEndOfTurn: (boundary) =>
398
+ // Story 056 (#812): fire onTurnResolved inside the SAME settle, AFTER the prompt resolves, so it
399
+ // runs EXACTLY ONCE per real end-of-turn. cancel() and onTurnTimeout intentionally do NOT call
400
+ // it — the settle latch + the detector's firedUuids guarantee a single call per turn.
401
+ settle(() => {
402
+ resolveFn({ stopReason: mapStopReason(readStopReason(boundary), opts.logger) });
403
+ opts.onTurnResolved?.();
404
+ }),
398
405
  onTurnTimeout: (error) => settle(() => rejectFn(error)),
399
406
  schedule: opts.schedule,
400
407
  sessionId: opts.sessionId,
@@ -147,8 +147,27 @@ export declare class SessionEngine {
147
147
  * launch AND the `|| claude` fresh fallback as `--permission-mode <mode>`, so an in-place re-spawn
148
148
  * (a dontAsk/bypass switch) reattaches the SAME sessionId/transcript under the new mode. Still no
149
149
  * `-p`/`--print`/`stream-json` — billing stays subscription `cli`.
150
+ *
151
+ * Story 056 (R3.3): an optional `agent` persona name is likewise carried via `flags` as the
152
+ * DOUBLE-QUOTED `--agent "<name>"`. Because `flags` is interpolated into BOTH the `--resume "<id>"`
153
+ * launch AND the `|| claude` fresh fallback, this single addition reaches both branches (R3.3). It is
154
+ * the SECOND layer of the command-injection defense — re-asserted via {@link isSafeAgentRef} (which
155
+ * accepts a namespaced `plugin:name`) so an unsafe name is DROPPED (no flag), never interpolated.
156
+ * Still no `-p`/`--print`/`stream-json`.
157
+ *
158
+ * Story 057 (R1.1/R1.2): an optional `additionalDirectories` list is folded into the SAME `flags`
159
+ * string as ONE double-quoted `--add-dir "<dir>"` per dir — so, like the agent flag, a single addition
160
+ * reaches BOTH the `--resume "<id>"` half AND the `|| claude` fallback half. Each dir is re-asserted via
161
+ * {@link isSafeDir} (absolute + existing + no shell metacharacter); an unsafe dir is DROPPED (logged),
162
+ * never interpolated. Still no `-p`/`--print`/`stream-json`.
163
+ *
164
+ * Story 057 (R2.2): an optional `mcpConfigFile` PATH is likewise folded into the SAME `flags` string as
165
+ * the DOUBLE-QUOTED `--mcp-config "<file>"` (a file path — the secret-bearing JSON stays off the command
166
+ * line), so this single addition reaches BOTH the `--resume "<id>"` launch AND the `|| claude` fresh
167
+ * fallback. R2.2 HARD rule: `--strict-mcp-config` is NEVER emitted, so a resumed turn keeps MERGING the
168
+ * scratch with the user's own `.mcp.json`/settings MCP servers. Still no `-p`/`--print`/`stream-json`.
150
169
  */
151
- export declare function buildResumeArgv(sessionId: string, permissionMode?: string, effortLevel?: string): [string, string];
170
+ export declare function buildResumeArgv(sessionId: string, permissionMode?: string, effortLevel?: string, agent?: string, additionalDirectories?: string[], mcpConfigFile?: string): [string, string];
152
171
  /** Options for {@link spawnResumePty}. */
153
172
  export interface SpawnResumeOptions {
154
173
  /** The prior session id to reattach to (== the JSONL transcript basename). */
@@ -166,6 +185,27 @@ export interface SpawnResumeOptions {
166
185
  permissionMode?: string;
167
186
  /** Story 046 (R2.2): carry `--effort <level>` into the resume argv for an effort re-spawn. */
168
187
  effortLevel?: string;
188
+ /**
189
+ * Story 056 (R3.3): carry the DOUBLE-QUOTED `--agent "<name>"` into the resume argv (both the
190
+ * `--resume` and the `|| claude` fallback branches). Injection-safe — re-asserted against the R3.3
191
+ * allowlist so an unsafe name is dropped. Absent → no flag.
192
+ */
193
+ agent?: string;
194
+ /**
195
+ * Story 057 (R1.1/R1.2): carry ONE double-quoted `--add-dir "<dir>"` per dir into the resume argv —
196
+ * folded into the shared `flags`, so the dirs reach BOTH the `--resume` and the `|| claude` fallback
197
+ * branches. Injection-safe — each dir re-asserted via {@link isSafeDir} so an unsafe dir is dropped.
198
+ * INTERACTIVE-ONLY: adds no `-p`/`--print`/`stream-json`. Absent/empty → no flag.
199
+ */
200
+ additionalDirectories?: string[];
201
+ /**
202
+ * Story 057 (R2.2): carry the DOUBLE-QUOTED `--mcp-config "<file>"` into the resume argv — folded into
203
+ * the shared `flags`, so the scratch-config path reaches BOTH the `--resume` and the `|| claude`
204
+ * fallback branches. NEVER paired with `--strict-mcp-config`, so the resumed turn keeps MERGING the
205
+ * scratch with the user's own `.mcp.json`/settings MCP servers (R2.2). A file path keeps the JSON off
206
+ * the command line. INTERACTIVE-ONLY: adds no `-p`/`--print`/`stream-json`. Absent → no flag.
207
+ */
208
+ mcpConfigFile?: string;
169
209
  }
170
210
  /**
171
211
  * Spawn the resume PTY, reusing story 013's sanitized env (R4.2) so the resumed turn keeps the
@@ -180,6 +220,12 @@ export declare function spawnResumePty(opts: SpawnResumeOptions): PtyEngineHandl
180
220
  export interface CreateSessionEngineOptions {
181
221
  /** Host working directory the spawned TUI runs in (story 013 spawn). */
182
222
  cwd: string;
223
+ /**
224
+ * Story 056 v4 — OPTIONAL pre-chosen session id for an in-place FRESH re-spawn (a pre-interaction
225
+ * selector change reuses the session's id). Forwarded to {@link spawnClaudePty}; absent → a fresh
226
+ * `randomUUID()` (the normal createSession path).
227
+ */
228
+ sessionId?: string;
183
229
  /** Base environment to sanitize; defaults to the parent process env. */
184
230
  baseEnv?: Record<string, string | undefined>;
185
231
  /** Injectable spawn function (defaults to node-pty's `spawn`); tests pass a fake. */
@@ -191,6 +237,25 @@ export interface CreateSessionEngineOptions {
191
237
  permissionMode?: string;
192
238
  /** Story 046 (R2.2): forwarded to {@link spawnClaudePty} as `--effort <level>` for a non-"default" seed. */
193
239
  effortLevel?: string;
240
+ /**
241
+ * Story 056 (R3.3): forwarded to {@link spawnClaudePty} as the DOUBLE-QUOTED `--agent "<name>"` for a
242
+ * fresh spawn seeded to a main-thread agent persona. Injection-safe (R3.3 allowlist re-assert).
243
+ * Absent → no flag.
244
+ */
245
+ agent?: string;
246
+ /**
247
+ * Story 057 (R1.1/R1.2): forwarded to {@link spawnClaudePty} as ONE double-quoted `--add-dir "<dir>"`
248
+ * per safe dir for a fresh spawn. Injection-safe (each dir re-asserted via {@link isSafeDir}).
249
+ * INTERACTIVE-ONLY: adds no `-p`/`--print`/`stream-json`. Absent/empty → no flag.
250
+ */
251
+ additionalDirectories?: string[];
252
+ /**
253
+ * Story 057 (R2.2): forwarded to {@link spawnClaudePty} as the DOUBLE-QUOTED `--mcp-config "<file>"`
254
+ * for a fresh spawn (the fork-controlled scratch path from `mcp-config-writer.ts`). NEVER paired with
255
+ * `--strict-mcp-config`, so claude MERGES the scratch with the user's own `.mcp.json`/settings MCP
256
+ * servers (R2.2). INTERACTIVE-ONLY: adds no `-p`/`--print`/`stream-json`. Absent → no flag.
257
+ */
258
+ mcpConfigFile?: string;
194
259
  /**
195
260
  * Story 034 (§9 hybrid gate): per-session SCRATCH settings file carrying the fork's
196
261
  * `PreToolUse` hook, forwarded to {@link spawnClaudePty} as `--settings "<file>"`. The
@@ -238,4 +303,3 @@ export interface CreateSessionEngineOptions {
238
303
  * ──────────────────────────────────────────────────────────────────────────────────────────────
239
304
  */
240
305
  export declare function createSessionEngine(opts: CreateSessionEngineOptions): SessionEngine;
241
- //# sourceMappingURL=engine-lifecycle.d.ts.map
@@ -21,7 +21,8 @@
21
21
  // debounced resize, the cancel primitives, the robust resume, and the single-process binding
22
22
  // land in Tasks 2–5.
23
23
  import pty from "node-pty";
24
- import { assertSpawnEnvUntainted, buildSanitizedEnv, resolveShell, spawnClaudePty, } from "./engine-pty.js";
24
+ import { assertSpawnEnvUntainted, buildAddDirFlags, buildSanitizedEnv, resolveShell, spawnClaudePty, } from "./engine-pty.js";
25
+ import { isSafeAgentRef } from "./agent-catalog.js";
25
26
  import { attachAnsiMirror } from "./ansi-mirror.js";
26
27
  /**
27
28
  * Default PTY geometry applied when Zed supplies no terminal size (§5 Spawn defaults). Mirrors
@@ -172,11 +173,45 @@ const RESUME_PTY_NAME = "xterm-256color";
172
173
  * launch AND the `|| claude` fresh fallback as `--permission-mode <mode>`, so an in-place re-spawn
173
174
  * (a dontAsk/bypass switch) reattaches the SAME sessionId/transcript under the new mode. Still no
174
175
  * `-p`/`--print`/`stream-json` — billing stays subscription `cli`.
176
+ *
177
+ * Story 056 (R3.3): an optional `agent` persona name is likewise carried via `flags` as the
178
+ * DOUBLE-QUOTED `--agent "<name>"`. Because `flags` is interpolated into BOTH the `--resume "<id>"`
179
+ * launch AND the `|| claude` fresh fallback, this single addition reaches both branches (R3.3). It is
180
+ * the SECOND layer of the command-injection defense — re-asserted via {@link isSafeAgentRef} (which
181
+ * accepts a namespaced `plugin:name`) so an unsafe name is DROPPED (no flag), never interpolated.
182
+ * Still no `-p`/`--print`/`stream-json`.
183
+ *
184
+ * Story 057 (R1.1/R1.2): an optional `additionalDirectories` list is folded into the SAME `flags`
185
+ * string as ONE double-quoted `--add-dir "<dir>"` per dir — so, like the agent flag, a single addition
186
+ * reaches BOTH the `--resume "<id>"` half AND the `|| claude` fallback half. Each dir is re-asserted via
187
+ * {@link isSafeDir} (absolute + existing + no shell metacharacter); an unsafe dir is DROPPED (logged),
188
+ * never interpolated. Still no `-p`/`--print`/`stream-json`.
189
+ *
190
+ * Story 057 (R2.2): an optional `mcpConfigFile` PATH is likewise folded into the SAME `flags` string as
191
+ * the DOUBLE-QUOTED `--mcp-config "<file>"` (a file path — the secret-bearing JSON stays off the command
192
+ * line), so this single addition reaches BOTH the `--resume "<id>"` launch AND the `|| claude` fresh
193
+ * fallback. R2.2 HARD rule: `--strict-mcp-config` is NEVER emitted, so a resumed turn keeps MERGING the
194
+ * scratch with the user's own `.mcp.json`/settings MCP servers. Still no `-p`/`--print`/`stream-json`.
175
195
  */
176
- export function buildResumeArgv(sessionId, permissionMode, effortLevel) {
196
+ export function buildResumeArgv(sessionId, permissionMode, effortLevel, agent, additionalDirectories, mcpConfigFile) {
177
197
  const pm = permissionMode && permissionMode !== "default" ? ` --permission-mode ${permissionMode}` : "";
178
198
  const ef = effortLevel && effortLevel !== "default" ? ` --effort ${effortLevel}` : "";
179
- const flags = `${pm}${ef}`;
199
+ // Story 056 (R3.3): double-quoted agent flag, emitted only for a real persona (the "default"
200
+ // sentinel = no persona emits nothing, mirroring --effort) and only when it passes the R3.3
201
+ // allowlist re-assert; folded into `flags` so it carries into both the --resume and || claude halves.
202
+ const ag = agent && agent !== "default" && isSafeAgentRef(agent) ? ` --agent "${agent}"` : "";
203
+ // Story 057 (R1.1/R1.2): ONE double-quoted `--add-dir "<dir>"` per safe dir, built by the SAME shared
204
+ // {@link buildAddDirFlags} the fresh spawn path uses (single source of truth — isSafeDir: absolute +
205
+ // existing + no shell metachar; unsafe → dropped+logged). Folded into `flags` BELOW so the dirs carry
206
+ // into BOTH the --resume launch AND the || claude fresh fallback (same single-addition-reaches-both
207
+ // mechanism as the agent flag). Empty/undefined → "".
208
+ const dirs = buildAddDirFlags(additionalDirectories);
209
+ // Story 057 (R2.2): the double-quoted `--mcp-config "<file>"` fragment, emitted only when set; folded
210
+ // into `flags` BELOW so — like every other flag here — it reaches BOTH the --resume launch AND the
211
+ // || claude fresh fallback. NEVER `--strict-mcp-config` (R2.2): the resumed turn keeps MERGING the
212
+ // scratch with the user's own .mcp.json/settings MCP servers. A file path keeps the JSON off the CLI.
213
+ const mcp = mcpConfigFile ? ` --mcp-config "${mcpConfigFile}"` : "";
214
+ const flags = `${pm}${ef}${ag}${dirs}${mcp}`;
180
215
  return ["-c", `claude --resume "${sessionId}"${flags} || claude${flags}`];
181
216
  }
182
217
  /**
@@ -190,7 +225,7 @@ export function buildResumeArgv(sessionId, permissionMode, effortLevel) {
190
225
  export function spawnResumePty(opts) {
191
226
  const { sessionId, cwd, baseEnv = process.env, spawn = pty.spawn } = opts;
192
227
  const shell = resolveShell(baseEnv);
193
- const argv = buildResumeArgv(sessionId, opts.permissionMode, opts.effortLevel);
228
+ const argv = buildResumeArgv(sessionId, opts.permissionMode, opts.effortLevel, opts.agent, opts.additionalDirectories, opts.mcpConfigFile);
194
229
  const env = buildSanitizedEnv(baseEnv);
195
230
  // §10 refuse-to-spawn guard (R4.2): abort if any forbidden billing var survived sanitization,
196
231
  // rather than resume a credit-billed run. Checks the SPAWN env, not process.env.
@@ -226,9 +261,13 @@ export function createSessionEngine(opts) {
226
261
  cwd: opts.cwd,
227
262
  baseEnv: opts.baseEnv,
228
263
  spawn: opts.spawn,
264
+ sessionId: opts.sessionId,
229
265
  permissionMode: opts.permissionMode,
230
266
  effortLevel: opts.effortLevel,
267
+ agent: opts.agent,
231
268
  settingsFile: opts.settingsFile,
269
+ additionalDirectories: opts.additionalDirectories,
270
+ mcpConfigFile: opts.mcpConfigFile,
232
271
  });
233
272
  // ONE watcher, started by the story 015 factory and bound to that same PTY.
234
273
  const watcher = opts.startWatcher?.(handle.sessionId, handle.pty);