@parall/claude-agent 1.51.0 → 1.52.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/src/dispatch.ts CHANGED
@@ -9,14 +9,21 @@ import {
9
9
  import type {
10
10
  CleanupForkOpts,
11
11
  DispatchAdapter,
12
+ DispatchInputLifecycle,
12
13
  DispatchOpts,
13
14
  ForkOpts,
14
15
  GatewayLogger,
15
16
  RuntimeEvent,
16
17
  } from '@parall/agent-core';
17
18
  import type { ClaudeAgentConfig } from './config.js';
18
- import { parseClaudeStreamJson, type ClaudeParsedEvent } from './output-parser.js';
19
- import { ClaudeSessionManager, type ClaudeProcessHandle } from './session-manager.js';
19
+ import { ClaudeInputRegistry, type ClaudeInputDelivery } from './input-lifecycle.js';
20
+ import {
21
+ parseClaudeStreamJson,
22
+ type ClaudeParsedEvent,
23
+ type ClaudeResultMeta,
24
+ } from './output-parser.js';
25
+ import type { ClaudeProcessHandle, ClaudeSessionManager } from './session-manager.js';
26
+ import { classifyClaudeTurn } from './turn-outcome.js';
20
27
 
21
28
  type ClaudeCodeAdapterOptions = Pick<
22
29
  ClaudeAgentConfig,
@@ -58,11 +65,9 @@ type ClaudeCodeAdapterOptions = Pick<
58
65
  };
59
66
 
60
67
  const IS_WIN32 = process.platform === 'win32';
68
+ const CAPABILITY_PROBE_TIMEOUT_MS = 15_000;
61
69
 
62
- // Upper bound on spurious empty turn_end frames skipped per turn (see the
63
- // guard in runTurn) — keeps a pathological stream of empty `result` frames
64
- // from spinning the drain loop forever.
65
- const MAX_SPURIOUS_TURN_END_SKIPS = 3;
70
+ class MissingClaudeLifecycleCapabilityError extends Error {}
66
71
 
