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,163 @@
1
+ // Response-completion detector.
2
+ //
3
+ // Combines three signals to decide when a request's response is finished:
4
+ //
5
+ // 1. Sentinel match (gated). A sentinel event with the request's nonce
6
+ // counts only if `assistant-region-entered` has already fired,
7
+ // ignoring the pre-response echo of the prompt input (see §7.1).
8
+ // After a sentinel match we still wait `idleMs` for any trailing
9
+ // output before declaring completion.
10
+ //
11
+ // 2. Idle silence. If at least one assistant-text event has fired and
12
+ // `idleMs` elapses without further events, complete with reason
13
+ // 'idle' (defensive fallback when the sentinel is dropped).
14
+ //
15
+ // 3. Hard timeout. Always completes after `maxResponseMs` to bound
16
+ // worst-case wait.
17
+ //
18
+ // `cancel()` short-circuits to completion with `reason='cancelled'`,
19
+ // `isError=true`.
20
+ //
21
+ // Completion is reported by the promise returned from `done()`.
22
+
23
+ export class CompletionDetector {
24
+ /**
25
+ * @param {object} opts
26
+ * @param {string} opts.nonce per-request sentinel nonce
27
+ * @param {number} [opts.idleMs=1500] idle silence after a sentinel
28
+ * match, before declaring done
29
+ * @param {number} [opts.preIdleMs=8000] idle silence BEFORE sentinel,
30
+ * used as a defensive fallback
31
+ * when the model drops the marker.
32
+ * @param {number} [opts.maxResponseMs=60000]
33
+ * @param {number} [opts.maxTurns] max number of assistant turns
34
+ * before the request is aborted
35
+ * (shim-enforced `--max-turns`).
36
+ * Each `⏺` region counts as one
37
+ * turn; when the (N+1)th turn
38
+ * opens, the request is aborted
39
+ * with reason 'max-turns'.
40
+ */
41
+ constructor({
42
+ nonce,
43
+ idleMs = 1500,
44
+ preIdleMs = 8000,
45
+ maxResponseMs = 60000,
46
+ maxTurns,
47
+ } = {}) {
48
+ if (!nonce) throw new Error('CompletionDetector: nonce is required');
49
+ this.nonce = nonce;
50
+ this.idleMs = idleMs;
51
+ this.preIdleMs = preIdleMs;
52
+ this.maxResponseMs = maxResponseMs;
53
+ this.maxTurns = Number.isFinite(maxTurns) && maxTurns >= 0 ? maxTurns : null;
54
+
55
+ this.startTime = Date.now();
56
+ this.regionEntered = false;
57
+ this.hadAssistantText = false;
58
+ this.sentinelMatched = false;
59
+ this.turnsEntered = 0;
60
+ this.lastEventTime = this.startTime;
61
+
62
+ this.completion = null;
63
+ this._resolve = null;
64
+ this._promise = new Promise((r) => { this._resolve = r; });
65
+
66
+ this._tick = setInterval(() => this._onTick(), 100);
67
+ this._hardTimeout = setTimeout(
68
+ () => this._complete('timeout', true),
69
+ this.maxResponseMs,
70
+ );
71
+ }
72
+
73
+ /**
74
+ * Mark generic activity (e.g. a raw PTY data chunk that produced no
75
+ * events). Without this, a slow-streaming response that emits no
76
+ * intermediate events between `assistant-region-entered` and `sentinel`
77
+ * can trip the idle fallback prematurely — for example when the upstream
78
+ * CLI is rendering long conversation history during a `--resume`.
79
+ */
80
+ markActivity() {
81
+ if (this.completion) return;
82
+ this.lastEventTime = Date.now();
83
+ }
84
+
85
+ /** Feed a parsed event into the detector. */
86
+ onEvent(e) {
87
+ if (this.completion) return;
88
+ this.lastEventTime = Date.now();
89
+
90
+ if (e.type === 'assistant-region-entered') {
91
+ this.regionEntered = true;
92
+ this.hadAssistantText = true;
93
+ this.turnsEntered += 1;
94
+ if (this.maxTurns !== null && this.turnsEntered > this.maxTurns) {
95
+ this._complete('max-turns', true);
96
+ }
97
+ return;
98
+ }
99
+
100
+ if (
101
+ e.type === 'sentinel' &&
102
+ e.nonce === this.nonce &&
103
+ this.regionEntered
104
+ ) {
105
+ // Any post-region sentinel match counts. We do NOT complete here
106
+ // because, in `--resume` workflows, both the prompt-echo and the
107
+ // model's real response can carry the same nonce. Instead we just
108
+ // flip sentinelMatched and let the tick-based idle window decide:
109
+ // completion fires only after `idleMs` of true silence — which means
110
+ // all subsequent sentinels (including any later "real" one) have
111
+ // already arrived. The text extractor then uses the LAST sentinel
112
+ // occurrence in the buffer to find the response.
113
+ this.sentinelMatched = true;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Cancel from the outside (AbortSignal, SIGINT, upstream exit, …).
119
+ * @param {string} [reason='cancelled']
120
+ */
121
+ cancel(reason = 'cancelled') {
122
+ this._complete(reason, true);
123
+ }
124
+
125
+ /** Resolves once a completion decision has been reached. */
126
+ done() {
127
+ return this._promise;
128
+ }
129
+
130
+ // ── internal ─────────────────────────────────────────────────────────
131
+ _onTick() {
132
+ if (this.completion) return;
133
+ const idleFor = Date.now() - this.lastEventTime;
134
+ if (this.sentinelMatched) {
135
+ // Once we've seen at least one post-region sentinel, complete after
136
+ // `idleMs` of true silence (any chunk resets via markActivity()).
137
+ if (idleFor >= this.idleMs) {
138
+ this._complete('sentinel', false);
139
+ }
140
+ return;
141
+ }
142
+ // Pre-sentinel fallback: only valid once we've seen any assistant
143
+ // signal, with the longer `preIdleMs` threshold so that brief render
144
+ // pauses (common during `--resume`) do not trip premature completion.
145
+ if (this.hadAssistantText) {
146
+ if (idleFor >= this.preIdleMs) {
147
+ this._complete('idle', false);
148
+ }
149
+ }
150
+ }
151
+
152
+ _complete(reason, isError) {
153
+ if (this.completion) return;
154
+ this.completion = { reason, isError };
155
+ clearInterval(this._tick);
156
+ clearTimeout(this._hardTimeout);
157
+ this._resolve({
158
+ reason,
159
+ isError,
160
+ durationMs: Date.now() - this.startTime,
161
+ });
162
+ }
163
+ }
@@ -0,0 +1,172 @@
1
+ // Daemon client — connects to the running daemon or starts one.
2
+ //
3
+ // sendToDaemon(sockPath, req, daemonOpts, key):
4
+ // 1. Try to connect to an existing daemon (IDLE state).
5
+ // 2. If no daemon: read state file for resumeSessionId (INACTIVE state),
6
+ // spawn daemon, wait for ready signal, retry.
7
+ // 3. Send request JSON, receive result JSON.
8
+
9
+ import net from 'node:net';
10
+ import { spawn } from 'node:child_process';
11
+ import { readdir } from 'node:fs/promises';
12
+ import { fileURLToPath } from 'node:url';
13
+ import path from 'node:path';
14
+ import { OCP_DIR, ensureOcpDir, readState } from './socket.js';
15
+
16
+ const DEFAULT_MAX_DAEMONS = 30;
17
+
18
+ const SERVER_JS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), 'server.js');
19
+
20
+ /**
21
+ * @param {string} sockPath
22
+ * @param {object} req request body forwarded to driver.runOneShot
23
+ * @param {object} daemonOpts options passed to the daemon on first start
24
+ * @param {string} key daemon key (for state file lookup)
25
+ * @returns {Promise<object>} result from the daemon
26
+ */
27
+ export async function sendToDaemon(sockPath, req, daemonOpts, key) {
28
+ await ensureOcpDir();
29
+
30
+ // Happy path — daemon already IDLE.
31
+ try {
32
+ return await sendOnSocket(sockPath, req);
33
+ } catch (e) {
34
+ if (e.code !== 'ENOENT' && e.code !== 'ECONNREFUSED') throw e;
35
+ }
36
+
37
+ // No daemon running — check for INACTIVE state (saved sessionId).
38
+ const state = await readState(key);
39
+ const resumeSessionId = state?.sessionId ?? null;
40
+
41
+ await startDaemon(sockPath, key, { ...daemonOpts, resumeSessionId });
42
+
43
+ return await sendOnSocket(sockPath, req);
44
+ }
45
+
46
+ // ── internals ─────────────────────────────────────────────────────────────
47
+
48
+ // Cap the response we'll buffer from the daemon. The daemon is same-uid
49
+ // trust but a corrupted / malicious / squatting socket could trickle MB
50
+ // of bytes without ending and OOM the client. 64 MiB is well above any
51
+ // legitimate response (events array + stalledOutputTail + diagnostics)
52
+ // while bounding the worst case.
53
+ function envPositiveIntLocal(name, fallback) {
54
+ const n = Number(process.env[name]);
55
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
56
+ }
57
+ const MAX_DAEMON_RESPONSE_BYTES = envPositiveIntLocal('OCP_DAEMON_MAX_RESPONSE_BYTES', 64 * 1024 * 1024);
58
+
59
+ function sendOnSocket(sockPath, req) {
60
+ return new Promise((resolve, reject) => {
61
+ const socket = net.createConnection(sockPath);
62
+ let buf = '';
63
+ let recv = 0;
64
+ let killed = false;
65
+
66
+ socket.setEncoding('utf8');
67
+ socket.on('connect', () => {
68
+ socket.write(JSON.stringify(req));
69
+ socket.end(); // signal end of request (server reads until 'end')
70
+ });
71
+ socket.on('data', (c) => {
72
+ if (killed) return;
73
+ // `setEncoding('utf8')` makes `c` a string of code units, which
74
+ // can be up to 4× lighter than the actual byte cost of multibyte
75
+ // characters. Use Buffer.byteLength so the cap is an honest byte
76
+ // cap, not a code-unit approximation.
77
+ recv += Buffer.byteLength(c, 'utf8');
78
+ if (recv > MAX_DAEMON_RESPONSE_BYTES) {
79
+ killed = true;
80
+ try { socket.destroy(); } catch {}
81
+ const e = new Error(`daemon response exceeded ${MAX_DAEMON_RESPONSE_BYTES} bytes; refusing to buffer further`);
82
+ e.code = 'ERR_DAEMON_RESPONSE_TOO_LARGE';
83
+ reject(e);
84
+ return;
85
+ }
86
+ buf += c;
87
+ });
88
+ socket.on('end', () => {
89
+ if (killed) return;
90
+ try {
91
+ const result = JSON.parse(buf.trim());
92
+ if (result.error) {
93
+ const err = new Error(result.error);
94
+ err.fromDaemon = true;
95
+ reject(err);
96
+ } else {
97
+ resolve(result);
98
+ }
99
+ } catch (e) {
100
+ // A same-uid peer squatting on the socket path can write any
101
+ // bytes including C0/C1/DEL/ANSI sequences; JSON.parse's error
102
+ // message quotes a snippet of that buffer, which would land in
103
+ // the user's terminal via the CLI's stderr printer and could
104
+ // re-emit cursor moves or BEL. Scrub before embedding.
105
+ const rawMsg = e?.message ?? String(e);
106
+ const safeMsg = String(rawMsg).replace(/[\x00-\x1f\x7f\x80-\x9f]/g, '?').slice(0, 512);
107
+ reject(new Error(`daemon response parse failed: ${safeMsg}`));
108
+ }
109
+ });
110
+ socket.on('error', reject);
111
+ });
112
+ }
113
+
114
+ async function countRunningDaemons() {
115
+ try {
116
+ const files = await readdir(OCP_DIR);
117
+ return files.filter((f) => f.startsWith('d-') && f.endsWith('.sock')).length;
118
+ } catch {
119
+ return 0;
120
+ }
121
+ }
122
+
123
+ async function startDaemon(sockPath, key, daemonOpts) {
124
+ // Number(env) → NaN for non-numeric input ("abc", "") and the
125
+ // `running >= NaN` check silently disables the cap. Coerce via the
126
+ // same positive-int helper used elsewhere so garbage values fall
127
+ // back to DEFAULT_MAX_DAEMONS instead of unbounded growth.
128
+ const rawLimit = process.env.OCP_MAX_DAEMONS;
129
+ const parsed = Number(rawLimit);
130
+ const limit = (Number.isFinite(parsed) && parsed > 0) ? Math.floor(parsed) : DEFAULT_MAX_DAEMONS;
131
+ const running = await countRunningDaemons();
132
+ if (running >= limit) {
133
+ throw new Error(
134
+ `terminal limit reached: ${running}/${limit} sessions are active or idle.\n` +
135
+ `Stop unused sessions or raise the limit with OCP_MAX_DAEMONS=<n>.`,
136
+ );
137
+ }
138
+
139
+ const child = spawn(
140
+ process.execPath,
141
+ [SERVER_JS, sockPath, key, JSON.stringify(daemonOpts)],
142
+ {
143
+ detached: true,
144
+ stdio: ['ignore', 'pipe', 'ignore'],
145
+ },
146
+ );
147
+
148
+ await new Promise((resolve, reject) => {
149
+ const timer = setTimeout(
150
+ () => reject(new Error('daemon start timeout (10s)')),
151
+ 10_000,
152
+ );
153
+ let buf = '';
154
+ child.stdout.setEncoding('utf8');
155
+ child.stdout.on('data', (c) => {
156
+ buf += c;
157
+ if (buf.includes('ready')) {
158
+ clearTimeout(timer);
159
+ resolve();
160
+ }
161
+ });
162
+ child.on('error', (e) => { clearTimeout(timer); reject(e); });
163
+ child.on('exit', (code) => {
164
+ if (code !== 0 && code !== null) {
165
+ clearTimeout(timer);
166
+ reject(new Error(`daemon exited early (code ${code})`));
167
+ }
168
+ });
169
+ });
170
+
171
+ child.unref(); // let daemon outlive this process
172
+ }
@@ -0,0 +1,267 @@
1
+ #!/usr/bin/env node
2
+ // Daemon server — manages a single claude PTY and keeps it alive between
3
+ // ocp invocations.
4
+ //
5
+ // Lifecycle states (as seen from outside):
6
+ // ACTIVE — currently processing a request
7
+ // IDLE — PTY running in background, waiting for next command (timer active)
8
+ // INACTIVE — daemon exited, sessionId saved to state file for --resume
9
+ //
10
+ // Started by src/daemon/client.js. Do not run directly.
11
+ // argv: <socketPath> <key> <daemonOptsJSON>
12
+
13
+ import net from 'node:net';
14
+ import { unlink, chmod } from 'node:fs/promises';
15
+ import { randomUUID } from 'node:crypto';
16
+ import { createDriver } from '../index.js';
17
+ import { writeState, clearState, resolveCwd } from './socket.js';
18
+ import { recordSession } from '../session-log.js';
19
+
20
+ const [,, socketPath, key, optsJson] = process.argv;
21
+ if (!socketPath || !key) { process.stderr.write('daemon: missing args\n'); process.exit(1); }
22
+
23
+ // A long-lived daemon must not die to an unhandled rejection — log and
24
+ // exit cleanly so the next client invocation spawns a fresh one rather
25
+ // than connecting to a half-dead socket.
26
+ process.on('unhandledRejection', (r) => {
27
+ process.stderr.write(`[ocp-daemon] unhandled rejection: ${r?.stack || r?.message || r}\n`);
28
+ process.exit(1);
29
+ });
30
+ process.on('uncaughtException', (e) => {
31
+ process.stderr.write(`[ocp-daemon] uncaught exception: ${e?.stack || e?.message || e}\n`);
32
+ process.exit(1);
33
+ });
34
+
35
+ // Refuse to honour the global argv-sanitizer opt-out inside the daemon.
36
+ // The daemon is long-lived and shared across invocations — letting it
37
+ // inherit OCP_ALLOW_UNSAFE_ARGV from whichever shell first started it
38
+ // creates a surprise-bypass for subsequent unrelated clients. Callers
39
+ // that genuinely need the opt-out must run with OCP_NO_DAEMON=1 to take
40
+ // the direct path.
41
+ delete process.env.OCP_ALLOW_UNSAFE_ARGV;
42
+
43
+ const opts = JSON.parse(optsJson ?? '{}');
44
+
45
+ function envPositiveInt(name, fallback) {
46
+ const raw = process.env[name];
47
+ if (raw === undefined) return fallback;
48
+ const n = Number(raw);
49
+ return Number.isFinite(n) && n > 0 ? n : fallback;
50
+ }
51
+
52
+ // Default 10-minute idle timeout. Each incoming request resets the timer.
53
+ // Guard against malformed env (NaN / negative / empty) collapsing the timer
54
+ // to zero and defeating the daemon's purpose.
55
+ const IDLE_MS = envPositiveInt('OCP_DAEMON_IDLE_MS', 600_000);
56
+
57
+ // Cap a single IPC request body. A same-user process flooding the socket
58
+ // could otherwise OOM the daemon by streaming bytes without ever ending
59
+ // the message.
60
+ const MAX_REQ_BYTES = envPositiveInt('OCP_DAEMON_MAX_REQ_BYTES', 4 * 1024 * 1024);
61
+ const SOCKET_IDLE_MS = envPositiveInt('OCP_DAEMON_SOCKET_TIMEOUT_MS', 30_000);
62
+ // Total lifetime cap on a single connection — guards against slow-loris
63
+ // where a peer trickles bytes just often enough to keep idle timeout
64
+ // from firing. Five minutes is far longer than any legitimate request.
65
+ const SOCKET_MAX_LIFETIME_MS = envPositiveInt('OCP_DAEMON_SOCKET_MAX_LIFETIME_MS', 300_000);
66
+
67
+ // Pool size = max number of warm PTYs the daemon can park concurrently.
68
+ // Set to MAX_PARALLEL so concurrent fresh requests (no resume/session-id)
69
+ // can all park their PTYs on release rather than being killed because the
70
+ // pool was full — the next round of fresh calls then gets warm reuse.
71
+ // The pool TTL is set longer than the daemon's idle timer so the pool
72
+ // never kills the PTY independently — the daemon's own timer controls
73
+ // shutdown.
74
+ const MAX_PARALLEL = envPositiveInt('OCP_DAEMON_MAX_PARALLEL', 8);
75
+ const driver = createDriver({
76
+ claudeBin: opts.claudeBin,
77
+ cwd: opts.cwd,
78
+ debug: opts.debug,
79
+ poolSize: MAX_PARALLEL,
80
+ poolMaxAgeMs: IDLE_MS + 60_000,
81
+ initialSessionId: opts.resumeSessionId ?? null,
82
+ });
83
+
84
+ let lastSessionId = opts.resumeSessionId ?? null;
85
+ let idleTimer;
86
+
87
+ function resetIdle() {
88
+ clearTimeout(idleTimer);
89
+ idleTimer = setTimeout(async () => {
90
+ // IDLE → INACTIVE: save session for future --resume, then exit.
91
+ await saveStateAndExit();
92
+ }, IDLE_MS);
93
+ if (idleTimer.unref) idleTimer.unref();
94
+ }
95
+
96
+ async function saveStateAndExit() {
97
+ if (lastSessionId) {
98
+ await writeState(key, { sessionId: lastSessionId, exitedAt: Date.now() }).catch(() => {});
99
+ }
100
+ await driver.close().catch(() => {});
101
+ try { await unlink(socketPath); } catch {}
102
+ process.exit(0);
103
+ }
104
+
105
+ process.on('SIGTERM', saveStateAndExit);
106
+ process.on('SIGINT', saveStateAndExit);
107
+
108
+ resetIdle();
109
+
110
+ // Per-session-key serialisation. The original design used a single
111
+ // global `pending` chain, which forced concurrent fresh `ocp` calls in
112
+ // the same directory to wait for each other even though they had no
113
+ // session relationship. The user-visible symptom was "I opened two
114
+ // terminals and ran `ocp` in each, the second hangs until the first
115
+ // finishes". Replace with a per-key Map:
116
+ //
117
+ // - `resume:<sessionId>` — `--resume <id>` requests serialise per id
118
+ // - `session:<sessionId>` — `--session-id <id>` requests serialise per id
119
+ // - `continue` — `--continue` requests serialise (single shared context)
120
+ // - `fresh:<uuid>` — plain `ocp "..."` calls each get a unique key →
121
+ // never collide → run in parallel up to MAX_PARALLEL
122
+ //
123
+ // MAX_PENDING is the cross-cutting ceiling so a runaway same-uid peer
124
+ // can't spawn unbounded in-flight runs.
125
+ const pendingByKey = new Map();
126
+ let pendingCount = 0;
127
+ const MAX_PENDING = envPositiveInt('OCP_DAEMON_MAX_PENDING', 16);
128
+
129
+ function keyForRequest(req) {
130
+ if (req?.resume) return `resume:${req.resume}`;
131
+ if (req?.sessionId) return `session:${req.sessionId}`;
132
+ if (req?.continue) return 'continue';
133
+ return `fresh:${randomUUID()}`;
134
+ }
135
+
136
+ const server = net.createServer((socket) => {
137
+ resetIdle(); // IDLE → ACTIVE: reset timer on new connection
138
+ let buf = '';
139
+ let recv = 0;
140
+ let killed = false;
141
+ socket.setEncoding('utf8');
142
+ socket.setTimeout(SOCKET_IDLE_MS, () => { killed = true; socket.destroy(); });
143
+ const lifetimeKill = setTimeout(() => {
144
+ killed = true;
145
+ try { socket.destroy(); } catch {}
146
+ }, SOCKET_MAX_LIFETIME_MS);
147
+ if (lifetimeKill.unref) lifetimeKill.unref();
148
+ socket.once('close', () => clearTimeout(lifetimeKill));
149
+ socket.on('data', (c) => {
150
+ if (killed) return;
151
+ recv += c.length;
152
+ if (recv > MAX_REQ_BYTES) { killed = true; socket.destroy(); return; }
153
+ buf += c;
154
+ });
155
+ socket.on('end', async () => {
156
+ if (killed) return;
157
+ let req;
158
+ try { req = JSON.parse(buf); } catch {
159
+ // Do not echo the parse-error message back — it may contain a
160
+ // byte-offset into attacker-controlled input. A generic code is
161
+ // enough for legitimate clients to retry.
162
+ socket.write(JSON.stringify({ error: 'bad json' }) + '\n');
163
+ socket.end(); return;
164
+ }
165
+ // Per-request cwd must match the cwd this daemon was spawned for.
166
+ // Otherwise a same-uid peer could reuse our socket to spawn claude
167
+ // in a different directory and influence which `~/.claude/projects/`
168
+ // files get written.
169
+ //
170
+ // Both sides are normalised via realpath so a symlink path and its
171
+ // target compare equal. A missing req.cwd is rejected outright
172
+ // (legitimate clients always set it) so an attacker cannot bypass
173
+ // by simply omitting the field.
174
+ const boundCwd = resolveCwd(opts.cwd);
175
+ if (req.cwd === undefined) {
176
+ socket.write(JSON.stringify({ error: 'cwd required' }) + '\n');
177
+ socket.end(); return;
178
+ }
179
+ if (resolveCwd(req.cwd) !== boundCwd) {
180
+ socket.write(JSON.stringify({ error: 'cwd mismatch' }) + '\n');
181
+ socket.end(); return;
182
+ }
183
+ // Mirror the cwd check for claudeBin — a same-uid peer connecting
184
+ // directly to our socket could otherwise issue requests intended
185
+ // for a DIFFERENT claude binary and silently get this daemon's
186
+ // bound one instead. The daemonKey already includes claudeBin so
187
+ // legitimate clients land on different sockets; this catches the
188
+ // direct-socket bypass.
189
+ const boundBin = opts.claudeBin || 'claude';
190
+ if (req.claudeBin !== undefined && req.claudeBin !== boundBin) {
191
+ socket.write(JSON.stringify({ error: 'claudeBin mismatch' }) + '\n');
192
+ socket.end(); return;
193
+ }
194
+ if (pendingCount >= MAX_PENDING) {
195
+ socket.write(JSON.stringify({ error: 'busy' }) + '\n');
196
+ socket.end(); return;
197
+ }
198
+ pendingCount += 1;
199
+
200
+ const processRequest = async () => {
201
+ try {
202
+ resetIdle();
203
+
204
+ const result = await driver.runOneShot(req);
205
+ if (result.sessionId) lastSessionId = result.sessionId;
206
+
207
+ // Active conversation running — no need for the INACTIVE state file.
208
+ await clearState(key).catch(() => {});
209
+
210
+ // Per-turn log under <cwd>/.ocp/<sessionId>/. Best-effort; any
211
+ // failure is captured inside recordSession and never propagates.
212
+ recordSession({
213
+ cwd: req.cwd,
214
+ sessionId: result.sessionId,
215
+ prompt: req.prompt,
216
+ response: result.text,
217
+ meta: {
218
+ isError: result.isError,
219
+ completionReason: result.completionReason,
220
+ durationMs: result.durationMs,
221
+ cost: result.cost,
222
+ },
223
+ events: result.events,
224
+ }).catch(() => {});
225
+
226
+ socket.write(JSON.stringify({
227
+ text: result.text,
228
+ sessionId: result.sessionId,
229
+ isError: result.isError,
230
+ completionReason: result.completionReason,
231
+ durationMs: result.durationMs,
232
+ cost: result.cost,
233
+ events: result.events,
234
+ diagnostics: result.diagnostics,
235
+ }) + '\n');
236
+ } catch (e) {
237
+ // Log full error locally; return only a generic code to the peer
238
+ // so absolute paths / homedir / internal symbols don't leak via IPC.
239
+ process.stderr.write(`[ocp-daemon] request failed: ${e.stack || e.message}\n`);
240
+ socket.write(JSON.stringify({ error: 'request failed' }) + '\n');
241
+ }
242
+ socket.end();
243
+ // ACTIVE → IDLE: idle timer was reset at start of this handler
244
+ };
245
+
246
+ const sessionKey = keyForRequest(req);
247
+ const prev = pendingByKey.get(sessionKey) ?? Promise.resolve();
248
+ const next = prev
249
+ .then(processRequest)
250
+ .catch((e) => process.stderr.write(`[ocp-daemon] queue handler crashed: ${e?.stack || e?.message}\n`))
251
+ .finally(() => {
252
+ // Only delete the slot if we're still the tail — a queued
253
+ // sibling may have already chained off `next`.
254
+ if (pendingByKey.get(sessionKey) === next) pendingByKey.delete(sessionKey);
255
+ pendingCount -= 1;
256
+ });
257
+ pendingByKey.set(sessionKey, next);
258
+ });
259
+ });
260
+
261
+ server.listen(socketPath, async () => {
262
+ // Restrict the unix socket to the owning user — defense in depth on
263
+ // multi-user hosts so other local accounts cannot inject argv via IPC.
264
+ try { await chmod(socketPath, 0o600); } catch {}
265
+ // Signal parent (client.js) that the socket is ready.
266
+ process.stdout.write('ready\n');
267
+ });
@@ -0,0 +1,78 @@
1
+ // Shared utilities: socket path, state file, daemon key.
2
+ //
3
+ // One daemon per (cwd, claudeBin) pair. Per-request options like --model
4
+ // are forwarded in the request body and handled by the pool internally.
5
+
6
+ import os from 'node:os';
7
+ import crypto from 'node:crypto';
8
+ import path from 'node:path';
9
+ import { realpathSync } from 'node:fs';
10
+ import { mkdir, readFile, writeFile, unlink, chmod, stat } from 'node:fs/promises';
11
+
12
+ export const OCP_DIR = path.join(os.homedir(), '.ocp');
13
+
14
+ export async function ensureOcpDir() {
15
+ await mkdir(OCP_DIR, { recursive: true, mode: 0o700 });
16
+ // mkdir with `recursive: true` does NOT apply mode to an existing dir,
17
+ // so chmod separately in case it was created earlier with default perms.
18
+ try { await chmod(OCP_DIR, 0o700); } catch {}
19
+ // If the directory exists but is owned by another uid (e.g. a prior
20
+ // sudo invocation), every later write fails silently and ocp keeps
21
+ // falling back to direct mode on every call with no diagnosis. Surface
22
+ // the actual cause once, fast.
23
+ if (process.getuid) {
24
+ try {
25
+ const st = await stat(OCP_DIR);
26
+ if (st.uid !== process.getuid()) {
27
+ throw new Error(
28
+ `~/.ocp is owned by uid ${st.uid}, not ${process.getuid()}. ` +
29
+ `Run \`sudo chown -R $USER ~/.ocp\` (or remove the directory) and retry.`,
30
+ );
31
+ }
32
+ } catch (e) {
33
+ if (e.message?.startsWith('~/.ocp is owned')) throw e;
34
+ // stat ENOENT is impossible right after mkdir; other errors fall through.
35
+ }
36
+ }
37
+ }
38
+
39
+ export function resolveCwd(cwd) {
40
+ const target = cwd ?? process.cwd();
41
+ try { return realpathSync(target); }
42
+ catch { return path.resolve(target); }
43
+ }
44
+
45
+ /** Stable key that identifies a daemon instance. */
46
+ export function daemonKey({ cwd, claudeBin } = {}) {
47
+ return JSON.stringify({
48
+ cwd: resolveCwd(cwd),
49
+ claudeBin: claudeBin ?? 'claude',
50
+ });
51
+ }
52
+
53
+ function hashOf(key) {
54
+ return crypto.createHash('sha1').update(key).digest('hex').slice(0, 8);
55
+ }
56
+
57
+ /** Unix socket path for a given daemon key. */
58
+ export function socketPath(key) {
59
+ return path.join(OCP_DIR, `d-${hashOf(key)}.sock`);
60
+ }
61
+
62
+ /** State file path — written when daemon goes INACTIVE, read on next start. */
63
+ export function statePath(key) {
64
+ return path.join(OCP_DIR, `s-${hashOf(key)}.json`);
65
+ }
66
+
67
+ export async function readState(key) {
68
+ try { return JSON.parse(await readFile(statePath(key), 'utf8')); } catch { return null; }
69
+ }
70
+
71
+ export async function writeState(key, state) {
72
+ await ensureOcpDir();
73
+ await writeFile(statePath(key), JSON.stringify(state), { encoding: 'utf8', mode: 0o600 });
74
+ }
75
+
76
+ export async function clearState(key) {
77
+ try { await unlink(statePath(key)); } catch {}
78
+ }