@parall/claude-agent 1.59.0 → 1.61.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/dist/dispatch.js CHANGED
@@ -2,9 +2,13 @@ import { execSync, spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import * as fs from 'node:fs';
4
4
  import * as path from 'node:path';
5
+ import { isRuntimeBusy, projectRuntimeEvent, RuntimeActivityPort } from '@parall/agent-core';
5
6
  import { appendPreparedLocalAttachmentRefs, pinLocalAttachmentPaths, } from '@parall/agent-core/internal/attachment-input';
7
+ import { runClaudeCompact } from './compact.js';
8
+ import { aggregateBusyState, DEFAULT_FOLLOW_UP_HOLD_MS } from './busy-state.js';
6
9
  import { ClaudeInputRegistry } from './input-lifecycle.js';
7
- import { parseClaudeStreamJson, } from './output-parser.js';
10
+ import { parseClaudeStreamJson } from './output-parser.js';
11
+ import { ClaudeProcessPump } from './process-pump.js';
8
12
  import { buildSpawnEnv } from './spawn-env.js';
9
13
  import { classifyClaudeTurn } from './turn-outcome.js';
10
14
  export { buildSpawnEnv };
@@ -30,15 +34,19 @@ export class ClaudeCodeAdapter {
30
34
  opts;
31
35
  inputLifecycleMode = 'explicit';
32
36
  processes = new Map();
37
+ /** Retired by abortDispatch while the CLI still had its own work: busy until idle, then terminated. */
38
+ retiring = new Set();
33
39
  capabilityProbe;
34
40
  capabilityProbeHandle;
35
41
  shuttingDown = false;
42
+ activity;
36
43
  _model;
37
44
  _effortLevel;
38
45
  _contextWindow;
39
46
  _maxTokens;
40
47
  constructor(opts) {
41
48
  this.opts = opts;
49
+ this.activity = new RuntimeActivityPort('ClaudeCodeAdapter');
42
50
  this._model = opts.model;
43
51
  this._contextWindow = opts.contextWindow;
44
52
  this._maxTokens = opts.maxTokens;
@@ -77,6 +85,24 @@ export class ClaudeCodeAdapter {
77
85
  state.needsRestart = true;
78
86
  }
79
87
  }
88
+ // --- runtime-initiated work -------------------------------------------------
89
+ subscribeRuntimeActivity(handler) {
90
+ return this.activity.subscribe(handler);
91
+ }
92
+ busyState(now = this.now()) {
93
+ const states = [];
94
+ for (const state of this.processes.values()) {
95
+ if (state.done)
96
+ continue;
97
+ states.push(state.pump.busyState(now));
98
+ }
99
+ for (const state of this.retiring)
100
+ states.push(state.pump.busyState(now));
101
+ return aggregateBusyState(states);
102
+ }
103
+ isBusy(now = this.now()) {
104
+ return isRuntimeBusy(this.busyState(now), now);
105
+ }
80
106
  enqueueDuringDispatch(sessionKey, body, inputLifecycle) {
81
107
  // Exact identity is mandatory for soft steer. Without it the later
82
108
  // buffered dispatch could only guess which stdout boundary belonged to
@@ -88,7 +114,7 @@ export class ClaudeCodeAdapter {
88
114
  return false;
89
115
  // A throwaway process already proved the CLI capability before any
90
116
  // business process started. The real process revalidates its own init.
91
- if (!state.capabilities?.has('msg_lifecycle_v1'))
117
+ if (!state.pump.capabilities.has('msg_lifecycle_v1'))
92
118
  return false;
93
119
  const { proc } = state.handle;
94
120
  if (proc.exitCode !== null || proc.signalCode !== null || proc.stdin.destroyed)
@@ -113,17 +139,23 @@ export class ClaudeCodeAdapter {
113
139
  const state = this.processes.get(sessionKey);
114
140
  if (!state || state.done)
115
141
  return;
116
- for (const delivery of state.inputs.values()) {
117
- if (!delivery.terminal)
118
- void state.inputs.failBestEffort(delivery);
119
- }
142
+ state.pump.abortDeliveries('dispatch aborted');
120
143
  state.done = true;
121
- try {
122
- state.handle.proc.stdin.end();
123
- }
124
- catch {
125
- /* best-effort */
144
+ if (state.pump.hasOwnWork()) {
145
+ // The CLI has (or is about to start) a runtime-initiated turn: retire
146
+ // the process instead of ending stdin under it. Out of `processes` and
147
+ // the session manager so the next dispatch's fresh process does not
148
+ // SIGTERM it as replaced; terminated once the pump reports idle.
149
+ this.processes.delete(sessionKey);
150
+ this.opts.sessionManager.clearProcess(sessionKey, state.handle);
151
+ this.retiring.add(state);
152
+ state.pump.whenIdle(() => {
153
+ if (this.retiring.delete(state))
154
+ this.terminateHandle(state.handle);
155
+ });
156
+ return;
126
157
  }
158
+ this.endStdin(state.handle);
127
159
  }
128
160
  hasPendingInjections(sessionKey) {
129
161
  const state = this.processes.get(sessionKey);
@@ -146,11 +178,7 @@ export class ClaudeCodeAdapter {
146
178
  // waiting on. Unlike the consume path's tail, the discarded delivery may
147
179
  // still be RUNNING (non-terminal): killing the process now would fail a
148
180
  // live input, so the restart also waits for every injection to settle.
149
- if (state.needsRestart &&
150
- !state.inputs.hasPendingInjections() &&
151
- !state.inputs.hasUnsettledInjections()) {
152
- this.killProcess(sessionKey, state);
153
- }
181
+ this.maybeApplyRestart(sessionKey, state);
154
182
  }
155
183
  async *dispatch({ event, bodyForAgent, sessionKey, context, inputLifecycle, noteActivity, }) {
156
184
  const deliveryKey = inputLifecycle?.deliveryKey ?? event.dispatchEventId ?? event.messageId;
@@ -166,16 +194,12 @@ export class ClaudeCodeAdapter {
166
194
  injected.drained = true;
167
195
  context.log?.info?.(`consuming steer input ${injected.commandUuid}`);
168
196
  try {
169
- yield* this.consumeDelivery(sessionKey, existingState, injected, context.log, noteActivity);
197
+ yield* this.consumeDelivery(sessionKey, existingState, injected, noteActivity);
170
198
  }
171
199
  finally {
172
200
  existingState.inputs.remove(injected);
173
201
  }
174
- if (existingState.needsRestart &&
175
- !existingState.inputs.hasPendingInjections() &&
176
- !existingState.inputs.hasUnsettledInjections()) {
177
- this.killProcess(sessionKey, existingState);
178
- }
202
+ this.maybeApplyRestart(sessionKey, existingState);
179
203
  return;
180
204
  }
181
205
  }
@@ -231,6 +255,12 @@ export class ClaudeCodeAdapter {
231
255
  this.killProcess(sessionKey, state);
232
256
  }
233
257
  this.processes.clear();
258
+ const retired = [...this.retiring];
259
+ this.retiring.clear();
260
+ for (const state of retired) {
261
+ state.pump.close('killed', 'Claude process terminated by the bridge');
262
+ this.terminateHandle(state.handle);
263
+ }
234
264
  }
235
265
  async shutdown() {
236
266
  this.shuttingDown = true;
@@ -241,6 +271,9 @@ export class ClaudeCodeAdapter {
241
271
  this.resetProcesses();
242
272
  await this.opts.sessionManager.shutdownAll();
243
273
  }
274
+ now() {
275
+ return (this.opts.now ?? Date.now)();
276
+ }
244
277
  async *runTurn(sessionKey, promptBody, deliveryKey, lifecycle, log, noteActivity) {
245
278
  let state;
246
279
  try {
@@ -257,7 +290,9 @@ export class ClaudeCodeAdapter {
257
290
  yield { type: 'error', message: `Claude spawn failed: ${String(err)}` };
258
291
  return;
259
292
  }
260
- const delivery = state.inputs.register(deliveryKey, lifecycle, false);
293
+ // Registered BEFORE the stdin write: the pump may route this command's
294
+ // queued/started frames before the drain below starts.
295
+ const delivery = state.inputs.register(deliveryKey, lifecycle, false, noteActivity, log);
261
296
  try {
262
297
  this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
263
298
  }
@@ -269,12 +304,22 @@ export class ClaudeCodeAdapter {
269
304
  return;
270
305
  }
271
306
  try {
272
- yield* this.consumeDelivery(sessionKey, state, delivery, log, noteActivity);
307
+ yield* this.consumeDelivery(sessionKey, state, delivery, noteActivity);
273
308
  }
274
309
  finally {
275
310
  state.inputs.remove(delivery);
276
311
  }
277
312
  }
313
+ /** Idle auto-compact (compact.ts): a `/compact` frame into the long-lived process. */
314
+ compact(opts) {
315
+ return runClaudeCompact({
316
+ ensureRuntimeCapability: (log) => this.ensureRuntimeCapability(log),
317
+ ensureProcess: (sessionKey, log) => this.ensureProcess(sessionKey, log),
318
+ killProcess: (sessionKey, state) => this.killProcess(sessionKey, state),
319
+ writeUserMessage: (handle, text, uuid) => this.writeUserMessage(handle, text, uuid),
320
+ applyPendingRestart: (sessionKey, state) => this.maybeApplyRestart(sessionKey, state),
321
+ }, opts);
322
+ }
278
323
  ensureRuntimeCapability(log) {
279
324
  if (!this.capabilityProbe) {
280
325
  const probe = this.probeRuntimeCapability(log);
@@ -331,179 +376,80 @@ export class ClaudeCodeAdapter {
331
376
  }
332
377
  }
333
378
  /**
334
- * Drain the shared stdout stream until the exact UUID written for target
335
- * reaches a terminal lifecycle state. Other injected inputs may start and
336
- * finish while this drain is active; their callbacks advance independently
337
- * and their later bookkeeping dispatch becomes a no-op.
379
+ * Drain the envelopes the pump routed to `target` until its exact stdin
380
+ * UUID reaches a terminal lifecycle state. Other injected inputs may start
381
+ * and finish while this drain is active; their callbacks advance in the
382
+ * pump independently and their later bookkeeping dispatch becomes a no-op.
338
383
  */
339
- async *consumeDelivery(sessionKey, state, target, log, noteActivity) {
384
+ async *consumeDelivery(sessionKey, state, target, noteActivity) {
340
385
  if (target.terminal)
341
386
  return;
342
387
  const groupKey = randomUUID();
343
- let sawError = false;
344
- // Turn-outcome evidence for this dispatch (agent-turn-outcome-design
345
- // §4.1). Lifecycle terminals — not turn_end — bound consumption, so the
346
- // result frame for the turn that carried this delivery is the LAST
347
- // non-poison turn_end observed before the terminal. Poison frames
348
- // (num_turns: 0 startup artifacts) never overwrite evidence. At most one
349
- // turn_outcome is emitted per dispatch, right before the generator
350
- // returns; with no evidence and no crash, nothing is emitted and the
351
- // legacy boolean error path stands (refine-only, never guess).
352
- let lastResultMeta;
353
- const noticeTexts = [];
354
- // usage_limit settles via the lane-level deferred complete; a failed-input
355
- // report would race it with an immediate redrive into the choked LLM.
356
- const settledAsLimit = () => classifyClaudeTurn(lastResultMeta, noticeTexts).outcome === 'usage_limit';
357
- while (!target.terminal) {
358
- const next = await state.parser.next();
359
- if (next.done) {
360
- state.done = true;
361
- this.processes.delete(sessionKey);
362
- const detail = state.handle.stderrChunks.join('').trim();
363
- const exit = await state.handle.exitPromise.catch(() => ({ code: null, signal: null }));
364
- if (detail) {
365
- log?.warn?.(`subprocess stderr: ${detail}`);
366
- }
367
- if (settledAsLimit())
368
- target.suppressFailReport = true;
369
- await state.inputs.failBestEffort(target, log);
370
- if (!sawError) {
371
- yield {
372
- type: 'error',
373
- message: detail ||
374
- `Claude exited with code ${exit.code ?? 'unknown'}${exit.signal ? ` (${exit.signal})` : ''}`,
375
- };
376
- }
377
- // No evidence at all classifies as runtime_crash (the process died
378
- // before any result frame); with evidence, classify what we saw.
379
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
380
- return;
381
- }
382
- const parsed = next.value;
383
- // Count every parser-observed CLI progress frame as activity, including
384
- // command lifecycle, turn boundaries, and explicit activity markers
385
- // for nested subagent frames that must not become RuntimeEvents.
386
- noteActivity?.();
387
- if (parsed.type === 'runtime_activity')
388
- continue;
389
- if (parsed.type === 'runtime_init') {
390
- state.capabilities = new Set(parsed.capabilities);
391
- if (parsed.sessionId) {
392
- this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
393
- yield {
394
- type: 'runtime_session',
395
- runtimeSessionId: parsed.sessionId,
396
- runtimeLaneKey: sessionKey,
397
- };
398
- }
399
- if (!state.capabilities.has('msg_lifecycle_v1')) {
400
- await state.inputs.failBestEffort(target, log);
401
- yield {
402
- type: 'error',
403
- message: 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
404
- };
405
- this.killProcess(sessionKey, state);
406
- return;
407
- }
408
- continue;
409
- }
410
- if (parsed.type === 'command_lifecycle') {
411
- if (!state.capabilities?.has('msg_lifecycle_v1')) {
412
- await state.inputs.failBestEffort(target, log);
413
- yield {
414
- type: 'error',
415
- message: 'Claude emitted command lifecycle before advertising msg_lifecycle_v1',
416
- };
417
- this.killProcess(sessionKey, state);
388
+ state.pump.setActiveDrain(target, noteActivity);
389
+ try {
390
+ while (true) {
391
+ const next = await target.sink.next();
392
+ if (next.done)
418
393
  return;
419
- }
420
- const delivery = state.inputs.getByCommand(parsed.commandUuid);
421
- if (!delivery) {
422
- log?.warn?.(`ignoring lifecycle for unknown Claude command ${parsed.commandUuid}`);
394
+ const envelope = next.value;
395
+ if (envelope.kind === 'runtime') {
396
+ yield projectRuntimeEvent(envelope.event, groupKey);
423
397
  continue;
424
398
  }
425
- try {
426
- await state.inputs.apply(delivery, parsed.state);
427
- }
428
- catch (err) {
429
- await state.inputs.failBestEffort(delivery, log);
399
+ if (envelope.kind === 'terminal')
400
+ break;
401
+ // The process is gone (eof / killed), the dispatch was aborted, or
402
+ // the pump ended this owner's window: surface the error unless the
403
+ // stream already carried one, then classify what was observed (no
404
+ // evidence at all is a runtime_crash).
405
+ if (envelope.reason !== 'error_result' && !target.evidence.sawError) {
430
406
  yield {
431
407
  type: 'error',
432
- message: `Claude input lifecycle update failed: ${String(err)}`,
408
+ message: envelope.message ?? `Claude turn ${envelope.reason}`,
433
409
  };
434
- this.killProcess(sessionKey, state);
435
- return;
436
- }
437
- continue;
438
- }
439
- if (parsed.type === 'turn_end') {
440
- // Evidence capture: the poison startup frame (numTurns === 0) is a
441
- // resume artifact, not a turn boundary — never let it overwrite real
442
- // evidence.
443
- if (parsed.numTurns !== 0) {
444
- lastResultMeta = parsed.resultMeta;
445
- }
446
- if (parsed.isError) {
447
- const limitSettled = settledAsLimit();
448
- const failedDelivery = parsed.userMessageUuid
449
- ? state.inputs.getByCommand(parsed.userMessageUuid)
450
- : undefined;
451
- if (!failedDelivery) {
452
- if (limitSettled) {
453
- for (const delivery of state.inputs.values()) {
454
- delivery.suppressFailReport = true;
455
- }
456
- }
457
- await state.inputs.failAllBestEffort(log);
458
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
459
- this.killProcess(sessionKey, state);
460
- return;
461
- }
462
- failedDelivery.resultFailed = true;
463
- if (limitSettled)
464
- failedDelivery.suppressFailReport = true;
465
410
  }
466
- // result adjacency is not a consumption boundary: the matching
467
- // command_lifecycle(completed) may follow it or interleave with a
468
- // different queued/injected command.
469
- continue;
470
- }
471
- if (parsed.type === 'assistant_error') {
472
- noticeTexts.push(parsed.message);
473
- continue;
474
- }
475
- if (parsed.type === 'error') {
476
- sawError = true;
477
- yield parsed;
478
- continue;
411
+ yield classifyClaudeTurn(target.evidence.lastResultMeta, target.evidence.noticeTexts);
412
+ return;
479
413
  }
480
- if (parsed.type === 'text') {
481
- yield { ...parsed, project: false, groupKey };
482
- continue;
414
+ // Delivery reached a lifecycle terminal. Classify only when a result
415
+ // frame was observed in this owner's window: a delivery whose frames
416
+ // were drained by a sibling consumption window has no evidence here,
417
+ // and guessing would mislabel the turn (refine-only rule, §4.3).
418
+ if (target.evidence.lastResultMeta) {
419
+ yield classifyClaudeTurn(target.evidence.lastResultMeta, target.evidence.noticeTexts);
483
420
  }
484
- if (parsed.type === 'runtime_session') {
485
- yield parsed;
486
- continue;
487
- }
488
- if (parsed.type === 'turn_outcome') {
489
- // (never produced by the parser — type guard only)
490
- continue;
491
- }
492
- yield { ...parsed, groupKey };
493
- }
494
- // Delivery reached a lifecycle terminal. Classify only when a result
495
- // frame was observed during this drain: a delivery whose frames were
496
- // drained by a sibling consumption window has no evidence here, and
497
- // guessing would mislabel the turn (refine-only rule, §4.3).
498
- if (lastResultMeta) {
499
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
500
- }
501
- if (state.needsRestart &&
502
- !state.inputs.hasPendingInjections() &&
503
- !state.inputs.hasUnsettledInjections()) {
504
- this.killProcess(sessionKey, state);
421
+ }
422
+ finally {
423
+ state.pump.clearActiveDrain(target);
424
+ this.maybeApplyRestart(sessionKey, state);
505
425
  }
506
426
  }
427
+ /**
428
+ * Lazy restart: kill the process so the next dispatch respawns it (the
429
+ * session survives via --resume). Waits for every injection to settle, for
430
+ * an open runtime-initiated turn and for a follow-up hold — but NOT for
431
+ * outstanding background tasks (a `make dev` would defer a config change
432
+ * forever); those die with the process, logged.
433
+ */
434
+ maybeApplyRestart(sessionKey, state) {
435
+ if (!state.needsRestart || state.done)
436
+ return;
437
+ if (state.inputs.hasPendingInjections() || state.inputs.hasUnsettledInjections())
438
+ return;
439
+ // A dispatch queued behind a runtime-initiated turn is registered but
440
+ // not started yet; killing now would fail it before the CLI runs it.
441
+ for (const delivery of state.inputs.values()) {
442
+ if (!delivery.terminal)
443
+ return;
444
+ }
445
+ if (state.pump.hasOwnWork())
446
+ return;
447
+ const outstanding = state.pump.busy.outstanding();
448
+ if (outstanding.total > 0) {
449
+ state.log?.warn?.(`restarting ${sessionKey} with ${outstanding.total} live background task(s); they die with the process`);
450
+ }
451
+ this.killProcess(sessionKey, state);
452
+ }
507
453
  ensureProcess(sessionKey, log) {
508
454
  if (this.shuttingDown) {
509
455
  throw new Error('adapter shutting down, refusing new process');
@@ -511,10 +457,15 @@ export class ClaudeCodeAdapter {
511
457
  const existing = this.processes.get(sessionKey);
512
458
  if (existing && !existing.done) {
513
459
  const { proc } = existing.handle;
514
- if (existing.needsRestart) {
460
+ const alive = proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed;
461
+ if (existing.needsRestart && alive) {
462
+ if (existing.pump.hasOwnWork()) {
463
+ log?.info?.(`deferring lazy restart of ${sessionKey}: runtime-initiated turn in progress`);
464
+ return existing;
465
+ }
515
466
  this.killProcess(sessionKey, existing);
516
467
  }
517
- else if (proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed) {
468
+ else if (alive) {
518
469
  return existing;
519
470
  }
520
471
  else {
@@ -523,19 +474,52 @@ export class ClaudeCodeAdapter {
523
474
  }
524
475
  const handle = this.spawnProcess(sessionKey, log);
525
476
  const parser = parseClaudeStreamJson(handle.proc.stdout);
477
+ const inputs = new ClaudeInputRegistry();
526
478
  const state = {
527
479
  handle,
528
- parser,
480
+ pump: new ClaudeProcessPump({
481
+ sessionKey,
482
+ parser,
483
+ handle,
484
+ inputs,
485
+ log,
486
+ followUpHoldMs: this.opts.followUpHoldMs ?? DEFAULT_FOLLOW_UP_HOLD_MS,
487
+ now: () => this.now(),
488
+ hooks: {
489
+ onRuntimeInit: (init) => {
490
+ if (init.sessionId) {
491
+ this.opts.sessionManager.recordSessionId(sessionKey, init.sessionId);
492
+ }
493
+ if (!init.capabilities.includes('msg_lifecycle_v1')) {
494
+ return 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage';
495
+ }
496
+ return undefined;
497
+ },
498
+ onFatal: () => {
499
+ const current = this.processes.get(sessionKey);
500
+ if (current === state)
501
+ this.killProcess(sessionKey, state);
502
+ },
503
+ onEof: () => {
504
+ state.done = true;
505
+ if (this.processes.get(sessionKey) === state)
506
+ this.processes.delete(sessionKey);
507
+ this.retiring.delete(state);
508
+ },
509
+ // Main-session turns go to the gateway (steps + session activity);
510
+ // fork-session turns only count as busy (fork-scope invariant).
511
+ onRuntimeTurnOpened: (turn) => this.activity.surfaceTurn(turn, this.opts.sessionManager.isMain(sessionKey), log),
512
+ onRuntimeTurnClosed: () => this.maybeApplyRestart(sessionKey, state),
513
+ },
514
+ }),
529
515
  done: false,
530
516
  needsRestart: false,
531
- // A throwaway process already proved the current CLI advertises this
532
- // capability. Real 2.1.220 sends queued/started before its own init,
533
- // so pre-seed the gate and still verify the real init when it arrives.
534
- capabilities: new Set(['msg_lifecycle_v1']),
535
- inputs: new ClaudeInputRegistry(),
517
+ inputs,
518
+ log,
536
519
  };
537
520
  this.processes.set(sessionKey, state);
538
521
  this.opts.sessionManager.registerProcess(sessionKey, handle);
522
+ state.pump.start();
539
523
  return state;
540
524
  }
541
525
  spawnProcess(sessionKey, log, resume = true) {
@@ -586,15 +570,19 @@ export class ClaudeCodeAdapter {
586
570
  if (current === state) {
587
571
  this.processes.delete(sessionKey);
588
572
  }
573
+ state.pump.close('killed', 'Claude process terminated by the bridge');
589
574
  this.terminateHandle(state.handle);
590
575
  }
591
- terminateHandle(handle) {
576
+ endStdin(handle) {
592
577
  try {
593
578
  handle.proc.stdin.end();
594
579
  }
595
580
  catch {
596
581
  /* best-effort */
597
582
  }
583
+ }
584
+ terminateHandle(handle) {
585
+ this.endStdin(handle);
598
586
  if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
599
587
  try {
600
588
  if (!IS_WIN32 || !handle.proc.pid || !killWin32Tree(handle.proc.pid)) {
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import * as os from 'node:os';
3
- import { capabilityBinDir, childLogger, clearAllProviderCreds, configureHttpKeepAlive, createLogger, createOtelLogger, createPlatformConfigManager, deriveModelIsPin, identityFromMe, initAgentTelemetry, llmSource, materializeChannelCapabilities, ParallAgentGateway, parseDispatchDeadlineMs, parseForkDeadlineMs, parseProviderConfig, parseShutdownDeadlineMs, resolveRuntimeContextWindow, resolveRuntimeMaxTokens, resolveRuntimeModel, } from '@parall/agent-core';
3
+ import { capabilityBinDir, childLogger, clearAllProviderCreds, configureHttpKeepAlive, createLogger, createOtelLogger, createPlatformConfigManager, deriveModelIsPin, identityFromMe, initAgentTelemetry, llmSource, resolveServiceVersion, materializeChannelCapabilities, ParallAgentGateway, parseDispatchDeadlineMs, parseForkDeadlineMs, parseProviderConfig, parseShutdownDeadlineMs, resolveRuntimeContextWindow, resolveRuntimeMaxTokens, resolveRuntimeModel, } from '@parall/agent-core';
4
4
  import { ApiError, ParallClient, ParallWs } from '@parall/sdk';
5
5
  import { buildClaudeRuntimeKey, contextFilePathForSession, dispatchContextDirPath, resolveClaudeAgentConfig, resolveWsUrl, sessionStateFilePathForRuntime, stepIdFilePathForSession, } from './config.js';
6
6
  import { ClaudeCodeAdapter } from './dispatch.js';
@@ -44,7 +44,11 @@ function resolveProviderEnv() {
44
44
  async function main() {
45
45
  // Before any fetch: long-lived HTTP connections for every bridge→api call.
46
46
  configureHttpKeepAlive();
47
- const telemetry = await initAgentTelemetry('parall-claude-agent', 'claude-code');
47
+ const telemetry = await initAgentTelemetry('parall-claude-agent', 'claude-code', {
48
+ apiUrl: process.env.PRLL_API_URL,
49
+ apiKey: process.env.PRLL_API_KEY,
50
+ serviceVersion: resolveServiceVersion(import.meta.url),
51
+ });
48
52
  activeLog = createOtelLogger('agent', 'claude-agent');
49
53
  try {
50
54
  const activeLLMSource = resolveProviderEnv();
@@ -1,4 +1,5 @@
1
1
  import type { DispatchInputLifecycle, GatewayLogger, RuntimeInputState } from '@parall/agent-core';
2
+ import { type ClaudeTurnSink, type TurnEvidence } from './turn-sink.js';
2
3
  export type ClaudeInputDelivery = {
3
4
  deliveryKey: string;
4
5
  commandUuid: string;
@@ -8,6 +9,14 @@ export type ClaudeInputDelivery = {
8
9
  terminal?: 'completed' | 'failed' | 'settled';
9
10
  reportedState?: RuntimeInputState;
10
11
  resultFailed: boolean;
12
+ /** Envelopes the stdout pump routed to this delivery; drained by its dispatch. */
13
+ sink: ClaudeTurnSink;
14
+ /** Turn-outcome evidence gathered while this delivery owned stdout. */
15
+ evidence: TurnEvidence;
16
+ /** Dispatch inactivity signal of the dispatch draining (or about to drain) this delivery. */
17
+ noteActivity?: () => void;
18
+ /** runtime_session already pushed to this delivery's sink. */
19
+ sessionAnnounced: boolean;
11
20
  /**
12
21
  * The turn settled as usage_limit: the lane-level deferred complete will
13
22
  * re-deliver this input at retry_at without burning redrive budget. A
@@ -41,7 +50,7 @@ export declare class ClaudeInputRegistry {
41
50
  * would release the member for redrive mid-processing.
42
51
  */
43
52
  hasUnsettledInjections(): boolean;
44
- register(deliveryKey: string, lifecycle: DispatchInputLifecycle | undefined, injected: boolean): ClaudeInputDelivery;
53
+ register(deliveryKey: string, lifecycle: DispatchInputLifecycle | undefined, injected: boolean, noteActivity?: () => void, log?: GatewayLogger): ClaudeInputDelivery;
45
54
  remove(delivery: ClaudeInputDelivery): void;
46
55
  /**
47
56
  * The buffered copy backing this delivery was discarded without a
@@ -1 +1 @@
1
- {"version":3,"file":"input-lifecycle.d.ts","sourceRoot":"","sources":["../src/input-lifecycle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEnG,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC9C,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,YAAY,EAAE,OAAO,CAAC;IACtB;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF;;;;GAIG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0C;IAChE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0C;IAEpE,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAI9D,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAIlE,eAAe,CAAC,gBAAgB,EAAE,MAAM,EAAE,GAAG,mBAAmB,GAAG,SAAS;IAU5E,MAAM,IAAI,gBAAgB,CAAC,mBAAmB,CAAC;IAI/C,oBAAoB,IAAI,OAAO;IAI/B;;;;;;;;;OASG;IACH,sBAAsB,IAAI,OAAO;IAIjC,QAAQ,CACN,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,sBAAsB,GAAG,SAAS,EAC7C,QAAQ,EAAE,OAAO,GAChB,mBAAmB;IAiBtB,MAAM,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI;IAS3C;;;;;;;;OAQG;IACH,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAOvC,KAAK,CACT,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACpE,OAAO,CAAC,IAAI,CAAC;IA0BV,IAAI,CAAC,QAAQ,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBlD,cAAc,CAAC,QAAQ,EAAE,mBAAmB,EAAE,GAAG,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAUjF,iBAAiB,CAAC,GAAG,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;CAO5D"}
1
+ {"version":3,"file":"input-lifecycle.d.ts","sourceRoot":"","sources":["../src/input-lifecycle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,aAAa,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AACnG,OAAO,EACL,KAAK,cAAc,EAGnB,KAAK,YAAY,EAClB,MAAM,gBAAgB,CAAC;AAExB,MAAM,MAAM,mBAAmB,GAAG;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,sBAAsB,CAAC;IACnC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,QAAQ,CAAC,EAAE,WAAW,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC9C,aAAa,CAAC,EAAE,iBAAiB,CAAC;IAClC,YAAY,EAAE,OAAO,CAAC;IACtB,kFAAkF;IAClF,IAAI,EAAE,cAAc,CAAC;IACrB,uEAAuE;IACvE,QAAQ,EAAE,YAAY,CAAC;IACvB,6FAA6F;IAC7F,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,8DAA8D;IAC9D,gBAAgB,EAAE,OAAO,CAAC;IAC1B;;;;;;OAMG;IACH,kBAAkB,CAAC,EAAE,OAAO,CAAC;CAC9B,CAAC;AAEF;;;;GAIG;AACH,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,KAAK,CAA0C;IAChE,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA0C;IAEpE,QAAQ,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAI9D,YAAY,CAAC,WAAW,EAAE,MAAM,GAAG,mBAAmB,GAAG,SAAS;IAIlE,eAAe,CAAC,gBAAgB,EAAE,MAAM,EAAE,GAAG,mBAAmB,GAAG,SAAS;IAU5E,MAAM,IAAI,gBAAgB,CAAC,mBAAmB,CAAC;IAI/C,oBAAoB,IAAI,OAAO;IAI/B;;;;;;;;;OASG;IACH,sBAAsB,IAAI,OAAO;IAIjC,QAAQ,CACN,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,sBAAsB,GAAG,SAAS,EAC7C,QAAQ,EAAE,OAAO,EACjB,YAAY,CAAC,EAAE,MAAM,IAAI,EACzB,GAAG,CAAC,EAAE,aAAa,GAClB,mBAAmB;IAsBtB,MAAM,CAAC,QAAQ,EAAE,mBAAmB,GAAG,IAAI;IAY3C;;;;;;;;OAQG;IACH,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,IAAI;IAOvC,KAAK,CACT,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACpE,OAAO,CAAC,IAAI,CAAC;IA0BV,IAAI,CAAC,QAAQ,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBlD,cAAc,CAAC,QAAQ,EAAE,mBAAmB,EAAE,GAAG,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;IAUjF,iBAAiB,CAAC,GAAG,CAAC,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC;CAO5D"}
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { newTurnEvidence, newTurnSink, } from './turn-sink.js';
2
3
  /**
3
4
  * Per-process registry that maps Parall WorkItem batches to Claude stdin
4
5
  * UUIDs. It owns lifecycle transition rules; process/stdout orchestration
@@ -41,17 +42,22 @@ export class ClaudeInputRegistry {
41
42
  hasUnsettledInjections() {
42
43
  return [...this.byKey.values()].some((delivery) => delivery.injected && !delivery.terminal);
43
44
  }
44
- register(deliveryKey, lifecycle, injected) {
45
+ register(deliveryKey, lifecycle, injected, noteActivity, log) {
45
46
  if (this.byKey.has(deliveryKey)) {
46
47
  throw new Error(`duplicate Claude delivery key ${deliveryKey}`);
47
48
  }
49
+ const commandUuid = randomUUID();
48
50
  const delivery = {
49
51
  deliveryKey,
50
- commandUuid: randomUUID(),
52
+ commandUuid,
51
53
  lifecycle,
52
54
  injected,
53
55
  drained: !injected,
54
56
  resultFailed: false,
57
+ sink: newTurnSink(`delivery ${deliveryKey}`, log),
58
+ evidence: newTurnEvidence(),
59
+ noteActivity,
60
+ sessionAnnounced: false,
55
61
  };
56
62
  this.byKey.set(deliveryKey, delivery);
57
63
  this.byCommand.set(delivery.commandUuid, delivery);
@@ -64,6 +70,9 @@ export class ClaudeInputRegistry {
64
70
  if (this.byCommand.get(delivery.commandUuid) === delivery) {
65
71
  this.byCommand.delete(delivery.commandUuid);
66
72
  }
73
+ // The sink stays open: apply() removes a drained delivery at its
74
+ // terminal BEFORE the pump pushes the terminal envelope its drain is
75
+ // waiting for. Only the pump closes sinks (process gone).
67
76
  }
68
77
  /**
69
78
  * The buffered copy backing this delivery was discarded without a