@mono-agent/agent-runtime 0.18.0 → 0.18.2

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.
@@ -1,16 +1,31 @@
1
1
  // @ts-check
2
2
 
3
+ import {
4
+ createCipheriv,
5
+ createDecipheriv,
6
+ hkdfSync,
7
+ randomBytes,
8
+ } from "node:crypto";
9
+
3
10
  const PROFILE_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
4
11
  const BASE64URL_RE = /^[A-Za-z0-9_-]+$/;
5
12
  const MAX_PROFILE_ID_LENGTH = 128;
6
13
  const MAX_RAW_TOKEN_BYTES = 4_096;
7
- const MAX_ENCODED_TOKEN_LENGTH = Math.ceil(MAX_RAW_TOKEN_BYTES * 4 / 3);
8
- const PROVIDER_SESSION_PREFIX = "acp:v1:";
9
- const SESSION_CURSOR_PREFIX = "acp-cursor:v1:";
14
+ const TOKEN_KEY_BYTES = 32;
15
+ const NONCE_BYTES = 12;
16
+ const AUTH_TAG_BYTES = 16;
17
+ const MAX_SEALED_TOKEN_BYTES = NONCE_BYTES + MAX_RAW_TOKEN_BYTES + AUTH_TAG_BYTES;
18
+ const MAX_ENCODED_TOKEN_LENGTH = Math.ceil(MAX_SEALED_TOKEN_BYTES * 4 / 3);
19
+ const PROVIDER_SESSION_PREFIX = "acp:v2:";
20
+ const SESSION_CURSOR_PREFIX = "acp-cursor:v2:";
10
21
  const MAX_PROVIDER_SESSION_ID_LENGTH = PROVIDER_SESSION_PREFIX.length
11
22
  + MAX_PROFILE_ID_LENGTH + 1 + MAX_ENCODED_TOKEN_LENGTH;
12
23
  const MAX_SESSION_CURSOR_LENGTH = SESSION_CURSOR_PREFIX.length
13
24
  + MAX_PROFILE_ID_LENGTH + 1 + MAX_ENCODED_TOKEN_LENGTH;
25
+ const TOKEN_DOMAIN = Buffer.from("mono-agent/acp-session-token", "utf8");
26
+ const TOKEN_VERSION = Buffer.from("v2", "utf8");
27
+ const HKDF_SALT = Buffer.from("mono-agent/acp-session-token/v2/hkdf", "utf8");
28
+ const ENCRYPTION_INFO = Buffer.from("mono-agent/acp-session-token/v2/encryption", "utf8");
14
29
 
