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/LICENSE +21 -0
- package/README.ja.md +708 -0
- package/README.ko.md +713 -0
- package/README.md +850 -0
- package/README.zh.md +708 -0
- package/bin/cli.js +782 -0
- package/package.json +68 -0
- package/scripts/postinstall.js +60 -0
- package/src/chat/event-filters.js +116 -0
- package/src/chat/index.js +1225 -0
- package/src/completion/detector.js +163 -0
- package/src/daemon/client.js +172 -0
- package/src/daemon/server.js +267 -0
- package/src/daemon/socket.js +78 -0
- package/src/index.js +908 -0
- package/src/options/index.js +4 -0
- package/src/options/parse-argv.js +214 -0
- package/src/options/spec.js +519 -0
- package/src/options/validate.js +104 -0
- package/src/output/index.js +8 -0
- package/src/output/json.js +83 -0
- package/src/output/registry.js +35 -0
- package/src/output/stream-json.js +111 -0
- package/src/output/text.js +94 -0
- package/src/parsers/ansi-strip.js +94 -0
- package/src/parsers/index.js +8 -0
- package/src/parsers/pipeline.js +50 -0
- package/src/parsers/registry.js +43 -0
- package/src/parsers/sentinel.js +41 -0
- package/src/parsers/tui-frame.js +256 -0
- package/src/print-mode.js +214 -0
- package/src/pty/index.js +3 -0
- package/src/pty/pool.js +127 -0
- package/src/pty/session.js +88 -0
- package/src/session-log.js +124 -0
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
|
+
*/
|