@parall/claude-agent 1.59.0 → 1.60.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +52 -6
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +179 -191
- package/dist/index.js +6 -2
- package/dist/input-lifecycle.d.ts +10 -1
- package/dist/input-lifecycle.d.ts.map +1 -1
- package/dist/input-lifecycle.js +11 -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 +201 -224
- package/src/index.ts +6 -1
- package/src/input-lifecycle.ts +25 -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 {
|
|
@@ -226,13 +272,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
226
272
|
// waiting on. Unlike the consume path's tail, the discarded delivery may
|
|
227
273
|
// still be RUNNING (non-terminal): killing the process now would fail a
|
|
228
274
|
// live input, so the restart also waits for every injection to settle.
|
|
229
|
-
|
|
230
|
-
state.needsRestart &&
|
|
231
|
-
!state.inputs.hasPendingInjections() &&
|
|
232
|
-
!state.inputs.hasUnsettledInjections()
|
|
233
|
-
) {
|
|
234
|
-
this.killProcess(sessionKey, state);
|
|
235
|
-
}
|
|
275
|
+
this.maybeApplyRestart(sessionKey, state);
|
|
236
276
|
}
|
|
237
277
|
|
|
238
278
|
async *dispatch({
|
|
@@ -255,23 +295,11 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
255
295
|
injected.drained = true;
|
|
256
296
|
context.log?.info?.(`consuming steer input ${injected.commandUuid}`);
|
|
257
297
|
try {
|
|
258
|
-
yield* this.consumeDelivery(
|
|
259
|
-
sessionKey,
|
|
260
|
-
existingState,
|
|
261
|
-
injected,
|
|
262
|
-
context.log,
|
|
263
|
-
noteActivity,
|
|
264
|
-
);
|
|
298
|
+
yield* this.consumeDelivery(sessionKey, existingState, injected, noteActivity);
|
|
265
299
|
} finally {
|
|
266
300
|
existingState.inputs.remove(injected);
|
|
267
301
|
}
|
|
268
|
-
|
|
269
|
-
existingState.needsRestart &&
|
|
270
|
-
!existingState.inputs.hasPendingInjections() &&
|
|
271
|
-
!existingState.inputs.hasUnsettledInjections()
|
|
272
|
-
) {
|
|
273
|
-
this.killProcess(sessionKey, existingState);
|
|
274
|
-
}
|
|
302
|
+
this.maybeApplyRestart(sessionKey, existingState);
|
|
275
303
|
return;
|
|
276
304
|
}
|
|
277
305
|
}
|
|
@@ -346,6 +374,12 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
346
374
|
this.killProcess(sessionKey, state);
|
|
347
375
|
}
|
|
348
376
|
this.processes.clear();
|
|
377
|
+
const retired = [...this.retiring];
|
|
378
|
+
this.retiring.clear();
|
|
379
|
+
for (const state of retired) {
|
|
380
|
+
state.pump.close('killed', 'Claude process terminated by the bridge');
|
|
381
|
+
this.terminateHandle(state.handle);
|
|
382
|
+
}
|
|
349
383
|
}
|
|
350
384
|
|
|
351
385
|
async shutdown(): Promise<void> {
|
|
@@ -358,6 +392,10 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
358
392
|
await this.opts.sessionManager.shutdownAll();
|
|
359
393
|
}
|
|
360
394
|
|
|
395
|
+
private now(): number {
|
|
396
|
+
return (this.opts.now ?? Date.now)();
|
|
397
|
+
}
|
|
398
|
+
|
|
361
399
|
private async *runTurn(
|
|
362
400
|
sessionKey: string,
|
|
363
401
|
promptBody: string,
|
|
@@ -379,7 +417,9 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
379
417
|
yield { type: 'error', message: `Claude spawn failed: ${String(err)}` };
|
|
380
418
|
return;
|
|
381
419
|
}
|
|
382
|
-
|
|
420
|
+
// Registered BEFORE the stdin write: the pump may route this command's
|
|
421
|
+
// queued/started frames before the drain below starts.
|
|
422
|
+
const delivery = state.inputs.register(deliveryKey, lifecycle, false, noteActivity, log);
|
|
383
423
|
try {
|
|
384
424
|
this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
|
|
385
425
|
} catch (err) {
|
|
@@ -391,12 +431,26 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
391
431
|
}
|
|
392
432
|
|
|
393
433
|
try {
|
|
394
|
-
yield* this.consumeDelivery(sessionKey, state, delivery,
|
|
434
|
+
yield* this.consumeDelivery(sessionKey, state, delivery, noteActivity);
|
|
395
435
|
} finally {
|
|
396
436
|
state.inputs.remove(delivery);
|
|
397
437
|
}
|
|
398
438
|
}
|
|
399
439
|
|
|
440
|
+
/** Idle auto-compact (compact.ts): a `/compact` frame into the long-lived process. */
|
|
441
|
+
compact(opts: CompactOpts): Promise<CompactResult> {
|
|
442
|
+
return runClaudeCompact(
|
|
443
|
+
{
|
|
444
|
+
ensureRuntimeCapability: (log) => this.ensureRuntimeCapability(log),
|
|
445
|
+
ensureProcess: (sessionKey, log) => this.ensureProcess(sessionKey, log),
|
|
446
|
+
killProcess: (sessionKey, state) => this.killProcess(sessionKey, state),
|
|
447
|
+
writeUserMessage: (handle, text, uuid) => this.writeUserMessage(handle, text, uuid),
|
|
448
|
+
applyPendingRestart: (sessionKey, state) => this.maybeApplyRestart(sessionKey, state),
|
|
449
|
+
},
|
|
450
|
+
opts,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
400
454
|
private ensureRuntimeCapability(log: GatewayLogger | undefined): Promise<void> {
|
|
401
455
|
if (!this.capabilityProbe) {
|
|
402
456
|
const probe = this.probeRuntimeCapability(log);
|
|
@@ -457,201 +511,80 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
457
511
|
}
|
|
458
512
|
|
|
459
513
|
/**
|
|
460
|
-
* Drain the
|
|
461
|
-
* reaches a terminal lifecycle state. Other injected inputs may start
|
|
462
|
-
* finish while this drain is active; their callbacks advance
|
|
463
|
-
* and their later bookkeeping dispatch becomes a no-op.
|
|
514
|
+
* Drain the envelopes the pump routed to `target` until its exact stdin
|
|
515
|
+
* UUID reaches a terminal lifecycle state. Other injected inputs may start
|
|
516
|
+
* and finish while this drain is active; their callbacks advance in the
|
|
517
|
+
* pump independently and their later bookkeeping dispatch becomes a no-op.
|
|
464
518
|
*/
|
|
465
519
|
private async *consumeDelivery(
|
|
466
520
|
sessionKey: string,
|
|
467
521
|
state: ProcessState,
|
|
468
522
|
target: ClaudeInputDelivery,
|
|
469
|
-
log: GatewayLogger | undefined,
|
|
470
523
|
noteActivity: (() => void) | undefined,
|
|
471
524
|
): AsyncGenerator<RuntimeEvent> {
|
|
472
525
|
if (target.terminal) return;
|
|
473
526
|
const groupKey = randomUUID();
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
// legacy boolean error path stands (refine-only, never guess).
|
|
483
|
-
let lastResultMeta: ClaudeResultMeta | undefined;
|
|
484
|
-
const noticeTexts: string[] = [];
|
|
485
|
-
// usage_limit settles via the lane-level deferred complete; a failed-input
|
|
486
|
-
// report would race it with an immediate redrive into the choked LLM.
|
|
487
|
-
const settledAsLimit = () =>
|
|
488
|
-
classifyClaudeTurn(lastResultMeta, noticeTexts).outcome === 'usage_limit';
|
|
489
|
-
|
|
490
|
-
while (!target.terminal) {
|
|
491
|
-
const next = await state.parser.next();
|
|
492
|
-
|
|
493
|
-
if (next.done) {
|
|
494
|
-
state.done = true;
|
|
495
|
-
this.processes.delete(sessionKey);
|
|
496
|
-
const detail = state.handle.stderrChunks.join('').trim();
|
|
497
|
-
const exit = await state.handle.exitPromise.catch(
|
|
498
|
-
() => ({ code: null, signal: null }) as const,
|
|
499
|
-
);
|
|
500
|
-
if (detail) {
|
|
501
|
-
log?.warn?.(`subprocess stderr: ${detail}`);
|
|
502
|
-
}
|
|
503
|
-
if (settledAsLimit()) target.suppressFailReport = true;
|
|
504
|
-
await state.inputs.failBestEffort(target, log);
|
|
505
|
-
if (!sawError) {
|
|
506
|
-
yield {
|
|
507
|
-
type: 'error',
|
|
508
|
-
message:
|
|
509
|
-
detail ||
|
|
510
|
-
`Claude exited with code ${exit.code ?? 'unknown'}${exit.signal ? ` (${exit.signal})` : ''}`,
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
// No evidence at all classifies as runtime_crash (the process died
|
|
514
|
-
// before any result frame); with evidence, classify what we saw.
|
|
515
|
-
yield classifyClaudeTurn(lastResultMeta, noticeTexts);
|
|
516
|
-
return;
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
const parsed = next.value;
|
|
520
|
-
// Count every parser-observed CLI progress frame as activity, including
|
|
521
|
-
// command lifecycle, turn boundaries, and explicit activity markers
|
|
522
|
-
// for nested subagent frames that must not become RuntimeEvents.
|
|
523
|
-
noteActivity?.();
|
|
524
|
-
|
|
525
|
-
if (parsed.type === 'runtime_activity') continue;
|
|
526
|
-
|
|
527
|
-
if (parsed.type === 'runtime_init') {
|
|
528
|
-
state.capabilities = new Set(parsed.capabilities);
|
|
529
|
-
if (parsed.sessionId) {
|
|
530
|
-
this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
|
|
531
|
-
yield {
|
|
532
|
-
type: 'runtime_session',
|
|
533
|
-
runtimeSessionId: parsed.sessionId,
|
|
534
|
-
runtimeLaneKey: sessionKey,
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
if (!state.capabilities.has('msg_lifecycle_v1')) {
|
|
538
|
-
await state.inputs.failBestEffort(target, log);
|
|
539
|
-
yield {
|
|
540
|
-
type: 'error',
|
|
541
|
-
message:
|
|
542
|
-
'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
|
|
543
|
-
};
|
|
544
|
-
this.killProcess(sessionKey, state);
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
continue;
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
if (parsed.type === 'command_lifecycle') {
|
|
551
|
-
if (!state.capabilities?.has('msg_lifecycle_v1')) {
|
|
552
|
-
await state.inputs.failBestEffort(target, log);
|
|
553
|
-
yield {
|
|
554
|
-
type: 'error',
|
|
555
|
-
message: 'Claude emitted command lifecycle before advertising msg_lifecycle_v1',
|
|
556
|
-
};
|
|
557
|
-
this.killProcess(sessionKey, state);
|
|
558
|
-
return;
|
|
559
|
-
}
|
|
560
|
-
const delivery = state.inputs.getByCommand(parsed.commandUuid);
|
|
561
|
-
if (!delivery) {
|
|
562
|
-
log?.warn?.(`ignoring lifecycle for unknown Claude command ${parsed.commandUuid}`);
|
|
527
|
+
state.pump.setActiveDrain(target, noteActivity);
|
|
528
|
+
try {
|
|
529
|
+
while (true) {
|
|
530
|
+
const next = await target.sink.next();
|
|
531
|
+
if (next.done) return;
|
|
532
|
+
const envelope = next.value;
|
|
533
|
+
if (envelope.kind === 'runtime') {
|
|
534
|
+
yield projectRuntimeEvent(envelope.event, groupKey);
|
|
563
535
|
continue;
|
|
564
536
|
}
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
537
|
+
if (envelope.kind === 'terminal') break;
|
|
538
|
+
// The process is gone (eof / killed), the dispatch was aborted, or
|
|
539
|
+
// the pump ended this owner's window: surface the error unless the
|
|
540
|
+
// stream already carried one, then classify what was observed (no
|
|
541
|
+
// evidence at all is a runtime_crash).
|
|
542
|
+
if (envelope.reason !== 'error_result' && !target.evidence.sawError) {
|
|
569
543
|
yield {
|
|
570
544
|
type: 'error',
|
|
571
|
-
message: `Claude
|
|
545
|
+
message: envelope.message ?? `Claude turn ${envelope.reason}`,
|
|
572
546
|
};
|
|
573
|
-
this.killProcess(sessionKey, state);
|
|
574
|
-
return;
|
|
575
|
-
}
|
|
576
|
-
continue;
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
if (parsed.type === 'turn_end') {
|
|
580
|
-
// Evidence capture: the poison startup frame (numTurns === 0) is a
|
|
581
|
-
// resume artifact, not a turn boundary — never let it overwrite real
|
|
582
|
-
// evidence.
|
|
583
|
-
if (parsed.numTurns !== 0) {
|
|
584
|
-
lastResultMeta = parsed.resultMeta;
|
|
585
|
-
}
|
|
586
|
-
if (parsed.isError) {
|
|
587
|
-
const limitSettled = settledAsLimit();
|
|
588
|
-
const failedDelivery = parsed.userMessageUuid
|
|
589
|
-
? state.inputs.getByCommand(parsed.userMessageUuid)
|
|
590
|
-
: undefined;
|
|
591
|
-
if (!failedDelivery) {
|
|
592
|
-
if (limitSettled) {
|
|
593
|
-
for (const delivery of state.inputs.values()) {
|
|
594
|
-
delivery.suppressFailReport = true;
|
|
595
|
-
}
|
|
596
|
-
}
|
|
597
|
-
await state.inputs.failAllBestEffort(log);
|
|
598
|
-
yield classifyClaudeTurn(lastResultMeta, noticeTexts);
|
|
599
|
-
this.killProcess(sessionKey, state);
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
failedDelivery.resultFailed = true;
|
|
603
|
-
if (limitSettled) failedDelivery.suppressFailReport = true;
|
|
604
547
|
}
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
// different queued/injected command.
|
|
608
|
-
continue;
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
if (parsed.type === 'assistant_error') {
|
|
612
|
-
noticeTexts.push(parsed.message);
|
|
613
|
-
continue;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
if (parsed.type === 'error') {
|
|
617
|
-
sawError = true;
|
|
618
|
-
yield parsed;
|
|
619
|
-
continue;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
if (parsed.type === 'text') {
|
|
623
|
-
yield { ...parsed, project: false, groupKey };
|
|
624
|
-
continue;
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
if (parsed.type === 'runtime_session') {
|
|
628
|
-
yield parsed;
|
|
629
|
-
continue;
|
|
548
|
+
yield classifyClaudeTurn(target.evidence.lastResultMeta, target.evidence.noticeTexts);
|
|
549
|
+
return;
|
|
630
550
|
}
|
|
631
551
|
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
552
|
+
// Delivery reached a lifecycle terminal. Classify only when a result
|
|
553
|
+
// frame was observed in this owner's window: a delivery whose frames
|
|
554
|
+
// were drained by a sibling consumption window has no evidence here,
|
|
555
|
+
// and guessing would mislabel the turn (refine-only rule, §4.3).
|
|
556
|
+
if (target.evidence.lastResultMeta) {
|
|
557
|
+
yield classifyClaudeTurn(target.evidence.lastResultMeta, target.evidence.noticeTexts);
|
|
635
558
|
}
|
|
636
|
-
|
|
637
|
-
|
|
559
|
+
} finally {
|
|
560
|
+
state.pump.clearActiveDrain(target);
|
|
561
|
+
this.maybeApplyRestart(sessionKey, state);
|
|
638
562
|
}
|
|
563
|
+
}
|
|
639
564
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
565
|
+
/**
|
|
566
|
+
* Lazy restart: kill the process so the next dispatch respawns it (the
|
|
567
|
+
* session survives via --resume). Waits for every injection to settle, for
|
|
568
|
+
* an open runtime-initiated turn and for a follow-up hold — but NOT for
|
|
569
|
+
* outstanding background tasks (a `make dev` would defer a config change
|
|
570
|
+
* forever); those die with the process, logged.
|
|
571
|
+
*/
|
|
572
|
+
private maybeApplyRestart(sessionKey: string, state: ProcessState): void {
|
|
573
|
+
if (!state.needsRestart || state.done) return;
|
|
574
|
+
if (state.inputs.hasPendingInjections() || state.inputs.hasUnsettledInjections()) return;
|
|
575
|
+
// A dispatch queued behind a runtime-initiated turn is registered but
|
|
576
|
+
// not started yet; killing now would fail it before the CLI runs it.
|
|
577
|
+
for (const delivery of state.inputs.values()) {
|
|
578
|
+
if (!delivery.terminal) return;
|
|
646
579
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
this.killProcess(sessionKey, state);
|
|
580
|
+
if (state.pump.hasOwnWork()) return;
|
|
581
|
+
const outstanding = state.pump.busy.outstanding();
|
|
582
|
+
if (outstanding.total > 0) {
|
|
583
|
+
state.log?.warn?.(
|
|
584
|
+
`restarting ${sessionKey} with ${outstanding.total} live background task(s); they die with the process`,
|
|
585
|
+
);
|
|
654
586
|
}
|
|
587
|
+
this.killProcess(sessionKey, state);
|
|
655
588
|
}
|
|
656
589
|
|
|
657
590
|
private ensureProcess(sessionKey: string, log: GatewayLogger | undefined): ProcessState {
|
|
@@ -662,9 +595,16 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
662
595
|
const existing = this.processes.get(sessionKey);
|
|
663
596
|
if (existing && !existing.done) {
|
|
664
597
|
const { proc } = existing.handle;
|
|
665
|
-
|
|
598
|
+
const alive = proc.exitCode === null && proc.signalCode === null && !proc.stdin.destroyed;
|
|
599
|
+
if (existing.needsRestart && alive) {
|
|
600
|
+
if (existing.pump.hasOwnWork()) {
|
|
601
|
+
log?.info?.(
|
|
602
|
+
`deferring lazy restart of ${sessionKey}: runtime-initiated turn in progress`,
|
|
603
|
+
);
|
|
604
|
+
return existing;
|
|
605
|
+
}
|
|
666
606
|
this.killProcess(sessionKey, existing);
|
|
667
|
-
} else if (
|
|
607
|
+
} else if (alive) {
|
|
668
608
|
return existing;
|
|
669
609
|
} else {
|
|
670
610
|
this.processes.delete(sessionKey);
|
|
@@ -673,19 +613,51 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
673
613
|
|
|
674
614
|
const handle = this.spawnProcess(sessionKey, log);
|
|
675
615
|
const parser = parseClaudeStreamJson(handle.proc.stdout!);
|
|
616
|
+
const inputs = new ClaudeInputRegistry();
|
|
676
617
|
const state: ProcessState = {
|
|
677
618
|
handle,
|
|
678
|
-
|
|
619
|
+
pump: new ClaudeProcessPump({
|
|
620
|
+
sessionKey,
|
|
621
|
+
parser,
|
|
622
|
+
handle,
|
|
623
|
+
inputs,
|
|
624
|
+
log,
|
|
625
|
+
followUpHoldMs: this.opts.followUpHoldMs ?? DEFAULT_FOLLOW_UP_HOLD_MS,
|
|
626
|
+
now: () => this.now(),
|
|
627
|
+
hooks: {
|
|
628
|
+
onRuntimeInit: (init) => {
|
|
629
|
+
if (init.sessionId) {
|
|
630
|
+
this.opts.sessionManager.recordSessionId(sessionKey, init.sessionId);
|
|
631
|
+
}
|
|
632
|
+
if (!init.capabilities.includes('msg_lifecycle_v1')) {
|
|
633
|
+
return 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage';
|
|
634
|
+
}
|
|
635
|
+
return undefined;
|
|
636
|
+
},
|
|
637
|
+
onFatal: () => {
|
|
638
|
+
const current = this.processes.get(sessionKey);
|
|
639
|
+
if (current === state) this.killProcess(sessionKey, state);
|
|
640
|
+
},
|
|
641
|
+
onEof: () => {
|
|
642
|
+
state.done = true;
|
|
643
|
+
if (this.processes.get(sessionKey) === state) this.processes.delete(sessionKey);
|
|
644
|
+
this.retiring.delete(state);
|
|
645
|
+
},
|
|
646
|
+
// Main-session turns go to the gateway (steps + session activity);
|
|
647
|
+
// fork-session turns only count as busy (fork-scope invariant).
|
|
648
|
+
onRuntimeTurnOpened: (turn) =>
|
|
649
|
+
this.activity.surfaceTurn(turn, this.opts.sessionManager.isMain(sessionKey), log),
|
|
650
|
+
onRuntimeTurnClosed: () => this.maybeApplyRestart(sessionKey, state),
|
|
651
|
+
},
|
|
652
|
+
}),
|
|
679
653
|
done: false,
|
|
680
654
|
needsRestart: false,
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
// so pre-seed the gate and still verify the real init when it arrives.
|
|
684
|
-
capabilities: new Set(['msg_lifecycle_v1']),
|
|
685
|
-
inputs: new ClaudeInputRegistry(),
|
|
655
|
+
inputs,
|
|
656
|
+
log,
|
|
686
657
|
};
|
|
687
658
|
this.processes.set(sessionKey, state);
|
|
688
659
|
this.opts.sessionManager.registerProcess(sessionKey, handle);
|
|
660
|
+
state.pump.start();
|
|
689
661
|
return state;
|
|
690
662
|
}
|
|
691
663
|
|
|
@@ -760,15 +732,20 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
760
732
|
if (current === state) {
|
|
761
733
|
this.processes.delete(sessionKey);
|
|
762
734
|
}
|
|
735
|
+
state.pump.close('killed', 'Claude process terminated by the bridge');
|
|
763
736
|
this.terminateHandle(state.handle);
|
|
764
737
|
}
|
|
765
738
|
|
|
766
|
-
private
|
|
739
|
+
private endStdin(handle: ClaudeProcessHandle): void {
|
|
767
740
|
try {
|
|
768
741
|
handle.proc.stdin.end();
|
|
769
742
|
} catch {
|
|
770
743
|
/* best-effort */
|
|
771
744
|
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private terminateHandle(handle: ClaudeProcessHandle): void {
|
|
748
|
+
this.endStdin(handle);
|
|
772
749
|
if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
|
|
773
750
|
try {
|
|
774
751
|
if (!IS_WIN32 || !handle.proc.pid || !killWin32Tree(handle.proc.pid)) {
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
identityFromMe,
|
|
14
14
|
initAgentTelemetry,
|
|
15
15
|
llmSource,
|
|
16
|
+
resolveServiceVersion,
|
|
16
17
|
materializeChannelCapabilities,
|
|
17
18
|
ParallAgentGateway,
|
|
18
19
|
parseDispatchDeadlineMs,
|
|
@@ -76,7 +77,11 @@ function resolveProviderEnv(): string {
|
|
|
76
77
|
async function main() {
|
|
77
78
|
// Before any fetch: long-lived HTTP connections for every bridge→api call.
|
|
78
79
|
configureHttpKeepAlive();
|
|
79
|
-
const telemetry = await initAgentTelemetry('parall-claude-agent', 'claude-code'
|
|
80
|
+
const telemetry = await initAgentTelemetry('parall-claude-agent', 'claude-code', {
|
|
81
|
+
apiUrl: process.env.PRLL_API_URL,
|
|
82
|
+
apiKey: process.env.PRLL_API_KEY,
|
|
83
|
+
serviceVersion: resolveServiceVersion(import.meta.url),
|
|
84
|
+
});
|
|
80
85
|
activeLog = createOtelLogger('agent', 'claude-agent');
|
|
81
86
|
try {
|
|
82
87
|
const activeLLMSource = resolveProviderEnv();
|