@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.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 { parseClaudeStreamJson } from './output-parser.js';
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
- // Upper bound on spurious empty turn_end frames skipped per turn (see the
9
- // guard in runTurn) — keeps a pathological stream of empty `result` frames
10
- // from spinning the drain loop forever.
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
- pendingInjections = new Map();
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
- this.writeUserMessage(state.handle, body);
126
- this.pendingInjections.set(sessionKey, (this.pendingInjections.get(sessionKey) ?? 0) + 1);
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
- return (this.pendingInjections.get(sessionKey) ?? 0) > 0;
148
- }
149
- async *dispatch({ event, bodyForAgent, sessionKey, context, }) {
150
- const pending = this.pendingInjections.get(sessionKey) ?? 0;
151
- if (pending > 0) {
152
- this.pendingInjections.delete(sessionKey);
153
- context.log?.info?.(`consuming ${pending} steer turn(s)`);
154
- for (let i = 0; i < pending; i++) {
155
- yield* this.consumeSteerTurn(sessionKey, context.log);
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
- // Steer-turn read timeout. Overridable via PRLL_STEER_TURN_TIMEOUT_MS (ms),
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 groupKey = randomUUID();
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
- if (state.steerReadPending) {
320
- const orphaned = await state.steerReadPending;
321
- state.steerReadPending = undefined;
322
- if (!orphaned.done) {
323
- const parsed = orphaned.value;
324
- // An orphaned read can deliver this turn's FIRST event — if it is
325
- // substantive it must count toward the poison-frame guard below, or
326
- // a later num_turns=0 turn_end would be wrongly skipped despite real
327
- // output having been produced this turn.
328
- if (parsed.type !== 'session_id' &&
329
- parsed.type !== 'runtime_session' &&
330
- parsed.type !== 'turn_end') {
331
- sawSubstantiveEvent = true;
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
- else if (parsed.type === 'runtime_session') {
354
- yield parsed;
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
- else {
357
- yield { ...parsed, groupKey };
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
- while (true) {
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 !== 'session_id' &&
382
- parsed.type !== 'runtime_session' &&
383
- parsed.type !== 'turn_end') {
384
- sawSubstantiveEvent = true;
385
- }
386
- if (parsed.type === 'session_id') {
387
- this.opts.sessionManager.recordSessionId(sessionKey, parsed.sessionId);
388
- yield {
389
- type: 'runtime_session',
390
- runtimeSessionId: parsed.sessionId,
391
- runtimeLaneKey: sessionKey,
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 === 'turn_end') {
396
- if (parsed.numTurns === 0 &&
397
- !sawSubstantiveEvent &&
398
- spuriousTurnEndSkips < MAX_SPURIOUS_TURN_END_SKIPS) {
399
- spuriousTurnEndSkips++;
400
- log?.warn?.('ignoring spurious empty turn_end (num_turns=0) resume/fork poison frame');
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
- if (state.needsRestart) {
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
- return;
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 = { handle, parser, done: false, needsRestart: false };
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
- state.handle.proc.stdin.end();
594
+ handle.proc.stdin.end();
492
595
  }
493
596
  catch {
494
597
  /* best-effort */
495
598
  }
496
- if (state.handle.proc.exitCode === null && state.handle.proc.signalCode === null) {
599
+ if (handle.proc.exitCode === null && handle.proc.signalCode === null) {
497
600
  try {
498
- if (!IS_WIN32 || !state.handle.proc.pid || !killWin32Tree(state.handle.proc.pid)) {
499
- state.handle.proc.kill('SIGTERM');
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
- writeUserMessage(handle, text) {
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
- args.push(...this.opts.sessionManager.getResumeArgs(sessionKey));
657
+ if (resume)
658
+ args.push(...this.opts.sessionManager.getResumeArgs(sessionKey));
544
659
  return args;
545
660
  }
546
661
  buildPlaceholderContext(sessionKey) {