67
72
  function quoteWin32Arg(arg: string): string {
68
73
  if (!/[\s"&|^<>()]/.test(arg)) return arg;
@@ -154,12 +159,15 @@ type ProcessState = {
154
159
  parser: AsyncGenerator<ClaudeParsedEvent>;
155
160
  done: boolean;
156
161
  needsRestart: boolean;
157
- steerReadPending?: Promise<IteratorResult<ClaudeParsedEvent>>;
162
+ capabilities?: Set<string>;
163
+ inputs: ClaudeInputRegistry;
158
164
  };
159
165
 
160
166
  export class ClaudeCodeAdapter implements DispatchAdapter {
167
+ readonly inputLifecycleMode = 'explicit' as const;
161
168
  private readonly processes = new Map<string, ProcessState>();
162
- private readonly pendingInjections = new Map<string, number>();
169
+ private capabilityProbe?: Promise<void>;
170
+ private capabilityProbeHandle?: ClaudeProcessHandle;
163
171
  private shuttingDown = false;
164
172
  private _model: string | undefined;
165
173
  private _effortLevel: string | undefined;
@@ -197,24 +205,43 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
197
205
  }
198
206
  }
199
207
 
200
- enqueueDuringDispatch(sessionKey: string, body: string): boolean {
208
+ enqueueDuringDispatch(
209
+ sessionKey: string,
210
+ body: string,
211
+ inputLifecycle?: DispatchInputLifecycle,
212
+ ): boolean {
213
+ // Exact identity is mandatory for soft steer. Without it the later
214
+ // buffered dispatch could only guess which stdout boundary belonged to
215
+ // this input — the bug this adapter is designed to remove.
216
+ if (!inputLifecycle) return false;
201
217
  const state = this.processes.get(sessionKey);
202
218
  if (!state || state.done) return false;
219
+ // A throwaway process already proved the CLI capability before any
220
+ // business process started. The real process revalidates its own init.
221
+ if (!state.capabilities?.has('msg_lifecycle_v1')) return false;
203
222
  const { proc } = state.handle;
204
223
  if (proc.exitCode !== null || proc.signalCode !== null || proc.stdin.destroyed) return false;
205
224
  try {
206
- this.writeUserMessage(state.handle, body);
207
- this.pendingInjections.set(sessionKey, (this.pendingInjections.get(sessionKey) ?? 0) + 1);
225
+ if (state.inputs.getByKey(inputLifecycle.deliveryKey)) return true;
226
+ const delivery = state.inputs.register(inputLifecycle.deliveryKey, inputLifecycle, true);
227
+ this.writeUserMessage(state.handle, body, delivery.commandUuid);
208
228
  return true;
209
229
  } catch {
230
+ const delivery = state.inputs.getByKey(inputLifecycle.deliveryKey);
231
+ if (delivery) {
232
+ void state.inputs.failBestEffort(delivery);
233
+ state.inputs.remove(delivery);
234
+ }
210
235
  return false;
211
236
  }
212
237
  }
213
238
 
214
239
  abortDispatch(sessionKey: string): void {
215
- this.pendingInjections.delete(sessionKey);
216
240
  const state = this.processes.get(sessionKey);
217
241
  if (!state || state.done) return;
242
+ for (const delivery of state.inputs.values()) {
243
+ if (!delivery.terminal) void state.inputs.failBestEffort(delivery);
244
+ }
218
245
  state.done = true;
219
246
  try {
220
247
  state.handle.proc.stdin.end();
@@ -224,7 +251,9 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
224
251
  }
225
252
 
226
253
  hasPendingInjections(sessionKey: string): boolean {
227
- return (this.pendingInjections.get(sessionKey) ?? 0) > 0;
254
+ const state = this.processes.get(sessionKey);
255
+ if (!state) return false;
256
+ return state.inputs.hasPendingInjections();
228
257
  }
229
258
 
230
259
  async *dispatch({
@@ -232,15 +261,29 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
232
261
  bodyForAgent,
233
262
  sessionKey,
234
263
  context,
264
+ inputLifecycle,
235
265
  }: DispatchOpts): AsyncIterable<RuntimeEvent> {
236
- const pending = this.pendingInjections.get(sessionKey) ?? 0;
237
- if (pending > 0) {
238
- this.pendingInjections.delete(sessionKey);
239
- context.log?.info?.(`consuming ${pending} steer turn(s)`);
240
- for (let i = 0; i < pending; i++) {
241
- yield* this.consumeSteerTurn(sessionKey, context.log);
266
+ const deliveryKey = inputLifecycle?.deliveryKey ?? event.dispatchEventId ?? event.messageId;
267
+ const existingState = this.processes.get(sessionKey);
268
+ const injected = existingState?.inputs.getByKey(deliveryKey);
269
+ if (existingState && injected) {
270
+ if (injected.terminal === 'failed') {
271
+ // The server released this exact WorkItem for retry. Its buffered
272
+ // bookkeeping dispatch is now real work again, not a no-op.
273
+ existingState.inputs.remove(injected);
274
+ } else {
275
+ injected.drained = true;
276
+ context.log?.info?.(`consuming steer input ${injected.commandUuid}`);
277
+ try {
278
+ yield* this.consumeDelivery(sessionKey, existingState, injected, context.log);
279
+ } finally {
280
+ existingState.inputs.remove(injected);
281
+ }
282
+ if (existingState.needsRestart && !existingState.inputs.hasPendingInjections()) {
283
+ this.killProcess(sessionKey, existingState);
284
+ }
285
+ return;
242
286
  }
243
- return;
244
287
  }
245
288
 
246
289
  let promptBody = bodyForAgent;
@@ -257,7 +300,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
257
300
  }
258
301
 
259
302
  try {
260
- yield* this.runTurn(sessionKey, promptBody, context.log);
303
+ yield* this.runTurn(sessionKey, promptBody, deliveryKey, inputLifecycle, context.log);
261
304
  } finally {
262
305
  releasePreparedAttachments();
263
306
  }
@@ -298,172 +341,146 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
298
341
  this.killProcess(sessionKey, state);
299
342
  }
300
343
  this.processes.clear();
301
- this.pendingInjections.clear();
302
344
  }
303
345
 
304
346
  async shutdown(): Promise<void> {
305
347
  this.shuttingDown = true;
348
+ if (this.capabilityProbeHandle) {
349
+ this.terminateHandle(this.capabilityProbeHandle);
350
+ this.capabilityProbeHandle = undefined;
351
+ }
306
352
  this.resetProcesses();
307
353
  await this.opts.sessionManager.shutdownAll();
308
354
  }
309
355
 
310
- // Steer-turn read timeout. Overridable via PRLL_STEER_TURN_TIMEOUT_MS (ms),
311
- // chiefly so tests can exercise the timeout path without a 10s wait.
312
- private readonly steerTurnTimeoutMs = Number(process.env.PRLL_STEER_TURN_TIMEOUT_MS) || 10_000;
313
-
314
- /**
315
- * Consume a steer turn whose message was already written to stdin via
316
- * enqueueDuringDispatch. Skip the stdin write — only read parser output.
317
- * If no output arrives within STEER_TURN_TIMEOUT_MS, the steer was
318
- * incorporated into the previous turn and there is nothing to consume.
319
- *
320
- * On timeout, the losing parser.next() promise is saved to
321
- * state.steerReadPending so the next runTurn can drain it instead of
322
- * silently losing the first event of the subsequent turn.
323
- */
324
- private async *consumeSteerTurn(
325
- sessionKey: string,
326
- log: GatewayLogger | undefined,
327
- ): AsyncGenerator<RuntimeEvent> {
328
- const state = this.processes.get(sessionKey);
329
- if (!state || state.done) {
330
- throw new Error('process dead during steer consumption');
331
- }
332
-
333
- const groupKey = randomUUID();
334
-
335
- // Reuse a pull already issued by a prior steer timeout instead of issuing a
336
- // fresh one. A burst of N steers all takes the pending-skip path, which calls
337
- // this method N times in a loop. Issuing a new state.parser.next() on each
338
- // call while a previous pull is still in flight queues multiple reads on the
339
- // same async generator, but only one can be stashed in the single-slot
340
- // state.steerReadPending — every earlier pull is then consumed-and-dropped,
341
- // silently losing parser events. When a dropped event is a tool_call, its
342
- // tool_result later orphans into "call_id does not match any tool_call".
343
- // Threading the single in-flight pull through every call keeps at most one
344
- // outstanding read and loses nothing.
345
- const parserNext = state.steerReadPending ?? state.parser.next();
346
- state.steerReadPending = undefined;
347
- const firstRead = await Promise.race([
348
- parserNext.then((r) => ({ kind: 'value' as const, result: r })),
349
- new Promise<{ kind: 'timeout' }>((resolve) =>
350
- setTimeout(() => resolve({ kind: 'timeout' }), this.steerTurnTimeoutMs),
351
- ),
352
- ]);
353
-
354
- if (firstRead.kind === 'timeout') {
355
- state.steerReadPending = parserNext;
356
- log?.info?.(`steer turn timeout — steer was incorporated into previous turn`);
357
- return;
358
- }
359
-
360
- let next = firstRead.result;
361
- // eslint-disable-next-line no-constant-condition
362
- while (true) {
363
- if (next.done) {
364
- state.done = true;
365
- this.processes.delete(sessionKey);
366
- throw new Error('process exited while consuming steer turn');
367
- }
368
- const parsed = next.value;
369
-
370
- if (parsed.type === 'session_id') {
371
- this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
372
- yield {
373
- type: 'runtime_session',
374
- runtimeSessionId: parsed.sessionId,
375
- runtimeLaneKey: sessionKey,
376
- };
377
- } else if (parsed.type === 'turn_end') {
378
- if (state.needsRestart) this.killProcess(sessionKey, state);
379
- return;
380
- } else if (parsed.type === 'error') {
381
- yield parsed;
382
- } else if (parsed.type === 'text') {
383
- yield { ...parsed, project: false, groupKey };
384
- } else if (parsed.type === 'runtime_session') {
385
- yield parsed;
386
- } else {
387
- yield { ...parsed, groupKey };
388
- }
389
- next = await state.parser.next();
390
- }
391
- }
392
-
393
356
  private async *runTurn(
394
357
  sessionKey: string,
395
358
  promptBody: string,
359
+ deliveryKey: string,
360
+ lifecycle: DispatchInputLifecycle | undefined,
396
361
  log: GatewayLogger | undefined,
397
362
  ): AsyncGenerator<RuntimeEvent> {
398
363
  let state: ProcessState;
399
364
  try {
365
+ await this.ensureRuntimeCapability(log);
400
366
  state = this.ensureProcess(sessionKey, log);
401
367
  } catch (err) {
368
+ try {
369
+ await lifecycle?.update('failed');
370
+ } catch (reportErr) {
371
+ log?.warn?.(`failed to report rejected Claude input as failed: ${String(reportErr)}`);
372
+ }
402
373
  yield { type: 'error', message: `Claude spawn failed: ${String(err)}` };
403
374
  return;
404
375
  }
405
- const groupKey = randomUUID();
406
- let sawError = false;
407
- // Poison-frame guard. When `--resume` (with or without `--fork-session`)
408
- // targets a transcript whose tail is dangling (previous process was
409
- // killed mid-turn — exactly the fork-on-busy case), Claude CLI emits a
410
- // spurious EMPTY result frame (num_turns: 0) at startup, BEFORE the
411
- // queued stdin user frame is processed. Treating its turn_end as the
412
- // dispatch boundary returns with zero output, the work item sweeps as
413
- // no_action, and the real reply is orphaned in a soon-to-be-killed
414
- // subprocess. A legitimate turn always reports num_turns >= 1 (even with
415
- // no visible text); undefined (CLIs that omit the field) must NOT skip.
416
- let sawSubstantiveEvent = false;
417
- let spuriousTurnEndSkips = 0;
418
-
376
+ const delivery = state.inputs.register(deliveryKey, lifecycle, false);
419
377
  try {
420
- this.writeUserMessage(state.handle, promptBody);
378
+ this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
421
379
  } catch (err) {
380
+ await state.inputs.failBestEffort(delivery, log);
381
+ state.inputs.remove(delivery);
422
382
  yield { type: 'error', message: `Claude stdin write failed: ${String(err)}` };
423
383
  this.killProcess(sessionKey, state);
424
384
  return;
425
385
  }
426
386
 
427
- if (state.steerReadPending) {
428
- const orphaned = await state.steerReadPending;
429
- state.steerReadPending = undefined;
430
- if (!orphaned.done) {
431
- const parsed = orphaned.value;
432
- // An orphaned read can deliver this turn's FIRST event — if it is
433
- // substantive it must count toward the poison-frame guard below, or
434
- // a later num_turns=0 turn_end would be wrongly skipped despite real
435
- // output having been produced this turn.
436
- if (
437
- parsed.type !== 'session_id' &&
438
- parsed.type !== 'runtime_session' &&
439
- parsed.type !== 'turn_end'
440
- ) {
441
- sawSubstantiveEvent = true;
387
+ try {
388
+ yield* this.consumeDelivery(sessionKey, state, delivery, log);
389
+ } finally {
390
+ state.inputs.remove(delivery);
391
+ }
392
+ }
393
+
394
+ private ensureRuntimeCapability(log: GatewayLogger | undefined): Promise<void> {
395
+ if (!this.capabilityProbe) {
396
+ const probe = this.probeRuntimeCapability(log);
397
+ this.capabilityProbe = probe.catch((err) => {
398
+ if (!(err instanceof MissingClaudeLifecycleCapabilityError)) {
399
+ this.capabilityProbe = undefined;
442
400
  }
443
- if (parsed.type === 'session_id') {
444
- this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
445
- yield {
446
- type: 'runtime_session',
447
- runtimeSessionId: parsed.sessionId,
448
- runtimeLaneKey: sessionKey,
449
- };
450
- } else if (parsed.type === 'turn_end') {
451
- if (state.needsRestart) this.killProcess(sessionKey, state);
452
- return;
453
- } else if (parsed.type === 'error') {
454
- sawError = true;
455
- yield parsed;
456
- } else if (parsed.type === 'text') {
457
- yield { ...parsed, project: false, groupKey };
458
- } else if (parsed.type === 'runtime_session') {
459
- yield parsed;
460
- } else {
461
- yield { ...parsed, groupKey };
401
+ throw err;
402
+ });
403
+ }
404
+ return this.capabilityProbe;
405
+ }
406
+
407
+ /**
408
+ * Claude does not emit system.init until it receives its first stdin
409
+ * command (2.1.220 emits queued/started before init). Probe a throwaway
410
+ * process with an empty, non-WorkItem command; only after its init
411
+ * advertises msg_lifecycle_v1 may any business input enter a real process.
412
+ */
413
+ private async probeRuntimeCapability(log: GatewayLogger | undefined): Promise<void> {
414
+ const handle = this.spawnProcess('__capability_probe__', log, false);
415
+ this.capabilityProbeHandle = handle;
416
+ const parser = parseClaudeStreamJson(handle.proc.stdout!);
417
+ let timedOut = false;
418
+ const timer = setTimeout(() => {
419
+ timedOut = true;
420
+ this.terminateHandle(handle);
421
+ }, CAPABILITY_PROBE_TIMEOUT_MS);
422
+ timer.unref?.();
423
+ try {
424
+ this.writeCapabilityProbe(handle);
425
+ while (true) {
426
+ const next = await parser.next();
427
+ if (next.done) {
428
+ const detail = handle.stderrChunks.join('').trim();
429
+ if (timedOut) {
430
+ throw new Error(
431
+ `Claude runtime capability probe timed out after ${CAPABILITY_PROBE_TIMEOUT_MS}ms`,
432
+ );
433
+ }
434
+ throw new Error(detail || 'Claude exited before its runtime capability probe completed');
462
435
  }
436
+ if (next.value.type !== 'runtime_init') continue;
437
+ if (!next.value.capabilities.includes('msg_lifecycle_v1')) {
438
+ throw new MissingClaudeLifecycleCapabilityError(
439
+ 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
440
+ );
441
+ }
442
+ return;
463
443
  }
444
+ } finally {
445
+ clearTimeout(timer);
446
+ if (this.capabilityProbeHandle === handle) {
447
+ this.capabilityProbeHandle = undefined;
448
+ }
449
+ this.terminateHandle(handle);
464
450
  }
451
+ }
465
452
 
466
- while (true) {
453
+ /**
454
+ * Drain the shared stdout stream until the exact UUID written for target
455
+ * reaches a terminal lifecycle state. Other injected inputs may start and
456
+ * finish while this drain is active; their callbacks advance independently
457
+ * and their later bookkeeping dispatch becomes a no-op.
458
+ */
459
+ private async *consumeDelivery(
460
+ sessionKey: string,
461
+ state: ProcessState,
462
+ target: ClaudeInputDelivery,
463
+ log: GatewayLogger | undefined,
464
+ ): AsyncGenerator<RuntimeEvent> {
465
+ if (target.terminal) return;
466
+ const groupKey = randomUUID();
467
+ let sawError = false;
468
+ // Turn-outcome evidence for this dispatch (agent-turn-outcome-design
469
+ // §4.1). Lifecycle terminals — not turn_end — bound consumption, so the
470
+ // result frame for the turn that carried this delivery is the LAST
471
+ // non-poison turn_end observed before the terminal. Poison frames
472
+ // (num_turns: 0 startup artifacts) never overwrite evidence. At most one
473
+ // turn_outcome is emitted per dispatch, right before the generator
474
+ // returns; with no evidence and no crash, nothing is emitted and the
475
+ // legacy boolean error path stands (refine-only, never guess).
476
+ let lastResultMeta: ClaudeResultMeta | undefined;
477
+ const noticeTexts: string[] = [];
478
+ // usage_limit settles via the lane-level deferred complete; a failed-input
479
+ // report would race it with an immediate redrive into the choked LLM.
480
+ const settledAsLimit = () =>
481
+ classifyClaudeTurn(lastResultMeta, noticeTexts).outcome === 'usage_limit';
482
+
483
+ while (!target.terminal) {
467
484
  const next = await state.parser.next();
468
485
 
469
486
  if (next.done) {
@@ -476,6 +493,8 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
476
493
  if (detail) {
477
494
  log?.warn?.(`subprocess stderr: ${detail}`);
478
495
  }
496
+ if (settledAsLimit()) target.suppressFailReport = true;
497
+ await state.inputs.failBestEffort(target, log);
479
498
  if (!sawError) {
480
499
  yield {
481
500
  type: 'error',
@@ -484,43 +503,101 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
484
503
  `Claude exited with code ${exit.code ?? 'unknown'}${exit.signal ? ` (${exit.signal})` : ''}`,
485
504
  };
486
505
  }
506
+ // No evidence at all classifies as runtime_crash (the process died
507
+ // before any result frame); with evidence, classify what we saw.
508
+ yield classifyClaudeTurn(lastResultMeta, noticeTexts);
487
509
  return;
488
510
  }
489
511
 
490
512
  const parsed = next.value;
491
513
 
492
- if (
493
- parsed.type !== 'session_id' &&
494
- parsed.type !== 'runtime_session' &&
495
- parsed.type !== 'turn_end'
496
- ) {
497
- sawSubstantiveEvent = true;
514
+ if (parsed.type === 'runtime_init') {
515
+ state.capabilities = new Set(parsed.capabilities);
516
+ if (parsed.sessionId) {
517
+ this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
518
+ yield {
519
+ type: 'runtime_session',
520
+ runtimeSessionId: parsed.sessionId,
521
+ runtimeLaneKey: sessionKey,
522
+ };
523
+ }
524
+ if (!state.capabilities.has('msg_lifecycle_v1')) {
525
+ await state.inputs.failBestEffort(target, log);
526
+ yield {
527
+ type: 'error',
528
+ message:
529
+ 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
530
+ };
531
+ this.killProcess(sessionKey, state);
532
+ return;
533
+ }
534
+ continue;
498
535
  }
499
536
 
500
- if (parsed.type === 'session_id') {
501
- this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
502
- yield {
503
- type: 'runtime_session',
504
- runtimeSessionId: parsed.sessionId,
505
- runtimeLaneKey: sessionKey,
506
- };
537
+ if (parsed.type === 'command_lifecycle') {
538
+ if (!state.capabilities?.has('msg_lifecycle_v1')) {
539
+ await state.inputs.failBestEffort(target, log);
540
+ yield {
541
+ type: 'error',
542
+ message: 'Claude emitted command lifecycle before advertising msg_lifecycle_v1',
543
+ };
544
+ this.killProcess(sessionKey, state);
545
+ return;
546
+ }
547
+ const delivery = state.inputs.getByCommand(parsed.commandUuid);
548
+ if (!delivery) {
549
+ log?.warn?.(`ignoring lifecycle for unknown Claude command ${parsed.commandUuid}`);
550
+ continue;
551
+ }
552
+ try {
553
+ await state.inputs.apply(delivery, parsed.state);
554
+ } catch (err) {
555
+ await state.inputs.failBestEffort(delivery, log);
556
+ yield {
557
+ type: 'error',
558
+ message: `Claude input lifecycle update failed: ${String(err)}`,
559
+ };
560
+ this.killProcess(sessionKey, state);
561
+ return;
562
+ }
507
563
  continue;
508
564
  }
509
565
 
510
566
  if (parsed.type === 'turn_end') {
511
- if (
512
- parsed.numTurns === 0 &&
513
- !sawSubstantiveEvent &&
514
- spuriousTurnEndSkips < MAX_SPURIOUS_TURN_END_SKIPS
515
- ) {
516
- spuriousTurnEndSkips++;
517
- log?.warn?.('ignoring spurious empty turn_end (num_turns=0) — resume/fork poison frame');
518
- continue;
567
+ // Evidence capture: the poison startup frame (numTurns === 0) is a
568
+ // resume artifact, not a turn boundary — never let it overwrite real
569
+ // evidence.
570
+ if (parsed.numTurns !== 0) {
571
+ lastResultMeta = parsed.resultMeta;
519
572
  }
520
- if (state.needsRestart) {
521
- this.killProcess(sessionKey, state);
573
+ if (parsed.isError) {
574
+ const limitSettled = settledAsLimit();
575
+ const failedDelivery = parsed.userMessageUuid
576
+ ? state.inputs.getByCommand(parsed.userMessageUuid)
577
+ : undefined;
578
+ if (!failedDelivery) {
579
+ if (limitSettled) {
580
+ for (const delivery of state.inputs.values()) {
581
+ delivery.suppressFailReport = true;
582
+ }
583
+ }
584
+ await state.inputs.failAllBestEffort(log);
585
+ yield classifyClaudeTurn(lastResultMeta, noticeTexts);
586
+ this.killProcess(sessionKey, state);
587
+ return;
588
+ }
589
+ failedDelivery.resultFailed = true;
590
+ if (limitSettled) failedDelivery.suppressFailReport = true;
522
591
  }
523
- return;
592
+ // result adjacency is not a consumption boundary: the matching
593
+ // command_lifecycle(completed) may follow it or interleave with a
594
+ // different queued/injected command.
595
+ continue;
596
+ }
597
+
598
+ if (parsed.type === 'assistant_error') {
599
+ noticeTexts.push(parsed.message);
600
+ continue;
524
601
  }
525
602
 
526
603
  if (parsed.type === 'error') {
@@ -539,8 +616,25 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
539
616
  continue;
540
617
  }
541
618
 
619
+ if (parsed.type === 'turn_outcome') {
620
+ // (never produced by the parser — type guard only)
621
+ continue;
622
+ }
623
+
542
624
  yield { ...parsed, groupKey };
543
625
  }
626
+
627
+ // Delivery reached a lifecycle terminal. Classify only when a result
628
+ // frame was observed during this drain: a delivery whose frames were
629
+ // drained by a sibling consumption window has no evidence here, and
630
+ // guessing would mislabel the turn (refine-only rule, §4.3).
631
+ if (lastResultMeta) {
632
+ yield classifyClaudeTurn(lastResultMeta, noticeTexts);
633
+ }
634
+
635
+ if (state.needsRestart && !state.inputs.hasPendingInjections()) {
636
+ this.killProcess(sessionKey, state);
637
+ }
544
638
  }
545
639
 
546
640
  private ensureProcess(sessionKey: string, log: GatewayLogger | undefined): ProcessState {
@@ -562,14 +656,28 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
562
656
 
563
657
  const handle = this.spawnProcess(sessionKey, log);
564
658
  const parser = parseClaudeStreamJson(handle.proc.stdout!);
565
- const state: ProcessState = { handle, parser, done: false, needsRestart: false };
659
+ const state: ProcessState = {
660
+ handle,
661
+ parser,
662
+ done: false,
663
+ needsRestart: false,
664
+ // A throwaway process already proved the current CLI advertises this
665
+ // capability. Real 2.1.220 sends queued/started before its own init,
666
+ // so pre-seed the gate and still verify the real init when it arrives.
667
+ capabilities: new Set(['msg_lifecycle_v1']),
668
+ inputs: new ClaudeInputRegistry(),
669
+ };
566
670
  this.processes.set(sessionKey, state);
567
671
  this.opts.sessionManager.registerProcess(sessionKey, handle);
568
672
  return state;
569
673
  }
570
674
 
571
- private spawnProcess(sessionKey: string, log: GatewayLogger | undefined): ClaudeProcessHandle {
572
- const args = this.buildArgs(sessionKey);
675
+ private spawnProcess(
676
+ sessionKey: string,
677
+ log: GatewayLogger | undefined,
678
+ resume = true,
679
+ ): ClaudeProcessHandle {
680
+ const args = this.buildArgs(sessionKey, resume);
573
681
  const env = buildSpawnEnv(
574
682
  process.env,
575
683
  this.opts.claudeHome,
@@ -625,20 +733,27 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
625
733
  }
626
734
 
627
735
  private killProcess(sessionKey: string, state: ProcessState) {
736
+ for (const delivery of state.inputs.values()) {
737
+ if (!delivery.terminal) void state.inputs.failBestEffort(delivery);
738
+ }
628
739
  state.done = true;
629
740
  const current = this.processes.get(sessionKey);
630
741
  if (current === state) {
631
742
  this.processes.delete(sessionKey);
632
743
  }
744
+ this.terminateHandle(state.handle);
745
+ }
746
+
747
+ private terminateHandle(handle: ClaudeProcessHandle): void {
633
748
  try {
634
- state.handle.proc.stdin.end();
749
+ handle.proc.stdin.end();
635
750
  } catch {
636
751
  /* best-effort */
637
752
  }
638
- if (state.handle.proc.exitCode === null && state.handle.proc.signalCode === null) {
753
+ if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
639
754
  try {
640
- if (!IS_WIN32 || !state.handle.proc.pid || !killWin32Tree(state.handle.proc.pid)) {
641
- state.handle.proc.kill('SIGTERM');
755
+ if (!IS_WIN32 || !handle.proc.pid || !killWin32Tree(handle.proc.pid)) {
756
+ handle.proc.kill('SIGTERM');
642
757
  }
643
758
  } catch {
644
759
  /* best-effort */
@@ -646,9 +761,21 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
646
761
  }
647
762
  }
648
763
 
649
- private writeUserMessage(handle: ClaudeProcessHandle, text: string) {
764
+ private writeCapabilityProbe(handle: ClaudeProcessHandle): void {
765
+ const payload = JSON.stringify({
766
+ type: 'user',
767
+ uuid: randomUUID(),
768
+ parent_tool_use_id: null,
769
+ message: { role: 'user', content: [] },
770
+ });
771
+ handle.proc.stdin.write(`${payload}\n`);
772
+ }
773
+
774
+ private writeUserMessage(handle: ClaudeProcessHandle, text: string, commandUuid: string) {
650
775
  const payload = JSON.stringify({
651
776
  type: 'user',
777
+ uuid: commandUuid,
778
+ parent_tool_use_id: null,
652
779
  message: {
653
780
  role: 'user',
654
781
  content: [{ type: 'text', text }],
@@ -657,7 +784,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
657
784
  handle.proc.stdin.write(`${payload}\n`);
658
785
  }
659
786
 
660
- private buildArgs(sessionKey: string): string[] {
787
+ private buildArgs(sessionKey: string, resume = true): string[] {
661
788
  const args = [
662
789
  '--verbose',
663
790
  '--input-format',
@@ -693,7 +820,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
693
820
  args.push('--add-dir', ...this.opts.additionalDirs);
694
821
  }
695
822
 
696
- args.push(...this.opts.sessionManager.getResumeArgs(sessionKey));
823
+ if (resume) args.push(...this.opts.sessionManager.getResumeArgs(sessionKey));
697
824
  return args;
698
825
  }
699
826