@lucascouts/claude-agent-tui 0.1.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/LICENSE +191 -0
- package/NOTICE +14 -0
- package/README.md +50 -0
- package/dist/acp-agent.d.ts +594 -0
- package/dist/acp-agent.d.ts.map +1 -0
- package/dist/acp-agent.js +2139 -0
- package/dist/ansi-mirror.d.ts +42 -0
- package/dist/ansi-mirror.d.ts.map +1 -0
- package/dist/ansi-mirror.js +61 -0
- package/dist/besteffort.d.ts +44 -0
- package/dist/besteffort.d.ts.map +1 -0
- package/dist/besteffort.js +100 -0
- package/dist/billing/entrypoint-guard.d.ts +97 -0
- package/dist/billing/entrypoint-guard.d.ts.map +1 -0
- package/dist/billing/entrypoint-guard.js +166 -0
- package/dist/claude-path.d.ts +12 -0
- package/dist/claude-path.d.ts.map +1 -0
- package/dist/claude-path.js +61 -0
- package/dist/diff-enriched-reader.d.ts +41 -0
- package/dist/diff-enriched-reader.d.ts.map +1 -0
- package/dist/diff-enriched-reader.js +106 -0
- package/dist/diff-source.d.ts +104 -0
- package/dist/diff-source.d.ts.map +1 -0
- package/dist/diff-source.js +164 -0
- package/dist/end-of-turn.d.ts +172 -0
- package/dist/end-of-turn.d.ts.map +1 -0
- package/dist/end-of-turn.js +415 -0
- package/dist/engine-lifecycle.d.ts +222 -0
- package/dist/engine-lifecycle.d.ts.map +1 -0
- package/dist/engine-lifecycle.js +236 -0
- package/dist/engine-pty.d.ts +143 -0
- package/dist/engine-pty.d.ts.map +1 -0
- package/dist/engine-pty.js +222 -0
- package/dist/engine-watcher.d.ts +83 -0
- package/dist/engine-watcher.d.ts.map +1 -0
- package/dist/engine-watcher.js +173 -0
- package/dist/engine.d.ts +30 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +34 -0
- package/dist/event-switch.d.ts +164 -0
- package/dist/event-switch.d.ts.map +1 -0
- package/dist/event-switch.js +206 -0
- package/dist/gate/port.d.ts +38 -0
- package/dist/gate/port.d.ts.map +1 -0
- package/dist/gate/port.js +126 -0
- package/dist/gate/settings-writer.d.ts +130 -0
- package/dist/gate/settings-writer.d.ts.map +1 -0
- package/dist/gate/settings-writer.js +349 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +106 -0
- package/dist/jsonl.d.ts +267 -0
- package/dist/jsonl.d.ts.map +1 -0
- package/dist/jsonl.js +527 -0
- package/dist/lib.d.ts +6 -0
- package/dist/lib.d.ts.map +1 -0
- package/dist/lib.js +5 -0
- package/dist/linearize.d.ts +219 -0
- package/dist/linearize.d.ts.map +1 -0
- package/dist/linearize.js +444 -0
- package/dist/live-diff-env.d.ts +7 -0
- package/dist/live-diff-env.d.ts.map +1 -0
- package/dist/live-diff-env.js +18 -0
- package/dist/live-subagent-env.d.ts +7 -0
- package/dist/live-subagent-env.d.ts.map +1 -0
- package/dist/live-subagent-env.js +19 -0
- package/dist/permissions/allow-inject.d.ts +67 -0
- package/dist/permissions/allow-inject.d.ts.map +1 -0
- package/dist/permissions/allow-inject.js +85 -0
- package/dist/permissions/deny.d.ts +60 -0
- package/dist/permissions/deny.d.ts.map +1 -0
- package/dist/permissions/deny.js +81 -0
- package/dist/permissions/gate-wiring.d.ts +112 -0
- package/dist/permissions/gate-wiring.d.ts.map +1 -0
- package/dist/permissions/gate-wiring.js +350 -0
- package/dist/permissions/hook-server.d.ts +72 -0
- package/dist/permissions/hook-server.d.ts.map +1 -0
- package/dist/permissions/hook-server.js +179 -0
- package/dist/permissions/permission-mode.d.ts +67 -0
- package/dist/permissions/permission-mode.d.ts.map +1 -0
- package/dist/permissions/permission-mode.js +100 -0
- package/dist/permissions/request-permission.d.ts +102 -0
- package/dist/permissions/request-permission.d.ts.map +1 -0
- package/dist/permissions/request-permission.js +124 -0
- package/dist/settings.d.ts +68 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +182 -0
- package/dist/stop-reason-map.d.ts +17 -0
- package/dist/stop-reason-map.d.ts.map +1 -0
- package/dist/stop-reason-map.js +33 -0
- package/dist/subagent-source.d.ts +63 -0
- package/dist/subagent-source.d.ts.map +1 -0
- package/dist/subagent-source.js +132 -0
- package/dist/subagent-watcher.d.ts +40 -0
- package/dist/subagent-watcher.d.ts.map +1 -0
- package/dist/subagent-watcher.js +108 -0
- package/dist/tools.d.ts +119 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +729 -0
- package/dist/usage-env.d.ts +7 -0
- package/dist/usage-env.d.ts.map +1 -0
- package/dist/usage-env.js +16 -0
- package/dist/usage.d.ts +54 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +53 -0
- package/dist/utils.d.ts +16 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +83 -0
- package/dist/zed-register.d.ts +26 -0
- package/dist/zed-register.d.ts.map +1 -0
- package/dist/zed-register.js +106 -0
- package/package.json +79 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// === §2/§5 MOTOR box — managed lifecycle around the story 013 PTY engine (story 014) ===
|
|
2
|
+
//
|
|
3
|
+
// Story 013 spawns the interactive `claude` TUI in a real PTY with a sanitized, subscription
|
|
4
|
+
// -billing environment, and RETURNS the handle for someone else to manage. This module is
|
|
5
|
+
// that someone: it makes the PTY a *managed* process for the lifetime of an ACP session.
|
|
6
|
+
//
|
|
7
|
+
// Per the §2 process model a single Node process hosts BOTH the PTY and the JSONL watcher for
|
|
8
|
+
// each session — there is no out-of-process supervisor. Per the §2 ordering rule the JSONL
|
|
9
|
+
// tail (story 015's watcher) is the single source of truth for session state; `p.onData` is a
|
|
10
|
+
// cosmetic live-mirror only (story 034) and MUST NOT drive state transitions.
|
|
11
|
+
//
|
|
12
|
+
// Spec is PINNED by IMPLEMENTACAO-FORK-ACP.md §5 (Lifecycle/resume):
|
|
13
|
+
// `p.onExit(({exitCode,signal}) => cleanup)` · `p.resize(cols,rows)` with debounce ·
|
|
14
|
+
// Cancel `p.write('\x03')`/`p.write('\x1b')`/`p.kill()` · Resume `bash -c 'claude --resume
|
|
15
|
+
// "<id>" || claude'`.
|
|
16
|
+
//
|
|
17
|
+
// Degrau-1 stays READ-ONLY: the cancel primitives are EXPOSED but not wired to any ACP
|
|
18
|
+
// `session/cancel` handler here (that is story 030). This module imports NO SDK.
|
|
19
|
+
//
|
|
20
|
+
// Scope grows task-by-task in this story: Task 1 owns onExit→cleanup (this commit); the
|
|
21
|
+
// debounced resize, the cancel primitives, the robust resume, and the single-process binding
|
|
22
|
+
// land in Tasks 2–5.
|
|
23
|
+
import pty from "node-pty";
|
|
24
|
+
import { assertSpawnEnvUntainted, buildSanitizedEnv, resolveShell, spawnClaudePty, } from "./engine-pty.js";
|
|
25
|
+
import { attachAnsiMirror } from "./ansi-mirror.js";
|
|
26
|
+
/**
|
|
27
|
+
* Default PTY geometry applied when Zed supplies no terminal size (§5 Spawn defaults). Mirrors
|
|
28
|
+
* the story 013 spawn dimensions so a no-size resize matches a freshly-spawned PTY.
|
|
29
|
+
*/
|
|
30
|
+
export const DEFAULT_COLS = 120;
|
|
31
|
+
export const DEFAULT_ROWS = 40;
|
|
32
|
+
/**
|
|
33
|
+
* Resize debounce window (ms). Rapid resize requests (e.g. a window drag) are coalesced and
|
|
34
|
+
* only the last size is applied after this window elapses, so the TUI is not thrashed (§5).
|
|
35
|
+
*/
|
|
36
|
+
export const RESIZE_DEBOUNCE_MS = 100;
|
|
37
|
+
/**
|
|
38
|
+
* Per-session managed engine: ONE Node process owns the PTY (§2). Task 1 wires the PTY's
|
|
39
|
+
* `onExit` to a single, idempotent `cleanup` that tears down the watcher and releases the
|
|
40
|
+
* per-session connection state exactly once (R1.1–R1.3).
|
|
41
|
+
*
|
|
42
|
+
* `onExit` is only ONE of the possible teardown triggers — an explicit kill (Task 3) routes
|
|
43
|
+
* through the same `cleanup`, so the per-session `disposed` guard makes the second trigger a
|
|
44
|
+
* no-op rather than double-freeing the watcher/session-map handles.
|
|
45
|
+
*/
|
|
46
|
+
export class SessionEngine {
|
|
47
|
+
constructor(opts) {
|
|
48
|
+
this.disposed = false;
|
|
49
|
+
this.sessionId = opts.handle.sessionId;
|
|
50
|
+
this.pty = opts.handle.pty;
|
|
51
|
+
this.watcher = opts.watcher;
|
|
52
|
+
this.sessions = opts.sessions;
|
|
53
|
+
this.onCleanup = opts.onCleanup;
|
|
54
|
+
this.setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
|
|
55
|
+
this.clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
|
|
56
|
+
this.debounceMs = opts.resizeDebounceMs ?? RESIZE_DEBOUNCE_MS;
|
|
57
|
+
this.sessions?.set(this.sessionId, this);
|
|
58
|
+
// §5: process exit tears the session down. onExit is NOT assumed to be the only teardown
|
|
59
|
+
// trigger (an explicit kill also calls cleanup) — idempotency is enforced in cleanup().
|
|
60
|
+
this.pty.onExit(({ exitCode, signal }) => this.cleanup({ exitCode, signal }));
|
|
61
|
+
// Story 035: OPTIONAL cosmetic live ANSI mirror (§5). attachAnsiMirror is a no-op that does NOT
|
|
62
|
+
// touch `p.onData` when the flag is OFF (the default) — so the OFF path is byte-for-byte the
|
|
63
|
+
// read-only Degrau-1 behaviour (no listener attached). When ON it returns the tap's IDisposable.
|
|
64
|
+
// The mirror is cosmetic and one-way: it never drives session state (§2 — structure comes only
|
|
65
|
+
// from the JSONL tail). UNWIRED §7: no `content_block_delta`→`SessionUpdate` path is layered here.
|
|
66
|
+
this.ansiMirror = attachAnsiMirror(this.pty, opts.ansiMirror);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Debounced terminal resize (R2.1–R2.3). Stores the latest (cols, rows) and (re)arms a short
|
|
70
|
+
* debounce timer; when it fires, applies ONLY the last size via `p.resize`. Falls back to the
|
|
71
|
+
* 120×40 default when no size is supplied. A resize requested after the PTY has exited is
|
|
72
|
+
* dropped without throwing (the `disposed` guard) rather than writing to a dead handle.
|
|
73
|
+
*/
|
|
74
|
+
requestResize(cols = DEFAULT_COLS, rows = DEFAULT_ROWS) {
|
|
75
|
+
if (this.disposed)
|
|
76
|
+
return; // drop post-exit resize, no throw (R2.3)
|
|
77
|
+
this.pendingSize = { cols, rows };
|
|
78
|
+
if (this.resizeTimer !== undefined)
|
|
79
|
+
this.clearTimeoutFn(this.resizeTimer);
|
|
80
|
+
this.resizeTimer = this.setTimeoutFn(() => {
|
|
81
|
+
this.resizeTimer = undefined;
|
|
82
|
+
if (this.disposed || !this.pendingSize)
|
|
83
|
+
return;
|
|
84
|
+
const { cols: c, rows: r } = this.pendingSize;
|
|
85
|
+
this.pendingSize = undefined;
|
|
86
|
+
this.pty.resize(c, r);
|
|
87
|
+
}, this.debounceMs);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Ctrl+C — write the interrupt byte `\x03` to the PTY to cancel the current turn (R3.1).
|
|
91
|
+
* No-op after the PTY has exited (R3.4) rather than writing to a dead handle.
|
|
92
|
+
*
|
|
93
|
+
* EXPOSED but deliberately NOT wired to any ACP `session/cancel` handler here — the Degrau-1
|
|
94
|
+
* path is read-only; the `session/cancel`→Ctrl+C wiring is story 030.
|
|
95
|
+
*/
|
|
96
|
+
interrupt() {
|
|
97
|
+
if (this.disposed)
|
|
98
|
+
return;
|
|
99
|
+
this.pty.write("\x03");
|
|
100
|
+
}
|
|
101
|
+
/** Esc — write the escape byte `\x1b` to the PTY (R3.2). No-op after exit (R3.4). */
|
|
102
|
+
escape() {
|
|
103
|
+
if (this.disposed)
|
|
104
|
+
return;
|
|
105
|
+
this.pty.write("\x1b");
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Kill the PTY process (R3.3). No-op after exit (R3.4). In production the subsequent `onExit`
|
|
109
|
+
* routes through the idempotent {@link cleanup}, so no explicit teardown call is needed here.
|
|
110
|
+
*/
|
|
111
|
+
kill() {
|
|
112
|
+
if (this.disposed)
|
|
113
|
+
return;
|
|
114
|
+
this.pty.kill();
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Idempotent teardown (R1.1–R1.3): runs the body exactly once per session. Clears any armed
|
|
118
|
+
* resize timer, stops the watcher, releases the session-map entry, and fires the diagnostics
|
|
119
|
+
* hook. A second call (e.g. explicit kill followed by `onExit`) is a no-op — it leaves no
|
|
120
|
+
* residual timer, watcher subscription, or session-map entry to free again.
|
|
121
|
+
*/
|
|
122
|
+
cleanup(info) {
|
|
123
|
+
if (this.disposed)
|
|
124
|
+
return;
|
|
125
|
+
this.disposed = true;
|
|
126
|
+
// Story 028 (sub-task 3.1): cancel the BACKGROUND fresh-path discovery poll (the unbounded
|
|
127
|
+
// `watchdogMs: Infinity` glob loop whose AbortController 2.1 stored via setPendingDiscovery).
|
|
128
|
+
// A session torn down before its first interaction never produced a transcript, so abort the
|
|
129
|
+
// dangling poll here — BEFORE stopping the watcher — so it leaks no poll. No-op when no
|
|
130
|
+
// discovery was pending (a resumed session, or one already past discovery: optional chaining).
|
|
131
|
+
// Idempotent via the `disposed` guard above.
|
|
132
|
+
this.pendingDiscovery?.abort();
|
|
133
|
+
this.pendingDiscovery = undefined;
|
|
134
|
+
// Story 035: detach the cosmetic live ANSI mirror's `p.onData` tap so it does not outlive the
|
|
135
|
+
// session. No-op when the mirror was never enabled (the default OFF path leaves this undefined).
|
|
136
|
+
this.ansiMirror?.dispose();
|
|
137
|
+
this.ansiMirror = undefined;
|
|
138
|
+
if (this.resizeTimer !== undefined) {
|
|
139
|
+
this.clearTimeoutFn(this.resizeTimer);
|
|
140
|
+
this.resizeTimer = undefined;
|
|
141
|
+
}
|
|
142
|
+
this.pendingSize = undefined;
|
|
143
|
+
if (this.watcher) {
|
|
144
|
+
this.watcher.stop();
|
|
145
|
+
this.watcher = undefined;
|
|
146
|
+
}
|
|
147
|
+
this.sessions?.delete(this.sessionId);
|
|
148
|
+
this.onCleanup?.({ sessionId: this.sessionId, exitCode: info?.exitCode, signal: info?.signal });
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Store the AbortController for the background fresh-path discovery poll (story 028, sub-task 2.1).
|
|
152
|
+
* `cleanup()` aborts it (sub-task 3.1) so a session torn down before its first interaction cancels
|
|
153
|
+
* the dangling `watchdogMs: Infinity` poll instead of leaking it. The engine owns the handle.
|
|
154
|
+
*/
|
|
155
|
+
setPendingDiscovery(ac) {
|
|
156
|
+
this.pendingDiscovery = ac;
|
|
157
|
+
}
|
|
158
|
+
/** Whether {@link cleanup} has already run for this session. */
|
|
159
|
+
get isDisposed() {
|
|
160
|
+
return this.disposed;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/** PTY name pinned by §5 — mirrors the story 013 spawn allocation. */
|
|
164
|
+
const RESUME_PTY_NAME = "xterm-256color";
|
|
165
|
+
/**
|
|
166
|
+
* Build the robust resume argv (§5, R4.1): `bash -c 'claude --resume "<id>" || claude'`. The
|
|
167
|
+
* `|| claude` makes a FAILED `--resume` fall back to a fresh interactive session rather than
|
|
168
|
+
* hanging. The id is double-quoted so it survives shell word-splitting. There is deliberately
|
|
169
|
+
* NO `-p`/`--print`/`stream-json` — those select the SDK/credit non-interactive path.
|
|
170
|
+
*/
|
|
171
|
+
export function buildResumeArgv(sessionId) {
|
|
172
|
+
return ["-c", `claude --resume "${sessionId}" || claude`];
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Spawn the resume PTY, reusing story 013's sanitized env (R4.2) so the resumed turn keeps the
|
|
176
|
+
* `entrypoint='cli'` subscription posture. Same env-sanitize ({@link buildSanitizedEnv}),
|
|
177
|
+
* refuse-to-spawn taint guard ({@link assertSpawnEnvUntainted}), shell resolution
|
|
178
|
+
* ({@link resolveShell}), and §5 PTY geometry as the story 013 spawn path — ONLY the argv
|
|
179
|
+
* differs (robust resume vs the fresh `--session-id` launch). Returns the same
|
|
180
|
+
* {@link PtyEngineHandle} shape so a {@link SessionEngine} manages a resumed PTY identically.
|
|
181
|
+
*/
|
|
182
|
+
export function spawnResumePty(opts) {
|
|
183
|
+
const { sessionId, cwd, baseEnv = process.env, spawn = pty.spawn } = opts;
|
|
184
|
+
const shell = resolveShell(baseEnv);
|
|
185
|
+
const argv = buildResumeArgv(sessionId);
|
|
186
|
+
const env = buildSanitizedEnv(baseEnv);
|
|
187
|
+
// §10 refuse-to-spawn guard (R4.2): abort if any forbidden billing var survived sanitization,
|
|
188
|
+
// rather than resume a credit-billed run. Checks the SPAWN env, not process.env.
|
|
189
|
+
assertSpawnEnvUntainted(env);
|
|
190
|
+
const p = spawn(shell, argv, {
|
|
191
|
+
name: RESUME_PTY_NAME,
|
|
192
|
+
cols: DEFAULT_COLS,
|
|
193
|
+
rows: DEFAULT_ROWS,
|
|
194
|
+
cwd,
|
|
195
|
+
env: env,
|
|
196
|
+
});
|
|
197
|
+
return { sessionId, pty: p };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Bind ONE PTY and ONE JSONL watcher into a single per-session {@link SessionEngine}, hosted by
|
|
201
|
+
* THIS one Node process (§2 — no out-of-process supervisor, no `child_process.fork`). The PTY is
|
|
202
|
+
* spawned via story 013's sanitized path; the watcher is started by the injected story-015
|
|
203
|
+
* factory; the returned engine record owns both (PTY handle + watcher handle + resize timer +
|
|
204
|
+
* disposed flag).
|
|
205
|
+
*
|
|
206
|
+
* ── tail-as-source-of-truth fence (§2 ordering) ───────────────────────────────────────────────
|
|
207
|
+
* Session state derives SOLELY from the watcher's JSONL tail. The live ANSI mirror (story 035) is
|
|
208
|
+
* cosmetic and OFF by default: with no `ansiMirror` option, `p.onData` is NEVER subscribed and this
|
|
209
|
+
* binding is byte-for-byte the read-only Degrau-1 path. Even when enabled, the mirror is one-way
|
|
210
|
+
* (bytes → live-view sink) and MUST NOT mutate session state or drive turn transitions. Do not add
|
|
211
|
+
* an `onData`→state path in this binding: structure comes only from the JSONL tail.
|
|
212
|
+
* ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
213
|
+
*/
|
|
214
|
+
export function createSessionEngine(opts) {
|
|
215
|
+
// ONE PTY, via the story 013 sanitized spawn path. `settingsFile` (story 034) injects the
|
|
216
|
+
// per-session gate hook via `--settings` — already written by the caller BEFORE this spawn.
|
|
217
|
+
const handle = spawnClaudePty({
|
|
218
|
+
cwd: opts.cwd,
|
|
219
|
+
baseEnv: opts.baseEnv,
|
|
220
|
+
spawn: opts.spawn,
|
|
221
|
+
settingsFile: opts.settingsFile,
|
|
222
|
+
});
|
|
223
|
+
// ONE watcher, started by the story 015 factory and bound to that same PTY.
|
|
224
|
+
const watcher = opts.startWatcher?.(handle.sessionId, handle.pty);
|
|
225
|
+
return new SessionEngine({
|
|
226
|
+
handle,
|
|
227
|
+
watcher,
|
|
228
|
+
sessions: opts.sessions,
|
|
229
|
+
resizeDebounceMs: opts.resizeDebounceMs,
|
|
230
|
+
onCleanup: opts.onCleanup,
|
|
231
|
+
setTimeoutFn: opts.setTimeoutFn,
|
|
232
|
+
clearTimeoutFn: opts.clearTimeoutFn,
|
|
233
|
+
// Story 035: forward the OPTIONAL cosmetic mirror config (OFF by default — no tap when absent).
|
|
234
|
+
ansiMirror: opts.ansiMirror,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import pty from "node-pty";
|
|
2
|
+
import type { IPty } from "node-pty";
|
|
3
|
+
/**
|
|
4
|
+
* The four billing/SDK env vars that must never reach the spawned TUI (§10). Inheriting
|
|
5
|
+
* any of `CLAUDE_CODE_ENTRYPOINT` / `CLAUDE_AGENT_SDK_VERSION` / `CLAUDE_AGENT_SDK_CLIENT_APP`
|
|
6
|
+
* from the parent Node/ACP process would silently bill the run as credit; `CLAUDECODE`
|
|
7
|
+
* additionally makes the nested `claude` TUI refuse to start (anti-nesting guard).
|
|
8
|
+
*/
|
|
9
|
+
export declare const FORBIDDEN_BILLING_VARS: readonly ["CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_AGENT_SDK_VERSION", "CLAUDE_AGENT_SDK_CLIENT_APP"];
|
|
10
|
+
/**
|
|
11
|
+
* Build the sanitized spawn env (§5/§10): spread the base env, set the three terminal vars
|
|
12
|
+
* (`TERM`/`COLORTERM`/`FORCE_COLOR`), then delete the four {@link FORBIDDEN_BILLING_VARS}.
|
|
13
|
+
*
|
|
14
|
+
* This is the SINGLE shared definition every spawn path reuses — this story's spawn and
|
|
15
|
+
* story 014's `--resume` — so the deletions never drift across two hand-copied blocks
|
|
16
|
+
* (R2.1-R2.4). Unrelated keys (PATH, HOME, …) pass through untouched.
|
|
17
|
+
*/
|
|
18
|
+
export declare function buildSanitizedEnv(baseEnv?: Record<string, string | undefined>): Record<string, string | undefined>;
|
|
19
|
+
/**
|
|
20
|
+
* Refuse-to-spawn taint guard (R4.1/R4.2). Throws — naming EVERY offending key and the
|
|
21
|
+
* inherited-taint hazard — if any of the four {@link FORBIDDEN_BILLING_VARS} is still
|
|
22
|
+
* present in the spawn env. Defense-in-depth: a future env-build regression that
|
|
23
|
+
* re-introduces a deleted var must abort the spawn loudly rather than launch a run that
|
|
24
|
+
* is silently billed as credit (§10, IMPLEMENTACAO-FORK-ACP.md §13 R1).
|
|
25
|
+
*/
|
|
26
|
+
export declare function assertSpawnEnvUntainted(env: Record<string, string | undefined>): void;
|
|
27
|
+
/** Resolve the login shell: `process.env.SHELL` when set, else `/bin/bash` (§5, R1.2). */
|
|
28
|
+
export declare function resolveShell(baseEnv?: Record<string, string | undefined>): string;
|
|
29
|
+
/**
|
|
30
|
+
* The interactive-TUI command (§5, R1.1). NO `-p`/`--print`, NO `stream-json` — those
|
|
31
|
+
* select the SDK/credit non-interactive path. The pre-generated `sessionId` is embedded
|
|
32
|
+
* verbatim so the JSONL transcript can be correlated by basename.
|
|
33
|
+
*
|
|
34
|
+
* When `planMode` is true, `--permission-mode plan` is appended (story 029 / R4.1).
|
|
35
|
+
* This is the DETERMINISTIC plan-mode entry: the spawn flag is preferred over the
|
|
36
|
+
* interactive Shift+Tab cycle (`\x1b[Z`), which is the §5-appendix UNTESTED fallback
|
|
37
|
+
* (documented in IMPLEMENTACAO-FORK-ACP.md §5, not wired here as primary — R4.2/R4.3).
|
|
38
|
+
*
|
|
39
|
+
* When `settingsFile` is set, `--settings "<file>"` is appended (story 034 / §9 hybrid
|
|
40
|
+
* gate wiring): the per-session SCRATCH settings file carrying the fork's `PreToolUse`
|
|
41
|
+
* type:http hook (story 032 `injectHook`). Injecting via `--settings` from a trusted cwd
|
|
42
|
+
* is the BINDING Degrau-0 recipe (GATE_FINDINGS blocker c) — never a project-local
|
|
43
|
+
* `.claude/` (trust dialog) and never `CLAUDE_CONFIG_DIR` (relocates auth, breaks
|
|
44
|
+
* billing). The path is double-quoted so it survives the `-lc` shell word-splitting.
|
|
45
|
+
* Like `--permission-mode plan`, this flag changes only TUI behavior — it adds no
|
|
46
|
+
* `-p`/`stream-json`, so billing stays subscription `cli` (§10).
|
|
47
|
+
*/
|
|
48
|
+
export declare function buildClaudeCmd(sessionId: string, planMode?: boolean, settingsFile?: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Login-shell argv (§5, R1.3). The `-lc` flag is MANDATORY: node-pty's `posix_spawnp`
|
|
51
|
+
* does not execute the `claude` npm launcher's `#!/usr/bin/env node` shebang, so the
|
|
52
|
+
* launcher must run *through* a login shell. (The compiled native-binary is the only
|
|
53
|
+
* thing that could be spawned directly — §5 / IMPLEMENTACAO-FORK-ACP.md §17.)
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildSpawnArgv(sessionId: string, planMode?: boolean, settingsFile?: string): [string, string];
|
|
56
|
+
/** Options for {@link spawnClaudePty}. */
|
|
57
|
+
export interface SpawnPtyOptions {
|
|
58
|
+
/** Host working directory the TUI runs in; passed straight through to the PTY (§5). */
|
|
59
|
+
cwd: string;
|
|
60
|
+
/** Base environment to sanitize; defaults to the parent process env. */
|
|
61
|
+
baseEnv?: Record<string, string | undefined>;
|
|
62
|
+
/**
|
|
63
|
+
* Injectable spawn function (defaults to node-pty's `spawn`). Unit tests pass a fake
|
|
64
|
+
* so they exercise the spawn spec without launching a real process.
|
|
65
|
+
*/
|
|
66
|
+
spawn?: typeof pty.spawn;
|
|
67
|
+
/**
|
|
68
|
+
* When true, adds `--permission-mode plan` to the spawn command (story 029 / R4.1).
|
|
69
|
+
* Prefer this over the interactive Shift+Tab cycle (`\x1b[Z`), which is the §5-appendix
|
|
70
|
+
* UNTESTED fallback and is documented, not wired as primary (R4.2/R4.3).
|
|
71
|
+
*/
|
|
72
|
+
planMode?: boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Story 034 (§9 hybrid gate): absolute path of the per-session SCRATCH settings file
|
|
75
|
+
* carrying the fork's `PreToolUse` hook; appended as `--settings "<file>"`. The file
|
|
76
|
+
* MUST be written (story 032 `injectHook`) BEFORE this spawn — claude reads settings
|
|
77
|
+
* only at startup (GATE_FINDINGS blocker c), so a late write misses the first tool call.
|
|
78
|
+
* Absent → no flag (the ungated/`FORK_GATE=off` spawn is byte-for-byte pre-034).
|
|
79
|
+
*/
|
|
80
|
+
settingsFile?: string;
|
|
81
|
+
}
|
|
82
|
+
/** Handle returned by {@link spawnClaudePty}; story 014 manages its lifecycle. */
|
|
83
|
+
export interface PtyEngineHandle {
|
|
84
|
+
/** Pre-generated v4 `sessionId`, reused verbatim in the argv (R1.1). */
|
|
85
|
+
sessionId: string;
|
|
86
|
+
/** The live PTY handle. */
|
|
87
|
+
pty: IPty;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Spawn the interactive `claude` TUI under a real PTY with the §5 dimensions and a
|
|
91
|
+
* sanitized, subscription-billing environment.
|
|
92
|
+
*
|
|
93
|
+
* A real PTY (allocated by `pty.spawn`) is MANDATORY: the binary checks `isTTY` (12× in
|
|
94
|
+
* the binary) and a plain pipe yields non-interactive mode (§5, R1.4). The stdio is NOT
|
|
95
|
+
* piped — the PTY is what makes `isTTY` true.
|
|
96
|
+
*
|
|
97
|
+
* Prior art (§4/§5): the login-shell command form, the `xterm-256color` PTY allocation,
|
|
98
|
+
* and the exit-code/signal capture *shape* are borrowed from siteboon/claudecodeui
|
|
99
|
+
* (`shell-websocket.service.ts`) and markes76/claude-code-gui (`pty-bridge.ts`) rather
|
|
100
|
+
* than hand-rolled — adapting only the sanitized env and `--session-id` correlation
|
|
101
|
+
* specific to this fork. The actual `onExit`→cleanup wiring is story 014; here only the
|
|
102
|
+
* spawn/exit shape is borrowed (the handle is returned for 014 to manage). No bespoke
|
|
103
|
+
* process-supervisor or non-PTY spawn variant is introduced.
|
|
104
|
+
*/
|
|
105
|
+
export declare function spawnClaudePty(opts: SpawnPtyOptions): PtyEngineHandle;
|
|
106
|
+
/** Submission delay (ms) between the payload write and the `\r` (§8 / R1.2). */
|
|
107
|
+
export declare const SUBMISSION_DELAY_MS = 60;
|
|
108
|
+
/**
|
|
109
|
+
* The input-clearing keystroke that leads every submission (story 034 G2 fix): Ctrl+U (`\x15`,
|
|
110
|
+
* kill-line). PROVED by `experiments/e-clear.ts` against the real TUI: Ctrl+U empties a non-empty
|
|
111
|
+
* input box (the question submits alone), as does Ctrl+C — but Ctrl+C is unsafe as a universal
|
|
112
|
+
* prefix (a second \x03 in close succession EXITS the TUI). Esc was the first candidate and was
|
|
113
|
+
* REFUTED live twice (sessions 76fbf771 and 78e56a85 still merged the residue). Known limit: a
|
|
114
|
+
* RESTORED MULTI-LINE residue may need one kill-line per line; the common case (a single-line
|
|
115
|
+
* cancelled prompt) is covered.
|
|
116
|
+
*/
|
|
117
|
+
export declare const CLEAR_INPUT_KEY = "\u0015";
|
|
118
|
+
/**
|
|
119
|
+
* Delay (ms) between the input-clearing keystroke and the payload write (story 034 G2 fix) —
|
|
120
|
+
* gives the TUI its own event-loop tick to process the clear before new bytes arrive.
|
|
121
|
+
*/
|
|
122
|
+
export declare const CLEAR_INPUT_DELAY_MS = 60;
|
|
123
|
+
/**
|
|
124
|
+
* Write `text` to the PTY and submit with a delayed carriage return (§8 / R1.1–R1.4).
|
|
125
|
+
*
|
|
126
|
+
* Sequence (story-034 G2 live fix prepended the clear): `\x15` (Ctrl+U — clear residual input) →
|
|
127
|
+
* delay → write(payload) → delay → `\r`. The G2 acceptance run (session 76fbf771) proved the TUI
|
|
128
|
+
* RESTORES a cancelled prompt into its input box, so a fresh submission would concatenate the
|
|
129
|
+
* residue with the new payload into one turn ("Conte de 1 a 100…Qual o nome…"). Ctrl+U kills the
|
|
130
|
+
* residue and is a no-op on an empty box — idempotent for every submission, whatever left the
|
|
131
|
+
* residue. (The permission allow-keystroke does NOT route through here — `allow-inject` writes
|
|
132
|
+
* raw bytes precisely so no clearing byte can touch a pending native dialog.)
|
|
133
|
+
*
|
|
134
|
+
* For single-line `text`: writes the body verbatim then `\r` after the delay.
|
|
135
|
+
* Multi-line handling (bracketed-paste) is added in story 029 task 2.1.
|
|
136
|
+
*
|
|
137
|
+
* The `schedule` parameter defaults to `setTimeout` and is injectable for unit tests
|
|
138
|
+
* so timing can be verified synchronously (the same pattern as `createEndOfTurnDetector`).
|
|
139
|
+
* Only the leading clear write is synchronous (a dead-PTY failure surfaces there, in the caller's
|
|
140
|
+
* try/catch); the payload and `\r` writes are scheduled and post-exit-safe.
|
|
141
|
+
*/
|
|
142
|
+
export declare function sendPrompt(p: IPty, text: string, schedule?: (fn: () => void, ms: number) => void): void;
|
|
143
|
+
//# sourceMappingURL=engine-pty.d.ts.map
|
|
@@ -0,0 +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;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,EAAE,QAAQ,CAAC,EAAE,OAAO,EAAE,YAAY,CAAC,EAAE,MAAM,GAAG,MAAM,CAKnG;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,EACjB,QAAQ,CAAC,EAAE,OAAO,EAClB,YAAY,CAAC,EAAE,MAAM,GACpB,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,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;;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,CA+BrE;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"}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// === §2 MOTOR box — node-pty spawn of the real interactive `claude` TUI (story 013) ===
|
|
2
|
+
//
|
|
3
|
+
// Spawns the user's *subscription* `claude` TUI under a REAL pseudo-terminal with a
|
|
4
|
+
// sanitized, subscription-billing environment, so the JSONL transcript self-labels its
|
|
5
|
+
// message events with entrypoint='cli' (subscription) rather than an SDK/credit signal.
|
|
6
|
+
// Degrau 0 proved the premise empirically: a clean-env PTY run yields the distinct
|
|
7
|
+
// entrypoint set {cli}; the negative control (inherited SDK env) flips it to {sdk-ts}
|
|
8
|
+
// (experiments/DEGRAU0-RESULTS.md, E1 = PASS — the keystone gate).
|
|
9
|
+
//
|
|
10
|
+
// Spec is PINNED by IMPLEMENTACAO-FORK-ACP.md §5 (Spawn — mantém assinatura) and §10
|
|
11
|
+
// (billing audit). This module imports NO SDK — no credit-billing `query()` path is
|
|
12
|
+
// reachable through it (the engine seam stays read-only in Degrau 1).
|
|
13
|
+
//
|
|
14
|
+
// Scope: this module owns ONLY the spawn moment (argv, sanitized env, taint guard,
|
|
15
|
+
// launch). Process lifecycle/resume is story 014; the JSONL tail watcher is story 015;
|
|
16
|
+
// the ACP createSession rewrite that wires this engine in is story 023.
|
|
17
|
+
import pty from "node-pty";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
// PTY geometry pinned by §5.
|
|
20
|
+
const PTY_NAME = "xterm-256color";
|
|
21
|
+
const PTY_COLS = 120;
|
|
22
|
+
const PTY_ROWS = 40;
|
|
23
|
+
/**
|
|
24
|
+
* The four billing/SDK env vars that must never reach the spawned TUI (§10). Inheriting
|
|
25
|
+
* any of `CLAUDE_CODE_ENTRYPOINT` / `CLAUDE_AGENT_SDK_VERSION` / `CLAUDE_AGENT_SDK_CLIENT_APP`
|
|
26
|
+
* from the parent Node/ACP process would silently bill the run as credit; `CLAUDECODE`
|
|
27
|
+
* additionally makes the nested `claude` TUI refuse to start (anti-nesting guard).
|
|
28
|
+
*/
|
|
29
|
+
export const FORBIDDEN_BILLING_VARS = [
|
|
30
|
+
"CLAUDECODE",
|
|
31
|
+
"CLAUDE_CODE_ENTRYPOINT",
|
|
32
|
+
"CLAUDE_AGENT_SDK_VERSION",
|
|
33
|
+
"CLAUDE_AGENT_SDK_CLIENT_APP",
|
|
34
|
+
];
|
|
35
|
+
/**
|
|
36
|
+
* Build the sanitized spawn env (§5/§10): spread the base env, set the three terminal vars
|
|
37
|
+
* (`TERM`/`COLORTERM`/`FORCE_COLOR`), then delete the four {@link FORBIDDEN_BILLING_VARS}.
|
|
38
|
+
*
|
|
39
|
+
* This is the SINGLE shared definition every spawn path reuses — this story's spawn and
|
|
40
|
+
* story 014's `--resume` — so the deletions never drift across two hand-copied blocks
|
|
41
|
+
* (R2.1-R2.4). Unrelated keys (PATH, HOME, …) pass through untouched.
|
|
42
|
+
*/
|
|
43
|
+
export function buildSanitizedEnv(baseEnv = process.env) {
|
|
44
|
+
const env = {
|
|
45
|
+
...baseEnv,
|
|
46
|
+
TERM: PTY_NAME,
|
|
47
|
+
COLORTERM: "truecolor",
|
|
48
|
+
FORCE_COLOR: "3",
|
|
49
|
+
};
|
|
50
|
+
for (const key of FORBIDDEN_BILLING_VARS) {
|
|
51
|
+
delete env[key];
|
|
52
|
+
}
|
|
53
|
+
return env;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Refuse-to-spawn taint guard (R4.1/R4.2). Throws — naming EVERY offending key and the
|
|
57
|
+
* inherited-taint hazard — if any of the four {@link FORBIDDEN_BILLING_VARS} is still
|
|
58
|
+
* present in the spawn env. Defense-in-depth: a future env-build regression that
|
|
59
|
+
* re-introduces a deleted var must abort the spawn loudly rather than launch a run that
|
|
60
|
+
* is silently billed as credit (§10, IMPLEMENTACAO-FORK-ACP.md §13 R1).
|
|
61
|
+
*/
|
|
62
|
+
export function assertSpawnEnvUntainted(env) {
|
|
63
|
+
const present = FORBIDDEN_BILLING_VARS.filter((key) => env[key] !== undefined);
|
|
64
|
+
if (present.length > 0) {
|
|
65
|
+
throw new Error(`engine-pty: refusing to spawn — forbidden billing var(s) survived sanitization: ` +
|
|
66
|
+
`${present.join(", ")}. Inheriting these from the parent Node/ACP process would ` +
|
|
67
|
+
`silently bill the run as credit instead of subscription (§10, R4).`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Resolve the login shell: `process.env.SHELL` when set, else `/bin/bash` (§5, R1.2). */
|
|
71
|
+
export function resolveShell(baseEnv = process.env) {
|
|
72
|
+
return baseEnv.SHELL || "/bin/bash";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The interactive-TUI command (§5, R1.1). NO `-p`/`--print`, NO `stream-json` — those
|
|
76
|
+
* select the SDK/credit non-interactive path. The pre-generated `sessionId` is embedded
|
|
77
|
+
* verbatim so the JSONL transcript can be correlated by basename.
|
|
78
|
+
*
|
|
79
|
+
* When `planMode` is true, `--permission-mode plan` is appended (story 029 / R4.1).
|
|
80
|
+
* This is the DETERMINISTIC plan-mode entry: the spawn flag is preferred over the
|
|
81
|
+
* interactive Shift+Tab cycle (`\x1b[Z`), which is the §5-appendix UNTESTED fallback
|
|
82
|
+
* (documented in IMPLEMENTACAO-FORK-ACP.md §5, not wired here as primary — R4.2/R4.3).
|
|
83
|
+
*
|
|
84
|
+
* When `settingsFile` is set, `--settings "<file>"` is appended (story 034 / §9 hybrid
|
|
85
|
+
* gate wiring): the per-session SCRATCH settings file carrying the fork's `PreToolUse`
|
|
86
|
+
* type:http hook (story 032 `injectHook`). Injecting via `--settings` from a trusted cwd
|
|
87
|
+
* is the BINDING Degrau-0 recipe (GATE_FINDINGS blocker c) — never a project-local
|
|
88
|
+
* `.claude/` (trust dialog) and never `CLAUDE_CONFIG_DIR` (relocates auth, breaks
|
|
89
|
+
* billing). The path is double-quoted so it survives the `-lc` shell word-splitting.
|
|
90
|
+
* Like `--permission-mode plan`, this flag changes only TUI behavior — it adds no
|
|
91
|
+
* `-p`/`stream-json`, so billing stays subscription `cli` (§10).
|
|
92
|
+
*/
|
|
93
|
+
export function buildClaudeCmd(sessionId, planMode, settingsFile) {
|
|
94
|
+
let cmd = `claude --session-id ${sessionId}`;
|
|
95
|
+
if (planMode)
|
|
96
|
+
cmd += " --permission-mode plan";
|
|
97
|
+
if (settingsFile)
|
|
98
|
+
cmd += ` --settings "${settingsFile}"`;
|
|
99
|
+
return cmd;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Login-shell argv (§5, R1.3). The `-lc` flag is MANDATORY: node-pty's `posix_spawnp`
|
|
103
|
+
* does not execute the `claude` npm launcher's `#!/usr/bin/env node` shebang, so the
|
|
104
|
+
* launcher must run *through* a login shell. (The compiled native-binary is the only
|
|
105
|
+
* thing that could be spawned directly — §5 / IMPLEMENTACAO-FORK-ACP.md §17.)
|
|
106
|
+
*/
|
|
107
|
+
export function buildSpawnArgv(sessionId, planMode, settingsFile) {
|
|
108
|
+
return ["-lc", buildClaudeCmd(sessionId, planMode, settingsFile)];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Spawn the interactive `claude` TUI under a real PTY with the §5 dimensions and a
|
|
112
|
+
* sanitized, subscription-billing environment.
|
|
113
|
+
*
|
|
114
|
+
* A real PTY (allocated by `pty.spawn`) is MANDATORY: the binary checks `isTTY` (12× in
|
|
115
|
+
* the binary) and a plain pipe yields non-interactive mode (§5, R1.4). The stdio is NOT
|
|
116
|
+
* piped — the PTY is what makes `isTTY` true.
|
|
117
|
+
*
|
|
118
|
+
* Prior art (§4/§5): the login-shell command form, the `xterm-256color` PTY allocation,
|
|
119
|
+
* and the exit-code/signal capture *shape* are borrowed from siteboon/claudecodeui
|
|
120
|
+
* (`shell-websocket.service.ts`) and markes76/claude-code-gui (`pty-bridge.ts`) rather
|
|
121
|
+
* than hand-rolled — adapting only the sanitized env and `--session-id` correlation
|
|
122
|
+
* specific to this fork. The actual `onExit`→cleanup wiring is story 014; here only the
|
|
123
|
+
* spawn/exit shape is borrowed (the handle is returned for 014 to manage). No bespoke
|
|
124
|
+
* process-supervisor or non-PTY spawn variant is introduced.
|
|
125
|
+
*/
|
|
126
|
+
export function spawnClaudePty(opts) {
|
|
127
|
+
const { cwd, baseEnv = process.env, spawn = pty.spawn, planMode, settingsFile } = opts;
|
|
128
|
+
const sessionId = randomUUID(); // pre-generated → correlates to the JSONL transcript
|
|
129
|
+
const shell = resolveShell(baseEnv);
|
|
130
|
+
const argv = buildSpawnArgv(sessionId, planMode, settingsFile);
|
|
131
|
+
// Sanitized, subscription-billing env (§5/§10) via the single shared definition.
|
|
132
|
+
const env = buildSanitizedEnv(baseEnv);
|
|
133
|
+
// §10 no-spoof contract (R3.1/R3.3): the 'cli' entrypoint label MUST be produced
|
|
134
|
+
// ORGANICALLY by running the real TUI — it is NEVER forged. CLAUDE_CODE_ENTRYPOINT is
|
|
135
|
+
// assigned nowhere on this path; after sanitization it must be absent, and nothing below
|
|
136
|
+
// re-introduces it. Forcing the field would be spoofing-with-enforcement (the OpenClaw
|
|
137
|
+
// evasion case). The argv is interactive-only — buildClaudeCmd() carries no -p/stream-json.
|
|
138
|
+
//
|
|
139
|
+
// Refuse-to-spawn taint guard (R4): abort loudly if ANY of the four forbidden billing
|
|
140
|
+
// vars (incl. CLAUDE_CODE_ENTRYPOINT) survived sanitization, rather than launch a
|
|
141
|
+
// credit-billed run. Checks the SPAWN env, not process.env.
|
|
142
|
+
assertSpawnEnvUntainted(env);
|
|
143
|
+
// Real PTY (not a pipe) so the binary's isTTY check passes → interactive mode (R1.4).
|
|
144
|
+
const p = spawn(shell, argv, {
|
|
145
|
+
name: PTY_NAME,
|
|
146
|
+
cols: PTY_COLS,
|
|
147
|
+
rows: PTY_ROWS,
|
|
148
|
+
cwd,
|
|
149
|
+
env: env,
|
|
150
|
+
});
|
|
151
|
+
return { sessionId, pty: p };
|
|
152
|
+
}
|
|
153
|
+
// === §8 Fluxo reverso — sendPrompt (story 029) ===
|
|
154
|
+
//
|
|
155
|
+
// Submission shape pinned by IMPLEMENTACAO-FORK-ACP.md §8 (Submissão):
|
|
156
|
+
// write(payload) → delay (~60 ms) → write('\r')
|
|
157
|
+
// Multi-line text MUST use bracketed-paste (the branch in sendPrompt below).
|
|
158
|
+
// All writes go directly through `p.write` — no xterm.js interposed.
|
|
159
|
+
//
|
|
160
|
+
// --- Empirical validation (task 4.2 / R3.3 / R5.3 — live run 2026-06-09, claude 2.1.170) ---
|
|
161
|
+
// The previously-UNTESTED bracketed-paste multi-line path is CONFIRMED: a 3-line and a
|
|
162
|
+
// 5-line prompt each submitted as EXACTLY ONE `user` event over the wire (R2.3 / R5.2) —
|
|
163
|
+
// the TUI treats the `\x1b[200~…\x1b[201~` envelope as a single paste, not one turn per
|
|
164
|
+
// embedded newline. The ~60 ms write→`\r` delay commits both single-line and multi-line
|
|
165
|
+
// submissions; on long input the `\r` is NOT absorbed into the paste body (R3.2).
|
|
166
|
+
//
|
|
167
|
+
// Flakiness observed (R3.3): single-line submission is reliable in isolation (3/3 live
|
|
168
|
+
// runs), but one transient MISS occurred when the engine was spawned back-to-back with
|
|
169
|
+
// other live engines in the same batch — that run created NO transcript at all (the
|
|
170
|
+
// submission never committed: no first interaction → no JSONL, per story 028). The miss
|
|
171
|
+
// correlates with TUI input-readiness right after a rapid re-spawn, NOT with the 60 ms
|
|
172
|
+
// constant (raising the delay would not help a not-yet-ready TUI). The bracketed-paste
|
|
173
|
+
// envelope proved the more robust path (atomic paste finalization). Resolving guidance:
|
|
174
|
+
// let the TUI reach input-readiness (the BOOT settle) before the first submission; the
|
|
175
|
+
// 60 ms submit delay itself needs no change.
|
|
176
|
+
//
|
|
177
|
+
// Shift+Tab plan-mode entry (`\x1b[Z`) remains the UNTESTED, unwired fallback — see
|
|
178
|
+
// buildClaudeCmd above; `--permission-mode plan` is the wired deterministic path (R4).
|
|
179
|
+
/** Submission delay (ms) between the payload write and the `\r` (§8 / R1.2). */
|
|
180
|
+
export const SUBMISSION_DELAY_MS = 60;
|
|
181
|
+
/**
|
|
182
|
+
* The input-clearing keystroke that leads every submission (story 034 G2 fix): Ctrl+U (`\x15`,
|
|
183
|
+
* kill-line). PROVED by `experiments/e-clear.ts` against the real TUI: Ctrl+U empties a non-empty
|
|
184
|
+
* input box (the question submits alone), as does Ctrl+C — but Ctrl+C is unsafe as a universal
|
|
185
|
+
* prefix (a second \x03 in close succession EXITS the TUI). Esc was the first candidate and was
|
|
186
|
+
* REFUTED live twice (sessions 76fbf771 and 78e56a85 still merged the residue). Known limit: a
|
|
187
|
+
* RESTORED MULTI-LINE residue may need one kill-line per line; the common case (a single-line
|
|
188
|
+
* cancelled prompt) is covered.
|
|
189
|
+
*/
|
|
190
|
+
export const CLEAR_INPUT_KEY = "\x15";
|
|
191
|
+
/**
|
|
192
|
+
* Delay (ms) between the input-clearing keystroke and the payload write (story 034 G2 fix) —
|
|
193
|
+
* gives the TUI its own event-loop tick to process the clear before new bytes arrive.
|
|
194
|
+
*/
|
|
195
|
+
export const CLEAR_INPUT_DELAY_MS = 60;
|
|
196
|
+
/**
|
|
197
|
+
* Write `text` to the PTY and submit with a delayed carriage return (§8 / R1.1–R1.4).
|
|
198
|
+
*
|
|
199
|
+
* Sequence (story-034 G2 live fix prepended the clear): `\x15` (Ctrl+U — clear residual input) →
|
|
200
|
+
* delay → write(payload) → delay → `\r`. The G2 acceptance run (session 76fbf771) proved the TUI
|
|
201
|
+
* RESTORES a cancelled prompt into its input box, so a fresh submission would concatenate the
|
|
202
|
+
* residue with the new payload into one turn ("Conte de 1 a 100…Qual o nome…"). Ctrl+U kills the
|
|
203
|
+
* residue and is a no-op on an empty box — idempotent for every submission, whatever left the
|
|
204
|
+
* residue. (The permission allow-keystroke does NOT route through here — `allow-inject` writes
|
|
205
|
+
* raw bytes precisely so no clearing byte can touch a pending native dialog.)
|
|
206
|
+
*
|
|
207
|
+
* For single-line `text`: writes the body verbatim then `\r` after the delay.
|
|
208
|
+
* Multi-line handling (bracketed-paste) is added in story 029 task 2.1.
|
|
209
|
+
*
|
|
210
|
+
* The `schedule` parameter defaults to `setTimeout` and is injectable for unit tests
|
|
211
|
+
* so timing can be verified synchronously (the same pattern as `createEndOfTurnDetector`).
|
|
212
|
+
* Only the leading clear write is synchronous (a dead-PTY failure surfaces there, in the caller's
|
|
213
|
+
* try/catch); the payload and `\r` writes are scheduled and post-exit-safe.
|
|
214
|
+
*/
|
|
215
|
+
export function sendPrompt(p, text, schedule = (fn, ms) => setTimeout(fn, ms)) {
|
|
216
|
+
// Multi-line: wrap in bracketed-paste so the TUI treats the whole block as ONE turn
|
|
217
|
+
// rather than submitting on each embedded newline (§5 Input / R2.1-R2.4).
|
|
218
|
+
const payload = text.includes("\n") ? `\x1b[200~${text}\x1b[201~` : text;
|
|
219
|
+
p.write(CLEAR_INPUT_KEY); // clear any residual input-box text (e.g. a cancelled prompt the TUI restored)
|
|
220
|
+
schedule(() => p.write(payload), CLEAR_INPUT_DELAY_MS);
|
|
221
|
+
schedule(() => p.write("\r"), CLEAR_INPUT_DELAY_MS + SUBMISSION_DELAY_MS);
|
|
222
|
+
}
|