@lucascouts/claude-agent-tui 0.5.1 → 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 (51) 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 +448 -60
  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 +34 -3
  19. package/dist/gate/settings-writer.d.ts.map +1 -1
  20. package/dist/gate/settings-writer.js +62 -7
  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/permissions/gate-wiring.d.ts +13 -1
  35. package/dist/permissions/gate-wiring.d.ts.map +1 -1
  36. package/dist/permissions/gate-wiring.js +158 -40
  37. package/dist/permissions/hook-server.d.ts +15 -0
  38. package/dist/permissions/hook-server.d.ts.map +1 -1
  39. package/dist/permissions/hook-server.js +30 -1
  40. package/dist/permissions/request-permission.d.ts +9 -0
  41. package/dist/permissions/request-permission.d.ts.map +1 -1
  42. package/dist/permissions/request-permission.js +20 -5
  43. package/dist/settings.d.ts.map +1 -1
  44. package/dist/settings.js +9 -0
  45. package/dist/tools.d.ts +10 -2
  46. package/dist/tools.d.ts.map +1 -1
  47. package/dist/tools.js +5 -1
  48. package/dist/usage.d.ts +3 -0
  49. package/dist/usage.d.ts.map +1 -1
  50. package/dist/usage.js +9 -5
  51. 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
@@ -19,18 +19,33 @@ export interface PreToolUseGroup {
19
19
  /** Present (and `true`) only on the FORK's own group — never on a user's group. */
20
20
  [FORK_HOOK_MARKER_KEY]?: true;
21
21
  }
22
+ /** Story 055 (R1.3) — options for {@link buildHookEntry}: a per-session token plus the hook timeout. */
23
+ export interface BuildHookOptions {
24
+ /**
25
+ * Per-session crypto-random secret appended to the hook URL AFTER {@link FORK_HOOK_MARKER_PATH}
26
+ * (e.g. `…/__fork-acp-gate__/<token>`). The hook-server rejects any PreToolUse POST that does not
27
+ * present it — the compensating control for the relaxed JSONL anti-forgery (decide() now seeds the
28
+ * correlator from the hook payload). Appending AFTER the marker keeps {@link isForkHookGroup} /
29
+ * {@link restore} matching on the marker substring, so teardown still recognizes the tokenized URL.
30
+ */
31
+ token?: string;
32
+ /** Hook timeout in SECONDS (default {@link DEFAULT_HOOK_TIMEOUT_SECONDS}). */
33
+ timeout?: number;
34
+ }
22
35
  /**
23
36
  * Build the fork's `PreToolUse` `type:http` hook group, pointing at the 127.0.0.1 TCP-loopback
24
37
  * endpoint for the dynamically allocated free port (R1 / R2.3). The URL carries the fork-owned
25
- * sentinel path ({@link FORK_HOOK_MARKER_PATH}) so teardown can surgically remove ONLY this group.
38
+ * sentinel path ({@link FORK_HOOK_MARKER_PATH}) so teardown can surgically remove ONLY this group;
39
+ * when a {@link BuildHookOptions.token} is given (story 055 / R1.3) it is appended AFTER that marker.
26
40
  *
27
41
  * @param port the verified-free port from {@link import("./port.js").findFreePort} — a positive integer.
28
- * @param timeout hook timeout in SECONDS (default {@link DEFAULT_HOOK_TIMEOUT_SECONDS}).
42
+ * @param optsOrTimeout either a {@link BuildHookOptions} (token + timeout) OR, for backward
43
+ * compatibility, the hook timeout in SECONDS as a bare number (default {@link DEFAULT_HOOK_TIMEOUT_SECONDS}).
29
44
  * @returns the `{ matcher, hooks:[{type:"http",…}], __forkAcpGate:true }` group.
30
45
  * @throws {Error} if `port` is not a positive integer (a bad port would produce a malformed/ungated
31
46
  * hook — fail loud rather than silently emit a hook claude ignores).
32
47
  */
33
- export declare function buildHookEntry(port: number, timeout?: number): PreToolUseGroup;
48
+ export declare function buildHookEntry(port: number, optsOrTimeout?: number | BuildHookOptions): PreToolUseGroup;
34
49
  /** True iff `group` is the FORK's own injected hook group (sentinel key OR sentinel URL path). */
35
50
  export declare function isForkHookGroup(group: unknown): boolean;
36
51
  /** Minimal structural view of a settings document's hooks block (only what the merge touches). */
@@ -107,6 +122,8 @@ export declare function injectHook(opts: {
107
122
  settingsPath: string;
108
123
  port: number;
109
124
  timeout?: number;
125
+ /** Story 055 (R1.3) — per-session token bound into the hook URL after {@link FORK_HOOK_MARKER_PATH}. */
126
+ token?: string;
110
127
  }): Promise<Backup>;
