@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.
- package/README.md +92 -44
- package/dist/config.d.ts +2 -2
- package/dist/control-rpc.d.ts +32 -0
- package/dist/control-rpc.d.ts.map +1 -0
- package/dist/control-rpc.js +112 -0
- package/dist/control-rpc.js.map +1 -0
- package/dist/rpc.d.ts +37 -93
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +242 -405
- 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-activity.js +221 -0
- 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 +13 -15
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +53 -152
- package/dist/runtime.js.map +1 -1
- package/dist/stream.d.ts +17 -3
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +75 -11
- package/dist/stream.js.map +1 -1
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +35 -31
- package/dist/supervisor.js.map +1 -1
- package/dist/types.d.ts +38 -17
- 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/runtime-submissions.d.ts +0 -58
- package/dist/runtime-submissions.d.ts.map +0 -1
- package/dist/runtime-submissions.js +0 -281
- 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
|
-
*
|
|
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, 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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
24
|
+
aggregator = new TurnAggregator();
|
|
25
|
+
requests = new Map();
|
|
26
|
+
consumed = new Set();
|
|
48
27
|
lifecycleSupported = null;
|
|
49
|
-
|
|
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
|
-
|
|
55
|
-
if (!this.stdin.writable) {
|
|
56
|
-
return Promise.
|
|
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
|
-
|
|
59
|
-
|
|
49
|
+
const concurrent = this.requests.size > 0;
|
|
50
|
+
if (concurrent && this.lifecycleSupported === false) {
|
|
51
|
+
return Promise.resolve({ status: 'failed', error: lifecycleUnsupportedError() });
|
|
60
52
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
114
|
-
if (this.
|
|
115
|
-
return
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
|
|
113
|
+
/** Actual transport loss fails each outstanding request, without a completion. */
|
|
114
|
+
fail(error) {
|
|
115
|
+
this.close({ kind: 'failed', error });
|
|
154
116
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
this.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
-
|
|
229
|
-
if (this.
|
|
230
|
-
return;
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (!pending.terminal.has(commandUuid))
|
|
239
|
-
return;
|
|
159
|
+
catch (error) {
|
|
160
|
+
this.interruptRequested = false;
|
|
161
|
+
throw error;
|
|
240
162
|
}
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
296
|
-
if (
|
|
204
|
+
const { commandUuid, state } = line;
|
|
205
|
+
if (commandUuid === null || state === null)
|
|
297
206
|
break;
|
|
298
|
-
if (
|
|
299
|
-
this.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
this.
|
|
344
|
-
|
|
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
|
-
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
kind: 'result',
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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
|
|
295
|
+
if (this.lifecycleSupported === true)
|
|
384
296
|
return;
|
|
385
|
-
this.lifecycleSupported
|
|
386
|
-
const pending = this.pending;
|
|
387
|
-
if (pending === null)
|
|
297
|
+
if (!supported && this.lifecycleSupported !== null)
|
|
388
298
|
return;
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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.
|
|
304
|
+
for (const request of this.requests.values())
|
|
305
|
+
request.write?.();
|
|
452
306
|
}
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
this.
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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.
|
|
318
|
+
this.clearIdleIfEmpty();
|
|
468
319
|
}
|
|
469
320
|
}
|
|
470
321
|
function lifecycleUnsupportedError() {
|
|
471
|
-
return
|
|
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));
|