@parall/claude-agent 1.59.0 → 1.60.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
@@ -4,24 +4,28 @@ import * as fs from 'node:fs';
4
4
  import * as path from 'node:path';
5
5
  import type {
6
6
  CleanupForkOpts,
7
+ CompactOpts,
8
+ CompactResult,
7
9
  DispatchAdapter,
8
10
  DispatchInputLifecycle,
9
11
  DispatchOpts,
10
12
  ForkOpts,
11
13
  GatewayLogger,
14
+ RuntimeActivityEvent,
15
+ RuntimeBusyState,
12
16
  RuntimeEvent,
13
17
  } from '@parall/agent-core';
18
+ import { isRuntimeBusy, projectRuntimeEvent, RuntimeActivityPort } from '@parall/agent-core';
14
19
  import {
15
20
  appendPreparedLocalAttachmentRefs,
16
21
  pinLocalAttachmentPaths,
17
22
  } from '@parall/agent-core/internal/attachment-input';
23
+ import { runClaudeCompact } from './compact.js';
24
+ import { aggregateBusyState, DEFAULT_FOLLOW_UP_HOLD_MS } from './busy-state.js';
18
25
  import type { ClaudeAgentConfig } from './config.js';
19
26
  import { type ClaudeInputDelivery, ClaudeInputRegistry } from './input-lifecycle.js';
20
- import {
21
- type ClaudeParsedEvent,
22
- type ClaudeResultMeta,
23
- parseClaudeStreamJson,
24
- } from './output-parser.js';
27
+ import { parseClaudeStreamJson } from './output-parser.js';
28
+ import { ClaudeProcessPump } from './process-pump.js';
25
29
  import type { ClaudeProcessHandle, ClaudeSessionManager } from './session-manager.js';
26
30
  import { buildSpawnEnv } from './spawn-env.js';
27
31
  import { classifyClaudeTurn } from './turn-outcome.js';
@@ -69,6 +73,14 @@ type ClaudeCodeAdapterOptions = Pick<
69
73
  * subprocess on its next shell command — no respawn needed.
70
74
  */
71
75
  capabilityBinDir?: string;
76
+ /**
77
+ * How long the bridge stays busy after a background task finishes,
78
+ * waiting for the follow-up turn the CLI usually runs on that
79
+ * notification (busy-state.ts). Default 30 s.
80
+ */
81
+ followUpHoldMs?: number;
82
+ /** Test port. */
83
+ now?: () => number;
72
84
  };
73
85
 
74
86
  const IS_WIN32 = process.platform === 'win32';
