@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.
Files changed (94) hide show
  1. package/README.md +41 -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 +9 -4
  12. package/dist/provider.js.map +1 -1
  13. package/dist/rpc.d.ts +14 -1
  14. package/dist/rpc.d.ts.map +1 -1
  15. package/dist/rpc.js +194 -53
  16. package/dist/rpc.js.map +1 -1
  17. package/dist/runtime-deps.d.ts +29 -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-turn.d.ts +7 -0
  26. package/dist/runtime-turn.d.ts.map +1 -0
  27. package/dist/runtime-turn.js +21 -0
  28. package/dist/runtime-turn.js.map +1 -0
  29. package/dist/runtime.d.ts +30 -131
  30. package/dist/runtime.d.ts.map +1 -1
  31. package/dist/runtime.js +258 -225
  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 +1 -1
  46. package/dist/stream.d.ts.map +1 -1
  47. package/dist/stream.js +21 -1
  48. package/dist/stream.js.map +1 -1
  49. package/dist/supervisor.d.ts.map +1 -1
  50. package/dist/supervisor.js +63 -10
  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 +44 -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 +7 -0
  93. package/dist/types.d.ts.map +1 -1
  94. 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 { 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 { 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
- 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,101 @@ 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
+ 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
- try {
165
- await session.stop();
166
- }
167
- catch (err) {
168
- this.log('warn', 'claude-code session stop errored', err);
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.queuedTurnCount = 0;
173
+ await this.drainAdmissions();
174
+ await this.queue;
172
175
  this.resolveIdleWaitersIfIdle();
173
176
  await this.setStatus('stopped');
174
177
  }
175
- async completionInput(input) {
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
- 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
+ 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
- // `turnId`, aligned with the Codex runtime's `claimActiveTurnSlot`
202
- // semantics. This is the runtime-owned plain-text path — no channel/XML
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', turnId: active.turnId };
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 turnId = this.nextTurnId('turn');
219
+ const runtimeTurn = createRuntimeTurn();
218
220
  this.recordQueuedTurnStart();
219
221
  const turn = {
220
- turnId,
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(turnId, resultText), (err) => this.markTurnFailed(turnId, err));
230
- return { status: 'submitted', turnId };
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
- async channelInput(input) {
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
- const text = renderChannelInput(input);
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', turnId: active.turnId };
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 turnId = this.nextTurnId('turn');
267
+ const runtimeTurn = createRuntimeTurn();
258
268
  this.recordQueuedTurnStart();
259
269
  const turn = {
260
- turnId,
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(turnId, resultText), (err) => this.markTurnFailed(turnId, err));
275
- return { status: 'submitted', turnId };
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
- return await this.applyTurnOutcome(await outcome, active.turnId);
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(() => session.steerTurn(prompt, { priority: 'next' }));
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(turnId, resultText) {
358
+ async markTurnSucceeded(turn, resultText) {
321
359
  this.recordQueuedTurnEnd();
322
- this.deps.onTurnSettled?.({
323
- turnId,
360
+ turn.settle({
324
361
  status: 'completed',
325
- result: { text: resultText },
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(turnId, err) {
370
+ async markTurnFailed(turn, err) {
333
371
  this.recordQueuedTurnEnd();
334
- this.log('error', `claude-code turn ${turnId} failed`, err);
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.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
- });
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
- resumeSessionId: this.threadId,
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.buildProcessEnv(this.config.extra_env),
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
- await session.start();
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
- 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);
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 (err) {
443
- if (err.code === 'EEXIST') {
444
- await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
445
- return;
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
- await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
448
- throw err;
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
- 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 });
516
+ try {
517
+ await session.stop();
518
+ if (this.session === session)
519
+ this.session = null;
467
520
  }
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}`);
521
+ catch (error) {
522
+ await this.setStatus('degraded', error);
523
+ return;
474
524
  }
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`);
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