@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.
Files changed (112) hide show
  1. package/LICENSE +191 -0
  2. package/NOTICE +14 -0
  3. package/README.md +50 -0
  4. package/dist/acp-agent.d.ts +594 -0
  5. package/dist/acp-agent.d.ts.map +1 -0
  6. package/dist/acp-agent.js +2139 -0
  7. package/dist/ansi-mirror.d.ts +42 -0
  8. package/dist/ansi-mirror.d.ts.map +1 -0
  9. package/dist/ansi-mirror.js +61 -0
  10. package/dist/besteffort.d.ts +44 -0
  11. package/dist/besteffort.d.ts.map +1 -0
  12. package/dist/besteffort.js +100 -0
  13. package/dist/billing/entrypoint-guard.d.ts +97 -0
  14. package/dist/billing/entrypoint-guard.d.ts.map +1 -0
  15. package/dist/billing/entrypoint-guard.js +166 -0
  16. package/dist/claude-path.d.ts +12 -0
  17. package/dist/claude-path.d.ts.map +1 -0
  18. package/dist/claude-path.js +61 -0
  19. package/dist/diff-enriched-reader.d.ts +41 -0
  20. package/dist/diff-enriched-reader.d.ts.map +1 -0
  21. package/dist/diff-enriched-reader.js +106 -0
  22. package/dist/diff-source.d.ts +104 -0
  23. package/dist/diff-source.d.ts.map +1 -0
  24. package/dist/diff-source.js +164 -0
  25. package/dist/end-of-turn.d.ts +172 -0
  26. package/dist/end-of-turn.d.ts.map +1 -0
  27. package/dist/end-of-turn.js +415 -0
  28. package/dist/engine-lifecycle.d.ts +222 -0
  29. package/dist/engine-lifecycle.d.ts.map +1 -0
  30. package/dist/engine-lifecycle.js +236 -0
  31. package/dist/engine-pty.d.ts +143 -0
  32. package/dist/engine-pty.d.ts.map +1 -0
  33. package/dist/engine-pty.js +222 -0
  34. package/dist/engine-watcher.d.ts +83 -0
  35. package/dist/engine-watcher.d.ts.map +1 -0
  36. package/dist/engine-watcher.js +173 -0
  37. package/dist/engine.d.ts +30 -0
  38. package/dist/engine.d.ts.map +1 -0
  39. package/dist/engine.js +34 -0
  40. package/dist/event-switch.d.ts +164 -0
  41. package/dist/event-switch.d.ts.map +1 -0
  42. package/dist/event-switch.js +206 -0
  43. package/dist/gate/port.d.ts +38 -0
  44. package/dist/gate/port.d.ts.map +1 -0
  45. package/dist/gate/port.js +126 -0
  46. package/dist/gate/settings-writer.d.ts +130 -0
  47. package/dist/gate/settings-writer.d.ts.map +1 -0
  48. package/dist/gate/settings-writer.js +349 -0
  49. package/dist/index.d.ts +3 -0
  50. package/dist/index.d.ts.map +1 -0
  51. package/dist/index.js +106 -0
  52. package/dist/jsonl.d.ts +267 -0
  53. package/dist/jsonl.d.ts.map +1 -0
  54. package/dist/jsonl.js +527 -0
  55. package/dist/lib.d.ts +6 -0
  56. package/dist/lib.d.ts.map +1 -0
  57. package/dist/lib.js +5 -0
  58. package/dist/linearize.d.ts +219 -0
  59. package/dist/linearize.d.ts.map +1 -0
  60. package/dist/linearize.js +444 -0
  61. package/dist/live-diff-env.d.ts +7 -0
  62. package/dist/live-diff-env.d.ts.map +1 -0
  63. package/dist/live-diff-env.js +18 -0
  64. package/dist/live-subagent-env.d.ts +7 -0
  65. package/dist/live-subagent-env.d.ts.map +1 -0
  66. package/dist/live-subagent-env.js +19 -0
  67. package/dist/permissions/allow-inject.d.ts +67 -0
  68. package/dist/permissions/allow-inject.d.ts.map +1 -0
  69. package/dist/permissions/allow-inject.js +85 -0
  70. package/dist/permissions/deny.d.ts +60 -0
  71. package/dist/permissions/deny.d.ts.map +1 -0
  72. package/dist/permissions/deny.js +81 -0
  73. package/dist/permissions/gate-wiring.d.ts +112 -0
  74. package/dist/permissions/gate-wiring.d.ts.map +1 -0
  75. package/dist/permissions/gate-wiring.js +350 -0
  76. package/dist/permissions/hook-server.d.ts +72 -0
  77. package/dist/permissions/hook-server.d.ts.map +1 -0
  78. package/dist/permissions/hook-server.js +179 -0
  79. package/dist/permissions/permission-mode.d.ts +67 -0
  80. package/dist/permissions/permission-mode.d.ts.map +1 -0
  81. package/dist/permissions/permission-mode.js +100 -0
  82. package/dist/permissions/request-permission.d.ts +102 -0
  83. package/dist/permissions/request-permission.d.ts.map +1 -0
  84. package/dist/permissions/request-permission.js +124 -0
  85. package/dist/settings.d.ts +68 -0
  86. package/dist/settings.d.ts.map +1 -0
  87. package/dist/settings.js +182 -0
  88. package/dist/stop-reason-map.d.ts +17 -0
  89. package/dist/stop-reason-map.d.ts.map +1 -0
  90. package/dist/stop-reason-map.js +33 -0
  91. package/dist/subagent-source.d.ts +63 -0
  92. package/dist/subagent-source.d.ts.map +1 -0
  93. package/dist/subagent-source.js +132 -0
  94. package/dist/subagent-watcher.d.ts +40 -0
  95. package/dist/subagent-watcher.d.ts.map +1 -0
  96. package/dist/subagent-watcher.js +108 -0
  97. package/dist/tools.d.ts +119 -0
  98. package/dist/tools.d.ts.map +1 -0
  99. package/dist/tools.js +729 -0
  100. package/dist/usage-env.d.ts +7 -0
  101. package/dist/usage-env.d.ts.map +1 -0
  102. package/dist/usage-env.js +16 -0
  103. package/dist/usage.d.ts +54 -0
  104. package/dist/usage.d.ts.map +1 -0
  105. package/dist/usage.js +53 -0
  106. package/dist/utils.d.ts +16 -0
  107. package/dist/utils.d.ts.map +1 -0
  108. package/dist/utils.js +83 -0
  109. package/dist/zed-register.d.ts +26 -0
  110. package/dist/zed-register.d.ts.map +1 -0
  111. package/dist/zed-register.js +106 -0
  112. 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
+ }