@excitedjs/agent-runtime-claude-code 0.7.0-alpha.gea3dc096ae44 → 0.7.0-beta.213

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