open-claude-p 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,782 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // `ocp` — CLI entry point.
4
+ //
5
+ // Accepts the same argv shape as `claude -p` (with `-p` itself implicit).
6
+ // Pipeline:
7
+ // argv -> parseArgv -> validate -> createDriver().runOneShot()
8
+ // -> output adapter (text / json / stream-json) -> stdout
9
+ // Unknown flags are forwarded verbatim to the upstream `claude` process via
10
+ // the driver's `passThroughArgv` so this binary stays argv-transparent.
11
+
12
+ // Don't crash on `ocp "…" | head -1` style usage where the downstream
13
+ // reader closes before we finish writing. Both stdout (assistant text)
14
+ // and stderr (live spinner, meta line, debug log) are vulnerable.
15
+ process.stdout.on('error', (e) => { if (e?.code !== 'EPIPE') throw e; });
16
+ process.stderr.on('error', (e) => { if (e?.code !== 'EPIPE') throw e; });
17
+
18
+ // Surface stray rejections / uncaught exceptions with a friendly
19
+ // prefix instead of Node's default `[UnhandledPromiseRejection]` dump.
20
+ process.on('unhandledRejection', (r) => {
21
+ process.stderr.write(`ocp: unhandled rejection: ${r?.stack || r?.message || r}\n`);
22
+ process.exit(1);
23
+ });
24
+ process.on('uncaughtException', (e) => {
25
+ process.stderr.write(`ocp: uncaught exception: ${e?.stack || e?.message || e}\n`);
26
+ process.exit(1);
27
+ });
28
+
29
+ import { parseArgv } from '../src/options/parse-argv.js';
30
+ import { validate } from '../src/options/validate.js';
31
+ import { OPTION_SPEC } from '../src/options/spec.js';
32
+ import { createDriver } from '../src/index.js';
33
+ import { sendToDaemon } from '../src/daemon/client.js';
34
+ import { daemonKey, socketPath, resolveCwd } from '../src/daemon/socket.js';
35
+ import { readFile } from 'node:fs/promises';
36
+ import { fileURLToPath } from 'node:url';
37
+ import path from 'node:path';
38
+ import {
39
+ registerOutputAdapter, getOutputAdapter,
40
+ } from '../src/output/registry.js';
41
+ import { textOutputAdapter } from '../src/output/text.js';
42
+ import { jsonOutputAdapter } from '../src/output/json.js';
43
+ import { streamJsonOutputAdapter } from '../src/output/stream-json.js';
44
+ import { stripTerminalControl } from '../src/chat/event-filters.js';
45
+ import { DEFAULT_APPEND_SYSTEM_PROMPT } from '../src/chat/index.js';
46
+ import { isatty } from 'node:tty';
47
+ import readline from 'node:readline';
48
+
49
+ // Register the bundled adapters once at startup. Plugins can extend this
50
+ // registry via the public `open-claude-p/output` entry point.
51
+ registerOutputAdapter(textOutputAdapter);
52
+ registerOutputAdapter(jsonOutputAdapter);
53
+ registerOutputAdapter(streamJsonOutputAdapter);
54
+
55
+ const EXIT = {
56
+ OK: 0,
57
+ GENERIC_ERROR: 1,
58
+ PARSE_ERROR: 2,
59
+ VALIDATION_ERROR: 3,
60
+ TIMEOUT: 4,
61
+ CANCELLED: 5,
62
+ INTERACTIVE_REQUIRED: 6,
63
+ NOT_IMPLEMENTED: 8,
64
+ };
65
+
66
+ async function main() {
67
+ const { options, positional, unknown, errors } = parseArgv(process.argv.slice(2));
68
+
69
+ if (errors.length > 0) {
70
+ process.stderr.write(errors.map((e) => `error: ${e}`).join('\n') + '\n');
71
+ return EXIT.PARSE_ERROR;
72
+ }
73
+
74
+ if (options.help === true) {
75
+ process.stdout.write(buildHelp() + '\n');
76
+ return EXIT.OK;
77
+ }
78
+ if (options.version === true) {
79
+ const v = await readPackageVersion();
80
+ process.stdout.write(`ocp ${v}\n`);
81
+ return EXIT.OK;
82
+ }
83
+
84
+ // CLI-only default: when the user has not explicitly set
85
+ // `--dangerously-skip-permissions`, honour the `OCP_DEFAULT_SKIP_PERMS=1`
86
+ // env. Rationale — `ocp` is a non-interactive automation surface; a
87
+ // permission prompt that wants a human answer makes most prompts
88
+ // (WebSearch, Bash, Read, Write) silently no-op since PTY automation
89
+ // cannot answer. Library callers via `createDriver()` retain the safer
90
+ // default (`false`) — this opt-in only changes the CLI default.
91
+ if (options['dangerously-skip-permissions'] !== true
92
+ && process.env.OCP_DEFAULT_SKIP_PERMS === '1') {
93
+ options['dangerously-skip-permissions'] = true;
94
+ }
95
+
96
+ // CLI-only default: pre-approve the read-only network tools so that a
97
+ // plain `ocp "오늘 날씨"` actually uses WebSearch/WebFetch instead of
98
+ // silently no-op'ing with "I can't access real-time info". These are
99
+ // safe-by-construction (no filesystem mutation, no shell exec); a
100
+ // headless tool that refuses to fetch the web for an automation
101
+ // surface is the wrong default. Caller-supplied `--allowed-tools`
102
+ // takes precedence (additive — we merge rather than replace).
103
+ // Set OCP_NO_DEFAULT_TOOLS=1 to opt out entirely.
104
+ if (process.env.OCP_NO_DEFAULT_TOOLS !== '1') {
105
+ const DEFAULT_SAFE_TOOLS = ['WebSearch', 'WebFetch'];
106
+ const existing = Array.isArray(options['allowed-tools']) ? options['allowed-tools'] : [];
107
+ const merged = [...existing];
108
+ for (const t of DEFAULT_SAFE_TOOLS) {
109
+ if (!merged.includes(t)) merged.push(t);
110
+ }
111
+ options['allowed-tools'] = merged;
112
+ }
113
+
114
+ // No CLI-side `--permission-mode` default. `acceptEdits` looked
115
+ // attractive ("auto-accept edits, prompt for rest"), but in PTY
116
+ // interactive mode claude's permission prompt is an interactive y/n
117
+ // box the headless driver can't answer — so any tool NOT in
118
+ // `--allowed-tools` still hangs the turn. `bypassPermissions` (=
119
+ // `--dangerously-skip-permissions`) is the only mode that's truly
120
+ // hang-free, and we don't default to it because it's a real security
121
+ // escalation (Bash, Edit, Write all run unprompted). Users who want
122
+ // full auto set `OCP_DEFAULT_SKIP_PERMS=1` once in their shell, or
123
+ // pass `--dangerously-skip-permissions` per call. The `--allowed-tools`
124
+ // default below pre-approves the safe network tools so the most
125
+ // common case (current-info queries) works without bypass.
126
+
127
+ // Per-invocation reminder when both opt-in envs are active — that
128
+ // combination means "any new cwd auto-trusted + every tool runs
129
+ // without a prompt". A forgotten `export` in ~/.zshrc is otherwise
130
+ // invisible. Set OCP_NO_WARN=1 to silence.
131
+ if (process.env.OCP_DEFAULT_SKIP_PERMS === '1'
132
+ && process.env.OCP_AUTO_ACCEPT_TRUST === '1'
133
+ && process.stderr.isTTY
134
+ && process.env.OCP_NO_WARN !== '1') {
135
+ process.stderr.write(
136
+ '\x1b[33m[ocp] OCP_DEFAULT_SKIP_PERMS + OCP_AUTO_ACCEPT_TRUST active — tools run unprompted in any cwd.\n' +
137
+ ' Unset one of those envs in ~/.zshrc to restore prompts, or set OCP_NO_WARN=1 to silence this notice.\x1b[0m\n',
138
+ );
139
+ }
140
+
141
+ // CLI-only default: encourage tool use for current/time-sensitive
142
+ // queries via the same SDK-wide DEFAULT_APPEND_SYSTEM_PROMPT the
143
+ // chat client already uses. Without this, a plain `ocp "오늘 날씨"`
144
+ // refuses with "I can't access real-time data" instead of calling
145
+ // WebSearch — because Claude's print-mode default behaviour leans
146
+ // toward declining over tool-use when it can answer with disclaimers.
147
+ // This is a generic, ONE-LINE rule ("use tools when you need to look
148
+ // something up"), not tool-by-tool guidance, so it stays within the
149
+ // "common rules" policy. Caller-supplied `--append-system-prompt`
150
+ // takes precedence (additive — we prepend the default before it).
151
+ // Set OCP_NO_DEFAULT_PROMPT=1 to opt out.
152
+ if (process.env.OCP_NO_DEFAULT_PROMPT !== '1') {
153
+ const userAppend = options['append-system-prompt'];
154
+ options['append-system-prompt'] = (typeof userAppend === 'string' && userAppend.length > 0)
155
+ ? `${DEFAULT_APPEND_SYSTEM_PROMPT}\n\n${userAppend}`
156
+ : DEFAULT_APPEND_SYSTEM_PROMPT;
157
+ }
158
+
159
+ const validationErrors = validate(options);
160
+ if (validationErrors.length > 0) {
161
+ process.stderr.write(
162
+ validationErrors.map((e) => `error: ${e}`).join('\n') + '\n',
163
+ );
164
+ return EXIT.VALIDATION_ERROR;
165
+ }
166
+
167
+ if (options.debug && unknown.length > 0) {
168
+ process.stderr.write(
169
+ `[ocp] forwarding unknown flags to claude: ${unknown.join(' ')}\n`,
170
+ );
171
+ }
172
+
173
+ const inputFormat = options['input-format'] ?? 'text';
174
+
175
+ // For `--input-format=text` the prompt resolution is positional argv
176
+ // first, then stdin if non-TTY. For `--input-format=stream-json` we
177
+ // drive a loop over NDJSON user messages on stdin instead — see
178
+ // runStreamJsonInputLoop() below.
179
+ let prompt = '';
180
+ if (inputFormat === 'text') {
181
+ prompt = positional.length > 0 ? positional.join(' ') : '';
182
+ if (!prompt && !isatty(0)) {
183
+ prompt = await readStdin();
184
+ }
185
+ if (!prompt) {
186
+ process.stderr.write(
187
+ 'error: no prompt provided. Pass it positionally (`ocp "hello"`) or pipe it via stdin.\n\n',
188
+ );
189
+ process.stderr.write(buildHelp() + '\n');
190
+ return EXIT.PARSE_ERROR;
191
+ }
192
+ }
193
+
194
+ const outputFormat = options['output-format'] ?? 'text';
195
+ const adapterDef = getOutputAdapter(outputFormat);
196
+ if (!adapterDef) {
197
+ process.stderr.write(
198
+ `error: --output-format=${outputFormat} is not registered.\n`,
199
+ );
200
+ return EXIT.NOT_IMPLEMENTED;
201
+ }
202
+
203
+ // ── Daemon path ──────────────────────────────────────────────────────────
204
+ // The daemon keeps a PTY alive between invocations so conversation context
205
+ // is preserved and subsequent commands skip the 2.5 s warmup delay.
206
+ //
207
+ // Bypassed when:
208
+ // OCP_NO_DAEMON=1 — explicit opt-out
209
+ // --input-format=stream-json — daemon doesn't help multi-turn loops
210
+ // --resume / --continue / --fork-session — caller wants a specific session
211
+ // Print-mode (--print-mode / OCP_PRINT_MODE) bypasses PTY+TUI entirely.
212
+ // Daemon is irrelevant for that path — the child process is short-lived.
213
+ const printMode =
214
+ options['print-mode'] === true ||
215
+ process.env.OCP_PRINT_MODE === '1' ||
216
+ process.env.OCP_PRINT_MODE === 'true';
217
+
218
+ const useDaemon = !process.env.OCP_NO_DAEMON
219
+ && !printMode
220
+ && inputFormat !== 'stream-json'
221
+ && !options.resume
222
+ && !options.continue
223
+ && !options['fork-session'];
224
+
225
+ if (useDaemon) {
226
+ // Include claudeBin in the key — two ocp runs in the same cwd but
227
+ // with different OCP_CLAUDE_BIN values must address different
228
+ // daemons (otherwise the second silently rides on the first's
229
+ // binary).
230
+ const key = daemonKey({ cwd: options.cwd, claudeBin: process.env.OCP_CLAUDE_BIN });
231
+ const sockPath = socketPath(key);
232
+
233
+ const req = {
234
+ prompt,
235
+ // Echo the realpath-normalised cwd we keyed the daemon on. Both
236
+ // sides must use the SAME normalisation (realpath) — otherwise
237
+ // two shells reaching the same directory via different symlinks
238
+ // route to the same socket (key uses realpath) but the daemon
239
+ // would reject the second shell's raw req.cwd as "cwd mismatch".
240
+ cwd: resolveCwd(options.cwd),
241
+ // Echo the claudeBin we expected this daemon to be bound to. A
242
+ // same-uid peer can otherwise connect directly to our socket and
243
+ // drive requests through whatever binary the daemon is running,
244
+ // regardless of what the requester intended. Daemon rejects on
245
+ // mismatch.
246
+ claudeBin: process.env.OCP_CLAUDE_BIN || 'claude',
247
+ model: options.model,
248
+ systemPrompt: options['system-prompt'],
249
+ appendSystemPrompt: options['append-system-prompt'],
250
+ allowedTools: options['allowed-tools'],
251
+ disallowedTools: options['disallowed-tools'],
252
+ dangerouslySkipPermissions: options['dangerously-skip-permissions'],
253
+ permissionMode: options['permission-mode'],
254
+ debug: options.debug,
255
+ verbose: options.verbose,
256
+ maxTurns: options['max-turns'],
257
+ maxBudgetUsd: options['max-budget-usd'],
258
+ taskBudget: options['task-budget'],
259
+ noSessionPersistence: options['no-session-persistence'],
260
+ passThroughArgv: unknown,
261
+ };
262
+
263
+ try {
264
+ const result = await sendToDaemon(sockPath, req, { cwd: options.cwd, debug: options.debug }, key);
265
+
266
+ const adapter = adapterDef.create(
267
+ { outputFormat, jsonSchema: options['json-schema'] },
268
+ process.stdout,
269
+ );
270
+ for (const ev of (result.events ?? [])) adapter.onEvent(ev);
271
+ adapter.end({
272
+ text: result.text,
273
+ isError: result.isError,
274
+ sessionId: result.sessionId,
275
+ cost: result.cost,
276
+ durationMs: result.durationMs,
277
+ completionReason: result.completionReason,
278
+ });
279
+
280
+ if (result.sessionId && options.debug) process.stderr.write(`[ocp] sessionId=${result.sessionId}\n`);
281
+ if (options.debug) {
282
+ process.stderr.write(`[ocp] daemon: completion=${result.completionReason} duration=${result.durationMs}ms\n`);
283
+ }
284
+ return mapCompletionToExit(result);
285
+ } catch (e) {
286
+ // Distinguish "daemon unreachable / crashed" (safe to retry direct)
287
+ // from "daemon explicitly refused this request" (must NOT retry —
288
+ // the refusal is a security decision such as cwd mismatch).
289
+ if (e.fromDaemon) {
290
+ process.stderr.write(`ocp: daemon refused request: ${e.message}\n`);
291
+ return EXIT.GENERIC_ERROR;
292
+ }
293
+ // Setup errors that won't be fixed by retrying direct (e.g. wrong-
294
+ // owner ~/.ocp/) — surface immediately rather than silently
295
+ // falling back. ensureOcpDir's wrong-uid error is the canonical
296
+ // case; identify it by message prefix so a chown hint reaches
297
+ // the user without --debug.
298
+ if (typeof e.message === 'string' && e.message.startsWith('~/.ocp is owned by')) {
299
+ process.stderr.write(`ocp: ${e.message}\n`);
300
+ return EXIT.GENERIC_ERROR;
301
+ }
302
+ if (options.debug) {
303
+ process.stderr.write(`[ocp] daemon unreachable (${e.message}), falling back to direct mode\n`);
304
+ }
305
+ // Fall through to direct mode below.
306
+ }
307
+ }
308
+
309
+ // ── Direct path (no daemon) ──────────────────────────────────────────────
310
+ const driver = createDriver({
311
+ debug: options.debug,
312
+ cwd: options.cwd,
313
+ printMode,
314
+ });
315
+
316
+ const ac = new AbortController();
317
+ setupSignalHandlers(ac);
318
+
319
+ // Print-mode bypasses the adapter: claude's stdout (already in the
320
+ // requested --output-format) is streamed verbatim to process.stdout.
321
+ if (printMode) {
322
+ try {
323
+ const result = await driver.runOneShot({
324
+ prompt,
325
+ cwd: options.cwd,
326
+ outputFormat,
327
+ model: options.model,
328
+ systemPrompt: options['system-prompt'],
329
+ appendSystemPrompt: options['append-system-prompt'],
330
+ allowedTools: options['allowed-tools'],
331
+ disallowedTools: options['disallowed-tools'],
332
+ dangerouslySkipPermissions: options['dangerously-skip-permissions'],
333
+ permissionMode: options['permission-mode'],
334
+ verbose: options.verbose,
335
+ continue: options.continue,
336
+ resume: options.resume,
337
+ forkSession: options['fork-session'],
338
+ noSessionPersistence: options['no-session-persistence'],
339
+ sessionId: options['session-id'],
340
+ passThroughArgv: unknown,
341
+ abortSignal: ac.signal,
342
+ maxTurns: options['max-turns'],
343
+ maxBudgetUsd: options['max-budget-usd'],
344
+ printSink: process.stdout,
345
+ });
346
+ if (result.sessionId && options.debug) process.stderr.write(`[ocp] sessionId=${result.sessionId}\n`);
347
+ if (options.debug) {
348
+ process.stderr.write(
349
+ `[ocp] print-mode completion=${result.completionReason} duration=${result.durationMs}ms\n`,
350
+ );
351
+ }
352
+ return mapCompletionToExit(result);
353
+ } finally {
354
+ await driver.close();
355
+ }
356
+ }
357
+
358
+ const adapter = adapterDef.create(
359
+ { outputFormat, jsonSchema: options['json-schema'] },
360
+ process.stdout,
361
+ );
362
+
363
+ try {
364
+ if (inputFormat === 'stream-json') {
365
+ return await runStreamJsonInputLoop({
366
+ driver, adapter, options, unknown, ac,
367
+ });
368
+ }
369
+
370
+ const result = await runOneTurn({
371
+ driver, adapter, options, unknown, ac, prompt,
372
+ resumeOverride: options.resume,
373
+ });
374
+
375
+ if (result.sessionId && options.debug) {
376
+ process.stderr.write(`[ocp] sessionId=${result.sessionId}\n`);
377
+ }
378
+ if (options.debug) {
379
+ process.stderr.write(
380
+ `[ocp] completion=${result.completionReason} duration=${result.durationMs}ms ` +
381
+ `rawBytes=${result.diagnostics.rawBytes} strippedBytes=${result.diagnostics.strippedBytes}\n`,
382
+ );
383
+ }
384
+ return mapCompletionToExit(result);
385
+ } finally {
386
+ await driver.close();
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Run a single user prompt through the driver, forwarding events into the
392
+ * shared adapter and calling `adapter.end()` once on completion. Used by
393
+ * both the default single-turn path and the stream-json input loop.
394
+ */
395
+ async function runOneTurn({ driver, adapter, options, unknown, ac, prompt, resumeOverride }) {
396
+ const startTime = Date.now();
397
+ const result = await driver.runOneShot({
398
+ prompt,
399
+ cwd: options.cwd,
400
+ model: options.model,
401
+ systemPrompt: options['system-prompt'],
402
+ appendSystemPrompt: options['append-system-prompt'],
403
+ allowedTools: options['allowed-tools'],
404
+ disallowedTools: options['disallowed-tools'],
405
+ dangerouslySkipPermissions: options['dangerously-skip-permissions'],
406
+ permissionMode: options['permission-mode'],
407
+ debug: options.debug,
408
+ verbose: options.verbose,
409
+ continue: options.continue,
410
+ resume: resumeOverride ?? options.resume,
411
+ forkSession: options['fork-session'],
412
+ noSessionPersistence: options['no-session-persistence'],
413
+ resumeSessionAt: options['resume-session-at'],
414
+ rewindFiles: options['rewind-files'],
415
+ sessionId: options['session-id'],
416
+ name: options.name,
417
+ passThroughArgv: unknown,
418
+ abortSignal: ac.signal,
419
+ maxTurns: options['max-turns'],
420
+ maxBudgetUsd: options['max-budget-usd'],
421
+ taskBudget: options['task-budget'],
422
+ onEvent: (ev) => adapter.onEvent(ev),
423
+ });
424
+
425
+ // Prefer the upstream JSONL session file's clean markdown over the
426
+ // PTY-extracted text when available. PTY-stripped buffers commonly
427
+ // interleave statusline / HUD plugin output that region extraction
428
+ // alone cannot scrub; the JSONL file is what claude itself stores
429
+ // and contains only the assistant's message verbatim. We call
430
+ // readSessionText even when sessionId is null — it falls back to the
431
+ // most-recently-modified JSONL written during this request window.
432
+ let finalText = result.text;
433
+ let usage = null;
434
+ let toolsFromSession = [];
435
+ if (!result.isError) {
436
+ try {
437
+ const { readSessionText } = await import('../src/chat/index.js');
438
+ const sessionRead = await readSessionText(result.sessionId, startTime, options.cwd);
439
+ if (sessionRead?.text) finalText = sessionRead.text;
440
+ if (sessionRead?.usage) usage = sessionRead.usage;
441
+ if (sessionRead?.tools) toolsFromSession = sessionRead.tools;
442
+ } catch { /* keep PTY-extracted fallback */ }
443
+ }
444
+
445
+ adapter.end({
446
+ text: finalText,
447
+ isError: result.isError,
448
+ sessionId: result.sessionId,
449
+ cost: result.cost,
450
+ durationMs: result.durationMs,
451
+ completionReason: result.completionReason,
452
+ });
453
+
454
+ // Persist a per-turn record under <cwd>/.ocp/<sessionId>/ for the
455
+ // direct (no-daemon) CLI path. Daemon-routed turns already record
456
+ // server-side. Best-effort — failures are captured inside
457
+ // recordSession and never block the response.
458
+ try {
459
+ const { recordSession } = await import('../src/session-log.js');
460
+ recordSession({
461
+ cwd: options.cwd ?? process.cwd(),
462
+ sessionId: result.sessionId,
463
+ prompt,
464
+ response: finalText,
465
+ meta: {
466
+ isError: result.isError,
467
+ completionReason: result.completionReason,
468
+ durationMs: result.durationMs,
469
+ cost: result.cost,
470
+ tools: toolsFromSession,
471
+ },
472
+ events: result.events,
473
+ }).catch(() => {});
474
+ } catch { /* ignore */ }
475
+
476
+ await printMetaLine({ options, result, usage, toolsFromSession });
477
+ return result;
478
+ }
479
+
480
+ async function printMetaLine({ options, result, usage, toolsFromSession }) {
481
+ // The meta line is supplementary UX — only show it when stderr is an
482
+ // interactive terminal AND the user has not opted out. Suppressed in
483
+ // pipe / script contexts so script consumers see only the response.
484
+ if (!process.stderr.isTTY) return;
485
+ if (options['no-meta'] === true) return;
486
+ if (process.env.OCP_NO_META === '1') return;
487
+ if (result.isError) return;
488
+
489
+ let totalInputTokens, computeCost, formatTokens, stripTerminalControl;
490
+ try {
491
+ const m = await import('../src/chat/index.js');
492
+ totalInputTokens = m.totalInputTokens;
493
+ computeCost = m.computeCost;
494
+ formatTokens = m.formatTokens;
495
+ stripTerminalControl = m.stripTerminalControl;
496
+ } catch { return; }
497
+
498
+ // Defense in depth: tool names are already sanitised at JSONL
499
+ // extraction time, but we also scrub here in case a future code path
500
+ // routes around that sink.
501
+ const tools = new Set();
502
+ for (const t of toolsFromSession ?? []) {
503
+ const safe = stripTerminalControl(t);
504
+ if (safe) tools.add(safe);
505
+ }
506
+ if (usage?.server_tool_use?.web_search_requests > 0) tools.add('web_search');
507
+ if (usage?.server_tool_use?.web_fetch_requests > 0) tools.add('web_fetch');
508
+
509
+ const secs = ((result.durationMs ?? 0) / 1000).toFixed(1);
510
+ const inTok = formatTokens(totalInputTokens(usage));
511
+ const outTok = usage?.output_tokens != null ? formatTokens(usage.output_tokens) : '?';
512
+ const cost = computeCost(usage);
513
+ const costStr = cost != null ? `$${cost.toFixed(4)}` : '$?';
514
+ const toolsStr = tools.size > 0 ? [...tools].join(', ') : 'none';
515
+
516
+ // Dim grey so the meta line doesn't compete with the response above.
517
+ process.stderr.write(
518
+ `\x1b[90m⏱ ${secs}s · ↑${inTok} ↓${outTok} tok · ${costStr} · 🔧 ${toolsStr}\x1b[0m\n`,
519
+ );
520
+ }
521
+
522
+ /**
523
+ * --input-format=stream-json loop.
524
+ *
525
+ * Reads NDJSON user messages from stdin and runs each through a fresh
526
+ * driver request. The session id is threaded through so the conversation
527
+ * persists across messages. The shared adapter accumulates per-turn output
528
+ * (stream-json adapters emit init once on first session id, then assistant
529
+ * + result per turn).
530
+ *
531
+ * Supported message shapes:
532
+ * { "type": "user", "content": "..." }
533
+ * { "type": "user", "message": { "content": "..." } }
534
+ * Unknown shapes / unparseable lines are reported as `status` warnings on
535
+ * stderr.
536
+ */
537
+ async function runStreamJsonInputLoop({ driver, adapter, options, unknown, ac }) {
538
+ if ((options['output-format'] ?? 'text') !== 'stream-json') {
539
+ // Cross-rule R1 enforces this earlier, but double-check.
540
+ process.stderr.write('error: --input-format=stream-json requires --output-format=stream-json.\n');
541
+ return EXIT.VALIDATION_ERROR;
542
+ }
543
+
544
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
545
+ let currentSessionId = options.resume ?? null;
546
+ let lastExit = EXIT.OK;
547
+ let any = false;
548
+ ac.signal.addEventListener('abort', () => rl.close(), { once: true });
549
+
550
+ for await (const line of rl) {
551
+ if (!line.trim()) continue;
552
+ let msg;
553
+ try { msg = JSON.parse(line); } catch (e) {
554
+ process.stderr.write(`[ocp] stream-json input: invalid JSON (${e.message})\n`);
555
+ continue;
556
+ }
557
+ if (msg?.type !== 'user') {
558
+ process.stderr.write(`[ocp] stream-json input: skipping non-user message type=${JSON.stringify(msg?.type)}\n`);
559
+ continue;
560
+ }
561
+ const content =
562
+ typeof msg.content === 'string' ? msg.content
563
+ : typeof msg.message?.content === 'string' ? msg.message.content
564
+ : null;
565
+ if (!content) {
566
+ process.stderr.write('[ocp] stream-json input: user message missing string content\n');
567
+ continue;
568
+ }
569
+ any = true;
570
+ const result = await runOneTurn({
571
+ driver, adapter, options, unknown, ac,
572
+ prompt: content,
573
+ resumeOverride: currentSessionId,
574
+ });
575
+ currentSessionId = result.sessionId ?? currentSessionId;
576
+ if (options.debug) {
577
+ process.stderr.write(
578
+ `[ocp] turn done: sid=${currentSessionId} completion=${result.completionReason}\n`,
579
+ );
580
+ }
581
+ const turnExit = mapCompletionToExit(result);
582
+ if (turnExit !== EXIT.OK) lastExit = turnExit;
583
+ if (ac.signal.aborted) break;
584
+ }
585
+ if (!any) {
586
+ process.stderr.write('error: stream-json input ended with no usable user messages.\n');
587
+ return EXIT.PARSE_ERROR;
588
+ }
589
+ return lastExit;
590
+ }
591
+
592
+ function mapCompletionToExit(result) {
593
+ if (result.completionReason === 'timeout') {
594
+ printStalledOutput(result);
595
+ process.stderr.write(
596
+ 'ocp: hard timeout waiting for response. Increase OCP_MAX_RESPONSE_MS\n' +
597
+ ' if the upstream genuinely needs more time, or inspect the screen\n' +
598
+ ' above for an interactive prompt we did not recognise.\n',
599
+ );
600
+ return EXIT.TIMEOUT;
601
+ }
602
+ if (result.completionReason === 'cancelled') return EXIT.CANCELLED;
603
+ if (result.completionReason === 'trust-required') {
604
+ process.stderr.write(
605
+ 'ocp: upstream is waiting on the "Do you trust this folder?" dialog.\n' +
606
+ ' Run `OCP_AUTO_ACCEPT_TRUST=1 ocp …` to auto-accept it, or run\n' +
607
+ ' `claude` directly in this directory once and choose "Yes".\n',
608
+ );
609
+ printStalledOutput(result);
610
+ return EXIT.INTERACTIVE_REQUIRED;
611
+ }
612
+ if (result.completionReason === 'interactive-required') {
613
+ process.stderr.write(
614
+ 'ocp: no response after waiting — claude TUI appears to be blocked\n' +
615
+ ' on an interactive prompt we cannot answer (tool-permission, MCP\n' +
616
+ ' auth, login expiry, theme picker, or a dialog new to this claude\n' +
617
+ ' version). The current PTY screen is shown below so you can handle\n' +
618
+ ' it manually by running `claude` directly in this directory.\n',
619
+ );
620
+ printStalledOutput(result);
621
+ return EXIT.INTERACTIVE_REQUIRED;
622
+ }
623
+ if (result.completionReason === 'max-turns') return EXIT.GENERIC_ERROR;
624
+ return result.isError ? EXIT.GENERIC_ERROR : EXIT.OK;
625
+ }
626
+
627
+ function printStalledOutput(result) {
628
+ const tail = result.diagnostics?.stalledOutputTail;
629
+ if (!tail) return;
630
+ // ansiStripParser removes only ESC-introduced sequences. Bare C1 /
631
+ // BEL / BS / CR injected by upstream into the TUI buffer would still
632
+ // reach stderr and corrupt the user's terminal. Strip them here.
633
+ // stripTerminalControl's default max=64 truncates to uselessness for
634
+ // a full-screen TUI capture — explicitly pass the buffer cap from
635
+ // upstream so the operator sees the actual stalled screen.
636
+ const safe = stripTerminalControl(tail, Number.MAX_SAFE_INTEGER);
637
+ process.stderr.write('\n─── current claude TUI screen (tail) ───\n');
638
+ process.stderr.write(safe + '\n');
639
+ process.stderr.write('─── end ───\n\n');
640
+ }
641
+
642
+ // ── helpers ────────────────────────────────────────────────────────────
643
+
644
+ function setupSignalHandlers(ac) {
645
+ const onSig = () => {
646
+ ac.abort();
647
+ };
648
+ process.once('SIGINT', onSig);
649
+ process.once('SIGTERM', onSig);
650
+ }
651
+
652
+ async function readPackageVersion() {
653
+ try {
654
+ const here = path.dirname(fileURLToPath(import.meta.url));
655
+ const pkgPath = path.resolve(here, '..', 'package.json');
656
+ const json = JSON.parse(await readFile(pkgPath, 'utf8'));
657
+ return json.version ?? '0.0.0';
658
+ } catch {
659
+ return '0.0.0';
660
+ }
661
+ }
662
+
663
+ function readStdin() {
664
+ // Number(env) || fb accepts -1 (truthy) and absurd values silently —
665
+ // route through a positive-int validator so garbage falls back.
666
+ const rawMax = Number(process.env.OCP_MAX_STDIN_BYTES);
667
+ const MAX = (Number.isFinite(rawMax) && rawMax > 0) ? Math.floor(rawMax) : 262_144;
668
+ return new Promise((resolve, reject) => {
669
+ let data = '';
670
+ let bytes = 0;
671
+ process.stdin.setEncoding('utf8');
672
+ process.stdin.on('data', (c) => {
673
+ bytes += Buffer.byteLength(c);
674
+ if (bytes > MAX) {
675
+ process.stdin.pause();
676
+ reject(new Error(`stdin prompt exceeds ${MAX} bytes; pipe a file path or raise OCP_MAX_STDIN_BYTES`));
677
+ return;
678
+ }
679
+ data += c;
680
+ });
681
+ process.stdin.on('end', () => resolve(data.replace(/\s+$/, '')));
682
+ process.stdin.on('error', (e) => {
683
+ if (e?.code === 'EISDIR') {
684
+ reject(new Error('stdin is a directory — pass a file via `ocp < file.txt` or a positional prompt'));
685
+ } else if (e?.code === 'ENOENT') {
686
+ reject(new Error('stdin source not found'));
687
+ } else {
688
+ reject(e);
689
+ }
690
+ });
691
+ });
692
+ }
693
+
694
+ /** Generate help text by walking OPTION_SPEC. */
695
+ function buildHelp() {
696
+ const lines = [];
697
+ lines.push('Usage:');
698
+ lines.push(' ocp [options] [prompt]');
699
+ lines.push(' echo "<prompt>" | ocp [options]');
700
+ lines.push('');
701
+ lines.push('A PTY-backed compatibility shim for `claude -p` (headless / print mode).');
702
+ lines.push('`-p` / `--print` is implicit; `ocp "hi"` is equivalent to `claude -p "hi"`.');
703
+ lines.push('');
704
+ lines.push('Options:');
705
+ for (const spec of OPTION_SPEC) {
706
+ const shortPart = spec.short ? `-${spec.short}, ` : ' ';
707
+ const longPart = `--${spec.name}`;
708
+ const aliasPart = (spec.aliases ?? []).length > 0
709
+ ? ` (alias: ${spec.aliases.map((a) => `--${a}`).join(', ')})`
710
+ : '';
711
+ const valuePart =
712
+ spec.kind === 'boolean' ? ''
713
+ : spec.kind === 'enum' ? ` <${spec.choices.join('|')}>`
714
+ : spec.kind === 'array' ? ' <value>…'
715
+ : ' <value>';
716
+ const head = ` ${shortPart}${longPart}${valuePart}${aliasPart}`;
717
+ lines.push(head);
718
+ if (spec.description) {
719
+ // Indent description under the head line.
720
+ for (const w of wrap(spec.description, 76)) {
721
+ lines.push(' ' + w);
722
+ }
723
+ }
724
+ if (Object.prototype.hasOwnProperty.call(spec, 'default') && spec.default !== false) {
725
+ lines.push(` (default: ${JSON.stringify(spec.default)})`);
726
+ }
727
+ }
728
+ lines.push('');
729
+ lines.push('Environment:');
730
+ lines.push(' OCP_CLAUDE_BIN Path to upstream `claude` binary (default: `claude`).');
731
+ lines.push(' OCP_WARMUP_MS Delay before sending the prompt (default: 2500).');
732
+ lines.push(' OCP_IDLE_MS Idle silence threshold for completion (default: 1500).');
733
+ lines.push(' OCP_MAX_RESPONSE_MS Hard timeout in ms (default: 86400000 = 24 h).');
734
+ lines.push(' OCP_AUTO_ACCEPT_TRUST=1');
735
+ lines.push(' Auto-accept the upstream "Do you trust this folder?"');
736
+ lines.push(' dialog. Off by default — without this, ocp aborts fast');
737
+ lines.push(' on first use in an unknown cwd with exit code 6.');
738
+ lines.push(' OCP_DEFAULT_SKIP_PERMS=1');
739
+ lines.push(' Default `--dangerously-skip-permissions` to on for the');
740
+ lines.push(' CLI so tool calls (WebSearch, Bash, Read, Write, …) run');
741
+ lines.push(' without permission prompts. Off by default; explicit');
742
+ lines.push(' `--dangerously-skip-permissions` always wins.');
743
+ lines.push(' OCP_NO_LIVE=1 Disable the live spinner / phase indicator on stderr');
744
+ lines.push(' even when the terminal is a TTY (useful with --debug).');
745
+ lines.push('');
746
+ lines.push('Daemon (background PTY, keeps conversation alive):');
747
+ lines.push(' OCP_NO_DAEMON=1 Disable daemon; use a fresh PTY for every call.');
748
+ lines.push(' OCP_DAEMON_IDLE_MS Idle timeout before daemon exits (default: 600000 = 10 min).');
749
+ lines.push(' OCP_MAX_DAEMONS Max concurrent active+idle terminals (default: 30).');
750
+ lines.push(' ~/.ocp/ State files and sockets (one daemon per working directory).');
751
+ return lines.join('\n');
752
+ }
753
+
754
+ function wrap(s, width) {
755
+ const out = [];
756
+ let line = '';
757
+ for (const w of s.split(/\s+/)) {
758
+ if ((line + ' ' + w).trim().length > width) {
759
+ out.push(line.trim());
760
+ line = w;
761
+ } else {
762
+ line += ' ' + w;
763
+ }
764
+ }
765
+ if (line.trim()) out.push(line.trim());
766
+ return out;
767
+ }
768
+
769
+ // ── entry ──────────────────────────────────────────────────────────────
770
+
771
+ main()
772
+ .then((code) => process.exit(code))
773
+ .catch((e) => {
774
+ // Default: surface only the message — stack traces include absolute
775
+ // homedir paths, line numbers, and internal class names that leak
776
+ // architecture when users paste failures into bug reports.
777
+ // Opt in to the full stack via --debug or OCP_DEBUG=1.
778
+ const wantStack = process.argv.includes('--debug') || process.env.OCP_DEBUG === '1';
779
+ const text = wantStack ? (e?.stack || e?.message || String(e)) : (e?.message || String(e));
780
+ process.stderr.write(`ocp: ${text}\n`);
781
+ process.exit(EXIT.GENERIC_ERROR);
782
+ });