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,256 @@
1
+ // TUI frame parser.
2
+ //
3
+ // A line-oriented state machine that classifies upstream TUI output into
4
+ // structured events. The line model is approximate: we DO NOT run a real
5
+ // terminal emulator, but we do honor the most common TTY redraw idiom —
6
+ // a bare carriage return (`\r`) returns the cursor to column 0 and any
7
+ // subsequent characters on the same logical line overwrite what was
8
+ // there. We model this by splitting each `\n`-terminated segment on `\r`
9
+ // and keeping only the last subsegment (i.e. what survives all redraws).
10
+ //
11
+ // Events emitted:
12
+ //
13
+ // { type: 'assistant-region-entered', n } a `⏺` opened the region
14
+ // { type: 'assistant-region-exited', n } a horizontal rule, the
15
+ // request's sentinel, or
16
+ // an unrecoverable break
17
+ // closed the region
18
+ // { type: 'assistant-text', text, region: n } one line of response
19
+ // { type: 'session-id', id } from "claude --resume <uuid>"
20
+ // { type: 'prompt-box-shown' } first `─{3,}` rule
21
+ // { type: 'spinner', label } `✻…` thinking/work lines
22
+ //
23
+ // `region` numbers increment per response area; a `--resume` session that
24
+ // re-renders prior history therefore produces region=1..k for old turns
25
+ // and a higher number for the new response. The driver filters on the
26
+ // MAX region when extracting clean assistant text.
27
+ //
28
+ // All version-sensitive patterns live in PATTERNS so future drift is a
29
+ // localized edit.
30
+
31
+ export const PATTERNS = {
32
+ assistantRegionMarker: '⏺',
33
+ boxBorder: /^─{3,}/,
34
+ spinnerLeading: /^[✻✶✺✹✸✷✵●✢✳✽·✾✿❀❁❂❃❄❅❆❇❈❉❊❋]/,
35
+ blankLine: /^\s*$/,
36
+ sessionIdBanner:
37
+ /claude\s+--resume\s+([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/,
38
+ /** Stripped-form sentinel — nonce-agnostic, used to close the region. */
39
+ sentinel: /⟦OCP_END:[0-9a-fA-F]+⟧/,
40
+ /** The `❯` chevron that marks the start of the input prompt line.
41
+ * Once we see this we know we have reached the input box; everything
42
+ * that follows on subsequent lines is the statusline / HUD plugin
43
+ * area, not response content, and is suppressed from parsing. */
44
+ promptInputChevron: /^[❯›❮‹]/,
45
+ };
46
+
47
+ /**
48
+ * Resolve carriage-return (`\r`) redraws within a single logical line.
49
+ *
50
+ * Real terminal semantics: a bare `\r` returns the cursor to column 0 of
51
+ * the current line, and any subsequent characters overwrite from that
52
+ * position. If the overwrite is shorter than the previous content, the
53
+ * trailing characters of the previous content remain visible. This
54
+ * function simulates that minimal cursor model and returns the final
55
+ * visible content of the line.
56
+ *
57
+ * Examples:
58
+ * resolveCarriageReturns("xxxxx\ryy") // "yyxxx"
59
+ * resolveCarriageReturns("⏺ apple\rstatus") // "status" (status fully overwrote)
60
+ * resolveCarriageReturns("⏺ apple\ra") // "appale" — kept the tail
61
+ *
62
+ * Strings without any `\r` are returned as-is.
63
+ *
64
+ * @param {string} line
65
+ * @returns {string}
66
+ */
67
+ export function resolveCarriageReturns(line) {
68
+ if (line.indexOf('\r') === -1) return line;
69
+ let buf = '';
70
+ let cursor = 0;
71
+ for (let i = 0; i < line.length; i++) {
72
+ const ch = line[i];
73
+ if (ch === '\r') {
74
+ cursor = 0;
75
+ } else {
76
+ if (cursor >= buf.length) {
77
+ buf += ch;
78
+ } else {
79
+ buf = buf.slice(0, cursor) + ch + buf.slice(cursor + 1);
80
+ }
81
+ cursor += 1;
82
+ }
83
+ }
84
+ return buf;
85
+ }
86
+
87
+ export const tuiFrameParser = {
88
+ name: 'tui-frame',
89
+ priority: 20,
90
+ create() {
91
+ /** Pending text awaiting a newline. */
92
+ let pending = '';
93
+ let regionActive = false;
94
+ let regionsEntered = 0;
95
+ let promptBoxShownEmitted = false;
96
+ let lastSessionId = null;
97
+ /**
98
+ * Becomes true the moment we see the input chevron (`❯`) line. Every
99
+ * subsequent line is statusline / HUD content (e.g. claude-hud
100
+ * plugin output, context meter, mode indicator) — none of it is
101
+ * response content, so we drop it. Reset only when a new response
102
+ * region begins (`⏺` marker), which is the upstream's signal that
103
+ * the screen scrolled and we're back above the prompt box.
104
+ */
105
+ let belowPromptBox = false;
106
+ /**
107
+ * Tracks the most recent assistant-text emission to dedupe consecutive
108
+ * blank-line emissions. Real paragraph breaks survive (one blank
109
+ * between text); runs of blanks from redraw artifacts collapse to one.
110
+ */
111
+ let lastEmittedBlank = false;
112
+
113
+ function closeRegion(events) {
114
+ if (!regionActive) return;
115
+ regionActive = false;
116
+ events.push({ type: 'assistant-region-exited', n: regionsEntered });
117
+ }
118
+
119
+ function emitText(events, raw) {
120
+ if (!regionActive) return;
121
+ const isBlank = raw === '' || /^\s*$/.test(raw);
122
+ if (isBlank && lastEmittedBlank) return; // collapse runs of blanks
123
+ lastEmittedBlank = isBlank;
124
+ events.push({
125
+ type: 'assistant-text',
126
+ text: raw,
127
+ region: regionsEntered,
128
+ });
129
+ }
130
+
131
+ function processLine(line, events) {
132
+ const trimmedStart = line.trimStart();
133
+
134
+ // A new assistant region marker (`⏺`) means the upstream is
135
+ // re-drawing above the prompt box. Whatever statusline/HUD content
136
+ // we were ignoring is now stale — fall back into normal parsing.
137
+ if (trimmedStart.startsWith(PATTERNS.assistantRegionMarker)) {
138
+ belowPromptBox = false;
139
+ }
140
+
141
+ // Once we have seen the input chevron, every line below it is
142
+ // statusline / HUD plugin content (claude-hud, mode indicator,
143
+ // context meter, queued-message hints, …). Suppress all parsing
144
+ // until a new assistant region is opened above.
145
+ if (belowPromptBox) return;
146
+
147
+ // Session-id banner — accept anywhere in the buffer.
148
+ const sm = line.match(PATTERNS.sessionIdBanner);
149
+ if (sm && sm[1] !== lastSessionId) {
150
+ lastSessionId = sm[1];
151
+ events.push({ type: 'session-id', id: sm[1] });
152
+ }
153
+
154
+ // Input chevron (`❯`) marks the prompt input line itself. This is
155
+ // the only reliable "the input box is ready" signal — box borders
156
+ // alone fire too early on the welcome banner's bottom border, so
157
+ // we anchor `prompt-box-shown` to the chevron. The line may
158
+ // legitimately contain queued draft text after the chevron, but
159
+ // it is not response content — drop it and from here on suppress
160
+ // everything until a new response region opens.
161
+ if (PATTERNS.promptInputChevron.test(trimmedStart)) {
162
+ closeRegion(events);
163
+ if (!promptBoxShownEmitted) {
164
+ promptBoxShownEmitted = true;
165
+ events.push({ type: 'prompt-box-shown' });
166
+ }
167
+ belowPromptBox = true;
168
+ return;
169
+ }
170
+
171
+ // Box border closes any open assistant region. We do NOT fire
172
+ // `prompt-box-shown` here — the welcome banner and tool-output
173
+ // panels both use box borders, so chevron-based emission above is
174
+ // the authoritative signal.
175
+ if (PATTERNS.boxBorder.test(line.trim())) {
176
+ closeRegion(events);
177
+ return;
178
+ }
179
+
180
+ const trimmed = trimmedStart;
181
+
182
+ // New assistant region: first character is the response marker.
183
+ if (trimmed.startsWith(PATTERNS.assistantRegionMarker)) {
184
+ regionActive = true;
185
+ regionsEntered += 1;
186
+ events.push({ type: 'assistant-region-entered', n: regionsEntered });
187
+ let after = trimmed.slice(PATTERNS.assistantRegionMarker.length).trimStart();
188
+ // The sentinel may sit on the same line as the marker (e.g.
189
+ // `⏺hello⟦OCP_END:…⟧`); split it out and close the region.
190
+ const sIdx = after.search(PATTERNS.sentinel);
191
+ if (sIdx !== -1) {
192
+ const before = after.slice(0, sIdx).replace(/\s+$/, '');
193
+ if (before) emitText(events, before);
194
+ closeRegion(events);
195
+ } else if (after) {
196
+ emitText(events, after);
197
+ }
198
+ return;
199
+ }
200
+
201
+ // Spinner / working status — emit a typed event and otherwise skip.
202
+ if (PATTERNS.spinnerLeading.test(trimmed)) {
203
+ events.push({ type: 'spinner', label: trimmed });
204
+ return;
205
+ }
206
+
207
+ if (!regionActive) return; // outside any region — ignore noise
208
+
209
+ // Inside an active region: the sentinel closes it.
210
+ const sIdx = line.search(PATTERNS.sentinel);
211
+ if (sIdx !== -1) {
212
+ const before = line.slice(0, sIdx).replace(/\s+$/, '');
213
+ if (before) emitText(events, before);
214
+ closeRegion(events);
215
+ return;
216
+ }
217
+
218
+ if (PATTERNS.blankLine.test(line)) {
219
+ emitText(events, ''); // paragraph break
220
+ return;
221
+ }
222
+ emitText(events, line.replace(/\s+$/, ''));
223
+ }
224
+
225
+ return {
226
+ feed(text) {
227
+ const events = [];
228
+ pending += text;
229
+ // Split on any of CR / LF / CRLF as a line boundary. Treating
230
+ // bare `\r` as a line ender is essential: many response lines
231
+ // arrive as `…content\r` without a following `\n` (the upstream
232
+ // does in-place updates of the current row), and a parser that
233
+ // waited for `\n` alone would block forever on those rows.
234
+ // Each segment is processed as its own line; consumers
235
+ // (extractAssistantTextFromEvents) pick the highest region.
236
+ const parts = pending.split(/\r\n|\r|\n/);
237
+ pending = parts.pop() ?? '';
238
+ for (const line of parts) {
239
+ processLine(line, events);
240
+ }
241
+ // text is passed through unchanged for downstream parsers (the
242
+ // sentinel parser still scans the raw stream).
243
+ return { text, events };
244
+ },
245
+ reset() {
246
+ pending = '';
247
+ regionActive = false;
248
+ regionsEntered = 0;
249
+ promptBoxShownEmitted = false;
250
+ lastSessionId = null;
251
+ lastEmittedBlank = false;
252
+ belowPromptBox = false;
253
+ },
254
+ };
255
+ },
256
+ };
@@ -0,0 +1,214 @@
1
+ // Direct `claude --print` mode — opt-in alternative to the default PTY+TUI
2
+ // pipeline. The default driver spawns `claude` interactively and captures
3
+ // the rendered TUI output, which means markdown formatting characters
4
+ // (``` fences, ## headings, **bold**, etc.) are consumed during rendering
5
+ // and never reach the caller.
6
+ //
7
+ // Print mode bypasses the TUI entirely:
8
+ // - spawns `claude --print "<prompt>" [forwarded args]` via child_process
9
+ // - claude detects non-TTY stdout and emits raw output
10
+ // - markdown formatting is preserved verbatim
11
+ //
12
+ // Trade-offs (vs. the default PTY path):
13
+ // + raw markdown reaches the caller (the original motivation)
14
+ // + simpler — no PTY, no ANSI strip, no TUI frame parser
15
+ // - claude's native schemas pass through unchanged (json / stream-json
16
+ // no longer match ocp's wrapped schema; document this for callers)
17
+ // - tool-approval prompts and other interactive flows are not available
18
+ // - print-mode MCP calls have a known upstream hang; callers using
19
+ // MCP servers should stay on the default path
20
+
21
+ import { spawn } from 'node:child_process';
22
+ import { readdir, stat } from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ import os from 'node:os';
25
+
26
+ import { OPTION_SPEC } from './options/spec.js';
27
+ import { sanitizePassThroughArgv, redactArgvForLog } from './index.js';
28
+
29
+ /**
30
+ * Translate a runOneShot-style request into a `claude --print` argv list.
31
+ * Flags come first, then a `--` end-of-options separator, then the prompt as
32
+ * a positional argument so the upstream parser cannot mistake a prompt
33
+ * starting with `-` for a flag.
34
+ *
35
+ * @param {object} req
36
+ * @returns {string[]}
37
+ */
38
+ export function buildPrintModeArgs(req) {
39
+ const args = ['--print'];
40
+ for (const spec of OPTION_SPEC) {
41
+ if (spec.forward?.type !== 'argv') continue;
42
+ const value = req[fieldNameOf(spec)];
43
+ if (value === undefined || value === null || value === false) continue;
44
+ if (spec.kind === 'boolean') {
45
+ if (value === true) args.push(spec.forward.flag);
46
+ } else if (spec.kind === 'array') {
47
+ if (Array.isArray(value) && value.length > 0) {
48
+ args.push(spec.forward.flag, ...value.map(String));
49
+ }
50
+ } else {
51
+ args.push(spec.forward.flag, String(value));
52
+ }
53
+ }
54
+ if (req.outputFormat) args.push('--output-format', String(req.outputFormat));
55
+ if (Array.isArray(req.passThroughArgv) && req.passThroughArgv.length > 0) {
56
+ const { sanitized } = sanitizePassThroughArgv(req.passThroughArgv);
57
+ args.push(...sanitized);
58
+ }
59
+ args.push('--', req.prompt);
60
+ return args;
61
+ }
62
+
63
+ function fieldNameOf(spec) {
64
+ return spec.name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
65
+ }
66
+
67
+ /**
68
+ * Run a single prompt against `claude --print` and stream stdout to the
69
+ * provided sink (typically process.stdout).
70
+ *
71
+ * @param {object} opts
72
+ * @param {string} opts.bin Binary to spawn (default 'claude').
73
+ * @param {string} opts.prompt
74
+ * @param {object} opts.req Full request object (forwarded fields used).
75
+ * @param {{ write: (s: string) => void }} [opts.sink] stdout sink (default: capture-only).
76
+ * @param {string} [opts.cwd]
77
+ * @param {NodeJS.ProcessEnv} [opts.env]
78
+ * @param {AbortSignal} [opts.abortSignal]
79
+ * @param {number} [opts.timeoutMs]
80
+ * @param {(msg: string) => void} [opts.logDebug]
81
+ * @returns {Promise<{ text: string, sessionId: string|null, isError: boolean, exitCode: number|null, durationMs: number, completionReason: string }>}
82
+ */
83
+ export async function runPrintMode(opts) {
84
+ const startMs = Date.now();
85
+ const args = buildPrintModeArgs(opts.req);
86
+ if (opts.logDebug) opts.logDebug(`print-mode spawn ${opts.bin} ${redactArgvForLog(args).join(' ')}`);
87
+
88
+ const sessionFilesBefore = await listSessionFiles(opts.cwd);
89
+
90
+ return new Promise((resolve) => {
91
+ const child = spawn(opts.bin, args, {
92
+ cwd: opts.cwd ?? process.cwd(),
93
+ env: opts.env ?? process.env,
94
+ stdio: ['ignore', 'pipe', 'pipe'],
95
+ });
96
+
97
+ let textBuf = '';
98
+ let stderrBuf = '';
99
+ let timedOut = false;
100
+ let aborted = false;
101
+
102
+ const timer = opts.timeoutMs && opts.timeoutMs > 0
103
+ ? setTimeout(() => { timedOut = true; child.kill('SIGTERM'); }, opts.timeoutMs)
104
+ : null;
105
+
106
+ let onAbort;
107
+ if (opts.abortSignal) {
108
+ onAbort = () => { aborted = true; child.kill('SIGTERM'); };
109
+ opts.abortSignal.addEventListener('abort', onAbort, { once: true });
110
+ }
111
+
112
+ child.stdout.on('data', (chunk) => {
113
+ const s = chunk.toString('utf8');
114
+ textBuf += s;
115
+ if (opts.sink) opts.sink.write(s);
116
+ });
117
+ child.stderr.on('data', (chunk) => { stderrBuf += chunk.toString('utf8'); });
118
+
119
+ child.on('close', async (code) => {
120
+ if (timer) clearTimeout(timer);
121
+ if (onAbort && opts.abortSignal) opts.abortSignal.removeEventListener('abort', onAbort);
122
+
123
+ let sessionId = extractSessionIdFromOutput(textBuf, opts.req.outputFormat);
124
+ if (!sessionId) {
125
+ sessionId = await scanForNewSessionFile(opts.cwd, sessionFilesBefore);
126
+ }
127
+
128
+ const completionReason =
129
+ aborted ? 'cancelled' :
130
+ timedOut ? 'timeout' :
131
+ code === 0 ? 'sentinel' :
132
+ 'upstream-exited';
133
+
134
+ if (opts.logDebug && stderrBuf) opts.logDebug(`print-mode stderr: ${stderrBuf.slice(-400)}`);
135
+
136
+ resolve({
137
+ text: textBuf,
138
+ sessionId,
139
+ isError: code !== 0,
140
+ exitCode: code,
141
+ durationMs: Date.now() - startMs,
142
+ completionReason,
143
+ });
144
+ });
145
+
146
+ child.on('error', (err) => {
147
+ if (timer) clearTimeout(timer);
148
+ if (opts.logDebug) opts.logDebug(`print-mode spawn error: ${err.message}`);
149
+ resolve({
150
+ text: textBuf,
151
+ sessionId: null,
152
+ isError: true,
153
+ exitCode: null,
154
+ durationMs: Date.now() - startMs,
155
+ completionReason: 'spawn-error',
156
+ });
157
+ });
158
+ });
159
+ }
160
+
161
+ // Pull session_id from claude --output-format=json or stream-json output.
162
+ function extractSessionIdFromOutput(text, outputFormat) {
163
+ if (!text) return null;
164
+ if (outputFormat === 'json') {
165
+ try {
166
+ const obj = JSON.parse(text.trim());
167
+ return obj.session_id ?? null;
168
+ } catch { return null; }
169
+ }
170
+ if (outputFormat === 'stream-json') {
171
+ for (const line of text.split(/\r?\n/)) {
172
+ if (!line.trim()) continue;
173
+ try {
174
+ const obj = JSON.parse(line);
175
+ if (obj.session_id) return obj.session_id;
176
+ } catch { /* ignore non-JSON lines */ }
177
+ }
178
+ }
179
+ return null;
180
+ }
181
+
182
+ async function listSessionFiles(cwd) {
183
+ const absCwd = path.resolve(cwd ?? process.cwd());
184
+ // Upstream encodes BOTH path separators and underscores as `-`, so
185
+ // `/Users/alice/gen_keypair` lands in `-Users-alice-gen-keypair/`.
186
+ // A `/`-only replacement misses any cwd containing `_` and silently
187
+ // looks in the wrong directory.
188
+ const encoded = absCwd.replace(/[/_]/g, '-');
189
+ const dir = path.join(os.homedir(), '.claude', 'projects', encoded);
190
+ const out = new Map();
191
+ let entries;
192
+ try { entries = await readdir(dir); } catch { return out; }
193
+ for (const name of entries) {
194
+ if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\.jsonl$/.test(name)) continue;
195
+ try {
196
+ const st = await stat(path.join(dir, name));
197
+ out.set(name, st.mtimeMs);
198
+ } catch { /* ignore */ }
199
+ }
200
+ return out;
201
+ }
202
+
203
+ async function scanForNewSessionFile(cwd, before) {
204
+ const after = await listSessionFiles(cwd);
205
+ let best = null;
206
+ let bestMtime = 0;
207
+ for (const [name, mtime] of after) {
208
+ const prev = before.get(name);
209
+ if (prev === undefined || mtime > prev) {
210
+ if (mtime > bestMtime) { bestMtime = mtime; best = name; }
211
+ }
212
+ }
213
+ return best ? best.replace(/\.jsonl$/, '') : null;
214
+ }
@@ -0,0 +1,3 @@
1
+ // Barrel re-export for the pty module.
2
+ export { PtySession } from './session.js';
3
+ export { PtyPool } from './pool.js';
@@ -0,0 +1,127 @@
1
+ // Warm pool of idle PTY sessions.
2
+ //
3
+ // Key design decisions:
4
+ // - No /clear on release — conversation context is preserved across calls.
5
+ // - Sliding TTL: lastUsedAt is updated on each acquire, not at park time.
6
+ // - acquire() does NOT spawn — it tells the caller what to do:
7
+ // { session, isReuse: true } → reuse warm session
8
+ // { session: null, resumeSessionId } → caller must spawn (use resumeSessionId for --resume if set)
9
+ // - release(session, key, sessionId) parks the session with its sessionId so
10
+ // a future respawn can --resume into the same conversation.
11
+ // - initialSessionId: on the very first acquire (empty pool), return this as
12
+ // resumeSessionId so the caller can --resume an earlier conversation.
13
+
14
+ export class PtyPool {
15
+ /**
16
+ * @param {object} opts
17
+ * @param {number} [opts.maxIdlePerKey=1]
18
+ * @param {number} [opts.maxAgeMs=600000] sliding idle TTL (10 min default)
19
+ * @param {string|null} [opts.initialSessionId] resume target for first spawn
20
+ */
21
+ constructor({ maxIdlePerKey = 1, maxAgeMs = 600_000, initialSessionId = null } = {}) {
22
+ this.maxIdlePerKey = maxIdlePerKey;
23
+ this.maxAgeMs = maxAgeMs;
24
+ /** Used once on the first empty-pool acquire, then cleared. */
25
+ this.initialSessionId = initialSessionId;
26
+ /** @type {Map<string, Array<{session, lastUsedAt: number, sessionId: string|null}>>} */
27
+ this.idle = new Map();
28
+ this.closed = false;
29
+ }
30
+
31
+ /**
32
+ * Compute a canonical pool key for a request.
33
+ * @param {object} opts
34
+ * @param {string} [opts.cwd]
35
+ * @param {string[]} [opts.spawnArgs]
36
+ * @returns {string}
37
+ */
38
+ static canonicalKey({ cwd, spawnArgs } = {}) {
39
+ return JSON.stringify({
40
+ cwd: cwd ?? '',
41
+ args: Array.isArray(spawnArgs) ? [...spawnArgs] : [],
42
+ });
43
+ }
44
+
45
+ /**
46
+ * Try to get a warm session for key.
47
+ *
48
+ * Returns one of:
49
+ * { session: PtySession, isReuse: true, resumeSessionId: null }
50
+ * → warm hit; caller uses this session directly (no spawn needed).
51
+ * { session: null, isReuse: false, resumeSessionId: string|null }
52
+ * → miss; caller must spawn a new PTY.
53
+ * If resumeSessionId is non-null, caller should pass --resume to the spawn.
54
+ *
55
+ * @param {{ key: string }} opts
56
+ */
57
+ async acquire({ key }) {
58
+ if (this.closed) throw new Error('PtyPool.acquire: pool is closed');
59
+ const list = this.idle.get(key);
60
+
61
+ while (list && list.length > 0) {
62
+ const entry = list.pop();
63
+ const age = Date.now() - entry.lastUsedAt;
64
+
65
+ if (entry.session.state === 'idle' && age <= this.maxAgeMs) {
66
+ // Warm hit — session is now checked out; do NOT push back into idle.
67
+ // release() will re-add it when the caller is done.
68
+ return { session: entry.session, isReuse: true, resumeSessionId: null };
69
+ }
70
+
71
+ // Stale — kill the dead/expired session and bubble up its sessionId
72
+ // so the caller can --resume into the same conversation.
73
+ const staleId = entry.sessionId;
74
+ try { await entry.session.kill(); } catch {}
75
+ return { session: null, isReuse: false, resumeSessionId: staleId };
76
+ }
77
+
78
+ // Empty pool — use initialSessionId (if any) so the first spawn can
79
+ // --resume an earlier conversation from a previous daemon run.
80
+ const resumeId = this.initialSessionId;
81
+ this.initialSessionId = null; // consume once
82
+ return { session: null, isReuse: false, resumeSessionId: resumeId };
83
+ }
84
+
85
+ /**
86
+ * Park a session for reuse. No /clear is sent — conversation context is
87
+ * intentionally preserved so the next acquire continues the same thread.
88
+ *
89
+ * @param {import('./session.js').PtySession} session
90
+ * @param {string} key
91
+ * @param {string|null} sessionId the claude session UUID from the last response
92
+ */
93
+ async release(session, key, sessionId = null) {
94
+ if (this.closed || session.state !== 'idle') {
95
+ try { await session.kill(); } catch {}
96
+ return;
97
+ }
98
+
99
+ const list = this.idle.get(key) ?? [];
100
+
101
+ if (list.length >= this.maxIdlePerKey) {
102
+ try { await session.kill(); } catch {}
103
+ return;
104
+ }
105
+
106
+ if (!this.idle.has(key)) this.idle.set(key, list);
107
+ list.push({ session, lastUsedAt: Date.now(), sessionId });
108
+ }
109
+
110
+ /** Kill all parked sessions and mark the pool unusable. */
111
+ async close() {
112
+ this.closed = true;
113
+ for (const list of this.idle.values()) {
114
+ for (const { session } of list) {
115
+ try { await session.kill(); } catch {}
116
+ }
117
+ }
118
+ this.idle.clear();
119
+ }
120
+
121
+ /** Number of currently parked sessions across all keys. */
122
+ size() {
123
+ let n = 0;
124
+ for (const list of this.idle.values()) n += list.length;
125
+ return n;
126
+ }
127
+ }
@@ -0,0 +1,88 @@
1
+ // A single PTY-backed `claude` session.
2
+ //
3
+ // Wraps `node-pty`'s spawn surface in a small EventEmitter so the driver can
4
+ // subscribe to data and exit events without depending on node-pty's typings
5
+ // directly. The class is intentionally dumb — it knows nothing about parsers,
6
+ // sentinels, or output formats. Higher layers compose those on top.
7
+ //
8
+ // Lifecycle:
9
+ // new PtySession() -> state = 'starting'
10
+ // .spawn({...}) -> state = 'idle' once node-pty returns the child
11
+ // .write(s) -> send raw bytes
12
+ // .kill() -> state = 'dead'; resolves when the child exits
13
+ //
14
+ // State values used so far ('busy', 'resetting') are reserved for the pool
15
+ // integration that lands in a later phase.
16
+
17
+ import { spawn as ptySpawn } from 'node-pty';
18
+ import { EventEmitter } from 'node:events';
19
+
20
+ export class PtySession extends EventEmitter {
21
+ constructor() {
22
+ super();
23
+ this.state = 'starting';
24
+ this.proc = null;
25
+ this.exitInfo = null;
26
+ }
27
+
28
+ /**
29
+ * Spawn the upstream binary under node-pty.
30
+ *
31
+ * @param {object} opts
32
+ * @param {string} opts.bin Path or PATH-resolvable name.
33
+ * @param {string[]} [opts.args]
34
+ * @param {string} [opts.cwd]
35
+ * @param {NodeJS.ProcessEnv} [opts.env]
36
+ * @param {number} [opts.cols]
37
+ * @param {number} [opts.rows]
38
+ */
39
+ async spawn({ bin, args = [], cwd, env, cols = 220, rows = 500 }) {
40
+ if (this.proc) throw new Error('PtySession.spawn: already spawned');
41
+ this.proc = ptySpawn(bin, args, {
42
+ name: 'xterm-256color',
43
+ cols,
44
+ rows,
45
+ cwd: cwd ?? process.cwd(),
46
+ env: env ?? process.env,
47
+ });
48
+ this.proc.onData((chunk) => this.emit('data', chunk));
49
+ this.proc.onExit((info) => {
50
+ this.exitInfo = info;
51
+ this.state = 'dead';
52
+ this.emit('exit', info);
53
+ });
54
+ this.state = 'idle';
55
+ }
56
+
57
+ /** Send raw bytes to the PTY. Throws if not spawned or already dead. */
58
+ write(data) {
59
+ if (!this.proc) throw new Error('PtySession.write: not spawned');
60
+ if (this.state === 'dead') throw new Error('PtySession.write: session is dead');
61
+ this.proc.write(data);
62
+ }
63
+
64
+ /** Resize the PTY. Safe to call repeatedly. */
65
+ resize(cols, rows) {
66
+ if (!this.proc || this.state === 'dead') return;
67
+ try { this.proc.resize(cols, rows); } catch {}
68
+ }
69
+
70
+ /**
71
+ * Kill the underlying process. Resolves when it has exited (or after 1 s
72
+ * if the OS does not deliver the exit event quickly enough).
73
+ */
74
+ async kill() {
75
+ if (!this.proc || this.state === 'dead') return;
76
+ try { this.proc.kill(); } catch {}
77
+ if (this.state !== 'dead') {
78
+ await new Promise((resolve) => {
79
+ const onExit = () => resolve();
80
+ this.once('exit', onExit);
81
+ setTimeout(() => {
82
+ this.off('exit', onExit);
83
+ resolve();
84
+ }, 1000);
85
+ });
86
+ }
87
+ }
88
+ }