open-claude-p 1.1.3 → 1.2.2
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/CHANGELOG.md +342 -0
- package/README.ja.md +5 -5
- package/README.ko.md +5 -5
- package/README.md +10 -5
- package/README.zh.md +5 -5
- package/bin/cli.js +117 -55
- package/package.json +1 -1
- package/src/chat/index.js +88 -3
- package/src/index.js +97 -12
- package/src/options/forward.js +70 -0
- package/src/options/index.js +1 -0
- package/src/options/parse-argv.js +73 -2
- package/src/options/spec.js +146 -6
- package/src/options/validate.js +41 -1
- package/src/parsers/ansi-strip.js +149 -42
package/bin/cli.js
CHANGED
|
@@ -29,6 +29,7 @@ process.on('uncaughtException', (e) => {
|
|
|
29
29
|
import { parseArgv } from '../src/options/parse-argv.js';
|
|
30
30
|
import { validate } from '../src/options/validate.js';
|
|
31
31
|
import { OPTION_SPEC } from '../src/options/spec.js';
|
|
32
|
+
import { forwardedRequestFields } from '../src/options/forward.js';
|
|
32
33
|
import { createDriver } from '../src/index.js';
|
|
33
34
|
import { sendToDaemon } from '../src/daemon/client.js';
|
|
34
35
|
import { daemonKey, socketPath, resolveCwd } from '../src/daemon/socket.js';
|
|
@@ -64,8 +65,29 @@ const EXIT = {
|
|
|
64
65
|
NOT_IMPLEMENTED: 8,
|
|
65
66
|
};
|
|
66
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Scrub TUI chrome out of PTY-extracted text.
|
|
70
|
+
*
|
|
71
|
+
* Only for text reconstructed from terminal frames. Text read from the
|
|
72
|
+
* upstream session JSONL is already clean and must be passed through
|
|
73
|
+
* untouched. Failures are swallowed: a scrub that throws must never cost
|
|
74
|
+
* the caller their answer.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @returns {Promise<string>}
|
|
78
|
+
*/
|
|
79
|
+
async function scrubPtyText(text) {
|
|
80
|
+
if (typeof text !== 'string' || text === '') return text;
|
|
81
|
+
try {
|
|
82
|
+
const { cleanResponse } = await import('../src/chat/index.js');
|
|
83
|
+
return cleanResponse(text);
|
|
84
|
+
} catch {
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
67
89
|
async function main() {
|
|
68
|
-
const { options, positional, unknown, errors } = parseArgv(process.argv.slice(2));
|
|
90
|
+
const { options, positional, unknown, errors, supplied } = parseArgv(process.argv.slice(2));
|
|
69
91
|
|
|
70
92
|
if (errors.length > 0) {
|
|
71
93
|
process.stderr.write(errors.map((e) => `error: ${e}`).join('\n') + '\n');
|
|
@@ -95,12 +117,47 @@ async function main() {
|
|
|
95
117
|
// retain the safer default (`false`) — this flip only changes the
|
|
96
118
|
// CLI default. The opt-in `OCP_DEFAULT_SKIP_PERMS=1` from 1.0 is
|
|
97
119
|
// still honoured but is now a no-op (the default already matches it).
|
|
120
|
+
//
|
|
121
|
+
// `--restricted` is the one case where the permissive default must
|
|
122
|
+
// stand down. Upstream refuses the combination outright ("Error:
|
|
123
|
+
// bypassPermissions not supported in restricted mode") and exits, so
|
|
124
|
+
// leaving the default on would make `ocp --restricted` fail every
|
|
125
|
+
// time with no output. Asking for restricted mode IS an explicit
|
|
126
|
+
// choice to keep the permission gates, so we honour it.
|
|
127
|
+
//
|
|
128
|
+
// NOTE on the `supplied` check: every option with a `default` is
|
|
129
|
+
// pre-seeded into `options`, so `options[x] === false` is ALSO true
|
|
130
|
+
// when the user passed nothing at all. Testing the value alone made
|
|
131
|
+
// this opt-out fire on every single invocation, which quietly turned
|
|
132
|
+
// the whole block into dead code — the default-on behaviour was in
|
|
133
|
+
// fact coming from `runOneShot`'s `?? true`, and an explicit
|
|
134
|
+
// `--dangerously-skip-permissions=false` was being ignored. Only a
|
|
135
|
+
// flag actually present in argv counts as an opt-out.
|
|
98
136
|
const skipPermsOptOut = process.env.OCP_NO_SKIP_PERMS === '1'
|
|
99
137
|
|| process.env.OCP_NO_SKIP_PERMS === 'true'
|
|
100
|
-
||
|
|
138
|
+
|| (supplied.has('dangerously-skip-permissions')
|
|
139
|
+
&& options['dangerously-skip-permissions'] === false)
|
|
140
|
+
|| options.restricted === true;
|
|
101
141
|
if (options['dangerously-skip-permissions'] !== true && !skipPermsOptOut) {
|
|
102
142
|
options['dangerously-skip-permissions'] = true;
|
|
103
143
|
}
|
|
144
|
+
if (options.restricted === true && options['dangerously-skip-permissions'] === true) {
|
|
145
|
+
process.stderr.write(
|
|
146
|
+
'error: `--restricted` cannot be combined with ' +
|
|
147
|
+
'`--dangerously-skip-permissions` — the upstream CLI refuses ' +
|
|
148
|
+
'bypassPermissions in restricted mode.\n',
|
|
149
|
+
);
|
|
150
|
+
return EXIT.VALIDATION_ERROR;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// With the bypass gone, something still has to answer permission
|
|
154
|
+
// prompts, and PTY automation cannot. `--permission-prompts=none`
|
|
155
|
+
// denies anything that would prompt instead of hanging on it, which
|
|
156
|
+
// is the only setting that keeps a restricted run headless. The
|
|
157
|
+
// caller can still ask for `host` explicitly.
|
|
158
|
+
if (options.restricted === true && options['permission-prompts'] === undefined) {
|
|
159
|
+
options['permission-prompts'] = 'none';
|
|
160
|
+
}
|
|
104
161
|
|
|
105
162
|
// CLI-only default: honour `OCP_NO_SESSION_PERSISTENCE=1` when the user
|
|
106
163
|
// has not explicitly set `--no-session-persistence`. Stateless
|
|
@@ -116,12 +173,13 @@ async function main() {
|
|
|
116
173
|
}
|
|
117
174
|
|
|
118
175
|
// CLI-only default: pre-approve the read-only network tools so that a
|
|
119
|
-
// plain `ocp "
|
|
120
|
-
// silently no-op'ing with "I can't access
|
|
121
|
-
// safe-by-construction (no filesystem
|
|
122
|
-
// headless tool that refuses to fetch the
|
|
123
|
-
// surface is the wrong default. Caller-supplied
|
|
124
|
-
// takes precedence (additive — we merge rather than
|
|
176
|
+
// plain `ocp "what is the weather today"` actually uses WebSearch /
|
|
177
|
+
// WebFetch instead of silently no-op'ing with "I can't access
|
|
178
|
+
// real-time info". These are safe-by-construction (no filesystem
|
|
179
|
+
// mutation, no shell exec); a headless tool that refuses to fetch the
|
|
180
|
+
// web for an automation surface is the wrong default. Caller-supplied
|
|
181
|
+
// `--allowed-tools` takes precedence (additive — we merge rather than
|
|
182
|
+
// replace).
|
|
125
183
|
// Set OCP_NO_DEFAULT_TOOLS=1 to opt out entirely.
|
|
126
184
|
if (process.env.OCP_NO_DEFAULT_TOOLS !== '1') {
|
|
127
185
|
const DEFAULT_SAFE_TOOLS = ['WebSearch', 'WebFetch'];
|
|
@@ -144,10 +202,11 @@ async function main() {
|
|
|
144
202
|
|
|
145
203
|
// CLI-only default: encourage tool use for current/time-sensitive
|
|
146
204
|
// queries via the same SDK-wide DEFAULT_APPEND_SYSTEM_PROMPT the
|
|
147
|
-
// chat client already uses. Without this, a plain
|
|
148
|
-
// refuses with "I can't access
|
|
149
|
-
// WebSearch — because Claude's
|
|
150
|
-
// toward declining over tool-use
|
|
205
|
+
// chat client already uses. Without this, a plain prompt such as
|
|
206
|
+
// `ocp "what is the weather today"` refuses with "I can't access
|
|
207
|
+
// real-time data" instead of calling WebSearch — because Claude's
|
|
208
|
+
// print-mode default behaviour leans toward declining over tool-use
|
|
209
|
+
// when it can answer with disclaimers.
|
|
151
210
|
// This is a generic, ONE-LINE rule ("use tools when you need to look
|
|
152
211
|
// something up"), not tool-by-tool guidance, so it stays within the
|
|
153
212
|
// "common rules" policy. Caller-supplied `--append-system-prompt`
|
|
@@ -248,19 +307,18 @@ async function main() {
|
|
|
248
307
|
// regardless of what the requester intended. Daemon rejects on
|
|
249
308
|
// mismatch.
|
|
250
309
|
claudeBin: process.env.OCP_CLAUDE_BIN || 'claude',
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
310
|
+
...forwardedRequestFields(options),
|
|
311
|
+
// Always a concrete boolean. `forwardedRequestFields` omits `false`
|
|
312
|
+
// (there is no argv to emit for it), but `runOneShot` resolves a
|
|
313
|
+
// missing field with `?? true` — so omitting it would silently
|
|
314
|
+
// re-enable the permission bypass the caller just opted out of via
|
|
315
|
+
// `OCP_NO_SKIP_PERMS=1`, `--dangerously-skip-permissions=false`, or
|
|
316
|
+
// `--restricted`.
|
|
317
|
+
dangerouslySkipPermissions: options['dangerously-skip-permissions'] === true,
|
|
318
|
+
// Shim-enforced options: no upstream flag, handled by the driver.
|
|
260
319
|
maxTurns: options['max-turns'],
|
|
261
320
|
maxBudgetUsd: options['max-budget-usd'],
|
|
262
321
|
taskBudget: options['task-budget'],
|
|
263
|
-
noSessionPersistence: options['no-session-persistence'],
|
|
264
322
|
passThroughArgv: unknown,
|
|
265
323
|
};
|
|
266
324
|
|
|
@@ -272,8 +330,10 @@ async function main() {
|
|
|
272
330
|
process.stdout,
|
|
273
331
|
);
|
|
274
332
|
for (const ev of (result.events ?? [])) adapter.onEvent(ev);
|
|
333
|
+
// Daemon results are always PTY-extracted — the daemon does not read
|
|
334
|
+
// the upstream session file — so they need the same scrub.
|
|
275
335
|
adapter.end({
|
|
276
|
-
text: result.text,
|
|
336
|
+
text: await scrubPtyText(result.text),
|
|
277
337
|
isError: result.isError,
|
|
278
338
|
sessionId: result.sessionId,
|
|
279
339
|
cost: result.cost,
|
|
@@ -328,21 +388,17 @@ async function main() {
|
|
|
328
388
|
prompt,
|
|
329
389
|
cwd: options.cwd,
|
|
330
390
|
outputFormat,
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
continue: options.continue,
|
|
340
|
-
resume: options.resume,
|
|
341
|
-
forkSession: options['fork-session'],
|
|
342
|
-
noSessionPersistence: options['no-session-persistence'],
|
|
343
|
-
sessionId: options['session-id'],
|
|
391
|
+
...forwardedRequestFields(options, { printMode: true }),
|
|
392
|
+
// Always a concrete boolean. `forwardedRequestFields` omits `false`
|
|
393
|
+
// (there is no argv to emit for it), but `runOneShot` resolves a
|
|
394
|
+
// missing field with `?? true` — so omitting it would silently
|
|
395
|
+
// re-enable the permission bypass the caller just opted out of via
|
|
396
|
+
// `OCP_NO_SKIP_PERMS=1`, `--dangerously-skip-permissions=false`, or
|
|
397
|
+
// `--restricted`.
|
|
398
|
+
dangerouslySkipPermissions: options['dangerously-skip-permissions'] === true,
|
|
344
399
|
passThroughArgv: unknown,
|
|
345
400
|
abortSignal: ac.signal,
|
|
401
|
+
// Shim-enforced options: no upstream flag, handled by the driver.
|
|
346
402
|
maxTurns: options['max-turns'],
|
|
347
403
|
maxBudgetUsd: options['max-budget-usd'],
|
|
348
404
|
printSink: process.stdout,
|
|
@@ -401,25 +457,20 @@ async function runOneTurn({ driver, adapter, options, unknown, ac, prompt, resum
|
|
|
401
457
|
const result = await driver.runOneShot({
|
|
402
458
|
prompt,
|
|
403
459
|
cwd: options.cwd,
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
460
|
+
...forwardedRequestFields(options),
|
|
461
|
+
// Always a concrete boolean. `forwardedRequestFields` omits `false`
|
|
462
|
+
// (there is no argv to emit for it), but `runOneShot` resolves a
|
|
463
|
+
// missing field with `?? true` — so omitting it would silently
|
|
464
|
+
// re-enable the permission bypass the caller just opted out of via
|
|
465
|
+
// `OCP_NO_SKIP_PERMS=1`, `--dangerously-skip-permissions=false`, or
|
|
466
|
+
// `--restricted`.
|
|
467
|
+
dangerouslySkipPermissions: options['dangerously-skip-permissions'] === true,
|
|
468
|
+
// The stream-json input loop threads each turn's session id back in
|
|
469
|
+
// so turn N+1 resumes turn N; it must win over `--resume` from argv.
|
|
414
470
|
resume: resumeOverride ?? options.resume,
|
|
415
|
-
forkSession: options['fork-session'],
|
|
416
|
-
noSessionPersistence: options['no-session-persistence'],
|
|
417
|
-
resumeSessionAt: options['resume-session-at'],
|
|
418
|
-
rewindFiles: options['rewind-files'],
|
|
419
|
-
sessionId: options['session-id'],
|
|
420
|
-
name: options.name,
|
|
421
471
|
passThroughArgv: unknown,
|
|
422
472
|
abortSignal: ac.signal,
|
|
473
|
+
// Shim-enforced options: no upstream flag, handled by the driver.
|
|
423
474
|
maxTurns: options['max-turns'],
|
|
424
475
|
maxBudgetUsd: options['max-budget-usd'],
|
|
425
476
|
taskBudget: options['task-budget'],
|
|
@@ -432,19 +483,30 @@ async function runOneTurn({ driver, adapter, options, unknown, ac, prompt, resum
|
|
|
432
483
|
// alone cannot scrub; the JSONL file is what claude itself stores
|
|
433
484
|
// and contains only the assistant's message verbatim. We call
|
|
434
485
|
// readSessionText even when sessionId is null — it falls back to the
|
|
435
|
-
// most-recently-modified JSONL written during this request window
|
|
486
|
+
// most-recently-modified JSONL written during this request window,
|
|
487
|
+
// gated on the transcript actually containing the prompt we sent so a
|
|
488
|
+
// concurrent session in the same cwd cannot answer for us.
|
|
436
489
|
let finalText = result.text;
|
|
437
490
|
let usage = null;
|
|
438
491
|
let toolsFromSession = [];
|
|
492
|
+
let fromSessionFile = false;
|
|
439
493
|
if (!result.isError) {
|
|
440
494
|
try {
|
|
441
495
|
const { readSessionText } = await import('../src/chat/index.js');
|
|
442
|
-
const sessionRead = await readSessionText(
|
|
443
|
-
|
|
496
|
+
const sessionRead = await readSessionText(
|
|
497
|
+
result.sessionId, startTime, options.cwd, { expectPrompt: prompt },
|
|
498
|
+
);
|
|
499
|
+
if (sessionRead?.text) { finalText = sessionRead.text; fromSessionFile = true; }
|
|
444
500
|
if (sessionRead?.usage) usage = sessionRead.usage;
|
|
445
501
|
if (sessionRead?.tools) toolsFromSession = sessionRead.tools;
|
|
446
502
|
} catch { /* keep PTY-extracted fallback */ }
|
|
447
503
|
}
|
|
504
|
+
// The JSONL is always clean; PTY-extracted text is not. `cleanResponse`
|
|
505
|
+
// exists as the last line of defence for exactly this fallback, but the
|
|
506
|
+
// CLI never called it — only the chat client did — so a run that could
|
|
507
|
+
// not read the session file printed the TUI's spinner cell and usage
|
|
508
|
+
// meters around the answer.
|
|
509
|
+
if (!fromSessionFile) finalText = await scrubPtyText(finalText);
|
|
448
510
|
|
|
449
511
|
adapter.end({
|
|
450
512
|
text: finalText,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "open-claude-p",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "A PTY-backed compatibility shim for `claude -p` (Claude Code headless/print mode). Drives the interactive `claude` CLI via node-pty and exposes the same option surface and stream-json contract. Ships as both a library and an `ocp` CLI binary.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
package/src/chat/index.js
CHANGED
|
@@ -1092,7 +1092,11 @@ export function createChatClient(opts = {}) {
|
|
|
1092
1092
|
* @param {string} [cwd] Directory used to derive the project dir key.
|
|
1093
1093
|
* @returns {Promise<{ text: string, usage: object|null, tools: string[] } | null>}
|
|
1094
1094
|
*/
|
|
1095
|
-
export async function readSessionText(sessionId, t0 = 0, cwd = process.cwd()) {
|
|
1095
|
+
export async function readSessionText(sessionId, t0 = 0, cwd = process.cwd(), opts = {}) {
|
|
1096
|
+
// `expectPrompt` is the prompt this request actually sent. It is used
|
|
1097
|
+
// ONLY on the no-sessionId scan path below, to confirm a candidate
|
|
1098
|
+
// transcript is ours before its text is returned as our answer.
|
|
1099
|
+
const expectPrompt = typeof opts.expectPrompt === 'string' ? opts.expectPrompt : '';
|
|
1096
1100
|
// Claude encodes the project cwd into a single token used as the
|
|
1097
1101
|
// directory name under ~/.claude/projects/. The mapping replaces BOTH
|
|
1098
1102
|
// path separators (`/`) and underscores (`_`) with a literal `-`, so
|
|
@@ -1233,12 +1237,57 @@ export async function readSessionText(sessionId, t0 = 0, cwd = process.cwd()) {
|
|
|
1233
1237
|
.filter((c) => c.mtime >= t0)
|
|
1234
1238
|
.sort((a, b) => b.mtime - a.mtime);
|
|
1235
1239
|
for (const { f } of recent.slice(0, 5)) {
|
|
1236
|
-
const
|
|
1240
|
+
const full = path.join(projectDir, f);
|
|
1241
|
+
// Ownership gate. Without a sessionId the only thing separating our
|
|
1242
|
+
// transcript from a neighbour's is "most recently modified", and a
|
|
1243
|
+
// concurrently-running session in the same cwd wins that race
|
|
1244
|
+
// routinely (a daemon holding a warm PTY appends to its JSONL the
|
|
1245
|
+
// whole time). Returning that file's text hands the caller somebody
|
|
1246
|
+
// else's answer — the same leak the strict path above refuses to
|
|
1247
|
+
// risk. When the caller tells us what it asked, require the file to
|
|
1248
|
+
// contain that prompt before trusting it.
|
|
1249
|
+
if (expectPrompt && !(await fileHasUserPrompt(full, expectPrompt))) continue;
|
|
1250
|
+
const r = await extractFromFile(full, t0);
|
|
1237
1251
|
if (r) return r;
|
|
1238
1252
|
}
|
|
1239
1253
|
return null;
|
|
1240
1254
|
}
|
|
1241
1255
|
|
|
1256
|
+
/**
|
|
1257
|
+
* Does this transcript contain the prompt we sent?
|
|
1258
|
+
*
|
|
1259
|
+
* Matching is deliberately loose at the tail and strict at the head: ocp
|
|
1260
|
+
* appends its end-of-reply marker instruction to the prompt before typing
|
|
1261
|
+
* it, and the TUI may reflow or truncate what it records, so an equality
|
|
1262
|
+
* check would reject our own file. A leading-substring match on the first
|
|
1263
|
+
* line is enough to tell our transcript apart from an unrelated one.
|
|
1264
|
+
*
|
|
1265
|
+
* @param {string} filePath
|
|
1266
|
+
* @param {string} prompt
|
|
1267
|
+
* @returns {Promise<boolean>}
|
|
1268
|
+
*/
|
|
1269
|
+
async function fileHasUserPrompt(filePath, prompt) {
|
|
1270
|
+
const head = prompt.replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
1271
|
+
// Too short to identify anything — do not use it as evidence either way.
|
|
1272
|
+
if (head.length < 8) return true;
|
|
1273
|
+
let raw;
|
|
1274
|
+
try { raw = await readFile(filePath, 'utf8'); } catch { return false; }
|
|
1275
|
+
for (const line of raw.split('\n')) {
|
|
1276
|
+
if (!line.trim()) continue;
|
|
1277
|
+
let ev;
|
|
1278
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
1279
|
+
if (ev.message?.role !== 'user') continue;
|
|
1280
|
+
const content = ev.message.content;
|
|
1281
|
+
const text = typeof content === 'string'
|
|
1282
|
+
? content
|
|
1283
|
+
: Array.isArray(content)
|
|
1284
|
+
? content.filter((c) => c?.type === 'text').map((c) => c.text ?? '').join(' ')
|
|
1285
|
+
: '';
|
|
1286
|
+
if (text.replace(/\s+/g, ' ').includes(head)) return true;
|
|
1287
|
+
}
|
|
1288
|
+
return false;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1242
1291
|
/**
|
|
1243
1292
|
* Strip TUI chrome (prompt chars, box borders, status bars, mode lines)
|
|
1244
1293
|
* from the PTY-stripped buffer text. Use as a fallback when the JSONL
|
|
@@ -1279,7 +1328,28 @@ export const TUI_CHROME_PATTERNS = [
|
|
|
1279
1328
|
// 2 MCPs | 4 hooks - Zero-config…"). These must NOT drop the whole
|
|
1280
1329
|
// line — only the matched fragment. Order matters: longer / more
|
|
1281
1330
|
// specific variants first so the shorter ones don't partial-match.
|
|
1331
|
+
// Spinner glyphs the TUI cycles through. Kept in sync with
|
|
1332
|
+
// `PATTERNS.spinnerLeading` in `src/parsers/tui-frame.js`.
|
|
1333
|
+
const SPINNER_GLYPHS = '✻✶✺✹✸✷✵●✢✳✽·✾✿❀❁❂❃❄❅❆❇❈❉❊❋';
|
|
1334
|
+
|
|
1282
1335
|
export const TUI_CHROME_INLINE_PATTERNS = [
|
|
1336
|
+
// Rate-limit meters painted to the right of a streamed reply:
|
|
1337
|
+
// `Usage ██░░░░░░░░ 16% (resets in 3h 18m)`, `| Weekly ░░░ 3% (resets in 6d 1h)`.
|
|
1338
|
+
// Anchored on the bar glyphs so ordinary prose with a percentage is safe.
|
|
1339
|
+
new RegExp(
|
|
1340
|
+
String.raw`\s*\|?\s*(?:Usage|Weekly)\s*[█░▒▓]+\s*\d+%\s*\(resets in [^)]*\)`,
|
|
1341
|
+
'gi',
|
|
1342
|
+
),
|
|
1343
|
+
// Bare context meter: `██████░░░░ 22% │`.
|
|
1344
|
+
/\s*[█░▒▓]{2,}\s*\d+%\s*│?/g,
|
|
1345
|
+
// The animated status cell the TUI paints to the LEFT of a reply,
|
|
1346
|
+
// carrying token counters: `63✢ 88 `. Two guards keep real prose
|
|
1347
|
+
// safe: a digit must sit beside the glyph, and the cell must be
|
|
1348
|
+
// separated from the content by the multi-space padding the TUI uses.
|
|
1349
|
+
// That padding is what distinguishes the chrome `✳ 128 Here is …`
|
|
1350
|
+
// from an ordinary bullet like `· 5 minutes later`.
|
|
1351
|
+
new RegExp(String.raw`^\s*\d+\s*[${SPINNER_GLYPHS}]\s*\d*\s{2,}`, 'g'),
|
|
1352
|
+
new RegExp(String.raw`^\s*[${SPINNER_GLYPHS}]\s*\d+\s{2,}`, 'g'),
|
|
1283
1353
|
/\s*\d+\s+CLAUDE\.md\s*\|\s*\d+\s+rules?\s*\|\s*\d+\s+MCPs?\s*\|\s*\d+\s+hooks?/gi,
|
|
1284
1354
|
/\s*\d+\s+rules?\s*\|\s*\d+\s+MCPs?\s*\|\s*\d+\s+hooks?/gi,
|
|
1285
1355
|
/\s*\d+\s+MCP servers?\s+need(?:s)?\s+auth\s*·\s*\/mcp/gi,
|
|
@@ -1295,7 +1365,7 @@ export const TUI_CHROME_INLINE_PATTERNS = [
|
|
|
1295
1365
|
* @param {string} text
|
|
1296
1366
|
*/
|
|
1297
1367
|
export function cleanResponse(text) {
|
|
1298
|
-
|
|
1368
|
+
const cleaned = String(text ?? '')
|
|
1299
1369
|
.replace(/\r/g, '\n')
|
|
1300
1370
|
.split('\n')
|
|
1301
1371
|
.map((l) => l.trimEnd())
|
|
@@ -1319,4 +1389,19 @@ export function cleanResponse(text) {
|
|
|
1319
1389
|
.replace(/\n{3,}/g, '\n\n')
|
|
1320
1390
|
.replace(SENTINEL_REGEX, '')
|
|
1321
1391
|
.trim();
|
|
1392
|
+
|
|
1393
|
+
// The TUI paints bare token counters (`63`, `128`) around a streamed
|
|
1394
|
+
// reply, and once the surrounding chrome is gone they are left stranded
|
|
1395
|
+
// on their own lines. Drop them — but ONLY when other content survives,
|
|
1396
|
+
// so a reply that is legitimately just a number ("what is 6*7" -> "42")
|
|
1397
|
+
// is never swallowed.
|
|
1398
|
+
const lines = cleaned.split('\n');
|
|
1399
|
+
// A counter cell is a bare integer, optionally still carrying the
|
|
1400
|
+
// spinner glyph it was painted next to.
|
|
1401
|
+
const isBareCounter = (l) =>
|
|
1402
|
+
new RegExp(String.raw`^\s*[${SPINNER_GLYPHS}]?\s*\d{1,6}\s*$`).test(l);
|
|
1403
|
+
if (lines.some((l) => l.trim() !== '' && !isBareCounter(l))) {
|
|
1404
|
+
return lines.filter((l) => !isBareCounter(l)).join('\n').trim();
|
|
1405
|
+
}
|
|
1406
|
+
return cleaned;
|
|
1322
1407
|
}
|
package/src/index.js
CHANGED
|
@@ -53,6 +53,7 @@ const UNSAFE_PASSTHROUGH_FLAGS = new Set([
|
|
|
53
53
|
// Load untrusted agents / plugins
|
|
54
54
|
'--agents',
|
|
55
55
|
'--plugin-dir',
|
|
56
|
+
'--plugin-url',
|
|
56
57
|
// Bypass permission gates
|
|
57
58
|
'--dangerously-skip-permissions',
|
|
58
59
|
'--allow-dangerously-skip-permissions',
|
|
@@ -92,7 +93,13 @@ export function redactArgvForLog(argv) {
|
|
|
92
93
|
out.push(`${flag}=<redacted len=${tok.length - eq - 1}>`);
|
|
93
94
|
} else {
|
|
94
95
|
out.push(flag);
|
|
95
|
-
|
|
96
|
+
// Variadic flags (`--mcp-config a.json '{"env":{"TOKEN":"…"}}'`)
|
|
97
|
+
// carry every value token after the flag, so redacting only the
|
|
98
|
+
// first left inline MCP/settings JSON — credentials included —
|
|
99
|
+
// in the log. Consume the whole run of non-flag tokens.
|
|
100
|
+
while (i + 1 < argv.length
|
|
101
|
+
&& typeof argv[i + 1] === 'string'
|
|
102
|
+
&& !argv[i + 1].startsWith('-')) {
|
|
96
103
|
out.push(`<redacted len=${argv[i + 1].length}>`);
|
|
97
104
|
i++;
|
|
98
105
|
}
|
|
@@ -136,6 +143,51 @@ export function sanitizePassThroughArgv(argv) {
|
|
|
136
143
|
* `runOneShot()` spawns a fresh PTY session; pass `poolSize: N` to opt
|
|
137
144
|
* into warm-session reuse.
|
|
138
145
|
*/
|
|
146
|
+
// Environment variables that identify the *calling* Claude Code session —
|
|
147
|
+
// its id, its IPC bridge, its resolved binary — rather than configuring
|
|
148
|
+
// Claude generally. When `ocp` itself runs inside a Claude Code session
|
|
149
|
+
// (an agent driving it, or a developer testing from one) the child
|
|
150
|
+
// `claude` inherits all of them and concludes it is a continuation of the
|
|
151
|
+
// parent rather than a new session. The visible consequence is that it
|
|
152
|
+
// writes no session JSONL at all: `sessionId` comes back null, the clean
|
|
153
|
+
// transcript ocp prefers is unavailable so answers fall back to
|
|
154
|
+
// PTY-scraped text, and `--continue` / `--resume` have nothing to resume.
|
|
155
|
+
//
|
|
156
|
+
// The child is a new, independent session, so these must not carry over.
|
|
157
|
+
// User-facing configuration (`CLAUDE_CODE_SIMPLE`, `CLAUDE_CODE_SAFE_MODE`,
|
|
158
|
+
// provider toggles, …) is deliberately NOT in this list and passes through
|
|
159
|
+
// untouched. Set OCP_KEEP_PARENT_SESSION_ENV=1 to disable the scrub.
|
|
160
|
+
const PARENT_SESSION_ENV = [
|
|
161
|
+
'CLAUDE_CODE_SESSION_ID',
|
|
162
|
+
'CLAUDE_CODE_BRIDGE_SESSION_ID',
|
|
163
|
+
'CLAUDE_CODE_CHILD_SESSION',
|
|
164
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
165
|
+
'CLAUDE_CODE_MESSAGING_SOCKET',
|
|
166
|
+
'CLAUDE_CODE_MESSAGING_TOKEN',
|
|
167
|
+
'CLAUDE_CODE_EXECPATH',
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Remove the calling Claude Code session's identity from a spawn env.
|
|
172
|
+
*
|
|
173
|
+
* Returns a new object; the input is not mutated. An explicit value in
|
|
174
|
+
* `opts.env` wins — if a caller deliberately sets one of these, we honour
|
|
175
|
+
* it rather than second-guessing them.
|
|
176
|
+
*
|
|
177
|
+
* @param {Record<string, string|undefined>} env
|
|
178
|
+
* @param {Record<string, string|undefined>} [explicit] caller-supplied overrides
|
|
179
|
+
* @returns {Record<string, string|undefined>}
|
|
180
|
+
*/
|
|
181
|
+
export function scrubParentSessionEnv(env, explicit = {}) {
|
|
182
|
+
if (process.env.OCP_KEEP_PARENT_SESSION_ENV === '1') return { ...env };
|
|
183
|
+
const out = { ...env };
|
|
184
|
+
for (const key of PARENT_SESSION_ENV) {
|
|
185
|
+
if (Object.prototype.hasOwnProperty.call(explicit, key)) continue;
|
|
186
|
+
delete out[key];
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
139
191
|
export function createDriver(driverOpts = {}) {
|
|
140
192
|
return new Driver(driverOpts);
|
|
141
193
|
}
|
|
@@ -245,7 +297,7 @@ class Driver {
|
|
|
245
297
|
process.env.OCP_PRINT_MODE === 'true';
|
|
246
298
|
if (printMode) {
|
|
247
299
|
const cwd = req.cwd ?? this.opts.cwd;
|
|
248
|
-
const env = { ...process.env, ...this.opts.env };
|
|
300
|
+
const env = scrubParentSessionEnv({ ...process.env, ...this.opts.env }, this.opts.env);
|
|
249
301
|
const r = await runPrintMode({
|
|
250
302
|
bin: this.opts.claudeBin,
|
|
251
303
|
req,
|
|
@@ -324,7 +376,7 @@ class Driver {
|
|
|
324
376
|
// Pool eligibility: explicit resume/continue bind to a specific past
|
|
325
377
|
// session and are not poolable (they bypass the pool entirely).
|
|
326
378
|
const cwd = req.cwd ?? this.opts.cwd;
|
|
327
|
-
const env = { ...process.env, ...this.opts.env };
|
|
379
|
+
const env = scrubParentSessionEnv({ ...process.env, ...this.opts.env }, this.opts.env);
|
|
328
380
|
const pool = this._getPool();
|
|
329
381
|
const isResumeLike =
|
|
330
382
|
(typeof req.resume === 'string' && req.resume !== '') ||
|
|
@@ -1037,7 +1089,7 @@ export async function writePromptToSession(session, content, opts = {}) {
|
|
|
1037
1089
|
* OPTION_SPEC and emitting each entry whose forward strategy is `argv`.
|
|
1038
1090
|
* Pass-through tokens from the CLI's unknown-flag list are appended last.
|
|
1039
1091
|
*/
|
|
1040
|
-
function buildSpawnArgs(req) {
|
|
1092
|
+
export function buildSpawnArgs(req) {
|
|
1041
1093
|
const args = [];
|
|
1042
1094
|
for (const spec of OPTION_SPEC) {
|
|
1043
1095
|
if (spec.forward?.type !== 'argv') continue;
|
|
@@ -1049,6 +1101,16 @@ function buildSpawnArgs(req) {
|
|
|
1049
1101
|
if (Array.isArray(value) && value.length > 0) {
|
|
1050
1102
|
args.push(spec.forward.flag, ...value.map(String));
|
|
1051
1103
|
}
|
|
1104
|
+
} else if (spec.kind === 'json') {
|
|
1105
|
+
// `parseArgv` stores json-kind options already parsed, so the plain
|
|
1106
|
+
// `String(value)` used below would hand upstream the literal
|
|
1107
|
+
// "[object Object]". Re-serialise objects; leave a string alone so a
|
|
1108
|
+
// library caller can pass raw JSON text without it being
|
|
1109
|
+
// double-encoded.
|
|
1110
|
+
args.push(
|
|
1111
|
+
spec.forward.flag,
|
|
1112
|
+
typeof value === 'string' ? value : JSON.stringify(value),
|
|
1113
|
+
);
|
|
1052
1114
|
} else {
|
|
1053
1115
|
args.push(spec.forward.flag, String(value));
|
|
1054
1116
|
}
|
|
@@ -1115,12 +1177,24 @@ async function listSessionFiles(cwd) {
|
|
|
1115
1177
|
* This avoids picking up an unrelated neighbour session created by
|
|
1116
1178
|
* another claude process that happened to finish around the same time.
|
|
1117
1179
|
*
|
|
1180
|
+
* Class (a) beats class (b) outright, and only the highest-mtime file
|
|
1181
|
+
* WITHIN the winning class is returned. Ranking both classes together by
|
|
1182
|
+
* mtime made this function pick a neighbour whenever one was touched
|
|
1183
|
+
* later than our own session — which is the normal state of affairs when
|
|
1184
|
+
* a background daemon holds a warm PTY in the same cwd: its JSONL keeps
|
|
1185
|
+
* being appended, so it reliably out-races the file we just created.
|
|
1186
|
+
* The wrong id then flows straight into `readSessionText`, whose
|
|
1187
|
+
* strict-by-session-id path faithfully returns SOMEBODY ELSE'S
|
|
1188
|
+
* transcript as this request's answer. A fresh spawn always creates a
|
|
1189
|
+
* new file, so preferring class (a) removes that race; class (b) still
|
|
1190
|
+
* covers the legitimate `--resume` append, where no new file appears.
|
|
1191
|
+
*
|
|
1118
1192
|
* @param {string|undefined} cwd
|
|
1119
1193
|
* @param {number} since epoch-ms taken at the start of the request
|
|
1120
1194
|
* @param {Map<string,number>} before baseline filename -> mtimeMs from listSessionFiles
|
|
1121
1195
|
* @returns {Promise<string|null>}
|
|
1122
1196
|
*/
|
|
1123
|
-
async function findRecentSessionId(cwd, since, before) {
|
|
1197
|
+
export async function findRecentSessionId(cwd, since, before) {
|
|
1124
1198
|
// See listSessionFiles for the rationale on realpath + the `/_` -> `-`
|
|
1125
1199
|
// mapping. Both helpers must agree, or the "did this file exist
|
|
1126
1200
|
// before our request?" comparison silently misses every entry.
|
|
@@ -1129,21 +1203,32 @@ async function findRecentSessionId(cwd, since, before) {
|
|
|
1129
1203
|
let entries;
|
|
1130
1204
|
try { entries = await readdir(dir); } catch { return null; }
|
|
1131
1205
|
const threshold = since - 1500;
|
|
1132
|
-
|
|
1133
|
-
|
|
1206
|
+
// Tracked separately so a newly-created file always wins over a
|
|
1207
|
+
// neighbour that merely got appended to during our window.
|
|
1208
|
+
let newBest = null; let newBestMtime = 0;
|
|
1209
|
+
let movedBest = null; let movedBestMtime = 0;
|
|
1134
1210
|
for (const name of entries) {
|
|
1135
1211
|
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;
|
|
1136
1212
|
let st;
|
|
1137
1213
|
try { st = await stat(path.join(dir, name)); } catch { continue; }
|
|
1138
1214
|
if (st.mtimeMs < threshold) continue;
|
|
1139
1215
|
const baseline = before?.get(name);
|
|
1140
|
-
if (baseline
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1216
|
+
if (baseline === undefined) {
|
|
1217
|
+
// (a) Created during our run.
|
|
1218
|
+
if (st.mtimeMs > newBestMtime) {
|
|
1219
|
+
newBestMtime = st.mtimeMs;
|
|
1220
|
+
newBest = name.replace(/\.jsonl$/, '');
|
|
1221
|
+
}
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
if (st.mtimeMs <= baseline + 100) continue; // existed before, didn't move
|
|
1225
|
+
// (b) Existed before and was appended to during our run.
|
|
1226
|
+
if (st.mtimeMs > movedBestMtime) {
|
|
1227
|
+
movedBestMtime = st.mtimeMs;
|
|
1228
|
+
movedBest = name.replace(/\.jsonl$/, '');
|
|
1144
1229
|
}
|
|
1145
1230
|
}
|
|
1146
|
-
return
|
|
1231
|
+
return newBest ?? movedBest;
|
|
1147
1232
|
}
|
|
1148
1233
|
|
|
1149
1234
|
/**
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Parsed-argv options -> driver request fields.
|
|
2
|
+
//
|
|
3
|
+
// `buildSpawnArgs()` in the driver is fully spec-driven: it walks
|
|
4
|
+
// OPTION_SPEC and emits `--flag value` for every entry whose
|
|
5
|
+
// `forward.type` is `'argv'`, reading the value from
|
|
6
|
+
// `req[camelCase(spec.name)]`. This module is the other half of that
|
|
7
|
+
// contract — it lifts those same options out of the parsed argv map and
|
|
8
|
+
// onto the request object under the field names the driver expects.
|
|
9
|
+
//
|
|
10
|
+
// The two halves used to be maintained by hand at each call site, and
|
|
11
|
+
// they drifted: roughly thirty documented flags (`--effort`,
|
|
12
|
+
// `--thinking`, `--add-dir`, `--mcp-config`, `--settings`, `--agents`,
|
|
13
|
+
// `--betas`, `--fallback-model`, `--tools`, `--bare`, …) parsed cleanly,
|
|
14
|
+
// passed validation, and appeared in `--help`, but nothing ever copied
|
|
15
|
+
// them into the request, so they were silently dropped before the
|
|
16
|
+
// upstream process could see them. Deriving both directions from the
|
|
17
|
+
// same spec keeps them in lockstep: appending an entry to OPTION_SPEC is
|
|
18
|
+
// once again the only edit a newly-supported upstream flag needs.
|
|
19
|
+
|
|
20
|
+
import { OPTION_SPEC } from './spec.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Canonical option name -> driver request field name.
|
|
24
|
+
* Must stay identical to `fieldNameOf()` in `src/index.js`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} name e.g. 'no-session-persistence'
|
|
27
|
+
* @returns {string} e.g. 'noSessionPersistence'
|
|
28
|
+
*/
|
|
29
|
+
export function fieldNameFor(name) {
|
|
30
|
+
return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the driver request fields implied by a parsed option map.
|
|
35
|
+
*
|
|
36
|
+
* Only options that forward to upstream argv are included; shim-enforced
|
|
37
|
+
* options (`--max-turns`, `--max-budget-usd`, `--task-budget`,
|
|
38
|
+
* `--output-format`, …) are the caller's responsibility because they map
|
|
39
|
+
* onto driver behaviour rather than onto spawn argv.
|
|
40
|
+
*
|
|
41
|
+
* Callers should spread the result FIRST and apply their own explicit
|
|
42
|
+
* fields afterwards, so per-path overrides still win.
|
|
43
|
+
*
|
|
44
|
+
* A few upstream flags abort the process unless `claude` is also given
|
|
45
|
+
* `--print` (currently `--include-partial-messages`). Those are marked
|
|
46
|
+
* `printModeOnly` in the spec and are withheld unless the caller says it
|
|
47
|
+
* is running the print-mode path.
|
|
48
|
+
*
|
|
49
|
+
* @param {Record<string, unknown>} options parsed argv options
|
|
50
|
+
* @param {object} [ctx]
|
|
51
|
+
* @param {boolean} [ctx.printMode=false] is this the `--print-mode` path?
|
|
52
|
+
* @returns {Record<string, unknown>} driver request fields
|
|
53
|
+
*/
|
|
54
|
+
export function forwardedRequestFields(options, { printMode = false } = {}) {
|
|
55
|
+
/** @type {Record<string, unknown>} */
|
|
56
|
+
const fields = {};
|
|
57
|
+
if (!options || typeof options !== 'object') return fields;
|
|
58
|
+
for (const spec of OPTION_SPEC) {
|
|
59
|
+
if (spec.forward?.type !== 'argv') continue;
|
|
60
|
+
if (spec.printModeOnly && !printMode) continue;
|
|
61
|
+
const value = options[spec.name];
|
|
62
|
+
// `buildSpawnArgs` already skips undefined / null / false, but we drop
|
|
63
|
+
// them here too so request objects stay readable in debug dumps and
|
|
64
|
+
// daemon payloads stay small.
|
|
65
|
+
if (value === undefined || value === null || value === false) continue;
|
|
66
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
67
|
+
fields[fieldNameFor(spec.name)] = value;
|
|
68
|
+
}
|
|
69
|
+
return fields;
|
|
70
|
+
}
|