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,83 @@
|
|
|
1
|
+
// `--output-format json` adapter.
|
|
2
|
+
//
|
|
3
|
+
// Emits a single JSON object on `end()` mirroring the top-level shape that
|
|
4
|
+
// `claude -p --output-format json` produces. Fields the shim cannot derive
|
|
5
|
+
// from PTY-only output (token usage, exact cost) are reported as `null`.
|
|
6
|
+
//
|
|
7
|
+
// Schema:
|
|
8
|
+
// {
|
|
9
|
+
// "result": string,
|
|
10
|
+
// "session_id": string | null,
|
|
11
|
+
// "is_error": boolean,
|
|
12
|
+
// "cost": { "total_usd": number|null, "num_turns": number|null },
|
|
13
|
+
// "duration_ms": number,
|
|
14
|
+
// "completion": "sentinel" | "idle" | "timeout" | "cancelled" | …
|
|
15
|
+
// }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Validate the assistant output against a JSON Schema, if one was provided
|
|
19
|
+
* via `--json-schema`. We support the minimal subset (`type` + required
|
|
20
|
+
* fields) the upstream documentation advertises. A real validator is out
|
|
21
|
+
* of scope; the goal here is to flag obvious mismatches rather than be a
|
|
22
|
+
* full JSON Schema engine.
|
|
23
|
+
*/
|
|
24
|
+
function validateAgainstSchema(text, schema) {
|
|
25
|
+
if (!schema || typeof schema !== 'object') return null;
|
|
26
|
+
let value;
|
|
27
|
+
try { value = JSON.parse(text); } catch { return 'json-parse-failed'; }
|
|
28
|
+
const expected = schema.type;
|
|
29
|
+
const actual =
|
|
30
|
+
value === null ? 'null'
|
|
31
|
+
: Array.isArray(value) ? 'array'
|
|
32
|
+
: typeof value;
|
|
33
|
+
if (expected && expected !== actual) return `type ${actual} != expected ${expected}`;
|
|
34
|
+
if (expected === 'object' && Array.isArray(schema.required)) {
|
|
35
|
+
for (const key of schema.required) {
|
|
36
|
+
if (!Object.prototype.hasOwnProperty.call(value, key)) {
|
|
37
|
+
return `missing required key: ${key}`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const jsonOutputAdapter = {
|
|
45
|
+
name: 'json',
|
|
46
|
+
/**
|
|
47
|
+
* @param {{ jsonSchema?: object }} opts
|
|
48
|
+
* @param {{ write: (s: string) => void }} sink
|
|
49
|
+
*/
|
|
50
|
+
create(opts, sink) {
|
|
51
|
+
return {
|
|
52
|
+
onEvent(_event) {
|
|
53
|
+
// The json adapter emits one consolidated object at end(); per-event
|
|
54
|
+
// streaming is handled by the stream-json adapter instead.
|
|
55
|
+
},
|
|
56
|
+
/**
|
|
57
|
+
* @param {object} finalResult
|
|
58
|
+
*/
|
|
59
|
+
end(finalResult) {
|
|
60
|
+
const result = finalResult?.text ?? '';
|
|
61
|
+
let isError = !!finalResult?.isError;
|
|
62
|
+
let schemaError = null;
|
|
63
|
+
if (opts?.jsonSchema) {
|
|
64
|
+
schemaError = validateAgainstSchema(result, opts.jsonSchema);
|
|
65
|
+
if (schemaError) isError = true;
|
|
66
|
+
}
|
|
67
|
+
const out = {
|
|
68
|
+
result,
|
|
69
|
+
session_id: finalResult?.sessionId ?? null,
|
|
70
|
+
is_error: isError,
|
|
71
|
+
cost: {
|
|
72
|
+
total_usd: finalResult?.cost?.totalUsd ?? null,
|
|
73
|
+
num_turns: finalResult?.cost?.numTurns ?? null,
|
|
74
|
+
},
|
|
75
|
+
duration_ms: finalResult?.durationMs ?? null,
|
|
76
|
+
completion: finalResult?.completionReason ?? null,
|
|
77
|
+
};
|
|
78
|
+
if (schemaError) out.schema_error = schemaError;
|
|
79
|
+
sink.write(JSON.stringify(out) + '\n');
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
},
|
|
83
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Pluggable output-adapter registry.
|
|
2
|
+
//
|
|
3
|
+
// An output adapter takes parsed events (produced by the parsers pipeline)
|
|
4
|
+
// and serializes them onto a writable sink. This is what makes
|
|
5
|
+
// `--output-format {text|json|stream-json}` work and lets us add new formats
|
|
6
|
+
// later (e.g. SSE, msgpack) without touching the driver core.
|
|
7
|
+
//
|
|
8
|
+
// Adapter shape:
|
|
9
|
+
// {
|
|
10
|
+
// name: string matches `--output-format` value
|
|
11
|
+
// create(opts, sink): AdapterInstance factory; sink is a Writable
|
|
12
|
+
// }
|
|
13
|
+
// AdapterInstance:
|
|
14
|
+
// .onEvent(event): void
|
|
15
|
+
// .end(finalResult): void
|
|
16
|
+
|
|
17
|
+
/** @type {Map<string, object>} */
|
|
18
|
+
const REGISTRY = new Map();
|
|
19
|
+
|
|
20
|
+
export function registerOutputAdapter(adapter) {
|
|
21
|
+
if (!adapter?.name) throw new Error('registerOutputAdapter: adapter.name is required');
|
|
22
|
+
REGISTRY.set(adapter.name, adapter);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function unregisterOutputAdapter(name) {
|
|
26
|
+
REGISTRY.delete(name);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function listOutputAdapters() {
|
|
30
|
+
return [...REGISTRY.values()];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getOutputAdapter(name) {
|
|
34
|
+
return REGISTRY.get(name);
|
|
35
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// `--output-format stream-json` adapter.
|
|
2
|
+
//
|
|
3
|
+
// Emits newline-delimited JSON events matching the upstream Claude Code
|
|
4
|
+
// stream-json schema. The full message-type catalogue is intentionally
|
|
5
|
+
// kept extensible via EVENT_BUILDERS so new SDK event types can be added
|
|
6
|
+
// without touching the adapter core.
|
|
7
|
+
//
|
|
8
|
+
// Three events are synthesized at end-of-session:
|
|
9
|
+
// 1) { type:'system', subtype:'init', session_id, … }
|
|
10
|
+
// 2) { type:'assistant', session_id, message:{content:[{type:'text',text}]} }
|
|
11
|
+
// 3) { type:'result', subtype:'success'|'error', session_id, … }
|
|
12
|
+
// The init event is emitted as soon as a session id has been observed.
|
|
13
|
+
// Per-line streaming of assistant deltas is also emitted via the
|
|
14
|
+
// `assistant-partial` events; consumers accumulate them to reconstruct
|
|
15
|
+
// the response incrementally before the final consolidated event.
|
|
16
|
+
//
|
|
17
|
+
// EVENT_BUILDERS lets consumers extend the upstream-event surface by
|
|
18
|
+
// registering a builder for a new internal event type:
|
|
19
|
+
//
|
|
20
|
+
// import { EVENT_BUILDERS } from 'open-claude-p/output';
|
|
21
|
+
// EVENT_BUILDERS['my-internal-event'] = (e) => ({
|
|
22
|
+
// type: 'my-upstream-event', session_id: e.session_id, payload: e,
|
|
23
|
+
// });
|
|
24
|
+
|
|
25
|
+
/** @type {Record<string, (event: object, ctx: { sessionId: string|null }) => object|null>} */
|
|
26
|
+
export const EVENT_BUILDERS = {
|
|
27
|
+
// Stream each assistant text line as it arrives. Consumers accumulate
|
|
28
|
+
// `delta` fields per session to reconstruct the response incrementally;
|
|
29
|
+
// the final `end()` still emits one consolidated `assistant` event so
|
|
30
|
+
// the contract is forward-compatible.
|
|
31
|
+
'assistant-text': (e, ctx) => ({
|
|
32
|
+
type: 'assistant-partial',
|
|
33
|
+
session_id: ctx.sessionId,
|
|
34
|
+
delta: e.text,
|
|
35
|
+
region: e.region,
|
|
36
|
+
}),
|
|
37
|
+
// Internal events with no upstream equivalent today.
|
|
38
|
+
'assistant-region-entered': () => null,
|
|
39
|
+
'assistant-region-exited': () => null,
|
|
40
|
+
'prompt-box-shown': () => null,
|
|
41
|
+
'sentinel': () => null,
|
|
42
|
+
'spinner': () => null,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function emit(sink, obj) {
|
|
46
|
+
sink.write(JSON.stringify(obj) + '\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const streamJsonOutputAdapter = {
|
|
50
|
+
name: 'stream-json',
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} _opts
|
|
53
|
+
* @param {{ write: (s: string) => void }} sink
|
|
54
|
+
*/
|
|
55
|
+
create(_opts, sink) {
|
|
56
|
+
let sessionId = null;
|
|
57
|
+
let initEmitted = false;
|
|
58
|
+
|
|
59
|
+
function maybeEmitInit() {
|
|
60
|
+
if (initEmitted) return;
|
|
61
|
+
initEmitted = true;
|
|
62
|
+
emit(sink, {
|
|
63
|
+
type: 'system',
|
|
64
|
+
subtype: 'init',
|
|
65
|
+
session_id: sessionId,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
onEvent(event) {
|
|
71
|
+
if (event.type === 'session-id' && event.id) {
|
|
72
|
+
sessionId = event.id;
|
|
73
|
+
maybeEmitInit();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const builder = EVENT_BUILDERS[event.type];
|
|
77
|
+
if (builder) {
|
|
78
|
+
const obj = builder(event, { sessionId });
|
|
79
|
+
if (obj) emit(sink, obj);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
end(finalResult) {
|
|
83
|
+
// Make sure the consumer always sees an init line, even if no
|
|
84
|
+
// session id was captured (e.g. upstream exited before printing
|
|
85
|
+
// its banner). The session id is reported once we have it.
|
|
86
|
+
sessionId = sessionId ?? finalResult?.sessionId ?? null;
|
|
87
|
+
maybeEmitInit();
|
|
88
|
+
|
|
89
|
+
if (finalResult?.text) {
|
|
90
|
+
emit(sink, {
|
|
91
|
+
type: 'assistant',
|
|
92
|
+
session_id: sessionId,
|
|
93
|
+
message: {
|
|
94
|
+
content: [{ type: 'text', text: finalResult.text }],
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
emit(sink, {
|
|
100
|
+
type: 'result',
|
|
101
|
+
subtype: finalResult?.isError ? 'error' : 'success',
|
|
102
|
+
session_id: sessionId,
|
|
103
|
+
total_cost_usd: finalResult?.cost?.totalUsd ?? null,
|
|
104
|
+
num_turns: finalResult?.cost?.numTurns ?? null,
|
|
105
|
+
duration_ms: finalResult?.durationMs ?? null,
|
|
106
|
+
completion: finalResult?.completionReason ?? null,
|
|
107
|
+
});
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// `--output-format text` adapter.
|
|
2
|
+
//
|
|
3
|
+
// Two behaviors depending on the sink:
|
|
4
|
+
//
|
|
5
|
+
// - **TTY (terminal)**: live progress UI. Spinner labels render on a
|
|
6
|
+
// single stderr line that overwrites itself; assistant text streams
|
|
7
|
+
// to stdout as it arrives. Gives interactive use the same "looks
|
|
8
|
+
// busy" feedback the sample chat UI shows in the browser.
|
|
9
|
+
//
|
|
10
|
+
// - **Pipe / file** (`ocp "…" > out.txt`, `ocp "…" | jq`): no live
|
|
11
|
+
// output. We accumulate the response and emit it once at end() so
|
|
12
|
+
// scripts get a clean blob with no carriage-return artefacts.
|
|
13
|
+
//
|
|
14
|
+
// The live path can be force-disabled via OCP_NO_LIVE=1 (useful for
|
|
15
|
+
// debugging when --debug output competes with the spinner line).
|
|
16
|
+
|
|
17
|
+
import { cleanSpinnerLabel } from '../chat/event-filters.js';
|
|
18
|
+
|
|
19
|
+
const SENTINEL_REGEX = /⟦OCP_END:[a-f0-9]+⟧/g;
|
|
20
|
+
|
|
21
|
+
export const textOutputAdapter = {
|
|
22
|
+
name: 'text',
|
|
23
|
+
/**
|
|
24
|
+
* @param {object} _opts
|
|
25
|
+
* @param {{ write: (s: string) => void, isTTY?: boolean }} sink
|
|
26
|
+
*/
|
|
27
|
+
create(_opts, sink) {
|
|
28
|
+
const liveStdout = !!sink.isTTY;
|
|
29
|
+
const liveStderr = !!process.stderr.isTTY;
|
|
30
|
+
const live = liveStdout && liveStderr && process.env.OCP_NO_LIVE !== '1';
|
|
31
|
+
|
|
32
|
+
let spinnerActive = false;
|
|
33
|
+
let lastSpinnerLabel = '';
|
|
34
|
+
|
|
35
|
+
// Default progress label shown from spawn until claude's own
|
|
36
|
+
// spinner / response arrives. Without this, the user sees a silent
|
|
37
|
+
// terminal during the 2-15 s window of warmup + hook/MCP loading +
|
|
38
|
+
// first-byte latency.
|
|
39
|
+
let phaseLabel = 'Starting…';
|
|
40
|
+
|
|
41
|
+
function clearSpinner() {
|
|
42
|
+
if (!spinnerActive) return;
|
|
43
|
+
// CR + clear-to-end-of-line. Cheap and works in every modern terminal.
|
|
44
|
+
process.stderr.write('\r\x1b[2K');
|
|
45
|
+
spinnerActive = false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function writeSpinner(label) {
|
|
49
|
+
if (!live) return;
|
|
50
|
+
if (label === lastSpinnerLabel && spinnerActive) return;
|
|
51
|
+
process.stderr.write(`\r\x1b[2K\x1b[90m⋯ ${label}\x1b[0m`);
|
|
52
|
+
spinnerActive = true;
|
|
53
|
+
lastSpinnerLabel = label;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Kick the spinner immediately so the user gets feedback that
|
|
57
|
+
// ocp is alive, even during warmup before claude itself draws
|
|
58
|
+
// anything we can recognise as activity.
|
|
59
|
+
if (live) writeSpinner(phaseLabel);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
onEvent(event) {
|
|
63
|
+
if (!live) return; // pipe mode: silent until end()
|
|
64
|
+
// We intentionally do NOT stream per-event `assistant-text` to
|
|
65
|
+
// stdout. The PTY-stripped chunks are interleaved with TUI
|
|
66
|
+
// chrome (statusline, HUD plugins, box borders, mode
|
|
67
|
+
// indicators) that this adapter cannot reliably scrub at
|
|
68
|
+
// single-line granularity. Instead we drive a phase spinner
|
|
69
|
+
// here and emit a single clean blob in `end()` once the driver
|
|
70
|
+
// has done region-based extraction.
|
|
71
|
+
if (event?.type === 'prompt-box-shown') {
|
|
72
|
+
phaseLabel = 'Sending prompt…';
|
|
73
|
+
writeSpinner(phaseLabel);
|
|
74
|
+
} else if (event?.type === 'spinner') {
|
|
75
|
+
const label = cleanSpinnerLabel(event.label);
|
|
76
|
+
if (label) writeSpinner(label);
|
|
77
|
+
} else if (event?.type === 'assistant-region-entered') {
|
|
78
|
+
phaseLabel = 'Receiving…';
|
|
79
|
+
writeSpinner(phaseLabel);
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* @param {{ text: string, isError: boolean }} finalResult
|
|
85
|
+
*/
|
|
86
|
+
end(finalResult) {
|
|
87
|
+
clearSpinner();
|
|
88
|
+
const text = (finalResult?.text ?? '').replace(SENTINEL_REGEX, '');
|
|
89
|
+
sink.write(text);
|
|
90
|
+
if (!text.endsWith('\n')) sink.write('\n');
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Baseline parser: strip ANSI escape sequences.
|
|
2
|
+
//
|
|
3
|
+
// We do not attempt to reconstruct a terminal screen here. Cursor moves,
|
|
4
|
+
// clears, and color codes are simply removed. The remaining text is what
|
|
5
|
+
// downstream parsers consume.
|
|
6
|
+
//
|
|
7
|
+
// Buffering rule for partial chunks: if the chunk ends with an ESC that has
|
|
8
|
+
// not yet been terminated (e.g. mid-CSI), hold it until the next chunk.
|
|
9
|
+
|
|
10
|
+
const ESC = '\x1b';
|
|
11
|
+
const BEL = '\x07';
|
|
12
|
+
|
|
13
|
+
// Cursor Forward (CUF): `ESC [ Pn C` advances the cursor `Pn` columns
|
|
14
|
+
// (default 1). In headless captures the upstream `claude` CLI uses this
|
|
15
|
+
// in place of literal space characters for inter-word gaps (a TUI
|
|
16
|
+
// rendering optimization). Stripping the sequence collapses words
|
|
17
|
+
// together — `each<CUF1>other` becomes `eachother`. We translate CUF
|
|
18
|
+
// into the equivalent run of spaces BEFORE the general CSI strip so the
|
|
19
|
+
// visible text is preserved.
|
|
20
|
+
const RE_CSI_CURSOR_FORWARD = /\x1b\[(\d*)C/g;
|
|
21
|
+
function expandCursorForward(text) {
|
|
22
|
+
return text.replace(RE_CSI_CURSOR_FORWARD, (_match, n) => {
|
|
23
|
+
const count = parseInt(n || '1', 10);
|
|
24
|
+
return ' '.repeat(Math.min(count, 200)); // cap defensively
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Complete-sequence regexes — used only on input slices we have proven to be
|
|
29
|
+
// complete (i.e. without a dangling partial at the end).
|
|
30
|
+
const RE_CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g; // ESC [ params intermediates final
|
|
31
|
+
// OSC-style sequences: ESC introducer then arbitrary content then BEL or ST.
|
|
32
|
+
// Covers OSC (`]`), DCS (`P`), SOS (`X`), PM (`^`), APC (`_`).
|
|
33
|
+
const RE_STRING_TERMINATED =
|
|
34
|
+
/\x1b[\]PX^_][^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
35
|
+
// Two-byte ESC forms — covers C1 7-bit aliases (`ESC @`..`ESC _` except the
|
|
36
|
+
// CSI/OSC/etc. introducers, which are handled above) AND the DEC private
|
|
37
|
+
// single-letter sequences in the 0x30..0x3F range (`ESC 7` = DECSC save
|
|
38
|
+
// cursor, `ESC 8` = DECRC restore, `ESC =` / `ESC >` keypad modes, etc.).
|
|
39
|
+
// Order is: CSI -> string-terminated -> this, so by the time we reach
|
|
40
|
+
// RE_C1 the only `\x1b[`/`\x1b]`/`\x1b_`/... left in the input is either
|
|
41
|
+
// complete-and-already-stripped or partial-and-held-back by the pending
|
|
42
|
+
// buffer.
|
|
43
|
+
const RE_C1 = /\x1b[0-?@-_]/g;
|
|
44
|
+
|
|
45
|
+
/** Introducer characters for OSC-style sequences (BEL/ST terminated). */
|
|
46
|
+
const STRING_INTRODUCERS = new Set([']', 'P', 'X', '^', '_']);
|
|
47
|
+
|
|
48
|
+
function isCompleteEscapeTail(tail) {
|
|
49
|
+
if (tail.length < 2) return false;
|
|
50
|
+
const c1 = tail[1];
|
|
51
|
+
if (c1 === '[') return /\x1b\[[0-?]*[ -/]*[@-~]/.test(tail);
|
|
52
|
+
if (STRING_INTRODUCERS.has(c1)) {
|
|
53
|
+
return tail.includes(BEL) || tail.includes(ESC + '\\');
|
|
54
|
+
}
|
|
55
|
+
// Two-byte form: ESC <letter>. Complete with two characters.
|
|
56
|
+
return tail.length >= 2;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const ansiStripParser = {
|
|
60
|
+
name: 'ansi-strip',
|
|
61
|
+
priority: 10,
|
|
62
|
+
create() {
|
|
63
|
+
let pending = '';
|
|
64
|
+
return {
|
|
65
|
+
feed(chunk) {
|
|
66
|
+
let input = pending + chunk;
|
|
67
|
+
pending = '';
|
|
68
|
+
|
|
69
|
+
// If input ends with a possibly-incomplete escape sequence, hold its
|
|
70
|
+
// tail back for the next feed() so we don't drop or mangle bytes.
|
|
71
|
+
const lastEsc = input.lastIndexOf(ESC);
|
|
72
|
+
if (lastEsc >= 0) {
|
|
73
|
+
const tail = input.slice(lastEsc);
|
|
74
|
+
if (!isCompleteEscapeTail(tail)) {
|
|
75
|
+
pending = tail;
|
|
76
|
+
input = input.slice(0, lastEsc);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Translate visible-gap CSI sequences (cursor forward) into
|
|
81
|
+
// literal spaces BEFORE the general CSI strip eats them.
|
|
82
|
+
const expanded = expandCursorForward(input);
|
|
83
|
+
const out = expanded
|
|
84
|
+
.replace(RE_CSI, '')
|
|
85
|
+
.replace(RE_STRING_TERMINATED, '')
|
|
86
|
+
.replace(RE_C1, '');
|
|
87
|
+
return { text: out, events: [] };
|
|
88
|
+
},
|
|
89
|
+
reset() {
|
|
90
|
+
pending = '';
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
},
|
|
94
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Barrel re-export for the parsers module.
|
|
2
|
+
export {
|
|
3
|
+
registerParser, unregisterParser, listParsers, getParser,
|
|
4
|
+
} from './registry.js';
|
|
5
|
+
export { ansiStripParser } from './ansi-strip.js';
|
|
6
|
+
export { tuiFrameParser, PATTERNS as TUI_PATTERNS } from './tui-frame.js';
|
|
7
|
+
export { createSentinelParser } from './sentinel.js';
|
|
8
|
+
export { createPipeline } from './pipeline.js';
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Parser pipeline runner.
|
|
2
|
+
//
|
|
3
|
+
// Composes a list of parser definitions into a single pipeline instance.
|
|
4
|
+
// Each chunk that enters `feed()` is passed through every parser in order;
|
|
5
|
+
// each parser receives the (cleaned) text from the previous stage and may
|
|
6
|
+
// emit any number of structured events. The pipeline collects all events
|
|
7
|
+
// and returns them along with the final text.
|
|
8
|
+
//
|
|
9
|
+
// Parser definition contract (each must satisfy):
|
|
10
|
+
// {
|
|
11
|
+
// name: string
|
|
12
|
+
// priority: number
|
|
13
|
+
// create(): { feed(text): { text, events }, reset(): void }
|
|
14
|
+
// }
|
|
15
|
+
//
|
|
16
|
+
// `priority` is taken into account when sorting; lower priorities run first.
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Array<object>} parserDefs parser definitions (NOT instances)
|
|
20
|
+
*/
|
|
21
|
+
export function createPipeline(parserDefs) {
|
|
22
|
+
const ordered = [...parserDefs].sort(
|
|
23
|
+
(a, b) => (a.priority ?? 100) - (b.priority ?? 100),
|
|
24
|
+
);
|
|
25
|
+
const instances = ordered.map((p) => ({ name: p.name, inst: p.create() }));
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
/**
|
|
29
|
+
* @param {string} chunk
|
|
30
|
+
* @returns {{ text: string, events: Array<object> }}
|
|
31
|
+
*/
|
|
32
|
+
feed(chunk) {
|
|
33
|
+
let text = chunk;
|
|
34
|
+
const events = [];
|
|
35
|
+
for (const { name, inst } of instances) {
|
|
36
|
+
const r = inst.feed(text);
|
|
37
|
+
text = r.text ?? text;
|
|
38
|
+
if (r.events?.length) {
|
|
39
|
+
for (const e of r.events) {
|
|
40
|
+
events.push({ ...e, _source: name });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { text, events };
|
|
45
|
+
},
|
|
46
|
+
reset() {
|
|
47
|
+
for (const { inst } of instances) inst.reset();
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Pluggable parser registry.
|
|
2
|
+
//
|
|
3
|
+
// A "parser" here is a stage that consumes raw PTY chunks and emits
|
|
4
|
+
// structured events (assistant text, tool-use indicators, status lines,
|
|
5
|
+
// session-id banners, completion sentinels, etc.). Parsers run as an ordered
|
|
6
|
+
// pipeline: the output of one becomes the input of the next.
|
|
7
|
+
//
|
|
8
|
+
// Why a registry? The TUI rendering of the upstream `claude` CLI changes
|
|
9
|
+
// across versions. Isolating each parsing concern into a small named module
|
|
10
|
+
// keeps version-pinning surgical — when something breaks, we replace or add
|
|
11
|
+
// one parser rather than rewriting a monolithic state machine.
|
|
12
|
+
//
|
|
13
|
+
// A parser is any object with shape:
|
|
14
|
+
// {
|
|
15
|
+
// name: string unique id
|
|
16
|
+
// priority: number lower runs earlier (default 100)
|
|
17
|
+
// create(): ParserInstance factory called once per session
|
|
18
|
+
// }
|
|
19
|
+
// A ParserInstance exposes:
|
|
20
|
+
// .feed(chunk: string): ParsedEvent[]
|
|
21
|
+
// .reset(): void
|
|
22
|
+
|
|
23
|
+
/** @type {Map<string, object>} */
|
|
24
|
+
const REGISTRY = new Map();
|
|
25
|
+
|
|
26
|
+
export function registerParser(parser) {
|
|
27
|
+
if (!parser?.name) throw new Error('registerParser: parser.name is required');
|
|
28
|
+
REGISTRY.set(parser.name, parser);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function unregisterParser(name) {
|
|
32
|
+
REGISTRY.delete(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function listParsers() {
|
|
36
|
+
return [...REGISTRY.values()].sort(
|
|
37
|
+
(a, b) => (a.priority ?? 100) - (b.priority ?? 100),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getParser(name) {
|
|
42
|
+
return REGISTRY.get(name);
|
|
43
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Sentinel matcher — emits a `sentinel` event for every occurrence of the
|
|
2
|
+
// expected marker string in the (already ANSI-stripped) text stream.
|
|
3
|
+
//
|
|
4
|
+
// We emit EVERY occurrence rather than only the first because the upstream
|
|
5
|
+
// CLI typically echoes the user prompt into its input box, producing a
|
|
6
|
+
// pre-response occurrence of the literal sentinel. The completion detector
|
|
7
|
+
// is responsible for the policy choice of which occurrence to act on.
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} nonce unique hex token bound to a single request
|
|
11
|
+
*/
|
|
12
|
+
export function createSentinelParser(nonce) {
|
|
13
|
+
const sentinel = `⟦OCP_END:${nonce}⟧`;
|
|
14
|
+
return {
|
|
15
|
+
name: 'sentinel',
|
|
16
|
+
priority: 90,
|
|
17
|
+
create() {
|
|
18
|
+
// Buffer concatenates incoming text so a sentinel split across two
|
|
19
|
+
// chunks still matches. `nextScanFrom` advances past each match so we
|
|
20
|
+
// don't re-emit the same one.
|
|
21
|
+
let buffer = '';
|
|
22
|
+
let nextScanFrom = 0;
|
|
23
|
+
return {
|
|
24
|
+
feed(text) {
|
|
25
|
+
buffer += text;
|
|
26
|
+
const events = [];
|
|
27
|
+
let idx;
|
|
28
|
+
while ((idx = buffer.indexOf(sentinel, nextScanFrom)) !== -1) {
|
|
29
|
+
events.push({ type: 'sentinel', nonce, at: idx });
|
|
30
|
+
nextScanFrom = idx + sentinel.length;
|
|
31
|
+
}
|
|
32
|
+
return { text, events };
|
|
33
|
+
},
|
|
34
|
+
reset() {
|
|
35
|
+
buffer = '';
|
|
36
|
+
nextScanFrom = 0;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|