@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/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);
@@ -131,6 +163,23 @@ export class ClaudeCodeAdapter {
131
163
  return false;
132
164
  return state.inputs.hasPendingInjections();
133
165
  }
166
+ hasUnsettledInjections(sessionKey) {
167
+ const state = this.processes.get(sessionKey);
168
+ if (!state)
169
+ return false;
170
+ return state.inputs.hasUnsettledInjections();
171
+ }
172
+ acknowledgeDiscardedInjection(sessionKey, deliveryKey) {
173
+ const state = this.processes.get(sessionKey);
174
+ if (!state)
175
+ return;
176
+ state.inputs.discardBookkeeping(deliveryKey);
177
+ // The discarded copy may have been the last thing a lazy restart was
178
+ // waiting on. Unlike the consume path's tail, the discarded delivery may
179
+ // still be RUNNING (non-terminal): killing the process now would fail a
180
+ // live input, so the restart also waits for every injection to settle.
181
+ this.maybeApplyRestart(sessionKey, state);
182
+ }
134
183
  async *dispatch({ event, bodyForAgent, sessionKey, context, inputLifecycle, noteActivity, }) {
135
184
  const deliveryKey = inputLifecycle?.deliveryKey ?? event.dispatchEventId ?? event.messageId;
136
185
  const existingState = this.processes.get(sessionKey);
@@ -145,14 +194,12 @@ export class ClaudeCodeAdapter {
145
194
  injected.drained = true;
146
195
  context.log?.info?.(`consuming steer input ${injected.commandUuid}`);
147
196
  try {
148
- yield* this.consumeDelivery(sessionKey, existingState, injected, context.log, noteActivity);
197
+ yield* this.consumeDelivery(sessionKey, existingState, injected, noteActivity);
149
198
  }
150
199
  finally {
151
200
  existingState.inputs.remove(injected);
152
201
  }
153
- if (existingState.needsRestart && !existingState.inputs.hasPendingInjections()) {
154
- this.killProcess(sessionKey, existingState);
155
- }
202
+ this.maybeApplyRestart(sessionKey, existingState);
156
203
  return;
157
204
  }
158
205
  }
@@ -208,6 +255,12 @@ export class ClaudeCodeAdapter {
208
255
  this.killProcess(sessionKey, state);
209
256
  }
210
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
+ }
211
264
  }
