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/CHANGELOG.md +333 -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 +85 -53
- package/package.json +1 -1
- package/src/chat/index.js +51 -2
- package/src/completion/detector.js +20 -1
- package/src/index.js +93 -11
- 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/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
|
-
||
|
|
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 "
|
|
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
|
|
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
|
|
148
|
-
// refuses with "I can't access
|
|
149
|
-
// WebSearch — because Claude's
|
|
150
|
-
// toward declining over tool-use
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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'],
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1091
|
-
|
|
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
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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
|
|
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
|
+
}
|
package/src/options/index.js
CHANGED