@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/dist/dispatch.js
CHANGED
|
@@ -3,12 +3,13 @@ import * as path from 'node:path';
|
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { execSync, spawn } from 'node:child_process';
|
|
5
5
|
import { appendPreparedLocalAttachmentRefs, pinLocalAttachmentPaths, } from '@parall/agent-core/internal/attachment-input';
|
|
6
|
-
import {
|
|
6
|
+
import { ClaudeInputRegistry } from './input-lifecycle.js';
|
|
7
|
+
import { parseClaudeStreamJson, } from './output-parser.js';
|
|
8
|
+
import { classifyClaudeTurn } from './turn-outcome.js';
|
|
7
9
|
const IS_WIN32 = process.platform === 'win32';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const MAX_SPURIOUS_TURN_END_SKIPS = 3;
|
|
10
|
+
const CAPABILITY_PROBE_TIMEOUT_MS = 15_000;
|
|
11
|
+
class MissingClaudeLifecycleCapabilityError extends Error {
|
|
12
|
+
}
|
|
12
13
|
function quoteWin32Arg(arg) {
|
|
13
14
|
if (!/[\s"&|^<>()]/.test(arg))
|
|
14
15
|
return arg;
|
|
@@ -77,8 +78,10 @@ export function buildSpawnEnv(parentEnv, claudeHome, context, opts) {
|
|
|
77
78
|
}
|
|
78
79
|
export class ClaudeCodeAdapter {
|
|
79
80
|
opts;
|
|
81
|
+
inputLifecycleMode = 'explicit';
|
|
80
82
|
processes = new Map();
|
|
81
|
-
|
|
83
|
+
capabilityProbe;
|
|
84
|
+
capabilityProbeHandle;
|
|
82
85
|
shuttingDown = false;
|
|
83
86
|
_model;
|
|
84
87
|
_effortLevel;
|
|
@@ -114,27 +117,46 @@ export class ClaudeCodeAdapter {
|
|
|
114
117
|
state.needsRestart = true;
|
|
115
118
|
}
|
|
116
119
|
}
|
|
117
|
-
enqueueDuringDispatch(sessionKey, body) {
|
|
120
|
+
enqueueDuringDispatch(sessionKey, body, inputLifecycle) {
|
|
121
|
+
// Exact identity is mandatory for soft steer. Without it the later
|
|
122
|
+
// buffered dispatch could only guess which stdout boundary belonged to
|
|
123
|
+
// this input — the bug this adapter is designed to remove.
|
|
124
|
+
if (!inputLifecycle)
|
|
125
|
+
return false;
|
|
118
126
|
const state = this.processes.get(sessionKey);
|
|
119
127
|
if (!state || state.done)
|
|
120
128
|
return false;
|
|
129
|
+
// A throwaway process already proved the CLI capability before any
|
|
130
|
+
// business process started. The real process revalidates its own init.
|
|
131
|
+
if (!state.capabilities?.has('msg_lifecycle_v1'))
|
|
132
|
+
return false;
|
|
121
133
|
const { proc } = state.handle;
|
|
122
134
|
if (proc.exitCode !== null || proc.signalCode !== null || proc.stdin.destroyed)
|
|
123
135
|
return false;
|
|
124
136
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
if (state.inputs.getByKey(inputLifecycle.deliveryKey))
|
|
138
|
+
return true;
|
|
139
|
+
const delivery = state.inputs.register(inputLifecycle.deliveryKey, inputLifecycle, true);
|
|
140
|
+
this.writeUserMessage(state.handle, body, delivery.commandUuid);
|
|
127
141
|
return true;
|
|
128
142
|
}
|
|
129
143
|
catch {
|
|
144
|
+
const delivery = state.inputs.getByKey(inputLifecycle.deliveryKey);
|
|
145
|
+
if (delivery) {
|
|
146
|
+
void state.inputs.failBestEffort(delivery);
|
|
147
|
+
state.inputs.remove(delivery);
|
|
148
|
+
}
|
|
130
149
|
return false;
|
|
131
150
|
}
|
|
132
151
|
}
|
|
133
152
|
abortDispatch(sessionKey) {
|
|
134
|
-
this.pendingInjections.delete(sessionKey);
|
|
135
153
|
const state = this.processes.get(sessionKey);
|
|
136
154
|
if (!state || state.done)
|
|
137
155
|
return;
|
|
156
|
+
for (const delivery of state.inputs.values()) {
|
|
157
|
+
if (!delivery.terminal)
|
|
158
|
+
void state.inputs.failBestEffort(delivery);
|
|
159
|
+
}
|
|
138
160
|
state.done = true;
|
|
139
161
|
try {
|
|
140
162
|
state.handle.proc.stdin.end();
|
|
@@ -144,17 +166,35 @@ export class ClaudeCodeAdapter {
|
|
|
144
166
|
}
|
|
145
167
|
}
|
|
146
168
|
hasPendingInjections(sessionKey) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
const state = this.processes.get(sessionKey);
|
|
170
|
+
if (!state)
|
|
171
|
+
return false;
|
|
172
|
+
return state.inputs.hasPendingInjections();
|
|
173
|
+
}
|
|
174
|
+
async *dispatch({ event, bodyForAgent, sessionKey, context, inputLifecycle, }) {
|
|
175
|
+
const deliveryKey = inputLifecycle?.deliveryKey ?? event.dispatchEventId ?? event.messageId;
|
|
176
|
+
const existingState = this.processes.get(sessionKey);
|
|
177
|
+
const injected = existingState?.inputs.getByKey(deliveryKey);
|
|
178
|
+
if (existingState && injected) {
|
|
179
|
+
if (injected.terminal === 'failed') {
|
|
180
|
+
// The server released this exact WorkItem for retry. Its buffered
|
|
181
|
+
// bookkeeping dispatch is now real work again, not a no-op.
|
|
182
|
+
existingState.inputs.remove(injected);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
injected.drained = true;
|
|
186
|
+
context.log?.info?.(`consuming steer input ${injected.commandUuid}`);
|
|
187
|
+
try {
|
|
188
|
+
yield* this.consumeDelivery(sessionKey, existingState, injected, context.log);
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
existingState.inputs.remove(injected);
|
|
192
|
+
}
|
|
193
|
+
if (existingState.needsRestart && !existingState.inputs.hasPendingInjections()) {
|
|
194
|
+
this.killProcess(sessionKey, existingState);
|
|
195
|
+
}
|
|
196
|
+
return;
|
|
156
197
|
}
|
|
157
|
-
return;
|
|
158
198
|
}
|
|
159
199
|
let promptBody = bodyForAgent;
|
|
160
200
|
let releasePreparedAttachments = () => { };
|
|
@@ -170,7 +210,7 @@ export class ClaudeCodeAdapter {
|
|
|
170
210
|
context.log?.warn?.(`failed to prepare local attachments: ${String(err)}`);
|
|
171
211
|
}
|
|
172
212
|
try {
|
|
173
|
-
yield* this.runTurn(sessionKey, promptBody, context.log);
|
|
213
|
+
yield* this.runTurn(sessionKey, promptBody, deliveryKey, inputLifecycle, context.log);
|
|
174
214
|
}
|
|
175
215
|
finally {
|
|
176
216
|
releasePreparedAttachments();
|
|
@@ -202,163 +242,130 @@ export class ClaudeCodeAdapter {
|
|
|
202
242
|
this.killProcess(sessionKey, state);
|
|
203
243
|
}
|
|
204
244
|
this.processes.clear();
|
|
205
|
-
this.pendingInjections.clear();
|
|
206
245
|
}
|
|
207
246
|
async shutdown() {
|
|
208
247
|
this.shuttingDown = true;
|
|
248
|
+
if (this.capabilityProbeHandle) {
|
|
249
|
+
this.terminateHandle(this.capabilityProbeHandle);
|
|
250
|
+
this.capabilityProbeHandle = undefined;
|
|
251
|
+
}
|
|
209
252
|
this.resetProcesses();
|
|
210
253
|
await this.opts.sessionManager.shutdownAll();
|
|
211
254
|
}
|
|
212
|
-
|
|
213
|
-
// chiefly so tests can exercise the timeout path without a 10s wait.
|
|
214
|
-
steerTurnTimeoutMs = Number(process.env.PRLL_STEER_TURN_TIMEOUT_MS) || 10_000;
|
|
215
|
-
/**
|
|
216
|
-
* Consume a steer turn whose message was already written to stdin via
|
|
217
|
-
* enqueueDuringDispatch. Skip the stdin write — only read parser output.
|
|
218
|
-
* If no output arrives within STEER_TURN_TIMEOUT_MS, the steer was
|
|
219
|
-
* incorporated into the previous turn and there is nothing to consume.
|
|
220
|
-
*
|
|
221
|
-
* On timeout, the losing parser.next() promise is saved to
|
|
222
|
-
* state.steerReadPending so the next runTurn can drain it instead of
|
|
223
|
-
* silently losing the first event of the subsequent turn.
|
|
224
|
-
*/
|
|
225
|
-
async *consumeSteerTurn(sessionKey, log) {
|
|
226
|
-
const state = this.processes.get(sessionKey);
|
|
227
|
-
if (!state || state.done) {
|
|
228
|
-
throw new Error('process dead during steer consumption');
|
|
229
|
-
}
|
|
230
|
-
const groupKey = randomUUID();
|
|
231
|
-
// Reuse a pull already issued by a prior steer timeout instead of issuing a
|
|
232
|
-
// fresh one. A burst of N steers all takes the pending-skip path, which calls
|
|
233
|
-
// this method N times in a loop. Issuing a new state.parser.next() on each
|
|
234
|
-
// call while a previous pull is still in flight queues multiple reads on the
|
|
235
|
-
// same async generator, but only one can be stashed in the single-slot
|
|
236
|
-
// state.steerReadPending — every earlier pull is then consumed-and-dropped,
|
|
237
|
-
// silently losing parser events. When a dropped event is a tool_call, its
|
|
238
|
-
// tool_result later orphans into "call_id does not match any tool_call".
|
|
239
|
-
// Threading the single in-flight pull through every call keeps at most one
|
|
240
|
-
// outstanding read and loses nothing.
|
|
241
|
-
const parserNext = state.steerReadPending ?? state.parser.next();
|
|
242
|
-
state.steerReadPending = undefined;
|
|
243
|
-
const firstRead = await Promise.race([
|
|
244
|
-
parserNext.then((r) => ({ kind: 'value', result: r })),
|
|
245
|
-
new Promise((resolve) => setTimeout(() => resolve({ kind: 'timeout' }), this.steerTurnTimeoutMs)),
|
|
246
|
-
]);
|
|
247
|
-
if (firstRead.kind === 'timeout') {
|
|
248
|
-
state.steerReadPending = parserNext;
|
|
249
|
-
log?.info?.(`steer turn timeout — steer was incorporated into previous turn`);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
let next = firstRead.result;
|
|
253
|
-
// eslint-disable-next-line no-constant-condition
|
|
254
|
-
while (true) {
|
|
255
|
-
if (next.done) {
|
|
256
|
-
state.done = true;
|
|
257
|
-
this.processes.delete(sessionKey);
|
|
258
|
-
throw new Error('process exited while consuming steer turn');
|
|
259
|
-
}
|
|
260
|
-
const parsed = next.value;
|
|
261
|
-
if (parsed.type === 'session_id') {
|
|
262
|
-
this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
|
|
263
|
-
yield {
|
|
264
|
-
type: 'runtime_session',
|
|
265
|
-
runtimeSessionId: parsed.sessionId,
|
|
266
|
-
runtimeLaneKey: sessionKey,
|
|
267
|
-
};
|
|
268
|
-
}
|
|
269
|
-
else if (parsed.type === 'turn_end') {
|
|
270
|
-
if (state.needsRestart)
|
|
271
|
-
this.killProcess(sessionKey, state);
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
else if (parsed.type === 'error') {
|
|
275
|
-
yield parsed;
|
|
276
|
-
}
|
|
277
|
-
else if (parsed.type === 'text') {
|
|
278
|
-
yield { ...parsed, project: false, groupKey };
|
|
279
|
-
}
|
|
280
|
-
else if (parsed.type === 'runtime_session') {
|
|
281
|
-
yield parsed;
|
|
282
|
-
}
|
|
283
|
-
else {
|
|
284
|
-
yield { ...parsed, groupKey };
|
|
285
|
-
}
|
|
286
|
-
next = await state.parser.next();
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
async *runTurn(sessionKey, promptBody, log) {
|
|
255
|
+
async *runTurn(sessionKey, promptBody, deliveryKey, lifecycle, log) {
|
|
290
256
|
let state;
|
|
291
257
|
try {
|
|
258
|
+
await this.ensureRuntimeCapability(log);
|
|
292
259
|
state = this.ensureProcess(sessionKey, log);
|
|
293
260
|
}
|
|
294
261
|
catch (err) {
|
|
262
|
+
try {
|
|
263
|
+
await lifecycle?.update('failed');
|
|
264
|
+
}
|
|
265
|
+
catch (reportErr) {
|
|
266
|
+
log?.warn?.(`failed to report rejected Claude input as failed: ${String(reportErr)}`);
|
|
267
|
+
}
|
|
295
268
|
yield { type: 'error', message: `Claude spawn failed: ${String(err)}` };
|
|
296
269
|
return;
|
|
297
270
|
}
|
|
298
|
-
const
|
|
299
|
-
let sawError = false;
|
|
300
|
-
// Poison-frame guard. When `--resume` (with or without `--fork-session`)
|
|
301
|
-
// targets a transcript whose tail is dangling (previous process was
|
|
302
|
-
// killed mid-turn — exactly the fork-on-busy case), Claude CLI emits a
|
|
303
|
-
// spurious EMPTY result frame (num_turns: 0) at startup, BEFORE the
|
|
304
|
-
// queued stdin user frame is processed. Treating its turn_end as the
|
|
305
|
-
// dispatch boundary returns with zero output, the work item sweeps as
|
|
306
|
-
// no_action, and the real reply is orphaned in a soon-to-be-killed
|
|
307
|
-
// subprocess. A legitimate turn always reports num_turns >= 1 (even with
|
|
308
|
-
// no visible text); undefined (CLIs that omit the field) must NOT skip.
|
|
309
|
-
let sawSubstantiveEvent = false;
|
|
310
|
-
let spuriousTurnEndSkips = 0;
|
|
271
|
+
const delivery = state.inputs.register(deliveryKey, lifecycle, false);
|
|
311
272
|
try {
|
|
312
|
-
this.writeUserMessage(state.handle, promptBody);
|
|
273
|
+
this.writeUserMessage(state.handle, promptBody, delivery.commandUuid);
|
|
313
274
|
}
|
|
314
275
|
catch (err) {
|
|
276
|
+
await state.inputs.failBestEffort(delivery, log);
|
|
277
|
+
state.inputs.remove(delivery);
|
|
315
278
|
yield { type: 'error', message: `Claude stdin write failed: ${String(err)}` };
|
|
316
279
|
this.killProcess(sessionKey, state);
|
|
317
280
|
return;
|
|
318
281
|
}
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
}
|
|
333
|
-
if (parsed.type === 'session_id') {
|
|
334
|
-
this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
|
|
335
|
-
yield {
|
|
336
|
-
type: 'runtime_session',
|
|
337
|
-
runtimeSessionId: parsed.sessionId,
|
|
338
|
-
runtimeLaneKey: sessionKey,
|
|
339
|
-
};
|
|
340
|
-
}
|
|
341
|
-
else if (parsed.type === 'turn_end') {
|
|
342
|
-
if (state.needsRestart)
|
|
343
|
-
this.killProcess(sessionKey, state);
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
else if (parsed.type === 'error') {
|
|
347
|
-
sawError = true;
|
|
348
|
-
yield parsed;
|
|
349
|
-
}
|
|
350
|
-
else if (parsed.type === 'text') {
|
|
351
|
-
yield { ...parsed, project: false, groupKey };
|
|
282
|
+
try {
|
|
283
|
+
yield* this.consumeDelivery(sessionKey, state, delivery, log);
|
|
284
|
+
}
|
|
285
|
+
finally {
|
|
286
|
+
state.inputs.remove(delivery);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
ensureRuntimeCapability(log) {
|
|
290
|
+
if (!this.capabilityProbe) {
|
|
291
|
+
const probe = this.probeRuntimeCapability(log);
|
|
292
|
+
this.capabilityProbe = probe.catch((err) => {
|
|
293
|
+
if (!(err instanceof MissingClaudeLifecycleCapabilityError)) {
|
|
294
|
+
this.capabilityProbe = undefined;
|
|
352
295
|
}
|
|
353
|
-
|
|
354
|
-
|
|
296
|
+
throw err;
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return this.capabilityProbe;
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Claude does not emit system.init until it receives its first stdin
|
|
303
|
+
* command (2.1.220 emits queued/started before init). Probe a throwaway
|
|
304
|
+
* process with an empty, non-WorkItem command; only after its init
|
|
305
|
+
* advertises msg_lifecycle_v1 may any business input enter a real process.
|
|
306
|
+
*/
|
|
307
|
+
async probeRuntimeCapability(log) {
|
|
308
|
+
const handle = this.spawnProcess('__capability_probe__', log, false);
|
|
309
|
+
this.capabilityProbeHandle = handle;
|
|
310
|
+
const parser = parseClaudeStreamJson(handle.proc.stdout);
|
|
311
|
+
let timedOut = false;
|
|
312
|
+
const timer = setTimeout(() => {
|
|
313
|
+
timedOut = true;
|
|
314
|
+
this.terminateHandle(handle);
|
|
315
|
+
}, CAPABILITY_PROBE_TIMEOUT_MS);
|
|
316
|
+
timer.unref?.();
|
|
317
|
+
try {
|
|
318
|
+
this.writeCapabilityProbe(handle);
|
|
319
|
+
while (true) {
|
|
320
|
+
const next = await parser.next();
|
|
321
|
+
if (next.done) {
|
|
322
|
+
const detail = handle.stderrChunks.join('').trim();
|
|
323
|
+
if (timedOut) {
|
|
324
|
+
throw new Error(`Claude runtime capability probe timed out after ${CAPABILITY_PROBE_TIMEOUT_MS}ms`);
|
|
325
|
+
}
|
|
326
|
+
throw new Error(detail || 'Claude exited before its runtime capability probe completed');
|
|
355
327
|
}
|
|
356
|
-
|
|
357
|
-
|
|
328
|
+
if (next.value.type !== 'runtime_init')
|
|
329
|
+
continue;
|
|
330
|
+
if (!next.value.capabilities.includes('msg_lifecycle_v1')) {
|
|
331
|
+
throw new MissingClaudeLifecycleCapabilityError('Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage');
|
|
358
332
|
}
|
|
333
|
+
return;
|
|
359
334
|
}
|
|
360
335
|
}
|
|
361
|
-
|
|
336
|
+
finally {
|
|
337
|
+
clearTimeout(timer);
|
|
338
|
+
if (this.capabilityProbeHandle === handle) {
|
|
339
|
+
this.capabilityProbeHandle = undefined;
|
|
340
|
+
}
|
|
341
|
+
this.terminateHandle(handle);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Drain the shared stdout stream until the exact UUID written for target
|
|
346
|
+
* reaches a terminal lifecycle state. Other injected inputs may start and
|
|
347
|
+
* finish while this drain is active; their callbacks advance independently
|
|
348
|
+
* and their later bookkeeping dispatch becomes a no-op.
|
|
349
|
+
*/
|
|
350
|
+
async *consumeDelivery(sessionKey, state, target, log) {
|
|
351
|
+
if (target.terminal)
|
|
352
|
+
return;
|
|
353
|
+
const groupKey = randomUUID();
|
|
354
|
+
let sawError = false;
|
|
355
|
+
// Turn-outcome evidence for this dispatch (agent-turn-outcome-design
|
|
356
|
+
// §4.1). Lifecycle terminals — not turn_end — bound consumption, so the
|
|
357
|
+
// result frame for the turn that carried this delivery is the LAST
|
|
358
|
+
// non-poison turn_end observed before the terminal. Poison frames
|
|
359
|
+
// (num_turns: 0 startup artifacts) never overwrite evidence. At most one
|
|
360
|
+
// turn_outcome is emitted per dispatch, right before the generator
|
|
361
|
+
// returns; with no evidence and no crash, nothing is emitted and the
|
|
362
|
+
// legacy boolean error path stands (refine-only, never guess).
|
|
363
|
+
let lastResultMeta;
|
|
364
|
+
const noticeTexts = [];
|
|
365
|
+
// usage_limit settles via the lane-level deferred complete; a failed-input
|
|
366
|
+
// report would race it with an immediate redrive into the choked LLM.
|
|
367
|
+
const settledAsLimit = () => classifyClaudeTurn(lastResultMeta, noticeTexts).outcome === 'usage_limit';
|
|
368
|
+
while (!target.terminal) {
|
|
362
369
|
const next = await state.parser.next();
|
|
363
370
|
if (next.done) {
|
|
364
371
|
state.done = true;
|
|
@@ -368,6 +375,9 @@ export class ClaudeCodeAdapter {
|
|
|
368
375
|
if (detail) {
|
|
369
376
|
log?.warn?.(`subprocess stderr: ${detail}`);
|
|
370
377
|
}
|
|
378
|
+
if (settledAsLimit())
|
|
379
|
+
target.suppressFailReport = true;
|
|
380
|
+
await state.inputs.failBestEffort(target, log);
|
|
371
381
|
if (!sawError) {
|
|
372
382
|
yield {
|
|
373
383
|
type: 'error',
|
|
@@ -375,35 +385,97 @@ export class ClaudeCodeAdapter {
|
|
|
375
385
|
`Claude exited with code ${exit.code ?? 'unknown'}${exit.signal ? ` (${exit.signal})` : ''}`,
|
|
376
386
|
};
|
|
377
387
|
}
|
|
388
|
+
// No evidence at all classifies as runtime_crash (the process died
|
|
389
|
+
// before any result frame); with evidence, classify what we saw.
|
|
390
|
+
yield classifyClaudeTurn(lastResultMeta, noticeTexts);
|
|
378
391
|
return;
|
|
379
392
|
}
|
|
380
393
|
const parsed = next.value;
|
|
381
|
-
if (parsed.type
|
|
382
|
-
|
|
383
|
-
parsed.
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
394
|
+
if (parsed.type === 'runtime_init') {
|
|
395
|
+
state.capabilities = new Set(parsed.capabilities);
|
|
396
|
+
if (parsed.sessionId) {
|
|
397
|
+
this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
|
|
398
|
+
yield {
|
|
399
|
+
type: 'runtime_session',
|
|
400
|
+
runtimeSessionId: parsed.sessionId,
|
|
401
|
+
runtimeLaneKey: sessionKey,
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
if (!state.capabilities.has('msg_lifecycle_v1')) {
|
|
405
|
+
await state.inputs.failBestEffort(target, log);
|
|
406
|
+
yield {
|
|
407
|
+
type: 'error',
|
|
408
|
+
message: 'Claude runtime lacks required msg_lifecycle_v1 capability; refusing heuristic input coverage',
|
|
409
|
+
};
|
|
410
|
+
this.killProcess(sessionKey, state);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
393
413
|
continue;
|
|
394
414
|
}
|
|
395
|
-
if (parsed.type === '
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
415
|
+
if (parsed.type === 'command_lifecycle') {
|
|
416
|
+
if (!state.capabilities?.has('msg_lifecycle_v1')) {
|
|
417
|
+
await state.inputs.failBestEffort(target, log);
|
|
418
|
+
yield {
|
|
419
|
+
type: 'error',
|
|
420
|
+
message: 'Claude emitted command lifecycle before advertising msg_lifecycle_v1',
|
|
421
|
+
};
|
|
422
|
+
this.killProcess(sessionKey, state);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
const delivery = state.inputs.getByCommand(parsed.commandUuid);
|
|
426
|
+
if (!delivery) {
|
|
427
|
+
log?.warn?.(`ignoring lifecycle for unknown Claude command ${parsed.commandUuid}`);
|
|
401
428
|
continue;
|
|
402
429
|
}
|
|
403
|
-
|
|
430
|
+
try {
|
|
431
|
+
await state.inputs.apply(delivery, parsed.state);
|
|
432
|
+
}
|
|
433
|
+
catch (err) {
|
|
434
|
+
await state.inputs.failBestEffort(delivery, log);
|
|
435
|
+
yield {
|
|
436
|
+
type: 'error',
|
|
437
|
+
message: `Claude input lifecycle update failed: ${String(err)}`,
|
|
438
|
+
};
|
|
404
439
|
this.killProcess(sessionKey, state);
|
|
440
|
+
return;
|
|
405
441
|
}
|
|
406
|
-
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
if (parsed.type === 'turn_end') {
|
|
445
|
+
// Evidence capture: the poison startup frame (numTurns === 0) is a
|
|
446
|
+
// resume artifact, not a turn boundary — never let it overwrite real
|
|
447
|
+
// evidence.
|
|
448
|
+
if (parsed.numTurns !== 0) {
|
|
449
|
+
lastResultMeta = parsed.resultMeta;
|
|
450
|
+
}
|
|
451
|
+
if (parsed.isError) {
|
|
452
|
+
const limitSettled = settledAsLimit();
|
|
453
|
+
const failedDelivery = parsed.userMessageUuid
|
|
454
|
+
? state.inputs.getByCommand(parsed.userMessageUuid)
|
|
455
|
+
: undefined;
|
|
456
|
+
if (!failedDelivery) {
|
|
457
|
+
if (limitSettled) {
|
|
458
|
+
for (const delivery of state.inputs.values()) {
|
|
459
|
+
delivery.suppressFailReport = true;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
await state.inputs.failAllBestEffort(log);
|
|
463
|
+
yield classifyClaudeTurn(lastResultMeta, noticeTexts);
|
|
464
|
+
this.killProcess(sessionKey, state);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
failedDelivery.resultFailed = true;
|
|
468
|
+
if (limitSettled)
|
|
469
|
+
failedDelivery.suppressFailReport = true;
|
|
470
|
+
}
|
|
471
|
+
// result adjacency is not a consumption boundary: the matching
|
|
472
|
+
// command_lifecycle(completed) may follow it or interleave with a
|
|
473
|
+
// different queued/injected command.
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (parsed.type === 'assistant_error') {
|
|
477
|
+
noticeTexts.push(parsed.message);
|
|
478
|
+
continue;
|
|
407
479
|
}
|
|
408
480
|
if (parsed.type === 'error') {
|
|
409
481
|
sawError = true;
|
|
@@ -418,8 +490,22 @@ export class ClaudeCodeAdapter {
|
|
|
418
490
|
yield parsed;
|
|
419
491
|
continue;
|
|
420
492
|
}
|
|
493
|
+
if (parsed.type === 'turn_outcome') {
|
|
494
|
+
// (never produced by the parser — type guard only)
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
421
497
|
yield { ...parsed, groupKey };
|
|
422
498
|
}
|
|
499
|
+
// Delivery reached a lifecycle terminal. Classify only when a result
|
|
500
|
+
// frame was observed during this drain: a delivery whose frames were
|
|
501
|
+
// drained by a sibling consumption window has no evidence here, and
|
|
502
|
+
// guessing would mislabel the turn (refine-only rule, §4.3).
|
|
503
|
+
if (lastResultMeta) {
|
|
504
|
+
yield classifyClaudeTurn(lastResultMeta, noticeTexts);
|
|
505
|
+
}
|
|
506
|
+
if (state.needsRestart && !state.inputs.hasPendingInjections()) {
|
|
507
|
+
this.killProcess(sessionKey, state);
|
|
508
|
+
}
|
|
423
509
|
}
|
|
424
510
|
ensureProcess(sessionKey, log) {
|
|
425
511
|
if (this.shuttingDown) {
|
|
@@ -440,13 +526,23 @@ export class ClaudeCodeAdapter {
|
|
|
440
526
|
}
|
|
441
527
|
const handle = this.spawnProcess(sessionKey, log);
|
|
442
528
|
const parser = parseClaudeStreamJson(handle.proc.stdout);
|
|
443
|
-
const state = {
|
|
529
|
+
const state = {
|
|
530
|
+
handle,
|
|
531
|
+
parser,
|
|
532
|
+
done: false,
|
|
533
|
+
needsRestart: false,
|
|
534
|
+
// A throwaway process already proved the current CLI advertises this
|
|
535
|
+
// capability. Real 2.1.220 sends queued/started before its own init,
|
|
536
|
+
// so pre-seed the gate and still verify the real init when it arrives.
|
|
537
|
+
capabilities: new Set(['msg_lifecycle_v1']),
|
|
538
|
+
inputs: new ClaudeInputRegistry(),
|
|
539
|
+
};
|
|
444
540
|
this.processes.set(sessionKey, state);
|
|
445
541
|
this.opts.sessionManager.registerProcess(sessionKey, handle);
|
|
446
542
|
return state;
|
|
447
543
|
}
|
|
448
|
-
spawnProcess(sessionKey, log) {
|
|
449
|
-
const args = this.buildArgs(sessionKey);
|
|
544
|
+
spawnProcess(sessionKey, log, resume = true) {
|
|
545
|
+
const args = this.buildArgs(sessionKey, resume);
|
|
450
546
|
const env = buildSpawnEnv(process.env, this.opts.claudeHome, this.buildPlaceholderContext(sessionKey), {
|
|
451
547
|
allowApiKey: this.opts.allowApiKey,
|
|
452
548
|
effortLevel: this._effortLevel,
|
|
@@ -482,21 +578,28 @@ export class ClaudeCodeAdapter {
|
|
|
482
578
|
return { proc, exitPromise, stderrChunks };
|
|
483
579
|
}
|
|
484
580
|
killProcess(sessionKey, state) {
|
|
581
|
+
for (const delivery of state.inputs.values()) {
|
|
582
|
+
if (!delivery.terminal)
|
|
583
|
+
void state.inputs.failBestEffort(delivery);
|
|
584
|
+
}
|
|
485
585
|
state.done = true;
|
|
486
586
|
const current = this.processes.get(sessionKey);
|
|
487
587
|
if (current === state) {
|
|
488
588
|
this.processes.delete(sessionKey);
|
|
489
589
|
}
|
|
590
|
+
this.terminateHandle(state.handle);
|
|
591
|
+
}
|
|
592
|
+
terminateHandle(handle) {
|
|
490
593
|
try {
|
|
491
|
-
|
|
594
|
+
handle.proc.stdin.end();
|
|
492
595
|
}
|
|
493
596
|
catch {
|
|
494
597
|
/* best-effort */
|
|
495
598
|
}
|
|
496
|
-
if (
|
|
599
|
+
if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
|
|
497
600
|
try {
|
|
498
|
-
if (!IS_WIN32 || !
|
|
499
|
-
|
|
601
|
+
if (!IS_WIN32 || !handle.proc.pid || !killWin32Tree(handle.proc.pid)) {
|
|
602
|
+
handle.proc.kill('SIGTERM');
|
|
500
603
|
}
|
|
501
604
|
}
|
|
502
605
|
catch {
|
|
@@ -504,9 +607,20 @@ export class ClaudeCodeAdapter {
|
|
|
504
607
|
}
|
|
505
608
|
}
|
|
506
609
|
}
|
|
507
|
-
|
|
610
|
+
writeCapabilityProbe(handle) {
|
|
611
|
+
const payload = JSON.stringify({
|
|
612
|
+
type: 'user',
|
|
613
|
+
uuid: randomUUID(),
|
|
614
|
+
parent_tool_use_id: null,
|
|
615
|
+
message: { role: 'user', content: [] },
|
|
616
|
+
});
|
|
617
|
+
handle.proc.stdin.write(`${payload}\n`);
|
|
618
|
+
}
|
|
619
|
+
writeUserMessage(handle, text, commandUuid) {
|
|
508
620
|
const payload = JSON.stringify({
|
|
509
621
|
type: 'user',
|
|
622
|
+
uuid: commandUuid,
|
|
623
|
+
parent_tool_use_id: null,
|
|
510
624
|
message: {
|
|
511
625
|
role: 'user',
|
|
512
626
|
content: [{ type: 'text', text }],
|
|
@@ -514,7 +628,7 @@ export class ClaudeCodeAdapter {
|
|
|
514
628
|
});
|
|
515
629
|
handle.proc.stdin.write(`${payload}\n`);
|
|
516
630
|
}
|
|
517
|
-
buildArgs(sessionKey) {
|
|
631
|
+
buildArgs(sessionKey, resume = true) {
|
|
518
632
|
const args = [
|
|
519
633
|
'--verbose',
|
|
520
634
|
'--input-format',
|
|
@@ -540,7 +654,8 @@ export class ClaudeCodeAdapter {
|
|
|
540
654
|
if (this.opts.additionalDirs.length > 0) {
|
|
541
655
|
args.push('--add-dir', ...this.opts.additionalDirs);
|
|
542
656
|
}
|
|
543
|
-
|
|
657
|
+
if (resume)
|
|
658
|
+
args.push(...this.opts.sessionManager.getResumeArgs(sessionKey));
|
|
544
659
|
return args;
|
|
545
660
|
}
|
|
546
661
|
buildPlaceholderContext(sessionKey) {
|