212
265
  async shutdown() {
213
266
  this.shuttingDown = true;
@@ -218,6 +271,9 @@ export class ClaudeCodeAdapter {
218
271
  this.resetProcesses();
219
272
  await this.opts.sessionManager.shutdownAll();
220
273
  }
274
+ now() {
275
+ return (this.opts.now ?? Date.now)();
276
+ }
221
277
  async *runTurn(sessionKey, promptBody, deliveryKey, lifecycle, log, noteActivity) {
222
278
  let state;
223
279
  try {
@@ -234,7 +290,9 @@ export class ClaudeCodeAdapter {
234
290
  yield { type: 'error', message: `Claude spawn failed: ${String(err)}` };
235
291
  return;
236
292
  }
237
- 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);
238
296
  try {
239
297
  this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
240
298
  }
@@ -246,12 +304,22 @@ export class ClaudeCodeAdapter {
246
304
  return;
247
305
  }
248
306
  try {
249
- yield* this.consumeDelivery(sessionKey, state, delivery, log, noteActivity);
307
+ yield* this.consumeDelivery(sessionKey, state, delivery, noteActivity);
250
308
  }
251
309
  finally {
252
310
  state.inputs.remove(delivery);
253
311
  }
254
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
+ }
255
323
  ensureRuntimeCapability(log) {
256
324
  if (!this.capabilityProbe) {
257
325
  const probe = this.probeRuntimeCapability(log);
@@ -308,176 +376,79 @@ export class ClaudeCodeAdapter {
308
376
  }
309
377
  }
310
378
  /**
311
- * Drain the shared stdout stream until the exact UUID written for target
312
- * reaches a terminal lifecycle state. Other injected inputs may start and
313
- * finish while this drain is active; their callbacks advance independently
314
- * 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.
315
383
  */
316
- async *consumeDelivery(sessionKey, state, target, log, noteActivity) {
384
+ async *consumeDelivery(sessionKey, state, target, noteActivity) {
317
385
  if (target.terminal)
318
386
  return;
319
387
  const groupKey = randomUUID();
320
- let sawError = false;
321
- // Turn-outcome evidence for this dispatch (agent-turn-outcome-design
322
- // §4.1). Lifecycle terminals — not turn_end — bound consumption, so the
323
- // result frame for the turn that carried this delivery is the LAST
324
- // non-poison turn_end observed before the terminal. Poison frames
325
- // (num_turns: 0 startup artifacts) never overwrite evidence. At most one
326
- // turn_outcome is emitted per dispatch, right before the generator
327
- // returns; with no evidence and no crash, nothing is emitted and the
328
- // legacy boolean error path stands (refine-only, never guess).
329
- let lastResultMeta;
330
- const noticeTexts = [];
331
- // usage_limit settles via the lane-level deferred complete; a failed-input
332
- // report would race it with an immediate redrive into the choked LLM.
333
- const settledAsLimit = () => classifyClaudeTurn(lastResultMeta, noticeTexts).outcome === 'usage_limit';
334
- while (!target.terminal) {
335
- const next = await state.parser.next();
336
- if (next.done) {
337
- state.done = true;
338
- this.processes.delete(sessionKey);
339
- const detail = state.handle.stderrChunks.join('').trim();
340
- const exit = await state.handle.exitPromise.catch(() => ({ code: null, signal: null }));
341
- if (detail) {
342
- log?.warn?.(`subprocess stderr: ${detail}`);
343
- }
344
- if (settledAsLimit())
345
- target.suppressFailReport = true;
346
- await state.inputs.failBestEffort(target, log);
347
- if (!sawError) {
348
- yield {
349
- type: 'error',
350
- message: detail ||
351
- `Claude exited with code ${exit.code ?? 'unknown'}${exit.signal ? ` (${exit.signal})` : ''}`,
352
- };
353
- }
354
- // No evidence at all classifies as runtime_crash (the process died
355
- // before any result frame); with evidence, classify what we saw.
356
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
357
- return;
358
- }
359
- const parsed = next.value;
360
- // Count every parser-observed CLI progress frame as activity, including
361
- // command lifecycle, turn boundaries, and explicit activity markers
362
- // for nested subagent frames that must not become RuntimeEvents.
363
- noteActivity?.();
364
- if (parsed.type === 'runtime_activity')
365
- continue;
366
- if (parsed.type === 'runtime_init') {
367
- state.capabilities = new Set(parsed.capabilities);
368
- if (parsed.sessionId) {
369
- this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
370
- yield {
371
- type: 'runtime_session',
372
- runtimeSessionId: parsed.sessionId,
373
- runtimeLaneKey: sessionKey,
374
- };
375
- }
376
- if (!state.capabilities.has('msg_lifecycle_v1')) {
377
- await state.inputs.failBestEffort(target, log);
378
- yield {
379
- type: 'error',
380
- message: 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
381
- };
382
- this.killProcess(sessionKey, state);
383
- return;
384
- }
385
- continue;
386
- }
387
- if (parsed.type === 'command_lifecycle') {
388
- if (!state.capabilities?.has('msg_lifecycle_v1')) {
389
- await state.inputs.failBestEffort(target, log);
390
- yield {
391
- type: 'error',
392
- message: 'Claude emitted command lifecycle before advertising msg_lifecycle_v1',
393
- };
394
- 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)
395
393
  return;
396
- }
397
- const delivery = state.inputs.getByCommand(parsed.commandUuid);
398
- if (!delivery) {
399
- 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);
400
397
  continue;
401
398
  }
402
- try {
403
- await state.inputs.apply(delivery, parsed.state);
404
- }
405
- catch (err) {
406
- 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) {
407
406
  yield {
408
407
  type: 'error',
409
- message: `Claude input lifecycle update failed: ${String(err)}`,
408
+ message: envelope.message ?? `Claude turn ${envelope.reason}`,
410
409
  };
411
- this.killProcess(sessionKey, state);
412
- return;
413
410
  }
414
- continue;
415
- }
416
- if (parsed.type === 'turn_end') {
417
- // Evidence capture: the poison startup frame (numTurns === 0) is a
418
- // resume artifact, not a turn boundary — never let it overwrite real
419
- // evidence.
420
- if (parsed.numTurns !== 0) {
421
- lastResultMeta = parsed.resultMeta;
422
- }
423
- if (parsed.isError) {
424
- const limitSettled = settledAsLimit();
425
- const failedDelivery = parsed.userMessageUuid
426
- ? state.inputs.getByCommand(parsed.userMessageUuid)
427
- : undefined;
428
- if (!failedDelivery) {
429
- if (limitSettled) {
430
- for (const delivery of state.inputs.values()) {
431
- delivery.suppressFailReport = true;
432
- }
433
- }
434
- await state.inputs.failAllBestEffort(log);
435
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
436
- this.killProcess(sessionKey, state);
437
- return;
438
- }
439
- failedDelivery.resultFailed = true;
440
- if (limitSettled)
441
- failedDelivery.suppressFailReport = true;
442
- }
443
- // result adjacency is not a consumption boundary: the matching
444
- // command_lifecycle(completed) may follow it or interleave with a
445
- // different queued/injected command.
446
- continue;
447
- }
448
- if (parsed.type === 'assistant_error') {
449
- noticeTexts.push(parsed.message);
450
- continue;
451
- }
452
- if (parsed.type === 'error') {
453
- sawError = true;
454
- yield parsed;
455
- continue;
456
- }
457
- if (parsed.type === 'text') {
458
- yield { ...parsed, project: false, groupKey };
459
- continue;
460
- }
461
- if (parsed.type === 'runtime_session') {
462
- yield parsed;
463
- continue;
411
+ yield classifyClaudeTurn(target.evidence.lastResultMeta, target.evidence.noticeTexts);
412
+ return;
464
413
  }
465
- if (parsed.type === 'turn_outcome') {
466
- // (never produced by the parser type guard only)
467
- 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);
468
420
  }
469
- yield { ...parsed, groupKey };
470
421
  }
471
- // Delivery reached a lifecycle terminal. Classify only when a result
472
- // frame was observed during this drain: a delivery whose frames were
473
- // drained by a sibling consumption window has no evidence here, and
474
- // guessing would mislabel the turn (refine-only rule, §4.3).
475
- if (lastResultMeta) {
476
- yield classifyClaudeTurn(lastResultMeta, noticeTexts);
422
+ finally {
423
+ state.pump.clearActiveDrain(target);
424
+ this.maybeApplyRestart(sessionKey, state);
477
425
  }
478
- if (state.needsRestart && !state.inputs.hasPendingInjections()) {
479
- this.killProcess(sessionKey, state);
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;
480
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);
481
452
  }
482
453
  ensureProcess(sessionKey, log) {
483
454
  if (this.shuttingDown) {
@@ -486,10 +457,15 @@ export class ClaudeCodeAdapter {
486
457
  const existing = this.processes.get(sessionKey);
487
458
  if (existing && !existing.done) {
488
459
  const { proc } = existing.handle;
489
- 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
+ }
490
466
  this.killProcess(sessionKey, existing);
491
467
  }
492
- else if (proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed) {
468
+ else if (alive) {
493
469
  return existing;
494
470
  }
495
471
  else {
@@ -498,19 +474,52 @@ export class ClaudeCodeAdapter {
498
474
  }
499
475
  const handle = this.spawnProcess(sessionKey, log);
500
476
  const parser = parseClaudeStreamJson(handle.proc.stdout);
477
+ const inputs = new ClaudeInputRegistry();
501
478
  const state = {
502
479
  handle,
503
- 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
+ }),
504
515
  done: false,
505
516
  needsRestart: false,
506
- // A throwaway process already proved the current CLI advertises this
507
- // capability. Real 2.1.220 sends queued/started before its own init,
508
- // so pre-seed the gate and still verify the real init when it arrives.
509
- capabilities: new Set(['msg_lifecycle_v1']),
510
- inputs: new ClaudeInputRegistry(),
517
+ inputs,
518
+ log,
511
519
  };
512
520
  this.processes.set(sessionKey, state);
513
521
  this.opts.sessionManager.registerProcess(sessionKey, handle);
522
+ state.pump.start();
514
523
  return state;
515
524
  }
516
525
  spawnProcess(sessionKey, log, resume = true) {
@@ -561,15 +570,19 @@ export class ClaudeCodeAdapter {
561
570
  if (current === state) {
562
571
  this.processes.delete(sessionKey);
563
572
  }
573
+ state.pump.close('killed', 'Claude process terminated by the bridge');
564
574
  this.terminateHandle(state.handle);
565
575
  }
566
- terminateHandle(handle) {
576
+ endStdin(handle) {
567
577
  try {
568
578
  handle.proc.stdin.end();
569
579
  }
570
580
  catch {
571
581
  /* best-effort */
572
582
  }
583
+ }
584
+ terminateHandle(handle) {
585
+ this.endStdin(handle);
573
586
  if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
574
587
  try {
575
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
@@ -30,8 +39,29 @@ export declare class ClaudeInputRegistry {
30
39
  findOverlapping(dispatchEventIds: string[]): ClaudeInputDelivery | undefined;
31
40
  values(): IterableIterator<ClaudeInputDelivery>;
32
41
  hasPendingInjections(): boolean;
33
- register(deliveryKey: string, lifecycle: DispatchInputLifecycle | undefined, injected: boolean): ClaudeInputDelivery;
42
+ /**
43
+ * Injected inputs whose lifecycle has NOT reached a terminal state. A
44
+ * terminal delivery still counts as pending (its buffered copy owes frame
45
+ * boundary bookkeeping) but owes the server nothing — the lane complete
46
+ * defers on this predicate, never on hasPendingInjections, so a bookkeeping
47
+ * copy that never drains cannot hold the lane open past its lease. Drained
48
+ * is deliberately NOT part of this predicate: a discarded bookkeeping copy
49
+ * (drained, non-terminal) is still a running input — completing its lane
50
+ * would release the member for redrive mid-processing.
51
+ */
52
+ hasUnsettledInjections(): boolean;
53
+ register(deliveryKey: string, lifecycle: DispatchInputLifecycle | undefined, injected: boolean, noteActivity?: () => void, log?: GatewayLogger): ClaudeInputDelivery;
34
54
  remove(delivery: ClaudeInputDelivery): void;
55
+ /**
56
+ * The buffered copy backing this delivery was discarded without a
57
+ * dispatch (already rendered into a frame the model saw), so the normal
58
+ * bookkeeping drain never comes. Its frame-boundary obligation is void:
59
+ * mark it drained so nothing waits on it, and drop it once terminal. A
60
+ * still-running input keeps its registration — byCommand must keep routing
61
+ * its lifecycle frames — and is dropped when its terminal state arrives
62
+ * (see apply/fail).
63
+ */
64
+ discardBookkeeping(deliveryKey: string): void;
35
65
  apply(delivery: ClaudeInputDelivery, state: 'queued' | 'started' | 'completed' | 'cancelled' | 'discarded'): Promise<void>;
36
66
  fail(delivery: ClaudeInputDelivery): Promise<void>;
37
67
  failBestEffort(delivery: ClaudeInputDelivery, log?: GatewayLogger): Promise<void>;
@@ -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,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;IASrC,KAAK,CACT,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,WAAW,GAAG,WAAW,GACpE,OAAO,CAAC,IAAI,CAAC;IAsBV,IAAI,CAAC,QAAQ,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC;IAiBlD,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"}