@dsh-enhanced/coding-subscription-provider 0.1.4 → 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/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 the prompt argv to the OS: report not-submitted and stop.
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, invocation.args, {
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: ['ignore', 'pipe', 'pipe'],
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
- // spawn() returned after receiving prompt argv, but only a later spawn/error event
160
- // can prove whether the OS accepted or rejected it.
161
- let promptSubmissionState = 'unknown';
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
- reportSettled(error);
288
- queue.close(error);
289
- resolveClosed?.();
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 the OS never accepted
296
- // the prompt argv, so replay stays safe: mark `not-submitted`. Once the child
297
- // has spawned or produced output, submission is no longer disprovable and the
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 onClose = (code, signal) => {
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
- // A delivered `spawn` proves the OS accepted the prompt argv; before it, replay is still safe.
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
- // Child output proves the process ran. On its own it never proves the prompt was
343
- // submitted, so the state stays at its default `unknown` (never demoted to
344
- // `not-submitted`): `onError` only sets `not-submitted` while `!sawChildOutput`, so
345
- // any spawn error after this point leaves submission `unknown`, not `not-submitted`.
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
- sawRecognizedEvent = true;
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.type === 'stream_event' && inner?.type === 'content_block_delta')
469
- return parsed(textOf(object(inner.delta)?.text), false);
690
+ if (isClaudeNativeToolEvent(event, inner))
691
+ return nativeTool();
470
692
  if (event.type === 'stream_event')
471
- return known();
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', 'tool_progress', 'tool_use_summary', 'auth_status', 'rate_limit_event']))
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 (knownType(event, ['user', 'tool_call', 'tool_result']))
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
- return typeof event.stopReason === 'string' && event.stopReason.length > 0
502
- ? known({ outcome: 'success' })
503
- : known({ outcome: 'invalid' });
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
- return parsed(textOf(event.text) ?? textOf(event.content) ?? textOf(object(event.delta)?.text) ?? textOf(event.message), false);
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 known();
758
+ return decodeAnthropicStreamEvent(anthropic);
513
759
  if (event.type === 'content_block_delta')
514
- return parsed(textOf(object(event.delta)?.text), false);
515
- const update = object(event.update);
516
- if (event.sessionUpdate === 'agent_message_chunk')
517
- return parsed(textOf(event.content) ?? textOf(update?.text) ?? textOf(update?.content), false);
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 known();
524
- const paramsUpdate = object(object(event.params)?.update);
525
- if (event.method === 'session/update' && paramsUpdate?.sessionUpdate === 'agent_message_chunk') {
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', 'tool_call', 'tool_result']))
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
- UNRECOGNIZED_EVENTS: 'emitted no recognized protocol events',
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([expected, 'Reading additional input from stdin...']);
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 () => { };