open-claude-p 1.1.2 → 1.2.1

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';
@@ -65,7 +66,7 @@ const EXIT = {
65
66
  };
66
67
 
67
68
  async function main() {
68
- const { options, positional, unknown, errors } = parseArgv(process.argv.slice(2));
69
+ const { options, positional, unknown, errors, supplied } = parseArgv(process.argv.slice(2));
69
70
 
70
71
  if (errors.length > 0) {
71
72
  process.stderr.write(errors.map((e) => `error: ${e}`).join('\n') + '\n');
@@ -95,12 +96,47 @@ async function main() {
95
96
  // retain the safer default (`false`) — this flip only changes the
96
97
  // CLI default. The opt-in `OCP_DEFAULT_SKIP_PERMS=1` from 1.0 is
97
98
  // still honoured but is now a no-op (the default already matches it).
99
+ //
100
+ // `--restricted` is the one case where the permissive default must
101
+ // stand down. Upstream refuses the combination outright ("Error:
102
+ // bypassPermissions not supported in restricted mode") and exits, so
103
+ // leaving the default on would make `ocp --restricted` fail every
104
+ // time with no output. Asking for restricted mode IS an explicit
105
+ // choice to keep the permission gates, so we honour it.
106
+ //
107
+ // NOTE on the `supplied` check: every option with a `default` is
108
+ // pre-seeded into `options`, so `options[x] === false` is ALSO true
109
+ // when the user passed nothing at all. Testing the value alone made
110
+ // this opt-out fire on every single invocation, which quietly turned
111
+ // the whole block into dead code — the default-on behaviour was in
112
+ // fact coming from `runOneShot`'s `?? true`, and an explicit
113
+ // `--dangerously-skip-permissions=false` was being ignored. Only a
114
+ // flag actually present in argv counts as an opt-out.
98
115
  const skipPermsOptOut = process.env.OCP_NO_SKIP_PERMS === '1'
99
116
  || process.env.OCP_NO_SKIP_PERMS === 'true'
100
- || options['dangerously-skip-permissions'] === false;
117
+ || (supplied.has('dangerously-skip-permissions')
118
+ && options['dangerously-skip-permissions'] === false)
119
+ || options.restricted === true;
101
120
  if (options['dangerously-skip-permissions'] !== true && !skipPermsOptOut) {
102
121
  options['dangerously-skip-permissions'] = true;
103
122
  }
123
+ if (options.restricted === true && options['dangerously-skip-permissions'] === true) {
124
+ process.stderr.write(
125
+ 'error: `--restricted` cannot be combined with ' +
126
+ '`--dangerously-skip-permissions` — the upstream CLI refuses ' +
127
+ 'bypassPermissions in restricted mode.\n',
128
+ );
129
+ return EXIT.VALIDATION_ERROR;
130
+ }
131
+
132
+ // With the bypass gone, something still has to answer permission
133
+ // prompts, and PTY automation cannot. `--permission-prompts=none`
134
+ // denies anything that would prompt instead of hanging on it, which
135
+ // is the only setting that keeps a restricted run headless. The
136
+ // caller can still ask for `host` explicitly.
137
+ if (options.restricted === true && options['permission-prompts'] === undefined) {
138
+ options['permission-prompts'] = 'none';
139
+ }
104
140
 
105
141
  // CLI-only default: honour `OCP_NO_SESSION_PERSISTENCE=1` when the user
106
142
  // has not explicitly set `--no-session-persistence`. Stateless
@@ -116,12 +152,13 @@ async function main() {
116
152
  }
117
153
 
118
154
  // 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).
155
+ // plain `ocp "what is the weather today"` actually uses WebSearch /
156
+ // WebFetch instead of silently no-op'ing with "I can't access
157
+ // real-time info". These are safe-by-construction (no filesystem
158
+ // mutation, no shell exec); a headless tool that refuses to fetch the
159
+ // web for an automation surface is the wrong default. Caller-supplied
160
+ // `--allowed-tools` takes precedence (additive — we merge rather than
161
+ // replace).
125
162
  // Set OCP_NO_DEFAULT_TOOLS=1 to opt out entirely.
126
163
  if (process.env.OCP_NO_DEFAULT_TOOLS !== '1') {
127
164
  const DEFAULT_SAFE_TOOLS = ['WebSearch', 'WebFetch'];
@@ -144,10 +181,11 @@ async function main() {
144
181
 
145
182
  // CLI-only default: encourage tool use for current/time-sensitive
146
183
  // 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.
184
+ // chat client already uses. Without this, a plain prompt such as
185
+ // `ocp "what is the weather today"` refuses with "I can't access
186
+ // real-time data" instead of calling WebSearch — because Claude's
187
+ // print-mode default behaviour leans toward declining over tool-use
188
+ // when it can answer with disclaimers.
151
189
  // This is a generic, ONE-LINE rule ("use tools when you need to look
152
190
  // something up"), not tool-by-tool guidance, so it stays within the
153
191
  // "common rules" policy. Caller-supplied `--append-system-prompt`
@@ -248,19 +286,18 @@ async function main() {
248
286
  // regardless of what the requester intended. Daemon rejects on
249
287
  // mismatch.
250
288
  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,
289
+ ...forwardedRequestFields(options),
290
+ // Always a concrete boolean. `forwardedRequestFields` omits `false`
291
+ // (there is no argv to emit for it), but `runOneShot` resolves a
292
+ // missing field with `?? true` — so omitting it would silently
293
+ // re-enable the permission bypass the caller just opted out of via
294
+ // `OCP_NO_SKIP_PERMS=1`, `--dangerously-skip-permissions=false`, or
295
+ // `--restricted`.
296
+ dangerouslySkipPermissions: options['dangerously-skip-permissions'] === true,
297
+ // Shim-enforced options: no upstream flag, handled by the driver.
260
298
  maxTurns: options['max-turns'],
261
299
  maxBudgetUsd: options['max-budget-usd'],
262
300
  taskBudget: options['task-budget'],
263
- noSessionPersistence: options['no-session-persistence'],
264
301
  passThroughArgv: unknown,
265
302
  };
266
303
 
@@ -328,21 +365,17 @@ async function main() {
328
365
  prompt,
329
366
  cwd: options.cwd,
330
367
  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'],
368
+ ...forwardedRequestFields(options, { printMode: true }),
369
+ // Always a concrete boolean. `forwardedRequestFields` omits `false`
370
+ // (there is no argv to emit for it), but `runOneShot` resolves a
371
+ // missing field with `?? true` — so omitting it would silently
372
+ // re-enable the permission bypass the caller just opted out of via
373
+ // `OCP_NO_SKIP_PERMS=1`, `--dangerously-skip-permissions=false`, or
374
+ // `--restricted`.
375
+ dangerouslySkipPermissions: options['dangerously-skip-permissions'] === true,
344
376
  passThroughArgv: unknown,
345
377
  abortSignal: ac.signal,
378
+ // Shim-enforced options: no upstream flag, handled by the driver.
346
379
  maxTurns: options['max-turns'],
347
380
  maxBudgetUsd: options['max-budget-usd'],
348
381
  printSink: process.stdout,
@@ -401,25 +434,20 @@ async function runOneTurn({ driver, adapter, options, unknown, ac, prompt, resum
401
434
  const result = await driver.runOneShot({
402
435
  prompt,
403
436
  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,
437
+ ...forwardedRequestFields(options),
438
+ // Always a concrete boolean. `forwardedRequestFields` omits `false`
439
+ // (there is no argv to emit for it), but `runOneShot` resolves a
440
+ // missing field with `?? true` — so omitting it would silently
441
+ // re-enable the permission bypass the caller just opted out of via
442
+ // `OCP_NO_SKIP_PERMS=1`, `--dangerously-skip-permissions=false`, or
443
+ // `--restricted`.
444
+ dangerouslySkipPermissions: options['dangerously-skip-permissions'] === true,
445
+ // The stream-json input loop threads each turn's session id back in
446
+ // so turn N+1 resumes turn N; it must win over `--resume` from argv.
414
447
  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
448
  passThroughArgv: unknown,
422
449
  abortSignal: ac.signal,
450
+ // Shim-enforced options: no upstream flag, handled by the driver.
423
451
  maxTurns: options['max-turns'],
424
452
  maxBudgetUsd: options['max-budget-usd'],
425
453
  taskBudget: options['task-budget'],
@@ -432,14 +460,18 @@ async function runOneTurn({ driver, adapter, options, unknown, ac, prompt, resum
432
460
  // alone cannot scrub; the JSONL file is what claude itself stores
433
461
  // and contains only the assistant's message verbatim. We call
434
462
  // readSessionText even when sessionId is null — it falls back to the
435
- // most-recently-modified JSONL written during this request window.
463
+ // most-recently-modified JSONL written during this request window,
464
+ // gated on the transcript actually containing the prompt we sent so a
465
+ // concurrent session in the same cwd cannot answer for us.
436
466
  let finalText = result.text;
437
467
  let usage = null;
438
468
  let toolsFromSession = [];
439
469
  if (!result.isError) {
440
470
  try {
441
471
  const { readSessionText } = await import('../src/chat/index.js');
442
- const sessionRead = await readSessionText(result.sessionId, startTime, options.cwd);
472
+ const sessionRead = await readSessionText(
473
+ result.sessionId, startTime, options.cwd, { expectPrompt: prompt },
474
+ );
443
475
  if (sessionRead?.text) finalText = sessionRead.text;
444
476
  if (sessionRead?.usage) usage = sessionRead.usage;
445
477
  if (sessionRead?.tools) toolsFromSession = sessionRead.tools;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-claude-p",
3
- "version": "1.1.2",
3
+ "version": "1.2.1",
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
@@ -37,6 +37,20 @@ export class CompletionDetector {
37
37
  * turn; when the (N+1)th turn
38
38
  * opens, the request is aborted
39
39
  * with reason 'max-turns'.
40
+ * @param {boolean} [opts.allowIdleWithoutResponse=false]
41
+ * Permit `_onTick`'s idle fallback
42
+ * even when no assistant region has
43
+ * opened. Set this for prompts that
44
+ * legitimately produce no assistant
45
+ * turn — slash commands (`/compact`,
46
+ * `/clear`, `/help`, …) whose
47
+ * upstream handler runs a local
48
+ * operation and returns directly to
49
+ * the input box. Without this flag
50
+ * the request would block until
51
+ * `maxResponseMs` (24 h default)
52
+ * because `hadAssistantText` stays
53
+ * false for the entire turn.
40
54
  */
41
55
  constructor({
42
56
  nonce,
@@ -44,6 +58,7 @@ export class CompletionDetector {
44
58
  preIdleMs = 8000,
45
59
  maxResponseMs = 60000,
46
60
  maxTurns,
61
+ allowIdleWithoutResponse = false,
47
62
  } = {}) {
48
63
  if (!nonce) throw new Error('CompletionDetector: nonce is required');
49
64
  this.nonce = nonce;
@@ -51,6 +66,7 @@ export class CompletionDetector {
51
66
  this.preIdleMs = preIdleMs;
52
67
  this.maxResponseMs = maxResponseMs;
53
68
  this.maxTurns = Number.isFinite(maxTurns) && maxTurns >= 0 ? maxTurns : null;
69
+ this.allowIdleWithoutResponse = !!allowIdleWithoutResponse;
54
70
 
55
71
  this.startTime = Date.now();
56
72
  this.regionEntered = false;
@@ -142,7 +158,10 @@ export class CompletionDetector {
142
158
  // Pre-sentinel fallback: only valid once we've seen any assistant
143
159
  // signal, with the longer `preIdleMs` threshold so that brief render
144
160
  // pauses (common during `--resume`) do not trip premature completion.
145
- if (this.hadAssistantText) {
161
+ // `allowIdleWithoutResponse` relaxes the gate for callers that know
162
+ // the turn legitimately produces no assistant region (e.g. slash
163
+ // commands like `/compact`).
164
+ if (this.hadAssistantText || this.allowIdleWithoutResponse) {
146
165
  if (idleFor >= this.preIdleMs) {
147
166
  this._complete('idle', false);
148
167
  }
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
  }
@@ -273,6 +280,39 @@ class Driver {
273
280
 
274
281
  const spawnArgs = buildSpawnArgs(req);
275
282
 
283
+ // Local-builtin slash command detection. Claude TUI has two distinct
284
+ // `/`-prefixed prompt classes:
285
+ //
286
+ // - **Local builtins** (this list) — handled entirely by the TUI's
287
+ // command dispatcher. No LLM turn runs, no `⏺` region opens, no
288
+ // sentinel can ever arrive. `/compact` is the motivating case:
289
+ // 57 s of compaction activity, then chevron returns silently,
290
+ // then ocp would block until `maxResponseMs` (24 h default)
291
+ // waiting for a response that by design never comes.
292
+ //
293
+ // - **LLM-bearing slash invocations** — skills (`/init`, `/review`,
294
+ // `/security-review`, custom user skills like `/ui-ux-pro-max`)
295
+ // and any unknown `/<name>` we don't recognise. These DO run
296
+ // the LLM, DO open `⏺`, and emit the sentinel like a normal
297
+ // prompt. They MUST keep the OCP_END instruction and the
298
+ // strict idle gate — relaxing either would drop the marker
299
+ // from a real LLM turn and trigger a false-positive idle
300
+ // completion (returning a half-streamed response prefixed
301
+ // with the 1.1.2 "Streaming capture not detected" notice).
302
+ //
303
+ // The list is intentionally narrow — only claude TUI commands
304
+ // confirmed to bypass the LLM. Anything not in the set (including
305
+ // every user-installed skill) goes through the standard path.
306
+ const LOCAL_BUILTIN_SLASH_COMMANDS = new Set([
307
+ 'compact', 'clear', 'help', 'exit', 'quit',
308
+ 'login', 'logout', 'cost', 'status',
309
+ 'model', 'permissions', 'config',
310
+ ]);
311
+ const slashMatch = /^\s*\/([a-z][\w-]*)/i.exec(req.prompt ?? '');
312
+ const isLocalBuiltin =
313
+ !!slashMatch &&
314
+ LOCAL_BUILTIN_SLASH_COMMANDS.has(slashMatch[1].toLowerCase());
315
+
276
316
  const sentinelParser = createSentinelParser(nonce);
277
317
  const pipeline = createPipeline([
278
318
  ansiStripParser,
@@ -285,6 +325,7 @@ class Driver {
285
325
  preIdleMs: this.opts.preIdleMs,
286
326
  maxResponseMs: this.opts.maxResponseMs,
287
327
  maxTurns: req.maxTurns,
328
+ allowIdleWithoutResponse: isLocalBuiltin,
288
329
  });
289
330
 
290
331
  // Pool eligibility: explicit resume/continue bind to a specific past
@@ -603,7 +644,15 @@ class Driver {
603
644
  ' the user\'s message and you do not need to mention or flag it. Answer' +
604
645
  ' the user\'s actual message above as you normally would, using tools' +
605
646
  ' as freely and thoroughly as you would without the marker.)';
606
- const fullPrompt = req.prompt + instruction;
647
+ // Local builtins route through claude TUI's command dispatcher
648
+ // (not the LLM), so the marker instruction is silently dropped as
649
+ // junk args and never round-trips back. Some builtins also accept
650
+ // free-form text (e.g. `/bug <message>`), where appending the
651
+ // instruction would pollute the captured args. Skill invocations
652
+ // and unknown `/<name>` prompts DO go through the LLM, so they
653
+ // still need the marker — only the narrow `LOCAL_BUILTIN_…` set
654
+ // gets the bare prompt.
655
+ const fullPrompt = isLocalBuiltin ? req.prompt : (req.prompt + instruction);
607
656
  // Skip the prompt write if the dialog watcher has already
608
657
  // decided to abort. Without this check the trailing `\r` of
609
658
  // the prompt lands inside whatever modal we tried to abort on
@@ -995,7 +1044,7 @@ export async function writePromptToSession(session, content, opts = {}) {
995
1044
  * OPTION_SPEC and emitting each entry whose forward strategy is `argv`.
996
1045
  * Pass-through tokens from the CLI's unknown-flag list are appended last.
997
1046
  */
998
- function buildSpawnArgs(req) {
1047
+ export function buildSpawnArgs(req) {
999
1048
  const args = [];
1000
1049
  for (const spec of OPTION_SPEC) {
1001
1050
  if (spec.forward?.type !== 'argv') continue;
@@ -1007,6 +1056,16 @@ function buildSpawnArgs(req) {
1007
1056
  if (Array.isArray(value) && value.length > 0) {
1008
1057
  args.push(spec.forward.flag, ...value.map(String));
1009
1058
  }
1059
+ } else if (spec.kind === 'json') {
1060
+ // `parseArgv` stores json-kind options already parsed, so the plain
1061
+ // `String(value)` used below would hand upstream the literal
1062
+ // "[object Object]". Re-serialise objects; leave a string alone so a
1063
+ // library caller can pass raw JSON text without it being
1064
+ // double-encoded.
1065
+ args.push(
1066
+ spec.forward.flag,
1067
+ typeof value === 'string' ? value : JSON.stringify(value),
1068
+ );
1010
1069
  } else {
1011
1070
  args.push(spec.forward.flag, String(value));
1012
1071
  }
@@ -1073,12 +1132,24 @@ async function listSessionFiles(cwd) {
1073
1132
  * This avoids picking up an unrelated neighbour session created by
1074
1133
  * another claude process that happened to finish around the same time.
1075
1134
  *
1135
+ * Class (a) beats class (b) outright, and only the highest-mtime file
1136
+ * WITHIN the winning class is returned. Ranking both classes together by
1137
+ * mtime made this function pick a neighbour whenever one was touched
1138
+ * later than our own session — which is the normal state of affairs when
1139
+ * a background daemon holds a warm PTY in the same cwd: its JSONL keeps
1140
+ * being appended, so it reliably out-races the file we just created.
1141
+ * The wrong id then flows straight into `readSessionText`, whose
1142
+ * strict-by-session-id path faithfully returns SOMEBODY ELSE'S
1143
+ * transcript as this request's answer. A fresh spawn always creates a
1144
+ * new file, so preferring class (a) removes that race; class (b) still
1145
+ * covers the legitimate `--resume` append, where no new file appears.
1146
+ *
1076
1147
  * @param {string|undefined} cwd
1077
1148
  * @param {number} since epoch-ms taken at the start of the request
1078
1149
  * @param {Map<string,number>} before baseline filename -> mtimeMs from listSessionFiles
1079
1150
  * @returns {Promise<string|null>}
1080
1151
  */
1081
- async function findRecentSessionId(cwd, since, before) {
1152
+ export async function findRecentSessionId(cwd, since, before) {
1082
1153
  // See listSessionFiles for the rationale on realpath + the `/_` -> `-`
1083
1154
  // mapping. Both helpers must agree, or the "did this file exist
1084
1155
  // before our request?" comparison silently misses every entry.
@@ -1087,21 +1158,32 @@ async function findRecentSessionId(cwd, since, before) {
1087
1158
  let entries;
1088
1159
  try { entries = await readdir(dir); } catch { return null; }
1089
1160
  const threshold = since - 1500;
1090
- let best = null;
1091
- let bestMtime = 0;
1161
+ // Tracked separately so a newly-created file always wins over a
1162
+ // neighbour that merely got appended to during our window.
1163
+ let newBest = null; let newBestMtime = 0;
1164
+ let movedBest = null; let movedBestMtime = 0;
1092
1165
  for (const name of entries) {
1093
1166
  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;
1094
1167
  let st;
1095
1168
  try { st = await stat(path.join(dir, name)); } catch { continue; }
1096
1169
  if (st.mtimeMs < threshold) continue;
1097
1170
  const baseline = before?.get(name);
1098
- if (baseline !== undefined && st.mtimeMs <= baseline + 100) continue; // existed before, didn't move
1099
- if (st.mtimeMs > bestMtime) {
1100
- bestMtime = st.mtimeMs;
1101
- best = name.replace(/\.jsonl$/, '');
1171
+ if (baseline === undefined) {
1172
+ // (a) Created during our run.
1173
+ if (st.mtimeMs > newBestMtime) {
1174
+ newBestMtime = st.mtimeMs;
1175
+ newBest = name.replace(/\.jsonl$/, '');
1176
+ }
1177
+ continue;
1178
+ }
1179
+ if (st.mtimeMs <= baseline + 100) continue; // existed before, didn't move
1180
+ // (b) Existed before and was appended to during our run.
1181
+ if (st.mtimeMs > movedBestMtime) {
1182
+ movedBestMtime = st.mtimeMs;
1183
+ movedBest = name.replace(/\.jsonl$/, '');
1102
1184
  }
1103
1185
  }
1104
- return best;
1186
+ return newBest ?? movedBest;
1105
1187
  }
1106
1188
 
1107
1189
  /**
@@ -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
+ }
@@ -2,3 +2,4 @@
2
2
  export { OPTION_SPEC, getOption } from './spec.js';
3
3
  export { parseArgv } from './parse-argv.js';
4
4
  export { CROSS_RULES, validate } from './validate.js';
5
+ export { forwardedRequestFields, fieldNameFor } from './forward.js';