open-claude-p 1.0.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/src/index.js ADDED
@@ -0,0 +1,908 @@
1
+ // open-claude-p — public library entry.
2
+ //
3
+ // The library and the CLI binary share the same core. `createDriver()`
4
+ // returns a Driver that can be used for one-shot prompt-response cycles
5
+ // against the upstream `claude` CLI driven through `node-pty`.
6
+ //
7
+ // Implements the single-request path (`runOneShot`) with the
8
+ // `text` output strategy, plus pooling, sessions, and additional
9
+ // output formats.
10
+
11
+ import { randomBytes } from 'node:crypto';
12
+ import { readdir, stat } from 'node:fs/promises';
13
+ import path from 'node:path';
14
+ import os from 'node:os';
15
+
16
+ import { PtySession } from './pty/session.js';
17
+ import { PtyPool } from './pty/pool.js';
18
+ import { ansiStripParser } from './parsers/ansi-strip.js';
19
+ import { tuiFrameParser, PATTERNS as TUI_PATTERNS } from './parsers/tui-frame.js';
20
+ import { createSentinelParser } from './parsers/sentinel.js';
21
+ import { createPipeline } from './parsers/pipeline.js';
22
+ import { CompletionDetector } from './completion/detector.js';
23
+ import { OPTION_SPEC } from './options/spec.js';
24
+ import { runPrintMode } from './print-mode.js';
25
+
26
+ const DEFAULT_WARMUP_MS = 2500;
27
+ const DEFAULT_IDLE_MS = 1500;
28
+ const DEFAULT_PRE_IDLE_MS = 8000;
29
+ // 24 h. Set high by design: a hard cap that's too low aborts legitimate
30
+ // long-running tool sequences (multi-step WebSearch / WebFetch / Bash
31
+ // rounds), and the in-flight idle/pre-idle silence detectors already
32
+ // stop "actually stuck" runs much earlier. Operators who want a tighter
33
+ // ceiling set OCP_MAX_RESPONSE_MS explicitly.
34
+ const DEFAULT_MAX_RESPONSE_MS = 24 * 60 * 60 * 1000;
35
+
36
+ // Flags that allow reading arbitrary files, loading untrusted config, granting
37
+ // extra filesystem scope, or bypassing permission gates on the upstream claude
38
+ // CLI. We refuse to forward these from `passThroughArgv` to prevent argv
39
+ // injection through wrappers that pipe user input verbatim. Set
40
+ // OCP_ALLOW_UNSAFE_ARGV=1 to opt out (e.g. trusted controlled environments).
41
+ const UNSAFE_PASSTHROUGH_FLAGS = new Set([
42
+ // Read arbitrary files into prompt context
43
+ '--system-prompt-file',
44
+ '--append-system-prompt-file',
45
+ // Load untrusted configuration
46
+ '--mcp-config',
47
+ '--strict-mcp-config',
48
+ '--settings',
49
+ '--setting-sources',
50
+ // Grant extra filesystem scope
51
+ '--add-dir',
52
+ '--debug-file',
53
+ // Load untrusted agents / plugins
54
+ '--agents',
55
+ '--plugin-dir',
56
+ // Bypass permission gates
57
+ '--dangerously-skip-permissions',
58
+ '--allow-dangerously-skip-permissions',
59
+ '--permission-mode',
60
+ '--permission-prompt-tool',
61
+ // Materialise files at attacker-chosen paths
62
+ '--file',
63
+ // Broaden attach surface to a discoverable IDE
64
+ '--ide',
65
+ ]);
66
+
67
+ // Flags whose VALUE may contain secrets / file paths the user would not
68
+ // want to paste into a bug report. We redact the value when debug-logging
69
+ // the spawn argv. The flag name itself is retained for diagnostics.
70
+ const SENSITIVE_FLAG_VALUE = new Set([
71
+ '--system-prompt',
72
+ '--append-system-prompt',
73
+ '--system-prompt-file',
74
+ '--append-system-prompt-file',
75
+ '--mcp-config',
76
+ '--settings',
77
+ '--resume',
78
+ '--session-id',
79
+ '--debug-file',
80
+ ]);
81
+
82
+ export function redactArgvForLog(argv) {
83
+ if (!Array.isArray(argv)) return [];
84
+ const out = [];
85
+ for (let i = 0; i < argv.length; i++) {
86
+ const tok = argv[i];
87
+ if (typeof tok !== 'string') { out.push(tok); continue; }
88
+ const eq = tok.indexOf('=');
89
+ const flag = eq >= 0 ? tok.slice(0, eq) : tok;
90
+ if (SENSITIVE_FLAG_VALUE.has(flag)) {
91
+ if (eq >= 0) {
92
+ out.push(`${flag}=<redacted len=${tok.length - eq - 1}>`);
93
+ } else {
94
+ out.push(flag);
95
+ if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
96
+ out.push(`<redacted len=${argv[i + 1].length}>`);
97
+ i++;
98
+ }
99
+ }
100
+ continue;
101
+ }
102
+ out.push(tok);
103
+ }
104
+ return out;
105
+ }
106
+
107
+ export function sanitizePassThroughArgv(argv) {
108
+ if (!Array.isArray(argv) || argv.length === 0) return { sanitized: [], rejected: [] };
109
+ if (process.env.OCP_ALLOW_UNSAFE_ARGV === '1') return { sanitized: argv.slice(), rejected: [] };
110
+ const sanitized = [];
111
+ const rejected = [];
112
+ for (let i = 0; i < argv.length; i++) {
113
+ const tok = argv[i];
114
+ if (typeof tok !== 'string') { sanitized.push(tok); continue; }
115
+ const eq = tok.indexOf('=');
116
+ const flag = eq >= 0 ? tok.slice(0, eq) : tok;
117
+ if (UNSAFE_PASSTHROUGH_FLAGS.has(flag)) {
118
+ rejected.push(tok);
119
+ if (eq < 0 && i + 1 < argv.length && !argv[i + 1].startsWith('-')) i++;
120
+ continue;
121
+ }
122
+ sanitized.push(tok);
123
+ }
124
+ if (rejected.length > 0) {
125
+ process.stderr.write(
126
+ `[ocp] dropped unsafe pass-through flag(s): ${rejected.join(' ')} ` +
127
+ `(set OCP_ALLOW_UNSAFE_ARGV=1 to override)\n`,
128
+ );
129
+ }
130
+ return { sanitized, rejected };
131
+ }
132
+
133
+ /**
134
+ * Construct a driver. The driver is the entry point for any consumer of
135
+ * the library API and is shared between calls. By default, every
136
+ * `runOneShot()` spawns a fresh PTY session; pass `poolSize: N` to opt
137
+ * into warm-session reuse.
138
+ */
139
+ export function createDriver(driverOpts = {}) {
140
+ return new Driver(driverOpts);
141
+ }
142
+
143
+ class Driver {
144
+ constructor(opts = {}) {
145
+ // All numeric options pass through `finiteNonNeg` so a caller-side
146
+ // typo (`maxEvents: 'lots'`, `firstResponseMs: NaN`, `poolSize: -1`)
147
+ // falls back to the env / hard-coded default instead of corrupting
148
+ // downstream math (e.g. `events.splice(0, len - (MAX>>1))` becomes
149
+ // unbounded growth or zero-retention with NaN).
150
+ this.opts = {
151
+ claudeBin: opts.claudeBin ?? process.env.OCP_CLAUDE_BIN ?? 'claude',
152
+ warmupMs: finiteNonNeg(opts.warmupMs, numberFromEnv('OCP_WARMUP_MS', DEFAULT_WARMUP_MS)),
153
+ reuseWarmupMs: finiteNonNeg(opts.reuseWarmupMs, numberFromEnv('OCP_REUSE_WARMUP_MS', 200)),
154
+ idleMs: finiteNonNeg(opts.idleMs, numberFromEnv('OCP_IDLE_MS', DEFAULT_IDLE_MS)),
155
+ preIdleMs: finiteNonNeg(opts.preIdleMs, numberFromEnv('OCP_PRE_IDLE_MS', DEFAULT_PRE_IDLE_MS)),
156
+ maxResponseMs: finiteNonNeg(opts.maxResponseMs, numberFromEnv('OCP_MAX_RESPONSE_MS', DEFAULT_MAX_RESPONSE_MS)),
157
+ cwd: opts.cwd,
158
+ env: (opts.env && typeof opts.env === 'object') ? opts.env : {},
159
+ debug: !!opts.debug,
160
+ poolSize: finiteNonNeg(opts.poolSize, numberFromEnv('OCP_POOL_SIZE', 0)),
161
+ poolMaxAgeMs: finiteNonNeg(opts.poolMaxAgeMs, numberFromEnv('OCP_POOL_MAX_AGE_MS', 600_000)),
162
+ maxBufferBytes: finiteNonNeg(opts.maxBufferBytes, 16 * 1024 * 1024),
163
+ maxEvents: finiteNonNeg(opts.maxEvents, 10_000),
164
+ firstResponseMs: finiteNonNeg(opts.firstResponseMs, numberFromEnv('OCP_FIRST_RESPONSE_MS', 120_000)),
165
+ trustSettleMs: finiteNonNeg(opts.trustSettleMs, numberFromEnv('OCP_TRUST_SETTLE_MS', 2_500)),
166
+ promptBoxWaitMs: finiteNonNeg(opts.promptBoxWaitMs, numberFromEnv('OCP_PROMPT_BOX_WAIT_MS', 6_000)),
167
+ initialSessionId: opts.initialSessionId ?? null,
168
+ printMode:
169
+ opts.printMode === true ||
170
+ process.env.OCP_PRINT_MODE === '1' ||
171
+ process.env.OCP_PRINT_MODE === 'true',
172
+ };
173
+ /** Lazily-constructed pool — only when poolSize > 0. */
174
+ this._pool = null;
175
+ }
176
+
177
+ /** @returns {PtyPool|null} */
178
+ _getPool() {
179
+ if (this.opts.poolSize <= 0) return null;
180
+ if (!this._pool) {
181
+ this._pool = new PtyPool({
182
+ maxIdlePerKey: this.opts.poolSize,
183
+ maxAgeMs: this.opts.poolMaxAgeMs,
184
+ initialSessionId: this.opts.initialSessionId,
185
+ });
186
+ }
187
+ return this._pool;
188
+ }
189
+
190
+ /**
191
+ * Single-prompt request. Spawns a fresh upstream `claude` process,
192
+ * sends the user prompt plus a sentinel instruction, waits for
193
+ * completion, and returns the extracted assistant text.
194
+ *
195
+ * @param {object} req
196
+ * @param {string} req.prompt
197
+ * @param {string} [req.cwd]
198
+ * @param {string} [req.model]
199
+ * @param {string} [req.systemPrompt]
200
+ * @param {string[]} [req.allowedTools]
201
+ * @param {string[]} [req.disallowedTools]
202
+ * @param {boolean} [req.dangerouslySkipPermissions]
203
+ * @param {boolean} [req.debug]
204
+ * @param {boolean} [req.verbose]
205
+ * @param {string[]} [req.passThroughArgv] extra argv to forward verbatim
206
+ * @param {AbortSignal} [req.abortSignal]
207
+ * @param {(event: object) => void} [req.onEvent]
208
+ * Optional live event callback. Receives parser events as they are
209
+ * emitted, before they are batched into the returned `events` array.
210
+ * Used by the stream-json output adapter to emit incremental output.
211
+ * @returns {Promise<OneShotResult>}
212
+ */
213
+ async runOneShot(req) {
214
+ if (!req?.prompt || typeof req.prompt !== 'string') {
215
+ throw new Error('runOneShot: `prompt` (string) is required');
216
+ }
217
+
218
+ // Print-mode short-circuit: spawn `claude --print` directly, bypass
219
+ // the PTY+TUI pipeline. Caller opts in via req.printMode, driver-level
220
+ // opts.printMode, or env OCP_PRINT_MODE=1.
221
+ const printMode =
222
+ req.printMode === true ||
223
+ this.opts.printMode === true ||
224
+ process.env.OCP_PRINT_MODE === '1' ||
225
+ process.env.OCP_PRINT_MODE === 'true';
226
+ if (printMode) {
227
+ const cwd = req.cwd ?? this.opts.cwd;
228
+ const env = { ...process.env, ...this.opts.env };
229
+ const r = await runPrintMode({
230
+ bin: this.opts.claudeBin,
231
+ req,
232
+ sink: req.printSink,
233
+ cwd, env,
234
+ abortSignal: req.abortSignal,
235
+ timeoutMs: this.opts.maxResponseMs,
236
+ logDebug: (m) => this._logDebug(m),
237
+ });
238
+ return {
239
+ text: r.text,
240
+ sessionId: r.sessionId,
241
+ isError: r.isError,
242
+ events: [],
243
+ completionReason: r.completionReason,
244
+ durationMs: r.durationMs,
245
+ cost: { totalUsd: null, numTurns: null },
246
+ diagnostics: { rawBytes: r.text.length, strippedBytes: r.text.length, mode: 'print' },
247
+ };
248
+ }
249
+
250
+ const startTime = Date.now();
251
+ const nonce = randomBytes(8).toString('hex');
252
+ const sentinel = `⟦OCP_END:${nonce}⟧`;
253
+
254
+ const spawnArgs = buildSpawnArgs(req);
255
+
256
+ const sentinelParser = createSentinelParser(nonce);
257
+ const pipeline = createPipeline([
258
+ ansiStripParser,
259
+ tuiFrameParser,
260
+ sentinelParser,
261
+ ]);
262
+ const detector = new CompletionDetector({
263
+ nonce,
264
+ idleMs: this.opts.idleMs,
265
+ preIdleMs: this.opts.preIdleMs,
266
+ maxResponseMs: this.opts.maxResponseMs,
267
+ maxTurns: req.maxTurns,
268
+ });
269
+
270
+ // Pool eligibility: explicit resume/continue bind to a specific past
271
+ // session and are not poolable (they bypass the pool entirely).
272
+ const cwd = req.cwd ?? this.opts.cwd;
273
+ const env = { ...process.env, ...this.opts.env };
274
+ const pool = this._getPool();
275
+ const isResumeLike =
276
+ (typeof req.resume === 'string' && req.resume !== '') ||
277
+ req.continue === true ||
278
+ (typeof req.sessionId === 'string' && req.sessionId !== '');
279
+ const poolKey = pool && !isResumeLike
280
+ ? PtyPool.canonicalKey({ cwd, spawnArgs })
281
+ : null;
282
+
283
+ let session;
284
+ let warmReuse = false;
285
+
286
+ if (poolKey) {
287
+ const acquired = await pool.acquire({ key: poolKey });
288
+
289
+ if (acquired.session) {
290
+ // Warm hit — PTY already running, conversation context intact.
291
+ session = acquired.session;
292
+ warmReuse = true;
293
+ this._logDebug(`pool hit (warm reuse) parked=${pool.size()}`);
294
+ } else {
295
+ // Miss — spawn a new PTY. Use resumeSessionId (if any) so the
296
+ // new process continues the conversation from where we left off.
297
+ const effectiveSpawnArgs = acquired.resumeSessionId
298
+ ? buildSpawnArgs({ ...req, resume: acquired.resumeSessionId })
299
+ : spawnArgs;
300
+ if (acquired.resumeSessionId) {
301
+ this._logDebug(`pool stale — respawn with --resume ${acquired.resumeSessionId}`);
302
+ } else {
303
+ this._logDebug(`pool empty — fresh spawn`);
304
+ }
305
+ session = new PtySession();
306
+ await session.spawn({ bin: this.opts.claudeBin, args: effectiveSpawnArgs, cwd, env });
307
+ }
308
+ } else {
309
+ session = new PtySession();
310
+ this._logDebug(`spawn ${this.opts.claudeBin} ${redactArgvForLog(spawnArgs).join(' ')}`);
311
+ await session.spawn({ bin: this.opts.claudeBin, args: spawnArgs, cwd, env });
312
+ }
313
+
314
+ let rawBytes = 0;
315
+ let strippedBuffer = '';
316
+ // Hard cap on per-request stripped output. A runaway upstream (tool loop,
317
+ // verbose paste from a hostile MCP server) would otherwise grow this
318
+ // string unbounded for the full maxResponseMs window — up to 24 h in
319
+ // the sample. When we exceed the cap we keep only the tail so the
320
+ // sentinel anchor and any session-id banner near the end are preserved.
321
+ const MAX_STRIPPED_BYTES = this.opts.maxBufferBytes ?? 16 * 1024 * 1024;
322
+ let strippedTruncated = false;
323
+ /** @type {Array<object>} */
324
+ const events = [];
325
+ // Hard cap on retained events. A 24 h request emitting per-millisecond
326
+ // spinner frames would otherwise grow `events` to gigabytes and
327
+ // amplify daemon IPC `JSON.stringify` cost. We keep the tail so
328
+ // completion-relevant events (sentinel, assistant-text, session-id)
329
+ // near the end of the run survive.
330
+ const MAX_EVENTS = this.opts.maxEvents ?? 10_000;
331
+ let eventsTruncated = false;
332
+ let capturedSessionId = null;
333
+ // Snapshot the pre-existing session-id files in the project dir so the
334
+ // filesystem fallback below only accepts NEW files (created during
335
+ // THIS request). Without this, a timed-out request would pick the
336
+ // most-recently-modified neighbour file — possibly from a different
337
+ // session entirely — leading to chained `--resume` calls landing in
338
+ // the wrong conversation.
339
+ const sessionFilesBeforeSpawn = await listSessionFiles(cwd);
340
+
341
+ const dataHandler = (chunk) => {
342
+ rawBytes += Buffer.byteLength(chunk);
343
+ // Any byte arriving from the PTY counts as activity — this prevents
344
+ // the idle fallback from tripping while the model is streaming text
345
+ // between region-entered and sentinel events.
346
+ detector.markActivity();
347
+ const r = pipeline.feed(chunk);
348
+ strippedBuffer += r.text;
349
+ if (strippedBuffer.length > MAX_STRIPPED_BYTES) {
350
+ // Keep the last quarter so sentinel matching at the tail still works.
351
+ strippedBuffer = strippedBuffer.slice(-(MAX_STRIPPED_BYTES >> 2));
352
+ if (!strippedTruncated) {
353
+ strippedTruncated = true;
354
+ this._logDebug(`stripped buffer truncated at ${MAX_STRIPPED_BYTES} bytes`);
355
+ }
356
+ }
357
+ for (const ev of r.events) {
358
+ events.push(ev);
359
+ if (events.length > MAX_EVENTS) {
360
+ events.splice(0, events.length - (MAX_EVENTS >> 1));
361
+ if (!eventsTruncated) {
362
+ eventsTruncated = true;
363
+ this._logDebug(`events array truncated at ${MAX_EVENTS}`);
364
+ }
365
+ }
366
+ if (ev.type === 'session-id' && ev.id) capturedSessionId = ev.id;
367
+ // Any sign that the model started producing output cancels the
368
+ // first-response watchdog (set up below). Spinner and region
369
+ // events both count — they prove the upstream is past whatever
370
+ // dialog or initialisation may have been blocking input.
371
+ if (ev.type === 'assistant-region-entered'
372
+ || ev.type === 'assistant-text'
373
+ || ev.type === 'spinner') {
374
+ assistantActivitySeen = true;
375
+ }
376
+ if (ev.type === 'prompt-box-shown' && !promptBoxReady) {
377
+ promptBoxReady = true;
378
+ promptBoxResolve();
379
+ }
380
+ detector.onEvent(ev);
381
+ if (typeof req.onEvent === 'function') {
382
+ try { req.onEvent(ev); } catch (e) { this._logDebug(`onEvent threw: ${e.message}`); }
383
+ }
384
+ }
385
+ };
386
+ let assistantActivitySeen = false;
387
+ let promptBoxReady = false;
388
+ let promptBoxResolve;
389
+ const promptBoxReadyPromise = new Promise((r) => { promptBoxResolve = r; });
390
+ session.on('data', dataHandler);
391
+
392
+ // If the upstream process exits before we've reached a completion
393
+ // decision, treat it as an error and short-circuit the detector. This
394
+ // typically happens when a forwarded flag was rejected by `claude`.
395
+ const exitHandler = (info) => {
396
+ this._logDebug(`session exited code=${info?.exitCode} signal=${info?.signal}`);
397
+ detector.cancel('upstream-exited');
398
+ };
399
+ session.once('exit', exitHandler);
400
+
401
+ let detachAbort = () => {};
402
+ if (req.abortSignal) {
403
+ const onAbort = () => detector.cancel();
404
+ req.abortSignal.addEventListener('abort', onAbort, { once: true });
405
+ detachAbort = () => req.abortSignal.removeEventListener('abort', onAbort);
406
+ }
407
+
408
+ // Interactive-dialog watcher. The upstream claude TUI can ask
409
+ // questions that block prompt input — most commonly the "Quick safety
410
+ // check: Is this a project you trust?" folder-trust dialog when the
411
+ // cwd has never been confirmed. PTY automation cannot answer these
412
+ // unless we recognise the dialog. We auto-accept folder-trust ONLY
413
+ // when explicitly opted in (OCP_AUTO_ACCEPT_TRUST=1 or req
414
+ // .autoAcceptFolderTrust); otherwise we abort fast with a clear
415
+ // completion reason rather than letting the user stare at a silent
416
+ // timeout. Unknown dialogs short-circuit with `interactive-required`
417
+ // so the CLI can surface an actionable error.
418
+ const autoAcceptTrust = process.env.OCP_AUTO_ACCEPT_TRUST === '1'
419
+ || req.autoAcceptFolderTrust === true;
420
+ const TRUST_PATTERNS = [
421
+ /Quick safety check/i,
422
+ /Is this a project you (?:created|trust)/i,
423
+ /trust this folder/i,
424
+ ];
425
+ const TRUST_SETTLE_MS = this.opts.trustSettleMs
426
+ ?? numberFromEnv('OCP_TRUST_SETTLE_MS')
427
+ ?? 5000;
428
+ let dialogState = 'none'; // 'none' | 'trust-accepted' | 'trust-blocked' | 'unknown-blocked'
429
+ let dialogScanBuf = '';
430
+
431
+ const dialogWatchHandler = (chunk) => {
432
+ if (dialogState !== 'none') return;
433
+ dialogScanBuf += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
434
+ if (dialogScanBuf.length > 16384) dialogScanBuf = dialogScanBuf.slice(-8192);
435
+ if (TRUST_PATTERNS.some((p) => p.test(dialogScanBuf))) {
436
+ if (autoAcceptTrust) {
437
+ dialogState = 'trust-accepted';
438
+ this._logDebug('trust dialog detected — auto-accepting (OCP_AUTO_ACCEPT_TRUST=1)');
439
+ // Slight delay so claude finishes rendering the dialog before
440
+ // it samples our \r.
441
+ setTimeout(() => { try { session.write('\r'); } catch {} }, 200);
442
+ } else {
443
+ dialogState = 'trust-blocked';
444
+ this._logDebug('trust dialog detected — aborting (set OCP_AUTO_ACCEPT_TRUST=1 to auto-accept)');
445
+ detector.cancel('trust-required');
446
+ }
447
+ }
448
+ };
449
+ session.on('data', dialogWatchHandler);
450
+
451
+ // Warm reuse: PTY is already running and showing the prompt — just a
452
+ // short settle delay. Fresh spawn: wait for the TUI to fully initialise
453
+ // before sending the prompt (avoids race with the welcome render).
454
+ await sleep(warmReuse ? this.opts.reuseWarmupMs : this.opts.warmupMs);
455
+
456
+ // If a folder-trust dialog was auto-accepted during warm-up, give the
457
+ // TUI extra time to transition to the main prompt box before we send
458
+ // anything — otherwise our keystrokes land in a half-rendered UI and
459
+ // get swallowed.
460
+ if (dialogState === 'trust-accepted') {
461
+ this._logDebug(`settling ${TRUST_SETTLE_MS}ms after trust accept`);
462
+ await sleep(TRUST_SETTLE_MS);
463
+ }
464
+
465
+ // First-response watchdog. After we have written the prompt, the
466
+ // upstream should produce at least a spinner or a `⏺` region marker
467
+ // within a few seconds — those are the cheapest signals that the
468
+ // model received the input and started working. If neither arrives
469
+ // within firstResponseMs the upstream is almost certainly waiting on
470
+ // an interactive prompt we don't recognise (folder-trust we missed,
471
+ // tool-permission ask, MCP auth, login expiry, an unknown new
472
+ // dialog). Fail fast with `interactive-required` instead of stalling
473
+ // silently until `maxResponseMs` so the caller gets an actionable
474
+ // error and the current PTY screen to read.
475
+ const FIRST_RESPONSE_MS = this.opts.firstResponseMs
476
+ ?? numberFromEnv('OCP_FIRST_RESPONSE_MS')
477
+ ?? 20_000;
478
+ let firstResponseTimer = null;
479
+
480
+ if (session.state === 'dead') {
481
+ // Upstream already exited during warm-up; detector has been cancelled.
482
+ } else {
483
+ // Don't fire blindly at fixed warmup. Wait for the parser to
484
+ // emit `prompt-box-shown` (anchored to the `❯` chevron, so it
485
+ // never fires on the welcome banner's border). In environments
486
+ // with heavy hook / MCP / rule loading, the input box may take
487
+ // 5-10 s after spawn to appear; the timeout below is the fallback
488
+ // for the unusual case where the chevron never lands. Without
489
+ // this wait, our prompt arrives during the welcome screen and
490
+ // most of it gets swallowed (only the tail makes it into the box).
491
+ const PROMPT_BOX_WAIT_MS = this.opts.promptBoxWaitMs
492
+ ?? numberFromEnv('OCP_PROMPT_BOX_WAIT_MS')
493
+ ?? 15_000;
494
+ const PROMPT_BOX_SETTLE_MS = this.opts.promptBoxSettleMs
495
+ ?? numberFromEnv('OCP_PROMPT_BOX_SETTLE_MS')
496
+ ?? 400;
497
+ let promptBoxTimeoutHit = false;
498
+ await Promise.race([
499
+ promptBoxReadyPromise,
500
+ sleep(PROMPT_BOX_WAIT_MS).then(() => { promptBoxTimeoutHit = true; }),
501
+ ]);
502
+ if (promptBoxTimeoutHit && !promptBoxReady) {
503
+ this._logDebug(`prompt-box-shown not seen within ${PROMPT_BOX_WAIT_MS}ms — sending anyway`);
504
+ } else if (promptBoxReady && PROMPT_BOX_SETTLE_MS > 0) {
505
+ // Small settle so an animating chevron / cursor blink doesn't
506
+ // race our first keystroke.
507
+ await sleep(PROMPT_BOX_SETTLE_MS);
508
+ }
509
+
510
+ // Sentinel marker appended to every user turn so PTY automation
511
+ // can tell when the reply is done. Phrased to be inert to model
512
+ // behaviour — do NOT use words like "finish", "complete",
513
+ // "wrap up", or "final" that nudge the model to cut tool use
514
+ // short. The reply itself is the actual signal of completion;
515
+ // this marker is just the bookkeeping byte we emit afterwards.
516
+ // Single-line form because `\n` flips claude TUI into multi-line
517
+ // edit mode where `\r` no longer submits.
518
+ const instruction =
519
+ ` (Append the literal token ${sentinel} on its own line at the very` +
520
+ ' end of your reply. Automation glue — does not constrain how you' +
521
+ ' answer above; use tools as freely and thoroughly as you would' +
522
+ ' without this marker.)';
523
+ try {
524
+ session.write(req.prompt + instruction);
525
+ session.write('\r');
526
+ firstResponseTimer = setTimeout(() => {
527
+ if (!assistantActivitySeen) {
528
+ this._logDebug(`no assistant activity for ${FIRST_RESPONSE_MS}ms — interactive prompt suspected`);
529
+ detector.cancel('interactive-required');
530
+ }
531
+ }, FIRST_RESPONSE_MS);
532
+ if (firstResponseTimer.unref) firstResponseTimer.unref();
533
+ } catch (e) {
534
+ this._logDebug(`write failed: ${e.message}`);
535
+ detector.cancel('write-failed');
536
+ }
537
+ }
538
+
539
+ const completion = await detector.done();
540
+ if (firstResponseTimer) clearTimeout(firstResponseTimer);
541
+
542
+ // Detach the request-scoped listeners so the session can be safely
543
+ // reused by the next acquirer (when pooled) or cleanly killed
544
+ // (when not). Without this, a recycled session would keep delivering
545
+ // events into THIS request's buffers.
546
+ session.off('data', dataHandler);
547
+ session.off('data', dialogWatchHandler);
548
+ session.off('exit', exitHandler);
549
+ detachAbort();
550
+
551
+ // A cancelled or dialog-blocked session may be sitting at a prompt
552
+ // we never resolved. Returning it to the pool would let the next
553
+ // request reuse a PTY whose UI state is dirty. Force the non-pooled
554
+ // tear-down path in those cases.
555
+ //
556
+ // `timeout` and `write-failed` are also dirty — the PTY is stuck
557
+ // mid-response (probably mid-tool-call or mid-render), and parking
558
+ // it would make the very next pool acquire reuse a hung PTY and
559
+ // see the same timeout. That was the root cause of the user-visible
560
+ // "sometimes the second `ocp` call hangs forever" pattern when
561
+ // running several `ocp` calls in parallel in the same cwd: one PTY
562
+ // timed out, got parked dirty, and every subsequent acquire of
563
+ // that pool slot inherited the hang.
564
+ const dirty = completion.reason === 'cancelled'
565
+ || completion.reason === 'trust-required'
566
+ || completion.reason === 'interactive-required'
567
+ || completion.reason === 'timeout'
568
+ || completion.reason === 'write-failed';
569
+ if (poolKey && session.state !== 'dead' && !dirty) {
570
+ // Pooled path — park for reuse. Context is preserved (no /clear).
571
+ await pool.release(session, poolKey, capturedSessionId);
572
+ } else {
573
+ // Non-pooled path — best-effort graceful exit so the upstream CLI
574
+ // gets a chance to print its end-of-session banner. We try a single
575
+ // Ctrl-D and wait briefly; if it does not exit we move on. The
576
+ // session-id is captured via the filesystem fallback below either
577
+ // way, so there is no need to block long here.
578
+ if (session.state !== 'dead') {
579
+ try { session.write('\x04'); } catch {}
580
+ await new Promise((resolve) => {
581
+ const t = setTimeout(resolve, 400);
582
+ session.once('exit', () => { clearTimeout(t); resolve(); });
583
+ });
584
+ }
585
+ await session.kill();
586
+ }
587
+
588
+ // After the session is fully terminated (or released), do one last
589
+ // scan of the accumulated stripped buffer for a session-id banner.
590
+ if (!capturedSessionId) {
591
+ const m = strippedBuffer.match(
592
+ /claude\s+--resume\s+([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/,
593
+ );
594
+ if (m) capturedSessionId = m[1];
595
+ }
596
+
597
+ // Filesystem fallback: the upstream CLI persists each session to
598
+ // `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`. If we never saw a
599
+ // banner — common when the request was cut short and `claude` did not
600
+ // print its exit hint — find the file whose mtime advanced during
601
+ // THIS request and take its filename as the session id.
602
+ if (!capturedSessionId) {
603
+ try {
604
+ capturedSessionId = await findRecentSessionId(cwd, startTime, sessionFilesBeforeSpawn);
605
+ } catch (e) {
606
+ this._logDebug(`session-id fs fallback failed: ${e.message}`);
607
+ }
608
+ }
609
+
610
+ // Buffer-scan is primary: finds text between the LAST ⏺ marker and the
611
+ // LAST sentinel occurrence, which is always Claude's complete final
612
+ // response regardless of how many partial re-renders the TUI produced.
613
+ // Event-based extraction is the fallback (used when the buffer scan finds
614
+ // nothing, e.g. if the PTY session was too short to accumulate a sentinel).
615
+ const text =
616
+ extractAssistantText(strippedBuffer, nonce) ||
617
+ extractAssistantTextFromEvents(events);
618
+
619
+ if (this.opts.debug) {
620
+ const marker = TUI_PATTERNS.assistantRegionMarker;
621
+ const rIdx = strippedBuffer.indexOf(marker);
622
+ const sIdx = strippedBuffer.indexOf(`⟦OCP_END:${nonce}⟧`);
623
+ this._logDebug(
624
+ `extract: regionIdx=${rIdx} sentinelIdx=${sIdx} ` +
625
+ `stripLen=${strippedBuffer.length} ` +
626
+ `sentinelEvents=${events.filter(e=>e.type==='sentinel').length} ` +
627
+ `assistantText=${events.filter(e=>e.type==='assistant-text').length} ` +
628
+ `sid=${capturedSessionId}`,
629
+ );
630
+ }
631
+
632
+ // For stall-style failures, capture the tail of the stripped PTY
633
+ // buffer so the caller can see exactly what claude was rendering
634
+ // when we gave up — that is usually the dialog or error that the
635
+ // user needs to act on manually.
636
+ const stalledReasons = new Set(['interactive-required', 'trust-required', 'timeout']);
637
+ let stalledOutputTail;
638
+ if (stalledReasons.has(completion.reason)) {
639
+ stalledOutputTail = strippedBuffer
640
+ .split('\n')
641
+ .map((l) => l.replace(/\s+$/, ''))
642
+ .filter((l) => l.length > 0)
643
+ .slice(-24)
644
+ .join('\n');
645
+ }
646
+
647
+ return {
648
+ sessionId: capturedSessionId,
649
+ text,
650
+ events,
651
+ exitCode: completion.isError ? 1 : 0,
652
+ isError: completion.isError,
653
+ completionReason: completion.reason,
654
+ cost: { totalUsd: null, numTurns: null },
655
+ durationMs: Date.now() - startTime,
656
+ diagnostics: { rawBytes, strippedBytes: strippedBuffer.length, stalledOutputTail, eventsTruncated, strippedTruncated },
657
+ };
658
+ }
659
+
660
+ async close() {
661
+ if (this._pool) {
662
+ await this._pool.close();
663
+ this._pool = null;
664
+ }
665
+ }
666
+
667
+ _logDebug(msg) {
668
+ if (this.opts.debug) {
669
+ process.stderr.write(`[ocp] ${msg}\n`);
670
+ }
671
+ }
672
+ }
673
+
674
+ // ── helpers ────────────────────────────────────────────────────────────
675
+
676
+ function numberFromEnv(name, fallback) {
677
+ const v = process.env[name];
678
+ if (v === undefined || v === '') return fallback;
679
+ const n = Number(v);
680
+ // Accept only positive finite values — `OCP_TRUST_SETTLE_MS=0`,
681
+ // `OCP_FIRST_RESPONSE_MS=-1`, etc. would silently disable the
682
+ // watchdog / collapse the wait, masking misconfiguration as fast
683
+ // hangs or premature timeouts. Fall back to the documented default.
684
+ return (Number.isFinite(n) && n > 0) ? n : fallback;
685
+ }
686
+
687
+ /**
688
+ * Coerce a caller-supplied option value to a finite non-negative number,
689
+ * falling back when it's missing/garbage. Lets the constructor accept
690
+ * untrusted `opts.maxEvents`, `opts.firstResponseMs`, etc. without
691
+ * silently letting NaN / strings / `-1` propagate to the math sites
692
+ * (which would corrupt `events.splice(0, events.length - (MAX>>1))` into
693
+ * unbounded growth or empty-array modes).
694
+ */
695
+ function finiteNonNeg(v, fallback) {
696
+ if (v === undefined || v === null) return fallback;
697
+ const n = Number(v);
698
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
699
+ }
700
+
701
+ function sleep(ms) {
702
+ return new Promise((r) => setTimeout(r, ms));
703
+ }
704
+
705
+ /**
706
+ * Build upstream `claude` argv from the request object by walking
707
+ * OPTION_SPEC and emitting each entry whose forward strategy is `argv`.
708
+ * Pass-through tokens from the CLI's unknown-flag list are appended last.
709
+ */
710
+ function buildSpawnArgs(req) {
711
+ const args = [];
712
+ for (const spec of OPTION_SPEC) {
713
+ if (spec.forward?.type !== 'argv') continue;
714
+ const value = req[fieldNameOf(spec)];
715
+ if (value === undefined || value === null || value === false) continue;
716
+ if (spec.kind === 'boolean') {
717
+ if (value === true) args.push(spec.forward.flag);
718
+ } else if (spec.kind === 'array') {
719
+ if (Array.isArray(value) && value.length > 0) {
720
+ args.push(spec.forward.flag, ...value.map(String));
721
+ }
722
+ } else {
723
+ args.push(spec.forward.flag, String(value));
724
+ }
725
+ }
726
+ if (Array.isArray(req.passThroughArgv) && req.passThroughArgv.length > 0) {
727
+ const { sanitized } = sanitizePassThroughArgv(req.passThroughArgv);
728
+ args.push(...sanitized);
729
+ }
730
+ return args;
731
+ }
732
+
733
+ /**
734
+ * Map a spec entry to the corresponding camelCase field on the request
735
+ * object. This is the convention the library API uses (e.g. spec name
736
+ * 'allowed-tools' -> req.allowedTools).
737
+ */
738
+ function fieldNameOf(spec) {
739
+ return spec.name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
740
+ }
741
+
742
+ /**
743
+ * List session-id files that already exist in the project's session dir
744
+ * BEFORE this request runs. Used as a "before" snapshot so the
745
+ * post-request fallback only considers files that didn't exist
746
+ * previously (or whose mtime moved during this request from a known
747
+ * baseline).
748
+ *
749
+ * @param {string|undefined} cwd
750
+ * @returns {Promise<Map<string, number>>} filename -> previous mtimeMs
751
+ */
752
+ async function listSessionFiles(cwd) {
753
+ const absCwd = path.resolve(cwd ?? process.cwd());
754
+ // Upstream encodes BOTH `/` and `_` as `-`, so a cwd containing
755
+ // underscores (`gen_keypair`) maps to `gen-keypair`. The `/`-only
756
+ // replacement silently looks in the wrong dir for those projects.
757
+ const encoded = absCwd.replace(/[/_]/g, '-');
758
+ const dir = path.join(os.homedir(), '.claude', 'projects', encoded);
759
+ const out = new Map();
760
+ let entries;
761
+ try { entries = await readdir(dir); } catch { return out; }
762
+ for (const name of entries) {
763
+ if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.jsonl$/.test(name)) continue;
764
+ try {
765
+ const st = await stat(path.join(dir, name));
766
+ out.set(name, st.mtimeMs);
767
+ } catch { /* ignore */ }
768
+ }
769
+ return out;
770
+ }
771
+
772
+ /**
773
+ * Locate the session id from claude's on-disk session log.
774
+ *
775
+ * The upstream CLI persists each session to
776
+ * `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`
777
+ * where `<encoded-cwd>` is the absolute cwd with `/` replaced by `-`.
778
+ * We accept a file as "ours" when:
779
+ * (a) it did not exist in `before` (NEW file created during our run), OR
780
+ * (b) it existed in `before` AND its mtime moved during our run
781
+ * (resume-style append).
782
+ * This avoids picking up an unrelated neighbour session created by
783
+ * another claude process that happened to finish around the same time.
784
+ *
785
+ * @param {string|undefined} cwd
786
+ * @param {number} since epoch-ms taken at the start of the request
787
+ * @param {Map<string,number>} before baseline filename -> mtimeMs from listSessionFiles
788
+ * @returns {Promise<string|null>}
789
+ */
790
+ async function findRecentSessionId(cwd, since, before) {
791
+ const absCwd = path.resolve(cwd ?? process.cwd());
792
+ // Upstream encodes BOTH `/` and `_` as `-`, so a cwd containing
793
+ // underscores (`gen_keypair`) maps to `gen-keypair`. The `/`-only
794
+ // replacement silently looks in the wrong dir for those projects.
795
+ const encoded = absCwd.replace(/[/_]/g, '-');
796
+ const dir = path.join(os.homedir(), '.claude', 'projects', encoded);
797
+ let entries;
798
+ try { entries = await readdir(dir); } catch { return null; }
799
+ const threshold = since - 1500;
800
+ let best = null;
801
+ let bestMtime = 0;
802
+ for (const name of entries) {
803
+ if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.jsonl$/.test(name)) continue;
804
+ let st;
805
+ try { st = await stat(path.join(dir, name)); } catch { continue; }
806
+ if (st.mtimeMs < threshold) continue;
807
+ const baseline = before?.get(name);
808
+ if (baseline !== undefined && st.mtimeMs <= baseline + 100) continue; // existed before, didn't move
809
+ if (st.mtimeMs > bestMtime) {
810
+ bestMtime = st.mtimeMs;
811
+ best = name.replace(/\.jsonl$/, '');
812
+ }
813
+ }
814
+ return best;
815
+ }
816
+
817
+ /**
818
+ * Preferred extraction path — operates on parsed events.
819
+ *
820
+ * The line-aware tui-frame parser tags each assistant-text event with the
821
+ * region number it came from. For a fresh session the buffer has one
822
+ * region (the response). For `--resume` the buffer can contain history
823
+ * regions plus the current response; the current response is always the
824
+ * HIGHEST region number, so we filter on that.
825
+ *
826
+ * @param {Array<{type:string,text?:string,region?:number}>} events
827
+ * @returns {string} empty string when no assistant-text events were seen
828
+ */
829
+ function extractAssistantTextFromEvents(events) {
830
+ let maxRegion = 0;
831
+ for (const e of events) {
832
+ if (e.type === 'assistant-text' && typeof e.region === 'number') {
833
+ if (e.region > maxRegion) maxRegion = e.region;
834
+ }
835
+ }
836
+ if (maxRegion === 0) return '';
837
+ const lines = [];
838
+ for (const e of events) {
839
+ if (e.type === 'assistant-text' && e.region === maxRegion) {
840
+ lines.push(e.text ?? '');
841
+ }
842
+ }
843
+ return lines.join('\n').replace(/\n+$/, '').trim();
844
+ }
845
+
846
+ /**
847
+ * Fallback extraction — buffer-scan when no assistant-text events arrived.
848
+ *
849
+ * The upstream TUI renders the assistant response on the line that begins
850
+ * with the `⏺` marker glyph. For a fresh session the first `⏺` is the
851
+ * current response; for a `--resume` session the upstream re-renders prior
852
+ * conversation history so the buffer can contain multiple `⏺` markers AND
853
+ * multiple occurrences of the literal sentinel string (prompt echo + the
854
+ * model's real response).
855
+ *
856
+ * The robust selection rule for both cases:
857
+ * 1. Find the LAST occurrence of the sentinel string — it must be the
858
+ * model's response (echoes are always BEFORE the actual response in
859
+ * arrival order, so the byte-position of the last occurrence in the
860
+ * accumulated buffer corresponds to the real response output).
861
+ * 2. Find the LAST `⏺` that appears BEFORE that sentinel — that is the
862
+ * response's region marker for the current turn.
863
+ * 3. Slice between them and trim.
864
+ *
865
+ * @param {string} stripped
866
+ * @param {string} nonce
867
+ */
868
+ function extractAssistantText(stripped, nonce) {
869
+ const sentinel = `⟦OCP_END:${nonce}⟧`;
870
+ const marker = TUI_PATTERNS.assistantRegionMarker;
871
+
872
+ let sentinelIdx = -1;
873
+ for (let i = stripped.indexOf(sentinel);
874
+ i !== -1;
875
+ i = stripped.indexOf(sentinel, i + sentinel.length)) {
876
+ sentinelIdx = i;
877
+ }
878
+ if (sentinelIdx === -1) {
879
+ // Fall back to "everything after the first ⏺" — this preserves the
880
+ // legacy behavior when the model dropped the sentinel.
881
+ const ri = stripped.indexOf(marker);
882
+ return ri === -1 ? '' : stripped.slice(ri + marker.length).trim();
883
+ }
884
+
885
+ let regionIdx = -1;
886
+ for (let j = stripped.indexOf(marker);
887
+ j !== -1 && j < sentinelIdx;
888
+ j = stripped.indexOf(marker, j + marker.length)) {
889
+ regionIdx = j;
890
+ }
891
+ if (regionIdx === -1) {
892
+ return stripped.slice(0, sentinelIdx).trim();
893
+ }
894
+ return stripped.slice(regionIdx + marker.length, sentinelIdx).trim();
895
+ }
896
+
897
+ /**
898
+ * @typedef {object} OneShotResult
899
+ * @property {string|null} sessionId
900
+ * @property {string} text
901
+ * @property {Array<object>} events
902
+ * @property {number} exitCode
903
+ * @property {boolean} isError
904
+ * @property {string} completionReason
905
+ * @property {{ totalUsd: number|null, numTurns: number|null }} cost
906
+ * @property {number} durationMs
907
+ * @property {{ rawBytes: number, strippedBytes: number }} diagnostics
908
+ */