@parall/claude-agent 1.51.0 → 1.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dispatch.d.ts +23 -16
- package/dist/dispatch.d.ts.map +1 -1
- package/dist/dispatch.js +297 -182
- package/dist/input-lifecycle.d.ts +39 -0
- package/dist/input-lifecycle.d.ts.map +1 -0
- package/dist/input-lifecycle.js +97 -0
- package/dist/output-parser.d.ts +37 -2
- package/dist/output-parser.d.ts.map +1 -1
- package/dist/output-parser.js +94 -3
- package/dist/turn-outcome.d.ts +10 -0
- package/dist/turn-outcome.d.ts.map +1 -0
- package/dist/turn-outcome.js +173 -0
- package/package.json +4 -4
- package/src/dispatch.ts +316 -189
- package/src/input-lifecycle.ts +137 -0
- package/src/output-parser.ts +137 -5
- package/src/turn-outcome.ts +177 -0
package/src/dispatch.ts
CHANGED
|
@@ -9,14 +9,21 @@ import {
|
|
|
9
9
|
import type {
|
|
10
10
|
CleanupForkOpts,
|
|
11
11
|
DispatchAdapter,
|
|
12
|
+
DispatchInputLifecycle,
|
|
12
13
|
DispatchOpts,
|
|
13
14
|
ForkOpts,
|
|
14
15
|
GatewayLogger,
|
|
15
16
|
RuntimeEvent,
|
|
16
17
|
} from '@parall/agent-core';
|
|
17
18
|
import type { ClaudeAgentConfig } from './config.js';
|
|
18
|
-
import {
|
|
19
|
-
import {
|
|
19
|
+
import { ClaudeInputRegistry, type ClaudeInputDelivery } from './input-lifecycle.js';
|
|
20
|
+
import {
|
|
21
|
+
parseClaudeStreamJson,
|
|
22
|
+
type ClaudeParsedEvent,
|
|
23
|
+
type ClaudeResultMeta,
|
|
24
|
+
} from './output-parser.js';
|
|
25
|
+
import type { ClaudeProcessHandle, ClaudeSessionManager } from './session-manager.js';
|
|
26
|
+
import { classifyClaudeTurn } from './turn-outcome.js';
|
|
20
27
|
|
|
21
28
|
type ClaudeCodeAdapterOptions = Pick<
|
|
22
29
|
ClaudeAgentConfig,
|
|
@@ -58,11 +65,9 @@ type ClaudeCodeAdapterOptions = Pick<
|
|
|
58
65
|
};
|
|
59
66
|
|
|
60
67
|
const IS_WIN32 = process.platform === 'win32';
|
|
68
|
+
const CAPABILITY_PROBE_TIMEOUT_MS = 15_000;
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
// guard in runTurn) — keeps a pathological stream of empty `result` frames
|
|
64
|
-
// from spinning the drain loop forever.
|
|
65
|
-
const MAX_SPURIOUS_TURN_END_SKIPS = 3;
|
|
70
|
+
class MissingClaudeLifecycleCapabilityError extends Error {}
|
|
66
71
|
|
|
67
72
|
function quoteWin32Arg(arg: string): string {
|
|
68
73
|
if (!/[\s"&|^<>()]/.test(arg)) return arg;
|
|
@@ -154,12 +159,15 @@ type ProcessState = {
|
|
|
154
159
|
parser: AsyncGenerator<ClaudeParsedEvent>;
|
|
155
160
|
done: boolean;
|
|
156
161
|
needsRestart: boolean;
|
|
157
|
-
|
|
162
|
+
capabilities?: Set<string>;
|
|
163
|
+
inputs: ClaudeInputRegistry;
|
|
158
164
|
};
|
|
159
165
|
|
|
160
166
|
export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
167
|
+
readonly inputLifecycleMode = 'explicit' as const;
|
|
161
168
|
private readonly processes = new Map<string, ProcessState>();
|
|
162
|
-
private
|
|
169
|
+
private capabilityProbe?: Promise<void>;
|
|
170
|
+
private capabilityProbeHandle?: ClaudeProcessHandle;
|
|
163
171
|
private shuttingDown = false;
|
|
164
172
|
private _model: string | undefined;
|
|
165
173
|
private _effortLevel: string | undefined;
|
|
@@ -197,24 +205,43 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
197
205
|
}
|
|
198
206
|
}
|
|
199
207
|
|
|
200
|
-
enqueueDuringDispatch(
|
|
208
|
+
enqueueDuringDispatch(
|
|
209
|
+
sessionKey: string,
|
|
210
|
+
body: string,
|
|
211
|
+
inputLifecycle?: DispatchInputLifecycle,
|
|
212
|
+
): boolean {
|
|
213
|
+
// Exact identity is mandatory for soft steer. Without it the later
|
|
214
|
+
// buffered dispatch could only guess which stdout boundary belonged to
|
|
215
|
+
// this input — the bug this adapter is designed to remove.
|
|
216
|
+
if (!inputLifecycle) return false;
|
|
201
217
|
const state = this.processes.get(sessionKey);
|
|
202
218
|
if (!state || state.done) return false;
|
|
219
|
+
// A throwaway process already proved the CLI capability before any
|
|
220
|
+
// business process started. The real process revalidates its own init.
|
|
221
|
+
if (!state.capabilities?.has('msg_lifecycle_v1')) return false;
|
|
203
222
|
const { proc } = state.handle;
|
|
204
223
|
if (proc.exitCode !== null || proc.signalCode !== null || proc.stdin.destroyed) return false;
|
|
205
224
|
try {
|
|
206
|
-
|
|
207
|
-
|
|
225
|
+
if (state.inputs.getByKey(inputLifecycle.deliveryKey)) return true;
|
|
226
|
+
const delivery = state.inputs.register(inputLifecycle.deliveryKey, inputLifecycle, true);
|
|
227
|
+
this.writeUserMessage(state.handle, body, delivery.commandUuid);
|
|
208
228
|
return true;
|
|
209
229
|
} catch {
|
|
230
|
+
const delivery = state.inputs.getByKey(inputLifecycle.deliveryKey);
|
|
231
|
+
if (delivery) {
|
|
232
|
+
void state.inputs.failBestEffort(delivery);
|
|
233
|
+
state.inputs.remove(delivery);
|
|
234
|
+
}
|
|
210
235
|
return false;
|
|
211
236
|
}
|
|
212
237
|
}
|
|
213
238
|
|
|
214
239
|
abortDispatch(sessionKey: string): void {
|
|
215
|
-
this.pendingInjections.delete(sessionKey);
|
|
216
240
|
const state = this.processes.get(sessionKey);
|
|
217
241
|
if (!state || state.done) return;
|
|
242
|
+
for (const delivery of state.inputs.values()) {
|
|
243
|
+
if (!delivery.terminal) void state.inputs.failBestEffort(delivery);
|
|
244
|
+
}
|
|
218
245
|
state.done = true;
|
|
219
246
|
try {
|
|
220
247
|
state.handle.proc.stdin.end();
|
|
@@ -224,7 +251,9 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
224
251
|
}
|
|
225
252
|
|
|
226
253
|
hasPendingInjections(sessionKey: string): boolean {
|
|
227
|
-
|
|
254
|
+
const state = this.processes.get(sessionKey);
|
|
255
|
+
if (!state) return false;
|
|
256
|
+
return state.inputs.hasPendingInjections();
|
|
228
257
|
}
|
|
229
258
|
|
|
230
259
|
async *dispatch({
|
|
@@ -232,15 +261,29 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
232
261
|
bodyForAgent,
|
|
233
262
|
sessionKey,
|
|
234
263
|
context,
|
|
264
|
+
inputLifecycle,
|
|
235
265
|
}: DispatchOpts): AsyncIterable<RuntimeEvent> {
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
266
|
+
const deliveryKey = inputLifecycle?.deliveryKey ?? event.dispatchEventId ?? event.messageId;
|
|
267
|
+
const existingState = this.processes.get(sessionKey);
|
|
268
|
+
const injected = existingState?.inputs.getByKey(deliveryKey);
|
|
269
|
+
if (existingState && injected) {
|
|
270
|
+
if (injected.terminal === 'failed') {
|
|
271
|
+
// The server released this exact WorkItem for retry. Its buffered
|
|
272
|
+
// bookkeeping dispatch is now real work again, not a no-op.
|
|
273
|
+
existingState.inputs.remove(injected);
|
|
274
|
+
} else {
|
|
275
|
+
injected.drained = true;
|
|
276
|
+
context.log?.info?.(`consuming steer input ${injected.commandUuid}`);
|
|
277
|
+
try {
|
|
278
|
+
yield* this.consumeDelivery(sessionKey, existingState, injected, context.log);
|
|
279
|
+
} finally {
|
|
280
|
+
existingState.inputs.remove(injected);
|
|
281
|
+
}
|
|
282
|
+
if (existingState.needsRestart && !existingState.inputs.hasPendingInjections()) {
|
|
283
|
+
this.killProcess(sessionKey, existingState);
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
242
286
|
}
|
|
243
|
-
return;
|
|
244
287
|
}
|
|
245
288
|
|
|
246
289
|
let promptBody = bodyForAgent;
|
|
@@ -257,7 +300,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
257
300
|
}
|
|
258
301
|
|
|
259
302
|
try {
|
|
260
|
-
yield* this.runTurn(sessionKey, promptBody, context.log);
|
|
303
|
+
yield* this.runTurn(sessionKey, promptBody, deliveryKey, inputLifecycle, context.log);
|
|
261
304
|
} finally {
|
|
262
305
|
releasePreparedAttachments();
|
|
263
306
|
}
|
|
@@ -298,172 +341,146 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
298
341
|
this.killProcess(sessionKey, state);
|
|
299
342
|
}
|
|
300
343
|
this.processes.clear();
|
|
301
|
-
this.pendingInjections.clear();
|
|
302
344
|
}
|
|
303
345
|
|
|
304
346
|
async shutdown(): Promise<void> {
|
|
305
347
|
this.shuttingDown = true;
|
|
348
|
+
if (this.capabilityProbeHandle) {
|
|
349
|
+
this.terminateHandle(this.capabilityProbeHandle);
|
|
350
|
+
this.capabilityProbeHandle = undefined;
|
|
351
|
+
}
|
|
306
352
|
this.resetProcesses();
|
|
307
353
|
await this.opts.sessionManager.shutdownAll();
|
|
308
354
|
}
|
|
309
355
|
|
|
310
|
-
// Steer-turn read timeout. Overridable via PRLL_STEER_TURN_TIMEOUT_MS (ms),
|
|
311
|
-
// chiefly so tests can exercise the timeout path without a 10s wait.
|
|
312
|
-
private readonly steerTurnTimeoutMs = Number(process.env.PRLL_STEER_TURN_TIMEOUT_MS) || 10_000;
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Consume a steer turn whose message was already written to stdin via
|
|
316
|
-
* enqueueDuringDispatch. Skip the stdin write — only read parser output.
|
|
317
|
-
* If no output arrives within STEER_TURN_TIMEOUT_MS, the steer was
|
|
318
|
-
* incorporated into the previous turn and there is nothing to consume.
|
|
319
|
-
*
|
|
320
|
-
* On timeout, the losing parser.next() promise is saved to
|
|
321
|
-
* state.steerReadPending so the next runTurn can drain it instead of
|
|
322
|
-
* silently losing the first event of the subsequent turn.
|
|
323
|
-
*/
|
|
324
|
-
private async *consumeSteerTurn(
|
|
325
|
-
sessionKey: string,
|
|
326
|
-
log: GatewayLogger | undefined,
|
|
327
|
-
): AsyncGenerator<RuntimeEvent> {
|
|
328
|
-
const state = this.processes.get(sessionKey);
|
|
329
|
-
if (!state || state.done) {
|
|
330
|
-
throw new Error('process dead during steer consumption');
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
const groupKey = randomUUID();
|
|
334
|
-
|
|
335
|
-
// Reuse a pull already issued by a prior steer timeout instead of issuing a
|
|
336
|
-
// fresh one. A burst of N steers all takes the pending-skip path, which calls
|
|
337
|
-
// this method N times in a loop. Issuing a new state.parser.next() on each
|
|
338
|
-
// call while a previous pull is still in flight queues multiple reads on the
|
|
339
|
-
// same async generator, but only one can be stashed in the single-slot
|
|
340
|
-
// state.steerReadPending — every earlier pull is then consumed-and-dropped,
|
|
341
|
-
// silently losing parser events. When a dropped event is a tool_call, its
|
|
342
|
-
// tool_result later orphans into "call_id does not match any tool_call".
|
|
343
|
-
// Threading the single in-flight pull through every call keeps at most one
|
|
344
|
-
// outstanding read and loses nothing.
|
|
345
|
-
const parserNext = state.steerReadPending ?? state.parser.next();
|
|
346
|
-
state.steerReadPending = undefined;
|
|
347
|
-
const firstRead = await Promise.race([
|
|
348
|
-
parserNext.then((r) => ({ kind: 'value' as const, result: r })),
|
|
349
|
-
new Promise<{ kind: 'timeout' }>((resolve) =>
|
|
350
|
-
setTimeout(() => resolve({ kind: 'timeout' }), this.steerTurnTimeoutMs),
|
|
351
|
-
),
|
|
352
|
-
]);
|
|
353
|
-
|
|
354
|
-
if (firstRead.kind === 'timeout') {
|
|
355
|
-
state.steerReadPending = parserNext;
|
|
356
|
-
log?.info?.(`steer turn timeout — steer was incorporated into previous turn`);
|
|
357
|
-
return;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
let next = firstRead.result;
|
|
361
|
-
// eslint-disable-next-line no-constant-condition
|
|
362
|
-
while (true) {
|
|
363
|
-
if (next.done) {
|
|
364
|
-
state.done = true;
|
|
365
|
-
this.processes.delete(sessionKey);
|
|
366
|
-
throw new Error('process exited while consuming steer turn');
|
|
367
|
-
}
|
|
368
|
-
const parsed = next.value;
|
|
369
|
-
|
|
370
|
-
if (parsed.type === 'session_id') {
|
|
371
|
-
this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
|
|
372
|
-
yield {
|
|
373
|
-
type: 'runtime_session',
|
|
374
|
-
runtimeSessionId: parsed.sessionId,
|
|
375
|
-
runtimeLaneKey: sessionKey,
|
|
376
|
-
};
|
|
377
|
-
} else if (parsed.type === 'turn_end') {
|
|
378
|
-
if (state.needsRestart) this.killProcess(sessionKey, state);
|
|
379
|
-
return;
|
|
380
|
-
} else if (parsed.type === 'error') {
|
|
381
|
-
yield parsed;
|
|
382
|
-
} else if (parsed.type === 'text') {
|
|
383
|
-
yield { ...parsed, project: false, groupKey };
|
|
384
|
-
} else if (parsed.type === 'runtime_session') {
|
|
385
|
-
yield parsed;
|
|
386
|
-
} else {
|
|
387
|
-
yield { ...parsed, groupKey };
|
|
388
|
-
}
|
|
389
|
-
next = await state.parser.next();
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
356
|
private async *runTurn(
|
|
394
357
|
sessionKey: string,
|
|
395
358
|
promptBody: string,
|
|
359
|
+
deliveryKey: string,
|
|
360
|
+
lifecycle: DispatchInputLifecycle | undefined,
|
|
396
361
|
log: GatewayLogger | undefined,
|
|
397
362
|
): AsyncGenerator<RuntimeEvent> {
|
|
398
363
|
let state: ProcessState;
|
|
399
364
|
try {
|
|
365
|
+
await this.ensureRuntimeCapability(log);
|
|
400
366
|
state = this.ensureProcess(sessionKey, log);
|
|
401
367
|
} catch (err) {
|
|
368
|
+
try {
|
|
369
|
+
await lifecycle?.update('failed');
|
|
370
|
+
} catch (reportErr) {
|
|
371
|
+
log?.warn?.(`failed to report rejected Claude input as failed: ${String(reportErr)}`);
|
|
372
|
+
}
|
|
402
373
|
yield { type: 'error', message: `Claude spawn failed: ${String(err)}` };
|
|
403
374
|
return;
|
|
404
375
|
}
|
|
405
|
-
const
|
|
406
|
-
let sawError = false;
|
|
407
|
-
// Poison-frame guard. When `--resume` (with or without `--fork-session`)
|
|
408
|
-
// targets a transcript whose tail is dangling (previous process was
|
|
409
|
-
// killed mid-turn — exactly the fork-on-busy case), Claude CLI emits a
|
|
410
|
-
// spurious EMPTY result frame (num_turns: 0) at startup, BEFORE the
|
|
411
|
-
// queued stdin user frame is processed. Treating its turn_end as the
|
|
412
|
-
// dispatch boundary returns with zero output, the work item sweeps as
|
|
413
|
-
// no_action, and the real reply is orphaned in a soon-to-be-killed
|
|
414
|
-
// subprocess. A legitimate turn always reports num_turns >= 1 (even with
|
|
415
|
-
// no visible text); undefined (CLIs that omit the field) must NOT skip.
|
|
416
|
-
let sawSubstantiveEvent = false;
|
|
417
|
-
let spuriousTurnEndSkips = 0;
|
|
418
|
-
|
|
376
|
+
const delivery = state.inputs.register(deliveryKey, lifecycle, false);
|
|
419
377
|
try {
|
|
420
|
-
this.writeUserMessage(state.handle, promptBody);
|
|
378
|
+
this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
|
|
421
379
|
} catch (err) {
|
|
380
|
+
await state.inputs.failBestEffort(delivery, log);
|
|
381
|
+
state.inputs.remove(delivery);
|
|
422
382
|
yield { type: 'error', message: `Claude stdin write failed: ${String(err)}` };
|
|
423
383
|
this.killProcess(sessionKey, state);
|
|
424
384
|
return;
|
|
425
385
|
}
|
|
426
386
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
) {
|
|
441
|
-
sawSubstantiveEvent = true;
|
|
387
|
+
try {
|
|
388
|
+
yield* this.consumeDelivery(sessionKey, state, delivery, log);
|
|
389
|
+
} finally {
|
|
390
|
+
state.inputs.remove(delivery);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
private ensureRuntimeCapability(log: GatewayLogger | undefined): Promise<void> {
|
|
395
|
+
if (!this.capabilityProbe) {
|
|
396
|
+
const probe = this.probeRuntimeCapability(log);
|
|
397
|
+
this.capabilityProbe = probe.catch((err) => {
|
|
398
|
+
if (!(err instanceof MissingClaudeLifecycleCapabilityError)) {
|
|
399
|
+
this.capabilityProbe = undefined;
|
|
442
400
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
401
|
+
throw err;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
return this.capabilityProbe;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Claude does not emit system.init until it receives its first stdin
|
|
409
|
+
* command (2.1.220 emits queued/started before init). Probe a throwaway
|
|
410
|
+
* process with an empty, non-WorkItem command; only after its init
|
|
411
|
+
* advertises msg_lifecycle_v1 may any business input enter a real process.
|
|
412
|
+
*/
|
|
413
|
+
private async probeRuntimeCapability(log: GatewayLogger | undefined): Promise<void> {
|
|
414
|
+
const handle = this.spawnProcess('__capability_probe__', log, false);
|
|
415
|
+
this.capabilityProbeHandle = handle;
|
|
416
|
+
const parser = parseClaudeStreamJson(handle.proc.stdout!);
|
|
417
|
+
let timedOut = false;
|
|
418
|
+
const timer = setTimeout(() => {
|
|
419
|
+
timedOut = true;
|
|
420
|
+
this.terminateHandle(handle);
|
|
421
|
+
}, CAPABILITY_PROBE_TIMEOUT_MS);
|
|
422
|
+
timer.unref?.();
|
|
423
|
+
try {
|
|
424
|
+
this.writeCapabilityProbe(handle);
|
|
425
|
+
while (true) {
|
|
426
|
+
const next = await parser.next();
|
|
427
|
+
if (next.done) {
|
|
428
|
+
const detail = handle.stderrChunks.join('').trim();
|
|
429
|
+
if (timedOut) {
|
|
430
|
+
throw new Error(
|
|
431
|
+
`Claude runtime capability probe timed out after ${CAPABILITY_PROBE_TIMEOUT_MS}ms`,
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
throw new Error(detail || 'Claude exited before its runtime capability probe completed');
|
|
462
435
|
}
|
|
436
|
+
if (next.value.type !== 'runtime_init') continue;
|
|
437
|
+
if (!next.value.capabilities.includes('msg_lifecycle_v1')) {
|
|
438
|
+
throw new MissingClaudeLifecycleCapabilityError(
|
|
439
|
+
'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return;
|
|
463
443
|
}
|
|
444
|
+
} finally {
|
|
445
|
+
clearTimeout(timer);
|
|
446
|
+
if (this.capabilityProbeHandle === handle) {
|
|
447
|
+
this.capabilityProbeHandle = undefined;
|
|
448
|
+
}
|
|
449
|
+
this.terminateHandle(handle);
|
|
464
450
|
}
|
|
451
|
+
}
|
|
465
452
|
|
|
466
|
-
|
|
453
|
+
/**
|
|
454
|
+
* Drain the shared stdout stream until the exact UUID written for target
|
|
455
|
+
* reaches a terminal lifecycle state. Other injected inputs may start and
|
|
456
|
+
* finish while this drain is active; their callbacks advance independently
|
|
457
|
+
* and their later bookkeeping dispatch becomes a no-op.
|
|
458
|
+
*/
|
|
459
|
+
private async *consumeDelivery(
|
|
460
|
+
sessionKey: string,
|
|
461
|
+
state: ProcessState,
|
|
462
|
+
target: ClaudeInputDelivery,
|
|
463
|
+
log: GatewayLogger | undefined,
|
|
464
|
+
): AsyncGenerator<RuntimeEvent> {
|
|
465
|
+
if (target.terminal) return;
|
|
466
|
+
const groupKey = randomUUID();
|
|
467
|
+
let sawError = false;
|
|
468
|
+
// Turn-outcome evidence for this dispatch (agent-turn-outcome-design
|
|
469
|
+
// §4.1). Lifecycle terminals — not turn_end — bound consumption, so the
|
|
470
|
+
// result frame for the turn that carried this delivery is the LAST
|
|
471
|
+
// non-poison turn_end observed before the terminal. Poison frames
|
|
472
|
+
// (num_turns: 0 startup artifacts) never overwrite evidence. At most one
|
|
473
|
+
// turn_outcome is emitted per dispatch, right before the generator
|
|
474
|
+
// returns; with no evidence and no crash, nothing is emitted and the
|
|
475
|
+
// legacy boolean error path stands (refine-only, never guess).
|
|
476
|
+
let lastResultMeta: ClaudeResultMeta | undefined;
|
|
477
|
+
const noticeTexts: string[] = [];
|
|
478
|
+
// usage_limit settles via the lane-level deferred complete; a failed-input
|
|
479
|
+
// report would race it with an immediate redrive into the choked LLM.
|
|
480
|
+
const settledAsLimit = () =>
|
|
481
|
+
classifyClaudeTurn(lastResultMeta, noticeTexts).outcome === 'usage_limit';
|
|
482
|
+
|
|
483
|
+
while (!target.terminal) {
|
|
467
484
|
const next = await state.parser.next();
|
|
468
485
|
|
|
469
486
|
if (next.done) {
|
|
@@ -476,6 +493,8 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
476
493
|
if (detail) {
|
|
477
494
|
log?.warn?.(`subprocess stderr: ${detail}`);
|
|
478
495
|
}
|
|
496
|
+
if (settledAsLimit()) target.suppressFailReport = true;
|
|
497
|
+
await state.inputs.failBestEffort(target, log);
|
|
479
498
|
if (!sawError) {
|
|
480
499
|
yield {
|
|
481
500
|
type: 'error',
|
|
@@ -484,43 +503,101 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
484
503
|
`Claude exited with code ${exit.code ?? 'unknown'}${exit.signal ? ` (${exit.signal})` : ''}`,
|
|
485
504
|
};
|
|
486
505
|
}
|
|
506
|
+
// No evidence at all classifies as runtime_crash (the process died
|
|
507
|
+
// before any result frame); with evidence, classify what we saw.
|
|
508
|
+
yield classifyClaudeTurn(lastResultMeta, noticeTexts);
|
|
487
509
|
return;
|
|
488
510
|
}
|
|
489
511
|
|
|
490
512
|
const parsed = next.value;
|
|
491
513
|
|
|
492
|
-
if (
|
|
493
|
-
|
|
494
|
-
parsed.
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
514
|
+
if (parsed.type === 'runtime_init') {
|
|
515
|
+
state.capabilities = new Set(parsed.capabilities);
|
|
516
|
+
if (parsed.sessionId) {
|
|
517
|
+
this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
|
|
518
|
+
yield {
|
|
519
|
+
type: 'runtime_session',
|
|
520
|
+
runtimeSessionId: parsed.sessionId,
|
|
521
|
+
runtimeLaneKey: sessionKey,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
if (!state.capabilities.has('msg_lifecycle_v1')) {
|
|
525
|
+
await state.inputs.failBestEffort(target, log);
|
|
526
|
+
yield {
|
|
527
|
+
type: 'error',
|
|
528
|
+
message:
|
|
529
|
+
'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
|
|
530
|
+
};
|
|
531
|
+
this.killProcess(sessionKey, state);
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
continue;
|
|
498
535
|
}
|
|
499
536
|
|
|
500
|
-
if (parsed.type === '
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
537
|
+
if (parsed.type === 'command_lifecycle') {
|
|
538
|
+
if (!state.capabilities?.has('msg_lifecycle_v1')) {
|
|
539
|
+
await state.inputs.failBestEffort(target, log);
|
|
540
|
+
yield {
|
|
541
|
+
type: 'error',
|
|
542
|
+
message: 'Claude emitted command lifecycle before advertising msg_lifecycle_v1',
|
|
543
|
+
};
|
|
544
|
+
this.killProcess(sessionKey, state);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const delivery = state.inputs.getByCommand(parsed.commandUuid);
|
|
548
|
+
if (!delivery) {
|
|
549
|
+
log?.warn?.(`ignoring lifecycle for unknown Claude command ${parsed.commandUuid}`);
|
|
550
|
+
continue;
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
await state.inputs.apply(delivery, parsed.state);
|
|
554
|
+
} catch (err) {
|
|
555
|
+
await state.inputs.failBestEffort(delivery, log);
|
|
556
|
+
yield {
|
|
557
|
+
type: 'error',
|
|
558
|
+
message: `Claude input lifecycle update failed: ${String(err)}`,
|
|
559
|
+
};
|
|
560
|
+
this.killProcess(sessionKey, state);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
507
563
|
continue;
|
|
508
564
|
}
|
|
509
565
|
|
|
510
566
|
if (parsed.type === 'turn_end') {
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
spuriousTurnEndSkips++;
|
|
517
|
-
log?.warn?.('ignoring spurious empty turn_end (num_turns=0) — resume/fork poison frame');
|
|
518
|
-
continue;
|
|
567
|
+
// Evidence capture: the poison startup frame (numTurns === 0) is a
|
|
568
|
+
// resume artifact, not a turn boundary — never let it overwrite real
|
|
569
|
+
// evidence.
|
|
570
|
+
if (parsed.numTurns !== 0) {
|
|
571
|
+
lastResultMeta = parsed.resultMeta;
|
|
519
572
|
}
|
|
520
|
-
if (
|
|
521
|
-
|
|
573
|
+
if (parsed.isError) {
|
|
574
|
+
const limitSettled = settledAsLimit();
|
|
575
|
+
const failedDelivery = parsed.userMessageUuid
|
|
576
|
+
? state.inputs.getByCommand(parsed.userMessageUuid)
|
|
577
|
+
: undefined;
|
|
578
|
+
if (!failedDelivery) {
|
|
579
|
+
if (limitSettled) {
|
|
580
|
+
for (const delivery of state.inputs.values()) {
|
|
581
|
+
delivery.suppressFailReport = true;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
await state.inputs.failAllBestEffort(log);
|
|
585
|
+
yield classifyClaudeTurn(lastResultMeta, noticeTexts);
|
|
586
|
+
this.killProcess(sessionKey, state);
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
failedDelivery.resultFailed = true;
|
|
590
|
+
if (limitSettled) failedDelivery.suppressFailReport = true;
|
|
522
591
|
}
|
|
523
|
-
|
|
592
|
+
// result adjacency is not a consumption boundary: the matching
|
|
593
|
+
// command_lifecycle(completed) may follow it or interleave with a
|
|
594
|
+
// different queued/injected command.
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (parsed.type === 'assistant_error') {
|
|
599
|
+
noticeTexts.push(parsed.message);
|
|
600
|
+
continue;
|
|
524
601
|
}
|
|
525
602
|
|
|
526
603
|
if (parsed.type === 'error') {
|
|
@@ -539,8 +616,25 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
539
616
|
continue;
|
|
540
617
|
}
|
|
541
618
|
|
|
619
|
+
if (parsed.type === 'turn_outcome') {
|
|
620
|
+
// (never produced by the parser — type guard only)
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
542
624
|
yield { ...parsed, groupKey };
|
|
543
625
|
}
|
|
626
|
+
|
|
627
|
+
// Delivery reached a lifecycle terminal. Classify only when a result
|
|
628
|
+
// frame was observed during this drain: a delivery whose frames were
|
|
629
|
+
// drained by a sibling consumption window has no evidence here, and
|
|
630
|
+
// guessing would mislabel the turn (refine-only rule, §4.3).
|
|
631
|
+
if (lastResultMeta) {
|
|
632
|
+
yield classifyClaudeTurn(lastResultMeta, noticeTexts);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (state.needsRestart && !state.inputs.hasPendingInjections()) {
|
|
636
|
+
this.killProcess(sessionKey, state);
|
|
637
|
+
}
|
|
544
638
|
}
|
|
545
639
|
|
|
546
640
|
private ensureProcess(sessionKey: string, log: GatewayLogger | undefined): ProcessState {
|
|
@@ -562,14 +656,28 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
562
656
|
|
|
563
657
|
const handle = this.spawnProcess(sessionKey, log);
|
|
564
658
|
const parser = parseClaudeStreamJson(handle.proc.stdout!);
|
|
565
|
-
const state: ProcessState = {
|
|
659
|
+
const state: ProcessState = {
|
|
660
|
+
handle,
|
|
661
|
+
parser,
|
|
662
|
+
done: false,
|
|
663
|
+
needsRestart: false,
|
|
664
|
+
// A throwaway process already proved the current CLI advertises this
|
|
665
|
+
// capability. Real 2.1.220 sends queued/started before its own init,
|
|
666
|
+
// so pre-seed the gate and still verify the real init when it arrives.
|
|
667
|
+
capabilities: new Set(['msg_lifecycle_v1']),
|
|
668
|
+
inputs: new ClaudeInputRegistry(),
|
|
669
|
+
};
|
|
566
670
|
this.processes.set(sessionKey, state);
|
|
567
671
|
this.opts.sessionManager.registerProcess(sessionKey, handle);
|
|
568
672
|
return state;
|
|
569
673
|
}
|
|
570
674
|
|
|
571
|
-
private spawnProcess(
|
|
572
|
-
|
|
675
|
+
private spawnProcess(
|
|
676
|
+
sessionKey: string,
|
|
677
|
+
log: GatewayLogger | undefined,
|
|
678
|
+
resume = true,
|
|
679
|
+
): ClaudeProcessHandle {
|
|
680
|
+
const args = this.buildArgs(sessionKey, resume);
|
|
573
681
|
const env = buildSpawnEnv(
|
|
574
682
|
process.env,
|
|
575
683
|
this.opts.claudeHome,
|
|
@@ -625,20 +733,27 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
625
733
|
}
|
|
626
734
|
|
|
627
735
|
private killProcess(sessionKey: string, state: ProcessState) {
|
|
736
|
+
for (const delivery of state.inputs.values()) {
|
|
737
|
+
if (!delivery.terminal) void state.inputs.failBestEffort(delivery);
|
|
738
|
+
}
|
|
628
739
|
state.done = true;
|
|
629
740
|
const current = this.processes.get(sessionKey);
|
|
630
741
|
if (current === state) {
|
|
631
742
|
this.processes.delete(sessionKey);
|
|
632
743
|
}
|
|
744
|
+
this.terminateHandle(state.handle);
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
private terminateHandle(handle: ClaudeProcessHandle): void {
|
|
633
748
|
try {
|
|
634
|
-
|
|
749
|
+
handle.proc.stdin.end();
|
|
635
750
|
} catch {
|
|
636
751
|
/* best-effort */
|
|
637
752
|
}
|
|
638
|
-
if (
|
|
753
|
+
if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
|
|
639
754
|
try {
|
|
640
|
-
if (!IS_WIN32 || !
|
|
641
|
-
|
|
755
|
+
if (!IS_WIN32 || !handle.proc.pid || !killWin32Tree(handle.proc.pid)) {
|
|
756
|
+
handle.proc.kill('SIGTERM');
|
|
642
757
|
}
|
|
643
758
|
} catch {
|
|
644
759
|
/* best-effort */
|
|
@@ -646,9 +761,21 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
646
761
|
}
|
|
647
762
|
}
|
|
648
763
|
|
|
649
|
-
private
|
|
764
|
+
private writeCapabilityProbe(handle: ClaudeProcessHandle): void {
|
|
765
|
+
const payload = JSON.stringify({
|
|
766
|
+
type: 'user',
|
|
767
|
+
uuid: randomUUID(),
|
|
768
|
+
parent_tool_use_id: null,
|
|
769
|
+
message: { role: 'user', content: [] },
|
|
770
|
+
});
|
|
771
|
+
handle.proc.stdin.write(`${payload}\n`);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
private writeUserMessage(handle: ClaudeProcessHandle, text: string, commandUuid: string) {
|
|
650
775
|
const payload = JSON.stringify({
|
|
651
776
|
type: 'user',
|
|
777
|
+
uuid: commandUuid,
|
|
778
|
+
parent_tool_use_id: null,
|
|
652
779
|
message: {
|
|
653
780
|
role: 'user',
|
|
654
781
|
content: [{ type: 'text', text }],
|
|
@@ -657,7 +784,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
657
784
|
handle.proc.stdin.write(`${payload}\n`);
|
|
658
785
|
}
|
|
659
786
|
|
|
660
|
-
private buildArgs(sessionKey: string): string[] {
|
|
787
|
+
private buildArgs(sessionKey: string, resume = true): string[] {
|
|
661
788
|
const args = [
|
|
662
789
|
'--verbose',
|
|
663
790
|
'--input-format',
|
|
@@ -693,7 +820,7 @@ export class ClaudeCodeAdapter implements DispatchAdapter {
|
|
|
693
820
|
args.push('--add-dir', ...this.opts.additionalDirs);
|
|
694
821
|
}
|
|
695
822
|
|
|
696
|
-
args.push(...this.opts.sessionManager.getResumeArgs(sessionKey));
|
|
823
|
+
if (resume) args.push(...this.opts.sessionManager.getResumeArgs(sessionKey));
|
|
697
824
|
return args;
|
|
698
825
|
}
|
|
699
826
|
|