@parall/claude-agent 1.58.2 → 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 {
@@ -212,6 +258,23 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
212
258
  return state.inputs.hasPendingInjections();
213
259
  }
214
260
 
261
+ hasUnsettledInjections(sessionKey: string): boolean {
262
+ const state = this.processes.get(sessionKey);
263
+ if (!state) return false;
264
+ return state.inputs.hasUnsettledInjections();
265
+ }
266
+
267
+ acknowledgeDiscardedInjection(sessionKey: string, deliveryKey: string): void {
268
+ const state = this.processes.get(sessionKey);
269
+ if (!state) return;
270
+ state.inputs.discardBookkeeping(deliveryKey);
271
+ // The discarded copy may have been the last thing a lazy restart was
272
+ // waiting on. Unlike the consume path's tail, the discarded delivery may
273
+ // still be RUNNING (non-terminal): killing the process now would fail a
274
+ // live input, so the restart also waits for every injection to settle.
275
+ this.maybeApplyRestart(sessionKey, state);
276
+ }
277
+
215
278
  async *dispatch({
216
279
  event,
217
280
  bodyForAgent,
@@ -232,19 +295,11 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
232
295
  injected.drained = true;
233
296
  context.log?.info?.(`consuming steer input ${injected.commandUuid}`);
234
297
  try {
235
- yield* this.consumeDelivery(
236
- sessionKey,
237
- existingState,
238
- injected,
239
- context.log,
240
- noteActivity,
241
- );
298
+ yield* this.consumeDelivery(sessionKey, existingState, injected, noteActivity);
242
299
  } finally {
243
300
  existingState.inputs.remove(injected);
244
301
  }
245
- if (existingState.needsRestart && !existingState.inputs.hasPendingInjections()) {
246
- this.killProcess(sessionKey, existingState);
247
- }
302
+ this.maybeApplyRestart(sessionKey, existingState);
248
303
  return;
249
304
  }
250
305
  }
@@ -319,6 +374,12 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
319
374
  this.killProcess(sessionKey, state);
320
375
  }
321
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
+ }
322
383
  }
323
384
 
324
385
  async shutdown(): Promise<void> {
@@ -331,6 +392,10 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
331
392
  await this.opts.sessionManager.shutdownAll();
332
393
  }
333
394
 
395
+ private now(): number {
396
+ return (this.opts.now ?? Date.now)();
397
+ }
398
+
334
399
  private async *runTurn(
335
400
  sessionKey: string,
336
401
  promptBody: string,
@@ -352,7 +417,9 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
352
417
  yield { type: 'error', message: `Claude spawn failed: ${String(err)}` };
353
418
  return;
354
419
  }
355
- 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);
356
423
  try {
357
424
  this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
358
425
  } catch (err) {
@@ -364,12 +431,26 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
364
431
  }
365
432
 
366
433
  try {
367
- yield* this.consumeDelivery(sessionKey, state, delivery, log, noteActivity);
434
+ yield* this.consumeDelivery(sessionKey, state, delivery, noteActivity);
368
435
  } finally {
369
436
  state.inputs.remove(delivery);
370
437
  }
371
438
  }
