@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.
- package/README.md +92 -44
- package/dist/config.d.ts +2 -2
- package/dist/rpc.d.ts +30 -104
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +217 -404
- package/dist/rpc.js.map +1 -1
- package/dist/runtime-activity.d.ts +33 -0
- package/dist/runtime-activity.d.ts.map +1 -0
- package/dist/{runtime-submissions.js → runtime-activity.js} +23 -145
- package/dist/runtime-activity.js.map +1 -0
- package/dist/runtime-session.d.ts +6 -2
- package/dist/runtime-session.d.ts.map +1 -1
- package/dist/runtime-session.js +20 -11
- package/dist/runtime-session.js.map +1 -1
- package/dist/runtime.d.ts +4 -16
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +37 -169
- package/dist/runtime.js.map +1 -1
- package/dist/stream.d.ts +7 -5
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +20 -19
- package/dist/stream.js.map +1 -1
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +23 -47
- package/dist/supervisor.js.map +1 -1
- package/dist/types.d.ts +24 -19
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +0 -5
- package/dist/types.js.map +1 -1
- package/package.json +3 -3
- package/dist/admission-classify.d.ts +0 -7
- package/dist/admission-classify.d.ts.map +0 -1
- package/dist/admission-classify.js +0 -17
- package/dist/admission-classify.js.map +0 -1
- package/dist/control-rpc.d.ts +0 -21
- package/dist/control-rpc.d.ts.map +0 -1
- package/dist/control-rpc.js +0 -103
- package/dist/control-rpc.js.map +0 -1
- package/dist/runtime-submissions.d.ts +0 -58
- package/dist/runtime-submissions.d.ts.map +0 -1
- 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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
14
|
+
aggregator = new TurnAggregator();
|
|
15
|
+
requests = new Map();
|
|
16
|
+
consumed = new Set();
|
|
49
17
|
lifecycleSupported = null;
|
|
50
|
-
|
|
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
|
-
|
|
57
|
-
if (!this.stdin.writable) {
|
|
58
|
-
return Promise.
|
|
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
|
-
|
|
61
|
-
|
|
29
|
+
const concurrent = this.requests.size > 0;
|
|
30
|
+
if (concurrent && this.lifecycleSupported === false) {
|
|
31
|
+
return Promise.resolve({ status: 'failed', error: lifecycleUnsupportedError() });
|
|
62
32
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
117
|
-
if (this.
|
|
118
|
-
return
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
93
|
+
/** Actual transport loss fails each outstanding request, without a completion. */
|
|
94
|
+
fail(error) {
|
|
95
|
+
this.close({ kind: 'failed', error });
|
|
157
96
|
}
|
|
158
|
-
|
|
159
|
-
|
|
97
|
+
/** Explicit teardown stops requests and converges unconfirmed admissions. */
|
|
98
|
+
stop() {
|
|
99
|
+
this.close({ kind: 'stopped' });
|
|
160
100
|
}
|
|
161
|
-
|
|
162
|
-
this.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
this.
|
|
175
|
-
this.
|
|
176
|
-
|
|
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
|
-
|
|
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.
|
|
196
|
-
this.
|
|
121
|
+
this.remoteControlRequestId = randomUUID();
|
|
122
|
+
this.stdin.write(`${buildRemoteControlEnable(this.remoteControlRequestId)}\n`);
|
|
197
123
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
314
|
-
if (
|
|
159
|
+
const { commandUuid, state } = line;
|
|
160
|
+
if (commandUuid === null || state === null)
|
|
315
161
|
break;
|
|
316
|
-
if (
|
|
317
|
-
this.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
this.
|
|
362
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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.
|
|
221
|
+
this.onControlRequest(line.requestId, line.subtype, line.request);
|
|
402
222
|
break;
|
|
403
223
|
case 'control_response':
|
|
404
|
-
this.
|
|
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
|
|
234
|
+
if (this.lifecycleSupported === true)
|
|
419
235
|
return;
|
|
420
|
-
this.lifecycleSupported
|
|
421
|
-
const pending = this.pending;
|
|
422
|
-
if (pending === null)
|
|
236
|
+
if (!supported && this.lifecycleSupported !== null)
|
|
423
237
|
return;
|
|
424
|
-
|
|
425
|
-
|
|
238
|
+
this.lifecycleSupported = supported;
|
|
239
|
+
if (!supported) {
|
|
240
|
+
this.rejectWaitingRequests();
|
|
426
241
|
return;
|
|
427
242
|
}
|
|
428
|
-
this.
|
|
243
|
+
for (const request of this.requests.values())
|
|
244
|
+
request.write?.();
|
|
429
245
|
}
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
449
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
if (waiter === undefined)
|
|
279
|
+
onControlResponse(requestId, ok, response, error) {
|
|
280
|
+
if (requestId === null || requestId !== this.remoteControlRequestId)
|
|
454
281
|
return;
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
|
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));
|