@excitedjs/agent-runtime-claude-code 0.6.1 → 0.7.0-alpha.g1e011989dff1

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.
Files changed (42) hide show
  1. package/README.md +92 -44
  2. package/dist/config.d.ts +2 -2
  3. package/dist/control-rpc.d.ts +32 -0
  4. package/dist/control-rpc.d.ts.map +1 -0
  5. package/dist/control-rpc.js +112 -0
  6. package/dist/control-rpc.js.map +1 -0
  7. package/dist/rpc.d.ts +37 -93
  8. package/dist/rpc.d.ts.map +1 -1
  9. package/dist/rpc.js +242 -405
  10. package/dist/rpc.js.map +1 -1
  11. package/dist/runtime-activity.d.ts +33 -0
  12. package/dist/runtime-activity.d.ts.map +1 -0
  13. package/dist/runtime-activity.js +221 -0
  14. package/dist/runtime-activity.js.map +1 -0
  15. package/dist/runtime-session.d.ts +6 -2
  16. package/dist/runtime-session.d.ts.map +1 -1
  17. package/dist/runtime-session.js +20 -11
  18. package/dist/runtime-session.js.map +1 -1
  19. package/dist/runtime.d.ts +13 -15
  20. package/dist/runtime.d.ts.map +1 -1
  21. package/dist/runtime.js +53 -152
  22. package/dist/runtime.js.map +1 -1
  23. package/dist/stream.d.ts +17 -3
  24. package/dist/stream.d.ts.map +1 -1
  25. package/dist/stream.js +75 -11
  26. package/dist/stream.js.map +1 -1
  27. package/dist/supervisor.d.ts.map +1 -1
  28. package/dist/supervisor.js +35 -31
  29. package/dist/supervisor.js.map +1 -1
  30. package/dist/types.d.ts +38 -17
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/types.js +0 -5
  33. package/dist/types.js.map +1 -1
  34. package/package.json +3 -3
  35. package/dist/admission-classify.d.ts +0 -7
  36. package/dist/admission-classify.d.ts.map +0 -1
  37. package/dist/admission-classify.js +0 -17
  38. package/dist/admission-classify.js.map +0 -1
  39. package/dist/runtime-submissions.d.ts +0 -58
  40. package/dist/runtime-submissions.d.ts.map +0 -1
  41. package/dist/runtime-submissions.js +0 -281
  42. package/dist/runtime-submissions.js.map +0 -1
package/dist/rpc.js CHANGED
@@ -1,290 +1,199 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { buildUserMessage, LineBuffer, parseLine, TurnAggregator, } from './stream.js';
3
+ import { ClaudeCodeControlRpc } from './control-rpc.js';
4
+ import { completionFromTurnOutcome } from './runtime-session.js';
1
5
  /**
2
- * Claude Code stream-json turn RPC.
3
- *
4
- * The supervisor owns the child process. This class owns one in-flight command
5
- * group, stdout line demux, command drainage, and defensive control replies.
6
- *
7
- * One resident CLI execution window can span several submitted commands: a
8
- * live steer is written while the CLI is already running. How the CLI answers them is
9
- * not fixed, which is what makes settlement subtle (probed against a live
10
- * 2.1.231 resident session):
11
- *
12
- * - **Commands fold.** A message that arrives while the in-flight turn is
13
- * inside a tool call is absorbed into that turn at the next query-loop
14
- * boundary. Several commands then share ONE `result` (3 → 1 observed), and
15
- * a folded command's uuid never appears on any `result`.
16
- * - **Or they do not.** A command that arrives between turns runs on its own
17
- * and gets its own `result`.
18
- * - **`result.user_message_uuid` is not a completion ledger.** It is present
19
- * only sometimes, is not reliably the first-submitted uuid of a fold, and
20
- * is absent entirely on the `error_during_execution` artifact an
21
- * interrupt produces.
22
- * - **`command_lifecycle` is the attribution signal.** Started commands identify
23
- * the submissions represented by the next native `result`; terminal states
24
- * drain the resident execution window. Its ordering against `result` is not
25
- * stable.
26
- *
27
- * Every valid `result` is forwarded immediately as its own native completion
28
- * boundary. Lifecycle terminality only decides when the command group has
29
- * drained and the resident session may accept a new initial command; it never
30
- * aggregates several results into one completion.
6
+ * One resident transport, one association from UUID to unanswered request.
7
+ * Native results consume requests directly; no aggregate execution window waits
8
+ * for terminal lifecycle frames. The consumed set also includes native internal
9
+ * commands so cancellation is scoped to work that actually entered a turn.
31
10
  */
