@excitedjs/agent-runtime-claude-code 0.5.0-beta.168 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +43 -1
- package/dist/args.d.ts +2 -0
- package/dist/args.d.ts.map +1 -1
- package/dist/args.js +5 -0
- package/dist/args.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/provider.d.ts +5 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +10 -4
- package/dist/provider.js.map +1 -1
- package/dist/rpc.d.ts +79 -6
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +329 -57
- package/dist/rpc.js.map +1 -1
- package/dist/runtime-deps.d.ts +30 -0
- package/dist/runtime-deps.d.ts.map +1 -0
- package/dist/runtime-deps.js +2 -0
- package/dist/runtime-deps.js.map +1 -0
- package/dist/runtime-session.d.ts +13 -0
- package/dist/runtime-session.d.ts.map +1 -0
- package/dist/runtime-session.js +41 -0
- package/dist/runtime-session.js.map +1 -0
- package/dist/runtime-submissions.d.ts +34 -0
- package/dist/runtime-submissions.d.ts.map +1 -0
- package/dist/runtime-submissions.js +200 -0
- package/dist/runtime-submissions.js.map +1 -0
- package/dist/runtime.d.ts +33 -131
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +286 -268
- package/dist/runtime.js.map +1 -1
- package/dist/skill-adapter.d.ts +11 -1
- package/dist/skill-adapter.d.ts.map +1 -1
- package/dist/skill-adapter.js +60 -7
- package/dist/skill-adapter.js.map +1 -1
- package/dist/skill-materializer.d.ts +6 -0
- package/dist/skill-materializer.d.ts.map +1 -0
- package/dist/skill-materializer.js +45 -0
- package/dist/skill-materializer.js.map +1 -0
- package/dist/source-reservation.d.ts +6 -0
- package/dist/source-reservation.d.ts.map +1 -0
- package/dist/source-reservation.js +48 -0
- package/dist/source-reservation.js.map +1 -0
- package/dist/stream.d.ts +3 -1
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +48 -1
- package/dist/stream.js.map +1 -1
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +68 -14
- package/dist/supervisor.js.map +1 -1
- package/dist/transcript/budget.d.ts +8 -0
- package/dist/transcript/budget.d.ts.map +1 -0
- package/dist/transcript/budget.js +9 -0
- package/dist/transcript/budget.js.map +1 -0
- package/dist/transcript/completion.d.ts +8 -0
- package/dist/transcript/completion.d.ts.map +1 -0
- package/dist/transcript/completion.js +57 -0
- package/dist/transcript/completion.js.map +1 -0
- package/dist/transcript/cursor.d.ts +25 -0
- package/dist/transcript/cursor.d.ts.map +1 -0
- package/dist/transcript/cursor.js +62 -0
- package/dist/transcript/cursor.js.map +1 -0
- package/dist/transcript/error.d.ts +9 -0
- package/dist/transcript/error.d.ts.map +1 -0
- package/dist/transcript/error.js +9 -0
- package/dist/transcript/error.js.map +1 -0
- package/dist/transcript/native-hash.d.ts +2 -0
- package/dist/transcript/native-hash.d.ts.map +1 -0
- package/dist/transcript/native-hash.js +79 -0
- package/dist/transcript/native-hash.js.map +1 -0
- package/dist/transcript/opened-file.d.ts +13 -0
- package/dist/transcript/opened-file.d.ts.map +1 -0
- package/dist/transcript/opened-file.js +110 -0
- package/dist/transcript/opened-file.js.map +1 -0
- package/dist/transcript/parallel-tools.d.ts +6 -0
- package/dist/transcript/parallel-tools.d.ts.map +1 -0
- package/dist/transcript/parallel-tools.js +123 -0
- package/dist/transcript/parallel-tools.js.map +1 -0
- package/dist/transcript/path.d.ts +25 -0
- package/dist/transcript/path.d.ts.map +1 -0
- package/dist/transcript/path.js +253 -0
- package/dist/transcript/path.js.map +1 -0
- package/dist/transcript/reader.d.ts +7 -0
- package/dist/transcript/reader.d.ts.map +1 -0
- package/dist/transcript/reader.js +476 -0
- package/dist/transcript/reader.js.map +1 -0
- package/dist/transcript/rewrites.d.ts +5 -0
- package/dist/transcript/rewrites.d.ts.map +1 -0
- package/dist/transcript/rewrites.js +65 -0
- package/dist/transcript/rewrites.js.map +1 -0
- package/dist/types.d.ts +33 -3
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -3
package/dist/runtime.js
CHANGED
|
@@ -1,71 +1,18 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `builtin:claude-code` AgentRuntime (issue #110 PR6, resident stream-json
|
|
3
|
-
* transport since issue #120; extracted into `@excitedjs/agent-runtime-claude-code`
|
|
4
|
-
* in issue #209).
|
|
5
|
-
*
|
|
6
|
-
* A real second agent runtime that proves the AgentRuntimeProvider abstraction
|
|
7
|
-
* is not "Codex renamed". Like Codex it runs a **resident child process**
|
|
8
|
-
* supervised for the runtime's lifetime — but it differs in every
|
|
9
|
-
* runtime-specific dimension:
|
|
10
|
-
*
|
|
11
|
-
* - **Stream-json over stdio, not an app-server WebSocket.** The resident child
|
|
12
|
-
* is `claude --print --input-format stream-json --output-format stream-json`
|
|
13
|
-
* (see `./args.ts`); turns are NDJSON `user` lines on stdin and
|
|
14
|
-
* `init`/`assistant`/`result` envelopes on stdout (see `./stream.ts`). There
|
|
15
|
-
* is no `initialize` handshake — the child emits `init` lazily with the first
|
|
16
|
-
* turn — so readiness is "child spawned", not "handshake completed".
|
|
17
|
-
* - **MCP injection is an inline JSON config document** (`--mcp-config <json>`),
|
|
18
|
-
* not Codex's `-c mcp_servers.*` TOML CLI flags.
|
|
19
|
-
* - **Runtime-owned config** is `DispatcherClaudeCodeConfig` (bin / model /
|
|
20
|
-
* permission_mode / remote_control / extra_args / extra_env), distinct from
|
|
21
|
-
* the Codex config.
|
|
22
|
-
* - **Completion delivery** is a plain user turn (no fake task-notification),
|
|
23
|
-
* not the Codex inbox-then-trigger path.
|
|
24
|
-
*
|
|
25
|
-
* Process spawning goes through an injectable {@link ClaudeCodeSessionFactory}
|
|
26
|
-
* seam (mirroring Codex's process-factory seam), so the lifecycle contract is
|
|
27
|
-
* fully unit-testable with a fake session. A live `claude` binary is exercised
|
|
28
|
-
* only by the opt-in live test.
|
|
29
|
-
*
|
|
30
|
-
* Failure contract (unchanged by #120): a turn failure (spawn error, child
|
|
31
|
-
* exit, error `result`) is never swallowed. For inbound/restart turns it drives
|
|
32
|
-
* the runtime to `degraded` with a persisted `last_error` (observable via
|
|
33
|
-
* status/doctor). For `completionInput` it surfaces as a `failed`
|
|
34
|
-
* result the caller can act on (PR8 delivery retry). `channelInput` still
|
|
35
|
-
* returns after accept (submit != completion) so the channel can ack promptly.
|
|
36
|
-
*
|
|
37
|
-
* Restart: an unexpected child exit marks the runtime `degraded`; the next turn
|
|
38
|
-
* re-spawns the resident child with `--resume <session_id>`, restoring the
|
|
39
|
-
* conversation. There is no background backoff timer — re-spawn is lazy and
|
|
40
|
-
* bound to the (serialized) turn queue, so it stays deterministic.
|
|
41
|
-
*
|
|
42
|
-
* Per-turn idle deadline: a turn whose still-alive child goes silent — never
|
|
43
|
-
* emitting another stream line (a stall, or a wait on input the runtime cannot
|
|
44
|
-
* satisfy) — would otherwise pend forever and wedge the serial queue, and behind
|
|
45
|
-
* it TeamMate completion delivery, which awaits this runtime. `turn_timeout_ms`
|
|
46
|
-
* is a *max-idle* window (issue #156): it is reset on every inbound stream line,
|
|
47
|
-
* so a long but continuously-streaming turn (e.g. a deep audit running many
|
|
48
|
-
* tool calls for far longer than the window) is never reaped, while a child that
|
|
49
|
-
* emits nothing for the whole window still is — turning an infinite hang into a
|
|
50
|
-
* normal degraded + `last_error` (inbound) or `failed` delivery result.
|
|
51
|
-
*
|
|
52
|
-
* Reference: the resident stream-json protocol model and process-supervision
|
|
53
|
-
* shape are adapted from the Claudemux `next` implementation; the AgentRuntime /
|
|
54
|
-
* Channel / DispatcherService boundaries (provider seam, runtime-owned MCP
|
|
55
|
-
* injection, degraded/last_error status, TeamMate delivery result contract) are
|
|
56
|
-
* Dreamux's own, per `.agents/decisions/agent-runtime-provider.md`.
|
|
57
|
-
*/
|
|
58
|
-
import { mkdir, rename, rm, symlink, writeFile, } from 'node:fs/promises';
|
|
1
|
+
/** Resident Claude Code AgentRuntime using stream-json stdio. */
|
|
59
2
|
import { randomUUID } from 'node:crypto';
|
|
60
|
-
import {
|
|
3
|
+
import { join } from 'node:path';
|
|
61
4
|
import { BUILTIN_CLAUDE_CODE_PROVIDER_REF } from './provider-ref.js';
|
|
62
5
|
import { claudeCodeResidentArgs } from './args.js';
|
|
63
6
|
import { stringifyClaudeCodeMcpConfig } from './mcp-config.js';
|
|
64
|
-
import {
|
|
65
|
-
import {
|
|
7
|
+
import { skillAdapterKey } from './skill-adapter.js';
|
|
8
|
+
import { materializeClaudeSkillAddDir } from './skill-materializer.js';
|
|
9
|
+
import { DEFAULT_MESSAGE_ID_DEDUPE_WINDOW, renderChannelInput, unsupportedFeatureError, } from '@excitedjs/dreamux-utils';
|
|
66
10
|
import { CLAUDE_CODE_AGENT_RUNTIME_CAPABILITIES } from './provider.js';
|
|
67
11
|
import { consoleFallbackLogger } from './logger.js';
|
|
68
|
-
|
|
12
|
+
import { ClaudeSteerAdmissionError } from './rpc.js';
|
|
13
|
+
import { createRuntimeSubmission, handleProtocolEvent, } from './runtime-submissions.js';
|
|
14
|
+
import { asError, classifySteerFailure, reserveSource, } from './source-reservation.js';
|
|
15
|
+
import { buildClaudeProcessEnv, resolveRuntimeTranscriptPath, } from './runtime-session.js';
|
|
69
16
|
function errMessage(err) {
|
|
70
17
|
return err instanceof Error ? err.message : String(err);
|
|
71
18
|
}
|
|
@@ -88,21 +35,31 @@ export class ClaudeCodeRuntime {
|
|
|
88
35
|
logger;
|
|
89
36
|
status = 'declared';
|
|
90
37
|
threadId;
|
|
38
|
+
transcriptLocator;
|
|
39
|
+
resumeOnNextSpawn;
|
|
91
40
|
resumed;
|
|
92
41
|
stopped = false;
|
|
93
42
|
seen = new Set();
|
|
43
|
+
seenOrder = [];
|
|
94
44
|
seenTextInputIds = new Set();
|
|
45
|
+
seenTextInputIdOrder = [];
|
|
46
|
+
sourceIdDedupeWindow;
|
|
47
|
+
pendingChannelSources = new Map();
|
|
48
|
+
pendingTextSources = new Map();
|
|
49
|
+
pendingAdmissions = new Set();
|
|
95
50
|
queue = Promise.resolve();
|
|
96
|
-
runtimeInstanceId = ++nextRuntimeInstanceId;
|
|
97
|
-
turnCounter = 0;
|
|
98
51
|
session = null;
|
|
99
|
-
|
|
52
|
+
sessionStarting = null;
|
|
53
|
+
startTask = null;
|
|
54
|
+
stopTask = null;
|
|
55
|
+
generation = 0;
|
|
100
56
|
activeTurn = null;
|
|
101
57
|
queuedTurnCount = 0;
|
|
102
58
|
idlePromise = null;
|
|
103
59
|
idleResolve = null;
|
|
104
60
|
constructor(identity, deps) {
|
|
105
61
|
this.deps = deps;
|
|
62
|
+
const checkpoint = identity.checkpoint ?? null;
|
|
106
63
|
this.dispatcherId = identity.runtime_id;
|
|
107
64
|
this.config = deps.config;
|
|
108
65
|
this.bin = deps.resolveBinPath(this.config.bin);
|
|
@@ -113,8 +70,11 @@ export class ClaudeCodeRuntime {
|
|
|
113
70
|
// central logs root (B2): core no longer names a per-runtime log file. The
|
|
114
71
|
// host supplies a unique, filesystem-safe `runtime_id`.
|
|
115
72
|
this.stderrLogPath = join(deps.paths.logsDir(), 'claude-code', `${this.dispatcherId}.stderr.log`);
|
|
116
|
-
this.threadId =
|
|
117
|
-
this.
|
|
73
|
+
this.threadId = checkpoint?.id ?? null;
|
|
74
|
+
this.transcriptLocator = checkpoint?.transcript_locator ?? null;
|
|
75
|
+
this.resumeOnNextSpawn = checkpoint !== null;
|
|
76
|
+
this.resumed = identity.checkpoint !== null;
|
|
77
|
+
this.sourceIdDedupeWindow = Math.max(0, deps.sourceIdDedupeWindow ?? DEFAULT_MESSAGE_ID_DEDUPE_WINDOW);
|
|
118
78
|
this.logger = deps.logger ?? consoleFallbackLogger(this.dispatcherId);
|
|
119
79
|
}
|
|
120
80
|
getStatus() {
|
|
@@ -124,55 +84,102 @@ export class ClaudeCodeRuntime {
|
|
|
124
84
|
return CLAUDE_CODE_AGENT_RUNTIME_CAPABILITIES;
|
|
125
85
|
}
|
|
126
86
|
getCheckpoint() {
|
|
127
|
-
return this.threadId === null
|
|
87
|
+
return this.threadId === null
|
|
88
|
+
? null
|
|
89
|
+
: {
|
|
90
|
+
id: this.threadId,
|
|
91
|
+
transcript_locator: this.transcriptLocator,
|
|
92
|
+
};
|
|
128
93
|
}
|
|
129
94
|
wasCheckpointResumed() {
|
|
130
95
|
return this.resumed;
|
|
131
96
|
}
|
|
132
|
-
async getLast() {
|
|
133
|
-
return this.lastResult;
|
|
134
|
-
}
|
|
135
97
|
async getContext() {
|
|
136
98
|
return null;
|
|
137
99
|
}
|
|
138
100
|
async resume() {
|
|
139
101
|
await this.start();
|
|
140
102
|
}
|
|
141
|
-
|
|
103
|
+
start() {
|
|
104
|
+
if (this.stopped) {
|
|
105
|
+
return Promise.reject(new Error('claude-code runtime is stopped'));
|
|
106
|
+
}
|
|
107
|
+
if (this.startTask !== null)
|
|
108
|
+
return this.startTask;
|
|
109
|
+
if (this.status === 'ready')
|
|
110
|
+
return Promise.resolve();
|
|
111
|
+
const generation = this.generation;
|
|
112
|
+
const task = this.startRuntime(generation);
|
|
113
|
+
this.startTask = task;
|
|
114
|
+
void task.finally(() => {
|
|
115
|
+
if (this.startTask === task)
|
|
116
|
+
this.startTask = null;
|
|
117
|
+
}).catch(() => undefined);
|
|
118
|
+
return task;
|
|
119
|
+
}
|
|
120
|
+
async startRuntime(generation) {
|
|
121
|
+
this.assertGeneration(generation);
|
|
142
122
|
await this.setStatus('starting');
|
|
143
123
|
try {
|
|
144
|
-
await this.
|
|
124
|
+
await materializeClaudeSkillAddDir(this.skillAddDirRoot, this.deps.skillSources ?? []);
|
|
125
|
+
this.assertGeneration(generation);
|
|
145
126
|
// Spawn the resident child up front so the runtime is truly resident
|
|
146
127
|
// (Codex-aligned). A missing/broken `claude` binary fails here and drives
|
|
147
128
|
// the runtime to degraded + throws, rather than a silent no-op.
|
|
148
129
|
await this.ensureSession();
|
|
130
|
+
this.assertGeneration(generation);
|
|
149
131
|
}
|
|
150
132
|
catch (err) {
|
|
151
|
-
|
|
133
|
+
if (!this.stopped && generation === this.generation) {
|
|
134
|
+
await this.setStatus('degraded', err);
|
|
135
|
+
}
|
|
152
136
|
throw err;
|
|
153
137
|
}
|
|
154
138
|
await this.setStatus('ready');
|
|
139
|
+
this.assertGeneration(generation);
|
|
155
140
|
}
|
|
156
141
|
async stop() {
|
|
157
|
-
if (this.stopped)
|
|
142
|
+
if (this.status === 'stopped')
|
|
158
143
|
return;
|
|
144
|
+
if (this.stopTask !== null)
|
|
145
|
+
return this.stopTask;
|
|
159
146
|
this.stopped = true;
|
|
147
|
+
this.generation += 1;
|
|
148
|
+
if (this.activeTurn !== null)
|
|
149
|
+
this.stopUnsettled(this.activeTurn);
|
|
150
|
+
const task = this.stopRuntime();
|
|
151
|
+
this.stopTask = task;
|
|
152
|
+
try {
|
|
153
|
+
await task;
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
if (this.stopTask === task)
|
|
157
|
+
this.stopTask = null;
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async stopRuntime() {
|
|
162
|
+
const sessionAtStop = this.session;
|
|
163
|
+
const sessionStop = sessionAtStop?.stop() ?? null;
|
|
164
|
+
void sessionStop?.catch(() => undefined);
|
|
160
165
|
await this.setStatus('stopping');
|
|
161
166
|
const session = this.session;
|
|
162
|
-
this.session = null;
|
|
163
167
|
if (session !== null) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
this.
|
|
169
|
-
}
|
|
168
|
+
await (session === sessionAtStop && sessionStop !== null
|
|
169
|
+
? sessionStop
|
|
170
|
+
: session.stop());
|
|
171
|
+
if (this.session === session)
|
|
172
|
+
this.session = null;
|
|
170
173
|
}
|
|
171
|
-
this.
|
|
174
|
+
await this.drainAdmissions();
|
|
175
|
+
await this.queue;
|
|
172
176
|
this.resolveIdleWaitersIfIdle();
|
|
173
177
|
await this.setStatus('stopped');
|
|
174
178
|
}
|
|
175
|
-
|
|
179
|
+
completionInput(input) {
|
|
180
|
+
return this.trackAdmission(this.completionInputImpl(input));
|
|
181
|
+
}
|
|
182
|
+
async completionInputImpl(input) {
|
|
176
183
|
if (this.stopped)
|
|
177
184
|
return { status: 'stopped' };
|
|
178
185
|
if (input.outputSchema !== undefined) {
|
|
@@ -188,91 +195,88 @@ export class ClaudeCodeRuntime {
|
|
|
188
195
|
return { status: 'failed', error };
|
|
189
196
|
}
|
|
190
197
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
if (key !== undefined && key !== '')
|
|
196
|
-
this.seenTextInputIds.add(key);
|
|
197
|
-
// Plain-text teammate/completion turns share the same active steerable
|
|
198
|
-
// logical-turn slot as channel inbound (PR #282 E2E regression): a send
|
|
199
|
-
// that arrives while an earlier turn is still in-flight folds into that
|
|
200
|
-
// active turn via `steerTurn({ priority: 'next' })` and returns the same
|
|
201
|
-
// `turnId`, aligned with the Codex runtime's `claimActiveTurnSlot`
|
|
202
|
-
// semantics. This is the runtime-owned plain-text path — no channel/XML
|
|
203
|
-
// rendering is applied.
|
|
204
|
-
const active = this.activeTurn;
|
|
205
|
-
if (active !== null) {
|
|
206
|
-
try {
|
|
207
|
-
await this.steerActiveTurn(active, input.text);
|
|
208
|
-
return { status: 'submitted', turnId: active.turnId };
|
|
209
|
-
}
|
|
210
|
-
catch (err) {
|
|
211
|
-
return {
|
|
212
|
-
status: 'failed',
|
|
213
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
const turnId = this.nextTurnId('turn');
|
|
218
|
-
this.recordQueuedTurnStart();
|
|
219
|
-
const turn = {
|
|
220
|
-
turnId,
|
|
221
|
-
pendingSteers: [],
|
|
222
|
-
session: null,
|
|
223
|
-
steerQueue: Promise.resolve(),
|
|
224
|
-
// Dreamux-owned plain text turns are real user turns, not synthetic
|
|
225
|
-
// system injections.
|
|
226
|
-
submitOptions: { isSynthetic: false },
|
|
227
|
-
};
|
|
228
|
-
this.activeTurn = turn;
|
|
229
|
-
void this.runActiveTurnOnQueue(input.text, turn).then((resultText) => this.markTurnSucceeded(turnId, resultText), (err) => this.markTurnFailed(turnId, err));
|
|
230
|
-
return { status: 'submitted', turnId };
|
|
198
|
+
return reserveSource(input.sourceId, this.seenTextInputIds, this.seenTextInputIdOrder, this.pendingTextSources, this.sourceIdDedupeWindow, () => this.acceptTextInput(input));
|
|
199
|
+
}
|
|
200
|
+
async acceptTextInput(input) {
|
|
201
|
+
return this.acceptInput(input.text, { isSynthetic: false });
|
|
231
202
|
}
|
|
232
|
-
|
|
203
|
+
channelInput(input) {
|
|
204
|
+
return this.trackAdmission(this.channelInputImpl(input));
|
|
205
|
+
}
|
|
206
|
+
async channelInputImpl(input) {
|
|
233
207
|
if (this.stopped)
|
|
234
208
|
return { status: 'stopped' };
|
|
235
|
-
const key = input.sourceId;
|
|
236
|
-
if (key !== '' && this.seen.has(key))
|
|
237
|
-
return { status: 'duplicate' };
|
|
238
|
-
if (key !== '')
|
|
239
|
-
this.seen.add(key);
|
|
240
209
|
// This runtime owns wrapping the channel input into its delivery shape: a
|
|
241
210
|
// structured channel turn becomes the native `<channel source="…">` block;
|
|
242
211
|
// a plain turn passes through unchanged.
|
|
243
|
-
|
|
212
|
+
let text;
|
|
213
|
+
try {
|
|
214
|
+
text = renderChannelInput(input);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
return { status: 'failed', error: asError(error) };
|
|
218
|
+
}
|
|
219
|
+
return reserveSource(input.sourceId, this.seen, this.seenOrder, this.pendingChannelSources, this.sourceIdDedupeWindow, () => this.acceptChannelInput(text));
|
|
220
|
+
}
|
|
221
|
+
async acceptChannelInput(text) {
|
|
222
|
+
return this.acceptInput(text);
|
|
223
|
+
}
|
|
224
|
+
async acceptInput(text, submitOptions) {
|
|
225
|
+
if (this.stopped)
|
|
226
|
+
return { status: 'stopped' };
|
|
227
|
+
const commandUuid = randomUUID();
|
|
228
|
+
const deferred = createRuntimeSubmission();
|
|
244
229
|
const active = this.activeTurn;
|
|
245
230
|
if (active !== null) {
|
|
231
|
+
active.submissions.set(commandUuid, deferred);
|
|
246
232
|
try {
|
|
247
|
-
await this.steerActiveTurn(active, text);
|
|
248
|
-
return { status: 'submitted',
|
|
233
|
+
await this.steerActiveTurn(active, text, commandUuid);
|
|
234
|
+
return { status: 'submitted', submission: deferred.submission };
|
|
249
235
|
}
|
|
250
|
-
catch (
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
};
|
|
236
|
+
catch (error) {
|
|
237
|
+
active.submissions.delete(commandUuid);
|
|
238
|
+
deferred.settle({ kind: 'failed', error: asError(error) });
|
|
239
|
+
return classifySteerFailure(error, this.stopped);
|
|
255
240
|
}
|
|
256
241
|
}
|
|
257
|
-
const turnId = this.nextTurnId('turn');
|
|
258
242
|
this.recordQueuedTurnStart();
|
|
243
|
+
let resolveSession;
|
|
244
|
+
let rejectSession;
|
|
245
|
+
const sessionReady = new Promise((resolve, reject) => {
|
|
246
|
+
resolveSession = resolve;
|
|
247
|
+
rejectSession = reject;
|
|
248
|
+
});
|
|
249
|
+
void sessionReady.catch(() => undefined);
|
|
259
250
|
const turn = {
|
|
260
|
-
|
|
261
|
-
|
|
251
|
+
initialCommandUuid: commandUuid,
|
|
252
|
+
submissions: new Map([[commandUuid, deferred]]),
|
|
253
|
+
started: [],
|
|
254
|
+
completedCommands: new Set(),
|
|
255
|
+
activitySequence: 0,
|
|
256
|
+
tools: new Map(),
|
|
262
257
|
session: null,
|
|
258
|
+
sessionReady,
|
|
259
|
+
resolveSession,
|
|
260
|
+
rejectSession,
|
|
263
261
|
steerQueue: Promise.resolve(),
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// refactor (the existing test asserts `submitOptions[0]` is undefined).
|
|
262
|
+
generation: this.generation,
|
|
263
|
+
...(submitOptions !== undefined ? { submitOptions } : {}),
|
|
267
264
|
};
|
|
268
265
|
this.activeTurn = turn;
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
void
|
|
275
|
-
|
|
266
|
+
void this.runActiveTurnOnQueue(text, turn).then(() => this.markTurnSucceeded(turn), (err) => this.markTurnFailed(turn, err));
|
|
267
|
+
return { status: 'submitted', submission: deferred.submission };
|
|
268
|
+
}
|
|
269
|
+
trackAdmission(admission) {
|
|
270
|
+
this.pendingAdmissions.add(admission);
|
|
271
|
+
void admission.finally(() => {
|
|
272
|
+
this.pendingAdmissions.delete(admission);
|
|
273
|
+
}).catch(() => undefined);
|
|
274
|
+
return admission;
|
|
275
|
+
}
|
|
276
|
+
async drainAdmissions() {
|
|
277
|
+
while (this.pendingAdmissions.size > 0) {
|
|
278
|
+
await Promise.allSettled([...this.pendingAdmissions]);
|
|
279
|
+
}
|
|
276
280
|
}
|
|
277
281
|
waitIdle() {
|
|
278
282
|
if (this.queuedTurnCount === 0)
|
|
@@ -292,61 +296,68 @@ export class ClaudeCodeRuntime {
|
|
|
292
296
|
return run;
|
|
293
297
|
}
|
|
294
298
|
async runActiveTurn(prompt, active) {
|
|
295
|
-
const session = await this.ensureSession();
|
|
296
|
-
const steers = active.pendingSteers.splice(0);
|
|
297
|
-
const fullPrompt = steers.length === 0 ? prompt : [prompt, ...steers].join('\n\n');
|
|
298
|
-
const outcome = session.submitTurn(fullPrompt, active.submitOptions);
|
|
299
|
-
active.session = session;
|
|
300
299
|
try {
|
|
301
|
-
|
|
300
|
+
this.assertGeneration(active.generation);
|
|
301
|
+
const session = await this.ensureSession();
|
|
302
|
+
this.assertGeneration(active.generation);
|
|
303
|
+
const outcome = session.submitTurn(prompt, active.submitOptions, active.initialCommandUuid);
|
|
304
|
+
active.session = session;
|
|
305
|
+
active.resolveSession(session);
|
|
306
|
+
await outcome;
|
|
307
|
+
this.assertGeneration(active.generation);
|
|
308
|
+
this.log('info', 'claude-code turn completed');
|
|
302
309
|
}
|
|
303
310
|
finally {
|
|
304
311
|
active.session = null;
|
|
305
312
|
if (this.activeTurn === active)
|
|
306
313
|
this.activeTurn = null;
|
|
307
|
-
active.pendingSteers = [];
|
|
308
314
|
}
|
|
309
315
|
}
|
|
310
|
-
async steerActiveTurn(active, prompt) {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
316
|
+
async steerActiveTurn(active, prompt, commandUuid) {
|
|
317
|
+
this.assertGeneration(active.generation);
|
|
318
|
+
const session = active.session ?? await active.sessionReady;
|
|
319
|
+
const steer = active.steerQueue.then(() => {
|
|
320
|
+
this.assertGeneration(active.generation);
|
|
321
|
+
if (active.session !== session || this.session !== session) {
|
|
322
|
+
throw new ClaudeSteerAdmissionError('failed', 'claude-code session changed before live steer');
|
|
323
|
+
}
|
|
324
|
+
return session.steerTurn(prompt, { priority: 'next' }, commandUuid);
|
|
325
|
+
});
|
|
317
326
|
active.steerQueue = steer.then(() => undefined, () => undefined);
|
|
318
327
|
await steer;
|
|
328
|
+
this.assertGeneration(active.generation);
|
|
319
329
|
}
|
|
320
|
-
async markTurnSucceeded(
|
|
330
|
+
async markTurnSucceeded(turn) {
|
|
321
331
|
this.recordQueuedTurnEnd();
|
|
322
|
-
this.
|
|
323
|
-
turnId,
|
|
324
|
-
status: 'completed',
|
|
325
|
-
result: { text: resultText },
|
|
326
|
-
});
|
|
332
|
+
this.stopUnsettled(turn);
|
|
327
333
|
if (this.stopped)
|
|
328
334
|
return;
|
|
329
335
|
if (this.status !== 'ready')
|
|
330
336
|
await this.setStatus('ready');
|
|
331
337
|
}
|
|
332
|
-
async markTurnFailed(
|
|
338
|
+
async markTurnFailed(turn, err) {
|
|
333
339
|
this.recordQueuedTurnEnd();
|
|
334
|
-
this.log('error',
|
|
340
|
+
this.log('error', 'claude-code turn failed', err);
|
|
341
|
+
turn.rejectSession(asError(err));
|
|
335
342
|
// A turn that fails after stop() was requested (the resident child is being
|
|
336
343
|
// torn down) is a `stopped` settlement; otherwise it is a genuine `failed`.
|
|
337
344
|
// Fire before the stopped early-return so an interrupted teammate turn is
|
|
338
345
|
// never lost.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
});
|
|
346
|
+
for (const deferred of turn.submissions.values()) {
|
|
347
|
+
deferred.settle(this.stopped
|
|
348
|
+
? { kind: 'stopped' }
|
|
349
|
+
: { kind: 'failed', error: asError(err) });
|
|
350
|
+
}
|
|
345
351
|
if (this.stopped)
|
|
346
352
|
return;
|
|
347
353
|
// Surface the failure as durable runtime state rather than swallowing it.
|
|
348
354
|
await this.setStatus('degraded', err);
|
|
349
355
|
}
|
|
356
|
+
stopUnsettled(turn) {
|
|
357
|
+
for (const deferred of turn.submissions.values()) {
|
|
358
|
+
deferred.settle({ kind: 'stopped' });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
350
361
|
recordQueuedTurnStart() {
|
|
351
362
|
this.queuedTurnCount += 1;
|
|
352
363
|
}
|
|
@@ -362,18 +373,47 @@ export class ClaudeCodeRuntime {
|
|
|
362
373
|
this.idleResolve = null;
|
|
363
374
|
resolve?.();
|
|
364
375
|
}
|
|
365
|
-
/**
|
|
366
|
-
* Ensure a live resident session exists, spawning (or re-spawning after an
|
|
367
|
-
* unexpected exit) as needed. Re-spawn resumes the persisted session id so the
|
|
368
|
-
* conversation survives a crash.
|
|
369
|
-
*/
|
|
376
|
+
/** Ensure a live resident session exists, resuming after a child exit. */
|
|
370
377
|
async ensureSession() {
|
|
378
|
+
if (this.stopped)
|
|
379
|
+
throw new Error('claude-code runtime is stopped');
|
|
380
|
+
if (this.sessionStarting !== null)
|
|
381
|
+
return this.sessionStarting;
|
|
371
382
|
if (this.session !== null && this.session.isAlive())
|
|
372
383
|
return this.session;
|
|
384
|
+
const generation = this.generation;
|
|
385
|
+
const starting = this.createSession(generation);
|
|
386
|
+
this.sessionStarting = starting;
|
|
387
|
+
void starting.finally(() => {
|
|
388
|
+
if (this.sessionStarting === starting)
|
|
389
|
+
this.sessionStarting = null;
|
|
390
|
+
}).catch(() => undefined);
|
|
391
|
+
return starting;
|
|
392
|
+
}
|
|
393
|
+
async createSession(generation) {
|
|
394
|
+
this.assertGeneration(generation);
|
|
395
|
+
const previous = this.session;
|
|
396
|
+
if (previous !== null) {
|
|
397
|
+
await previous.stop();
|
|
398
|
+
if (this.session === previous)
|
|
399
|
+
this.session = null;
|
|
400
|
+
this.assertGeneration(generation);
|
|
401
|
+
}
|
|
402
|
+
const resuming = this.resumeOnNextSpawn;
|
|
403
|
+
const candidateSessionId = resuming
|
|
404
|
+
? this.threadId
|
|
405
|
+
: (this.deps.generateSessionId?.() ?? randomUUID());
|
|
406
|
+
const candidatePath = await this.resolveTranscriptPath({
|
|
407
|
+
sessionId: candidateSessionId,
|
|
408
|
+
locator: resuming ? this.transcriptLocator : null,
|
|
409
|
+
resume: resuming,
|
|
410
|
+
});
|
|
373
411
|
const args = claudeCodeResidentArgs({
|
|
374
412
|
config: this.config,
|
|
375
413
|
mcpConfigJson: this.mcpConfigJson,
|
|
376
|
-
|
|
414
|
+
...(resuming
|
|
415
|
+
? { resumeSessionId: candidateSessionId }
|
|
416
|
+
: { freshSessionId: candidateSessionId }),
|
|
377
417
|
systemPromptAppend: this.deps.systemPromptAppend,
|
|
378
418
|
skillAddDirs: (this.deps.skillSources ?? []).length === 0
|
|
379
419
|
? []
|
|
@@ -385,7 +425,7 @@ export class ClaudeCodeRuntime {
|
|
|
385
425
|
bin: this.bin,
|
|
386
426
|
args,
|
|
387
427
|
cwd: this.cwd,
|
|
388
|
-
env: this.
|
|
428
|
+
env: buildClaudeProcessEnv(this.deps.injectEnv, this.config.extra_env),
|
|
389
429
|
stderrLogPath: this.stderrLogPath,
|
|
390
430
|
turnTimeoutMs: this.config.turn_timeout_ms,
|
|
391
431
|
remoteControl: this.config.remote_control,
|
|
@@ -394,109 +434,87 @@ export class ClaudeCodeRuntime {
|
|
|
394
434
|
this.log('info', `claude-code remote control URL: ${url}`);
|
|
395
435
|
}
|
|
396
436
|
: undefined,
|
|
437
|
+
onProtocolEvent: (event) => this.onProtocolEvent(event),
|
|
397
438
|
log: (level, msg, err) => this.log(level, msg, err),
|
|
398
439
|
});
|
|
399
440
|
session.setOnExit(() => {
|
|
400
441
|
void this.onSessionExit(session);
|
|
401
442
|
});
|
|
402
|
-
|
|
443
|
+
// Retain termination authority before spawn.
|
|
403
444
|
this.session = session;
|
|
404
|
-
return session;
|
|
405
|
-
}
|
|
406
|
-
async materializeSkillAddDir() {
|
|
407
|
-
const sources = this.deps.skillSources ?? [];
|
|
408
|
-
if (sources.length === 0)
|
|
409
|
-
return;
|
|
410
|
-
const manifest = join(this.skillAddDirRoot, '.dreamux-skill-adapter.json');
|
|
411
|
-
if (await adapterExists(manifest))
|
|
412
|
-
return;
|
|
413
|
-
const tmpRoot = `${this.skillAddDirRoot}.${randomUUID()}.tmp`;
|
|
414
|
-
const tmpSkillsRoot = join(tmpRoot, '.claude', 'skills');
|
|
415
445
|
try {
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
linkedNames.set(skill.name, skill.path);
|
|
428
|
-
await symlink(skill.path, join(tmpSkillsRoot, skill.name), 'dir');
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
await writeFile(join(tmpRoot, '.dreamux-skill-adapter.json'), `${JSON.stringify({
|
|
432
|
-
version: 1,
|
|
433
|
-
key: skillAdapterKey(sources),
|
|
434
|
-
sources: uniqueSkillSources(sources).map((source) => ({
|
|
435
|
-
name: source.name,
|
|
436
|
-
path: resolve(source.path),
|
|
437
|
-
})),
|
|
438
|
-
}, null, 2)}\n`, { mode: 0o600 });
|
|
439
|
-
await mkdir(dirname(this.skillAddDirRoot), { recursive: true });
|
|
440
|
-
await rename(tmpRoot, this.skillAddDirRoot);
|
|
446
|
+
this.assertGeneration(generation);
|
|
447
|
+
await session.start();
|
|
448
|
+
this.assertGeneration(generation);
|
|
449
|
+
await this.deps.state.setCheckpoint({
|
|
450
|
+
id: candidateSessionId,
|
|
451
|
+
transcript_locator: candidatePath,
|
|
452
|
+
});
|
|
453
|
+
this.assertGeneration(generation);
|
|
454
|
+
this.threadId = candidateSessionId;
|
|
455
|
+
this.transcriptLocator = candidatePath;
|
|
456
|
+
this.resumeOnNextSpawn = true;
|
|
441
457
|
}
|
|
442
|
-
catch (
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
|
|
458
|
+
catch (error) {
|
|
459
|
+
if (this.stopped)
|
|
460
|
+
throw error;
|
|
461
|
+
try {
|
|
462
|
+
await session.stop();
|
|
463
|
+
if (this.session === session)
|
|
464
|
+
this.session = null;
|
|
446
465
|
}
|
|
447
|
-
|
|
448
|
-
|
|
466
|
+
catch (stopError) {
|
|
467
|
+
throw new AggregateError([error, stopError], 'claude session start failed and termination could not be proved');
|
|
468
|
+
}
|
|
469
|
+
throw error;
|
|
449
470
|
}
|
|
471
|
+
return session;
|
|
472
|
+
}
|
|
473
|
+
resolveTranscriptPath(input) {
|
|
474
|
+
return resolveRuntimeTranscriptPath({
|
|
475
|
+
sessionId: input.sessionId,
|
|
476
|
+
cwd: this.cwd,
|
|
477
|
+
locator: input.locator,
|
|
478
|
+
env: buildClaudeProcessEnv(this.deps.injectEnv, this.config.extra_env),
|
|
479
|
+
resume: input.resume,
|
|
480
|
+
override: this.deps.resolveTranscriptPath,
|
|
481
|
+
});
|
|
450
482
|
}
|
|
451
483
|
/** React to an unexpected resident-child exit: degrade and drop the session. */
|
|
452
484
|
async onSessionExit(session) {
|
|
453
485
|
if (this.session !== session)
|
|
454
486
|
return; // already replaced/stopped
|
|
455
|
-
this.session = null;
|
|
456
487
|
if (this.stopped)
|
|
457
488
|
return;
|
|
458
489
|
this.log('error', 'claude-code resident child exited unexpectedly');
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
outcome.sessionId !== '' &&
|
|
464
|
-
outcome.sessionId !== this.threadId) {
|
|
465
|
-
this.threadId = outcome.sessionId;
|
|
466
|
-
await this.deps.state.setCheckpoint({ id: outcome.sessionId });
|
|
490
|
+
try {
|
|
491
|
+
await session.stop();
|
|
492
|
+
if (this.session === session)
|
|
493
|
+
this.session = null;
|
|
467
494
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
? outcome.errors.join('; ')
|
|
472
|
-
: (outcome.subtype ?? 'unknown error');
|
|
473
|
-
throw new Error(`claude turn ${turnId} returned an error result: ${detail}`);
|
|
495
|
+
catch (error) {
|
|
496
|
+
await this.setStatus('degraded', error);
|
|
497
|
+
return;
|
|
474
498
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
499
|
+
if (!this.stopped) {
|
|
500
|
+
await this.setStatus('degraded', new Error('claude resident child exited'));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
onProtocolEvent(event) {
|
|
504
|
+
const active = this.activeTurn;
|
|
505
|
+
if (active === null)
|
|
506
|
+
return;
|
|
507
|
+
handleProtocolEvent(active, event, {
|
|
508
|
+
threadId: this.threadId,
|
|
509
|
+
outputSchemaEnabled: this.deps.outputSchema !== undefined,
|
|
510
|
+
activitySink: this.deps.activitySink,
|
|
511
|
+
log: (level, message, error) => this.log(level, message, error),
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
assertGeneration(generation) {
|
|
515
|
+
if (this.stopped || generation !== this.generation) {
|
|
516
|
+
throw new Error('claude-code runtime is stopped');
|
|
480
517
|
}
|
|
481
|
-
// Only cache the result once all validation passes, so a failed schema turn
|
|
482
|
-
// never leaks its unvalidated free-form text through getLast().
|
|
483
|
-
if (resultText !== null)
|
|
484
|
-
this.lastResult = { text: resultText };
|
|
485
|
-
this.log('info', `claude-code turn ${turnId} completed`);
|
|
486
|
-
return resultText;
|
|
487
|
-
}
|
|
488
|
-
nextTurnId(kind) {
|
|
489
|
-
return `claude-${kind}-${this.runtimeInstanceId}-${++this.turnCounter}`;
|
|
490
|
-
}
|
|
491
|
-
buildProcessEnv(extraEnv) {
|
|
492
|
-
// Neutral env boundary: { ...process.env, ...injectEnv, ...extra_env }.
|
|
493
|
-
// `injectEnv` is the host's optional injection seam (empty today); `extraEnv`
|
|
494
|
-
// is this provider's own `config.extra_env`, merged last so it can override.
|
|
495
|
-
return {
|
|
496
|
-
...globalThis.process.env,
|
|
497
|
-
...(this.deps.injectEnv ?? {}),
|
|
498
|
-
...extraEnv,
|
|
499
|
-
};
|
|
500
518
|
}
|
|
501
519
|
async setStatus(status, err) {
|
|
502
520
|
// The in-memory status is authoritative (getStatus reads it). Persisting it is
|