@lucascouts/claude-agent-tui 0.5.2 → 0.6.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 (41) hide show
  1. package/dist/acp-agent.d.ts +187 -13
  2. package/dist/acp-agent.d.ts.map +1 -1
  3. package/dist/acp-agent.js +444 -59
  4. package/dist/agent-catalog.d.ts +96 -0
  5. package/dist/agent-catalog.d.ts.map +1 -0
  6. package/dist/agent-catalog.js +287 -0
  7. package/dist/claude-path.d.ts.map +1 -1
  8. package/dist/claude-path.js +6 -0
  9. package/dist/end-of-turn.d.ts +6 -0
  10. package/dist/end-of-turn.d.ts.map +1 -1
  11. package/dist/end-of-turn.js +8 -1
  12. package/dist/engine-lifecycle.d.ts +66 -1
  13. package/dist/engine-lifecycle.d.ts.map +1 -1
  14. package/dist/engine-lifecycle.js +43 -4
  15. package/dist/engine-pty.d.ts +70 -2
  16. package/dist/engine-pty.d.ts.map +1 -1
  17. package/dist/engine-pty.js +80 -6
  18. package/dist/gate/settings-writer.d.ts +14 -0
  19. package/dist/gate/settings-writer.d.ts.map +1 -1
  20. package/dist/gate/settings-writer.js +49 -0
  21. package/dist/image-input.d.ts +31 -0
  22. package/dist/image-input.d.ts.map +1 -0
  23. package/dist/image-input.js +79 -0
  24. package/dist/image-vision-smoke.d.ts +52 -0
  25. package/dist/image-vision-smoke.d.ts.map +1 -0
  26. package/dist/image-vision-smoke.js +111 -0
  27. package/dist/index.js +6 -0
  28. package/dist/mcp-config-writer.d.ts +61 -0
  29. package/dist/mcp-config-writer.d.ts.map +1 -0
  30. package/dist/mcp-config-writer.js +172 -0
  31. package/dist/model-catalog.d.ts +29 -2
  32. package/dist/model-catalog.d.ts.map +1 -1
  33. package/dist/model-catalog.js +50 -10
  34. package/dist/settings.d.ts.map +1 -1
  35. package/dist/settings.js +9 -0
  36. package/dist/tools.d.ts.map +1 -1
  37. package/dist/tools.js +5 -1
  38. package/dist/usage.d.ts +3 -0
  39. package/dist/usage.d.ts.map +1 -1
  40. package/dist/usage.js +3 -0
  41. package/package.json +8 -8
@@ -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);
@@ -49,19 +49,63 @@ export declare function resolveShell(baseEnv?: Record<string, string | undefined
49
49
  * non-"default" mode is emitted as `--permission-mode <mode>`; "default"/undefined emit nothing;
50
50
  * `permissionMode: "plan"` reproduces the old planMode=true path. Still interactive-only — adds no
51
51
  * `-p`/`stream-json`, billing stays subscription `cli`.
52
+ *
53
+ * Story 056 (R3.3): an optional `agent` persona name is emitted as `--agent "<name>"` (DOUBLE-QUOTED,
54
+ * grouped with the other behavior flags). This is the SECOND layer of the two-layer command-injection
55
+ * defense — `agent-catalog.ts` already drops any name outside `SAFE_AGENT_REF`
56
+ * (`/^[A-Za-z0-9_-]+(?::[A-Za-z0-9_-]+)?$/`, allowing a namespaced `plugin:name`) at discovery, and
57
+ * here we re-assert via {@link isSafeAgentRef} so an unsafe name is silently DROPPED (no flag), never
58
+ * interpolated into the `-lc` shell string. Interactive-only — adds no `-p`/`stream-json`.
59
+ *
60
+ * Story 057 (R1.1/R1.2): an optional `additionalDirectories` list is emitted as ONE double-quoted
61
+ * `--add-dir "<dir>"` flag PER directory (never a single space-joined value — each dir is its own flag,
62
+ * matching the upstream/CLI shape). Each dir is re-asserted via {@link isSafeDir} (absolute + existing +
63
+ * free of shell metacharacters); an unsafe dir is DROPPED and a one-line warning is logged, never
64
+ * interpolated into the `-lc` shell string. Interactive-only — adds no `-p`/`stream-json`.
65
+ *
66
+ * Story 057 (R2.2): an optional `mcpConfigFile` PATH is emitted as the DOUBLE-QUOTED `--mcp-config
67
+ * "<file>"` flag (same style as `--settings` — a file path, so the secret-bearing JSON stays OFF the
68
+ * command line and survives the `-lc` shell word-splitting). The file is the fork-controlled
69
+ * uuid-named scratch from `mcp-config-writer.ts`. R2.2 is a HARD rule: `--strict-mcp-config` is NEVER
70
+ * emitted, so claude MERGES the scratch with the user's own `.mcp.json`/settings MCP servers (the
71
+ * parity target) instead of discarding them. Interactive-only — adds no `-p`/`stream-json`.
72
+ */
73
+ export declare function buildClaudeCmd(sessionId: string, permissionMode?: string, settingsFile?: string, effortLevel?: string, agent?: string, additionalDirectories?: string[], mcpConfigFile?: string): string;
74
+ /**
75
+ * Story 057 (R1.2). The directory-allowlist predicate, mirroring the discipline of
76
+ * {@link isSafeAgentRef}: a directory is safe to interpolate into a double-quoted `--add-dir "<dir>"`
77
+ * flag ONLY when it is an ABSOLUTE path, currently EXISTS on disk, and is FREE of the shell
78
+ * metacharacters that could break out of (or inject into) the `-lc` double-quoted argument — `"`, `$`,
79
+ * a backtick, or a newline/CR. Single source of truth — exported so engine-lifecycle.ts's resume path
80
+ * reuses it rather than re-implementing the rule.
81
+ */
82
+ export declare function isSafeDir(p: string): boolean;
83
+ /**
84
+ * Story 057 (R1.1/R1.2). Build the trailing ` --add-dir "<dir>"` fragment for a list of additional
85
+ * directories: ONE double-quoted flag per dir that passes {@link isSafeDir}. An unsafe dir (relative,
86
+ * non-existent, or containing a shell metacharacter) is DROPPED — never interpolated — and a one-line
87
+ * warning is logged to stderr (the fork's diagnostic channel, never the ACP stdout wire). An
88
+ * empty/undefined list emits the empty string (no flags). Shared by the fresh and resume spawn paths.
52
89
  */