32
- import { randomUUID } from 'node:crypto';
33
- import { buildCanUseToolAllow, buildControlAck, buildRemoteControlEnable, buildUserMessage, LineBuffer, parseLine, TurnAggregator, } from './stream.js';
34
- /** Provider-private admission classification; never crosses the neutral API. */
35
- export class ClaudeSteerAdmissionError extends Error {
36
- admission;
37
- constructor(admission, message, options) {
38
- super(message, options);
39
- this.admission = admission;
40
- this.name = 'ClaudeSteerAdmissionError';
41
- }
42
- }
11
+ /**
12
+ * The two `terminal_reason` values that mean the turn was aborted rather than
13
+ * answered, from the Agent SDK's documented set. The other reasons all name a
14
+ * failure or a limit, so this is the whole of what an interrupt looks like on
15
+ * the wire. Under this dispatcher's unattended posture the only cause is our
16
+ * own `interrupt` control request: the other documented cause, a permission
17
+ * callback denying with `interrupt`, cannot happen where every callback allows.
18
+ */
19
+ const INTERRUPT_TERMINAL_REASONS = new Set(['aborted_streaming', 'aborted_tools']);
43
20
  export class ClaudeCodeStreamRpc {
44
21
  stdin;
45
22
  options;
46
23
  lineBuf = new LineBuffer();
47
- pending = null;
24
+ aggregator = new TurnAggregator();
25
+ requests = new Map();
26
+ consumed = new Set();
48
27
  lifecycleSupported = null;
49
- remoteControlRequestId = null;
28
+ timer = null;
29
+ closed = false;
30
+ control;
31
+ /**
32
+ * An interrupt this session asked for has not been answered yet.
33
+ *
34
+ * It is the session's fact, not a request's: claude interrupts whatever it is
35
+ * doing, and the artifact it leaves behind names the command it was in the
36
+ * middle of. This only decides who is still waiting for an answer — what
37
+ * ended the work is read from the result itself.
38
+ */
39
+ interruptRequested = false;
50
40
  constructor(stdin, options) {
51
41
  this.stdin = stdin;
52
42
  this.options = options;
43
+ this.control = new ClaudeCodeControlRpc(stdin, options);
53
44
  }
54
- async submitTurn(prompt, options = {}, commandUuid = randomUUID()) {
55
- if (!this.stdin.writable) {
56
- return Promise.reject(new Error('claude resident child is not running'));
45
+ submit(prompt, options = {}, commandUuid = randomUUID()) {
46
+ if (this.closed || !this.stdin.writable) {
47
+ return Promise.resolve({ status: 'failed', error: new Error('claude resident child is not running') });
57
48
  }
58
- if (this.pending !== null) {
59
- return Promise.reject(new Error('claude resident session is already mid-turn'));
49
+ const concurrent = this.requests.size > 0;
50
+ if (concurrent && this.lifecycleSupported === false) {
51
+ return Promise.resolve({ status: 'failed', error: lifecycleUnsupportedError() });
60
52
  }
61
- return new Promise((resolve, reject) => {
62
- const pending = {
63
- resolve,
64
- reject,
65
- aggregator: new TurnAggregator(),
66
- timer: null,
67
- submitted: [commandUuid],
68
- terminal: new Set(),
69
- startedSinceResult: new Set(),
70
- ranAnyCommand: false,
71
- sawResult: false,
72
- lastAbnormalReason: null,
73
- capabilityWaiters: [],
74
- writeWaiters: new Map(),
75
- };
76
- this.pending = pending;
77
- // Arm the idle deadline (reset on every inbound stream line in `onLine`).
78
- this.armIdleTimer(pending);
79
- try {
80
- this.stdin.write(`${buildUserMessage(prompt, options, commandUuid)}\n`, (err) => {
81
- if (err != null && this.pending === pending) {
82
- const error = asError(err);
83
- this.settlePending(error)?.reject(error);
53
+ let settle;
54
+ const submission = Object.freeze({
55
+ settled: new Promise((resolve) => { settle = resolve; }),
56
+ });
57
+ return new Promise((admit) => {
58
+ const request = {
59
+ submission,
60
+ settle,
61
+ admit,
62
+ write: () => {
63
+ request.write = null;
64
+ this.armIdleTimer();
65
+ try {
66
+ this.stdin.write(`${buildUserMessage(prompt, options, commandUuid)}\n`, (error) => {
67
+ if (error != null)
68
+ this.failWrite(commandUuid, request, error);
69
+ else
70
+ this.acceptRequest(request);
71
+ });
84
72
  }
85
- });
86
- }
87
- catch (error) {
88
- if (this.pending === pending) {
89
- const failure = asError(error);
90
- this.settlePending(failure)?.reject(failure);
91
- }
73
+ catch (error) {
74
+ this.failWrite(commandUuid, request, asError(error));
75
+ }
76
+ },
77
+ };
78
+ // Registration precedes writing: a transport may synchronously acknowledge
79
+ // or answer this message before write() returns or invokes its callback.
80
+ this.requests.set(commandUuid, request);
81
+ if (this.lifecycleSupported === true) {
82
+ // Reentrant input must not overtake older requests waiting on init.
83
+ for (const pending of this.requests.values())
84
+ pending.write?.();
92
85
  }
86
+ else if (!concurrent)
87
+ request.write();
93
88
  });
94
89
  }
95
- async steerTurn(prompt, options = {}, commandUuid = randomUUID()) {
96
- if (!this.stdin.writable) {
97
- return Promise.reject(preAdmissionError('claude resident child is not running'));
98
- }
99
- if (this.pending === null) {
100
- return Promise.reject(preAdmissionError('claude resident session has no active turn'));
101
- }
102
- const pending = this.pending;
103
- if (this.lifecycleSupported === false) {
104
- return Promise.reject(lifecycleUnsupportedError());
105
- }
106
- if (this.lifecycleSupported === null) {
107
- return new Promise((resolve, reject) => {
108
- pending.capabilityWaiters.push({ prompt, options, commandUuid, resolve, reject });
109
- });
110
- }
111
- return this.writeSteer(pending, prompt, options, commandUuid);
90
+ acceptRequest(request) {
91
+ request.admit?.({ status: 'submitted', submission: request.submission });
92
+ request.admit = null;
112
93
  }
113
- writeSteer(pending, prompt, options, commandUuid = randomUUID()) {
114
- if (this.pending !== pending) {
115
- return Promise.reject(capabilityUndecidedTurnEndedError());
116
- }
117
- if (!this.stdin.writable) {
118
- return Promise.reject(preAdmissionError('claude resident child is not running'));
119
- }
120
- // The steer joins this resident execution window: from here on it cannot drain
121
- // until this command has also reached a terminal lifecycle state.
122
- pending.submitted.push(commandUuid);
123
- return new Promise((resolve, reject) => {
124
- pending.writeWaiters.set(commandUuid, { resolve, reject });
125
- const fail = (error) => {
126
- // A steer whose write failed will never reach the CLI, so no
127
- // `command_lifecycle` is coming for it: mark it terminal here or the
128
- // turn waits on a signal that cannot arrive. Reject the waiter first,
129
- // since marking may settle the turn and settlement rejects surviving
130
- // waiters with the generic write-unconfirmed message.
131
- this.rejectWriteWaiter(pending, commandUuid, ambiguousWriteError(error));
132
- this.markCommandTerminal(pending, commandUuid, 'steer write failed');
133
- };
134
- try {
135
- this.stdin.write(`${buildUserMessage(prompt, options, commandUuid)}\n`, (err) => {
136
- if (err != null) {
137
- fail(err);
138
- return;
139
- }
140
- this.resolveWriteWaiter(pending, commandUuid);
141
- });
142
- }
143
- catch (error) {
144
- fail(error);
145
- }
146
- });
94
+ failWrite(uuid, request, error) {
95
+ if (this.requests.get(uuid) !== request || request.admit === null)
96
+ return;
97
+ this.requests.delete(uuid);
98
+ this.consumed.delete(uuid);
99
+ request.admit({ status: 'ambiguous', error });
100
+ request.admit = null;
101
+ request.settle({ kind: 'failed', error });
102
+ this.clearIdleIfEmpty();
147
103
  }
148
104
  onStdoutChunk(chunk) {
149
- for (const line of this.lineBuf.push(chunk))
105
+ if (this.closed)
106
+ return;
107
+ for (const line of this.lineBuf.push(chunk)) {
108
+ if (this.closed)
109
+ break;
150
110
  this.onLine(parseLine(line));
111
+ }
151
112
  }
152
- failPending(err) {
153
- this.settlePending(err)?.reject(err);
113
+ /** Actual transport loss fails each outstanding request, without a completion. */
114
+ fail(error) {
115
+ this.close({ kind: 'failed', error });
154
116
  }
155
- enableRemoteControl() {
156
- if (!this.stdin.writable)
157
- return;
158
- this.remoteControlRequestId = randomUUID();
159
- this.stdin.write(`${buildRemoteControlEnable(this.remoteControlRequestId)}\n`);
117
+ /** Explicit teardown stops requests and converges unconfirmed admissions. */
118
+ stop() {
119
+ this.close({ kind: 'stopped' });
160
120
  }
161
- /**
162
- * Detach the in-flight turn: clear its deadline timer and null `pending`,
163
- * returning it so the caller can resolve or reject it exactly once.
164
- */
165
- settlePending(failure) {
166
- const pending = this.pending;
167
- if (pending === null)
168
- return null;
169
- if (pending.timer !== null)
170
- clearTimeout(pending.timer);
171
- this.pending = null;
172
- // On an explicit failure (write error, stop, idle reap) both waiter kinds
173
- // get the real error. On a clean `result` settlement there is no error:
174
- // capability waiters never got a decision, while write waiters were written
175
- // but unconfirmed — distinct messages for distinct conditions.
176
- this.rejectCapabilityWaiters(pending, failure ?? capabilityUndecidedTurnEndedError());
177
- this.rejectWriteWaiters(pending, failure ?? steerWriteUnconfirmedError());
178
- return pending;
179
- }
180
- /**
181
- * Mark a submitted command as producing nothing further, then re-check
182
- * settlement. `abnormalReason` is `null` for the normal ending (`completed`)
183
- * and a short phrase for a command that never ran — those are logged,
184
- * because the CLI gives no other trace of a command it declined.
185
- *
186
- * A command ending abnormally never fails the turn by itself: the probe
187
- * shows a `cancelled` command coexisting with another that answers normally
188
- * (that is exactly what an interrupt looks like), so
189
- * rejecting on `cancelled` — as the pre-#342 code did — is wrong.
190
- */
191
- markCommandTerminal(pending, commandUuid, abnormalReason) {
192
- if (!pending.submitted.includes(commandUuid))
193
- return;
194
- if (!pending.terminal.has(commandUuid)) {
195
- pending.terminal.add(commandUuid);
196
- if (abnormalReason === null) {
197
- pending.ranAnyCommand = true;
198
- }
199
- else {
200
- pending.lastAbnormalReason = abnormalReason;
201
- this.options.log?.('warn', `claude command ${commandUuid} ${abnormalReason}; it will not ` +
202
- 'produce further output for this turn');
203
- }
121
+ close(settlement) {
122
+ this.closed = true;
123
+ const error = settlement.kind === 'failed'
124
+ ? settlement.error
125
+ : new Error('claude resident session stopped before write acknowledgement');
126
+ for (const request of this.requests.values()) {
127
+ request.admit?.(request.write === null
128
+ ? { status: 'ambiguous', error }
129
+ : settlement.kind === 'stopped' ? { status: 'stopped' } : { status: 'failed', error });
130
+ request.admit = null;
131
+ request.settle(settlement);
132
+ }
133
+ this.requests.clear();
134
+ this.consumed.clear();
135
+ this.aggregator.discard();
136
+ this.clearIdleIfEmpty();
137
+ // The session ended before claude answered the ask, so the ask is answered
138
+ // here instead: teardown interrupted the work, a transport loss did not.
139
+ if (this.interruptRequested) {
140
+ this.interruptRequested = false;
141
+ this.control.settleInterrupt(settlement.kind === 'failed' ? settlement.error : undefined, settlement.kind === 'stopped');
204
142
  }
205
- this.settleIfReady(pending);
206
143
  }
207
144
  /**
208
- * The drainage gate: every submitted command has reached a terminal
209
- * lifecycle state AND at least one valid `result` has been seen. Result
210
- * identity and settlement have already been forwarded one-by-one.
211
- *
212
- * Two escapes, both anti-hang:
145
+ * Ask claude to interrupt whatever it is doing.
213
146
  *
214
- * - no lifecycle signal at all (`msg_lifecycle_v1` absent, so no
215
- * `command_lifecycle` will ever arrive) the `result` is then the only
216
- * terminal event there is, so settle on it;
217
- * - every command terminal, none of them ever ran, and no result — nothing
218
- * can answer this turn, so fail it loudly. The idle deadline is not an
219
- * acceptable backstop here: it reaps the resident child, and any inbound
220
- * line re-arms it, so a healthy session could be killed long after the
221
- * turn became unanswerable.
222
- *
223
- * When a command *did* run but no result has arrived yet, this waits: the
224
- * probe shows terminal lifecycle states arriving both before and after the
225
- * result they belong to, so "terminal, therefore no result is coming" is not
226
- * a sound inference.
147
+ * Whatever it is doing, not only what this host asked for: a resident session
148
+ * runs turns of its own, and a `/stop` that skipped those would leave the
149
+ * conversation watching work it cannot stop. Answers false only when there is
150
+ * no session left to ask.
227
151
  */
228
- settleIfReady(pending) {
229
- if (this.pending !== pending)
230
- return;
231
- if (pending.sawResult && this.lifecycleSupported !== true) {
232
- this.settlePending()?.resolve();
233
- return;
152
+ async interrupt(reason) {
153
+ if (this.closed)
154
+ return false;
155
+ this.interruptRequested = true;
156
+ try {
157
+ return await this.control.requestInterrupt(reason);
234
158
  }
235
- if (pending.startedSinceResult.size > 0)
236
- return;
237
- for (const commandUuid of pending.submitted) {
238
- if (!pending.terminal.has(commandUuid))
239
- return;
159
+ catch (error) {
160
+ this.interruptRequested = false;
161
+ throw error;
240
162
  }
241
- if (pending.sawResult) {
242
- this.settlePending()?.resolve();
163
+ }
164
+ enableRemoteControl() {
165
+ if (this.closed || !this.stdin.writable)
243
166
  return;
167
+ this.control.enableRemoteControl();
168
+ }
169
+ clearIdleIfEmpty() {
170
+ if (this.requests.size === 0 && this.timer !== null) {
171
+ clearTimeout(this.timer);
172
+ this.timer = null;
244
173
  }
245
- if (pending.ranAnyCommand)
246
- return;
247
- const error = new Error('claude turn ended without running any of its commands ' +
248
- `(last: ${pending.lastAbnormalReason ?? 'no command reached the CLI'})`);
249
- // Pass the cause into `settlePending` so surviving steer waiters get the
250
- // real reason instead of the generic write-unconfirmed message.
251
- this.settlePending(error)?.reject(error);
252
174
  }
253
- /**
254
- * (Re)arm the per-turn idle deadline. `turnTimeoutMs` is a *max-idle* window,
255
- * not a total-turn cap: any inbound stream line for this turn pushes it out
256
- * (see `onLine`). A genuinely wedged child (no stream activity for the whole
257
- * window) is still reaped — preserving the #120 anti-hang intent — but a long
258
- * but continuously-streaming turn never trips the deadline (#156).
259
- */
260
- armIdleTimer(pending) {
261
- if (pending.timer !== null)
262
- clearTimeout(pending.timer);
263
- pending.timer = setTimeout(() => {
264
- if (this.pending !== pending)
265
- return;
266
- const error = new Error(`claude resident turn stalled: no stream activity for ${this.options.turnTimeoutMs}ms`);
267
- const stalled = this.settlePending(error);
268
- if (stalled === null)
269
- return;
270
- this.options.log?.('error', `claude turn stalled: no stream activity for ${this.options.turnTimeoutMs}ms; reaping resident child`);
271
- stalled.reject(error);
272
- this.options.reapOnTimeout();
175
+ /** Outstanding work has a max-idle deadline; pure background work has none. */
176
+ armIdleTimer() {
177
+ if (this.requests.size === 0)
178
+ return;
179
+ if (this.timer !== null)
180
+ clearTimeout(this.timer);
181
+ this.timer = setTimeout(() => {
182
+ const error = new Error(`claude resident requests stalled: no stream activity for ${this.options.turnTimeoutMs}ms`);
183
+ this.options.log?.('error', `${error.message}; reaping resident child`);
184
+ this.fail(error);
185
+ this.options.reapOnTimeout(error);
273
186
  }, this.options.turnTimeoutMs);
274
187
  }
275
188
  onLine(line) {
276
- // Idle-timeout reset: any inbound stream line for the pending turn is
277
- // activity, so push the deadline out. The terminal `result` clears the
278
- // timer via `settlePending` below.
279
- if (this.pending !== null)
280
- this.armIdleTimer(this.pending);
189
+ this.armIdleTimer();
281
190
  switch (line.kind) {
282
191
  case 'init':
192
+ this.aggregator.accept(line);
283
193
  this.decideLifecycleSupport(line.capabilities.includes('msg_lifecycle_v1'));
284
- this.pending?.aggregator.accept(line);
285
194
  break;
286
195
  case 'assistant':
287
- this.pending?.aggregator.accept(line);
196
+ this.aggregator.accept(line);
288
197
  this.options.onProtocolEvent?.({ kind: 'stream', line });
289
198
  break;
290
199
  case 'user':
@@ -292,197 +201,125 @@ export class ClaudeCodeStreamRpc {
292
201
  this.options.onProtocolEvent?.({ kind: 'stream', line });
293
202
  break;
294
203
  case 'command_lifecycle': {
295
- const pending = this.pending;
296
- if (pending === null || line.commandUuid === null)
204
+ const { commandUuid, state } = line;
205
+ if (commandUuid === null || state === null)
297
206
  break;
298
- if (line.state !== null) {
299
- this.options.onProtocolEvent?.({
300
- kind: 'command_lifecycle',
301
- commandUuid: line.commandUuid,
302
- state: line.state,
303
- });
304
- }
305
- if (line.state === 'started')
306
- pending.startedSinceResult.add(line.commandUuid);
307
- // `command_lifecycle` does double duty: it coordinates live-steer
308
- // admission (writeWaiters) and proves lifecycle capability, and its
309
- // terminal states are the drainage gate when the CLI represents several
310
- // started commands with one `result`.
311
- const newlySupported = this.lifecycleSupported === null;
312
- if (newlySupported)
313
- this.lifecycleSupported = true;
314
- this.resolveWriteWaiter(pending, line.commandUuid);
315
- if (newlySupported && this.pending === pending) {
316
- this.flushCapabilityWaiters(pending);
317
- }
318
- if (this.pending !== pending)
319
- break;
320
- if (line.state === 'completed') {
321
- this.markCommandTerminal(pending, line.commandUuid, null);
322
- }
323
- else if (line.state === 'cancelled' || line.state === 'discarded' || line.state === 'refused') {
324
- pending.startedSinceResult.delete(line.commandUuid);
325
- this.markCommandTerminal(pending, line.commandUuid, `was ${line.state} by claude`);
207
+ if (state === 'started')
208
+ this.consumed.add(commandUuid);
209
+ const request = this.requests.get(commandUuid);
210
+ if (request?.write === null) {
211
+ this.acceptRequest(request);
212
+ // Consumed commands can report cancelled before their failure result.
213
+ // Keep the result's members; lifecycle alone cannot supply its outcome.
214
+ if (state === 'discarded' || state === 'refused' ||
215
+ (state === 'cancelled' && !this.consumed.has(commandUuid))) {
216
+ this.requests.delete(commandUuid);
217
+ request.settle({ kind: 'failed', error: new Error(`claude command was ${state}`) });
218
+ this.clearIdleIfEmpty();
219
+ }
326
220
  }
221
+ // An unconsumed command's cancellation cannot discard generating text.
222
+ if (state === 'cancelled' && this.consumed.has(commandUuid))
223
+ this.aggregator.discard();
224
+ this.options.onProtocolEvent?.({ kind: 'command_lifecycle', commandUuid, state });
225
+ this.decideLifecycleSupport(true);
327
226
  break;
328
227
  }
329
228
  case 'result': {
330
- const pending = this.pending;
331
- if (pending === null) {
332
- const error = new Error('claude result envelope arrived without an attributable command group');
333
- this.options.log?.('error', error.message, error);
334
- this.options.reapOnTimeout();
335
- break;
229
+ this.aggregator.accept(line);
230
+ const outcome = this.aggregator.takeOutcome();
231
+ const commandUuids = new Set(this.consumed);
232
+ this.consumed.clear();
233
+ const uuid = line.outcome.userMessageUuid;
234
+ if (uuid !== null)
235
+ commandUuids.add(uuid);
236
+ if (this.lifecycleSupported !== true && uuid === null) {
237
+ for (const [id, request] of this.requests) {
238
+ if (request.write === null)
239
+ commandUuids.add(id);
240
+ }
336
241
  }
337
- const commandUuid = line.outcome.userMessageUuid;
338
- if (commandUuid !== null && !pending.submitted.includes(commandUuid)) {
339
- const error = new Error(`claude result envelope for unsubmitted command ${commandUuid}; ` +
340
- 'native completion ownership is ambiguous');
341
- this.options.log?.('error', error.message, error);
342
- this.settlePending(error)?.reject(error);
343
- this.options.reapOnTimeout();
344
- break;
242
+ const answered = [];
243
+ const submittedUuids = [];
244
+ for (const id of commandUuids) {
245
+ const request = this.requests.get(id);
246
+ if (request === undefined || request.write !== null)
247
+ continue;
248
+ this.requests.delete(id);
249
+ answered.push(request);
250
+ submittedUuids.push(id);
345
251
  }
346
- // The interrupt artifact (`error_during_execution` with no result) is
347
- // not a native answer boundary. Older valid results may omit the uuid.
348
- if (line.outcome.subtype === 'error_during_execution' &&
349
- line.outcome.userMessageUuid === null &&
350
- line.outcome.text === null) {
351
- this.options.log?.('warn', 'claude interrupt result artifact ignored');
352
- break;
252
+ this.clearIdleIfEmpty();
253
+ // What ended this turn is the turn's own fact. An aborted turn reports
254
+ // one of the two `terminal_reason` values the Agent SDK documents for
255
+ // it, distinct from every failure reason, so an interrupt is read here
256
+ // rather than inferred from whether we happened to ask for one. The
257
+ // artifact answers the requests it names, but with nothing said rather
258
+ // than a completion, so they settle `stopped`.
259
+ const interrupted = INTERRUPT_TERMINAL_REASONS.has(line.outcome.terminalReason ?? '');
260
+ // Our own ask is answered by the first result after it, whatever that
261
+ // result turned out to be. The receipt normally comes back on the
262
+ // control channel first; this covers a session that never answers it.
263
+ if (this.interruptRequested) {
264
+ this.interruptRequested = false;
265
+ this.control.settleInterrupt(undefined, interrupted);
353
266
  }
354
- pending.aggregator.accept(line);
355
- const outcome = pending.aggregator.takeOutcome();
356
- pending.startedSinceResult.clear();
357
- pending.sawResult = true;
358
- this.options.onProtocolEvent?.({
359
- kind: 'result',
360
- outcome,
361
- });
362
- this.settleIfReady(pending);
267
+ const completion = interrupted || answered.length === 0 ? null : completionFromTurnOutcome(outcome, this.options.sessionId, this.options.outputSchemaEnabled === true);
268
+ // Remove the answered requests before callbacks can admit or stop work.
269
+ // Native end is still delivered before these submissions settle.
270
+ this.options.onProtocolEvent?.(interrupted
271
+ ? { kind: 'interrupted', outcome }
272
+ : { kind: 'result', outcome, commandUuids: submittedUuids });
273
+ for (const request of answered) {
274
+ this.acceptRequest(request);
275
+ request.settle(interrupted ? { kind: 'stopped' } : { kind: 'completion', completion: completion });
276
+ }
277
+ if (this.lifecycleSupported !== true)
278
+ this.rejectWaitingRequests();
363
279
  break;
364
280
  }
365
281
  case 'control_request':
366
- this.onControlRequest(line.requestId, line.subtype, line.request);
282
+ this.control.onControlRequest(line.requestId, line.subtype, line.request);
367
283
  break;
368
284
  case 'control_response':
369
- this.onControlResponse(line.requestId, line.ok, line.response, line.error);
285
+ this.control.onControlResponse(line.requestId, line.ok, line.response, line.error);
370
286
  break;
371
287
  case 'parse_error':
372
288
  this.options.log?.('warn', `claude stream-json parse error: ${line.raw}`);
373
289
  break;
374
290
  default:
375
- // Every other envelope (`system` notices past `init` and
376
- // `compact_boundary`, `stream_event`,
377
- // `rate_limit_event`, ...) carries nothing the runtime consumes. It
378
- // already counted as activity for the idle deadline above.
379
291
  break;
380
292
  }
381
293
  }
382
294
  decideLifecycleSupport(supported) {
383
- if (this.lifecycleSupported !== null)
295
+ if (this.lifecycleSupported === true)
384
296
  return;
385
- this.lifecycleSupported = supported;
386
- const pending = this.pending;
387
- if (pending === null)
297
+ if (!supported && this.lifecycleSupported !== null)
388
298
  return;
389
- if (supported) {
390
- this.flushCapabilityWaiters(pending);
391
- return;
392
- }
393
- this.rejectCapabilityWaiters(pending, lifecycleUnsupportedError());
394
- }
395
- flushCapabilityWaiters(pending) {
396
- const waiters = pending.capabilityWaiters.splice(0);
397
- for (const waiter of waiters) {
398
- void this.writeSteer(pending, waiter.prompt, waiter.options, waiter.commandUuid).then(waiter.resolve, waiter.reject);
399
- }
400
- }
401
- rejectCapabilityWaiters(pending, error) {
402
- const waiters = pending.capabilityWaiters.splice(0);
403
- const failure = error instanceof ClaudeSteerAdmissionError
404
- ? error
405
- : preAdmissionError(error.message, error);
406
- for (const waiter of waiters)
407
- waiter.reject(failure);
408
- }
409
- resolveWriteWaiter(pending, commandUuid) {
410
- const waiter = pending.writeWaiters.get(commandUuid);
411
- if (waiter === undefined)
412
- return;
413
- pending.writeWaiters.delete(commandUuid);
414
- waiter.resolve();
415
- }
416
- rejectWriteWaiter(pending, commandUuid, error) {
417
- const waiter = pending.writeWaiters.get(commandUuid);
418
- if (waiter === undefined)
419
- return;
420
- pending.writeWaiters.delete(commandUuid);
421
- waiter.reject(error);
422
- }
423
- rejectWriteWaiters(pending, error) {
424
- const failure = error instanceof ClaudeSteerAdmissionError &&
425
- error.admission === 'ambiguous'
426
- ? error
427
- : ambiguousWriteError(error);
428
- const waiters = [...pending.writeWaiters.values()];
429
- pending.writeWaiters.clear();
430
- for (const waiter of waiters)
431
- waiter.reject(failure);
432
- }
433
- onControlRequest(requestId, subtype, request) {
434
- if (requestId === null || !this.stdin.writable)
299
+ this.lifecycleSupported = supported;
300
+ if (!supported) {
301
+ this.rejectWaitingRequests();
435
302
  return;
436
- // Unattended posture: answer permission callbacks so a turn never wedges
437
- // waiting on a human.
438
- let reply;
439
- if (subtype === 'can_use_tool') {
440
- const rawInput = request['input'];
441
- const input = typeof rawInput === 'object' &&
442
- rawInput !== null &&
443
- !Array.isArray(rawInput)
444
- ? rawInput
445
- : {};
446
- reply = buildCanUseToolAllow(requestId, input);
447
- }
448
- else {
449
- reply = buildControlAck(requestId);
450
303
  }
451
- this.stdin.write(`${reply}\n`);
304
+ for (const request of this.requests.values())
305
+ request.write?.();
452
306
  }
453
- onControlResponse(requestId, ok, response, error) {
454
- if (requestId === null || requestId !== this.remoteControlRequestId)
455
- return;
456
- this.remoteControlRequestId = null;
457
- if (ok && response !== null) {
458
- const url = response['session_url'] ?? response['connect_url'];
459
- if (typeof url === 'string') {
460
- this.options.onRemoteControlUrl?.(url);
461
- }
462
- else {
463
- this.options.log?.('warn', 'claude remote control enable succeeded without a URL');
464
- }
465
- return;
307
+ rejectWaitingRequests() {
308
+ const error = this.lifecycleSupported === false ? lifecycleUnsupportedError()
309
+ : new Error('claude result arrived before concurrent-input capability was decided');
310
+ for (const [uuid, request] of this.requests) {
311
+ if (request.write === null)
312
+ continue;
313
+ this.requests.delete(uuid);
314
+ request.admit?.({ status: 'failed', error });
315
+ request.admit = null;
316
+ request.settle({ kind: 'failed', error });
466
317
  }
467
- this.options.log?.('warn', `claude remote control enable failed${error !== null ? `: ${error}` : ''}`);
318
+ this.clearIdleIfEmpty();
468
319
  }
469
320
  }
470
321
  function lifecycleUnsupportedError() {
471
- return preAdmissionError('claude resident session cannot prove live-steer lifecycle: ' +
472
- 'msg_lifecycle_v1 is unavailable');
473
- }
474
- function capabilityUndecidedTurnEndedError() {
475
- return preAdmissionError('claude resident turn ended before live-steer capability was decided');
476
- }
477
- function steerWriteUnconfirmedError() {
478
- return new ClaudeSteerAdmissionError('ambiguous', 'claude resident turn ended before the steer write was confirmed');
479
- }
480
- function preAdmissionError(message, cause) {
481
- return new ClaudeSteerAdmissionError('failed', message, cause === undefined ? undefined : { cause });
482
- }
483
- function ambiguousWriteError(error) {
484
- const cause = asError(error);
485
- return new ClaudeSteerAdmissionError('ambiguous', cause.message, { cause });
322
+ return new Error('claude resident session cannot attribute concurrent inputs: msg_lifecycle_v1 is unavailable');
486
323
  }
487
324
  function asError(error) {
488
325
  return error instanceof Error ? error : new Error(String(error));