372
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
+
373
454
  private ensureRuntimeCapability(log: GatewayLogger | undefined): Promise<void> {
374
455
  if (!this.capabilityProbe) {
375
456
  const probe = this.probeRuntimeCapability(log);
@@ -430,197 +511,80 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
430
511
  }
431
512
 
432
513
  /**
433
- * Drain the shared stdout stream until the exact UUID written for target
434
- * reaches a terminal lifecycle state. Other injected inputs may start and
435
- * finish while this drain is active; their callbacks advance independently
436
- * 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.
437
518
  */
438
519
  private async *consumeDelivery(
439
520
  sessionKey: string,
440
521
  state: ProcessState,
441
522
  target: ClaudeInputDelivery,
442
- log: GatewayLogger | undefined,
443
523
  noteActivity: (() => void) | undefined,
444
524
  ): AsyncGenerator<RuntimeEvent> {
445
525
  if (target.terminal) return;
446
526
  const groupKey = randomUUID();
447
- let sawError = false;
448
- // Turn-outcome evidence for this dispatch (agent-turn-outcome-design
449
- // §4.1). Lifecycle terminals — not turn_end — bound consumption, so the
450
- // result frame for the turn that carried this delivery is the LAST
451
- // non-poison turn_end observed before the terminal. Poison frames
452
- // (num_turns: 0 startup artifacts) never overwrite evidence. At most one
453
- // turn_outcome is emitted per dispatch, right before the generator
454
- // returns; with no evidence and no crash, nothing is emitted and the
455
- // legacy boolean error path stands (refine-only, never guess).
456
- let lastResultMeta: ClaudeResultMeta | undefined;
457
- const noticeTexts: string[] = [];
458
- // usage_limit settles via the lane-level deferred complete; a failed-input
459
- // report would race it with an immediate redrive into the choked LLM.
460
- const settledAsLimit = () =>
461
- classifyClaudeTurn(lastResultMeta, noticeTexts).outcome === 'usage_limit';
462
-
463
- while (!target.terminal) {
464
- const next = await state.parser.next();
465
-
466
- if (next.done) {
467
- state.done = true;
468
- this.processes.delete(sessionKey);
469
- const detail = state.handle.stderrChunks.join('').trim();
470
- const exit = await state.handle.exitPromise.catch(
471
- () => ({ code: null, signal: null }) as const,
472
- );
473
- if (detail) {
474
- log?.warn?.(`subprocess stderr: ${detail}`);
475
- }
476
- if (settledAsLimit()) target.suppressFailReport = true;
477
- await state.inputs.failBestEffort(target, log);
478
- if (!sawError) {
479
- yield {
480
- type: 'error',
481
- message:
482
- detail ||
483
- `Claude exited with code ${exit.code ?? 'unknown'}${exit.signal ? ` (${exit.signal})` : ''}`,
484
- };
485
- }
486
- // No evidence at all classifies as runtime_crash (the process died
487
- // before any result frame); with evidence, classify what we saw.
488
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
489
- return;
490
- }
491
-
492
- const parsed = next.value;
493
- // Count every parser-observed CLI progress frame as activity, including
494
- // command lifecycle, turn boundaries, and explicit activity markers
495
- // for nested subagent frames that must not become RuntimeEvents.
496
- noteActivity?.();
497
-
498
- if (parsed.type === 'runtime_activity') continue;
499
-
500
- if (parsed.type === 'runtime_init') {
501
- state.capabilities = new Set(parsed.capabilities);
502
- if (parsed.sessionId) {
503
- this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
504
- yield {
505
- type: 'runtime_session',
506
- runtimeSessionId: parsed.sessionId,
507
- runtimeLaneKey: sessionKey,
508
- };
509
- }
510
- if (!state.capabilities.has('msg_lifecycle_v1')) {
511
- await state.inputs.failBestEffort(target, log);
512
- yield {
513
- type: 'error',
514
- message:
515
- 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
516
- };
517
- this.killProcess(sessionKey, state);
518
- return;
519
- }
520
- continue;
521
- }
522
-
523
- if (parsed.type === 'command_lifecycle') {
524
- if (!state.capabilities?.has('msg_lifecycle_v1')) {
525
- await state.inputs.failBestEffort(target, log);
526
- yield {
527
- type: 'error',
528
- message: 'Claude emitted command lifecycle before advertising msg_lifecycle_v1',
529
- };
530
- this.killProcess(sessionKey, state);
531
- return;
532
- }
533
- const delivery = state.inputs.getByCommand(parsed.commandUuid);
534
- if (!delivery) {
535
- 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);
536
535
  continue;
537
536
  }
538
- try {
539
- await state.inputs.apply(delivery, parsed.state);
540
- } catch (err) {
541
- 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) {
542
543
  yield {
543
544
  type: 'error',
544
- message: `Claude input lifecycle update failed: ${String(err)}`,
545
+ message: envelope.message ?? `Claude turn ${envelope.reason}`,
545
546
  };
546
- this.killProcess(sessionKey, state);
547
- return;
548
- }
549
- continue;
550
- }
551
-
552
- if (parsed.type === 'turn_end') {
553
- // Evidence capture: the poison startup frame (numTurns === 0) is a
554
- // resume artifact, not a turn boundary — never let it overwrite real
555
- // evidence.
556
- if (parsed.numTurns !== 0) {
557
- lastResultMeta = parsed.resultMeta;
558
547
  }
559
- if (parsed.isError) {
560
- const limitSettled = settledAsLimit();
561
- const failedDelivery = parsed.userMessageUuid
562
- ? state.inputs.getByCommand(parsed.userMessageUuid)
563
- : undefined;
564
- if (!failedDelivery) {
565
- if (limitSettled) {
566
- for (const delivery of state.inputs.values()) {
567
- delivery.suppressFailReport = true;
568
- }
569
- }
570
- await state.inputs.failAllBestEffort(log);
571
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
572
- this.killProcess(sessionKey, state);
573
- return;
574
- }
575
- failedDelivery.resultFailed = true;
576
- if (limitSettled) failedDelivery.suppressFailReport = true;
577
- }
578
- // result adjacency is not a consumption boundary: the matching
579
- // command_lifecycle(completed) may follow it or interleave with a
580
- // different queued/injected command.
581
- continue;
582
- }
583
-
584
- if (parsed.type === 'assistant_error') {
585
- noticeTexts.push(parsed.message);
586
- continue;
587
- }
588
-
589
- if (parsed.type === 'error') {
590
- sawError = true;
591
- yield parsed;
592
- continue;
593
- }
594
-
595
- if (parsed.type === 'text') {
596
- yield { ...parsed, project: false, groupKey };
597
- continue;
598
- }
599
-
600
- if (parsed.type === 'runtime_session') {
601
- yield parsed;
602
- continue;
548
+ yield classifyClaudeTurn(target.evidence.lastResultMeta, target.evidence.noticeTexts);
549
+ return;
603
550
  }
604
551
 
605
- if (parsed.type === 'turn_outcome') {
606
- // (never produced by the parser type guard only)
607
- 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);
608
558
  }
609
-
610
- yield { ...parsed, groupKey };
559
+ } finally {
560
+ state.pump.clearActiveDrain(target);
561
+ this.maybeApplyRestart(sessionKey, state);
611
562
  }