53
- export declare function buildClaudeCmd(sessionId: string, permissionMode?: string, settingsFile?: string, effortLevel?: string): string;
90
+ export declare function buildAddDirFlags(additionalDirectories?: string[]): string;
54
91
  /**
55
92
  * Login-shell argv (§5, R1.3). The `-lc` flag is MANDATORY: node-pty's `posix_spawnp`
56
93
  * does not execute the `claude` npm launcher's `#!/usr/bin/env node` shebang, so the
57
94
  * launcher must run *through* a login shell. (The compiled native-binary is the only
58
95
  * thing that could be spawned directly — §5 / IMPLEMENTACAO-FORK-ACP.md §17.)
59
96
  */
60
- export declare function buildSpawnArgv(sessionId: string, permissionMode?: string, settingsFile?: string, effortLevel?: string): [string, string];
97
+ export declare function buildSpawnArgv(sessionId: string, permissionMode?: string, settingsFile?: string, effortLevel?: string, agent?: string, additionalDirectories?: string[], mcpConfigFile?: string): [string, string];
61
98
  /** Options for {@link spawnClaudePty}. */
62
99
  export interface SpawnPtyOptions {
63
100
  /** Host working directory the TUI runs in; passed straight through to the PTY (§5). */
64
101
  cwd: string;
102
+ /**
103
+ * Story 056 v4 — an OPTIONAL pre-chosen session id for an in-place FRESH re-spawn (a selector change
104
+ * before the first interaction reuses the session's existing id). Absent → a new `randomUUID()` is
105
+ * generated (the normal createSession path). claude accepts a reused `--session-id` once the prior
106
+ * PTY for that id has exited (LIVE-VERIFIED).
107
+ */
108
+ sessionId?: string;
65
109
  /** Base environment to sanitize; defaults to the parent process env. */
66
110
  baseEnv?: Record<string, string | undefined>;
67
111
  /**
@@ -88,6 +132,30 @@ export interface SpawnPtyOptions {
88
132
  * Absent → no flag (the ungated/`FORK_GATE=off` spawn is byte-for-byte pre-034).
89
133
  */
90
134
  settingsFile?: string;
135
+ /**
136
+ * Story 056 (R3.3): a main-thread agent persona name (from `agent-catalog.ts`) emitted as the
137
+ * interactive-only `--agent "<name>"` spawn flag. DOUBLE-QUOTED and injection-safe — re-asserted
138
+ * against the R3.3 allowlist (`/^[A-Za-z0-9_-]+$/`) so an unsafe name is dropped, never interpolated.
139
+ * Absent → no flag. Adds no `-p`/`stream-json`; billing stays subscription `cli`.
140
+ */
141
+ agent?: string;
142
+ /**
143
+ * Story 057 (R1.1/R1.2): extra workspace directories surfaced as ONE double-quoted
144
+ * `--add-dir "<dir>"` flag per dir. Each dir is re-asserted via {@link isSafeDir} (absolute +
145
+ * existing + no shell metacharacter) — an unsafe dir is dropped (logged), never interpolated.
146
+ * INTERACTIVE-ONLY: adds no `-p`/`--print`/`stream-json`, so billing stays subscription `cli`.
147
+ * Absent/empty → no flag.
148
+ */
149
+ additionalDirectories?: string[];
150
+ /**
151
+ * Story 057 (R2.2): the fork-controlled uuid-named scratch MCP-config PATH (written by
152
+ * `mcp-config-writer.ts`), surfaced as the DOUBLE-QUOTED `--mcp-config "<file>"` flag. A file path —
153
+ * the secret-bearing JSON lives in the file, OFF the command line. NEVER paired with
154
+ * `--strict-mcp-config`, so claude MERGES it with the user's own `.mcp.json`/settings MCP servers
155
+ * (the R2.2 parity target) rather than discarding them. INTERACTIVE-ONLY: adds no
156
+ * `-p`/`--print`/`stream-json`, so billing stays subscription `cli`. Absent → no flag.
157
+ */
158
+ mcpConfigFile?: string;
91
159
  }
92
160
  /** Handle returned by {@link spawnClaudePty}; story 014 manages its lifecycle. */