15
30
  export class AcpClientError extends Error {
16
31
  /**
@@ -45,46 +60,187 @@ function requiredTokenString(value, code, label) {
45
60
  return value;
46
61
  }
47
62
 
48
- /** @param {string} encoded @param {string} code @param {string} label */
49
- function decodeToken(encoded, code, label) {
50
- if (!BASE64URL_RE.test(encoded)) throw new AcpClientError(code, `Invalid ${label} encoding.`);
51
- const bytes = Buffer.from(encoded, "base64url");
52
- if (bytes.length === 0 || bytes.toString("base64url") !== encoded) {
53
- throw new AcpClientError(code, `Non-canonical ${label} encoding.`);
63
+ /**
64
+ * The token key is binary on purpose: accepting textual secrets here would
65
+ * make encoding, truncation, and cross-host persistence ambiguous.
66
+ * @param {unknown} value
67
+ */
68
+ function sessionTokenKey(value) {
69
+ if (!(value instanceof Uint8Array) || value.byteLength !== TOKEN_KEY_BYTES) {
70
+ throw new AcpClientError(
71
+ "invalid_token_key",
72
+ `ACP session token key must be exactly ${TOKEN_KEY_BYTES} bytes.`,
73
+ );
54
74
  }
55
- if (bytes.length > MAX_RAW_TOKEN_BYTES) {
56
- throw new AcpClientError(code, `${label} exceeds ${MAX_RAW_TOKEN_BYTES} bytes.`);
75
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
76
+ }
77
+
78
+ /** @param {unknown} key @returns {Buffer} */
79
+ export function validateAcpSessionTokenKey(key) {
80
+ return Buffer.from(sessionTokenKey(key));
81
+ }
82
+
83
+ /** @param {ReadonlyArray<Uint8Array>} parts */
84
+ function frame(parts) {
85
+ const size = parts.reduce((total, part) => total + 4 + part.byteLength, 0);
86
+ const result = Buffer.allocUnsafe(size);
87
+ let offset = 0;
88
+ for (const part of parts) {
89
+ result.writeUInt32BE(part.byteLength, offset);
90
+ offset += 4;
91
+ Buffer.from(part.buffer, part.byteOffset, part.byteLength).copy(result, offset);
92
+ offset += part.byteLength;
57
93
  }
94
+ return result;
95
+ }
96
+
97
+ /** @param {"session"|"cursor"} kind @param {string} profileId */
98
+ function tokenAad(kind, profileId) {
99
+ return frame([
100
+ TOKEN_DOMAIN,
101
+ TOKEN_VERSION,
102
+ Buffer.from(kind, "utf8"),
103
+ Buffer.from(profileId, "utf8"),
104
+ ]);
105
+ }
106
+
107
+ /** @param {Uint8Array} key */
108
+ function encryptionKey(key) {
109
+ const rawKey = Buffer.from(sessionTokenKey(key));
58
110
  try {
59
- return requiredTokenString(new TextDecoder("utf-8", { fatal: true }).decode(bytes), code, label);
60
- } catch (error) {
61
- if (error instanceof AcpClientError) throw error;
62
- throw new AcpClientError(code, `${label} is not valid UTF-8.`);
111
+ return Buffer.from(hkdfSync("sha256", rawKey, HKDF_SALT, ENCRYPTION_INFO, TOKEN_KEY_BYTES));
112
+ } finally {
113
+ rawKey.fill(0);
63
114
  }
64
115
  }
65
116
 
66
- /** @param {string} profileId @param {string} sessionId */
67
- export function encodeAcpProviderSessionId(profileId, sessionId) {
117
+ /** @param {string} profileId @param {string} raw @param {string} code @param {string} label */
118
+ function rawTokenBytes(profileId, raw, code, label) {
68
119
  validateAcpProfileId(profileId);
69
- requiredTokenString(sessionId, "invalid_session_id", "ACP session id");
70
- if (Buffer.byteLength(sessionId, "utf8") > MAX_RAW_TOKEN_BYTES) {
71
- throw new AcpClientError("invalid_session_id", `ACP session id exceeds ${MAX_RAW_TOKEN_BYTES} bytes.`);
120
+ requiredTokenString(raw, code, label);
121
+ const plaintext = Buffer.from(raw, "utf8");
122
+ if (plaintext.byteLength > MAX_RAW_TOKEN_BYTES) {
123
+ throw new AcpClientError(code, `${label} exceeds ${MAX_RAW_TOKEN_BYTES} bytes.`);
72
124
  }
73
- return `${PROVIDER_SESSION_PREFIX}${profileId}:${Buffer.from(sessionId, "utf8").toString("base64url")}`;
125
+ return plaintext;
74
126
  }
75
127
 
76
- /** Internal protocol-state decoder. This module is not a package export. @param {string} providerSessionId */
77
- export function decodeAcpProviderSessionId(providerSessionId) {
78
- if (typeof providerSessionId !== "string") {
79
- throw new AcpClientError("invalid_session_id", "ACP provider session id must be a string.");
128
+ /**
129
+ * @param {"session"|"cursor"} kind
130
+ * @param {string} profileId
131
+ * @param {string} raw
132
+ * @param {Uint8Array} key
133
+ * @param {string} code
134
+ * @param {string} label
135
+ */
136
+ function sealToken(kind, profileId, raw, key, code, label) {
137
+ const plaintext = rawTokenBytes(profileId, raw, code, label);
138
+ const aad = tokenAad(kind, profileId);
139
+ const nonce = randomBytes(NONCE_BYTES);
140
+ const derivedKey = encryptionKey(key);
141
+ let cipher;
142
+ try {
143
+ cipher = createCipheriv("aes-256-gcm", derivedKey, nonce);
144
+ } finally {
145
+ derivedKey.fill(0);
80
146
  }
81
- if (providerSessionId.length > MAX_PROVIDER_SESSION_ID_LENGTH) {
82
- throw new AcpClientError("invalid_session_id", "ACP provider session id exceeds the supported length.");
147
+ cipher.setAAD(aad);
148
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
149
+ return Buffer.concat([nonce, ciphertext, cipher.getAuthTag()]).toString("base64url");
150
+ }
151
+
152
+ /** @param {string} profileId @param {string} code @param {string} label */
153
+ function tokenProfileId(profileId, code, label) {
154
+ try {
155
+ return validateAcpProfileId(profileId);
156
+ } catch {
157
+ throw new AcpClientError(code, `Invalid ${label}.`);
83
158
  }
84
- const match = /^acp:v1:([^:]+):([^:]+)$/.exec(providerSessionId);
159
+ }
160
+
161
+ /**
162
+ * @param {string} encoded
163
+ * @param {string} code
164
+ * @param {string} label
165
+ */
166
+ function sealedTokenBytes(encoded, code, label) {
167
+ if (!BASE64URL_RE.test(encoded)) throw new AcpClientError(code, `Invalid ${label}.`);
168
+ const sealed = Buffer.from(encoded, "base64url");
169
+ if (sealed.toString("base64url") !== encoded
170
+ || sealed.byteLength <= NONCE_BYTES + AUTH_TAG_BYTES
171
+ || sealed.byteLength > MAX_SEALED_TOKEN_BYTES) {
172
+ throw new AcpClientError(code, `Invalid ${label}.`);
173
+ }
174
+ return sealed;
175
+ }
176
+
177
+ /**
178
+ * @param {"session"|"cursor"} kind
179
+ * @param {string} profileId
180
+ * @param {string} encoded
181
+ * @param {Uint8Array} key
182
+ * @param {string} code
183
+ * @param {string} label
184
+ */
185
+ function openToken(kind, profileId, encoded, key, code, label) {
186
+ const sealed = sealedTokenBytes(encoded, code, label);
187
+ const aad = tokenAad(kind, profileId);
188
+ const nonce = sealed.subarray(0, NONCE_BYTES);
189
+ const ciphertext = sealed.subarray(NONCE_BYTES, -AUTH_TAG_BYTES);
190
+ const authTag = sealed.subarray(-AUTH_TAG_BYTES);
191
+ const derivedKey = encryptionKey(key);
192
+ let decipher;
193
+ try {
194
+ decipher = createDecipheriv("aes-256-gcm", derivedKey, nonce);
195
+ } finally {
196
+ derivedKey.fill(0);
197
+ }
198
+ let plaintext;
199
+ try {
200
+ decipher.setAAD(aad);
201
+ decipher.setAuthTag(authTag);
202
+ plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
203
+ } catch {
204
+ throw new AcpClientError(code, `Invalid ${label}.`);
205
+ }
206
+ try {
207
+ return requiredTokenString(
208
+ new TextDecoder("utf-8", { fatal: true }).decode(plaintext),
209
+ code,
210
+ label,
211
+ );
212
+ } catch (error) {
213
+ if (error instanceof AcpClientError) throw error;
214
+ throw new AcpClientError(code, `Invalid ${label}.`);
215
+ }
216
+ }
217
+
218
+ /** @param {string} profileId @param {string} sessionId @param {Uint8Array} key */
219
+ export function encodeAcpProviderSessionId(profileId, sessionId, key) {
220
+ const encoded = sealToken("session", profileId, sessionId, key, "invalid_session_id", "ACP session id");
221
+ return `${PROVIDER_SESSION_PREFIX}${profileId}:${encoded}`;
222
+ }
223
+
224
+ /**
225
+ * Internal protocol-state decoder. This module is not a package export.
226
+ * @param {string} providerSessionId
227
+ * @param {Uint8Array} key
228
+ */
229
+ export function decodeAcpProviderSessionId(providerSessionId, key) {
230
+ if (typeof providerSessionId !== "string" || providerSessionId.length > MAX_PROVIDER_SESSION_ID_LENGTH) {
231
+ throw new AcpClientError("invalid_session_id", "Invalid ACP provider session id.");
232
+ }
233
+ const match = /^acp:v2:([^:]+):([^:]+)$/.exec(providerSessionId);
85
234
  if (!match) throw new AcpClientError("invalid_session_id", "Invalid ACP provider session id.");
86
- const profileId = validateAcpProfileId(match[1]);
87
- const sessionId = decodeToken(match[2], "invalid_session_id", "ACP session id");
235
+ const profileId = tokenProfileId(match[1], "invalid_session_id", "ACP provider session id");
236
+ const sessionId = openToken(
237
+ "session",
238
+ profileId,
239
+ match[2],
240
+ key,
241
+ "invalid_session_id",
242
+ "ACP provider session id",
243
+ );
88
244
  return { profileId, sessionId };
89
245
  }
90
246
 
@@ -94,36 +250,33 @@ export function decodeAcpProviderSessionId(providerSessionId) {
94
250
  *
95
251
  * @param {string} providerSessionId
96
252
  * @param {string} expectedProfileId
253
+ * @param {Uint8Array} key Host-owned 32-byte ACP session-token key.
97
254
  * @returns {string}
98
255
  */
99
- export function validateAcpProviderSessionId(providerSessionId, expectedProfileId) {
256
+ export function validateAcpProviderSessionId(providerSessionId, expectedProfileId, key) {
100
257
  const profileId = validateAcpProfileId(expectedProfileId);
101
- const decoded = decodeAcpProviderSessionId(providerSessionId);
258
+ const decoded = decodeAcpProviderSessionId(providerSessionId, key);
102
259
  if (decoded.profileId !== profileId) {
103
260
  throw new AcpClientError("invalid_session_id", "ACP provider session belongs to a different profile.");
104
261
  }
105
262
  return providerSessionId;
106
263
  }
107
264
 
108
- /** @param {string} profileId @param {string} cursor */
109
- export function encodeAcpSessionCursor(profileId, cursor) {
110
- validateAcpProfileId(profileId);
111
- requiredTokenString(cursor, "invalid_cursor", "ACP session cursor");
112
- if (Buffer.byteLength(cursor, "utf8") > MAX_RAW_TOKEN_BYTES) {
113
- throw new AcpClientError("invalid_cursor", `ACP session cursor exceeds ${MAX_RAW_TOKEN_BYTES} bytes.`);
114
- }
115
- return `${SESSION_CURSOR_PREFIX}${profileId}:${Buffer.from(cursor, "utf8").toString("base64url")}`;
265
+ /** @param {string} profileId @param {string} cursor @param {Uint8Array} key */
266
+ export function encodeAcpSessionCursor(profileId, cursor, key) {
267
+ const encoded = sealToken("cursor", profileId, cursor, key, "invalid_cursor", "ACP session cursor");
268
+ return `${SESSION_CURSOR_PREFIX}${profileId}:${encoded}`;
116
269
  }
117
270
 
118
- /** @param {string} profileId @param {unknown} cursor */
119
- export function decodeAcpSessionCursor(profileId, cursor) {
271
+ /** @param {string} profileId @param {unknown} cursor @param {Uint8Array} key */
272
+ export function decodeAcpSessionCursor(profileId, cursor, key) {
120
273
  validateAcpProfileId(profileId);
121
274
  if (typeof cursor !== "string" || cursor.length > MAX_SESSION_CURSOR_LENGTH) {
122
275
  throw new AcpClientError("invalid_cursor", "ACP session cursor must be an opaque cursor returned by listAcpSessions.");
123
276
  }
124
- const match = /^acp-cursor:v1:([^:]+):([^:]+)$/.exec(cursor);
125
- if (!match || match[1] !== profileId) {
277
+ const match = /^acp-cursor:v2:([^:]+):([^:]+)$/.exec(cursor);
278
+ if (!match || tokenProfileId(match[1], "invalid_cursor", "ACP session cursor") !== profileId) {
126
279
  throw new AcpClientError("invalid_cursor", "ACP session cursor is invalid for this profile.");
127
280
  }
128
- return decodeToken(match[2], "invalid_cursor", "ACP session cursor");
281
+ return openToken("cursor", profileId, match[2], key, "invalid_cursor", "ACP session cursor");
129
282
  }
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+
3
5
  // Strict, bounded ACP v1 newline transport for an owned stdio child process.
4
6
  // The SDK's stock ndJsonStream intentionally tolerates malformed input and its
5
7
  // line buffer is unbounded, which is a poor fit for a long-lived host boundary.
@@ -7,6 +9,101 @@
7
9
  const DEFAULT_MAX_LINE_BYTES = 1024 * 1024;
8
10
  const MAX_MAX_LINE_BYTES = 16 * 1024 * 1024;
9
11
 
12
+ // @agentclientprotocol/sdk 1.3.0 sends the trailing arguments of these
13
+ // diagnostics directly to console. Those values include raw JSON-RPC payloads
14
+ // (and, for malformed notifications, Zod error details derived from them).
15
+ // Preserve the useful classification while dropping payload-bearing details.
16
+ const ACP_SDK_PAYLOAD_DIAGNOSTICS = Object.freeze({
17
+ error: new Set([
18
+ "Invalid message",
19
+ "Error handling notification",
20
+ "Got response to unknown request",
21
+ "Failed to parse JSON message:",
22
+ "ACP connection router stopped unexpectedly:",
23
+ ]),
24
+ warn: new Set([
25
+ "Skipping JSON line that is not an object:",
26
+ ]),
27
+ });
28
+ const acpSdkDiagnosticScope = new AsyncLocalStorage();
29
+ let activeAcpSdkDiagnosticGuards = 0;
30
+ /** @type {null|{
31
+ * originalError: typeof console.error,
32
+ * originalWarn: typeof console.warn,
33
+ * guardedError: typeof console.error,
34
+ * guardedWarn: typeof console.warn,
35
+ * }} */
36
+ let acpSdkConsoleGuard = null;
37
+
38
+ function installAcpSdkConsoleGuard() {
39
+ activeAcpSdkDiagnosticGuards += 1;
40
+ if (acpSdkConsoleGuard) return;
41
+
42
+ const originalError = console.error;
43
+ const originalWarn = console.warn;
44
+ /** @type {typeof console.error} */
45
+ const guardedError = (...args) => {
46
+ const contentFree = args[0];
47
+ if (acpSdkDiagnosticScope.getStore() === true
48
+ && typeof contentFree === "string"
49
+ && ACP_SDK_PAYLOAD_DIAGNOSTICS.error.has(contentFree)) {
50
+ Reflect.apply(originalError, console, [contentFree]);
51
+ return;
52
+ }
53
+ Reflect.apply(originalError, console, args);
54
+ };
55
+ /** @type {typeof console.warn} */
56
+ const guardedWarn = (...args) => {
57
+ const contentFree = args[0];
58
+ if (acpSdkDiagnosticScope.getStore() === true
59
+ && typeof contentFree === "string"
60
+ && ACP_SDK_PAYLOAD_DIAGNOSTICS.warn.has(contentFree)) {
61
+ Reflect.apply(originalWarn, console, [contentFree]);
62
+ return;
63
+ }
64
+ Reflect.apply(originalWarn, console, args);
65
+ };
66
+
67
+ acpSdkConsoleGuard = { originalError, originalWarn, guardedError, guardedWarn };
68
+ console.error = guardedError;
69
+ console.warn = guardedWarn;
70
+ }
71
+
72
+ function releaseAcpSdkConsoleGuard() {
73
+ activeAcpSdkDiagnosticGuards = Math.max(0, activeAcpSdkDiagnosticGuards - 1);
74
+ if (activeAcpSdkDiagnosticGuards !== 0 || !acpSdkConsoleGuard) return;
75
+ const guard = acpSdkConsoleGuard;
76
+ acpSdkConsoleGuard = null;
77
+ if (console.error === guard.guardedError) console.error = guard.originalError;
78
+ if (console.warn === guard.guardedWarn) console.warn = guard.originalWarn;
79
+ }
80
+
81
+ /**
82
+ * Open one SDK connection inside an async context that strips payload-bearing
83
+ * SDK console arguments. The SDK starts its detached receive loop during
84
+ * `connect`, so descendants retain this scope without muting concurrent host
85
+ * work or other ACP connections.
86
+ *
87
+ * @template {{closed: Promise<unknown>}} T
88
+ * @param {() => T} connect
89
+ * @returns {T}
90
+ */
91
+ export function connectWithSafeAcpSdkDiagnostics(connect) {
92
+ installAcpSdkConsoleGuard();
93
+ let connection;
94
+ try {
95
+ connection = acpSdkDiagnosticScope.run(true, connect);
96
+ } catch (error) {
97
+ releaseAcpSdkConsoleGuard();
98
+ throw error;
99
+ }
100
+ void Promise.resolve(connection.closed).then(
101
+ releaseAcpSdkConsoleGuard,
102
+ releaseAcpSdkConsoleGuard,
103
+ );
104
+ return connection;
105
+ }
106
+
10
107
  export class AcpTransportError extends Error {
11
108
  /**
12
109
  * @param {string} code
@@ -11,6 +11,7 @@ import {
11
11
  decodeAcpProviderSessionId,
12
12
  encodeAcpProviderSessionId,
13
13
  validateAcpProviderSessionId,
14
+ validateAcpSessionTokenKey,
14
15
  } from "./acp-session-tokens.js";
15
16
  import {
16
17
  ownAcpSessionUpdateKind,
@@ -288,8 +289,8 @@ function selectValues(options) {
288
289
  return values;
289
290
  }
290
291
 
291
- /** @param {any} connection @param {any} descriptor @param {any} req @param {string} profileId @param {(notification:any)=>void} onUpdate */
292
- async function openSession(connection, descriptor, req, profileId, onUpdate) {
292
+ /** @param {any} connection @param {any} descriptor @param {any} req @param {string} profileId @param {Uint8Array} key @param {(notification:any)=>void} onUpdate */
293
+ async function openSession(connection, descriptor, req, profileId, key, onUpdate) {
293
294
  const cwd = workspaceFor(descriptor, req);
294
295
  const config = sessionConfig(descriptor);
295
296
  const baseRequest = { cwd, ...config };
@@ -297,7 +298,7 @@ async function openSession(connection, descriptor, req, profileId, onUpdate) {
297
298
  const response = await connection.newSession(baseRequest);
298
299
  return { sessionId: response.sessionId, response, resumed: false, resumeMethod: null };
299
300
  }
300
- const decoded = decodeAcpProviderSessionId(req.providerSessionId);
301
+ const decoded = decodeAcpProviderSessionId(req.providerSessionId, key);
301
302
  if (decoded.profileId !== profileId) {
302
303
  throw new AcpClientError("invalid_session_id", "ACP provider session belongs to a different profile.");
303
304
  }
@@ -369,8 +370,13 @@ export async function generateAcpResponse(systemPrompt, req) {
369
370
  let setup = null;
370
371
  let configuration = { modeApplied: false, configOptionsApplied: [] };
371
372
  try {
373
+ const sessionTokenKey = validateAcpSessionTokenKey(req?.acpSessionTokenKey);
372
374
  if (req?.providerSessionId != null) {
373
- providerSessionId = validateAcpProviderSessionId(req.providerSessionId, profileId);
375
+ providerSessionId = validateAcpProviderSessionId(
376
+ req.providerSessionId,
377
+ profileId,
378
+ sessionTokenKey,
379
+ );
374
380
  }
375
381
  connection = await connectAcpProfile(profileId, {
376
382
  resolveAcpProfile: req.resolveAcpProfile,
@@ -382,6 +388,7 @@ export async function generateAcpResponse(systemPrompt, req) {
382
388
  signal: req.abortSignal,
383
389
  context: { operation: "run", model: reference },
384
390
  operation: "run",
391
+ acpSessionTokenKey: sessionTokenKey,
385
392
  });
386
393
  capture({
387
394
  type: "capabilities_resolved",
@@ -395,9 +402,18 @@ export async function generateAcpResponse(systemPrompt, req) {
395
402
  const onUpdate = (notification) => {
396
403
  for (const event of normalizeUpdate(notification, state)) capture(event);
397
404
  };
398
- setup = await openSession(connection, connection.descriptor, req, profileId, onUpdate);
405
+ setup = await openSession(
406
+ connection,
407
+ connection.descriptor,
408
+ req,
409
+ profileId,
410
+ sessionTokenKey,
411
+ onUpdate,
412
+ );
399
413
  sessionId = setup.sessionId;
400
- providerSessionId = encodeAcpProviderSessionId(profileId, sessionId);
414
+ providerSessionId = setup.resumed && providerSessionId
415
+ ? providerSessionId
416
+ : encodeAcpProviderSessionId(profileId, sessionId, sessionTokenKey);
401
417
  configuration = await applyClientConfiguration(
402
418
  connection,
403
419
  sessionId,
@@ -25,6 +25,7 @@ import {
25
25
  claudeSandboxPolicyProblem,
26
26
  } from "./claude-sandbox.js";
27
27
  import { resolveSandboxPolicy } from "../../agent/tools/shared/tool-context.js";
28
+ import { createClaudeSubagentActivityNormalizer } from "./claude-subagent-activity.js";
28
29
 
29
30
  const CODEX_CLI_SANDBOX_POLICY_UNSUPPORTED =
30
31
  "Direct Codex CLI cannot enforce mono-agent's native srt sandbox scopes. Remove the mono-agent sandbox policy or use a Pi runtime for exact readableRoots, writableRoots, denyWrite, and network rules.";
@@ -449,6 +450,7 @@ export function buildCliCommand({
449
450
  "-p",
450
451
  "--output-format", "stream-json",
451
452
  "--include-partial-messages",
453
+ "--forward-subagent-text",
452
454
  "--verbose",
453
455
  ...(outputSchema ? ["--json-schema", JSON.stringify(outputSchema)] : []),
454
456
  "--model", modelWithContextWindow(model, contextWindow),
@@ -615,8 +617,29 @@ export async function generateCliResponse(systemPrompt, options = {}) {
615
617
  fileChangeSnapshots: new Map(),
616
618
  thinkingBuffer: createThinkingBuffer(),
617
619
  };
620
+ const subagentNormalizer = resolved.sdk === "claude-code"
621
+ ? createClaudeSubagentActivityNormalizer()
622
+ : null;
623
+ function emitSubagentEvents(activityEvents) {
624
+ for (const activity of activityEvents) {
625
+ events.push(activity);
626
+ options.onEvent?.(activity);
627
+ }
628
+ }
629
+ function drainSubagents(reason) {
630
+ if (!subagentNormalizer) return;
631
+ emitSubagentEvents(subagentNormalizer.drain(reason));
632
+ }
633
+ function observedSubagentCapabilities() {
634
+ if (!subagentNormalizer) return { invoked: null, names: [] };
635
+ return {
636
+ invoked: subagentNormalizer.subagentInvoked(),
637
+ names: subagentNormalizer.nativeSubagentsUsed(),
638
+ };
639
+ }
618
640
 
619
641
  const stderrTail = createStderrTail({ limit: 8 * 1024 });
642
+ let abortHandler = null;
620
643
  try {
621
644
  const child = spawn(commandSpec.command, commandSpec.args, {
622
645
  cwd: commandSpec.cwd,
@@ -638,43 +661,67 @@ export async function generateCliResponse(systemPrompt, options = {}) {
638
661
  options.onEvent?.(ev);
639
662
  return;
640
663
  }
641
- const ev = normalizeCliEvent(raw, cliEventContext);
664
+ // Capture the provider session before a child event is consumed. Claude
665
+ // uses the same session id on parent and child records.
666
+ const candidateSessionId = raw.session_id ?? raw.sessionId ?? raw.thread_id ?? null;
667
+ if (typeof candidateSessionId === "string" && candidateSessionId.trim().length > 0) {
668
+ providerSessionId = candidateSessionId.trim();
669
+ }
670
+ const observation = subagentNormalizer?.observe(raw);
671
+ if (observation) emitSubagentEvents(observation.events);
672
+ // Child messages must not be normalized, added to parent text, counted as
673
+ // parent usage, or considered for the parent's StructuredOutput result.
674
+ if (observation?.consumed) return;
675
+ // A root user message may batch a background Agent launch acknowledgement
676
+ // with unrelated tool results. The normalizer removes only that launch
677
+ // block so the remaining parent activity still flows normally.
678
+ const parentRaw = observation?.forwarded ?? raw;
679
+ const ev = normalizeCliEvent(parentRaw, cliEventContext);
642
680
  if (ev) {
643
681
  events.push(ev);
644
682
  options.onEvent?.(ev);
645
683
  }
646
- if (!isCodexReasoningEvent(raw)) {
647
- const text = textFromEvent(raw);
684
+ if (!isCodexReasoningEvent(parentRaw)) {
685
+ const text = textFromEvent(parentRaw);
648
686
  pushUniqueText(texts, text);
649
687
  }
650
- captureStructuredOutputFromRaw(raw);
651
- if (raw.usage) usage = raw.usage;
652
- // intelligence-ramp Phase 5.1: capture session_id from CLI events so the
653
- // coordinator can chain it on the next continuation. Claude Code emits
654
- // session_id on the init system message and again on the result event.
655
- const candidateSessionId = raw.session_id ?? raw.sessionId ?? raw.thread_id ?? null;
656
- if (typeof candidateSessionId === "string" && candidateSessionId.trim().length > 0) {
657
- providerSessionId = candidateSessionId.trim();
658
- }
659
- if (raw.type === "error") {
660
- const rawError = raw.message || raw.error || "cli error";
688
+ captureStructuredOutputFromRaw(parentRaw);
689
+ if (parentRaw.usage) usage = parentRaw.usage;
690
+ if (parentRaw.type === "error") {
691
+ const rawError = parentRaw.message || parentRaw.error || "cli error";
661
692
  errorMessage = typeof rawError === "string" ? rawError : JSON.stringify(rawError);
662
693
  failureKind = "provider_unavailable";
694
+ drainSubagents("subagent stopped because the Claude CLI stream failed");
663
695
  }
664
- const resultError = resultEventError(raw, commandSpec.command);
696
+ const resultError = resultEventError(parentRaw, commandSpec.command);
665
697
  if (resultError) {
666
698
  errorMessage = resultError.message;
667
699
  failureKind = resultError.failureKind;
700
+ drainSubagents("subagent stopped because the Claude CLI stream failed");
668
701
  }
669
702
  });
670
703
 
704
+ child.on("error", (error) => {
705
+ if (!errorMessage) errorMessage = error?.message || String(error);
706
+ failureKind ||= "provider_unavailable";
707
+ drainSubagents("subagent stopped because the Claude CLI process failed");
708
+ });
709
+
671
710
  if (options.abortSignal) {
672
- const abort = () => child.kill("SIGTERM");
673
- if (options.abortSignal.aborted) abort();
674
- else options.abortSignal.addEventListener("abort", abort, { once: true });
711
+ abortHandler = () => {
712
+ drainSubagents("subagent cancelled with the parent run");
713
+ child.kill("SIGTERM");
714
+ };
715
+ if (options.abortSignal.aborted) abortHandler();
716
+ else options.abortSignal.addEventListener("abort", abortHandler, { once: true });
675
717
  }
676
718
 
677
719
  const exitCode = await new Promise((resolve) => child.on("close", resolve));
720
+ drainSubagents(options.abortSignal?.aborted
721
+ ? "subagent cancelled with the parent run"
722
+ : exitCode === 0
723
+ ? "subagent stream closed before completion"
724
+ : "subagent stopped because the Claude CLI process failed");
678
725
  const stderrText = stderrTail.toString().trim();
679
726
  let cliErrorCode = null;
680
727
  if (exitCode !== 0 && !errorMessage) errorMessage = stderrText || `${commandSpec.command} exited ${exitCode}`;
@@ -706,6 +753,7 @@ export async function generateCliResponse(systemPrompt, options = {}) {
706
753
  cache_creation_tokens: cacheCreationTokens || null,
707
754
  cost_usd: costUsd,
708
755
  };
756
+ const subagentCapabilities = observedSubagentCapabilities();
709
757
  return {
710
758
  text,
711
759
  structuredResult,
@@ -733,14 +781,18 @@ export async function generateCliResponse(systemPrompt, options = {}) {
733
781
  promptCacheActive: (cachedTokens || 0) > 0 || (cacheCreationTokens || 0) > 0,
734
782
  thinkingEnabled: null,
735
783
  structuredOutputEnforced: !!options.outputSchema,
736
- subagentInvoked: null,
784
+ subagentInvoked: subagentCapabilities.invoked,
737
785
  mcpServersUsed: Object.keys(options.mcpServers || {}),
738
- nativeSubagentsUsed: [],
786
+ nativeSubagentsUsed: subagentCapabilities.names,
739
787
  toolCompactionApplied: false,
740
788
  contextCompactionApplied: null,
741
789
  }),
742
790
  };
743
791
  } catch (err) {
792
+ drainSubagents(options.abortSignal?.aborted
793
+ ? "subagent cancelled with the parent run"
794
+ : "subagent stopped because the Claude CLI process failed");
795
+ const subagentCapabilities = observedSubagentCapabilities();
744
796
  return {
745
797
  text: texts[texts.length - 1] || null,
746
798
  structuredResult,
@@ -766,14 +818,17 @@ export async function generateCliResponse(systemPrompt, options = {}) {
766
818
  promptCacheActive: null,
767
819
  thinkingEnabled: null,
768
820
  structuredOutputEnforced: !!options.outputSchema,
769
- subagentInvoked: null,
821
+ subagentInvoked: subagentCapabilities.invoked,
770
822
  mcpServersUsed: Object.keys(options.mcpServers || {}),
771
- nativeSubagentsUsed: [],
823
+ nativeSubagentsUsed: subagentCapabilities.names,
772
824
  toolCompactionApplied: false,
773
825
  contextCompactionApplied: null,
774
826
  }),
775
827
  };
776
828
  } finally {
829
+ if (abortHandler && options.abortSignal) {
830
+ options.abortSignal.removeEventListener?.("abort", abortHandler);
831
+ }
777
832
  try { rmSync(dir, { recursive: true, force: true }); } catch {}
778
833
  }
779
834
  }