@excitedjs/agent-runtime-claude-code 0.5.0-beta.167 → 1.0.0-beta.169
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 +41 -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 +9 -4
- package/dist/provider.js.map +1 -1
- package/dist/rpc.d.ts +14 -1
- package/dist/rpc.d.ts.map +1 -1
- package/dist/rpc.js +194 -53
- package/dist/rpc.js.map +1 -1
- package/dist/runtime-deps.d.ts +29 -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-turn.d.ts +7 -0
- package/dist/runtime-turn.d.ts.map +1 -0
- package/dist/runtime-turn.js +21 -0
- package/dist/runtime-turn.js.map +1 -0
- package/dist/runtime.d.ts +30 -131
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +258 -225
- 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 +1 -1
- package/dist/stream.d.ts.map +1 -1
- package/dist/stream.js +21 -1
- package/dist/stream.js.map +1 -1
- package/dist/supervisor.d.ts.map +1 -1
- package/dist/supervisor.js +63 -10
- 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 +44 -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 +7 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -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 { createRuntimeTurn } from './runtime-turn.js';
|
|
13
|
+
import { ClaudeSteerAdmissionError } from './rpc.js';
|
|
14
|
+
import { asError, classifySteerFailure, reserveSource, } from './source-reservation.js';
|
|
15
|
+
import { buildClaudeProcessEnv, resolveRuntimeTranscriptPath, resultTextFromTurnOutcome, } 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,101 @@ 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
|
+
this.activeTurn?.settle({ status: 'stopped' });
|
|
149
|
+
const task = this.stopRuntime();
|
|
150
|
+
this.stopTask = task;
|
|
151
|
+
try {
|
|
152
|
+
await task;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (this.stopTask === task)
|
|
156
|
+
this.stopTask = null;
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async stopRuntime() {
|
|
161
|
+
const sessionAtStop = this.session;
|
|
162
|
+
const sessionStop = sessionAtStop?.stop() ?? null;
|
|
163
|
+
void sessionStop?.catch(() => undefined);
|
|
160
164
|
await this.setStatus('stopping');
|
|
161
165
|
const session = this.session;
|
|
162
|
-
this.session = null;
|
|
163
166
|
if (session !== null) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
this.
|
|
169
|
-
}
|
|
167
|
+
await (session === sessionAtStop && sessionStop !== null
|
|
168
|
+
? sessionStop
|
|
169
|
+
: session.stop());
|
|
170
|
+
if (this.session === session)
|
|
171
|
+
this.session = null;
|
|
170
172
|
}
|
|
171
|
-
this.
|
|
173
|
+
await this.drainAdmissions();
|
|
174
|
+
await this.queue;
|
|
172
175
|
this.resolveIdleWaitersIfIdle();
|
|
173
176
|
await this.setStatus('stopped');
|
|
174
177
|
}
|
|
175
|
-
|
|
178
|
+
completionInput(input) {
|
|
179
|
+
return this.trackAdmission(this.completionInputImpl(input));
|
|
180
|
+
}
|
|
181
|
+
async completionInputImpl(input) {
|
|
176
182
|
if (this.stopped)
|
|
177
183
|
return { status: 'stopped' };
|
|
178
184
|
if (input.outputSchema !== undefined) {
|
|
@@ -188,79 +194,85 @@ export class ClaudeCodeRuntime {
|
|
|
188
194
|
return { status: 'failed', error };
|
|
189
195
|
}
|
|
190
196
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
this.seenTextInputIds.add(key);
|
|
197
|
+
return reserveSource(input.sourceId, this.seenTextInputIds, this.seenTextInputIdOrder, this.pendingTextSources, this.sourceIdDedupeWindow, () => this.acceptTextInput(input));
|
|
198
|
+
}
|
|
199
|
+
async acceptTextInput(input) {
|
|
200
|
+
if (this.stopped)
|
|
201
|
+
return { status: 'stopped' };
|
|
197
202
|
// Plain-text teammate/completion turns share the same active steerable
|
|
198
203
|
// logical-turn slot as channel inbound (PR #282 E2E regression): a send
|
|
199
204
|
// that arrives while an earlier turn is still in-flight folds into that
|
|
200
205
|
// active turn via `steerTurn({ priority: 'next' })` and returns the same
|
|
201
|
-
//
|
|
202
|
-
//
|
|
206
|
+
// exact RuntimeTurn, aligned with the Codex active-slot semantics. This is
|
|
207
|
+
// the runtime-owned plain-text path — no channel/XML
|
|
203
208
|
// rendering is applied.
|
|
204
209
|
const active = this.activeTurn;
|
|
205
210
|
if (active !== null) {
|
|
206
211
|
try {
|
|
207
212
|
await this.steerActiveTurn(active, input.text);
|
|
208
|
-
return { status: 'submitted',
|
|
213
|
+
return { status: 'submitted', turn: active.runtimeTurn };
|
|
209
214
|
}
|
|
210
215
|
catch (err) {
|
|
211
|
-
return
|
|
212
|
-
status: 'failed',
|
|
213
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
214
|
-
};
|
|
216
|
+
return classifySteerFailure(err, this.stopped);
|
|
215
217
|
}
|
|
216
218
|
}
|
|
217
|
-
const
|
|
219
|
+
const runtimeTurn = createRuntimeTurn();
|
|
218
220
|
this.recordQueuedTurnStart();
|
|
219
221
|
const turn = {
|
|
220
|
-
|
|
222
|
+
runtimeTurn: runtimeTurn.turn,
|
|
223
|
+
settle: runtimeTurn.settle,
|
|
221
224
|
pendingSteers: [],
|
|
222
225
|
session: null,
|
|
223
226
|
steerQueue: Promise.resolve(),
|
|
227
|
+
generation: this.generation,
|
|
224
228
|
// Dreamux-owned plain text turns are real user turns, not synthetic
|
|
225
229
|
// system injections.
|
|
226
230
|
submitOptions: { isSynthetic: false },
|
|
227
231
|
};
|
|
228
232
|
this.activeTurn = turn;
|
|
229
|
-
void this.runActiveTurnOnQueue(input.text, turn).then((resultText) => this.markTurnSucceeded(
|
|
230
|
-
return { status: 'submitted',
|
|
233
|
+
void this.runActiveTurnOnQueue(input.text, turn).then((resultText) => this.markTurnSucceeded(turn, resultText), (err) => this.markTurnFailed(turn, err));
|
|
234
|
+
return { status: 'submitted', turn: turn.runtimeTurn };
|
|
231
235
|
}
|
|
232
|
-
|
|
236
|
+
channelInput(input) {
|
|
237
|
+
return this.trackAdmission(this.channelInputImpl(input));
|
|
238
|
+
}
|
|
239
|
+
async channelInputImpl(input) {
|
|
233
240
|
if (this.stopped)
|
|
234
241
|
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
242
|
// This runtime owns wrapping the channel input into its delivery shape: a
|
|
241
243
|
// structured channel turn becomes the native `<channel source="…">` block;
|
|
242
244
|
// a plain turn passes through unchanged.
|
|
243
|
-
|
|
245
|
+
let text;
|
|
246
|
+
try {
|
|
247
|
+
text = renderChannelInput(input);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
return { status: 'failed', error: asError(error) };
|
|
251
|
+
}
|
|
252
|
+
return reserveSource(input.sourceId, this.seen, this.seenOrder, this.pendingChannelSources, this.sourceIdDedupeWindow, () => this.acceptChannelInput(text));
|
|
253
|
+
}
|
|
254
|
+
async acceptChannelInput(text) {
|
|
255
|
+
if (this.stopped)
|
|
256
|
+
return { status: 'stopped' };
|
|
244
257
|
const active = this.activeTurn;
|
|
245
258
|
if (active !== null) {
|
|
246
259
|
try {
|
|
247
260
|
await this.steerActiveTurn(active, text);
|
|
248
|
-
return { status: 'submitted',
|
|
261
|
+
return { status: 'submitted', turn: active.runtimeTurn };
|
|
249
262
|
}
|
|
250
263
|
catch (err) {
|
|
251
|
-
return
|
|
252
|
-
status: 'failed',
|
|
253
|
-
error: err instanceof Error ? err : new Error(String(err)),
|
|
254
|
-
};
|
|
264
|
+
return classifySteerFailure(err, this.stopped);
|
|
255
265
|
}
|
|
256
266
|
}
|
|
257
|
-
const
|
|
267
|
+
const runtimeTurn = createRuntimeTurn();
|
|
258
268
|
this.recordQueuedTurnStart();
|
|
259
269
|
const turn = {
|
|
260
|
-
|
|
270
|
+
runtimeTurn: runtimeTurn.turn,
|
|
271
|
+
settle: runtimeTurn.settle,
|
|
261
272
|
pendingSteers: [],
|
|
262
273
|
session: null,
|
|
263
274
|
steerQueue: Promise.resolve(),
|
|
275
|
+
generation: this.generation,
|
|
264
276
|
// Channel-initial turns carry no explicit submit options; the native
|
|
265
277
|
// `submitTurn(prompt)` call is unchanged from before the shared active-slot
|
|
266
278
|
// refactor (the existing test asserts `submitOptions[0]` is undefined).
|
|
@@ -271,8 +283,20 @@ export class ClaudeCodeRuntime {
|
|
|
271
283
|
// returned to this caller without blocking the channel ack on full turn
|
|
272
284
|
// completion. Instead, a failed turn drives the runtime to `degraded` with a
|
|
273
285
|
// persisted `last_error` (visible via status/doctor) — never swallowed.
|
|
274
|
-
void this.runActiveTurnOnQueue(text, turn).then((resultText) => this.markTurnSucceeded(
|
|
275
|
-
return { status: 'submitted',
|
|
286
|
+
void this.runActiveTurnOnQueue(text, turn).then((resultText) => this.markTurnSucceeded(turn, resultText), (err) => this.markTurnFailed(turn, err));
|
|
287
|
+
return { status: 'submitted', turn: turn.runtimeTurn };
|
|
288
|
+
}
|
|
289
|
+
trackAdmission(admission) {
|
|
290
|
+
this.pendingAdmissions.add(admission);
|
|
291
|
+
void admission.finally(() => {
|
|
292
|
+
this.pendingAdmissions.delete(admission);
|
|
293
|
+
}).catch(() => undefined);
|
|
294
|
+
return admission;
|
|
295
|
+
}
|
|
296
|
+
async drainAdmissions() {
|
|
297
|
+
while (this.pendingAdmissions.size > 0) {
|
|
298
|
+
await Promise.allSettled([...this.pendingAdmissions]);
|
|
299
|
+
}
|
|
276
300
|
}
|
|
277
301
|
waitIdle() {
|
|
278
302
|
if (this.queuedTurnCount === 0)
|
|
@@ -292,13 +316,19 @@ export class ClaudeCodeRuntime {
|
|
|
292
316
|
return run;
|
|
293
317
|
}
|
|
294
318
|
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
319
|
try {
|
|
301
|
-
|
|
320
|
+
this.assertGeneration(active.generation);
|
|
321
|
+
const session = await this.ensureSession();
|
|
322
|
+
this.assertGeneration(active.generation);
|
|
323
|
+
const steers = active.pendingSteers.splice(0);
|
|
324
|
+
const fullPrompt = steers.length === 0 ? prompt : [prompt, ...steers].join('\n\n');
|
|
325
|
+
const outcome = session.submitTurn(fullPrompt, active.submitOptions);
|
|
326
|
+
active.session = session;
|
|
327
|
+
const settled = await outcome;
|
|
328
|
+
this.assertGeneration(active.generation);
|
|
329
|
+
const resultText = resultTextFromTurnOutcome(settled, this.threadId, this.deps.outputSchema !== undefined);
|
|
330
|
+
this.log('info', 'claude-code turn completed');
|
|
331
|
+
return resultText;
|
|
302
332
|
}
|
|
303
333
|
finally {
|
|
304
334
|
active.session = null;
|
|
@@ -308,40 +338,48 @@ export class ClaudeCodeRuntime {
|
|
|
308
338
|
}
|
|
309
339
|
}
|
|
310
340
|
async steerActiveTurn(active, prompt) {
|
|
341
|
+
this.assertGeneration(active.generation);
|
|
311
342
|
const session = active.session;
|
|
312
343
|
if (session === null) {
|
|
313
344
|
active.pendingSteers.push(prompt);
|
|
314
345
|
return;
|
|
315
346
|
}
|
|
316
|
-
const steer = active.steerQueue.then(() =>
|
|
347
|
+
const steer = active.steerQueue.then(() => {
|
|
348
|
+
this.assertGeneration(active.generation);
|
|
349
|
+
if (active.session !== session || this.session !== session) {
|
|
350
|
+
throw new ClaudeSteerAdmissionError('failed', 'claude-code session changed before live steer');
|
|
351
|
+
}
|
|
352
|
+
return session.steerTurn(prompt, { priority: 'next' });
|
|
353
|
+
});
|
|
317
354
|
active.steerQueue = steer.then(() => undefined, () => undefined);
|
|
318
355
|
await steer;
|
|
356
|
+
this.assertGeneration(active.generation);
|
|
319
357
|
}
|
|
320
|
-
async markTurnSucceeded(
|
|
358
|
+
async markTurnSucceeded(turn, resultText) {
|
|
321
359
|
this.recordQueuedTurnEnd();
|
|
322
|
-
|
|
323
|
-
turnId,
|
|
360
|
+
turn.settle({
|
|
324
361
|
status: 'completed',
|
|
325
|
-
|
|
362
|
+
resultText,
|
|
363
|
+
truncated: false,
|
|
326
364
|
});
|
|
327
365
|
if (this.stopped)
|
|
328
366
|
return;
|
|
329
367
|
if (this.status !== 'ready')
|
|
330
368
|
await this.setStatus('ready');
|
|
331
369
|
}
|
|
332
|
-
async markTurnFailed(
|
|
370
|
+
async markTurnFailed(turn, err) {
|
|
333
371
|
this.recordQueuedTurnEnd();
|
|
334
|
-
this.log('error',
|
|
372
|
+
this.log('error', 'claude-code turn failed', err);
|
|
335
373
|
// A turn that fails after stop() was requested (the resident child is being
|
|
336
374
|
// torn down) is a `stopped` settlement; otherwise it is a genuine `failed`.
|
|
337
375
|
// Fire before the stopped early-return so an interrupted teammate turn is
|
|
338
376
|
// never lost.
|
|
339
|
-
this.
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
377
|
+
turn.settle(this.stopped
|
|
378
|
+
? { status: 'stopped' }
|
|
379
|
+
: {
|
|
380
|
+
status: 'failed',
|
|
381
|
+
error: err instanceof Error ? err : new Error(String(err)),
|
|
382
|
+
});
|
|
345
383
|
if (this.stopped)
|
|
346
384
|
return;
|
|
347
385
|
// Surface the failure as durable runtime state rather than swallowing it.
|
|
@@ -362,18 +400,47 @@ export class ClaudeCodeRuntime {
|
|
|
362
400
|
this.idleResolve = null;
|
|
363
401
|
resolve?.();
|
|
364
402
|
}
|
|
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
|
-
*/
|
|
403
|
+
/** Ensure a live resident session exists, resuming after a child exit. */
|
|
370
404
|
async ensureSession() {
|
|
405
|
+
if (this.stopped)
|
|
406
|
+
throw new Error('claude-code runtime is stopped');
|
|
407
|
+
if (this.sessionStarting !== null)
|
|
408
|
+
return this.sessionStarting;
|
|
371
409
|
if (this.session !== null && this.session.isAlive())
|
|
372
410
|
return this.session;
|
|
411
|
+
const generation = this.generation;
|
|
412
|
+
const starting = this.createSession(generation);
|
|
413
|
+
this.sessionStarting = starting;
|
|
414
|
+
void starting.finally(() => {
|
|
415
|
+
if (this.sessionStarting === starting)
|
|
416
|
+
this.sessionStarting = null;
|
|
417
|
+
}).catch(() => undefined);
|
|
418
|
+
return starting;
|
|
419
|
+
}
|
|
420
|
+
async createSession(generation) {
|
|
421
|
+
this.assertGeneration(generation);
|
|
422
|
+
const previous = this.session;
|
|
423
|
+
if (previous !== null) {
|
|
424
|
+
await previous.stop();
|
|
425
|
+
if (this.session === previous)
|
|
426
|
+
this.session = null;
|
|
427
|
+
this.assertGeneration(generation);
|
|
428
|
+
}
|
|
429
|
+
const resuming = this.resumeOnNextSpawn;
|
|
430
|
+
const candidateSessionId = resuming
|
|
431
|
+
? this.threadId
|
|
432
|
+
: (this.deps.generateSessionId?.() ?? randomUUID());
|
|
433
|
+
const candidatePath = await this.resolveTranscriptPath({
|
|
434
|
+
sessionId: candidateSessionId,
|
|
435
|
+
locator: resuming ? this.transcriptLocator : null,
|
|
436
|
+
resume: resuming,
|
|
437
|
+
});
|
|
373
438
|
const args = claudeCodeResidentArgs({
|
|
374
439
|
config: this.config,
|
|
375
440
|
mcpConfigJson: this.mcpConfigJson,
|
|
376
|
-
|
|
441
|
+
...(resuming
|
|
442
|
+
? { resumeSessionId: candidateSessionId }
|
|
443
|
+
: { freshSessionId: candidateSessionId }),
|
|
377
444
|
systemPromptAppend: this.deps.systemPromptAppend,
|
|
378
445
|
skillAddDirs: (this.deps.skillSources ?? []).length === 0
|
|
379
446
|
? []
|
|
@@ -385,7 +452,7 @@ export class ClaudeCodeRuntime {
|
|
|
385
452
|
bin: this.bin,
|
|
386
453
|
args,
|
|
387
454
|
cwd: this.cwd,
|
|
388
|
-
env: this.
|
|
455
|
+
env: buildClaudeProcessEnv(this.deps.injectEnv, this.config.extra_env),
|
|
389
456
|
stderrLogPath: this.stderrLogPath,
|
|
390
457
|
turnTimeoutMs: this.config.turn_timeout_ms,
|
|
391
458
|
remoteControl: this.config.remote_control,
|
|
@@ -399,104 +466,70 @@ export class ClaudeCodeRuntime {
|
|
|
399
466
|
session.setOnExit(() => {
|
|
400
467
|
void this.onSessionExit(session);
|
|
401
468
|
});
|
|
402
|
-
|
|
469
|
+
// Retain termination authority before spawn.
|
|
403
470
|
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
471
|
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);
|
|
472
|
+
this.assertGeneration(generation);
|
|
473
|
+
await session.start();
|
|
474
|
+
this.assertGeneration(generation);
|
|
475
|
+
await this.deps.state.setCheckpoint({
|
|
476
|
+
id: candidateSessionId,
|
|
477
|
+
transcript_locator: candidatePath,
|
|
478
|
+
});
|
|
479
|
+
this.assertGeneration(generation);
|
|
480
|
+
this.threadId = candidateSessionId;
|
|
481
|
+
this.transcriptLocator = candidatePath;
|
|
482
|
+
this.resumeOnNextSpawn = true;
|
|
441
483
|
}
|
|
442
|
-
catch (
|
|
443
|
-
if (
|
|
444
|
-
|
|
445
|
-
|
|
484
|
+
catch (error) {
|
|
485
|
+
if (this.stopped)
|
|
486
|
+
throw error;
|
|
487
|
+
try {
|
|
488
|
+
await session.stop();
|
|
489
|
+
if (this.session === session)
|
|
490
|
+
this.session = null;
|
|
446
491
|
}
|
|
447
|
-
|
|
448
|
-
|
|
492
|
+
catch (stopError) {
|
|
493
|
+
throw new AggregateError([error, stopError], 'claude session start failed and termination could not be proved');
|
|
494
|
+
}
|
|
495
|
+
throw error;
|
|
449
496
|
}
|
|
497
|
+
return session;
|
|
498
|
+
}
|
|
499
|
+
resolveTranscriptPath(input) {
|
|
500
|
+
return resolveRuntimeTranscriptPath({
|
|
501
|
+
sessionId: input.sessionId,
|
|
502
|
+
cwd: this.cwd,
|
|
503
|
+
locator: input.locator,
|
|
504
|
+
env: buildClaudeProcessEnv(this.deps.injectEnv, this.config.extra_env),
|
|
505
|
+
resume: input.resume,
|
|
506
|
+
override: this.deps.resolveTranscriptPath,
|
|
507
|
+
});
|
|
450
508
|
}
|
|
451
509
|
/** React to an unexpected resident-child exit: degrade and drop the session. */
|
|
452
510
|
async onSessionExit(session) {
|
|
453
511
|
if (this.session !== session)
|
|
454
512
|
return; // already replaced/stopped
|
|
455
|
-
this.session = null;
|
|
456
513
|
if (this.stopped)
|
|
457
514
|
return;
|
|
458
515
|
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 });
|
|
516
|
+
try {
|
|
517
|
+
await session.stop();
|
|
518
|
+
if (this.session === session)
|
|
519
|
+
this.session = null;
|
|
467
520
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
? outcome.errors.join('; ')
|
|
472
|
-
: (outcome.subtype ?? 'unknown error');
|
|
473
|
-
throw new Error(`claude turn ${turnId} returned an error result: ${detail}`);
|
|
521
|
+
catch (error) {
|
|
522
|
+
await this.setStatus('degraded', error);
|
|
523
|
+
return;
|
|
474
524
|
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
525
|
+
if (!this.stopped) {
|
|
526
|
+
await this.setStatus('degraded', new Error('claude resident child exited'));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
assertGeneration(generation) {
|
|
530
|
+
if (this.stopped || generation !== this.generation) {
|
|
531
|
+
throw new Error('claude-code runtime is stopped');
|
|
480
532
|
}
|
|
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
533
|
}
|
|
501
534
|
async setStatus(status, err) {
|
|
502
535
|
// The in-memory status is authoritative (getStatus reads it). Persisting it is
|