@@ -93,31 +105,38 @@ function killWin32Tree(pid: number): boolean {
93
105
  /**
94
106
  * Per-sessionKey long-lived process state. The process stays alive across
95
107
  * dispatches; each dispatch writes one NDJSON user message to stdin and
96
- * drains stdout until `turn_end`. Between dispatches the process is idle
97
- * gateway's `drainMainBuffer` serializes dispatch calls so no concurrent
98
- * access to the same process occurs.
108
+ * drains the envelopes the stdout pump routes to that delivery until its
109
+ * lifecycle terminal. The pump (process-pump.ts) is the ONLY stdout reader:
110
+ * between dispatches it keeps reading, so turns the CLI starts on its own
111
+ * (background-task follow-ups) are observed instead of piling up in the
112
+ * pipe. Gateway's `drainMainBuffer` serializes dispatch calls per
113
+ * sessionKey, so no two drains compete for the same delivery.
99
114
  */
100
- type ProcessState = {
115
+ export type ProcessState = {
101
116
  handle: ClaudeProcessHandle;
102
- parser: AsyncGenerator<ClaudeParsedEvent>;
117
+ pump: ClaudeProcessPump;
103
118
  done: boolean;
104
119
  needsRestart: boolean;
105
- capabilities?: Set<string>;
106
120
  inputs: ClaudeInputRegistry;
121
+ log?: GatewayLogger;
107
122
  };
108
123
 
109
124
  export class ClaudeCodeAdapter implements DispatchAdapter {
110
125
  readonly inputLifecycleMode = 'explicit' as const;
111
126
  private readonly processes = new Map<string, ProcessState>();
127
+ /** Retired by abortDispatch while the CLI still had its own work: busy until idle, then terminated. */
128
+ private readonly retiring = new Set<ProcessState>();
112
129
  private capabilityProbe?: Promise<void>;
113
130
  private capabilityProbeHandle?: ClaudeProcessHandle;
114
131
  private shuttingDown = false;
132
+ private readonly activity: RuntimeActivityPort;
115
133
  private _model: string | undefined;
116
134
  private _effortLevel: string | undefined;
117
135
  private _contextWindow: number | undefined;
118
136
  private _maxTokens: number | undefined;
119
137
 
120
138
  constructor(private readonly opts: ClaudeCodeAdapterOptions) {
139
+ this.activity = new RuntimeActivityPort('ClaudeCodeAdapter');
121
140
  this._model = opts.model;
122
141
  this._contextWindow = opts.contextWindow;
123
142
  this._maxTokens = opts.maxTokens;
@@ -161,6 +180,26 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
161
180
  }
162
181
  }
163
182
 
183
+ // --- runtime-initiated work -------------------------------------------------
184
+
185
+ subscribeRuntimeActivity(handler: (event: RuntimeActivityEvent) => void): () => void {
186
+ return this.activity.subscribe(handler);
187
+ }
188
+
189
+ busyState(now = this.now()): RuntimeBusyState {
190
+ const states: RuntimeBusyState[] = [];
191
+ for (const state of this.processes.values()) {
192
+ if (state.done) continue;
193
+ states.push(state.pump.busyState(now));
194
+ }
195
+ for (const state of this.retiring) states.push(state.pump.busyState(now));
196
+ return aggregateBusyState(states);
197
+ }
198
+
199
+ isBusy(now = this.now()): boolean {
200
+ return isRuntimeBusy(this.busyState(now), now);
201
+ }
202
+
164
203
  enqueueDuringDispatch(
165
204
  sessionKey: string,
166
205
  body: string,
@@ -174,7 +213,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
174
213
  if (!state || state.done) return false;
175
214
  // A throwaway process already proved the CLI capability before any
176
215
  // business process started. The real process revalidates its own init.
177
- if (!state.capabilities?.has('msg_lifecycle_v1')) return false;
216
+ if (!state.pump.capabilities.has('msg_lifecycle_v1')) return false;
178
217
  const { proc } = state.handle;
179
218
  if (proc.exitCode !== null || proc.signalCode !== null || proc.stdin.destroyed) return false;
180
219
  try {
@@ -195,15 +234,22 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
195
234
  abortDispatch(sessionKey: string): void {
196
235
  const state = this.processes.get(sessionKey);
197
236
  if (!state || state.done) return;
198
- for (const delivery of state.inputs.values()) {
199
- if (!delivery.terminal) void state.inputs.failBestEffort(delivery);
200
- }
237
+ state.pump.abortDeliveries('dispatch aborted');
201
238
  state.done = true;
202
- try {
203
- state.handle.proc.stdin.end();
204
- } catch {
205
- /* best-effort */
239
+ if (state.pump.hasOwnWork()) {
240
+ // The CLI has (or is about to start) a runtime-initiated turn: retire
241
+ // the process instead of ending stdin under it. Out of `processes` and
242
+ // the session manager so the next dispatch's fresh process does not
243
+ // SIGTERM it as replaced; terminated once the pump reports idle.
244
+ this.processes.delete(sessionKey);
245
+ this.opts.sessionManager.clearProcess(sessionKey, state.handle);
246
+ this.retiring.add(state);
247
+ state.pump.whenIdle(() => {
248
+ if (this.retiring.delete(state)) this.terminateHandle(state.handle);
249
+ });
250
+ return;
206
251
  }
252
+ this.endStdin(state.handle);
207
253
  }
208
254
 
209
255
  hasPendingInjections(sessionKey: string): boolean {
@@ -226,13 +272,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
226
272
  // waiting on. Unlike the consume path's tail, the discarded delivery may
227
273
  // still be RUNNING (non-terminal): killing the process now would fail a
228
274
  // live input, so the restart also waits for every injection to settle.
229
- if (
230
- state.needsRestart &&
231
- !state.inputs.hasPendingInjections() &&
232
- !state.inputs.hasUnsettledInjections()
233
- ) {
234
- this.killProcess(sessionKey, state);
235
- }
275
+ this.maybeApplyRestart(sessionKey, state);
236
276
  }
237
277
 
238
278
  async *dispatch({
@@ -255,23 +295,11 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
255
295
  injected.drained = true;
256
296
  context.log?.info?.(`consuming steer input ${injected.commandUuid}`);
257
297
  try {
258
- yield* this.consumeDelivery(
259
- sessionKey,
260
- existingState,
261
- injected,
262
- context.log,
263
- noteActivity,
264
- );
298
+ yield* this.consumeDelivery(sessionKey, existingState, injected, noteActivity);
265
299
  } finally {
266
300
  existingState.inputs.remove(injected);
267
301
  }
268
- if (
269
- existingState.needsRestart &&
270
- !existingState.inputs.hasPendingInjections() &&
271
- !existingState.inputs.hasUnsettledInjections()
272
- ) {
273
- this.killProcess(sessionKey, existingState);
274
- }
302
+ this.maybeApplyRestart(sessionKey, existingState);
275
303
  return;
276
304
  }
277
305
  }
@@ -346,6 +374,12 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
346
374
  this.killProcess(sessionKey, state);
347
375
  }
348
376
  this.processes.clear();
377
+ const retired = [...this.retiring];
378
+ this.retiring.clear();
379
+ for (const state of retired) {
380
+ state.pump.close('killed', 'Claude process terminated by the bridge');
381
+ this.terminateHandle(state.handle);
382
+ }
349
383
  }
350
384
 
351
385
  async shutdown(): Promise<void> {
@@ -358,6 +392,10 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
358
392
  await this.opts.sessionManager.shutdownAll();
359
393
  }
360
394
 
395
+ private now(): number {
396
+ return (this.opts.now ?? Date.now)();
397
+ }
398
+
361
399
  private async *runTurn(
362
400
  sessionKey: string,
363
401
  promptBody: string,
@@ -379,7 +417,9 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
379
417
  yield { type: 'error', message: `Claude spawn failed: ${String(err)}` };
380
418
  return;
381
419
  }
382
- const delivery = state.inputs.register(deliveryKey, lifecycle, false);
420
+ // Registered BEFORE the stdin write: the pump may route this command's
421
+ // queued/started frames before the drain below starts.
422
+ const delivery = state.inputs.register(deliveryKey, lifecycle, false, noteActivity, log);
383
423
  try {
384
424
  this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
385
425
  } catch (err) {
@@ -391,12 +431,26 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
391
431
  }
392
432
 
393
433
  try {
394
- yield* this.consumeDelivery(sessionKey, state, delivery, log, noteActivity);
434
+ yield* this.consumeDelivery(sessionKey, state, delivery, noteActivity);
395
435
  } finally {
396
436
  state.inputs.remove(delivery);
397
437
  }
398
438
  }
399
439
 
440
+ /** Idle auto-compact (compact.ts): a `/compact` frame into the long-lived process. */
441
+ compact(opts: CompactOpts): Promise<CompactResult> {
442
+ return runClaudeCompact(
443
+ {
444
+ ensureRuntimeCapability: (log) => this.ensureRuntimeCapability(log),
445
+ ensureProcess: (sessionKey, log) => this.ensureProcess(sessionKey, log),
446
+ killProcess: (sessionKey, state) => this.killProcess(sessionKey, state),
447
+ writeUserMessage: (handle, text, uuid) => this.writeUserMessage(handle, text, uuid),
448
+ applyPendingRestart: (sessionKey, state) => this.maybeApplyRestart(sessionKey, state),
449
+ },
450
+ opts,
451
+ );
452
+ }
453
+
400
454
  private ensureRuntimeCapability(log: GatewayLogger | undefined): Promise<void> {
401
455
  if (!this.capabilityProbe) {
402
456
  const probe = this.probeRuntimeCapability(log);
@@ -457,201 +511,80 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
457
511
  }
458
512
 
459
513
  /**
460
- * Drain the shared stdout stream until the exact UUID written for target
461
- * reaches a terminal lifecycle state. Other injected inputs may start and
462
- * finish while this drain is active; their callbacks advance independently
463
- * and their later bookkeeping dispatch becomes a no-op.
514
+ * Drain the envelopes the pump routed to `target` until its exact stdin
515
+ * UUID reaches a terminal lifecycle state. Other injected inputs may start
516
+ * and finish while this drain is active; their callbacks advance in the
517
+ * pump independently and their later bookkeeping dispatch becomes a no-op.
464
518
  */
465
519
  private async *consumeDelivery(
466
520
  sessionKey: string,
467
521
  state: ProcessState,
468
522
  target: ClaudeInputDelivery,
469
- log: GatewayLogger | undefined,
470
523
  noteActivity: (() => void) | undefined,
471
524
  ): AsyncGenerator<RuntimeEvent> {
472
525
  if (target.terminal) return;
473
526
  const groupKey = randomUUID();
474
- let sawError = false;
475
- // Turn-outcome evidence for this dispatch (agent-turn-outcome-design
476
- // §4.1). Lifecycle terminals — not turn_end — bound consumption, so the
477
- // result frame for the turn that carried this delivery is the LAST
478
- // non-poison turn_end observed before the terminal. Poison frames
479
- // (num_turns: 0 startup artifacts) never overwrite evidence. At most one
480
- // turn_outcome is emitted per dispatch, right before the generator
481
- // returns; with no evidence and no crash, nothing is emitted and the
482
- // legacy boolean error path stands (refine-only, never guess).
483
- let lastResultMeta: ClaudeResultMeta | undefined;
484
- const noticeTexts: string[] = [];
485
- // usage_limit settles via the lane-level deferred complete; a failed-input
486
- // report would race it with an immediate redrive into the choked LLM.
487
- const settledAsLimit = () =>
488
- classifyClaudeTurn(lastResultMeta, noticeTexts).outcome === 'usage_limit';
489
-
490
- while (!target.terminal) {
491
- const next = await state.parser.next();
492
-
493
- if (next.done) {
494
- state.done = true;
495
- this.processes.delete(sessionKey);
496
- const detail = state.handle.stderrChunks.join('').trim();
497
- const exit = await state.handle.exitPromise.catch(
498
- () => ({ code: null, signal: null }) as const,
499
- );
500
- if (detail) {
501
- log?.warn?.(`subprocess stderr: ${detail}`);
502
- }
503
- if (settledAsLimit()) target.suppressFailReport = true;
504
- await state.inputs.failBestEffort(target, log);
505
- if (!sawError) {
506
- yield {
507
- type: 'error',
508
- message:
509
- detail ||
510
- `Claude exited with code ${exit.code ?? 'unknown'}${exit.signal ? ` (${exit.signal})` : ''}`,
511
- };
512
- }
513
- // No evidence at all classifies as runtime_crash (the process died
514
- // before any result frame); with evidence, classify what we saw.
515
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
516
- return;
517
- }
518
-
519
- const parsed = next.value;
520
- // Count every parser-observed CLI progress frame as activity, including
521
- // command lifecycle, turn boundaries, and explicit activity markers
522
- // for nested subagent frames that must not become RuntimeEvents.
523
- noteActivity?.();
524
-
525
- if (parsed.type === 'runtime_activity') continue;
526
-
527
- if (parsed.type === 'runtime_init') {
528
- state.capabilities = new Set(parsed.capabilities);
529
- if (parsed.sessionId) {
530
- this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
531
- yield {
532
- type: 'runtime_session',
533
- runtimeSessionId: parsed.sessionId,
534
- runtimeLaneKey: sessionKey,
535
- };
536
- }
537
- if (!state.capabilities.has('msg_lifecycle_v1')) {
538
- await state.inputs.failBestEffort(target, log);
539
- yield {
540
- type: 'error',
541
- message:
542
- 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
543
- };
544
- this.killProcess(sessionKey, state);
545
- return;
546
- }
547
- continue;
548
- }
549
-
550
- if (parsed.type === 'command_lifecycle') {
551
- if (!state.capabilities?.has('msg_lifecycle_v1')) {
552
- await state.inputs.failBestEffort(target, log);
553
- yield {
554
- type: 'error',
555
- message: 'Claude emitted command lifecycle before advertising msg_lifecycle_v1',
556
- };
557
- this.killProcess(sessionKey, state);
558
- return;
559
- }
560
- const delivery = state.inputs.getByCommand(parsed.commandUuid);
561
- if (!delivery) {
562
- log?.warn?.(`ignoring lifecycle for unknown Claude command ${parsed.commandUuid}`);
527
+ state.pump.setActiveDrain(target, noteActivity);
528
+ try {
529
+ while (true) {
530
+ const next = await target.sink.next();
531
+ if (next.done) return;
532
+ const envelope = next.value;
533
+ if (envelope.kind === 'runtime') {
534
+ yield projectRuntimeEvent(envelope.event, groupKey);
563
535
  continue;
564
536
  }
565
- try {
566
- await state.inputs.apply(delivery, parsed.state);
567
- } catch (err) {
568
- await state.inputs.failBestEffort(delivery, log);
537
+ if (envelope.kind === 'terminal') break;
538
+ // The process is gone (eof / killed), the dispatch was aborted, or
539
+ // the pump ended this owner's window: surface the error unless the
540
+ // stream already carried one, then classify what was observed (no
541
+ // evidence at all is a runtime_crash).
542
+ if (envelope.reason !== 'error_result' && !target.evidence.sawError) {
569
543
  yield {
570
544
  type: 'error',
571
- message: `Claude input lifecycle update failed: ${String(err)}`,
545
+ message: envelope.message ?? `Claude turn ${envelope.reason}`,
572
546
  };
573
- this.killProcess(sessionKey, state);
574
- return;
575
- }
576
- continue;
577
- }
578
-
579
- if (parsed.type === 'turn_end') {
580
- // Evidence capture: the poison startup frame (numTurns === 0) is a
581
- // resume artifact, not a turn boundary — never let it overwrite real
582
- // evidence.
583
- if (parsed.numTurns !== 0) {
584
- lastResultMeta = parsed.resultMeta;
585
- }
586
- if (parsed.isError) {
587
- const limitSettled = settledAsLimit();
588
- const failedDelivery = parsed.userMessageUuid
589
- ? state.inputs.getByCommand(parsed.userMessageUuid)
590
- : undefined;
591
- if (!failedDelivery) {
592
- if (limitSettled) {
593
- for (const delivery of state.inputs.values()) {
594
- delivery.suppressFailReport = true;
595
- }
596
- }
597
- await state.inputs.failAllBestEffort(log);
598
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
599
- this.killProcess(sessionKey, state);
600
- return;
601
- }
602
- failedDelivery.resultFailed = true;
603
- if (limitSettled) failedDelivery.suppressFailReport = true;
604
547
  }
605
- // result adjacency is not a consumption boundary: the matching
606
- // command_lifecycle(completed) may follow it or interleave with a
607
- // different queued/injected command.
608
- continue;
609
- }
610
-
611
- if (parsed.type === 'assistant_error') {
612
- noticeTexts.push(parsed.message);
613
- continue;
614
- }
615
-
616
- if (parsed.type === 'error') {
617
- sawError = true;
618
- yield parsed;
619
- continue;
620
- }
621
-
622
- if (parsed.type === 'text') {
623
- yield { ...parsed, project: false, groupKey };
624
- continue;
625
- }
626
-
627
- if (parsed.type === 'runtime_session') {
628
- yield parsed;
629
- continue;
548
+ yield classifyClaudeTurn(target.evidence.lastResultMeta, target.evidence.noticeTexts);
549
+ return;
630
550
  }
631
551
 
632
- if (parsed.type === 'turn_outcome') {
633
- // (never produced by the parser type guard only)
634
- continue;
552
+ // Delivery reached a lifecycle terminal. Classify only when a result
553
+ // frame was observed in this owner's window: a delivery whose frames
554
+ // were drained by a sibling consumption window has no evidence here,
555
+ // and guessing would mislabel the turn (refine-only rule, §4.3).
556
+ if (target.evidence.lastResultMeta) {
557
+ yield classifyClaudeTurn(target.evidence.lastResultMeta, target.evidence.noticeTexts);
635
558
  }
636
-
637
- yield { ...parsed, groupKey };
559
+ } finally {
560
+ state.pump.clearActiveDrain(target);
561
+ this.maybeApplyRestart(sessionKey, state);
638
562
  }
563
+ }
639
564
 
640
- // Delivery reached a lifecycle terminal. Classify only when a result
641
- // frame was observed during this drain: a delivery whose frames were
642
- // drained by a sibling consumption window has no evidence here, and
643
- // guessing would mislabel the turn (refine-only rule, §4.3).
644
- if (lastResultMeta) {
645
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
565
+ /**
566
+ * Lazy restart: kill the process so the next dispatch respawns it (the
567
+ * session survives via --resume). Waits for every injection to settle, for
568
+ * an open runtime-initiated turn and for a follow-up hold — but NOT for
569
+ * outstanding background tasks (a `make dev` would defer a config change
570
+ * forever); those die with the process, logged.
571
+ */
572
+ private maybeApplyRestart(sessionKey: string, state: ProcessState): void {
573
+ if (!state.needsRestart || state.done) return;
574
+ if (state.inputs.hasPendingInjections() || state.inputs.hasUnsettledInjections()) return;
575
+ // A dispatch queued behind a runtime-initiated turn is registered but
576
+ // not started yet; killing now would fail it before the CLI runs it.
577
+ for (const delivery of state.inputs.values()) {
578
+ if (!delivery.terminal) return;
646
579
  }
647
-
648
- if (
649
- state.needsRestart &&
650
- !state.inputs.hasPendingInjections() &&
651
- !state.inputs.hasUnsettledInjections()
652
- ) {
653
- this.killProcess(sessionKey, state);
580
+ if (state.pump.hasOwnWork()) return;
581
+ const outstanding = state.pump.busy.outstanding();
582
+ if (outstanding.total > 0) {
583
+ state.log?.warn?.(
584
+ `restarting ${sessionKey} with ${outstanding.total} live background task(s); they die with the process`,
585
+ );
654
586
  }
587
+ this.killProcess(sessionKey, state);
655
588
  }
656
589
 
657
590
  private ensureProcess(sessionKey: string, log: GatewayLogger | undefined): ProcessState {
@@ -662,9 +595,16 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
662
595
  const existing = this.processes.get(sessionKey);
663
596
  if (existing && !existing.done) {
664
597
  const { proc } = existing.handle;
665
- if (existing.needsRestart) {
598
+ const alive = proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed;
599
+ if (existing.needsRestart && alive) {
600
+ if (existing.pump.hasOwnWork()) {
601
+ log?.info?.(
602
+ `deferring lazy restart of ${sessionKey}: runtime-initiated turn in progress`,
603
+ );
604
+ return existing;
605
+ }
666
606
  this.killProcess(sessionKey, existing);
667
- } else if (proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed) {
607
+ } else if (alive) {
668
608
  return existing;
669
609
  } else {
670
610
  this.processes.delete(sessionKey);
@@ -673,19 +613,51 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
673
613
 
674
614
  const handle = this.spawnProcess(sessionKey, log);
675
615
  const parser = parseClaudeStreamJson(handle.proc.stdout!);
616
+ const inputs = new ClaudeInputRegistry();
676
617
  const state: ProcessState = {
677
618
  handle,
678
- parser,
619
+ pump: new ClaudeProcessPump({
620
+ sessionKey,
621
+ parser,
622
+ handle,
623
+ inputs,
624
+ log,
625
+ followUpHoldMs: this.opts.followUpHoldMs ?? DEFAULT_FOLLOW_UP_HOLD_MS,
626
+ now: () => this.now(),
627
+ hooks: {
628
+ onRuntimeInit: (init) => {
629
+ if (init.sessionId) {
630
+ this.opts.sessionManager.recordSessionId(sessionKey, init.sessionId);
631
+ }
632
+ if (!init.capabilities.includes('msg_lifecycle_v1')) {
633
+ return 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage';
634
+ }
635
+ return undefined;
636
+ },
637
+ onFatal: () => {
638
+ const current = this.processes.get(sessionKey);
639
+ if (current === state) this.killProcess(sessionKey, state);
640
+ },
641
+ onEof: () => {
642
+ state.done = true;
643
+ if (this.processes.get(sessionKey) === state) this.processes.delete(sessionKey);
644
+ this.retiring.delete(state);
645
+ },
646
+ // Main-session turns go to the gateway (steps + session activity);
647
+ // fork-session turns only count as busy (fork-scope invariant).
648
+ onRuntimeTurnOpened: (turn) =>
649
+ this.activity.surfaceTurn(turn, this.opts.sessionManager.isMain(sessionKey), log),
650
+ onRuntimeTurnClosed: () => this.maybeApplyRestart(sessionKey, state),
651
+ },
652
+ }),
679
653
  done: false,
680
654
  needsRestart: false,
681
- // A throwaway process already proved the current CLI advertises this
682
- // capability. Real 2.1.220 sends queued/started before its own init,
683
- // so pre-seed the gate and still verify the real init when it arrives.
684
- capabilities: new Set(['msg_lifecycle_v1']),
685
- inputs: new ClaudeInputRegistry(),
655
+ inputs,
656
+ log,
686
657
  };
687
658
  this.processes.set(sessionKey, state);
688
659
  this.opts.sessionManager.registerProcess(sessionKey, handle);
660
+ state.pump.start();
689
661
  return state;
690
662
  }
691
663
 
@@ -760,15 +732,20 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
760
732
  if (current === state) {
761
733
  this.processes.delete(sessionKey);
762
734
  }
735
+ state.pump.close('killed', 'Claude process terminated by the bridge');
763
736
  this.terminateHandle(state.handle);
764
737
  }
765
738
 
766
- private terminateHandle(handle: ClaudeProcessHandle): void {
739
+ private endStdin(handle: ClaudeProcessHandle): void {
767
740
  try {
768
741
  handle.proc.stdin.end();
769
742
  } catch {
770
743
  /* best-effort */
771
744
  }
745
+ }
746
+
747
+ private terminateHandle(handle: ClaudeProcessHandle): void {
748
+ this.endStdin(handle);
772
749
  if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
773
750
  try {
774
751
  if (!IS_WIN32 || !handle.proc.pid || !killWin32Tree(handle.proc.pid)) {
package/src/index.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  identityFromMe,
14
14
  initAgentTelemetry,
15
15
  llmSource,
16
+ resolveServiceVersion,
16
17
  materializeChannelCapabilities,
17
18
  ParallAgentGateway,
18
19
  parseDispatchDeadlineMs,
@@ -76,7 +77,11 @@ function resolveProviderEnv(): string {
76
77
  async function main() {
77
78
  // Before any fetch: long-lived HTTP connections for every bridge→api call.
78
79
  configureHttpKeepAlive();
79
- const telemetry = await initAgentTelemetry('parall-claude-agent', 'claude-code');
80
+ const telemetry = await initAgentTelemetry('parall-claude-agent', 'claude-code', {
81
+ apiUrl: process.env.PRLL_API_URL,
82
+ apiKey: process.env.PRLL_API_KEY,
83
+ serviceVersion: resolveServiceVersion(import.meta.url),
84
+ });
80
85
  activeLog = createOtelLogger('agent', 'claude-agent');
81
86
  try {
82
87
  const activeLLMSource = resolveProviderEnv();