@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.
- package/NOTICE +1 -1
- package/README.md +1 -1
- package/dist/acp-agent.d.ts +249 -21
- package/dist/acp-agent.js +573 -73
- package/dist/agent-catalog.d.ts +95 -0
- package/dist/agent-catalog.js +287 -0
- package/dist/ansi-mirror.d.ts +0 -1
- package/dist/besteffort.d.ts +0 -1
- package/dist/billing/entrypoint-guard.d.ts +0 -1
- package/dist/claude-path.d.ts +0 -1
- package/dist/claude-path.js +6 -0
- package/dist/command-catalog.d.ts +84 -0
- package/dist/command-catalog.js +339 -0
- package/dist/diff-enriched-reader.d.ts +0 -1
- package/dist/diff-source.d.ts +0 -1
- package/dist/drift-checks.d.ts +0 -1
- package/dist/end-of-turn.d.ts +6 -1
- package/dist/end-of-turn.js +8 -1
- package/dist/engine-lifecycle.d.ts +66 -2
- package/dist/engine-lifecycle.js +43 -4
- package/dist/engine-pty.d.ts +70 -3
- package/dist/engine-pty.js +80 -6
- package/dist/engine-watcher.d.ts +0 -1
- package/dist/engine.d.ts +0 -1
- package/dist/event-switch.d.ts +0 -1
- package/dist/gate/port.d.ts +0 -1
- package/dist/gate/settings-writer.d.ts +14 -1
- package/dist/gate/settings-writer.js +49 -0
- package/dist/image-input.d.ts +30 -0
- package/dist/image-input.js +79 -0
- package/dist/image-vision-smoke.d.ts +51 -0
- package/dist/image-vision-smoke.js +111 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +6 -0
- package/dist/jsonl.d.ts +0 -1
- package/dist/lib.d.ts +0 -1
- package/dist/linearize.d.ts +1 -2
- package/dist/linearize.js +1 -1
- package/dist/live-diff-env.d.ts +0 -1
- package/dist/live-subagent-env.d.ts +0 -1
- package/dist/mcp-config-writer.d.ts +60 -0
- package/dist/mcp-config-writer.js +172 -0
- package/dist/model-catalog.d.ts +68 -3
- package/dist/model-catalog.js +123 -13
- package/dist/permissions/allow-inject.d.ts +0 -1
- package/dist/permissions/deny.d.ts +12 -1
- package/dist/permissions/deny.js +18 -0
- package/dist/permissions/elicitation-bridge.d.ts +71 -0
- package/dist/permissions/elicitation-bridge.js +146 -0
- package/dist/permissions/gate-wiring.d.ts +23 -3
- package/dist/permissions/gate-wiring.js +123 -1
- package/dist/permissions/hook-server.d.ts +11 -3
- package/dist/permissions/hook-server.js +10 -1
- package/dist/permissions/permission-mode.d.ts +0 -1
- package/dist/permissions/request-permission.d.ts +0 -1
- package/dist/settings.d.ts +0 -1
- package/dist/settings.js +9 -0
- package/dist/stop-reason-map.d.ts +0 -1
- package/dist/subagent-gate.d.ts +0 -1
- package/dist/subagent-source.d.ts +0 -1
- package/dist/subagent-watcher.d.ts +0 -1
- package/dist/tools.d.ts +0 -1
- package/dist/tools.js +5 -1
- package/dist/usage-env.d.ts +0 -1
- package/dist/usage.d.ts +3 -1
- package/dist/usage.js +3 -0
- package/dist/utils.d.ts +0 -1
- package/dist/zed-register.d.ts +0 -1
- package/package.json +12 -9
- package/dist/acp-agent.d.ts.map +0 -1
- package/dist/ansi-mirror.d.ts.map +0 -1
- package/dist/besteffort.d.ts.map +0 -1
- package/dist/billing/entrypoint-guard.d.ts.map +0 -1
- package/dist/claude-path.d.ts.map +0 -1
- package/dist/diff-enriched-reader.d.ts.map +0 -1
- package/dist/diff-source.d.ts.map +0 -1
- package/dist/drift-checks.d.ts.map +0 -1
- package/dist/end-of-turn.d.ts.map +0 -1
- package/dist/engine-lifecycle.d.ts.map +0 -1
- package/dist/engine-pty.d.ts.map +0 -1
- package/dist/engine-watcher.d.ts.map +0 -1
- package/dist/engine.d.ts.map +0 -1
- package/dist/event-switch.d.ts.map +0 -1
- package/dist/gate/port.d.ts.map +0 -1
- package/dist/gate/settings-writer.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/jsonl.d.ts.map +0 -1
- package/dist/lib.d.ts.map +0 -1
- package/dist/linearize.d.ts.map +0 -1
- package/dist/live-diff-env.d.ts.map +0 -1
- package/dist/live-subagent-env.d.ts.map +0 -1
- package/dist/model-catalog.d.ts.map +0 -1
- package/dist/permissions/allow-inject.d.ts.map +0 -1
- package/dist/permissions/deny.d.ts.map +0 -1
- package/dist/permissions/gate-wiring.d.ts.map +0 -1
- package/dist/permissions/hook-server.d.ts.map +0 -1
- package/dist/permissions/permission-mode.d.ts.map +0 -1
- package/dist/permissions/request-permission.d.ts.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/stop-reason-map.d.ts.map +0 -1
- package/dist/subagent-gate.d.ts.map +0 -1
- package/dist/subagent-source.d.ts.map +0 -1
- package/dist/subagent-watcher.d.ts.map +0 -1
- package/dist/tools.d.ts.map +0 -1
- package/dist/usage-env.d.ts.map +0 -1
- package/dist/usage.d.ts.map +0 -1
- package/dist/utils.d.ts.map +0 -1
- 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
|
package/dist/diff-source.d.ts
CHANGED
|
@@ -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
|
package/dist/drift-checks.d.ts
CHANGED
|
@@ -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
|
package/dist/end-of-turn.d.ts
CHANGED
|
@@ -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
|
package/dist/end-of-turn.js
CHANGED
|
@@ -394,7 +394,14 @@ export function createTurnResolver(opts = {}) {
|
|
|
394
394
|
action();
|
|
395
395
|
}
|
|
396
396
|
const detector = createEndOfTurnDetector({
|
|
397
|
-
onEndOfTurn: (boundary) =>
|
|
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
|
package/dist/engine-lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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);
|