@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/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/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
|
|
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
|
|
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
|
-
|
|
117
|
-
if (!delivery.terminal)
|
|
118
|
-
void state.inputs.failBestEffort(delivery);
|
|
119
|
-
}
|
|
142
|
+
state.pump.abortDeliveries('dispatch aborted');
|
|
120
143
|
state.done = true;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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,
|
|
197
|
+
yield* this.consumeDelivery(sessionKey, existingState, injected, noteActivity);
|
|
170
198
|
}
|
|
171
199
|
finally {
|
|
172
200
|
existingState.inputs.remove(injected);
|
|
173
201
|
}
|
|
174
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
335
|
-
* reaches a terminal lifecycle state. Other injected inputs may start
|
|
336
|
-
* finish while this drain is active; their callbacks advance
|
|
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,
|
|
384
|
+
async *consumeDelivery(sessionKey, state, target, noteActivity) {
|
|
340
385
|
if (target.terminal)
|
|
341
386
|
return;
|
|
342
387
|
const groupKey = randomUUID();
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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
|
-
|
|
421
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
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
|
|
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
|
-
|
|
467
|
-
|
|
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
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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;
|
|
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"}
|
package/dist/input-lifecycle.js
CHANGED
|
@@ -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
|
|
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
|