@dsh-enhanced/coding-subscription-provider 0.1.3 → 0.1.5
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/README.md +60 -214
- package/cordis.patch.yml +5 -0
- package/lib/adapter.d.ts +18 -5
- package/lib/adapter.d.ts.map +1 -1
- package/lib/adapter.js +170 -49
- package/lib/adapter.js.map +1 -1
- package/lib/codex-direct-auth.d.ts +2 -1
- package/lib/codex-direct-auth.d.ts.map +1 -1
- package/lib/codex-direct-auth.js +33 -11
- package/lib/codex-direct-auth.js.map +1 -1
- package/lib/codex-direct-responses.d.ts +8 -1
- package/lib/codex-direct-responses.d.ts.map +1 -1
- package/lib/codex-direct-responses.js +191 -14
- package/lib/codex-direct-responses.js.map +1 -1
- package/lib/config.d.ts +3 -1
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +9 -3
- package/lib/config.js.map +1 -1
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +5 -3
- package/lib/index.js.map +1 -1
- package/lib/process.d.ts +14 -3
- package/lib/process.d.ts.map +1 -1
- package/lib/process.js +458 -61
- package/lib/process.js.map +1 -1
- package/lib/prompt.d.ts +12 -1
- package/lib/prompt.d.ts.map +1 -1
- package/lib/prompt.js +221 -5
- package/lib/prompt.js.map +1 -1
- package/lib/providers.d.ts +15 -2
- package/lib/providers.d.ts.map +1 -1
- package/lib/providers.js +134 -16
- package/lib/providers.js.map +1 -1
- package/lib/session-cwd.d.ts +4 -2
- package/lib/session-cwd.d.ts.map +1 -1
- package/lib/session-cwd.js +22 -4
- package/lib/session-cwd.js.map +1 -1
- package/lib/version.d.ts +1 -1
- package/lib/version.js +1 -1
- package/package.json +16 -2
package/lib/process.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { chmod, mkdtemp, open, rmdir, unlink } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
2
5
|
/** A stable, credential-free protocol failure that the adapter can map. */
|
|
3
6
|
export class CliProtocolError extends Error {
|
|
4
7
|
provider;
|
|
@@ -60,6 +63,18 @@ export class CliTeardownTimeoutError extends Error {
|
|
|
60
63
|
this.name = 'CliTeardownTimeoutError';
|
|
61
64
|
}
|
|
62
65
|
}
|
|
66
|
+
/** Prompt transport failed without embedding any request content in the error. */
|
|
67
|
+
export class CliPromptInputError extends Error {
|
|
68
|
+
provider;
|
|
69
|
+
reason;
|
|
70
|
+
code = 'CLI_PROMPT_INPUT_ERROR';
|
|
71
|
+
constructor(provider, reason) {
|
|
72
|
+
super(promptInputErrorMessage(provider, reason), { cause: 'prompt-input' });
|
|
73
|
+
this.provider = provider;
|
|
74
|
+
this.reason = reason;
|
|
75
|
+
this.name = 'CliPromptInputError';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
63
78
|
const defaults = {
|
|
64
79
|
timeoutMs: 10 * 60_000,
|
|
65
80
|
killGraceMs: 3_000,
|
|
@@ -97,6 +112,66 @@ export function buildSubscriptionEnv(extraEnvNames = []) {
|
|
|
97
112
|
}
|
|
98
113
|
return env;
|
|
99
114
|
}
|
|
115
|
+
async function preparePromptTransport(invocation) {
|
|
116
|
+
if (invocation.promptTransport === 'stdin') {
|
|
117
|
+
return { args: invocation.args, cleanup: async () => { } };
|
|
118
|
+
}
|
|
119
|
+
let directory;
|
|
120
|
+
let promptPath;
|
|
121
|
+
try {
|
|
122
|
+
directory = await mkdtemp(join(tmpdir(), 'dsh-coding-prompt-'));
|
|
123
|
+
// mkdtemp is private on supported platforms; chmod makes the contract explicit
|
|
124
|
+
// for unusual umasks and for cross-platform transport tests running on POSIX.
|
|
125
|
+
await chmod(directory, 0o700);
|
|
126
|
+
promptPath = join(directory, 'prompt.txt');
|
|
127
|
+
const handle = await open(promptPath, 'wx', 0o600);
|
|
128
|
+
try {
|
|
129
|
+
await handle.writeFile(invocation.prompt, { encoding: 'utf8' });
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
await handle.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
if (promptPath !== undefined)
|
|
137
|
+
await unlink(promptPath).catch(() => { });
|
|
138
|
+
if (directory !== undefined)
|
|
139
|
+
await rmdir(directory).catch(() => { });
|
|
140
|
+
throw new CliPromptInputError(invocation.provider, 'FILE_PREPARE_FAILED');
|
|
141
|
+
}
|
|
142
|
+
let fileCleaned = false;
|
|
143
|
+
let directoryCleaned = false;
|
|
144
|
+
const cleanup = async () => {
|
|
145
|
+
if (!fileCleaned) {
|
|
146
|
+
try {
|
|
147
|
+
await unlink(promptPath);
|
|
148
|
+
fileCleaned = true;
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (isNotFoundError(error))
|
|
152
|
+
fileCleaned = true;
|
|
153
|
+
else
|
|
154
|
+
throw new CliPromptInputError(invocation.provider, 'FILE_CLEANUP_FAILED');
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!directoryCleaned) {
|
|
158
|
+
try {
|
|
159
|
+
await rmdir(directory);
|
|
160
|
+
directoryCleaned = true;
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (isNotFoundError(error))
|
|
164
|
+
directoryCleaned = true;
|
|
165
|
+
else
|
|
166
|
+
throw new CliPromptInputError(invocation.provider, 'FILE_CLEANUP_FAILED');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
args: ['--prompt-file', promptPath, ...invocation.args],
|
|
172
|
+
cleanup,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
100
175
|
/**
|
|
101
176
|
* Starts one local, already-authenticated coding CLI and yields only assistant text.
|
|
102
177
|
* It never reads credential files or accepts credential values in config. An explicitly
|
|
@@ -105,7 +180,7 @@ export function buildSubscriptionEnv(extraEnvNames = []) {
|
|
|
105
180
|
*/
|
|
106
181
|
export async function* runCliText(invocation, options = {}) {
|
|
107
182
|
const limits = { ...defaults, ...options };
|
|
108
|
-
// Pre-spawn failures never handed
|
|
183
|
+
// Pre-spawn failures never handed prompt input to the child: report not-submitted and stop.
|
|
109
184
|
const reportPreSpawn = () => {
|
|
110
185
|
if (options.onSettled === undefined)
|
|
111
186
|
return;
|
|
@@ -120,19 +195,47 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
120
195
|
reportPreSpawn();
|
|
121
196
|
throw abortError('aborted before CLI spawn');
|
|
122
197
|
}
|
|
198
|
+
let prepared;
|
|
199
|
+
try {
|
|
200
|
+
prepared = await preparePromptTransport(invocation);
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
reportPreSpawn();
|
|
204
|
+
throw error;
|
|
205
|
+
}
|
|
206
|
+
// Creating a private Grok prompt file is asynchronous. Close the abort race
|
|
207
|
+
// before spawning and remove the file before returning control to the caller.
|
|
208
|
+
if (options.signal?.aborted) {
|
|
209
|
+
let cleanupError;
|
|
210
|
+
try {
|
|
211
|
+
await prepared.cleanup();
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
cleanupError = error;
|
|
215
|
+
}
|
|
216
|
+
reportPreSpawn();
|
|
217
|
+
throw cleanupError ?? abortError('aborted before CLI spawn');
|
|
218
|
+
}
|
|
123
219
|
let child;
|
|
124
220
|
try {
|
|
125
|
-
child = (options.spawn ?? defaultSpawn)(invocation.command,
|
|
221
|
+
child = (options.spawn ?? defaultSpawn)(invocation.command, prepared.args, {
|
|
126
222
|
cwd: invocation.cwd,
|
|
127
223
|
shell: false,
|
|
128
224
|
detached: process.platform !== 'win32',
|
|
129
|
-
stdio: ['
|
|
225
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
130
226
|
env: buildSubscriptionEnv(options.extraEnvNames),
|
|
131
227
|
});
|
|
132
228
|
}
|
|
133
229
|
catch (error) {
|
|
230
|
+
let cleanupError;
|
|
231
|
+
try {
|
|
232
|
+
await prepared.cleanup();
|
|
233
|
+
}
|
|
234
|
+
catch (cleanupFailure) {
|
|
235
|
+
cleanupError = cleanupFailure;
|
|
236
|
+
}
|
|
134
237
|
reportPreSpawn();
|
|
135
|
-
throw error;
|
|
238
|
+
throw cleanupError ?? error;
|
|
136
239
|
}
|
|
137
240
|
const queue = new TextQueue();
|
|
138
241
|
let totalOutputBytes = 0;
|
|
@@ -143,7 +246,6 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
143
246
|
let sawIncrementalText = false;
|
|
144
247
|
let sawAggregateText = false;
|
|
145
248
|
let sawValidJson = false;
|
|
146
|
-
let sawRecognizedEvent = false;
|
|
147
249
|
let sawAssistantText = false;
|
|
148
250
|
let sawSuccessTerminal = false;
|
|
149
251
|
let terminalReason;
|
|
@@ -154,11 +256,18 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
154
256
|
let postKillTimer;
|
|
155
257
|
let resolveClosed;
|
|
156
258
|
const closed = new Promise(resolve => { resolveClosed = resolve; });
|
|
259
|
+
let disposeStdin = () => { };
|
|
157
260
|
let disposeStdout = () => { };
|
|
158
261
|
let disposeStderr = () => { };
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
262
|
+
// Prompt input is not submitted until either the whole stdin stream flushes or
|
|
263
|
+
// the OS accepts Grok's private prompt-file path. Partial stdin writes are unknown.
|
|
264
|
+
// A temporary-file path already entered spawn argv when spawn() returned, so
|
|
265
|
+
// its state is unknown until a spawn/error event. stdin carries no prompt argv
|
|
266
|
+
// and remains provably not-submitted until the first write attempt.
|
|
267
|
+
let promptSubmissionState = invocation.promptTransport === 'secure-temporary-file'
|
|
268
|
+
? 'unknown'
|
|
269
|
+
: 'not-submitted';
|
|
270
|
+
let promptInputStarted = false;
|
|
162
271
|
let sawSpawn = false;
|
|
163
272
|
let phase = 'spawn';
|
|
164
273
|
// Teardown begins the moment `stop` fires and completes when ChildProcess `close` is settled.
|
|
@@ -217,6 +326,7 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
217
326
|
if (teardownState === 'not-started')
|
|
218
327
|
teardownState = 'in-progress';
|
|
219
328
|
stopAt ??= performance.now();
|
|
329
|
+
destroyWritable(child.stdin);
|
|
220
330
|
terminateProcessTree(child, 'SIGINT');
|
|
221
331
|
// An injected/embedded ChildProcess may emit close synchronously from kill().
|
|
222
332
|
// Do not arm escalation after that close already settled the request.
|
|
@@ -246,6 +356,7 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
246
356
|
child.removeListener?.('spawn', onSpawn);
|
|
247
357
|
child.removeListener?.('error', onError);
|
|
248
358
|
child.removeListener?.('close', onClose);
|
|
359
|
+
disposeStdin();
|
|
249
360
|
disposeStdout();
|
|
250
361
|
disposeStderr();
|
|
251
362
|
};
|
|
@@ -253,8 +364,12 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
253
364
|
if (finished) {
|
|
254
365
|
// A post-deadline close only releases background tracking. The request and
|
|
255
366
|
// its diagnostic callback were already settled exactly once as timed-out.
|
|
256
|
-
if (observedClose && teardownState === 'timed-out')
|
|
367
|
+
if (observedClose && teardownState === 'timed-out') {
|
|
257
368
|
cleanupChildTracking();
|
|
369
|
+
// A child may have kept the prompt file open past the teardown
|
|
370
|
+
// deadline. Retry deletion once actual process/stdio closure is observed.
|
|
371
|
+
void prepared.cleanup().catch(() => { });
|
|
372
|
+
}
|
|
258
373
|
return;
|
|
259
374
|
}
|
|
260
375
|
finished = true;
|
|
@@ -284,27 +399,37 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
284
399
|
// Diagnostics must never change model-call settlement.
|
|
285
400
|
}
|
|
286
401
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
402
|
+
const settleAfterPromptCleanup = async () => {
|
|
403
|
+
let settledError = error;
|
|
404
|
+
try {
|
|
405
|
+
await prepared.cleanup();
|
|
406
|
+
}
|
|
407
|
+
catch (cleanupError) {
|
|
408
|
+
if (settledError === undefined) {
|
|
409
|
+
phase = 'child-close';
|
|
410
|
+
settledError = cleanupError;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
reportSettled(settledError);
|
|
414
|
+
queue.close(settledError);
|
|
415
|
+
resolveClosed?.();
|
|
416
|
+
};
|
|
417
|
+
void settleAfterPromptCleanup();
|
|
290
418
|
};
|
|
291
419
|
const onError = (error) => {
|
|
292
420
|
// Node guarantees `close` after a spawn `error`. Waiting for it also avoids
|
|
293
421
|
// treating a kill-related error as proof that a live process has exited.
|
|
294
422
|
// Any spawn error before both a `spawn` event and any child output (ENOENT,
|
|
295
|
-
// EACCES, and every other pre-run failure alike) proves
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
// state is left as-is (`submitted` or the default `unknown`).
|
|
423
|
+
// EACCES, and every other pre-run failure alike) proves prompt input was never
|
|
424
|
+
// attempted, so replay stays safe. Once the child has spawned or produced
|
|
425
|
+
// output, submission is no longer disprovable.
|
|
299
426
|
if (!stopping && !sawSpawn && !sawChildOutput)
|
|
300
427
|
promptSubmissionState = 'not-submitted';
|
|
301
428
|
if (!terminationError)
|
|
302
429
|
terminationError = error;
|
|
303
430
|
stopping = true;
|
|
304
431
|
};
|
|
305
|
-
const
|
|
306
|
-
observedExitCode = code;
|
|
307
|
-
observedSignal = signal;
|
|
432
|
+
const settleChildClose = (code, signal) => {
|
|
308
433
|
if (terminationError)
|
|
309
434
|
close(terminationError);
|
|
310
435
|
else if (code !== 0 || signal !== null) {
|
|
@@ -317,19 +442,95 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
317
442
|
else
|
|
318
443
|
close(validateProtocolSettlement(invocation.provider, {
|
|
319
444
|
sawValidJson,
|
|
320
|
-
sawRecognizedEvent,
|
|
321
445
|
sawAssistantText,
|
|
322
446
|
sawSuccessTerminal,
|
|
323
447
|
sawCursorAuthInit,
|
|
324
448
|
}));
|
|
325
449
|
};
|
|
326
|
-
|
|
450
|
+
const onClose = (code, signal) => {
|
|
451
|
+
observedExitCode = code;
|
|
452
|
+
observedSignal = signal;
|
|
453
|
+
if (finished) {
|
|
454
|
+
settleChildClose(code, signal);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
// Writable `finish` is queued on nextTick for Node streams. Give it that one
|
|
458
|
+
// bounded turn, then fail closed if a reduced process shim closed without ever
|
|
459
|
+
// completing or explicitly failing its stdin lifecycle.
|
|
460
|
+
process.nextTick(() => {
|
|
461
|
+
if (!terminationError && invocation.promptTransport === 'stdin'
|
|
462
|
+
&& promptInputStarted && promptSubmissionState !== 'submitted') {
|
|
463
|
+
phase = 'prompt';
|
|
464
|
+
close(new CliPromptInputError(invocation.provider, 'CLOSED_EARLY'));
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
settleChildClose(code, signal);
|
|
468
|
+
});
|
|
469
|
+
};
|
|
470
|
+
const startPromptSubmission = () => {
|
|
471
|
+
if (promptInputStarted || stopping || finished)
|
|
472
|
+
return;
|
|
473
|
+
promptInputStarted = true;
|
|
474
|
+
phase = 'prompt';
|
|
475
|
+
if (invocation.promptTransport === 'secure-temporary-file') {
|
|
476
|
+
// The spawn event proves the private path was handed to the child. Keep the
|
|
477
|
+
// file until child-close so the child cannot race path opening against cleanup.
|
|
478
|
+
promptSubmissionState = 'submitted';
|
|
479
|
+
disposeStdin = closeUnusedWritable(child.stdin);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const input = child.stdin;
|
|
483
|
+
if (!input) {
|
|
484
|
+
stop(new CliPromptInputError(invocation.provider, 'MISSING_STDIN'));
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
promptSubmissionState = 'unknown';
|
|
488
|
+
let inputSettled = false;
|
|
489
|
+
const onInputFinish = () => {
|
|
490
|
+
if (inputSettled)
|
|
491
|
+
return;
|
|
492
|
+
inputSettled = true;
|
|
493
|
+
promptSubmissionState = 'submitted';
|
|
494
|
+
};
|
|
495
|
+
const onInputError = () => {
|
|
496
|
+
if (inputSettled)
|
|
497
|
+
return;
|
|
498
|
+
inputSettled = true;
|
|
499
|
+
if (stopping || finished)
|
|
500
|
+
return;
|
|
501
|
+
stop(new CliPromptInputError(invocation.provider, 'WRITE_FAILED'));
|
|
502
|
+
};
|
|
503
|
+
const onInputClose = () => {
|
|
504
|
+
if (inputSettled)
|
|
505
|
+
return;
|
|
506
|
+
inputSettled = true;
|
|
507
|
+
if (stopping || finished)
|
|
508
|
+
return;
|
|
509
|
+
stop(new CliPromptInputError(invocation.provider, 'CLOSED_EARLY'));
|
|
510
|
+
};
|
|
511
|
+
input.once('finish', onInputFinish);
|
|
512
|
+
// Keep absorbing any follow-up stream errors until child-close cleanup.
|
|
513
|
+
input.on('error', onInputError);
|
|
514
|
+
input.once('close', onInputClose);
|
|
515
|
+
disposeStdin = () => {
|
|
516
|
+
input.removeListener('finish', onInputFinish);
|
|
517
|
+
input.removeListener('error', onInputError);
|
|
518
|
+
input.removeListener('close', onInputClose);
|
|
519
|
+
};
|
|
520
|
+
try {
|
|
521
|
+
input.end(invocation.prompt);
|
|
522
|
+
}
|
|
523
|
+
catch {
|
|
524
|
+
onInputError();
|
|
525
|
+
}
|
|
526
|
+
};
|
|
527
|
+
// A delivered `spawn` is the first point at which prompt transport may begin.
|
|
327
528
|
const onSpawn = () => {
|
|
328
529
|
sawSpawn = true;
|
|
329
530
|
spawnedAt ??= performance.now();
|
|
330
|
-
promptSubmissionState = 'submitted';
|
|
331
531
|
if (phase === 'spawn')
|
|
332
532
|
phase = 'initialize';
|
|
533
|
+
startPromptSubmission();
|
|
333
534
|
};
|
|
334
535
|
child.once('spawn', onSpawn);
|
|
335
536
|
child.once('error', onError);
|
|
@@ -339,26 +540,36 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
339
540
|
disposeStdout = consumeLines(child.stdout, limits.maxLineBytes, line => {
|
|
340
541
|
if (stopping || finished)
|
|
341
542
|
return;
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
//
|
|
543
|
+
// Real ChildProcess always emits `spawn` before output. Starting here as a
|
|
544
|
+
// defensive fallback also supports embedders that provide a reduced process shim.
|
|
545
|
+
startPromptSubmission();
|
|
546
|
+
// Child output proves the process ran. Without a full stdin finish or accepted
|
|
547
|
+
// prompt-file path, replay safety is unknown.
|
|
346
548
|
sawChildOutput = true;
|
|
549
|
+
if (promptSubmissionState === 'not-submitted')
|
|
550
|
+
promptSubmissionState = 'unknown';
|
|
347
551
|
const decoded = decodeJsonLine(line);
|
|
348
552
|
if (decoded.kind === 'empty')
|
|
349
553
|
return;
|
|
554
|
+
if (sawSuccessTerminal) {
|
|
555
|
+
stop(new CliProtocolError(invocation.provider, 'EVENT_AFTER_TERMINAL'));
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
350
558
|
sawStdoutOutput = true;
|
|
351
559
|
if (decoded.kind === 'malformed') {
|
|
352
560
|
stop(new CliProtocolError(invocation.provider, 'MALFORMED_JSON'));
|
|
353
561
|
return;
|
|
354
562
|
}
|
|
355
563
|
sawValidJson = true;
|
|
356
|
-
if (!decoded.event)
|
|
564
|
+
if (!decoded.event) {
|
|
565
|
+
stop(new CliProtocolError(invocation.provider, 'UNKNOWN_EVENT'));
|
|
357
566
|
return;
|
|
567
|
+
}
|
|
358
568
|
const parsed = parseProviderEvent(invocation.provider, decoded.event);
|
|
359
|
-
if (!parsed)
|
|
569
|
+
if (!parsed) {
|
|
570
|
+
stop(new CliProtocolError(invocation.provider, 'UNKNOWN_EVENT'));
|
|
360
571
|
return;
|
|
361
|
-
|
|
572
|
+
}
|
|
362
573
|
firstEventAt ??= performance.now();
|
|
363
574
|
if (phase === 'spawn' || phase === 'initialize')
|
|
364
575
|
phase = 'prompt';
|
|
@@ -368,6 +579,12 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
368
579
|
stop(new CliSubscriptionAuthError('cursor', 'UNEXPECTED_AUTH_SOURCE'));
|
|
369
580
|
return;
|
|
370
581
|
}
|
|
582
|
+
if (parsed.nativeTool) {
|
|
583
|
+
terminalReason = 'native-tool-event';
|
|
584
|
+
phase = 'terminal';
|
|
585
|
+
stop(new CliProtocolError(invocation.provider, 'NATIVE_TOOL_EVENT'));
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
371
588
|
if (parsed.outcome === 'failure') {
|
|
372
589
|
terminalReason = 'reported-failure';
|
|
373
590
|
phase = 'terminal';
|
|
@@ -408,7 +625,10 @@ export async function* runCliText(invocation, options = {}) {
|
|
|
408
625
|
disposeStderr = consumeLines(child.stderr, limits.maxLineBytes, line => {
|
|
409
626
|
if (stopping || finished)
|
|
410
627
|
return;
|
|
628
|
+
startPromptSubmission();
|
|
411
629
|
sawChildOutput = true;
|
|
630
|
+
if (promptSubmissionState === 'not-submitted')
|
|
631
|
+
promptSubmissionState = 'unknown';
|
|
412
632
|
// Stderr is diagnostic only; never expose it as model output or retain it unbounded.
|
|
413
633
|
stderr = appendBounded(stderr, line, limits.maxStderrBytes);
|
|
414
634
|
}, error => stop(error));
|
|
@@ -450,6 +670,8 @@ const providerDecoders = {
|
|
|
450
670
|
};
|
|
451
671
|
function decodeCodexEvent(event) {
|
|
452
672
|
const item = object(event.item);
|
|
673
|
+
if (isCodexItemEvent(event.type) && isCodexNativeToolItem(item))
|
|
674
|
+
return nativeTool();
|
|
453
675
|
if (event.type === 'item.completed' && item?.type === 'agent_message')
|
|
454
676
|
return parsed(textOf(item.content) ?? textOf(item.text), false);
|
|
455
677
|
if (event.type === 'agent_message')
|
|
@@ -465,19 +687,22 @@ function decodeCodexEvent(event) {
|
|
|
465
687
|
}
|
|
466
688
|
function decodeClaudeEvent(event) {
|
|
467
689
|
const inner = object(event.event);
|
|
468
|
-
if (event
|
|
469
|
-
return
|
|
690
|
+
if (isClaudeNativeToolEvent(event, inner))
|
|
691
|
+
return nativeTool();
|
|
470
692
|
if (event.type === 'stream_event')
|
|
471
|
-
return
|
|
693
|
+
return decodeAnthropicStreamEvent(inner);
|
|
472
694
|
if (event.type === 'assistant')
|
|
473
695
|
return parsed(textOf(event.message) ?? textOf(event.content), true);
|
|
474
696
|
if (event.type === 'result')
|
|
475
697
|
return parsed(textOf(event.result), true, resultOutcome(event));
|
|
476
|
-
if (knownType(event, ['system', 'user', '
|
|
698
|
+
if (knownType(event, ['system', 'user', 'auth_status', 'rate_limit_event']))
|
|
477
699
|
return known();
|
|
478
700
|
return undefined;
|
|
479
701
|
}
|
|
480
702
|
function decodeCursorEvent(event) {
|
|
703
|
+
if (isToolLifecycle(event.type) || containsNativeToolBlock(event.message) || containsNativeToolBlock(event.content)) {
|
|
704
|
+
return nativeTool();
|
|
705
|
+
}
|
|
481
706
|
if (event.type === 'system' && event.subtype === 'init') {
|
|
482
707
|
return known({ auth: event.apiKeySource === 'login' ? 'subscription' : 'other' });
|
|
483
708
|
}
|
|
@@ -487,56 +712,182 @@ function decodeCursorEvent(event) {
|
|
|
487
712
|
return parsed(textOf(event.message) ?? textOf(event.content) ?? textOf(event.text), false);
|
|
488
713
|
if (event.type === 'result')
|
|
489
714
|
return parsed(textOf(event.result) ?? textOf(event.message), true, resultOutcome(event));
|
|
490
|
-
if (
|
|
715
|
+
if (event.type === 'user')
|
|
491
716
|
return known();
|
|
492
717
|
return undefined;
|
|
493
718
|
}
|
|
494
719
|
function decodeGrokEvent(event) {
|
|
720
|
+
const update = object(event.update);
|
|
721
|
+
const paramsUpdate = object(object(event.params)?.update);
|
|
722
|
+
const anthropic = object(event.event);
|
|
723
|
+
if (isGrokNativeToolEvent(event, update, paramsUpdate, anthropic))
|
|
724
|
+
return nativeTool();
|
|
495
725
|
if (event.type === 'text') {
|
|
496
726
|
return typeof event.data === 'string' ? parsed(event.data, false) : undefined;
|
|
497
727
|
}
|
|
498
728
|
if (event.type === 'error')
|
|
499
729
|
return known({ outcome: 'failure' });
|
|
500
730
|
if (event.type === 'end') {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
731
|
+
if (event.stopReason === 'end_turn')
|
|
732
|
+
return known({ outcome: 'success' });
|
|
733
|
+
if (event.stopReason === 'tool_use')
|
|
734
|
+
return nativeTool();
|
|
735
|
+
if (isKnownGrokFailureStopReason(event.stopReason))
|
|
736
|
+
return known({ outcome: 'failure' });
|
|
737
|
+
return known({ outcome: 'invalid' });
|
|
738
|
+
}
|
|
739
|
+
if (event.type === 'available_commands') {
|
|
740
|
+
if (!Array.isArray(event.tools) || (event.commands !== undefined && !Array.isArray(event.commands)))
|
|
741
|
+
return undefined;
|
|
742
|
+
return event.tools.length === 0 ? known() : nativeTool();
|
|
743
|
+
}
|
|
744
|
+
if (event.type === 'usage') {
|
|
745
|
+
if (event.stopReason === undefined || event.stopReason === 'end_turn')
|
|
746
|
+
return known();
|
|
747
|
+
if (event.stopReason === 'tool_use')
|
|
748
|
+
return nativeTool();
|
|
749
|
+
if (isKnownGrokFailureStopReason(event.stopReason))
|
|
750
|
+
return known({ outcome: 'failure' });
|
|
751
|
+
return known({ outcome: 'invalid' });
|
|
504
752
|
}
|
|
505
753
|
if (event.type === 'streaming-messages-json' || event.type === 'message' || event.type === 'update') {
|
|
506
|
-
|
|
754
|
+
const text = textOf(event.text) ?? textOf(event.content) ?? textOf(object(event.delta)?.text) ?? textOf(event.message);
|
|
755
|
+
return text === undefined ? undefined : parsed(text, false);
|
|
507
756
|
}
|
|
508
|
-
const anthropic = object(event.event);
|
|
509
|
-
if (event.type === 'stream_event' && anthropic?.type === 'content_block_delta')
|
|
510
|
-
return parsed(textOf(object(anthropic.delta)?.text), false);
|
|
511
757
|
if (event.type === 'stream_event')
|
|
512
|
-
return
|
|
758
|
+
return decodeAnthropicStreamEvent(anthropic);
|
|
513
759
|
if (event.type === 'content_block_delta')
|
|
514
|
-
return
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
if (typeof event.sessionUpdate === 'string')
|
|
519
|
-
return known();
|
|
520
|
-
if (update?.sessionUpdate === 'agent_message_chunk')
|
|
521
|
-
return parsed(textOf(update.content), false);
|
|
760
|
+
return decodeAnthropicContentBlockDelta(object(event.delta));
|
|
761
|
+
if (typeof event.sessionUpdate === 'string') {
|
|
762
|
+
return decodeGrokSessionUpdate(event.sessionUpdate, event.content ?? update?.content);
|
|
763
|
+
}
|
|
522
764
|
if (typeof update?.sessionUpdate === 'string')
|
|
523
|
-
return
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
return parsed(textOf(paramsUpdate.content), false);
|
|
765
|
+
return decodeGrokSessionUpdate(update.sessionUpdate, update.content);
|
|
766
|
+
if (event.method === 'session/update' && typeof paramsUpdate?.sessionUpdate === 'string') {
|
|
767
|
+
return decodeGrokSessionUpdate(paramsUpdate.sessionUpdate, paramsUpdate.content);
|
|
527
768
|
}
|
|
528
|
-
if (event.method === 'session/update')
|
|
529
|
-
return known();
|
|
530
769
|
if (event.type === 'result') {
|
|
531
770
|
const outcome = resultOutcome(event);
|
|
532
771
|
return parsed(textOf(event.result) ?? textOf(event.text), true, outcome === 'success' ? undefined : outcome);
|
|
533
772
|
}
|
|
534
773
|
if (event.type === 'assistant')
|
|
535
774
|
return parsed(textOf(event.message) ?? textOf(event.content) ?? textOf(event.text), false);
|
|
536
|
-
if (knownType(event, ['system', 'user'
|
|
775
|
+
if (knownType(event, ['system', 'user']))
|
|
776
|
+
return known();
|
|
777
|
+
return undefined;
|
|
778
|
+
}
|
|
779
|
+
function isCodexItemEvent(type) {
|
|
780
|
+
return type === 'item.started' || type === 'item.updated' || type === 'item.completed';
|
|
781
|
+
}
|
|
782
|
+
function decodeAnthropicStreamEvent(inner) {
|
|
783
|
+
if (inner?.type === 'content_block_delta')
|
|
784
|
+
return decodeAnthropicContentBlockDelta(object(inner.delta));
|
|
785
|
+
if (inner?.type === 'content_block_start') {
|
|
786
|
+
const block = object(inner.content_block);
|
|
787
|
+
if (!knownValue(block?.type, ['text', 'thinking', 'redacted_thinking']))
|
|
788
|
+
return undefined;
|
|
789
|
+
return parsed(textOf(block?.text), false);
|
|
790
|
+
}
|
|
791
|
+
if (inner?.type === 'error')
|
|
792
|
+
return known({ outcome: 'failure' });
|
|
793
|
+
if (knownValue(inner?.type, ['message_start', 'content_block_stop', 'message_delta', 'message_stop', 'ping']))
|
|
537
794
|
return known();
|
|
538
795
|
return undefined;
|
|
539
796
|
}
|
|
797
|
+
function decodeAnthropicContentBlockDelta(delta) {
|
|
798
|
+
if (delta?.type === undefined && typeof delta?.text === 'string')
|
|
799
|
+
return parsed(delta.text, false);
|
|
800
|
+
if (delta?.type === 'text_delta' && typeof delta.text === 'string')
|
|
801
|
+
return parsed(delta.text, false);
|
|
802
|
+
if (knownValue(delta?.type, ['thinking_delta', 'signature_delta', 'citations_delta']))
|
|
803
|
+
return known();
|
|
804
|
+
return undefined;
|
|
805
|
+
}
|
|
806
|
+
const passiveGrokSessionUpdates = [
|
|
807
|
+
'user_message_chunk',
|
|
808
|
+
'agent_thought_chunk',
|
|
809
|
+
'plan',
|
|
810
|
+
'plan_update',
|
|
811
|
+
'plan_removed',
|
|
812
|
+
'available_commands_update',
|
|
813
|
+
'current_mode_update',
|
|
814
|
+
'config_option_update',
|
|
815
|
+
'session_info_update',
|
|
816
|
+
'usage_update',
|
|
817
|
+
];
|
|
818
|
+
function decodeGrokSessionUpdate(type, content) {
|
|
819
|
+
if (type === 'agent_message_chunk') {
|
|
820
|
+
const text = textOf(content);
|
|
821
|
+
return text === undefined ? undefined : parsed(text, false);
|
|
822
|
+
}
|
|
823
|
+
return knownValue(type, passiveGrokSessionUpdates) ? known() : undefined;
|
|
824
|
+
}
|
|
825
|
+
/** Codex exec JSONL currently has only three passive item kinds; every other
|
|
826
|
+
* item kind represents a built-in/dynamic tool or another active capability.
|
|
827
|
+
* Keeping this as a passive allowlist makes new native executors fail closed. */
|
|
828
|
+
function isCodexNativeToolItem(item) {
|
|
829
|
+
if (typeof item?.type !== 'string')
|
|
830
|
+
return true;
|
|
831
|
+
return item.type !== 'agent_message' && item.type !== 'reasoning' && item.type !== 'error';
|
|
832
|
+
}
|
|
833
|
+
function isClaudeNativeToolEvent(event, inner) {
|
|
834
|
+
if (isToolLifecycle(event.type) || event.type === 'tool_progress')
|
|
835
|
+
return true;
|
|
836
|
+
if (event.stop_reason === 'tool_use' || object(event.message)?.stop_reason === 'tool_use')
|
|
837
|
+
return true;
|
|
838
|
+
if (object(inner?.delta)?.stop_reason === 'tool_use')
|
|
839
|
+
return true;
|
|
840
|
+
if (event.type === 'system' && Array.isArray(event.tools) && event.tools.length > 0)
|
|
841
|
+
return true;
|
|
842
|
+
if (containsNativeToolBlock(event.message) || containsNativeToolBlock(event.content)
|
|
843
|
+
|| containsNativeToolBlock(event.delta))
|
|
844
|
+
return true;
|
|
845
|
+
return event.type === 'stream_event'
|
|
846
|
+
&& (containsNativeToolBlock(inner?.message) || containsNativeToolBlock(inner?.content_block)
|
|
847
|
+
|| containsNativeToolBlock(inner?.delta));
|
|
848
|
+
}
|
|
849
|
+
function isGrokNativeToolEvent(event, update, paramsUpdate, anthropic) {
|
|
850
|
+
if (isToolLifecycle(event.type) || isToolLifecycle(event.sessionUpdate)
|
|
851
|
+
|| isToolLifecycle(update?.sessionUpdate) || isToolLifecycle(paramsUpdate?.sessionUpdate))
|
|
852
|
+
return true;
|
|
853
|
+
if (event.type === 'available_commands' && Array.isArray(event.tools) && event.tools.length > 0)
|
|
854
|
+
return true;
|
|
855
|
+
if (event.method === 'session/request_permission' || event.method === 'session/requestPermission')
|
|
856
|
+
return true;
|
|
857
|
+
if (containsNativeToolBlock(event.message) || containsNativeToolBlock(event.content)
|
|
858
|
+
|| containsNativeToolBlock(event.delta))
|
|
859
|
+
return true;
|
|
860
|
+
return event.type === 'stream_event'
|
|
861
|
+
&& (containsNativeToolBlock(anthropic?.message) || containsNativeToolBlock(anthropic?.content_block)
|
|
862
|
+
|| containsNativeToolBlock(anthropic?.delta));
|
|
863
|
+
}
|
|
864
|
+
function isToolLifecycle(value) {
|
|
865
|
+
if (typeof value !== 'string')
|
|
866
|
+
return false;
|
|
867
|
+
return value === 'tool_call' || value === 'tool_result' || value === 'tool_use'
|
|
868
|
+
|| value.startsWith('tool_call_') || value.startsWith('tool_use_')
|
|
869
|
+
|| value === 'hook_execution' || value === 'pending_interaction' || value === 'interaction_resolved';
|
|
870
|
+
}
|
|
871
|
+
function containsNativeToolBlock(value, depth = 0) {
|
|
872
|
+
if (depth > 8)
|
|
873
|
+
return false;
|
|
874
|
+
if (Array.isArray(value))
|
|
875
|
+
return value.some(item => containsNativeToolBlock(item, depth + 1));
|
|
876
|
+
const record = object(value);
|
|
877
|
+
if (record === undefined)
|
|
878
|
+
return false;
|
|
879
|
+
const type = record.type;
|
|
880
|
+
if (isToolLifecycle(type) || type === 'server_tool_use' || type === 'input_json_delta'
|
|
881
|
+
|| (typeof type === 'string' && type.endsWith('_tool_result')))
|
|
882
|
+
return true;
|
|
883
|
+
return containsNativeToolBlock(record.content, depth + 1)
|
|
884
|
+
|| containsNativeToolBlock(record.message, depth + 1)
|
|
885
|
+
|| containsNativeToolBlock(record.content_block, depth + 1)
|
|
886
|
+
|| containsNativeToolBlock(record.delta, depth + 1);
|
|
887
|
+
}
|
|
888
|
+
function nativeTool() {
|
|
889
|
+
return { terminal: false, nativeTool: true };
|
|
890
|
+
}
|
|
540
891
|
export function parseAssistantText(provider, line) {
|
|
541
892
|
return parseAssistantEvent(provider, line)?.text;
|
|
542
893
|
}
|
|
@@ -549,6 +900,12 @@ function known(fields = {}) {
|
|
|
549
900
|
function knownType(event, types) {
|
|
550
901
|
return typeof event.type === 'string' && types.includes(event.type);
|
|
551
902
|
}
|
|
903
|
+
function knownValue(value, values) {
|
|
904
|
+
return typeof value === 'string' && values.includes(value);
|
|
905
|
+
}
|
|
906
|
+
function isKnownGrokFailureStopReason(value) {
|
|
907
|
+
return knownValue(value, ['cancelled', 'canceled', 'max_tokens', 'max_turn_requests']);
|
|
908
|
+
}
|
|
552
909
|
function resultOutcome(event) {
|
|
553
910
|
const subtype = typeof event.subtype === 'string' ? event.subtype.toLowerCase() : '';
|
|
554
911
|
if (subtype === 'success' && event.is_error === false)
|
|
@@ -579,8 +936,6 @@ function validateProtocolSettlement(provider, state) {
|
|
|
579
936
|
}
|
|
580
937
|
if (!state.sawValidJson)
|
|
581
938
|
return new CliProtocolError(provider, 'NO_JSON_EVENTS');
|
|
582
|
-
if (!state.sawRecognizedEvent)
|
|
583
|
-
return new CliProtocolError(provider, 'UNRECOGNIZED_EVENTS');
|
|
584
939
|
if (!state.sawAssistantText)
|
|
585
940
|
return new CliProtocolError(provider, 'NO_ASSISTANT_TEXT');
|
|
586
941
|
if ((provider === 'codex' || provider === 'claude' || provider === 'cursor' || provider === 'grok') && !state.sawSuccessTerminal) {
|
|
@@ -592,14 +947,26 @@ function protocolErrorMessage(provider, reason) {
|
|
|
592
947
|
const descriptions = {
|
|
593
948
|
MALFORMED_JSON: 'emitted malformed NDJSON',
|
|
594
949
|
NO_JSON_EVENTS: 'closed without any JSON events',
|
|
595
|
-
|
|
950
|
+
UNKNOWN_EVENT: 'emitted an event outside the supported protocol allowlist',
|
|
951
|
+
EVENT_AFTER_TERMINAL: 'emitted an event after its successful terminal event',
|
|
596
952
|
NO_ASSISTANT_TEXT: 'closed without assistant text',
|
|
597
953
|
MISSING_SUCCESS_TERMINAL: 'closed without a successful terminal event',
|
|
598
954
|
INVALID_TERMINAL: 'emitted an unrecognized terminal result',
|
|
955
|
+
NATIVE_TOOL_EVENT: 'emitted a forbidden native tool event',
|
|
599
956
|
REPORTED_FAILURE: 'reported a failed model turn',
|
|
600
957
|
};
|
|
601
958
|
return `${provider} CLI ${descriptions[reason]}`;
|
|
602
959
|
}
|
|
960
|
+
function promptInputErrorMessage(provider, reason) {
|
|
961
|
+
const descriptions = {
|
|
962
|
+
MISSING_STDIN: 'did not expose a writable stdin pipe',
|
|
963
|
+
WRITE_FAILED: 'failed while receiving prompt input',
|
|
964
|
+
CLOSED_EARLY: 'closed before prompt input completed',
|
|
965
|
+
FILE_PREPARE_FAILED: 'could not prepare its private prompt file',
|
|
966
|
+
FILE_CLEANUP_FAILED: 'could not remove its private prompt file',
|
|
967
|
+
};
|
|
968
|
+
return `${provider} CLI ${descriptions[reason]}`;
|
|
969
|
+
}
|
|
603
970
|
function exitDescription(code, signal) {
|
|
604
971
|
const parts = [`code=${code === null ? 'null' : String(code)}`];
|
|
605
972
|
if (signal !== null)
|
|
@@ -608,7 +975,11 @@ function exitDescription(code, signal) {
|
|
|
608
975
|
}
|
|
609
976
|
function isCodexWorkingDirectoryRejection(diagnostic) {
|
|
610
977
|
const expected = 'Not inside a trusted directory and --skip-git-repo-check was not specified.';
|
|
611
|
-
const allowed = new Set([
|
|
978
|
+
const allowed = new Set([
|
|
979
|
+
expected,
|
|
980
|
+
'Reading additional input from stdin...',
|
|
981
|
+
'Reading prompt from stdin...',
|
|
982
|
+
]);
|
|
612
983
|
const lines = diagnostic.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
613
984
|
return lines.includes(expected) && lines.every(line => allowed.has(line));
|
|
614
985
|
}
|
|
@@ -630,6 +1001,32 @@ function appendBounded(value, addition, limit) {
|
|
|
630
1001
|
return bytes.byteLength <= limit ? next : bytes.subarray(bytes.byteLength - limit).toString('utf8');
|
|
631
1002
|
}
|
|
632
1003
|
function byteLength(value) { return Buffer.byteLength(value, 'utf8'); }
|
|
1004
|
+
function isNotFoundError(error) {
|
|
1005
|
+
return error !== null && typeof error === 'object' && 'code' in error && error.code === 'ENOENT';
|
|
1006
|
+
}
|
|
1007
|
+
function closeUnusedWritable(stream) {
|
|
1008
|
+
if (!stream)
|
|
1009
|
+
return () => { };
|
|
1010
|
+
const onError = () => { };
|
|
1011
|
+
stream.on('error', onError);
|
|
1012
|
+
try {
|
|
1013
|
+
stream.end();
|
|
1014
|
+
}
|
|
1015
|
+
catch {
|
|
1016
|
+
// The prompt lives in the private file; failure to close an unused stdin pipe
|
|
1017
|
+
// neither loses nor exposes it. Keep absorbing a later stream error.
|
|
1018
|
+
}
|
|
1019
|
+
return () => { stream.removeListener('error', onError); };
|
|
1020
|
+
}
|
|
1021
|
+
function destroyWritable(stream) {
|
|
1022
|
+
try {
|
|
1023
|
+
const destroyable = stream;
|
|
1024
|
+
destroyable?.destroy?.();
|
|
1025
|
+
}
|
|
1026
|
+
catch {
|
|
1027
|
+
// Process-tree termination remains the authoritative teardown path.
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
633
1030
|
function consumeLines(stream, maxLineBytes, onLine, onError) {
|
|
634
1031
|
if (!stream)
|
|
635
1032
|
return () => { };
|