@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/busy-state.d.ts +56 -0
- package/dist/busy-state.d.ts.map +1 -0
- package/dist/busy-state.js +140 -0
- package/dist/compact.d.ts +29 -0
- package/dist/compact.d.ts.map +1 -0
- package/dist/compact.js +98 -0
- package/dist/dispatch.d.ts +54 -6
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +193 -180
- package/dist/index.js +6 -2
- package/dist/input-lifecycle.d.ts +31 -1
- package/dist/input-lifecycle.d.ts.map +1 -1
- package/dist/input-lifecycle.js +50 -2
- package/dist/output-parser.d.ts +32 -0
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +106 -10
- package/dist/process-pump.d.ts +105 -0
- package/dist/process-pump.d.ts.map +1 -0
- package/dist/process-pump.js +436 -0
- package/dist/runtime-turn.d.ts +19 -0
- package/dist/runtime-turn.d.ts.map +1 -0
- package/dist/runtime-turn.js +59 -0
- package/dist/session-manager.d.ts +1 -0
- package/dist/session-manager.d.ts.map +1 -1
- package/dist/session-manager.js +3 -0
- package/dist/turn-sink.d.ts +49 -0
- package/dist/turn-sink.d.ts.map +1 -0
- package/dist/turn-sink.js +9 -0
- package/package.json +4 -4
- package/src/busy-state.ts +159 -0
- package/src/compact.ts +132 -0
- package/src/dispatch.ts +217 -209
- package/src/index.ts +6 -1
- package/src/input-lifecycle.ts +61 -1
- package/src/output-parser.ts +140 -10
- package/src/process-pump.ts +493 -0
- package/src/runtime-turn.ts +80 -0
- package/src/session-manager.ts +4 -0
- package/src/turn-sink.ts +59 -0
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
|
-
|
|
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
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
199
|
-
if (!delivery.terminal) void state.inputs.failBestEffort(delivery);
|
|
200
|
-
}
|
|
237
|
+
state.pump.abortDeliveries('dispatch aborted');
|
|
201
238
|
state.done = true;
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
434
|
-
* reaches a terminal lifecycle state. Other injected inputs may start
|
|
435
|
-
* finish while this drain is active; their callbacks advance
|
|
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
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
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
|
-
|
|
560
|
-
|
|
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
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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
|
-
|
|
559
|
+
} finally {
|
|
560
|
+
state.pump.clearActiveDrain(target);
|
|
561
|
+
this.maybeApplyRestart(sessionKey, state);
|
|
611
562
|
}
|
|
563
|
+
}
|
|
612
564
|
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
622
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
651
|
-
|
|
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
|
|
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();
|