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,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
|
+
}
|
package/src/pty/index.js
ADDED
package/src/pty/pool.js
ADDED
|
@@ -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
|
+
}
|