93
161
  export interface PtyEngineHandle {
@@ -1 +1 @@
1
- {"version":3,"file":"engine-pty.d.ts","sourceRoot":"","sources":["../src/engine-pty.ts"],"names":[],"mappings":"AAiBA,OAAO,GAAG,MAAM,UAAU,CAAC;AAC3B,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAQrC;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,8GAKzB,CAAC;AAEX;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAC/B,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GACxD,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAWpC;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,GAAG,IAAI,CASrF;AAED,0FAA0F;AAC1F,wBAAgB,YAAY,CAAC,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAe,GAAG,MAAM,CAE9F;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,cAAc,CAAC,EAAE,MAAM,EACvB,YAAY,CAAC,EAAE,MAAM,EACrB,WAAW,CAAC,EAAE,MAAM,GACnB,MAAM,CAQR;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,cAAc,CAAC,EAAE,MAAM,EACvB,YAAY,CAAC,EAAE,MAAM,EACrB,WAAW,CAAC,EAAE,MAAM,GACnB,CAAC,MAAM,EAAE,MAAM,CAAC,CAElB;AAED,0CAA0C;AAC1C,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,GAAG,EAAE,MAAM,CAAC;IACZ,wEAAwE;IACxE,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;IAC7C;;;OAGG;IACH,KAAK,CAAC,EAAE,OAAO,GAAG,CAAC,KAAK,CAAC;IACzB;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;OAMG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,kFAAkF;AAClF,MAAM,WAAW,eAAe;IAC9B,wEAAwE;IACxE,SAAS,EAAE,MAAM,CAAC;IAClB,2BAA2B;IAC3B,GAAG,EAAE,IAAI,CAAC;CACX;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe,CAsCrE;AA6BD,gFAAgF;AAChF,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,WAAS,CAAC;AAEtC;;;GAGG;AACH,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,UAAU,CACxB,CAAC,EAAE,IAAI,EACP,IAAI,EAAE,MAAM,EACZ,QAAQ,GAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,IAAqC,GAC9E,IAAI,CAON"}
1
+ {"version":3,"file":"engine-pty.d.ts","sourceRoot":"","sources":["../src/engine-pty.ts"],"names":[],"mappings":"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"}
@@ -16,6 +16,9 @@
16
16
  // the ACP createSession rewrite that wires this engine in is story 023.
17
17
  import pty from "node-pty";
18
18
  import { randomUUID } from "node:crypto";
19
+ import { existsSync } from "node:fs";
20
+ import * as path from "node:path";
21
+ import { isSafeAgentRef } from "./agent-catalog.js";
19
22
  // PTY geometry pinned by §5.
20
23
  const PTY_NAME = "xterm-256color";
21
24
  const PTY_COLS = 120;
@@ -94,8 +97,28 @@ export function resolveShell(baseEnv = process.env) {
94
97
  * non-"default" mode is emitted as `--permission-mode <mode>`; "default"/undefined emit nothing;
95
98
  * `permissionMode: "plan"` reproduces the old planMode=true path. Still interactive-only — adds no
96
99
  * `-p`/`stream-json`, billing stays subscription `cli`.
100
+ *
101
+ * Story 056 (R3.3): an optional `agent` persona name is emitted as `--agent "<name>"` (DOUBLE-QUOTED,
102
+ * grouped with the other behavior flags). This is the SECOND layer of the two-layer command-injection
103
+ * defense — `agent-catalog.ts` already drops any name outside `SAFE_AGENT_REF`
104
+ * (`/^[A-Za-z0-9_-]+(?::[A-Za-z0-9_-]+)?$/`, allowing a namespaced `plugin:name`) at discovery, and
105
+ * here we re-assert via {@link isSafeAgentRef} so an unsafe name is silently DROPPED (no flag), never
106
+ * interpolated into the `-lc` shell string. Interactive-only — adds no `-p`/`stream-json`.
107
+ *
108
+ * Story 057 (R1.1/R1.2): an optional `additionalDirectories` list is emitted as ONE double-quoted
109
+ * `--add-dir "<dir>"` flag PER directory (never a single space-joined value — each dir is its own flag,
110
+ * matching the upstream/CLI shape). Each dir is re-asserted via {@link isSafeDir} (absolute + existing +
111
+ * free of shell metacharacters); an unsafe dir is DROPPED and a one-line warning is logged, never
112
+ * interpolated into the `-lc` shell string. Interactive-only — adds no `-p`/`stream-json`.
113
+ *
114
+ * Story 057 (R2.2): an optional `mcpConfigFile` PATH is emitted as the DOUBLE-QUOTED `--mcp-config
115
+ * "<file>"` flag (same style as `--settings` — a file path, so the secret-bearing JSON stays OFF the
116
+ * command line and survives the `-lc` shell word-splitting). The file is the fork-controlled
117
+ * uuid-named scratch from `mcp-config-writer.ts`. R2.2 is a HARD rule: `--strict-mcp-config` is NEVER
118
+ * emitted, so claude MERGES the scratch with the user's own `.mcp.json`/settings MCP servers (the
119
+ * parity target) instead of discarding them. Interactive-only — adds no `-p`/`stream-json`.
97
120
  */
98
- export function buildClaudeCmd(sessionId, permissionMode, settingsFile, effortLevel) {
121
+ export function buildClaudeCmd(sessionId, permissionMode, settingsFile, effortLevel, agent, additionalDirectories, mcpConfigFile) {
99
122
  let cmd = `claude --session-id ${sessionId}`;
100
123
  if (permissionMode && permissionMode !== "default")
101
124
  cmd += ` --permission-mode ${permissionMode}`;
@@ -103,18 +126,69 @@ export function buildClaudeCmd(sessionId, permissionMode, settingsFile, effortLe
103
126
  // non-"default" level is seeded/re-spawned with `--effort <level>`. Interactive-only — no credit path.
104
127
  if (effortLevel && effortLevel !== "default")
105
128
  cmd += ` --effort ${effortLevel}`;
129
+ // Story 056 (R3.3): the agent persona, double-quoted, emitted ONLY for a real persona — the "default"
130
+ // sentinel (= no persona, mirrors --effort/--permission-mode) emits nothing — and only when it passes
131
+ // the R3.3 allowlist re-assert (defense-in-depth — an unsafe name is dropped, never interpolated).
132
+ if (agent && agent !== "default" && isSafeAgentRef(agent))
133
+ cmd += ` --agent "${agent}"`;
106
134
  if (settingsFile)
107
135
  cmd += ` --settings "${settingsFile}"`;
136
+ // Story 057 (R1.1/R1.2): ONE double-quoted `--add-dir "<dir>"` per safe dir (never a space-joined
137
+ // value). Each dir is re-asserted via isSafeDir (absolute + existing + no shell metachar) — an unsafe
138
+ // dir is DROPPED with a one-line warning, never interpolated into the `-lc` shell string.
139
+ cmd += buildAddDirFlags(additionalDirectories);
140
+ // Story 057 (R2.2): the DOUBLE-QUOTED `--mcp-config "<file>"` (mirrors `--settings` above — a file path,
141
+ // so the secret-bearing JSON stays off the command line). Emitted ONLY when set. R2.2 HARD rule: NEVER
142
+ // `--strict-mcp-config` — without it claude MERGES the scratch with the user's own .mcp.json/settings
143
+ // MCP servers (the parity target) instead of discarding them.
144
+ if (mcpConfigFile)
145
+ cmd += ` --mcp-config "${mcpConfigFile}"`;
108
146
  return cmd;
109
147
  }
148
+ /**
149
+ * Story 057 (R1.2). The directory-allowlist predicate, mirroring the discipline of
150
+ * {@link isSafeAgentRef}: a directory is safe to interpolate into a double-quoted `--add-dir "<dir>"`
151
+ * flag ONLY when it is an ABSOLUTE path, currently EXISTS on disk, and is FREE of the shell
152
+ * metacharacters that could break out of (or inject into) the `-lc` double-quoted argument — `"`, `$`,
153
+ * a backtick, or a newline/CR. Single source of truth — exported so engine-lifecycle.ts's resume path
154
+ * reuses it rather than re-implementing the rule.
155
+ */
156
+ export function isSafeDir(p) {
157
+ return path.isAbsolute(p) && existsSync(p) && !/["$`\n\r]/.test(p);
158
+ }
159
+ /**
160
+ * Story 057 (R1.1/R1.2). Build the trailing ` --add-dir "<dir>"` fragment for a list of additional
161
+ * directories: ONE double-quoted flag per dir that passes {@link isSafeDir}. An unsafe dir (relative,
162
+ * non-existent, or containing a shell metacharacter) is DROPPED — never interpolated — and a one-line
163
+ * warning is logged to stderr (the fork's diagnostic channel, never the ACP stdout wire). An
164
+ * empty/undefined list emits the empty string (no flags). Shared by the fresh and resume spawn paths.
165
+ */
166
+ export function buildAddDirFlags(additionalDirectories) {
167
+ if (!additionalDirectories || additionalDirectories.length === 0)
168
+ return "";
169
+ let fragment = "";
170
+ for (const dir of additionalDirectories) {
171
+ if (isSafeDir(dir)) {
172
+ fragment += ` --add-dir "${dir}"`;
173
+ }
174
+ else {
175
+ console.error(`engine-pty: dropping unsafe --add-dir entry (not absolute+existing, or contains a shell ` +
176
+ `metacharacter): ${JSON.stringify(dir)}`);
177
+ }
178
+ }
179
+ return fragment;
180
+ }
110
181
  /**
111
182
  * Login-shell argv (§5, R1.3). The `-lc` flag is MANDATORY: node-pty's `posix_spawnp`
112
183
  * does not execute the `claude` npm launcher's `#!/usr/bin/env node` shebang, so the
113
184
  * launcher must run *through* a login shell. (The compiled native-binary is the only
114
185
  * thing that could be spawned directly — §5 / IMPLEMENTACAO-FORK-ACP.md §17.)
115
186
  */
116
- export function buildSpawnArgv(sessionId, permissionMode, settingsFile, effortLevel) {
117
- return ["-lc", buildClaudeCmd(sessionId, permissionMode, settingsFile, effortLevel)];
187
+ export function buildSpawnArgv(sessionId, permissionMode, settingsFile, effortLevel, agent, additionalDirectories, mcpConfigFile) {
188
+ return [
189
+ "-lc",
190
+ buildClaudeCmd(sessionId, permissionMode, settingsFile, effortLevel, agent, additionalDirectories, mcpConfigFile),
191
+ ];
118
192
  }
119
193
  /**
120
194
  * Spawn the interactive `claude` TUI under a real PTY with the §5 dimensions and a
@@ -133,10 +207,10 @@ export function buildSpawnArgv(sessionId, permissionMode, settingsFile, effortLe
133
207
  * process-supervisor or non-PTY spawn variant is introduced.
134
208
  */
135
209
  export function spawnClaudePty(opts) {
136
- const { cwd, baseEnv = process.env, spawn = pty.spawn, permissionMode, settingsFile, effortLevel, } = opts;
137
- const sessionId = randomUUID(); // pre-generated → correlates to the JSONL transcript
210
+ const { cwd, baseEnv = process.env, spawn = pty.spawn, permissionMode, settingsFile, effortLevel, agent, additionalDirectories, mcpConfigFile, } = opts;
211
+ const sessionId = opts.sessionId ?? randomUUID(); // pre-generated → correlates to the JSONL transcript (v4: reused on a fresh re-spawn)
138
212
  const shell = resolveShell(baseEnv);
139
- const argv = buildSpawnArgv(sessionId, permissionMode, settingsFile, effortLevel);
213
+ const argv = buildSpawnArgv(sessionId, permissionMode, settingsFile, effortLevel, agent, additionalDirectories, mcpConfigFile);
140
214
  // Sanitized, subscription-billing env (§5/§10) via the single shared definition.
141
215
  const env = buildSanitizedEnv(baseEnv);
142
216
  // §10 no-spoof contract (R3.1/R3.3): the 'cli' entrypoint label MUST be produced
@@ -144,4 +144,18 @@ export declare function injectHook(opts: {
144
144
  * underlying error so teardown never silently strands the fork hook.
145
145
  */
146
146
  export declare function restore(backup: Backup): Promise<RestoreResult>;
147
+ /**
148
+ * Story 060 (R2.2/R2.3/R3.2) — toggle the ultracode keys in the per-session SCRATCH settings file the
149
+ * gate wrote (preserving the hook + EVERY other key). `active` → set `ultracode:true` (the per-session
150
+ * ACTIVATOR) + `ultracodeKeywordTrigger:true` (defensively ENABLE the keyword even if the user disabled
151
+ * it). `!active` → remove BOTH keys. Read tolerantly (JSONC) + written durably (temp+fsync+rename),
152
+ * mirroring {@link injectHook}. A missing/empty file is treated as an empty object. No-op-safe and
153
+ * idempotent: re-applying the same `active` produces a byte-equivalent document.
154
+ *
155
+ * @param settingsPath absolute path to the per-session scratch `settings.local.json` (the gate's file).
156
+ * @param active whether ultracode is selected (set the keys) or de-selected (remove them).
157
+ * @throws {Error} only on a genuine read/parse/write failure — NEVER on the benign absence of the file
158
+ * (ENOENT → treated as `{}`), mirroring the rest of the module's discipline.
159
+ */
160
+ export declare function applyUltracodeSettings(settingsPath: string, active: boolean): Promise<void>;
147
161
  //# sourceMappingURL=settings-writer.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"settings-writer.d.ts","sourceRoot":"","sources":["../../src/gate/settings-writer.ts"],"names":[],"mappings":"AA6BA,qGAAqG;AACrG,eAAO,MAAM,qBAAqB,uBAAuB,CAAC;AAE1D,oGAAoG;AACpG,eAAO,MAAM,oBAAoB,kBAAkB,CAAC;AAEpD,qGAAqG;AACrG,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD,qGAAqG;AACrG,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAErC,uGAAuG;AACvG,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,uGAAuG;AACvG,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,mFAAmF;IACnF,CAAC,oBAAoB,CAAC,CAAC,EAAE,IAAI,CAAC;CAC/B;AAED,wGAAwG;AACxG,MAAM,WAAW,gBAAgB;IAC/B;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8EAA8E;IAC9E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,aAAa,GAAE,MAAM,GAAG,gBAA+C,GACtE,eAAe,CAuBjB;AAED,kGAAkG;AAClG,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAuBvD;AAED,kGAAkG;AAClG,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE;QACN,UAAU,CAAC,EAAE,OAAO,EAAE,CAAC;QACvB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;KAC1B,CAAC;IACF,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,EAAE,KAAK,EAAE,eAAe,GAAG,YAAY,CAsB1F;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAiB7D;AAYD;;;;GAIG;AACH,MAAM,WAAW,MAAM;IACrB,6DAA6D;IAC7D,YAAY,EAAE,MAAM,CAAC;IACrB,sDAAsD;IACtD,OAAO,EAAE,OAAO,CAAC;IACjB,gEAAgE;IAChE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,uGAAuG;AACvG,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC;CAC7B;AA2DD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wGAAwG;IACxG,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAwClB;AAWD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAkEpE"}
1
+ {"version":3,"file":"settings-writer.d.ts","sourceRoot":"","sources":["../../src/gate/settings-writer.ts"],"names":[],"mappings":"AA6BA,qGAAqG;AACrG,eAAO,MAAM,qBAAqB,uBAAuB,CAAC;AAE1D,oGAAoG;AACpG,eAAO,MAAM,oBAAoB,kBAAkB,CAAC;AAEpD,qGAAqG;AACrG,eAAO,MAAM,4BAA4B,MAAM,CAAC;AAEhD,qGAAqG;AACrG,eAAO,MAAM,iBAAiB,MAAM,CAAC;AAErC,uGAAuG;AACvG,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,uGAAuG;AACvG,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,aAAa,EAAE,CAAC;IACvB,mFAAmF;IACnF,CAAC,oBAAoB,CAAC,CAAC,EAAE,IAAI,CAAC;CAC/B;AAED,wGAAwG;AACxG,MAAM,WAAW,gBAAgB;IAC/B;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,8EAA8E;IAC9E,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,aAAa,GAAE,MAAM,GAAG,gBAA+C,GACtE,eAAe,CAuBjB;AAED,kGAAkG;AAClG,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAuBvD;AAED,kGAAkG;AAClG,MAAM,WAAW,YAAY;IAC3B,KAAK,CAAC,EAAE;QACN,UAAU,CAAC,EAAE,OAAO,EAAE,CAAC;QACvB,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;KAC1B,CAAC;IACF,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,YAAY,GAAG,IAAI,EAAE,KAAK,EAAE,eAAe,GAAG,YAAY,CAsB1F;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,YAAY,CAiB7D;AAYD;;;;GAIG;AACH,MAAM,WAAW,MAAM;IACrB,6DAA6D;IAC7D,YAAY,EAAE,MAAM,CAAC;IACrB,sDAAsD;IACtD,OAAO,EAAE,OAAO,CAAC;IACjB,gEAAgE;IAChE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC3B;AAED,uGAAuG;AACvG,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,UAAU,GAAG,QAAQ,CAAC;CAC7B;AA2DD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,UAAU,CAAC,IAAI,EAAE;IACrC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,wGAAwG;IACxG,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,GAAG,OAAO,CAAC,MAAM,CAAC,CAwClB;AAWD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,CAAC,CAkEpE;AAeD;;;;;;;;;;;;GAYG;AACH,wBAAsB,sBAAsB,CAAC,YAAY,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBjG"}
@@ -353,3 +353,52 @@ export async function restore(backup) {
353
353
  }
354
354
  }
355
355
  }
356
+ // ─────────────────────────────────────────────────────────────────────────────
357
+ // Story 060 (R2.2 / R2.3 / R3.2) — toggle the ultracode keys in the per-session SCRATCH settings file.
358
+ //
359
+ // The "ultracode" effort-selector sentinel (story 060) activates the binary's Workflow keyword-trigger.
360
+ // Its LIVE activation is a keyword prefix on the outgoing prompt (acp-agent.ts); THIS function is the
361
+ // declarative spawn-time complement — the keys `claude` reads only at (re-)spawn, so the scratch stays
362
+ // in sync for any (re-)spawn that DOES happen. It is NOT itself a re-spawn trigger (Option A).
363
+ // ─────────────────────────────────────────────────────────────────────────────
364
+ /** The two settings keys that opt a session into the ultracode keyword-trigger (story 060). */
365
+ const ULTRACODE_SETTINGS_KEY = "ultracode";
366
+ const ULTRACODE_KEYWORD_TRIGGER_KEY = "ultracodeKeywordTrigger";
367
+ /**
368
+ * Story 060 (R2.2/R2.3/R3.2) — toggle the ultracode keys in the per-session SCRATCH settings file the
369
+ * gate wrote (preserving the hook + EVERY other key). `active` → set `ultracode:true` (the per-session
370
+ * ACTIVATOR) + `ultracodeKeywordTrigger:true` (defensively ENABLE the keyword even if the user disabled
371
+ * it). `!active` → remove BOTH keys. Read tolerantly (JSONC) + written durably (temp+fsync+rename),
372
+ * mirroring {@link injectHook}. A missing/empty file is treated as an empty object. No-op-safe and
373
+ * idempotent: re-applying the same `active` produces a byte-equivalent document.
374
+ *
375
+ * @param settingsPath absolute path to the per-session scratch `settings.local.json` (the gate's file).
376
+ * @param active whether ultracode is selected (set the keys) or de-selected (remove them).
377
+ * @throws {Error} only on a genuine read/parse/write failure — NEVER on the benign absence of the file
378
+ * (ENOENT → treated as `{}`), mirroring the rest of the module's discipline.
379
+ */
380
+ export async function applyUltracodeSettings(settingsPath, active) {
381
+ // 1) Read the current bytes; a missing file (ENOENT) is treated as "no prior settings" (→ `{}`).
382
+ const priorBytes = await readPriorBytes(settingsPath);
383
+ let current = {};
384
+ if (priorBytes !== null) {
385
+ const text = priorBytes.toString("utf8").trim();
386
+ // An empty existing file is treated as an empty object rather than a parse error (mirrors inject).
387
+ if (text.length > 0) {
388
+ current = parsePriorSettings(text);
389
+ }
390
+ }
391
+ // 2) Clone (never mutate the parsed prior) and toggle ONLY the two ultracode keys — every other key
392
+ // (the gate hook, the user's own config) is preserved verbatim.
393
+ const next = structuredClone(current);
394
+ if (active) {
395
+ next[ULTRACODE_SETTINGS_KEY] = true;
396
+ next[ULTRACODE_KEYWORD_TRIGGER_KEY] = true;
397
+ }
398
+ else {
399
+ delete next[ULTRACODE_SETTINGS_KEY];
400
+ delete next[ULTRACODE_KEYWORD_TRIGGER_KEY];
401
+ }
402
+ // 3) Durably write (temp+fsync+rename) so a concurrent (re-)spawn read never sees a half-written file.
403
+ await durableWrite(settingsPath, `${JSON.stringify(next, null, 2)}\n`);
404
+ }
@@ -0,0 +1,31 @@
1
+ /** Filename prefix for every materialized image — shared so a later cleanup task can match it. */
2
+ export declare const MATERIALIZED_IMAGE_PREFIX = "fork-acp-img-";
3
+ /**
4
+ * Map an ACP `image` block `mimeType` to a file extension the TUI's Read tool recognizes.
5
+ *
6
+ * Tolerant of a `;`-parameterized mimeType (e.g. `image/png; charset=binary`): only the media type
7
+ * before the first `;` is matched. Anything unrecognized — including `undefined`/`null` — falls back
8
+ * to `.img` so a path is still produced (the Read tool sniffs the bytes regardless of extension).
9
+ * Never throws.
10
+ */
11
+ export declare function extFor(mimeType: string | undefined | null): string;
12
+ /**
13
+ * Decode a base64 `image` payload to a uniquely-named temp file and return its absolute path.
14
+ *
15
+ * The file lives under {@link os.tmpdir} with a uuid name (no user-controlled characters) and an
16
+ * extension derived from `mimeType` via {@link extFor}. The write is SYNCHRONOUS on purpose — the
17
+ * caller (`promptToClaude`) is sync. May throw if the decode or write fails; the caller isolates that
18
+ * in its per-block try/catch (R1.3), so a bad image is skipped rather than aborting the whole prompt.
19
+ */
20
+ export declare function materializeImage(data: string, mimeType: string | undefined | null): string;
21
+ /**
22
+ * Story 058 / Task 2.1 (R2.1/R2.2) — best-effort unlink of every materialized temp image.
23
+ *
24
+ * The single unlink path shared by the turn-settle cleanup (prompt()'s catch + finally) and the
25
+ * session-teardown backstop, kept here so it stays OFFLINE-testable. NEVER throws: each unlink is
26
+ * isolated so a missing file (already removed, or a torn-down/raced turn) is a no-op rather than an
27
+ * error — which makes the teardown backstop idempotent with the prompt-finally cleanup. `undefined`
28
+ * (no image materialized this turn) returns immediately.
29
+ */
30
+ export declare function cleanupMaterializedImages(paths: readonly string[] | undefined): void;
31
+ //# sourceMappingURL=image-input.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-input.d.ts","sourceRoot":"","sources":["../src/image-input.ts"],"names":[],"mappings":"AAoBA,kGAAkG;AAClG,eAAO,MAAM,yBAAyB,kBAAkB,CAAC;AAEzD;;;;;;;GAOG;AACH,wBAAgB,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAelE;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI,GAAG,MAAM,CAK1F;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,GAAG,IAAI,CASpF"}
@@ -0,0 +1,79 @@
1
+ // Story 058 / Task 1.1 — materialize an ACP `image` block to a temp file for the @path vision path.
2
+ //
3
+ // The fork drives the real `claude` TUI over a PTY; it has no API surface to push raw image bytes.
4
+ // The proven claude-2.1.195 path to get an image vision-encoded is to write the bytes to a file and
5
+ // reference it with `@<path>` in the prompt text, then ask the model to Read it — the TUI's Read tool
6
+ // then vision-encodes the file. This module is the standalone, OFFLINE-testable materialize seam:
7
+ //
8
+ // extFor(mimeType) → ".png" | ".jpg" | ".gif" | ".webp" | ".img" (PURE — mime → ext)
9
+ // materializeImage(data, mt) → <abs temp path> (DURABLE — base64 → temp file, returns the path)
10
+ //
11
+ // `promptToClaude` (acp-agent.ts) is SYNCHRONOUS and runs before the `await` at its call site, so the
12
+ // write here is synchronous (`writeFileSync`) by design — do NOT make it async. The temp path is
13
+ // uuid-named and fork-controlled (no user input in the filename), which is what lets a later cleanup
14
+ // task (Task 2) treat it as shell-safe. The decoded bytes are not logged.
15
+ import { writeFileSync, unlinkSync } from "node:fs";
16
+ import * as path from "node:path";
17
+ import * as os from "node:os";
18
+ import { randomUUID } from "node:crypto";
19
+ /** Filename prefix for every materialized image — shared so a later cleanup task can match it. */
20
+ export const MATERIALIZED_IMAGE_PREFIX = "fork-acp-img-";
21
+ /**
22
+ * Map an ACP `image` block `mimeType` to a file extension the TUI's Read tool recognizes.
23
+ *
24
+ * Tolerant of a `;`-parameterized mimeType (e.g. `image/png; charset=binary`): only the media type
25
+ * before the first `;` is matched. Anything unrecognized — including `undefined`/`null` — falls back
26
+ * to `.img` so a path is still produced (the Read tool sniffs the bytes regardless of extension).
27
+ * Never throws.
28
+ */
29
+ export function extFor(mimeType) {
30
+ // Strip any `;`-delimited parameters and normalize, so `image/JPEG; q=…` still matches.
31
+ const media = (mimeType ?? "").split(";", 1)[0].trim().toLowerCase();
32
+ switch (media) {
33
+ case "image/png":
34
+ return ".png";
35
+ case "image/jpeg":
36
+ return ".jpg";
37
+ case "image/gif":
38
+ return ".gif";
39
+ case "image/webp":
40
+ return ".webp";
41
+ default:
42
+ return ".img";
43
+ }
44
+ }
45
+ /**
46
+ * Decode a base64 `image` payload to a uniquely-named temp file and return its absolute path.
47
+ *
48
+ * The file lives under {@link os.tmpdir} with a uuid name (no user-controlled characters) and an
49
+ * extension derived from `mimeType` via {@link extFor}. The write is SYNCHRONOUS on purpose — the
50
+ * caller (`promptToClaude`) is sync. May throw if the decode or write fails; the caller isolates that
51
+ * in its per-block try/catch (R1.3), so a bad image is skipped rather than aborting the whole prompt.
52
+ */
53
+ export function materializeImage(data, mimeType) {
54
+ const bytes = Buffer.from(data, "base64");
55
+ const tempPath = path.join(os.tmpdir(), MATERIALIZED_IMAGE_PREFIX + randomUUID() + extFor(mimeType));
56
+ writeFileSync(tempPath, bytes);
57
+ return tempPath;
58
+ }
59
+ /**
60
+ * Story 058 / Task 2.1 (R2.1/R2.2) — best-effort unlink of every materialized temp image.
61
+ *
62
+ * The single unlink path shared by the turn-settle cleanup (prompt()'s catch + finally) and the
63
+ * session-teardown backstop, kept here so it stays OFFLINE-testable. NEVER throws: each unlink is
64
+ * isolated so a missing file (already removed, or a torn-down/raced turn) is a no-op rather than an
65
+ * error — which makes the teardown backstop idempotent with the prompt-finally cleanup. `undefined`
66
+ * (no image materialized this turn) returns immediately.
67
+ */
68
+ export function cleanupMaterializedImages(paths) {
69
+ if (!paths)
70
+ return;
71
+ for (const p of paths) {
72
+ try {
73
+ unlinkSync(p);
74
+ }
75
+ catch {
76
+ /* best-effort: the file is already gone, or a raced teardown removed it first */
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The claude version where `@<path>` → Read-vision was PROVEN to work (story 058 research).
3
+ *
4
+ * WHY this exact anchor: on claude 2.1.170 a `@path` image prompt did NOT vision-encode the picture —
5
+ * the TUI did not turn it into a Read the model could see (story 030). On claude 2.1.195 it DOES: the
6
+ * two-arm proof (a blue vs a green image under the same uuid filename, each described correctly) showed
7
+ * `@path` → Read vision-encodes. So 2.1.195 is the earliest version we are willing to call "confirmed";
8
+ * anything below is treated as unconfirmed and warned about.
9
+ */
10
+ export declare const VISION_CONFIRMED_CLAUDE_VERSION = "2.1.195";
11
+ /** The verdict {@link imageVisionSmoke} returns. Observability only — there is NO capability toggle here. */
12
+ export interface VisionSmokeResult {
13
+ /** True iff the running `claude` version is one we have CONFIRMED vision-encodes `@image` via Read. */
14
+ confirmed: boolean;
15
+ /**
16
+ * Human-readable one-liner. On `confirmed=true` it is a positive note. On `confirmed=false` it is the
17
+ * fail-loud WARNING text and NAMES the detected version (or "undetected") plus the confirmed anchor
18
+ * (mirrors drift-checks.ts's "name the offending value" habit).
19
+ */
20
+ detail: string;
21
+ }
22
+ /**
23
+ * Compare two `x.y.z` semver strings: `true` iff `a >= b` componentwise (major, then minor, then patch).
24
+ *
25
+ * Splits on `.`, parses the three leading numeric components; a missing/non-numeric component counts as
26
+ * `0` (so `"2.1"` reads as `2.1.0`). Private to this module — exported only so the unit test can pin the
27
+ * ordering directly. Pure.
28
+ */
29
+ export declare function versionGte(a: string, b: string): boolean;
30
+ /**
31
+ * PURE verdict: is the running `claude` `version` one we have CONFIRMED vision-encodes `@image` via Read?
32
+ *
33
+ * `confirmed: true` IFF `version` is a parseable leading `x.y.z` that is `>= {@link
34
+ * VISION_CONFIRMED_CLAUDE_VERSION}`. In that case `detail` is a positive one-liner.
35
+ *
36
+ * Otherwise (`null` / `undefined` / unparseable, OR a parseable version BELOW the anchor) `confirmed:
37
+ * false` and `detail` is the fail-loud WARNING text — it NAMES the detected version (or "undetected")
38
+ * and the confirmed anchor, and states that the image is NOT blocked (R3.1). No I/O; depends only on
39
+ * its argument.
40
+ */
41
+ export declare function imageVisionSmoke(version: string | null | undefined): VisionSmokeResult;
42
+ /**
43
+ * Best-effort fail-loud reporter: compute {@link imageVisionSmoke} and, WHEN unconfirmed, log the
44
+ * verdict's `detail` EXACTLY ONCE via `log` with an `[image-vision]` prefix (mirrors claude-path.ts's
45
+ * reportVersionDrift `[claude-path] …` style). Returns the verdict.
46
+ *
47
+ * Logs NOTHING on a confirmed version (a confirmed `claude` is the expected case — no news is good news,
48
+ * matching reportVersionDrift which is silent when there is no drift). NEVER blocks and NEVER throws out
49
+ * of the caller's intent — it is pure aside from the single conditional `log` call (R3.1, R3.2).
50
+ */
51
+ export declare function reportImageVisionSmoke(version: string | null | undefined, log?: (...args: unknown[]) => void): VisionSmokeResult;
52
+ //# sourceMappingURL=image-vision-smoke.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-vision-smoke.d.ts","sourceRoot":"","sources":["../src/image-vision-smoke.ts"],"names":[],"mappings":"AA8BA;;;;;;;;GAQG;AACH,eAAO,MAAM,+BAA+B,YAAY,CAAC;AAEzD,6GAA6G;AAC7G,MAAM,WAAW,iBAAiB;IAChC,uGAAuG;IACvG,SAAS,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,CAAC,EAAE,MAAM,EAAE,CAAC,EAAE,MAAM,GAAG,OAAO,CAWxD;AAcD;;;;;;;;;;GAUG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,iBAAiB,CAiBtF;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAClC,GAAG,GAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,IAAoB,GAChD,iBAAiB,CAMnB"}