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.
@@ -0,0 +1,1225 @@
1
+ // High-level chat client.
2
+ //
3
+ // Wraps the low-level `createDriver()` from open-claude-p with the
4
+ // "ready-to-use chat application" conveniences that the bundled sample
5
+ // server uses verbatim:
6
+ //
7
+ // - conversation persistence (file-backed JSON store, or in-memory)
8
+ // - skill loading from ~/.claude/skills/<name>/SKILL.md (path-safe)
9
+ // - per-turn `runOneShot` with sane defaults (system prompt, --resume
10
+ // threading, tool-permission skipping)
11
+ // - post-turn extraction of clean markdown + usage + tool list from
12
+ // the upstream JSONL session file
13
+ // - per-token cost calculation
14
+ //
15
+ // Usage:
16
+ // import { createChatClient } from 'open-claude-p/chat';
17
+ // const chat = createChatClient({ dangerouslySkipPermissions: true });
18
+ // const { text, conversationId, meta } = await chat.send({
19
+ // message: 'Hello',
20
+ // onEvent: (ev) => { /* spinner, assistant-text streaming, ... */ },
21
+ // });
22
+
23
+ import path from 'node:path';
24
+ import os from 'node:os';
25
+ import { AsyncLocalStorage } from 'node:async_hooks';
26
+ import { randomUUID, createHash } from 'node:crypto';
27
+ import { readFile, writeFile, mkdir, readdir, stat, lstat, chmod, realpath, rename, unlink, open } from 'node:fs/promises';
28
+ import { constants as fsConstants } from 'node:fs';
29
+ import lockfile from 'proper-lockfile';
30
+
31
+ import { createDriver } from '../index.js';
32
+ export {
33
+ cleanSpinnerLabel,
34
+ isAssistantTextNoise,
35
+ extractToolName,
36
+ stripTerminalControl,
37
+ } from './event-filters.js';
38
+ import { extractToolName, stripTerminalControl } from './event-filters.js';
39
+
40
+ const DEFAULT_DB_FILENAME = 'conversations.json';
41
+ const DEFAULT_SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills');
42
+ function envPositiveInt(name, fallback) {
43
+ const n = Number(process.env[name]);
44
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
45
+ }
46
+ const MAX_CONVERSATIONS = envPositiveInt('OCP_MAX_CONVERSATIONS', 500);
47
+ const MAX_MESSAGES_PER_CONV = envPositiveInt('OCP_MAX_MESSAGES_PER_CONV', 500);
48
+
49
+ // Module-level per-key promise chain. Used in two roles:
50
+ // (a) `withDbLock(dbPath, …)` — in-process serialisation of file IO
51
+ // for a given conversations.json. Combined with `withFileLock`
52
+ // (below) to also block other processes targeting the same file.
53
+ // (b) `withDbLock("${dbPath}::${convId}", …)` — per-conversation lock
54
+ // wrapping a whole `chat.send()` so two same-conv calls cannot
55
+ // interleave their user/assistant turns.
56
+ //
57
+ // Lock-key invariant: the per-conversation key uses `::` as separator
58
+ // (never present in absolute filesystem paths in either form), so it
59
+ // cannot collide with a bare `dbPath` key acquired by `withFileLock`.
60
+ // Keep this invariant if you ever change the key format — collision
61
+ // would deadlock (outer holds key X waiting on its inner fn, inner
62
+ // `withDbLock(X, …)` chains forever behind outer).
63
+ //
64
+ // NOTE on re-entrancy: calling `chat.send(...)` synchronously from
65
+ // inside an `onEvent` callback of an in-flight `chat.send(...)` to the
66
+ // SAME dbPath would deadlock — outer holds the lock until its driver
67
+ // finishes; inner waits for outer to release; outer can't finish
68
+ // because its driver is blocked on the inner await. Defer the inner
69
+ // call with `queueMicrotask` / `setImmediate` (fire-and-forget) if
70
+ // you need to chain, or use a different `dbPath` for the inner client.
71
+ const dbLocks = new Map();
72
+ function withDbLock(key, fn) {
73
+ const prev = dbLocks.get(key) ?? Promise.resolve();
74
+ const next = prev.then(fn, fn).finally(() => {
75
+ if (dbLocks.get(key) === next) dbLocks.delete(key);
76
+ });
77
+ dbLocks.set(key, next);
78
+ return next;
79
+ }
80
+
81
+ // In-process `withDbLock` only serialises within ONE Node process.
82
+ // Two separate processes (PM2 cluster, daemon + manual CLI, parallel
83
+ // shells) targeting the same conversations.json would still race on
84
+ // read-modify-write — the atomic tmp+rename keeps each write whole
85
+ // but the loser silently overwrites the winner with stale state.
86
+ // `withFileLock` adds a cross-process advisory lock on top, via
87
+ // proper-lockfile.
88
+ //
89
+ // Why a synthetic lock path under os.tmpdir() rather than locking the
90
+ // dbPath directly (which would create a sibling `<dbPath>.lock/` dir):
91
+ // - `<dbPath>.lock/` in the user's cwd is surprising; shows up in
92
+ // `git status`, can be accidentally committed, and leaks chat
93
+ // existence after `conversations.json` is deleted.
94
+ // - Locking `dbPath` directly requires it to exist pre-lock — eagerly
95
+ // creating `{conversations:[]}` surprises callers that probe with
96
+ // `existsSync` before deciding to chat.
97
+ //
98
+ // The lock path = sha256(realpath(dbPath)) so symlinked aliases (path A
99
+ // is a symlink whose target is path B) hash to the same lock. Plain
100
+ // `path.resolve` is purely lexical and would let A and B race.
101
+ //
102
+ // LOCK_DIR is per-uid: `open-claude-p-locks-<uid>` under tmpdir. This
103
+ // closes three multi-tenant hazards at once:
104
+ // - shared 0o700 dir owned by user A blocks user B with EACCES
105
+ // - attacker on the host pre-creating LOCK_DIR with looser perms
106
+ // bypasses the 0o700 we'd otherwise have set (mkdir doesn't chmod
107
+ // existing dirs)
108
+ // - cross-user hash enumeration of `<sha256>.lock` filenames leaks
109
+ // which dbPaths neighbours are using
110
+ // We still verify ownership + mode after mkdir as belt-and-suspenders
111
+ // in case the dir was tampered with between runs.
112
+ //
113
+ // Trade-off: locks live on tmpdir (machine-local). Two processes on
114
+ // different hosts sharing a dbPath via NFS/SMB will NOT serialise —
115
+ // but proper-lockfile is itself unsafe on NFS<v3, so this is no
116
+ // regression from the threat model.
117
+ const LOCK_UID = (typeof process.getuid === 'function') ? process.getuid() : 'nouid';
118
+ const LOCK_DIR = path.join(os.tmpdir(), `open-claude-p-locks-${LOCK_UID}`);
119
+ const SENTINEL_MAX_AGE_MS = envPositiveInt('OCP_LOCK_SWEEP_AGE_MS', 24 * 60 * 60 * 1000);
120
+
121
+ async function canonicalDbPath(filePath) {
122
+ // Prefer realpath so symlink A and target B canonicalise to one key.
123
+ // If filePath doesn't exist yet (first-ever call), realpath ENOENT —
124
+ // fall back to realpath(parent)/basename so we still canonicalise the
125
+ // dir portion (the common alias source). On macOS this is also where
126
+ // `/var → /private/var` collapses, so two callers passing the same
127
+ // logical path through different OS-level symlinks end up identical.
128
+ //
129
+ // Non-ENOENT failures (EACCES on a parent dir, ELOOP from a symlink
130
+ // cycle, ENOTDIR when a path component is actually a regular file)
131
+ // mean the dbPath is unusable for locking — wrap as a domain error so
132
+ // callers see a consistent ChatErrorCodes value instead of raw
133
+ // libuv-style codes leaking through.
134
+ try {
135
+ return await realpath(filePath);
136
+ } catch (e) {
137
+ if (e.code === 'ENOENT') {
138
+ const dir = path.dirname(filePath);
139
+ let realDir;
140
+ try { realDir = await realpath(dir); }
141
+ catch (e2) {
142
+ if (e2.code !== 'ENOENT') {
143
+ const wrapped = new Error(`chat: cannot resolve dbPath ${filePath} (${e2.code} on parent dir)`, { cause: e2 });
144
+ wrapped.code = 'ERR_CHAT_DBPATH_INVALID';
145
+ throw wrapped;
146
+ }
147
+ realDir = path.resolve(dir);
148
+ }
149
+ return path.join(realDir, path.basename(filePath));
150
+ }
151
+ const wrapped = new Error(`chat: cannot resolve dbPath ${filePath} (${e.code})`, { cause: e });
152
+ wrapped.code = 'ERR_CHAT_DBPATH_INVALID';
153
+ throw wrapped;
154
+ }
155
+ }
156
+
157
+ function lockPathForCanonical(canonical) {
158
+ const h = createHash('sha256').update(canonical).digest('hex').slice(0, 32);
159
+ return path.join(LOCK_DIR, `${h}.lock`);
160
+ }
161
+
162
+ let lockDirVerified = false;
163
+ async function ensureLockDir() {
164
+ if (lockDirVerified) return;
165
+ await mkdir(LOCK_DIR, { recursive: true, mode: 0o700 });
166
+ // mkdir is a no-op on an existing dir — re-assert mode + ownership so
167
+ // a pre-existing tampered or umask-derived dir doesn't leak listings.
168
+ const st = await lstat(LOCK_DIR);
169
+ if (st.isSymbolicLink()) {
170
+ const e = new Error(`chat: refusing to use ${LOCK_DIR} — it is a symbolic link`);
171
+ e.code = 'ERR_CHAT_LOCK_DIR_TAMPERED';
172
+ throw e;
173
+ }
174
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid()) {
175
+ const e = new Error(`chat: refusing to use ${LOCK_DIR} — owned by uid ${st.uid}, expected ${process.getuid()}`);
176
+ e.code = 'ERR_CHAT_LOCK_DIR_TAMPERED';
177
+ throw e;
178
+ }
179
+ if ((st.mode & 0o777) !== 0o700) {
180
+ await chmod(LOCK_DIR, 0o700).catch(() => {});
181
+ }
182
+ lockDirVerified = true;
183
+ }
184
+
185
+ let sentinelSwept = false;
186
+ async function sweepLockDir() {
187
+ if (sentinelSwept) return;
188
+ sentinelSwept = true;
189
+ try {
190
+ const entries = await readdir(LOCK_DIR);
191
+ if (entries.length > 4096) return; // hostile-peer guard
192
+ const now = Date.now();
193
+ await Promise.all(entries
194
+ .filter((e) => e.endsWith('.lock'))
195
+ .map(async (e) => {
196
+ const p = path.join(LOCK_DIR, e);
197
+ try {
198
+ const st = await lstat(p);
199
+ if (!st.isFile()) return; // never touch dirs/symlinks
200
+ if (now - st.mtimeMs > SENTINEL_MAX_AGE_MS) await unlink(p).catch(() => {});
201
+ } catch {}
202
+ }));
203
+ } catch {}
204
+ }
205
+
206
+ // Track in-flight lock keys per async context so a re-entrant
207
+ // `chat.send()` from within an `onEvent` callback throws a clear
208
+ // `ERR_REENTRANT_SEND` instead of deadlocking forever on its own
209
+ // outer lock. AsyncLocalStorage propagates through promise chains,
210
+ // timers, and `await`, which is exactly the surface where a consumer
211
+ // might naively re-enter.
212
+ //
213
+ // CAVEAT: ALS does NOT propagate across `worker_threads`. A driver
214
+ // that dispatches work to a worker and re-enters `chat.send` from
215
+ // that worker's context bypasses the re-entrancy guard. Same goes
216
+ // for forked child processes (which inherit the lock fd via flock
217
+ // semantics but not the ALS store). Out of scope for the SDK to
218
+ // detect; document so callers know.
219
+ const LOCK_CONTEXT = new AsyncLocalStorage();
220
+ function heldLockKeys() { return LOCK_CONTEXT.getStore() ?? new Set(); }
221
+
222
+ // Module-level constant — hoisted out of the catch block so we don't
223
+ // pay per-error Set allocation. Codes that already carry their own
224
+ // `code` value should propagate as-is rather than being re-wrapped as
225
+ // `ERR_CHAT_LOCK_FAILED`.
226
+ const PASS_THROUGH_CODES = new Set([
227
+ 'ERR_REENTRANT_SEND',
228
+ 'ERR_CHAT_LOCK_TAMPERED',
229
+ 'ERR_CHAT_LOCK_DIR_TAMPERED',
230
+ 'ERR_CHAT_LOCK_LOST',
231
+ 'ERR_CHAT_DBPATH_INVALID',
232
+ ]);
233
+ // AbortError is matched by `e.name` separately below since it has no
234
+ // `e.code` in stock Node.
235
+
236
+ // One-shot Node warning so callers that catch `chat.send` errors
237
+ // generically (no `err.code` check) still see a signal that they're
238
+ // losing user turns. Emitted via the standard process warning channel
239
+ // so it routes through any structured logger the host already wired.
240
+ let busyWarned = false;
241
+ function emitBusyWarningOnce() {
242
+ if (busyWarned) return;
243
+ busyWarned = true;
244
+ try {
245
+ process.emitWarning(
246
+ 'chat.send rejected with ERR_CHAT_BUSY — the user turn was NOT persisted. Catch the error and retry, or you will silently lose messages under cross-process contention.',
247
+ { type: 'ChatBusyDataLoss', code: 'ERR_CHAT_BUSY' },
248
+ );
249
+ } catch { /* emitWarning unavailable */ }
250
+ }
251
+
252
+ async function withFileLock(filePath, fn) {
253
+ // Canonicalise BEFORE picking the in-process key so two callers in
254
+ // one process passing symlink aliases (`./link.json` vs the realpath
255
+ // target) still queue on the same key. Without this, the in-process
256
+ // mutex would be bypassed even though the cross-process lockfile
257
+ // would catch it — silent same-process corruption.
258
+ const canonical = await canonicalDbPath(filePath);
259
+ return withDbLock(canonical, async () => {
260
+ const held = heldLockKeys();
261
+ if (held.has(`file::${canonical}`)) {
262
+ const e = new Error(`chat: re-entrant chat.send detected on ${filePath}; do not call chat.send from inside another chat.send's onEvent`);
263
+ e.code = 'ERR_REENTRANT_SEND';
264
+ throw e;
265
+ }
266
+ await ensureLockDir();
267
+ await sweepLockDir();
268
+ const lockPath = lockPathForCanonical(canonical);
269
+ // O_CREAT|O_EXCL|O_NOFOLLOW|O_WRONLY: atomic create that refuses to
270
+ // follow a pre-planted symlink at the final path component. EEXIST
271
+ // = a sibling created it first (benign). ELOOP = symlink hijack
272
+ // attempt — refuse rather than racing.
273
+ try {
274
+ const fh = await open(
275
+ lockPath,
276
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW | fsConstants.O_WRONLY,
277
+ 0o600,
278
+ );
279
+ await fh.close();
280
+ } catch (err) {
281
+ if (err.code === 'ELOOP') {
282
+ const e = new Error(`chat: refusing to lock ${filePath} — sentinel ${lockPath} is a symlink (tampering)`);
283
+ e.code = 'ERR_CHAT_LOCK_TAMPERED';
284
+ throw e;
285
+ }
286
+ if (err.code !== 'EEXIST') throw err;
287
+ // Re-verify the existing sentinel isn't a symlink (race: planted
288
+ // between sweep and our open).
289
+ const lst = await lstat(lockPath).catch(() => null);
290
+ if (lst && !lst.isFile()) {
291
+ const e = new Error(`chat: refusing to lock ${filePath} — sentinel ${lockPath} is not a regular file (tampering)`);
292
+ e.code = 'ERR_CHAT_LOCK_TAMPERED';
293
+ throw e;
294
+ }
295
+ }
296
+ let release;
297
+ let compromised = null;
298
+ const newHeld = new Set(held);
299
+ newHeld.add(`file::${canonical}`);
300
+ try {
301
+ release = await lockfile.lock(lockPath, {
302
+ retries: { retries: 30, minTimeout: 50, maxTimeout: 500, factor: 1.5 },
303
+ stale: 10_000,
304
+ realpath: false,
305
+ // Fires when proper-lockfile's internal mtime-refresh interval
306
+ // fails (e.g. tmpdir cleared mid-lock). Without a handler, the
307
+ // library emits a `compromised` event with no listeners and the
308
+ // in-flight `fn()` proceeds blind — split-brain. We capture the
309
+ // error so the outer try can rethrow after fn() completes; we
310
+ // can't interrupt fn() directly (no AbortController surface),
311
+ // but signalling via a rejected promise still aborts the writer.
312
+ onCompromised: (err) => { compromised = err; },
313
+ });
314
+ const result = await LOCK_CONTEXT.run(newHeld, fn);
315
+ if (compromised) {
316
+ const e = new Error(`chat: lock on ${filePath} was compromised mid-operation (${compromised.message || compromised.code})`);
317
+ e.code = 'ERR_CHAT_LOCK_LOST';
318
+ e.cause = compromised;
319
+ throw e;
320
+ }
321
+ return result;
322
+ } catch (e) {
323
+ // If fn threw AND the lock was also compromised mid-flight, the
324
+ // original throw is the proximate cause — but attach the
325
+ // compromised error so post-mortem still surfaces it. Without
326
+ // this, a fn() that EACCES'd because the tmpdir was cleared
327
+ // would look like a random filesystem error with no hint that
328
+ // the lock was lost.
329
+ if (compromised && e && typeof e === 'object' && !e.lockCompromised) {
330
+ try { e.lockCompromised = compromised; } catch {}
331
+ }
332
+ // Surface cross-process contention as a stable domain error.
333
+ // Caller MUST retry — Phase 1 rejected before the user turn was
334
+ // persisted, so silently swallowing this means data loss. We
335
+ // also emit a one-shot Node warning so callers that swallow all
336
+ // errors generically still see a signal in their logs.
337
+ if (e?.code === 'ELOCKED') {
338
+ const wrapped = new Error(
339
+ `chat: lock acquisition timed out for ${filePath}; message was NOT persisted — retry to record this turn`,
340
+ { cause: e },
341
+ );
342
+ wrapped.code = 'ERR_CHAT_BUSY';
343
+ wrapped.persisted = false;
344
+ // Mirror lockCompromised onto the wrap so consumers don't have
345
+ // to dig through err.cause.lockCompromised in their handler.
346
+ if (compromised) wrapped.lockCompromised = compromised;
347
+ emitBusyWarningOnce();
348
+ throw wrapped;
349
+ }
350
+ // Anything else thrown out of fn() or the lock acquire — wrap as
351
+ // a single domain error so consumers don't need to hand-classify
352
+ // libuv codes (EACCES, ENOSPC, EROFS, ENOTDIR) or chase
353
+ // programmer bugs (TypeError, ReferenceError, SyntaxError) that
354
+ // happened to escape from `fn`. Re-entrancy/tampering errors
355
+ // already have their own codes and pass through unchanged.
356
+ if (e?.code && PASS_THROUGH_CODES.has(e.code)) throw e;
357
+ if (e?.name === 'AbortError') throw e;
358
+ if (e?.__chatWrapped) throw e;
359
+ const wrapped = new Error(
360
+ `chat: lock subsystem failure for ${filePath} (${e?.code || e?.name || 'unknown'})`,
361
+ { cause: e },
362
+ );
363
+ wrapped.code = 'ERR_CHAT_LOCK_FAILED';
364
+ wrapped.__chatWrapped = true;
365
+ if (compromised) wrapped.lockCompromised = compromised;
366
+ throw wrapped;
367
+ } finally {
368
+ if (release) await release().catch(() => {});
369
+ // Best-effort sentinel cleanup once we've released. A racer that
370
+ // re-creates it via O_CREAT|O_EXCL will succeed atomically; if
371
+ // they already created the marker dir we'll skip the unlink.
372
+ try {
373
+ const markerExists = await stat(`${lockPath}.lock`).then(() => true, () => false);
374
+ if (!markerExists) await unlink(lockPath).catch(() => {});
375
+ } catch {}
376
+ }
377
+ });
378
+ }
379
+
380
+ /**
381
+ * Stable error codes thrown by `chat.send()` / `deleteConversation()`.
382
+ * Caller-checkable via `err.code === ChatErrorCodes.BUSY`, etc.
383
+ */
384
+ export const ChatErrorCodes = Object.freeze({
385
+ BUSY: 'ERR_CHAT_BUSY',
386
+ LOCK_LOST: 'ERR_CHAT_LOCK_LOST',
387
+ LOCK_TAMPERED: 'ERR_CHAT_LOCK_TAMPERED',
388
+ LOCK_DIR_TAMPERED: 'ERR_CHAT_LOCK_DIR_TAMPERED',
389
+ LOCK_FAILED: 'ERR_CHAT_LOCK_FAILED',
390
+ DBPATH_INVALID: 'ERR_CHAT_DBPATH_INVALID',
391
+ DRIVER_FAILED: 'ERR_CHAT_DRIVER_FAILED',
392
+ REENTRANT: 'ERR_REENTRANT_SEND',
393
+ INVALID_ID: 'ERR_INVALID_ID',
394
+ INVALID_MESSAGE: 'ERR_INVALID_MESSAGE',
395
+ MESSAGE_TOO_LARGE: 'ERR_MESSAGE_TOO_LARGE',
396
+ });
397
+
398
+ // Sonnet 4.x default pricing (USD per token). Override via `pricing` opt
399
+ // (createChatClient) or by passing your own rates into `computeCost`.
400
+ export const DEFAULT_PRICING = {
401
+ input: 3.00e-6,
402
+ cacheWrite: 3.75e-6,
403
+ cacheRead: 0.30e-6,
404
+ output: 15.00e-6,
405
+ };
406
+
407
+ /**
408
+ * Total input tokens for a turn = base input + cache write + cache read.
409
+ *
410
+ * @param {object|null} usage upstream `message.usage` block from the JSONL session file.
411
+ * @returns {number|null}
412
+ */
413
+ // Defensive coercion — a malicious or buggy JSONL field that holds a
414
+ // string ("3") or an absurdly large number (1e308) would otherwise
415
+ // propagate NaN / Infinity through cost / token math.
416
+ function safeTokens(v) {
417
+ const n = Number(v);
418
+ if (!Number.isFinite(n) || n < 0) return 0;
419
+ return Math.min(n, 100_000_000); // 100M token ceiling
420
+ }
421
+
422
+ export function totalInputTokens(usage) {
423
+ if (!usage) return null;
424
+ return safeTokens(usage.input_tokens)
425
+ + safeTokens(usage.cache_creation_input_tokens)
426
+ + safeTokens(usage.cache_read_input_tokens);
427
+ }
428
+
429
+ /**
430
+ * USD cost of a turn from the upstream `message.usage` block and a
431
+ * `{ input, cacheWrite, cacheRead, output }` pricing table. Returns
432
+ * `null` if usage is missing.
433
+ *
434
+ * @param {object|null} usage
435
+ * @param {object} [pricing] defaults to `DEFAULT_PRICING`
436
+ * @returns {number|null}
437
+ */
438
+ export function computeCost(usage, pricing = DEFAULT_PRICING) {
439
+ if (!usage) return null;
440
+ return safeTokens(usage.input_tokens) * pricing.input
441
+ + safeTokens(usage.cache_creation_input_tokens) * pricing.cacheWrite
442
+ + safeTokens(usage.cache_read_input_tokens) * pricing.cacheRead
443
+ + safeTokens(usage.output_tokens) * pricing.output;
444
+ }
445
+
446
+ /**
447
+ * Compact token count formatter: 41200 → `"41.2K"`, 864 → `"864"`.
448
+ * @param {number|null} n
449
+ * @returns {string}
450
+ */
451
+ export function formatTokens(n) {
452
+ if (n == null) return '?';
453
+ if (n < 1000) return String(n);
454
+ if (n < 100_000) return (n / 1000).toFixed(1) + 'K';
455
+ return Math.round(n / 1000) + 'K';
456
+ }
457
+
458
+ // Base rule the chat SDK applies to every turn — phrased as a
459
+ // behavioural principle, not a tool-by-tool recipe. The intent is the
460
+ // thing this SDK is for: an *interactive* assistant that actually uses
461
+ // its capabilities instead of declining. Caller-supplied
462
+ // `appendSystemPrompt` is APPENDED on top of this, not a replacement.
463
+ // To opt the base rule out entirely, pass `appendSystemPrompt: null`.
464
+ export const DEFAULT_APPEND_SYSTEM_PROMPT =
465
+ 'You are powering an interactive assistant. When the user asks for ' +
466
+ 'information you cannot fully answer from training data alone ' +
467
+ '(current events, real-time data, specific facts that may have ' +
468
+ 'changed, content of a URL, anything time-sensitive), use the ' +
469
+ 'appropriate available tools to look it up and answer with the ' +
470
+ 'actual values, rather than declining or returning only a list of ' +
471
+ 'links for the user to check themselves. Be thorough.\n\n' +
472
+ 'If you cannot derive an answer from your own knowledge, you MUST ' +
473
+ 'use tools like WebSearch / WebFetch to look it up and return the ' +
474
+ 'actual result.';
475
+
476
+ /**
477
+ * Compose the effective `appendSystemPrompt` from the SDK base default,
478
+ * the chat-client option, and the per-turn override.
479
+ *
480
+ * Semantics:
481
+ * - `clientOpt === undefined` → base default only
482
+ * - `clientOpt === null` → base default is suppressed for this client
483
+ * - `clientOpt === string` → base default + clientOpt
484
+ * - per-turn `null` → suppress everything for this turn
485
+ * - per-turn string → previous compose + turnOpt
486
+ */
487
+ function composeAppendSystemPrompt(clientOpt, turnOpt) {
488
+ if (turnOpt === null) return null; // explicit per-turn opt-out
489
+ const parts = [];
490
+ if (clientOpt !== null) parts.push(DEFAULT_APPEND_SYSTEM_PROMPT);
491
+ if (typeof clientOpt === 'string' && clientOpt.length > 0) parts.push(clientOpt);
492
+ if (typeof turnOpt === 'string' && turnOpt.length > 0) parts.push(turnOpt);
493
+ return parts.length > 0 ? parts.join('\n\n') : null;
494
+ }
495
+
496
+ const SENTINEL_REGEX = /⟦OCP_END:[a-f0-9]+⟧/g;
497
+
498
+ /**
499
+ * @param {object} [opts]
500
+ * @param {string} [opts.dbPath] Path to the conversations JSON file. Default `./conversations.json`
501
+ * in the process cwd, so each project keeps its own history.
502
+ * @param {string} [opts.skillsDir] Directory holding `<name>/SKILL.md` files. Default `~/.claude/skills`.
503
+ * @param {boolean} [opts.dangerouslySkipPermissions=false]
504
+ * Forward `--dangerously-skip-permissions` to upstream so tool
505
+ * calls (WebSearch, Bash, …) do not block on a permission prompt.
506
+ * @param {string|null} [opts.appendSystemPrompt] Appended to every turn's system prompt. Pass `null` to disable
507
+ * the default ("use WebSearch immediately …") text.
508
+ * @param {object} [opts.pricing] Per-token rates: `{ input, cacheWrite, cacheRead, output }`.
509
+ * @param {object} [opts.driver] An existing driver instance to reuse. If omitted, the client
510
+ * creates and owns its own driver (closed by `close()`).
511
+ * @param {object} [opts.driverOpts] Passed to `createDriver()` when no driver is supplied.
512
+ */
513
+ export function createChatClient(opts = {}) {
514
+ // Default to `<cwd>/conversations.json` so each project gets its own
515
+ // store. Pass an explicit absolute path to share one DB across
516
+ // multiple processes / cwds.
517
+ const dbPath = opts.dbPath
518
+ ? path.resolve(opts.dbPath)
519
+ : path.resolve(process.cwd(), DEFAULT_DB_FILENAME);
520
+ const skillsDir = path.resolve(opts.skillsDir ?? DEFAULT_SKILLS_DIR);
521
+ const dangerouslySkipPermissions = opts.dangerouslySkipPermissions ?? false;
522
+ // Store the raw client-level option (string | null | undefined); the
523
+ // per-turn compose merges it with the SDK base default and any
524
+ // per-turn override.
525
+ const clientAppendOpt = opts.appendSystemPrompt;
526
+ const pricing = { ...DEFAULT_PRICING, ...(opts.pricing ?? {}) };
527
+ // Sanity guard — anyone copy-pasting Anthropic's published "$3 / 1M
528
+ // tokens" rate as `input: 3` would overcharge by 1,000,000×. No
529
+ // sane per-token rate ever reaches one cent.
530
+ for (const [k, v] of Object.entries(pricing)) {
531
+ if (typeof v === 'number' && v > 1e-2) {
532
+ process.stderr.write(
533
+ `[ocp/chat] pricing.${k}=${v} looks like a per-1M-token rate. Anthropic publishes USD per 1M tokens — divide by 1e6 (e.g. 3 → 3e-6).\n`,
534
+ );
535
+ break; // one warning per createChatClient is enough
536
+ }
537
+ }
538
+ const driver = opts.driver ?? createDriver(opts.driverOpts ?? {});
539
+ const ownsDriver = !opts.driver;
540
+
541
+ // Best-effort cleanup of stale `<dbPath>.<pid>.<ts>.tmp` leftovers
542
+ // from crashed saveDB calls. Runs at most once per process per dbPath.
543
+ let tmpSwept = false;
544
+ async function sweepStaleTmp() {
545
+ if (tmpSwept) return;
546
+ tmpSwept = true;
547
+ try {
548
+ const dir = path.dirname(dbPath);
549
+ const base = path.basename(dbPath);
550
+ const entries = await readdir(dir);
551
+ const matches = entries.filter((e) => e.startsWith(`${base}.`) && e.endsWith('.tmp'));
552
+ // Hard cap to defend against a hostile/noisy peer dropping
553
+ // thousands of `<base>.999999.999999.tmp` files in dirname —
554
+ // unbounded Promise.all over readdir would EMFILE / stall the
555
+ // event loop on first chat.send.
556
+ if (matches.length > 64) matches.length = 64;
557
+ const now = Date.now();
558
+ const TMP_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour
559
+ await Promise.all(matches.map(async (e) => {
560
+ const p = path.join(dir, e);
561
+ try {
562
+ const st = await stat(p);
563
+ if (now - st.mtimeMs > TMP_MAX_AGE_MS) await unlink(p).catch(() => {});
564
+ } catch { /* ignore */ }
565
+ }));
566
+ } catch { /* ignore */ }
567
+ }
568
+
569
+ async function loadDB() {
570
+ await sweepStaleTmp();
571
+ let raw;
572
+ try { raw = await readFile(dbPath, 'utf8'); }
573
+ catch (e) {
574
+ // Only treat "file does not exist" as empty. EISDIR / EACCES /
575
+ // EIO must rethrow — otherwise saveDB later atomically renames an
576
+ // empty conversations array over the user's real history.
577
+ if (e.code === 'ENOENT') return { conversations: [] };
578
+ // Scrub error message — a same-uid attacker who can write the
579
+ // store can plant control bytes that JSON.parse / fs would
580
+ // surface raw into the user's terminal otherwise.
581
+ const scrub = (s) => String(s ?? '').replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '?').slice(0, 512);
582
+ throw new Error(`loadDB: refusing to overwrite ${dbPath} — ${e.code || e.name}: ${scrub(e.message)}`);
583
+ }
584
+ try { return JSON.parse(raw); }
585
+ catch (e) {
586
+ const scrub = (s) => String(s ?? '').replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '?').slice(0, 512);
587
+ throw new Error(`loadDB: ${dbPath} is not valid JSON (${scrub(e.message)}) — refusing to overwrite. Move or delete the file to start fresh.`);
588
+ }
589
+ }
590
+ async function saveDB(db) {
591
+ // Atomic write: stage to a sibling tmp file then rename, so a crash
592
+ // between truncation and final flush never produces a partial JSON.
593
+ // Mode 0o600 — the store contains user prompts + assistant replies
594
+ // (potentially sensitive) and other users on the host should not be
595
+ // able to read it.
596
+ await mkdir(path.dirname(dbPath), { recursive: true });
597
+ const tmp = `${dbPath}.${process.pid}.${Date.now()}.tmp`;
598
+ await writeFile(tmp, JSON.stringify(db, null, 2), { mode: 0o600 });
599
+ await rename(tmp, dbPath);
600
+ }
601
+
602
+ function resolveSkillPath(skillName) {
603
+ if (typeof skillName !== 'string') return null;
604
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(skillName)) return null;
605
+ const resolved = path.resolve(skillsDir, skillName, 'SKILL.md');
606
+ if (!resolved.startsWith(skillsDir + path.sep)) return null;
607
+ return resolved;
608
+ }
609
+
610
+ // Read a SKILL.md without following symlinks at ANY path component.
611
+ // O_NOFOLLOW only blocks the final segment, so `~/.claude/skills/foo
612
+ // → /etc` with a real SKILL.md inside would still escape. We lstat
613
+ // every segment from skillsDir down and refuse if any is a symlink.
614
+ //
615
+ // We also refuse to read from skillsDir if it (or any intermediate
616
+ // segment) is group/world-writable: a per-segment lstat + final
617
+ // O_NOFOLLOW can't close the TOCTOU window where an attacker with
618
+ // write access swaps a checked component into a symlink between our
619
+ // lstat and the final open. Refusing loose-mode skillsDir is the
620
+ // practical mitigation since Node lacks `openat`. Owner-only mode is
621
+ // also what `mkdir ~/.claude/skills` will produce under any sane
622
+ // umask, so this should never fire on a legitimate setup.
623
+ let skillsDirVerified = false;
624
+ let skillsDirWarned = false;
625
+ async function ensureSkillsDirSafe() {
626
+ if (skillsDirVerified) return true;
627
+ let reason = null;
628
+ try {
629
+ const st = await lstat(skillsDir);
630
+ if (st.isSymbolicLink()) reason = 'is a symbolic link';
631
+ else if (st.mode & 0o022) reason = `mode ${(st.mode & 0o777).toString(8)} is group/other-writable (expected 0o700 or 0o755 without write)`;
632
+ else if (typeof process.getuid === 'function' && st.uid !== process.getuid()) reason = `owned by uid ${st.uid}, expected ${process.getuid()}`;
633
+ else {
634
+ skillsDirVerified = true;
635
+ return true;
636
+ }
637
+ } catch (e) {
638
+ // ENOENT is expected on a fresh install; suppress to avoid noise.
639
+ if (e?.code !== 'ENOENT' && !skillsDirWarned) {
640
+ skillsDirWarned = true;
641
+ try { process.emitWarning(`chat: skillsDir ${skillsDir} is unreadable (${e?.code || e?.message}); skills will be silently ignored.`, { type: 'ChatSkillsDirUnsafe' }); } catch {}
642
+ }
643
+ return false;
644
+ }
645
+ if (!skillsDirWarned) {
646
+ skillsDirWarned = true;
647
+ try { process.emitWarning(`chat: skillsDir ${skillsDir} refused — ${reason}. Skills will be silently ignored until this is fixed.`, { type: 'ChatSkillsDirUnsafe' }); } catch {}
648
+ }
649
+ return false;
650
+ }
651
+ async function readSkillFile(skillPath) {
652
+ if (!await ensureSkillsDirSafe()) return null;
653
+ const rel = path.relative(skillsDir, skillPath);
654
+ const parts = rel.split(path.sep);
655
+ let probe = skillsDir;
656
+ for (let i = 0; i < parts.length - 1; i++) {
657
+ probe = path.join(probe, parts[i]);
658
+ const lst = await lstat(probe).catch(() => null);
659
+ if (!lst) return null;
660
+ if (lst.isSymbolicLink()) return null;
661
+ // Same loose-mode refusal at each level — a group-writable
662
+ // sub-dir under skillsDir is still swap-able mid-walk.
663
+ if (lst.mode & 0o022) return null;
664
+ }
665
+ let fh;
666
+ try {
667
+ fh = await open(skillPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
668
+ return await fh.readFile('utf8');
669
+ } finally {
670
+ if (fh) await fh.close().catch(() => {});
671
+ }
672
+ }
673
+
674
+ return {
675
+ /** List all conversations (metadata only). */
676
+ async listConversations() {
677
+ const db = await loadDB();
678
+ return db.conversations.map((c) => ({
679
+ id: c.id,
680
+ title: c.title,
681
+ createdAt: c.createdAt,
682
+ updatedAt: c.updatedAt,
683
+ messageCount: c.messages.length,
684
+ claudeSessionId: c.claudeSessionId,
685
+ }));
686
+ },
687
+
688
+ /** Get a conversation with full message history. Returns null if not found. */
689
+ async getConversation(id) {
690
+ const db = await loadDB();
691
+ return db.conversations.find((c) => c.id === id) ?? null;
692
+ },
693
+
694
+ /**
695
+ * Delete a conversation. Returns `true` if removed, `false` if not
696
+ * found. Also throws the same lock-related `ChatErrorCodes` as
697
+ * `chat.send()` (`BUSY`, `LOCK_LOST`, `LOCK_TAMPERED`, etc.) since
698
+ * the read-modify-write acquires the same file lock.
699
+ * @throws {Error} `ERR_INVALID_ID` — `id` is missing/empty/non-string
700
+ */
701
+ async deleteConversation(id) {
702
+ // Without this guard, `undefined` would silently match no row and
703
+ // return `false`, which the caller can't distinguish from "valid
704
+ // id, not found" — and writing `if (!await deleteConversation(x))`
705
+ // would mask programmer bugs that ought to surface.
706
+ if (typeof id !== 'string' || !id) {
707
+ const err = new Error('chat.deleteConversation: `id` must be a non-empty string');
708
+ err.code = 'ERR_INVALID_ID';
709
+ throw err;
710
+ }
711
+ let removed = false;
712
+ await withFileLock(dbPath, async () => {
713
+ const db = await loadDB();
714
+ const before = db.conversations.length;
715
+ db.conversations = db.conversations.filter((c) => c.id !== id);
716
+ removed = db.conversations.length < before;
717
+ if (removed) await saveDB(db);
718
+ });
719
+ return removed;
720
+ },
721
+
722
+ /** List installed skills with description from `SKILL.md` frontmatter. */
723
+ async listSkills() {
724
+ let entries;
725
+ try { entries = await readdir(skillsDir); } catch { return []; }
726
+ const out = [];
727
+ for (const name of entries) {
728
+ if (name.startsWith('_')) continue;
729
+ const skillPath = resolveSkillPath(name);
730
+ if (!skillPath) continue;
731
+ try {
732
+ const md = await readSkillFile(skillPath);
733
+ // readSkillFile returns null when the skill (or skillsDir)
734
+ // fails any of the safety checks. List the skill with no
735
+ // description rather than throwing in null.match — the
736
+ // outer catch would swallow but the entry would silently
737
+ // disappear, surprising callers that compare list lengths.
738
+ const m = md ? md.match(/^description:\s*(.+)$/m) : null;
739
+ out.push({ name, description: m?.[1]?.trim() || name });
740
+ } catch { /* skip */ }
741
+ }
742
+ return out;
743
+ },
744
+
745
+ /**
746
+ * Send a chat message. Creates a new conversation when `conversationId`
747
+ * is null. Resumes the upstream session for subsequent turns so the
748
+ * model preserves context.
749
+ *
750
+ * @param {object} req
751
+ * @param {string|null} [req.conversationId] Null to create a new conversation.
752
+ * @param {string} req.message User prompt text.
753
+ * @param {string|null} [req.skillName] Name under `skillsDir` whose SKILL.md is injected as system prompt.
754
+ * @param {Function} [req.onEvent] Receives `{type, …}` driver events (spinner, assistant-text, …)
755
+ * for live progress rendering.
756
+ * @param {AbortSignal} [req.signal] Abort the in-flight request.
757
+ * @param {number} [req.maxResponseMs] Override the driver's response timeout for this turn.
758
+ * @param {string} [req.appendSystemPrompt]
759
+ * Per-turn override of the client default.
760
+ * @returns {Promise<{
761
+ * conversationId: string,
762
+ * text: string,
763
+ * isNew: boolean,
764
+ * isError: boolean,
765
+ * completionReason: string,
766
+ * sessionId: string|null,
767
+ * meta: {
768
+ * elapsedMs: number,
769
+ * inputTokens: number|null,
770
+ * outputTokens: number|null,
771
+ * costUsd: number|null,
772
+ * tools: string[],
773
+ * },
774
+ * }>}
775
+ *
776
+ * @throws {Error} with `err.code` set to one of `ChatErrorCodes`:
777
+ * - `ERR_INVALID_MESSAGE` — `message` missing/empty/non-string
778
+ * - `ERR_MESSAGE_TOO_LARGE` — exceeds `OCP_MAX_MESSAGE_CHARS` (default 256 KiB)
779
+ * - `ERR_CHAT_BUSY` — cross-process lock contention after retries;
780
+ * `err.persisted === false` indicates the user
781
+ * turn was NOT saved. Caller MUST retry to
782
+ * record this turn. (Also emits a one-shot
783
+ * `ChatBusyDataLoss` Node warning so callers
784
+ * that catch generically still see the signal.)
785
+ * - `ERR_CHAT_LOCK_LOST` — proper-lockfile's mtime refresh failed mid-op
786
+ * (e.g. tmpdir cleared). Treat as transient;
787
+ * `err.cause` carries the original event.
788
+ * - `ERR_CHAT_LOCK_TAMPERED` — sentinel file is a symlink or non-regular file;
789
+ * someone is racing the lock dir.
790
+ * - `ERR_CHAT_LOCK_DIR_TAMPERED` — lock dir owned by another uid or is a
791
+ * symlink; refuse to proceed.
792
+ * - `ERR_CHAT_LOCK_FAILED` — any other lock-acquire failure (EACCES on
793
+ * LOCK_DIR, ENOSPC on tmpdir, ENOTDIR, …)
794
+ * wrapped with `err.cause` preserved.
795
+ * - `ERR_CHAT_DBPATH_INVALID` — `dbPath` cannot be canonicalised (EACCES on
796
+ * a parent dir, ELOOP from a symlink cycle,
797
+ * ENOTDIR when a component is a regular file).
798
+ * - `ERR_REENTRANT_SEND` — `chat.send()` called from within another
799
+ * `chat.send()`'s onEvent callback for the
800
+ * same dbPath. Defer with `setImmediate` or
801
+ * use a separate dbPath. NOTE: detection
802
+ * uses AsyncLocalStorage, which does not
803
+ * propagate across `worker_threads` or
804
+ * `child_process.fork()`. Re-entry from a
805
+ * worker bypasses this guard.
806
+ * - `AbortError` — `signal` was already aborted at entry.
807
+ *
808
+ * Cross-uid: each uid has its own lock dir under
809
+ * `os.tmpdir()/open-claude-p-locks-<uid>/`. Two users sharing a
810
+ * group-writable `dbPath` will NOT serialise across uids — operate
811
+ * the store as single-user.
812
+ */
813
+ async send({
814
+ conversationId = null,
815
+ message,
816
+ skillName = null,
817
+ onEvent,
818
+ signal,
819
+ maxResponseMs,
820
+ appendSystemPrompt,
821
+ }) {
822
+ if (typeof message !== 'string' || !message.trim()) {
823
+ const err = new Error('chat.send: `message` must be a non-empty string');
824
+ err.code = 'ERR_INVALID_MESSAGE';
825
+ throw err;
826
+ }
827
+ const MAX_MESSAGE_CHARS = envPositiveInt('OCP_MAX_MESSAGE_CHARS', 262_144);
828
+ if (message.length > MAX_MESSAGE_CHARS) {
829
+ const err = new Error(`chat.send: message exceeds ${MAX_MESSAGE_CHARS} chars; split it or raise OCP_MAX_MESSAGE_CHARS`);
830
+ err.code = 'ERR_MESSAGE_TOO_LARGE';
831
+ throw err;
832
+ }
833
+ // Pre-flight abort check — fail fast before persisting anything.
834
+ if (signal?.aborted) {
835
+ const err = new Error('chat.send: aborted before send');
836
+ err.name = 'AbortError';
837
+ throw err;
838
+ }
839
+
840
+ // Per-conversation serialisation. Two concurrent sends to the
841
+ // SAME conversationId would otherwise interleave Phase 1 / Phase
842
+ // 2 and corrupt transcript order (`[userA, userB, asstA, asstB]`).
843
+ // The lock key differs from `dbPath` so different conversations
844
+ // on the same store still proceed in parallel — only the inner
845
+ // Phase-1/Phase-2 file IO serialises across convs.
846
+ //
847
+ // Re-entrancy detection: track BOTH the conv key (catches same-
848
+ // conv recursion) and a coarser `dbpath::` key (catches different-
849
+ // conv recursion on the same store — without it, an `onEvent` that
850
+ // synchronously calls `chat.send` with a NEW conversationId would
851
+ // recurse forever, since each call gets a fresh UUID and the conv
852
+ // check never matches). The `file::` key inside withFileLock is
853
+ // released between Phase 1 and Phase 2, so we can't rely on it
854
+ // during the driver call window where onEvent fires.
855
+ // Canonicalise dbPath so symlink-aliased clients (one passes
856
+ // `~/conv.json`, another passes the realpath target) share the
857
+ // same `dbpath::` key. Without this, the re-entrancy guard would
858
+ // miss aliases — the file-level guard inside `withFileLock` still
859
+ // catches them, but later (Phase 1) and with less context.
860
+ const canonicalDb = await canonicalDbPath(dbPath);
861
+ const convLockKey = `${canonicalDb}::${conversationId ?? `new-${randomUUID()}`}`;
862
+ const heldNow = heldLockKeys();
863
+ if (heldNow.has(`conv::${convLockKey}`) || heldNow.has(`dbpath::${canonicalDb}`)) {
864
+ const err = new Error(`chat.send: re-entrant call detected on ${dbPath}; do not call chat.send from inside another chat.send's onEvent (defer with setImmediate or use a separate dbPath)`);
865
+ err.code = 'ERR_REENTRANT_SEND';
866
+ throw err;
867
+ }
868
+ const nextHeld = new Set(heldNow);
869
+ nextHeld.add(`conv::${convLockKey}`);
870
+ nextHeld.add(`dbpath::${canonicalDb}`);
871
+ return withDbLock(convLockKey, async () => LOCK_CONTEXT.run(nextHeld, async () => {
872
+
873
+ // Phase 1 (locked): create / fetch the conversation, append the
874
+ // user message, persist. Held only for the duration of one
875
+ // load→mutate→save round so concurrent send()s queue here for
876
+ // milliseconds, not for the duration of the LLM call below.
877
+ let conv, isNew, initialClaudeSessionId;
878
+ await withFileLock(dbPath, async () => {
879
+ const db = await loadDB();
880
+ conv = conversationId ? db.conversations.find((c) => c.id === conversationId) : null;
881
+ isNew = !conv;
882
+ if (!conv) {
883
+ conv = {
884
+ id: randomUUID(),
885
+ title: message.slice(0, 60),
886
+ claudeSessionId: null,
887
+ messages: [],
888
+ createdAt: new Date().toISOString(),
889
+ updatedAt: new Date().toISOString(),
890
+ };
891
+ db.conversations.unshift(conv);
892
+ }
893
+ conv.messages.push({ role: 'user', content: message, timestamp: new Date().toISOString() });
894
+ // Cap unbounded growth — every send() does a full read-modify-
895
+ // write of this file, so an old project's transcript grows
896
+ // O(turns) on disk and O(turns) per-call CPU. Tunable via the
897
+ // OCP_MAX_CONVERSATIONS / OCP_MAX_MESSAGES_PER_CONV envs.
898
+ if (conv.messages.length > MAX_MESSAGES_PER_CONV) {
899
+ conv.messages = conv.messages.slice(-MAX_MESSAGES_PER_CONV);
900
+ }
901
+ if (db.conversations.length > MAX_CONVERSATIONS) {
902
+ db.conversations = db.conversations.slice(0, MAX_CONVERSATIONS);
903
+ }
904
+ initialClaudeSessionId = conv.claudeSessionId;
905
+ await saveDB(db);
906
+ });
907
+
908
+ let skillContext = '';
909
+ if (skillName) {
910
+ const skillPath = resolveSkillPath(skillName);
911
+ if (skillPath) {
912
+ try { skillContext = await readSkillFile(skillPath); }
913
+ catch { /* skill not found / symlink rejected, ignore */ }
914
+ }
915
+ }
916
+
917
+ const composed = composeAppendSystemPrompt(clientAppendOpt, appendSystemPrompt);
918
+ const systemPromptParts = [
919
+ skillContext ? `# Active Skill: ${skillName}\n\n${skillContext}` : '',
920
+ composed ?? '',
921
+ ].filter(Boolean);
922
+
923
+ const tools = new Set();
924
+ let streamed = '';
925
+ const t0 = Date.now();
926
+
927
+ // Compensating rollback: if the driver throws (abort, upstream
928
+ // exit, IO error), remove the trailing user message we just
929
+ // persisted in Phase 1 so the conversation transcript doesn't
930
+ // accumulate orphan user turns with no assistant reply.
931
+ async function rollbackUserMessage() {
932
+ await withFileLock(dbPath, async () => {
933
+ const db = await loadDB();
934
+ const c = db.conversations.find((x) => x.id === conv.id);
935
+ if (!c) return;
936
+ if (c.messages.at(-1)?.role === 'user' && c.messages.at(-1)?.content === message) {
937
+ c.messages.pop();
938
+ // Drop empty conversation entirely if this was the first turn.
939
+ if (c.messages.length === 0) {
940
+ db.conversations = db.conversations.filter((x) => x.id !== c.id);
941
+ }
942
+ c.updatedAt = new Date().toISOString();
943
+ await saveDB(db);
944
+ }
945
+ }).catch((e) => {
946
+ // Don't shadow the original driver error (rethrown below) but
947
+ // surface rollback failure so a misaligned transcript isn't
948
+ // invisible — otherwise the user sees an orphan user turn
949
+ // with no assistant reply and no log explaining why.
950
+ process.stderr.write(`[ocp/chat] rollback failed for ${dbPath}: ${e?.message || e}\n`);
951
+ });
952
+ }
953
+
954
+ let result;
955
+ try {
956
+ result = await driver.runOneShot({
957
+ prompt: message,
958
+ dangerouslySkipPermissions,
959
+ abortSignal: signal,
960
+ maxResponseMs,
961
+ appendSystemPrompt: systemPromptParts.join('\n\n') || undefined,
962
+ ...(initialClaudeSessionId ? { resume: initialClaudeSessionId } : {}),
963
+ onEvent(ev) {
964
+ if (ev.type === 'assistant-text' && ev.text) {
965
+ const tn = extractToolName(ev.text);
966
+ if (tn) tools.add(tn);
967
+ // Cap the fallback buffer — only used when the JSONL session
968
+ // file isn't readable. A runaway upstream that emits MB of
969
+ // assistant text shouldn't pin GB of heap on the off-chance
970
+ // we need the buffer. Keep the tail so the last sentinel /
971
+ // useful content survives.
972
+ streamed += ev.text;
973
+ if (streamed.length > 524_288) streamed = streamed.slice(-262_144);
974
+ }
975
+ if (typeof onEvent === 'function') {
976
+ try { onEvent(ev); }
977
+ catch (e) { /* swallow consumer errors so the request continues */ }
978
+ }
979
+ },
980
+ });
981
+ } catch (e) {
982
+ await rollbackUserMessage();
983
+ // Don't re-wrap chat-domain errors / AbortError (already coded);
984
+ // wrap raw driver/upstream failures so callers can switch on a
985
+ // single error code surface without missing the upstream's
986
+ // libuv / domain-less codes.
987
+ // `startsWith('ERR_CHAT_')` matches all chat-domain codes
988
+ // including BUSY. REENTRANT lives outside that namespace so it
989
+ // gets an explicit check.
990
+ if (
991
+ (e?.code && (e.code.startsWith('ERR_CHAT_') || e.code === 'ERR_REENTRANT_SEND'))
992
+ || e?.name === 'AbortError'
993
+ ) {
994
+ throw e;
995
+ }
996
+ // Sanitize the upstream message — it may carry C0/C1/DEL bytes
997
+ // (ANSI escape sequences, BEL, raw cursor moves) that would
998
+ // corrupt terminals or log parsers when the wrapped error is
999
+ // printed downstream. The original `e` rides on `cause` so
1000
+ // debuggers still see the raw bytes.
1001
+ const rawMsg = e?.message ?? String(e);
1002
+ const safeMsg = String(rawMsg).replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '?').slice(0, 512);
1003
+ const wrapped = new Error(`chat.send: driver failure (${e?.code || e?.name || 'unknown'}): ${safeMsg}`, { cause: e });
1004
+ wrapped.code = 'ERR_CHAT_DRIVER_FAILED';
1005
+ if (e?.completionReason) wrapped.completionReason = e.completionReason;
1006
+ throw wrapped;
1007
+ }
1008
+
1009
+ const elapsed = Date.now() - t0;
1010
+
1011
+ // Prefer the JSONL session file's clean markdown over the
1012
+ // PTY-extracted text — the JSONL has real hyperlinks and no TUI
1013
+ // artifacts. Fall back to the buffer-scanned text if the JSONL is
1014
+ // unavailable (e.g. session id never landed).
1015
+ const sessionRead = await readSessionText(result.sessionId, t0);
1016
+ const finalText = sessionRead?.text
1017
+ || cleanResponse(result.text || streamed)
1018
+ || '';
1019
+ const usage = sessionRead?.usage ?? null;
1020
+ for (const t of sessionRead?.tools ?? []) tools.add(t);
1021
+ if ((usage?.server_tool_use?.web_search_requests ?? 0) > 0) tools.add('web_search');
1022
+ if ((usage?.server_tool_use?.web_fetch_requests ?? 0) > 0) tools.add('web_fetch');
1023
+
1024
+ const inputTokens = totalInputTokens(usage);
1025
+ const outputTokens = usage?.output_tokens ?? null;
1026
+ const costUsd = computeCost(usage, pricing);
1027
+
1028
+ // Phase 2 (locked): re-load the DB so any concurrent send() that
1029
+ // appended in the meantime is preserved, find our conversation
1030
+ // by id, append the assistant message, persist.
1031
+ if (!result.isError && finalText) {
1032
+ await withFileLock(dbPath, async () => {
1033
+ const db = await loadDB();
1034
+ const c = db.conversations.find((x) => x.id === conv.id);
1035
+ if (!c) return; // conversation was deleted while we were waiting
1036
+ c.claudeSessionId = result.sessionId ?? c.claudeSessionId;
1037
+ c.messages.push({
1038
+ role: 'assistant',
1039
+ content: finalText,
1040
+ timestamp: new Date().toISOString(),
1041
+ });
1042
+ c.updatedAt = new Date().toISOString();
1043
+ await saveDB(db);
1044
+ });
1045
+ }
1046
+
1047
+ return {
1048
+ conversationId: conv.id,
1049
+ text: finalText,
1050
+ isNew,
1051
+ isError: result.isError,
1052
+ completionReason: result.completionReason,
1053
+ sessionId: result.sessionId,
1054
+ meta: { elapsedMs: elapsed, inputTokens, outputTokens, costUsd, tools: [...tools] },
1055
+ };
1056
+ })); // end per-conversation lock + AsyncLocalStorage scope
1057
+ },
1058
+
1059
+ /** Close the underlying driver (and pooled PTYs) if owned by this client. */
1060
+ async close() {
1061
+ if (ownsDriver) await driver.close();
1062
+ },
1063
+ };
1064
+ }
1065
+
1066
+ /**
1067
+ * Read the last assistant message + usage + tool list from the upstream
1068
+ * `~/.claude/projects/<cwd>/<uuid>.jsonl` session file. The JSONL has
1069
+ * clean markdown (no TUI artifacts) and accurate token counts.
1070
+ *
1071
+ * Resumed sessions may create a new JSONL with a different UUID, so we
1072
+ * first try the named session file and then fall back to the most
1073
+ * recently modified file written during or after `t0`.
1074
+ *
1075
+ * @param {string|null} sessionId
1076
+ * @param {number} [t0=0] Lower bound on message timestamp (ms).
1077
+ * @param {string} [cwd] Directory used to derive the project dir key.
1078
+ * @returns {Promise<{ text: string, usage: object|null, tools: string[] } | null>}
1079
+ */
1080
+ export async function readSessionText(sessionId, t0 = 0, cwd = process.cwd()) {
1081
+ // Claude encodes the project cwd into a single token used as the
1082
+ // directory name under ~/.claude/projects/. The mapping replaces BOTH
1083
+ // path separators (`/`) and underscores (`_`) with a literal `-`, so
1084
+ // `/Users/alice/gen_keypair` lands in `-Users-alice-gen-keypair/`. The
1085
+ // earlier "slash-only" version missed any cwd containing `_` and
1086
+ // silently fell back to the raw PTY text.
1087
+ const cwdKey = path.resolve(cwd).replace(/[/_]/g, '-');
1088
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', cwdKey);
1089
+
1090
+ async function extractFromFile(filePath, minTimestampMs) {
1091
+ // Long-lived conversations grow JSONLs to tens / hundreds of MB.
1092
+ // A full readFile per send() balloons heap; tail-read past the
1093
+ // size cap and parse only what we can fit.
1094
+ const MAX_JSONL_BYTES = envPositiveInt('OCP_MAX_JSONL_BYTES', 8 * 1024 * 1024);
1095
+ let raw;
1096
+ try {
1097
+ const st = await stat(filePath);
1098
+ if (st.size <= MAX_JSONL_BYTES) {
1099
+ raw = await readFile(filePath, 'utf8');
1100
+ } else {
1101
+ // Open + read the last MAX_JSONL_BYTES; drop the first
1102
+ // (almost certainly partial) line so JSON.parse doesn't choke.
1103
+ const { open } = await import('node:fs/promises');
1104
+ const fh = await open(filePath, 'r');
1105
+ try {
1106
+ const buf = Buffer.alloc(MAX_JSONL_BYTES);
1107
+ const start = Math.max(0, st.size - MAX_JSONL_BYTES);
1108
+ await fh.read(buf, 0, MAX_JSONL_BYTES, start);
1109
+ raw = buf.toString('utf8');
1110
+ const nl = raw.indexOf('\n');
1111
+ if (nl >= 0) raw = raw.slice(nl + 1);
1112
+ } finally { await fh.close(); }
1113
+ }
1114
+ } catch { return null; }
1115
+ const lines = raw.split('\n').filter((l) => l.trim());
1116
+
1117
+ const tools = new Set();
1118
+ for (const line of lines) {
1119
+ try {
1120
+ const ev = JSON.parse(line);
1121
+ if (ev.message?.role === 'assistant' && Array.isArray(ev.message.content)) {
1122
+ for (const block of ev.message.content) {
1123
+ // Sanitise early — `block.name` is upstream-controlled JSON
1124
+ // and could contain terminal-control sequences that would
1125
+ // execute when echoed to stderr / SSE downstream.
1126
+ if (block.type === 'tool_use' && block.name) {
1127
+ const safe = stripTerminalControl(block.name);
1128
+ if (safe) tools.add(safe);
1129
+ }
1130
+ }
1131
+ }
1132
+ } catch { /* skip non-JSON line */ }
1133
+ }
1134
+
1135
+ for (let i = lines.length - 1; i >= 0; i--) {
1136
+ try {
1137
+ const ev = JSON.parse(lines[i]);
1138
+ if (ev.message?.role !== 'assistant') continue;
1139
+ if (minTimestampMs > 0 && ev.timestamp
1140
+ && new Date(ev.timestamp).getTime() < minTimestampMs) continue;
1141
+ const textBlock = ev.message.content?.find?.((c) => c.type === 'text');
1142
+ if (textBlock?.text) {
1143
+ return {
1144
+ text: textBlock.text.replace(SENTINEL_REGEX, '').trim(),
1145
+ usage: ev.message.usage ?? null,
1146
+ tools: [...tools],
1147
+ };
1148
+ }
1149
+ } catch { /* skip */ }
1150
+ }
1151
+ return null;
1152
+ }
1153
+
1154
+ if (sessionId) {
1155
+ const r = await extractFromFile(path.join(projectDir, `${sessionId}.jsonl`), t0);
1156
+ if (r) return r;
1157
+ }
1158
+
1159
+ let files;
1160
+ try { files = (await readdir(projectDir)).filter((f) => f.endsWith('.jsonl')); }
1161
+ catch { return null; }
1162
+ // A long-lived project accumulates one JSONL per session. We need to
1163
+ // pick the most-recently-modified, but dirent return order is not
1164
+ // reliably creation-ordered on XFS, hashed APFS variants, NFS, or
1165
+ // SMB — so we can't just slice the tail and assume freshness. Stat-
1166
+ // and-sort by mtime instead.
1167
+ //
1168
+ // EMFILE guard: macOS default `ulimit -n` is 256, so a single
1169
+ // unbounded `Promise.all(stat)` over 2k files can saturate FDs and
1170
+ // start failing concurrent fs ops elsewhere in the process. We cap
1171
+ // the dirent set AND batch the stats so peak concurrency stays well
1172
+ // below typical limits.
1173
+ // 512 dirents covers any realistic per-project session count
1174
+ // (one JSONL per session resume) while keeping the worst-case stat
1175
+ // latency bounded (~512/32 batches × libuv-pool=4 stat budget).
1176
+ const MAX_PROJECT_FILES = 512;
1177
+ const STAT_BATCH = 32;
1178
+ if (files.length > MAX_PROJECT_FILES) files = files.slice(0, MAX_PROJECT_FILES);
1179
+ const stats = [];
1180
+ for (let i = 0; i < files.length; i += STAT_BATCH) {
1181
+ const chunk = files.slice(i, i + STAT_BATCH);
1182
+ const batch = await Promise.all(chunk.map(async (f) => {
1183
+ try { return { f, mtime: (await stat(path.join(projectDir, f))).mtimeMs }; }
1184
+ catch { return null; }
1185
+ }));
1186
+ for (const c of batch) if (c) stats.push(c);
1187
+ }
1188
+ const recent = stats
1189
+ .filter((c) => c.mtime >= t0)
1190
+ .sort((a, b) => b.mtime - a.mtime);
1191
+ for (const { f } of recent.slice(0, 5)) {
1192
+ const r = await extractFromFile(path.join(projectDir, f), t0);
1193
+ if (r) return r;
1194
+ }
1195
+ return null;
1196
+ }
1197
+
1198
+ /**
1199
+ * Strip TUI chrome (prompt chars, box borders, status bars, mode lines)
1200
+ * from the PTY-stripped buffer text. Use as a fallback when the JSONL
1201
+ * session file is unavailable.
1202
+ *
1203
+ * @param {string} text
1204
+ * @returns {string}
1205
+ */
1206
+ export function cleanResponse(text) {
1207
+ return String(text ?? '')
1208
+ .replace(/\r/g, '\n')
1209
+ .split('\n')
1210
+ .map((l) => l.trimEnd())
1211
+ .filter((line) => {
1212
+ const t = line.trim();
1213
+ if (/^[❯›❮‹]\s*$/.test(t)) return false;
1214
+ if (/^─{5,}/.test(t)) return false;
1215
+ if (/^\[.*\]\s*[│|]/.test(t)) return false;
1216
+ if (/^Context\s/.test(t)) return false;
1217
+ if (/^⏵/.test(t)) return false;
1218
+ if (/^◉\s/.test(t)) return false;
1219
+ return true;
1220
+ })
1221
+ .join('\n')
1222
+ .replace(/\n{3,}/g, '\n\n')
1223
+ .replace(SENTINEL_REGEX, '')
1224
+ .trim();
1225
+ }