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/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
- || options['dangerously-skip-permissions'] === false;
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 "오늘 날씨"` actually uses WebSearch/WebFetch instead of
120
- // silently no-op'ing with "I can't access real-time info". These are
121
- // safe-by-construction (no filesystem mutation, no shell exec); a
122
- // headless tool that refuses to fetch the web for an automation
123
- // surface is the wrong default. Caller-supplied `--allowed-tools`
124
- // takes precedence (additive — we merge rather than replace).
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 `ocp "오늘 날씨"`
148
- // refuses with "I can't access real-time data" instead of calling
149
- // WebSearch — because Claude's print-mode default behaviour leans
150
- // toward declining over tool-use when it can answer with disclaimers.
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
- model: options.model,
252
- systemPrompt: options['system-prompt'],
253
- appendSystemPrompt: options['append-system-prompt'],
254
- allowedTools: options['allowed-tools'],
255
- disallowedTools: options['disallowed-tools'],
256
- dangerouslySkipPermissions: options['dangerously-skip-permissions'],
257
- permissionMode: options['permission-mode'],
258
- debug: options.debug,
259
- verbose: options.verbose,
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
- model: options.model,
332
- systemPrompt: options['system-prompt'],
333
- appendSystemPrompt: options['append-system-prompt'],
334
- allowedTools: options['allowed-tools'],
335
- disallowedTools: options['disallowed-tools'],
336
- dangerouslySkipPermissions: options['dangerously-skip-permissions'],
337
- permissionMode: options['permission-mode'],
338
- verbose: options.verbose,
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
- model: options.model,
405
- systemPrompt: options['system-prompt'],
406
- appendSystemPrompt: options['append-system-prompt'],
407
- allowedTools: options['allowed-tools'],
408
- disallowedTools: options['disallowed-tools'],
409
- dangerouslySkipPermissions: options['dangerously-skip-permissions'],
410
- permissionMode: options['permission-mode'],
411
- debug: options.debug,
412
- verbose: options.verbose,
413
- continue: options.continue,
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(result.sessionId, startTime, options.cwd);
443
- if (sessionRead?.text) finalText = sessionRead.text;
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.1.3",
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 r = await extractFromFile(path.join(projectDir, f), t0);
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
- return String(text ?? '')
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
- if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
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
- let best = null;
1133
- let bestMtime = 0;
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 !== undefined && st.mtimeMs <= baseline + 100) continue; // existed before, didn't move
1141
- if (st.mtimeMs > bestMtime) {
1142
- bestMtime = st.mtimeMs;
1143
- best = name.replace(/\.jsonl$/, '');
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 best;
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
+ }