563
+ }
612
564
 
613
- // Delivery reached a lifecycle terminal. Classify only when a result
614
- // frame was observed during this drain: a delivery whose frames were
615
- // drained by a sibling consumption window has no evidence here, and
616
- // guessing would mislabel the turn (refine-only rule, §4.3).
617
- if (lastResultMeta) {
618
- 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;
619
579
  }
620
-
621
- if (state.needsRestart && !state.inputs.hasPendingInjections()) {
622
- 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
+ );
623
586
  }
587
+ this.killProcess(sessionKey, state);
624
588
  }
625
589
 
626
590
  private ensureProcess(sessionKey: string, log: GatewayLogger | undefined): ProcessState {
@@ -631,9 +595,16 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
631
595
  const existing = this.processes.get(sessionKey);
632
596
  if (existing && !existing.done) {
633
597
  const { proc } = existing.handle;
634
- 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
+ }
635
606
  this.killProcess(sessionKey, existing);
636
- } else if (proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed) {
607
+ } else if (alive) {
637
608
  return existing;
638
609
  } else {
639
610
  this.processes.delete(sessionKey);
@@ -642,19 +613,51 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
642
613
 
643
614
  const handle = this.spawnProcess(sessionKey, log);
644
615
  const parser = parseClaudeStreamJson(handle.proc.stdout!);
616
+ const inputs = new ClaudeInputRegistry();
645
617
  const state: ProcessState = {
646
618
  handle,
647
- 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
+ }),
648
653
  done: false,
649
654
  needsRestart: false,
650
- // A throwaway process already proved the current CLI advertises this
651
- // capability. Real 2.1.220 sends queued/started before its own init,
652
- // so pre-seed the gate and still verify the real init when it arrives.
653
- capabilities: new Set(['msg_lifecycle_v1']),
654
- inputs: new ClaudeInputRegistry(),
655
+ inputs,
656
+ log,
655
657
  };
656
658
  this.processes.set(sessionKey, state);
657
659
  this.opts.sessionManager.registerProcess(sessionKey, handle);
660
+ state.pump.start();
658
661
  return state;
659
662
  }
660
663
 
@@ -729,15 +732,20 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
729
732
  if (current === state) {
730
733
  this.processes.delete(sessionKey);
731
734
  }
735
+ state.pump.close('killed', 'Claude process terminated by the bridge');
732
736
  this.terminateHandle(state.handle);
733
737
  }
734
738
 
735
- private terminateHandle(handle: ClaudeProcessHandle): void {
739
+ private endStdin(handle: ClaudeProcessHandle): void {
736
740
  try {
737
741
  handle.proc.stdin.end();
738
742
  } catch {
739
743
  /* best-effort */
740
744
  }
745
+ }
746
+
747
+ private terminateHandle(handle: ClaudeProcessHandle): void {
748
+ this.endStdin(handle);
741
749
  if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
742
750
  try {
743
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();