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/LICENSE +21 -0
- package/README.ja.md +708 -0
- package/README.ko.md +713 -0
- package/README.md +850 -0
- package/README.zh.md +708 -0
- package/bin/cli.js +782 -0
- package/package.json +68 -0
- package/scripts/postinstall.js +60 -0
- package/src/chat/event-filters.js +116 -0
- package/src/chat/index.js +1225 -0
- package/src/completion/detector.js +163 -0
- package/src/daemon/client.js +172 -0
- package/src/daemon/server.js +267 -0
- package/src/daemon/socket.js +78 -0
- package/src/index.js +908 -0
- package/src/options/index.js +4 -0
- package/src/options/parse-argv.js +214 -0
- package/src/options/spec.js +519 -0
- package/src/options/validate.js +104 -0
- package/src/output/index.js +8 -0
- package/src/output/json.js +83 -0
- package/src/output/registry.js +35 -0
- package/src/output/stream-json.js +111 -0
- package/src/output/text.js +94 -0
- package/src/parsers/ansi-strip.js +94 -0
- package/src/parsers/index.js +8 -0
- package/src/parsers/pipeline.js +50 -0
- package/src/parsers/registry.js +43 -0
- package/src/parsers/sentinel.js +41 -0
- package/src/parsers/tui-frame.js +256 -0
- package/src/print-mode.js +214 -0
- package/src/pty/index.js +3 -0
- package/src/pty/pool.js +127 -0
- package/src/pty/session.js +88 -0
- package/src/session-log.js +124 -0
|
@@ -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
|
+
}
|