@dsh-enhanced/coding-subscription-provider 0.1.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.md +204 -0
- package/cordis.patch.yml +3 -0
- package/lib/adapter.d.ts +76 -0
- package/lib/adapter.d.ts.map +1 -0
- package/lib/adapter.js +404 -0
- package/lib/adapter.js.map +1 -0
- package/lib/auth.d.ts +33 -0
- package/lib/auth.d.ts.map +1 -0
- package/lib/auth.js +101 -0
- package/lib/auth.js.map +1 -0
- package/lib/codex-catalog.d.ts +57 -0
- package/lib/codex-catalog.d.ts.map +1 -0
- package/lib/codex-catalog.js +368 -0
- package/lib/codex-catalog.js.map +1 -0
- package/lib/config.d.ts +48 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +66 -0
- package/lib/config.js.map +1 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +40 -0
- package/lib/index.js.map +1 -0
- package/lib/process.d.ts +114 -0
- package/lib/process.d.ts.map +1 -0
- package/lib/process.js +712 -0
- package/lib/process.js.map +1 -0
- package/lib/prompt.d.ts +4 -0
- package/lib/prompt.d.ts.map +1 -0
- package/lib/prompt.js +49 -0
- package/lib/prompt.js.map +1 -0
- package/lib/providers.d.ts +30 -0
- package/lib/providers.d.ts.map +1 -0
- package/lib/providers.js +61 -0
- package/lib/providers.js.map +1 -0
- package/lib/subscription-catalog.d.ts +51 -0
- package/lib/subscription-catalog.d.ts.map +1 -0
- package/lib/subscription-catalog.js +289 -0
- package/lib/subscription-catalog.js.map +1 -0
- package/package.json +69 -0
package/lib/process.js
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
/** A stable, credential-free protocol failure that the adapter can map. */
|
|
3
|
+
export class CliProtocolError extends Error {
|
|
4
|
+
provider;
|
|
5
|
+
reason;
|
|
6
|
+
code = 'CLI_PROTOCOL_ERROR';
|
|
7
|
+
constructor(provider, reason) {
|
|
8
|
+
super(protocolErrorMessage(provider, reason), { cause: 'protocol' });
|
|
9
|
+
this.provider = provider;
|
|
10
|
+
this.reason = reason;
|
|
11
|
+
this.name = 'CliProtocolError';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Cursor reported, or failed to report, the subscription login required by this route. */
|
|
15
|
+
export class CliSubscriptionAuthError extends Error {
|
|
16
|
+
provider;
|
|
17
|
+
reason;
|
|
18
|
+
code = 'CLI_SUBSCRIPTION_AUTH_ERROR';
|
|
19
|
+
constructor(provider, reason) {
|
|
20
|
+
super(reason === 'MISSING_AUTH_EVENT'
|
|
21
|
+
? 'cursor CLI did not report its authentication source'
|
|
22
|
+
: 'cursor CLI is not authenticated with the subscription login', { cause: 'subscription-auth' });
|
|
23
|
+
this.provider = provider;
|
|
24
|
+
this.reason = reason;
|
|
25
|
+
this.name = 'CliSubscriptionAuthError';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** The child really closed, but not with an unambiguous zero-code, no-signal exit. */
|
|
29
|
+
export class CliProcessExitError extends Error {
|
|
30
|
+
provider;
|
|
31
|
+
exitCode;
|
|
32
|
+
signal;
|
|
33
|
+
code = 'CLI_PROCESS_EXIT_ERROR';
|
|
34
|
+
constructor(provider, exitCode, signal) {
|
|
35
|
+
super(`${provider} CLI exited unsuccessfully (${exitDescription(exitCode, signal)})`, { cause: 'process-exit' });
|
|
36
|
+
this.provider = provider;
|
|
37
|
+
this.exitCode = exitCode;
|
|
38
|
+
this.signal = signal;
|
|
39
|
+
this.name = 'CliProcessExitError';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** SIGKILL was requested, but ChildProcess never proved process/stdio closure. */
|
|
43
|
+
export class CliTeardownTimeoutError extends Error {
|
|
44
|
+
provider;
|
|
45
|
+
timeoutMs;
|
|
46
|
+
code = 'CLI_TEARDOWN_TIMEOUT';
|
|
47
|
+
constructor(provider, timeoutMs) {
|
|
48
|
+
super(`${provider} CLI did not close within ${timeoutMs}ms after SIGKILL`, { cause: 'process-exit' });
|
|
49
|
+
this.provider = provider;
|
|
50
|
+
this.timeoutMs = timeoutMs;
|
|
51
|
+
this.name = 'CliTeardownTimeoutError';
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const defaults = {
|
|
55
|
+
timeoutMs: 10 * 60_000,
|
|
56
|
+
killGraceMs: 3_000,
|
|
57
|
+
maxLineBytes: 64 * 1024,
|
|
58
|
+
maxOutputBytes: 512 * 1024,
|
|
59
|
+
maxStderrBytes: 32 * 1024,
|
|
60
|
+
};
|
|
61
|
+
const defaultSpawn = (command, args, options) => nodeSpawn(command, [...args], { ...options, stdio: [...options.stdio] });
|
|
62
|
+
const inheritedEnvNames = new Set([
|
|
63
|
+
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'USERPROFILE',
|
|
64
|
+
'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_CACHE_HOME', 'XDG_RUNTIME_DIR',
|
|
65
|
+
'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL', 'DBUS_SESSION_BUS_ADDRESS',
|
|
66
|
+
'SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', 'APPDATA', 'LOCALAPPDATA',
|
|
67
|
+
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
68
|
+
'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'REQUESTS_CA_BUNDLE',
|
|
69
|
+
'CODEX_HOME', 'CLAUDE_CONFIG_DIR', 'CURSOR_CONFIG_DIR',
|
|
70
|
+
'GROK_HOME', 'GROK_CONFIG_DIR', 'XAI_CONFIG_DIR',
|
|
71
|
+
]);
|
|
72
|
+
const excludedEnvNames = new Set([
|
|
73
|
+
'CODEX_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN',
|
|
74
|
+
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
75
|
+
'CURSOR_API_KEY', 'XAI_API_KEY',
|
|
76
|
+
'OPENAI_BASE_URL', 'ANTHROPIC_BASE_URL', 'XAI_BASE_URL',
|
|
77
|
+
'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY', 'ANTHROPIC_PROFILE',
|
|
78
|
+
]);
|
|
79
|
+
/** Build an inherited environment that favours the local CLI's subscription login over API-key billing. */
|
|
80
|
+
export function buildSubscriptionEnv(extraEnvNames = []) {
|
|
81
|
+
const env = {};
|
|
82
|
+
for (const name of new Set([...inheritedEnvNames, ...extraEnvNames])) {
|
|
83
|
+
if (excludedEnvNames.has(name.toUpperCase()))
|
|
84
|
+
continue;
|
|
85
|
+
const value = process.env[name];
|
|
86
|
+
if (value !== undefined)
|
|
87
|
+
env[name] = value;
|
|
88
|
+
}
|
|
89
|
+
return env;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Starts one local, already-authenticated coding CLI and yields only assistant text.
|
|
93
|
+
* It never reads credential files or accepts credential values in config. An explicitly
|
|
94
|
+
* allowlisted environment name may still be forwarded unchanged to the official CLI.
|
|
95
|
+
* No shell is invoked.
|
|
96
|
+
*/
|
|
97
|
+
export async function* runCliText(invocation, options = {}) {
|
|
98
|
+
const limits = { ...defaults, ...options };
|
|
99
|
+
// Pre-spawn failures never handed the prompt argv to the OS: report not-submitted and stop.
|
|
100
|
+
const reportPreSpawn = () => {
|
|
101
|
+
if (options.onSettled === undefined)
|
|
102
|
+
return;
|
|
103
|
+
try {
|
|
104
|
+
options.onSettled({ phase: 'spawn', promptSubmissionState: 'not-submitted', assistantTextObserved: false, teardownState: 'not-started' });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A diagnostic sink must never change model-call settlement.
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
if (options.signal?.aborted) {
|
|
111
|
+
reportPreSpawn();
|
|
112
|
+
throw abortError('aborted before CLI spawn');
|
|
113
|
+
}
|
|
114
|
+
let child;
|
|
115
|
+
try {
|
|
116
|
+
child = (options.spawn ?? defaultSpawn)(invocation.command, invocation.args, {
|
|
117
|
+
cwd: invocation.cwd,
|
|
118
|
+
shell: false,
|
|
119
|
+
detached: process.platform !== 'win32',
|
|
120
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
121
|
+
env: buildSubscriptionEnv(options.extraEnvNames),
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
reportPreSpawn();
|
|
126
|
+
throw error;
|
|
127
|
+
}
|
|
128
|
+
const queue = new TextQueue();
|
|
129
|
+
let totalOutputBytes = 0;
|
|
130
|
+
let stderr = '';
|
|
131
|
+
let finished = false;
|
|
132
|
+
let stopping = false;
|
|
133
|
+
let terminationError;
|
|
134
|
+
let sawIncrementalText = false;
|
|
135
|
+
let sawAggregateText = false;
|
|
136
|
+
let sawValidJson = false;
|
|
137
|
+
let sawRecognizedEvent = false;
|
|
138
|
+
let sawAssistantText = false;
|
|
139
|
+
let sawSuccessTerminal = false;
|
|
140
|
+
let terminalReason;
|
|
141
|
+
let sawCursorAuthInit = false;
|
|
142
|
+
let sawChildOutput = false;
|
|
143
|
+
let killTimer;
|
|
144
|
+
let postKillTimer;
|
|
145
|
+
let resolveClosed;
|
|
146
|
+
const closed = new Promise(resolve => { resolveClosed = resolve; });
|
|
147
|
+
let disposeStdout = () => { };
|
|
148
|
+
let disposeStderr = () => { };
|
|
149
|
+
// spawn() returned after receiving prompt argv, but only a later spawn/error event
|
|
150
|
+
// can prove whether the OS accepted or rejected it.
|
|
151
|
+
let promptSubmissionState = 'unknown';
|
|
152
|
+
let sawSpawn = false;
|
|
153
|
+
let phase = 'spawn';
|
|
154
|
+
// Teardown begins the moment `stop` fires and completes when ChildProcess `close` is settled.
|
|
155
|
+
let teardownState = 'not-started';
|
|
156
|
+
// Monotonic clock anchors (ms); each metric is reported only when both its ends are known.
|
|
157
|
+
// `spawn()` returning a ChildProcess does not prove the OS accepted it, so the spawn
|
|
158
|
+
// anchor is captured only from the corresponding child event below.
|
|
159
|
+
let spawnedAt;
|
|
160
|
+
let firstEventAt;
|
|
161
|
+
let firstTextAt;
|
|
162
|
+
let terminalAt;
|
|
163
|
+
let stopAt;
|
|
164
|
+
let closeAt;
|
|
165
|
+
let observedExitCode;
|
|
166
|
+
let observedSignal;
|
|
167
|
+
const buildMetrics = () => {
|
|
168
|
+
const metrics = {};
|
|
169
|
+
if (spawnedAt !== undefined && firstEventAt !== undefined && firstEventAt >= spawnedAt) {
|
|
170
|
+
metrics.spawnToFirstEventMs = firstEventAt - spawnedAt;
|
|
171
|
+
}
|
|
172
|
+
if (firstEventAt !== undefined && firstTextAt !== undefined)
|
|
173
|
+
metrics.eventToFirstTextMs = firstTextAt - firstEventAt;
|
|
174
|
+
if (spawnedAt !== undefined && terminalAt !== undefined && terminalAt >= spawnedAt) {
|
|
175
|
+
metrics.spawnToTerminalMs = terminalAt - spawnedAt;
|
|
176
|
+
}
|
|
177
|
+
if (stopAt !== undefined && closeAt !== undefined)
|
|
178
|
+
metrics.teardownDurationMs = closeAt - stopAt;
|
|
179
|
+
return Object.keys(metrics).length > 0 ? metrics : undefined;
|
|
180
|
+
};
|
|
181
|
+
const reportSettled = (error) => {
|
|
182
|
+
if (options.onSettled === undefined)
|
|
183
|
+
return;
|
|
184
|
+
const metrics = buildMetrics();
|
|
185
|
+
const context = {
|
|
186
|
+
phase: error === undefined ? 'terminal' : phase,
|
|
187
|
+
promptSubmissionState,
|
|
188
|
+
assistantTextObserved: sawAssistantText,
|
|
189
|
+
teardownState,
|
|
190
|
+
...(terminalReason !== undefined ? { terminalReason } : {}),
|
|
191
|
+
...(observedExitCode !== undefined ? { exitCode: observedExitCode } : {}),
|
|
192
|
+
...(observedSignal !== undefined ? { signal: observedSignal } : {}),
|
|
193
|
+
...(metrics !== undefined ? { metrics } : {}),
|
|
194
|
+
};
|
|
195
|
+
try {
|
|
196
|
+
options.onSettled(context);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// A diagnostic sink must never change model-call settlement.
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const stop = (error) => {
|
|
203
|
+
if (stopping || finished)
|
|
204
|
+
return;
|
|
205
|
+
stopping = true;
|
|
206
|
+
terminationError = error;
|
|
207
|
+
if (teardownState === 'not-started')
|
|
208
|
+
teardownState = 'in-progress';
|
|
209
|
+
stopAt ??= performance.now();
|
|
210
|
+
terminateProcessTree(child, 'SIGINT');
|
|
211
|
+
// An injected/embedded ChildProcess may emit close synchronously from kill().
|
|
212
|
+
// Do not arm escalation after that close already settled the request.
|
|
213
|
+
if (finished)
|
|
214
|
+
return;
|
|
215
|
+
killTimer = setTimeout(() => {
|
|
216
|
+
terminateProcessTree(child, 'SIGKILL');
|
|
217
|
+
// Apply the same guard when SIGKILL synchronously proves closure.
|
|
218
|
+
if (finished)
|
|
219
|
+
return;
|
|
220
|
+
// SIGKILL being sent does not prove that the process (or its stdio) has
|
|
221
|
+
// closed. Give `close` one more bounded grace interval, then settle with
|
|
222
|
+
// an explicit live-process-risk state instead of hanging forever.
|
|
223
|
+
postKillTimer = setTimeout(() => {
|
|
224
|
+
phase = 'child-close';
|
|
225
|
+
teardownState = 'timed-out';
|
|
226
|
+
// Preserve the initiating model-call error (abort/timeout/protocol/limit)
|
|
227
|
+
// for routing and health classification. The distinct teardown risk lives
|
|
228
|
+
// in context; the fallback is only for defensive completeness.
|
|
229
|
+
close(terminationError ?? new CliTeardownTimeoutError(invocation.provider, limits.killGraceMs), false);
|
|
230
|
+
}, limits.killGraceMs);
|
|
231
|
+
}, limits.killGraceMs);
|
|
232
|
+
};
|
|
233
|
+
const onAbort = () => stop(abortError('CLI execution aborted'));
|
|
234
|
+
const timeout = setTimeout(() => stop(timeoutError(limits.timeoutMs)), limits.timeoutMs);
|
|
235
|
+
const cleanupChildTracking = () => {
|
|
236
|
+
child.removeListener?.('spawn', onSpawn);
|
|
237
|
+
child.removeListener?.('error', onError);
|
|
238
|
+
child.removeListener?.('close', onClose);
|
|
239
|
+
disposeStdout();
|
|
240
|
+
disposeStderr();
|
|
241
|
+
};
|
|
242
|
+
const close = (error, observedClose = true) => {
|
|
243
|
+
if (finished) {
|
|
244
|
+
// A post-deadline close only releases background tracking. The request and
|
|
245
|
+
// its diagnostic callback were already settled exactly once as timed-out.
|
|
246
|
+
if (observedClose && teardownState === 'timed-out')
|
|
247
|
+
cleanupChildTracking();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
finished = true;
|
|
251
|
+
clearTimeout(timeout);
|
|
252
|
+
if (killTimer)
|
|
253
|
+
clearTimeout(killTimer);
|
|
254
|
+
if (postKillTimer)
|
|
255
|
+
clearTimeout(postKillTimer);
|
|
256
|
+
options.signal?.removeEventListener('abort', onAbort);
|
|
257
|
+
if (observedClose) {
|
|
258
|
+
// The child's stdio has now closed; any teardown started by `stop` has completed.
|
|
259
|
+
closeAt ??= performance.now();
|
|
260
|
+
if (teardownState === 'in-progress')
|
|
261
|
+
teardownState = 'completed';
|
|
262
|
+
cleanupChildTracking();
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
// The request is bounded, while minimal background listeners keep draining
|
|
266
|
+
// pipes and observe a late close without ever reporting a second settlement.
|
|
267
|
+
child.unref?.();
|
|
268
|
+
}
|
|
269
|
+
if (stderr) {
|
|
270
|
+
try {
|
|
271
|
+
options.onDiagnostic?.(stderr);
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
// Diagnostics must never change model-call settlement.
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
reportSettled(error);
|
|
278
|
+
queue.close(error);
|
|
279
|
+
resolveClosed?.();
|
|
280
|
+
};
|
|
281
|
+
const onError = (error) => {
|
|
282
|
+
// Node guarantees `close` after a spawn `error`. Waiting for it also avoids
|
|
283
|
+
// treating a kill-related error as proof that a live process has exited.
|
|
284
|
+
// Any spawn error before both a `spawn` event and any child output (ENOENT,
|
|
285
|
+
// EACCES, and every other pre-run failure alike) proves the OS never accepted
|
|
286
|
+
// the prompt argv, so replay stays safe: mark `not-submitted`. Once the child
|
|
287
|
+
// has spawned or produced output, submission is no longer disprovable and the
|
|
288
|
+
// state is left as-is (`submitted` or the default `unknown`).
|
|
289
|
+
if (!stopping && !sawSpawn && !sawChildOutput)
|
|
290
|
+
promptSubmissionState = 'not-submitted';
|
|
291
|
+
if (!terminationError)
|
|
292
|
+
terminationError = error;
|
|
293
|
+
stopping = true;
|
|
294
|
+
};
|
|
295
|
+
const onClose = (code, signal) => {
|
|
296
|
+
observedExitCode = code;
|
|
297
|
+
observedSignal = signal;
|
|
298
|
+
if (terminationError)
|
|
299
|
+
close(terminationError);
|
|
300
|
+
else if (code !== 0 || signal !== null) {
|
|
301
|
+
phase = 'child-close';
|
|
302
|
+
close(new CliProcessExitError(invocation.provider, code, signal));
|
|
303
|
+
}
|
|
304
|
+
else
|
|
305
|
+
close(validateProtocolSettlement(invocation.provider, {
|
|
306
|
+
sawValidJson,
|
|
307
|
+
sawRecognizedEvent,
|
|
308
|
+
sawAssistantText,
|
|
309
|
+
sawSuccessTerminal,
|
|
310
|
+
sawCursorAuthInit,
|
|
311
|
+
}));
|
|
312
|
+
};
|
|
313
|
+
// A delivered `spawn` proves the OS accepted the prompt argv; before it, replay is still safe.
|
|
314
|
+
const onSpawn = () => {
|
|
315
|
+
sawSpawn = true;
|
|
316
|
+
spawnedAt ??= performance.now();
|
|
317
|
+
promptSubmissionState = 'submitted';
|
|
318
|
+
if (phase === 'spawn')
|
|
319
|
+
phase = 'initialize';
|
|
320
|
+
};
|
|
321
|
+
child.once('spawn', onSpawn);
|
|
322
|
+
child.once('error', onError);
|
|
323
|
+
// Unlike `exit`, `close` fires after stdio is closed, so the final JSONL event
|
|
324
|
+
// cannot be dropped while buffered stdout is still draining.
|
|
325
|
+
child.once('close', onClose);
|
|
326
|
+
disposeStdout = consumeLines(child.stdout, limits.maxLineBytes, line => {
|
|
327
|
+
if (stopping || finished)
|
|
328
|
+
return;
|
|
329
|
+
// Child output proves the process ran. On its own it never proves the prompt was
|
|
330
|
+
// submitted, so the state stays at its default `unknown` (never demoted to
|
|
331
|
+
// `not-submitted`): `onError` only sets `not-submitted` while `!sawChildOutput`, so
|
|
332
|
+
// any spawn error after this point leaves submission `unknown`, not `not-submitted`.
|
|
333
|
+
sawChildOutput = true;
|
|
334
|
+
const decoded = decodeJsonLine(line);
|
|
335
|
+
if (decoded.kind === 'empty')
|
|
336
|
+
return;
|
|
337
|
+
if (decoded.kind === 'malformed') {
|
|
338
|
+
stop(new CliProtocolError(invocation.provider, 'MALFORMED_JSON'));
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
sawValidJson = true;
|
|
342
|
+
if (!decoded.event)
|
|
343
|
+
return;
|
|
344
|
+
const parsed = parseProviderEvent(invocation.provider, decoded.event);
|
|
345
|
+
if (!parsed)
|
|
346
|
+
return;
|
|
347
|
+
sawRecognizedEvent = true;
|
|
348
|
+
firstEventAt ??= performance.now();
|
|
349
|
+
if (phase === 'spawn' || phase === 'initialize')
|
|
350
|
+
phase = 'prompt';
|
|
351
|
+
if (parsed.auth === 'subscription')
|
|
352
|
+
sawCursorAuthInit = true;
|
|
353
|
+
else if (parsed.auth === 'other') {
|
|
354
|
+
stop(new CliSubscriptionAuthError('cursor', 'UNEXPECTED_AUTH_SOURCE'));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (parsed.outcome === 'failure') {
|
|
358
|
+
terminalReason = 'reported-failure';
|
|
359
|
+
phase = 'terminal';
|
|
360
|
+
stop(new CliProtocolError(invocation.provider, 'REPORTED_FAILURE'));
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (parsed.outcome === 'invalid') {
|
|
364
|
+
terminalReason = 'invalid-terminal';
|
|
365
|
+
phase = 'terminal';
|
|
366
|
+
stop(new CliProtocolError(invocation.provider, 'INVALID_TERMINAL'));
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (parsed.outcome === 'success') {
|
|
370
|
+
sawSuccessTerminal = true;
|
|
371
|
+
terminalReason = 'success';
|
|
372
|
+
terminalAt ??= performance.now();
|
|
373
|
+
phase = 'terminal';
|
|
374
|
+
}
|
|
375
|
+
if (!parsed.text)
|
|
376
|
+
return;
|
|
377
|
+
sawAssistantText = true;
|
|
378
|
+
firstTextAt ??= performance.now();
|
|
379
|
+
if (phase === 'prompt')
|
|
380
|
+
phase = 'stream';
|
|
381
|
+
if (parsed.terminal && (sawIncrementalText || sawAggregateText))
|
|
382
|
+
return;
|
|
383
|
+
if (parsed.terminal)
|
|
384
|
+
sawAggregateText = true;
|
|
385
|
+
else
|
|
386
|
+
sawIncrementalText = true;
|
|
387
|
+
totalOutputBytes += byteLength(parsed.text);
|
|
388
|
+
if (totalOutputBytes > limits.maxOutputBytes) {
|
|
389
|
+
stop(outputLimitError(limits.maxOutputBytes));
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
queue.push(parsed.text);
|
|
393
|
+
}, error => stop(error));
|
|
394
|
+
disposeStderr = consumeLines(child.stderr, limits.maxLineBytes, line => {
|
|
395
|
+
if (stopping || finished)
|
|
396
|
+
return;
|
|
397
|
+
sawChildOutput = true;
|
|
398
|
+
// Stderr is diagnostic only; never expose it as model output or retain it unbounded.
|
|
399
|
+
stderr = appendBounded(stderr, line, limits.maxStderrBytes);
|
|
400
|
+
}, error => stop(error));
|
|
401
|
+
options.signal?.addEventListener('abort', onAbort, { once: true });
|
|
402
|
+
// Close the race where the signal aborted after the pre-spawn check but before
|
|
403
|
+
// listener registration. Child and pipe lifecycle handlers are installed first,
|
|
404
|
+
// including for injected process implementations whose kill() settles synchronously.
|
|
405
|
+
if (options.signal?.aborted)
|
|
406
|
+
onAbort();
|
|
407
|
+
try {
|
|
408
|
+
for await (const text of queue)
|
|
409
|
+
yield text;
|
|
410
|
+
}
|
|
411
|
+
finally {
|
|
412
|
+
// A consumer that breaks early must still give SIGINT the grace interval.
|
|
413
|
+
if (!finished)
|
|
414
|
+
stop(abortError('CLI output consumer closed early'));
|
|
415
|
+
await closed;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
export function parseAssistantEvent(provider, line) {
|
|
419
|
+
const decoded = decodeJsonLine(line);
|
|
420
|
+
return decoded.kind === 'json' && decoded.event ? parseProviderEvent(provider, decoded.event) : undefined;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Recognizes one already-decoded JSON event into the normalized {@link ParsedEvent}.
|
|
424
|
+
* The layering is: `decodeJsonLine` (syntax) -> a per-provider decoder (shape) ->
|
|
425
|
+
* this dispatcher (routing) -> the stdout reducer in `runCliText` (text/terminal/auth).
|
|
426
|
+
* An unrecognized event returns `undefined` so the caller can fail closed on it.
|
|
427
|
+
*/
|
|
428
|
+
function parseProviderEvent(provider, event) {
|
|
429
|
+
return providerDecoders[provider](event);
|
|
430
|
+
}
|
|
431
|
+
const providerDecoders = {
|
|
432
|
+
codex: decodeCodexEvent,
|
|
433
|
+
claude: decodeClaudeEvent,
|
|
434
|
+
cursor: decodeCursorEvent,
|
|
435
|
+
grok: decodeGrokEvent,
|
|
436
|
+
};
|
|
437
|
+
function decodeCodexEvent(event) {
|
|
438
|
+
const item = object(event.item);
|
|
439
|
+
if (event.type === 'item.completed' && item?.type === 'agent_message')
|
|
440
|
+
return parsed(textOf(item.content) ?? textOf(item.text), false);
|
|
441
|
+
if (event.type === 'agent_message')
|
|
442
|
+
return parsed(textOf(event.content) ?? textOf(event.text), false);
|
|
443
|
+
if (event.type === 'turn.completed')
|
|
444
|
+
return known({ outcome: 'success' });
|
|
445
|
+
if (event.type === 'turn.failed' || event.type === 'error')
|
|
446
|
+
return known({ outcome: 'failure' });
|
|
447
|
+
if (event.type === 'thread.started' || event.type === 'turn.started' || event.type === 'item.started'
|
|
448
|
+
|| event.type === 'item.updated' || event.type === 'item.completed')
|
|
449
|
+
return known();
|
|
450
|
+
return undefined;
|
|
451
|
+
}
|
|
452
|
+
function decodeClaudeEvent(event) {
|
|
453
|
+
const inner = object(event.event);
|
|
454
|
+
if (event.type === 'stream_event' && inner?.type === 'content_block_delta')
|
|
455
|
+
return parsed(textOf(object(inner.delta)?.text), false);
|
|
456
|
+
if (event.type === 'stream_event')
|
|
457
|
+
return known();
|
|
458
|
+
if (event.type === 'assistant')
|
|
459
|
+
return parsed(textOf(event.message) ?? textOf(event.content), true);
|
|
460
|
+
if (event.type === 'result')
|
|
461
|
+
return parsed(textOf(event.result), true, resultOutcome(event));
|
|
462
|
+
if (knownType(event, ['system', 'user', 'tool_progress', 'tool_use_summary', 'auth_status', 'rate_limit_event']))
|
|
463
|
+
return known();
|
|
464
|
+
return undefined;
|
|
465
|
+
}
|
|
466
|
+
function decodeCursorEvent(event) {
|
|
467
|
+
if (event.type === 'system' && event.subtype === 'init') {
|
|
468
|
+
return known({ auth: event.apiKeySource === 'login' ? 'subscription' : 'other' });
|
|
469
|
+
}
|
|
470
|
+
if (event.type === 'system')
|
|
471
|
+
return known();
|
|
472
|
+
if (event.type === 'assistant')
|
|
473
|
+
return parsed(textOf(event.message) ?? textOf(event.content) ?? textOf(event.text), false);
|
|
474
|
+
if (event.type === 'result')
|
|
475
|
+
return parsed(textOf(event.result) ?? textOf(event.message), true, resultOutcome(event));
|
|
476
|
+
if (knownType(event, ['user', 'tool_call', 'tool_result']))
|
|
477
|
+
return known();
|
|
478
|
+
return undefined;
|
|
479
|
+
}
|
|
480
|
+
function decodeGrokEvent(event) {
|
|
481
|
+
if (event.type === 'text') {
|
|
482
|
+
return typeof event.data === 'string' ? parsed(event.data, false) : undefined;
|
|
483
|
+
}
|
|
484
|
+
if (event.type === 'error')
|
|
485
|
+
return known({ outcome: 'failure' });
|
|
486
|
+
if (event.type === 'end') {
|
|
487
|
+
return typeof event.stopReason === 'string' && event.stopReason.length > 0
|
|
488
|
+
? known({ outcome: 'success' })
|
|
489
|
+
: known({ outcome: 'invalid' });
|
|
490
|
+
}
|
|
491
|
+
if (event.type === 'streaming-messages-json' || event.type === 'message' || event.type === 'update') {
|
|
492
|
+
return parsed(textOf(event.text) ?? textOf(event.content) ?? textOf(object(event.delta)?.text) ?? textOf(event.message), false);
|
|
493
|
+
}
|
|
494
|
+
const anthropic = object(event.event);
|
|
495
|
+
if (event.type === 'stream_event' && anthropic?.type === 'content_block_delta')
|
|
496
|
+
return parsed(textOf(object(anthropic.delta)?.text), false);
|
|
497
|
+
if (event.type === 'stream_event')
|
|
498
|
+
return known();
|
|
499
|
+
if (event.type === 'content_block_delta')
|
|
500
|
+
return parsed(textOf(object(event.delta)?.text), false);
|
|
501
|
+
const update = object(event.update);
|
|
502
|
+
if (event.sessionUpdate === 'agent_message_chunk')
|
|
503
|
+
return parsed(textOf(event.content) ?? textOf(update?.text) ?? textOf(update?.content), false);
|
|
504
|
+
if (typeof event.sessionUpdate === 'string')
|
|
505
|
+
return known();
|
|
506
|
+
if (update?.sessionUpdate === 'agent_message_chunk')
|
|
507
|
+
return parsed(textOf(update.content), false);
|
|
508
|
+
if (typeof update?.sessionUpdate === 'string')
|
|
509
|
+
return known();
|
|
510
|
+
const paramsUpdate = object(object(event.params)?.update);
|
|
511
|
+
if (event.method === 'session/update' && paramsUpdate?.sessionUpdate === 'agent_message_chunk') {
|
|
512
|
+
return parsed(textOf(paramsUpdate.content), false);
|
|
513
|
+
}
|
|
514
|
+
if (event.method === 'session/update')
|
|
515
|
+
return known();
|
|
516
|
+
if (event.type === 'result')
|
|
517
|
+
return parsed(textOf(event.result) ?? textOf(event.text), true, resultOutcome(event));
|
|
518
|
+
if (event.type === 'assistant')
|
|
519
|
+
return parsed(textOf(event.message) ?? textOf(event.content) ?? textOf(event.text), false);
|
|
520
|
+
if (knownType(event, ['system', 'user', 'tool_call', 'tool_result']))
|
|
521
|
+
return known();
|
|
522
|
+
return undefined;
|
|
523
|
+
}
|
|
524
|
+
export function parseAssistantText(provider, line) {
|
|
525
|
+
return parseAssistantEvent(provider, line)?.text;
|
|
526
|
+
}
|
|
527
|
+
function parsed(text, terminal, outcome) {
|
|
528
|
+
return { ...(text ? { text } : {}), terminal, ...(outcome ? { outcome } : {}) };
|
|
529
|
+
}
|
|
530
|
+
function known(fields = {}) {
|
|
531
|
+
return { terminal: false, ...fields };
|
|
532
|
+
}
|
|
533
|
+
function knownType(event, types) {
|
|
534
|
+
return typeof event.type === 'string' && types.includes(event.type);
|
|
535
|
+
}
|
|
536
|
+
function resultOutcome(event) {
|
|
537
|
+
const subtype = typeof event.subtype === 'string' ? event.subtype.toLowerCase() : '';
|
|
538
|
+
if (subtype === 'success' && event.is_error === false)
|
|
539
|
+
return 'success';
|
|
540
|
+
if (event.is_error === true || event.success === false
|
|
541
|
+
|| (event.error !== undefined && event.error !== null && event.error !== false)
|
|
542
|
+
|| subtype.includes('error') || subtype.includes('fail')
|
|
543
|
+
|| subtype.includes('cancel') || subtype.includes('interrupt'))
|
|
544
|
+
return 'failure';
|
|
545
|
+
return 'invalid';
|
|
546
|
+
}
|
|
547
|
+
function decodeJsonLine(line) {
|
|
548
|
+
const trimmed = line.trim();
|
|
549
|
+
if (!trimmed)
|
|
550
|
+
return { kind: 'empty' };
|
|
551
|
+
try {
|
|
552
|
+
const value = JSON.parse(trimmed);
|
|
553
|
+
const event = object(value);
|
|
554
|
+
return event ? { kind: 'json', event } : { kind: 'json' };
|
|
555
|
+
}
|
|
556
|
+
catch {
|
|
557
|
+
return { kind: 'malformed' };
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
function validateProtocolSettlement(provider, state) {
|
|
561
|
+
if (provider === 'cursor' && !state.sawCursorAuthInit) {
|
|
562
|
+
return new CliSubscriptionAuthError('cursor', 'MISSING_AUTH_EVENT');
|
|
563
|
+
}
|
|
564
|
+
if (!state.sawValidJson)
|
|
565
|
+
return new CliProtocolError(provider, 'NO_JSON_EVENTS');
|
|
566
|
+
if (!state.sawRecognizedEvent)
|
|
567
|
+
return new CliProtocolError(provider, 'UNRECOGNIZED_EVENTS');
|
|
568
|
+
if (!state.sawAssistantText)
|
|
569
|
+
return new CliProtocolError(provider, 'NO_ASSISTANT_TEXT');
|
|
570
|
+
if ((provider === 'codex' || provider === 'claude' || provider === 'cursor') && !state.sawSuccessTerminal) {
|
|
571
|
+
return new CliProtocolError(provider, 'MISSING_SUCCESS_TERMINAL');
|
|
572
|
+
}
|
|
573
|
+
return undefined;
|
|
574
|
+
}
|
|
575
|
+
function protocolErrorMessage(provider, reason) {
|
|
576
|
+
const descriptions = {
|
|
577
|
+
MALFORMED_JSON: 'emitted malformed NDJSON',
|
|
578
|
+
NO_JSON_EVENTS: 'closed without any JSON events',
|
|
579
|
+
UNRECOGNIZED_EVENTS: 'emitted no recognized protocol events',
|
|
580
|
+
NO_ASSISTANT_TEXT: 'closed without assistant text',
|
|
581
|
+
MISSING_SUCCESS_TERMINAL: 'closed without a successful terminal event',
|
|
582
|
+
INVALID_TERMINAL: 'emitted an unrecognized terminal result',
|
|
583
|
+
REPORTED_FAILURE: 'reported a failed model turn',
|
|
584
|
+
};
|
|
585
|
+
return `${provider} CLI ${descriptions[reason]}`;
|
|
586
|
+
}
|
|
587
|
+
function exitDescription(code, signal) {
|
|
588
|
+
const parts = [`code=${code === null ? 'null' : String(code)}`];
|
|
589
|
+
if (signal !== null)
|
|
590
|
+
parts.push(`signal=${signal}`);
|
|
591
|
+
return parts.join(', ');
|
|
592
|
+
}
|
|
593
|
+
function object(value) {
|
|
594
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
|
|
595
|
+
}
|
|
596
|
+
function textOf(value) {
|
|
597
|
+
if (typeof value === 'string')
|
|
598
|
+
return value;
|
|
599
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value))
|
|
600
|
+
return textOf(object(value)?.text) ?? textOf(object(value)?.content);
|
|
601
|
+
if (Array.isArray(value))
|
|
602
|
+
return value.map(part => textOf(part) ?? '').join('') || undefined;
|
|
603
|
+
return undefined;
|
|
604
|
+
}
|
|
605
|
+
function appendBounded(value, addition, limit) {
|
|
606
|
+
const next = `${value}${value ? '\n' : ''}${addition}`;
|
|
607
|
+
const bytes = Buffer.from(next);
|
|
608
|
+
return bytes.byteLength <= limit ? next : bytes.subarray(bytes.byteLength - limit).toString('utf8');
|
|
609
|
+
}
|
|
610
|
+
function byteLength(value) { return Buffer.byteLength(value, 'utf8'); }
|
|
611
|
+
function consumeLines(stream, maxLineBytes, onLine, onError) {
|
|
612
|
+
if (!stream)
|
|
613
|
+
return () => { };
|
|
614
|
+
let pending = '';
|
|
615
|
+
stream.setEncoding('utf8');
|
|
616
|
+
const onData = (chunk) => {
|
|
617
|
+
pending += chunk;
|
|
618
|
+
while (true) {
|
|
619
|
+
const newline = pending.indexOf('\n');
|
|
620
|
+
if (newline === -1) {
|
|
621
|
+
if (byteLength(pending) > maxLineBytes) {
|
|
622
|
+
onError(lineLimitError(maxLineBytes));
|
|
623
|
+
pending = '';
|
|
624
|
+
}
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
let line = pending.slice(0, newline);
|
|
628
|
+
pending = pending.slice(newline + 1);
|
|
629
|
+
if (line.endsWith('\r'))
|
|
630
|
+
line = line.slice(0, -1);
|
|
631
|
+
if (byteLength(line) > maxLineBytes)
|
|
632
|
+
onError(lineLimitError(maxLineBytes));
|
|
633
|
+
else
|
|
634
|
+
onLine(line);
|
|
635
|
+
}
|
|
636
|
+
};
|
|
637
|
+
const onEnd = () => {
|
|
638
|
+
if (!pending)
|
|
639
|
+
return;
|
|
640
|
+
if (byteLength(pending) > maxLineBytes)
|
|
641
|
+
onError(lineLimitError(maxLineBytes));
|
|
642
|
+
else
|
|
643
|
+
onLine(pending);
|
|
644
|
+
};
|
|
645
|
+
stream.on('data', onData);
|
|
646
|
+
stream.on('error', onError);
|
|
647
|
+
stream.on('end', onEnd);
|
|
648
|
+
return () => {
|
|
649
|
+
stream.removeListener('data', onData);
|
|
650
|
+
stream.removeListener('error', onError);
|
|
651
|
+
stream.removeListener('end', onEnd);
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
function abortError(message) { return new Error(message, { cause: 'abort' }); }
|
|
655
|
+
function timeoutError(timeoutMs) { return new Error(`CLI execution timed out after ${timeoutMs}ms`, { cause: 'timeout' }); }
|
|
656
|
+
function outputLimitError(maxOutputBytes) { return new Error(`CLI output exceeded ${maxOutputBytes} bytes`, { cause: 'output-limit' }); }
|
|
657
|
+
function lineLimitError(maxLineBytes) { return new Error(`CLI line exceeded ${maxLineBytes} bytes`, { cause: 'line-limit' }); }
|
|
658
|
+
function terminateProcessTree(child, signal) {
|
|
659
|
+
if (process.platform !== 'win32' && child.pid !== undefined && child.pid > 0) {
|
|
660
|
+
try {
|
|
661
|
+
process.kill(-child.pid, signal);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
catch {
|
|
665
|
+
// The group may already have exited; fall back to the direct child handle.
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
child.kill(signal);
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
// Teardown is best-effort after the process has already disappeared.
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
class TextQueue {
|
|
676
|
+
values = [];
|
|
677
|
+
waiter;
|
|
678
|
+
error;
|
|
679
|
+
done = false;
|
|
680
|
+
push(value) {
|
|
681
|
+
if (this.done)
|
|
682
|
+
return;
|
|
683
|
+
this.values.push(value);
|
|
684
|
+
const waiter = this.waiter;
|
|
685
|
+
this.waiter = undefined;
|
|
686
|
+
waiter?.();
|
|
687
|
+
}
|
|
688
|
+
close(error) {
|
|
689
|
+
if (this.done)
|
|
690
|
+
return;
|
|
691
|
+
this.done = true;
|
|
692
|
+
this.error = error;
|
|
693
|
+
const waiter = this.waiter;
|
|
694
|
+
this.waiter = undefined;
|
|
695
|
+
waiter?.();
|
|
696
|
+
}
|
|
697
|
+
async *[Symbol.asyncIterator]() {
|
|
698
|
+
while (true) {
|
|
699
|
+
if (this.values.length)
|
|
700
|
+
yield this.values.shift();
|
|
701
|
+
else if (this.done) {
|
|
702
|
+
if (this.error)
|
|
703
|
+
throw this.error;
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
await new Promise(resolve => { this.waiter = resolve; });
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
//# sourceMappingURL=process.js.map
|