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,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
|
+
}
|