@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.
- package/dist/acp-agent.d.ts +187 -13
- package/dist/acp-agent.d.ts.map +1 -1
- package/dist/acp-agent.js +444 -59
- package/dist/agent-catalog.d.ts +96 -0
- package/dist/agent-catalog.d.ts.map +1 -0
- package/dist/agent-catalog.js +287 -0
- package/dist/claude-path.d.ts.map +1 -1
- package/dist/claude-path.js +6 -0
- package/dist/end-of-turn.d.ts +6 -0
- package/dist/end-of-turn.d.ts.map +1 -1
- package/dist/end-of-turn.js +8 -1
- package/dist/engine-lifecycle.d.ts +66 -1
- package/dist/engine-lifecycle.d.ts.map +1 -1
- package/dist/engine-lifecycle.js +43 -4
- package/dist/engine-pty.d.ts +70 -2
- package/dist/engine-pty.d.ts.map +1 -1
- package/dist/engine-pty.js +80 -6
- package/dist/gate/settings-writer.d.ts +14 -0
- package/dist/gate/settings-writer.d.ts.map +1 -1
- package/dist/gate/settings-writer.js +49 -0
- package/dist/image-input.d.ts +31 -0
- package/dist/image-input.d.ts.map +1 -0
- package/dist/image-input.js +79 -0
- package/dist/image-vision-smoke.d.ts +52 -0
- package/dist/image-vision-smoke.d.ts.map +1 -0
- package/dist/image-vision-smoke.js +111 -0
- package/dist/index.js +6 -0
- package/dist/mcp-config-writer.d.ts +61 -0
- package/dist/mcp-config-writer.d.ts.map +1 -0
- package/dist/mcp-config-writer.js +172 -0
- package/dist/model-catalog.d.ts +29 -2
- package/dist/model-catalog.d.ts.map +1 -1
- package/dist/model-catalog.js +50 -10
- package/dist/settings.d.ts.map +1 -1
- package/dist/settings.js +9 -0
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -1
- package/dist/usage.d.ts +3 -0
- package/dist/usage.d.ts.map +1 -1
- package/dist/usage.js +3 -0
- package/package.json +8 -8
package/dist/engine-lifecycle.js
CHANGED
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// debounced resize, the cancel primitives, the robust resume, and the single-process binding
|
|
22
22
|
// land in Tasks 2–5.
|
|
23
23
|
import pty from "node-pty";
|
|
24
|
-
import { assertSpawnEnvUntainted, buildSanitizedEnv, resolveShell, spawnClaudePty, } from "./engine-pty.js";
|
|
24
|
+
import { assertSpawnEnvUntainted, buildAddDirFlags, buildSanitizedEnv, resolveShell, spawnClaudePty, } from "./engine-pty.js";
|
|
25
|
+
import { isSafeAgentRef } from "./agent-catalog.js";
|
|
25
26
|
import { attachAnsiMirror } from "./ansi-mirror.js";
|
|
26
27
|
/**
|
|
27
28
|
* Default PTY geometry applied when Zed supplies no terminal size (§5 Spawn defaults). Mirrors
|
|
@@ -172,11 +173,45 @@ const RESUME_PTY_NAME = "xterm-256color";
|
|
|
172
173
|
* launch AND the `|| claude` fresh fallback as `--permission-mode <mode>`, so an in-place re-spawn
|
|
173
174
|
* (a dontAsk/bypass switch) reattaches the SAME sessionId/transcript under the new mode. Still no
|
|
174
175
|
* `-p`/`--print`/`stream-json` — billing stays subscription `cli`.
|
|
176
|
+
*
|
|
177
|
+
* Story 056 (R3.3): an optional `agent` persona name is likewise carried via `flags` as the
|
|
178
|
+
* DOUBLE-QUOTED `--agent "<name>"`. Because `flags` is interpolated into BOTH the `--resume "<id>"`
|
|
179
|
+
* launch AND the `|| claude` fresh fallback, this single addition reaches both branches (R3.3). It is
|
|
180
|
+
* the SECOND layer of the command-injection defense — re-asserted via {@link isSafeAgentRef} (which
|
|
181
|
+
* accepts a namespaced `plugin:name`) so an unsafe name is DROPPED (no flag), never interpolated.
|
|
182
|
+
* Still no `-p`/`--print`/`stream-json`.
|
|
183
|
+
*
|
|
184
|
+
* Story 057 (R1.1/R1.2): an optional `additionalDirectories` list is folded into the SAME `flags`
|
|
185
|
+
* string as ONE double-quoted `--add-dir "<dir>"` per dir — so, like the agent flag, a single addition
|
|
186
|
+
* reaches BOTH the `--resume "<id>"` half AND the `|| claude` fallback half. Each dir is re-asserted via
|
|
187
|
+
* {@link isSafeDir} (absolute + existing + no shell metacharacter); an unsafe dir is DROPPED (logged),
|
|
188
|
+
* never interpolated. Still no `-p`/`--print`/`stream-json`.
|
|
189
|
+
*
|
|
190
|
+
* Story 057 (R2.2): an optional `mcpConfigFile` PATH is likewise folded into the SAME `flags` string as
|
|
191
|
+
* the DOUBLE-QUOTED `--mcp-config "<file>"` (a file path — the secret-bearing JSON stays off the command
|
|
192
|
+
* line), so this single addition reaches BOTH the `--resume "<id>"` launch AND the `|| claude` fresh
|
|
193
|
+
* fallback. R2.2 HARD rule: `--strict-mcp-config` is NEVER emitted, so a resumed turn keeps MERGING the
|
|
194
|
+
* scratch with the user's own `.mcp.json`/settings MCP servers. Still no `-p`/`--print`/`stream-json`.
|
|
175
195
|
*/
|
|
176
|
-
export function buildResumeArgv(sessionId, permissionMode, effortLevel) {
|
|
196
|
+
export function buildResumeArgv(sessionId, permissionMode, effortLevel, agent, additionalDirectories, mcpConfigFile) {
|
|
177
197
|
const pm = permissionMode && permissionMode !== "default" ? ` --permission-mode ${permissionMode}` : "";
|
|
178
198
|
const ef = effortLevel && effortLevel !== "default" ? ` --effort ${effortLevel}` : "";
|
|
179
|
-
|
|
199
|
+
// Story 056 (R3.3): double-quoted agent flag, emitted only for a real persona (the "default"
|
|
200
|
+
// sentinel = no persona emits nothing, mirroring --effort) and only when it passes the R3.3
|
|
201
|
+
// allowlist re-assert; folded into `flags` so it carries into both the --resume and || claude halves.
|
|
202
|
+
const ag = agent && agent !== "default" && isSafeAgentRef(agent) ? ` --agent "${agent}"` : "";
|
|
203
|
+
// Story 057 (R1.1/R1.2): ONE double-quoted `--add-dir "<dir>"` per safe dir, built by the SAME shared
|
|
204
|
+
// {@link buildAddDirFlags} the fresh spawn path uses (single source of truth — isSafeDir: absolute +
|
|
205
|
+
// existing + no shell metachar; unsafe → dropped+logged). Folded into `flags` BELOW so the dirs carry
|
|
206
|
+
// into BOTH the --resume launch AND the || claude fresh fallback (same single-addition-reaches-both
|
|
207
|
+
// mechanism as the agent flag). Empty/undefined → "".
|
|
208
|
+
const dirs = buildAddDirFlags(additionalDirectories);
|
|
209
|
+
// Story 057 (R2.2): the double-quoted `--mcp-config "<file>"` fragment, emitted only when set; folded
|
|
210
|
+
// into `flags` BELOW so — like every other flag here — it reaches BOTH the --resume launch AND the
|
|
211
|
+
// || claude fresh fallback. NEVER `--strict-mcp-config` (R2.2): the resumed turn keeps MERGING the
|
|
212
|
+
// scratch with the user's own .mcp.json/settings MCP servers. A file path keeps the JSON off the CLI.
|
|
213
|
+
const mcp = mcpConfigFile ? ` --mcp-config "${mcpConfigFile}"` : "";
|
|
214
|
+
const flags = `${pm}${ef}${ag}${dirs}${mcp}`;
|
|
180
215
|
return ["-c", `claude --resume "${sessionId}"${flags} || claude${flags}`];
|
|
181
216
|
}
|
|
182
217
|
/**
|
|
@@ -190,7 +225,7 @@ export function buildResumeArgv(sessionId, permissionMode, effortLevel) {
|
|
|
190
225
|
export function spawnResumePty(opts) {
|
|
191
226
|
const { sessionId, cwd, baseEnv = process.env, spawn = pty.spawn } = opts;
|
|
192
227
|
const shell = resolveShell(baseEnv);
|
|
193
|
-
const argv = buildResumeArgv(sessionId, opts.permissionMode, opts.effortLevel);
|
|
228
|
+
const argv = buildResumeArgv(sessionId, opts.permissionMode, opts.effortLevel, opts.agent, opts.additionalDirectories, opts.mcpConfigFile);
|
|
194
229
|
const env = buildSanitizedEnv(baseEnv);
|
|
195
230
|
// §10 refuse-to-spawn guard (R4.2): abort if any forbidden billing var survived sanitization,
|
|
196
231
|
// rather than resume a credit-billed run. Checks the SPAWN env, not process.env.
|
|
@@ -226,9 +261,13 @@ export function createSessionEngine(opts) {
|
|
|
226
261
|
cwd: opts.cwd,
|
|
227
262
|
baseEnv: opts.baseEnv,
|
|
228
263
|
spawn: opts.spawn,
|
|
264
|
+
sessionId: opts.sessionId,
|
|
229
265
|
permissionMode: opts.permissionMode,
|
|
230
266
|
effortLevel: opts.effortLevel,
|
|
267
|
+
agent: opts.agent,
|
|
231
268
|
settingsFile: opts.settingsFile,
|
|
269
|
+
additionalDirectories: opts.additionalDirectories,
|
|
270
|
+
mcpConfigFile: opts.mcpConfigFile,
|
|
232
271
|
});
|
|
233
272
|
// ONE watcher, started by the story 015 factory and bound to that same PTY.
|
|
234
273
|
const watcher = opts.startWatcher?.(handle.sessionId, handle.pty);
|
package/dist/engine-pty.d.ts
CHANGED
|
@@ -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
|
|
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 {
|
package/dist/engine-pty.d.ts.map
CHANGED
|
@@ -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;
|
|
1
|
+
{"version":3,"file":"engine-pty.d.ts","sourceRoot":"","sources":["../src/engine-pty.ts"],"names":[],"mappings":"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"}
|
package/dist/engine-pty.js
CHANGED
|
@@ -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 [
|
|
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"}
|