111
128
  /**
112
129
  * Teardown: remove the fork's injected hook, leaving ZERO residue (R4.2, R4.3).
@@ -127,4 +144,18 @@ export declare function injectHook(opts: {
127
144
  * underlying error so teardown never silently strands the fork hook.
128
145
  */
129
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>;
130
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;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,MAAM,EACZ,OAAO,GAAE,MAAqC,GAC7C,eAAe,CAkBjB;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;CAClB,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"}
@@ -36,25 +36,31 @@ export const FORK_HOOK_MATCHER = "*";
36
36
  /**
37
37
  * Build the fork's `PreToolUse` `type:http` hook group, pointing at the 127.0.0.1 TCP-loopback
38
38
  * endpoint for the dynamically allocated free port (R1 / R2.3). The URL carries the fork-owned
39
- * sentinel path ({@link FORK_HOOK_MARKER_PATH}) so teardown can surgically remove ONLY this group.
39
+ * sentinel path ({@link FORK_HOOK_MARKER_PATH}) so teardown can surgically remove ONLY this group;
40
+ * when a {@link BuildHookOptions.token} is given (story 055 / R1.3) it is appended AFTER that marker.
40
41
  *
41
42
  * @param port the verified-free port from {@link import("./port.js").findFreePort} — a positive integer.
42
- * @param timeout hook timeout in SECONDS (default {@link DEFAULT_HOOK_TIMEOUT_SECONDS}).
43
+ * @param optsOrTimeout either a {@link BuildHookOptions} (token + timeout) OR, for backward
44
+ * compatibility, the hook timeout in SECONDS as a bare number (default {@link DEFAULT_HOOK_TIMEOUT_SECONDS}).
43
45
  * @returns the `{ matcher, hooks:[{type:"http",…}], __forkAcpGate:true }` group.
44
46
  * @throws {Error} if `port` is not a positive integer (a bad port would produce a malformed/ungated
45
47
  * hook — fail loud rather than silently emit a hook claude ignores).
46
48
  */
47
- export function buildHookEntry(port, timeout = DEFAULT_HOOK_TIMEOUT_SECONDS) {
49
+ export function buildHookEntry(port, optsOrTimeout = DEFAULT_HOOK_TIMEOUT_SECONDS) {
48
50
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
49
51
  throw new Error(`buildHookEntry: port must be a positive integer in 1..65535, got ${String(port)} — refusing ` +
50
52
  `to build a malformed hook (claude would ignore it and the tool would run ungated).`);
51
53
  }
54
+ const opts = typeof optsOrTimeout === "number" ? { timeout: optsOrTimeout } : optsOrTimeout;
55
+ const timeout = opts.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS;
56
+ // R1.3: the token rides AFTER the marker path so isForkHookGroup/restore still match the marker.
57
+ const tokenSuffix = opts.token ? `/${opts.token}` : "";
52
58
  return {
53
59
  matcher: FORK_HOOK_MATCHER,
54
60
  hooks: [
55
61
  {
56
62
  type: "http",
57
- url: `http://127.0.0.1:${port}${FORK_HOOK_MARKER_PATH}`,
63
+ url: `http://127.0.0.1:${port}${FORK_HOOK_MARKER_PATH}${tokenSuffix}`,
58
64
  timeout,
59
65
  },
60
66
  ],
@@ -224,12 +230,12 @@ async function readPriorBytes(settingsPath) {
224
230
  * deleted) before rejecting, so a failed inject never leaves a file claude could read half-written.
225
231
  */
226
232
  export async function injectHook(opts) {
227
- const { settingsPath, port, timeout } = opts;
233
+ const { settingsPath, port, timeout, token } = opts;
228
234
  // 1) Snapshot the prior state (bytes or absence) — captured BEFORE any mutation (R4.1).
229
235
  const priorBytes = await readPriorBytes(settingsPath);
230
236
  const backup = { settingsPath, existed: priorBytes !== null, priorBytes };
231
- // 2) Compute the merged object from the prior (parsed tolerantly) + the fork hook.
232
- const group = buildHookEntry(port, timeout);
237
+ // 2) Compute the merged object from the prior (parsed tolerantly) + the fork hook (R1.3: tokenized).
238
+ const group = buildHookEntry(port, { token, timeout });
233
239
  let prior = null;
234
240
  if (priorBytes !== null) {
235
241
  const text = priorBytes.toString("utf8").trim();
@@ -347,3 +353,52 @@ export async function restore(backup) {
347
353
  }
348
354
  }
349
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
+ }