@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.
Files changed (94) hide show
  1. package/README.md +43 -1
  2. package/dist/args.d.ts +2 -0
  3. package/dist/args.d.ts.map +1 -1
  4. package/dist/args.js +5 -0
  5. package/dist/args.js.map +1 -1
  6. package/dist/index.d.ts +2 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/provider.d.ts +5 -0
  10. package/dist/provider.d.ts.map +1 -1
  11. package/dist/provider.js +10 -4
  12. package/dist/provider.js.map +1 -1
  13. package/dist/rpc.d.ts +79 -6
  14. package/dist/rpc.d.ts.map +1 -1
  15. package/dist/rpc.js +329 -57
  16. package/dist/rpc.js.map +1 -1
  17. package/dist/runtime-deps.d.ts +30 -0
  18. package/dist/runtime-deps.d.ts.map +1 -0
  19. package/dist/runtime-deps.js +2 -0
  20. package/dist/runtime-deps.js.map +1 -0
  21. package/dist/runtime-session.d.ts +13 -0
  22. package/dist/runtime-session.d.ts.map +1 -0
  23. package/dist/runtime-session.js +41 -0
  24. package/dist/runtime-session.js.map +1 -0
  25. package/dist/runtime-submissions.d.ts +34 -0
  26. package/dist/runtime-submissions.d.ts.map +1 -0
  27. package/dist/runtime-submissions.js +200 -0
  28. package/dist/runtime-submissions.js.map +1 -0
  29. package/dist/runtime.d.ts +33 -131
  30. package/dist/runtime.d.ts.map +1 -1
  31. package/dist/runtime.js +286 -268
  32. package/dist/runtime.js.map +1 -1
  33. package/dist/skill-adapter.d.ts +11 -1
  34. package/dist/skill-adapter.d.ts.map +1 -1
  35. package/dist/skill-adapter.js +60 -7
  36. package/dist/skill-adapter.js.map +1 -1
  37. package/dist/skill-materializer.d.ts +6 -0
  38. package/dist/skill-materializer.d.ts.map +1 -0
  39. package/dist/skill-materializer.js +45 -0
  40. package/dist/skill-materializer.js.map +1 -0
  41. package/dist/source-reservation.d.ts +6 -0
  42. package/dist/source-reservation.d.ts.map +1 -0
  43. package/dist/source-reservation.js +48 -0
  44. package/dist/source-reservation.js.map +1 -0
  45. package/dist/stream.d.ts +3 -1
  46. package/dist/stream.d.ts.map +1 -1
  47. package/dist/stream.js +48 -1
  48. package/dist/stream.js.map +1 -1
  49. package/dist/supervisor.d.ts.map +1 -1
  50. package/dist/supervisor.js +68 -14
  51. package/dist/supervisor.js.map +1 -1
  52. package/dist/transcript/budget.d.ts +8 -0
  53. package/dist/transcript/budget.d.ts.map +1 -0
  54. package/dist/transcript/budget.js +9 -0
  55. package/dist/transcript/budget.js.map +1 -0
  56. package/dist/transcript/completion.d.ts +8 -0
  57. package/dist/transcript/completion.d.ts.map +1 -0
  58. package/dist/transcript/completion.js +57 -0
  59. package/dist/transcript/completion.js.map +1 -0
  60. package/dist/transcript/cursor.d.ts +25 -0
  61. package/dist/transcript/cursor.d.ts.map +1 -0
  62. package/dist/transcript/cursor.js +62 -0
  63. package/dist/transcript/cursor.js.map +1 -0
  64. package/dist/transcript/error.d.ts +9 -0
  65. package/dist/transcript/error.d.ts.map +1 -0
  66. package/dist/transcript/error.js +9 -0
  67. package/dist/transcript/error.js.map +1 -0
  68. package/dist/transcript/native-hash.d.ts +2 -0
  69. package/dist/transcript/native-hash.d.ts.map +1 -0
  70. package/dist/transcript/native-hash.js +79 -0
  71. package/dist/transcript/native-hash.js.map +1 -0
  72. package/dist/transcript/opened-file.d.ts +13 -0
  73. package/dist/transcript/opened-file.d.ts.map +1 -0
  74. package/dist/transcript/opened-file.js +110 -0
  75. package/dist/transcript/opened-file.js.map +1 -0
  76. package/dist/transcript/parallel-tools.d.ts +6 -0
  77. package/dist/transcript/parallel-tools.d.ts.map +1 -0
  78. package/dist/transcript/parallel-tools.js +123 -0
  79. package/dist/transcript/parallel-tools.js.map +1 -0
  80. package/dist/transcript/path.d.ts +25 -0
  81. package/dist/transcript/path.d.ts.map +1 -0
  82. package/dist/transcript/path.js +253 -0
  83. package/dist/transcript/path.js.map +1 -0
  84. package/dist/transcript/reader.d.ts +7 -0
  85. package/dist/transcript/reader.d.ts.map +1 -0
  86. package/dist/transcript/reader.js +476 -0
  87. package/dist/transcript/reader.js.map +1 -0
  88. package/dist/transcript/rewrites.d.ts +5 -0
  89. package/dist/transcript/rewrites.d.ts.map +1 -0
  90. package/dist/transcript/rewrites.js +65 -0
  91. package/dist/transcript/rewrites.js.map +1 -0
  92. package/dist/types.d.ts +33 -3
  93. package/dist/types.d.ts.map +1 -1
  94. 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 { dirname, join, resolve } from 'node:path';
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 { adapterExists, skillAdapterKey, skillDirsInRoot, uniqueSkillSources, } from './skill-adapter.js';
65
- import { renderChannelInput, unsupportedFeatureError, } from '@excitedjs/dreamux-utils';
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
- let nextRuntimeInstanceId = 0;
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
- lastResult = null;
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 = identity.checkpoint_id ?? null;
117
- this.resumed = (identity.checkpoint_id ?? null) !== null;
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 ? null : { id: this.threadId };
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
- async start() {
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.materializeSkillAddDir();
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
- await this.setStatus('degraded', err);
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
- try {
165
- await session.stop();
166
- }
167
- catch (err) {
168
- this.log('warn', 'claude-code session stop errored', err);
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.queuedTurnCount = 0;
174
+ await this.drainAdmissions();
175
+ await this.queue;
172
176
  this.resolveIdleWaitersIfIdle();
173
177
  await this.setStatus('stopped');
174
178
  }
175
- async completionInput(input) {
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
- const key = input.sourceId;
192
- if (key !== undefined && key !== '' && this.seenTextInputIds.has(key)) {
193
- return { status: 'duplicate' };
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
- async channelInput(input) {
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
- const text = renderChannelInput(input);
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', turnId: active.turnId };
233
+ await this.steerActiveTurn(active, text, commandUuid);
234
+ return { status: 'submitted', submission: deferred.submission };
249
235
  }
250
- catch (err) {
251
- return {
252
- status: 'failed',
253
- error: err instanceof Error ? err : new Error(String(err)),
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
- turnId,
261
- pendingSteers: [],
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
- // Channel-initial turns carry no explicit submit options; the native
265
- // `submitTurn(prompt)` call is unchanged from before the shared active-slot
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
- // Submit-then-serialize: return after accept (so the channel can ack
270
- // promptly), run the turn on the serial queue. A turn failure cannot be
271
- // returned to this caller without blocking the channel ack on full turn
272
- // completion. Instead, a failed turn drives the runtime to `degraded` with a
273
- // persisted `last_error` (visible via status/doctor) — never swallowed.
274
- void this.runActiveTurnOnQueue(text, turn).then((resultText) => this.markTurnSucceeded(turnId, resultText), (err) => this.markTurnFailed(turnId, err));
275
- return { status: 'submitted', turnId };
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
- return await this.applyTurnOutcome(await outcome, active.turnId);
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
- const session = active.session;
312
- if (session === null) {
313
- active.pendingSteers.push(prompt);
314
- return;
315
- }
316
- const steer = active.steerQueue.then(() => session.steerTurn(prompt, { priority: 'next' }));
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(turnId, resultText) {
330
+ async markTurnSucceeded(turn) {
321
331
  this.recordQueuedTurnEnd();
322
- this.deps.onTurnSettled?.({
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(turnId, err) {
338
+ async markTurnFailed(turn, err) {
333
339
  this.recordQueuedTurnEnd();
334
- this.log('error', `claude-code turn ${turnId} failed`, err);
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
- this.deps.onTurnSettled?.({
340
- turnId,
341
- status: this.stopped ? 'stopped' : 'failed',
342
- result: { text: null },
343
- error: err instanceof Error ? err : new Error(String(err)),
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
- resumeSessionId: this.threadId,
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.buildProcessEnv(this.config.extra_env),
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
- await session.start();
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
- await mkdir(tmpSkillsRoot, { recursive: true });
417
- const linkedNames = new Map();
418
- for (const source of uniqueSkillSources(sources)) {
419
- for (const skill of await skillDirsInRoot(source.path)) {
420
- const previous = linkedNames.get(skill.name);
421
- if (previous !== undefined && previous !== skill.path) {
422
- throw new Error(`duplicate Claude skill name ${JSON.stringify(skill.name)} from ` +
423
- `${previous} and ${skill.path}`);
424
- }
425
- if (previous !== undefined)
426
- continue;
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 (err) {
443
- if (err.code === 'EEXIST') {
444
- await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
445
- return;
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
- await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
448
- throw err;
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
- await this.setStatus('degraded', new Error('claude resident child exited'));
460
- }
461
- async applyTurnOutcome(outcome, turnId) {
462
- if (outcome.sessionId !== null &&
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
- const resultText = outcome.isError || outcome.text === '' ? null : outcome.text;
469
- if (outcome.isError) {
470
- const detail = outcome.errors.length > 0
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
- // When --json-schema was requested, Claude must return a validated
476
- // `structured_output`; fail loud rather than surfacing free-form text.
477
- if (this.deps.outputSchema !== undefined && !outcome.hasStructuredOutput) {
478
- throw new Error(`claude turn ${turnId} did not return structured_output for a ` +
479
- `--json-schema session`);
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