@gajae-code/coding-agent 0.12.21 → 0.13.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 (222) hide show
  1. package/CHANGELOG.md +67 -2
  2. package/dist/types/cli/args.d.ts +5 -2
  3. package/dist/types/cli/root-flags.d.ts +246 -0
  4. package/dist/types/cli.d.ts +11 -19
  5. package/dist/types/commands/daemon.d.ts +0 -30
  6. package/dist/types/commands/launch.d.ts +11 -19
  7. package/dist/types/commands/quick-lane.d.ts +32 -0
  8. package/dist/types/commands/sdk.d.ts +21 -15
  9. package/dist/types/config/file-lock-gc.d.ts +2 -0
  10. package/dist/types/config/model-equivalence.d.ts +20 -0
  11. package/dist/types/config/model-profile-activation.d.ts +62 -17
  12. package/dist/types/config/model-profiles.d.ts +10 -0
  13. package/dist/types/config/model-registry.d.ts +68 -1
  14. package/dist/types/config/model-resolver.d.ts +15 -4
  15. package/dist/types/config/models-config-schema.d.ts +7 -2
  16. package/dist/types/config/provider-selection-policy.d.ts +62 -0
  17. package/dist/types/config/settings-schema.d.ts +36 -78
  18. package/dist/types/config/settings.d.ts +2 -0
  19. package/dist/types/edit/renderer.d.ts +5 -2
  20. package/dist/types/edit/streaming.d.ts +13 -0
  21. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  22. package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
  23. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
  25. package/dist/types/internal-urls/local-protocol.d.ts +1 -0
  26. package/dist/types/modes/components/model-selector.d.ts +0 -2
  27. package/dist/types/modes/components/provider-order-context.d.ts +45 -0
  28. package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
  29. package/dist/types/modes/components/settings-defs.d.ts +5 -1
  30. package/dist/types/modes/components/settings-selector.d.ts +2 -0
  31. package/dist/types/quick-lane/classify.d.ts +40 -0
  32. package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
  33. package/dist/types/sdk/broker/broker.d.ts +56 -2
  34. package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
  35. package/dist/types/sdk/broker/session-index.d.ts +83 -2
  36. package/dist/types/sdk/broker/state-version.d.ts +1 -1
  37. package/dist/types/sdk/bus/notification-service.d.ts +6 -0
  38. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
  39. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
  40. package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
  41. package/dist/types/sdk/cli/rows.d.ts +98 -0
  42. package/dist/types/sdk/cli/session-cli.d.ts +35 -3
  43. package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
  44. package/dist/types/sdk/elevation/audit.d.ts +93 -0
  45. package/dist/types/sdk/elevation/capability.d.ts +3 -0
  46. package/dist/types/sdk/elevation/digest.d.ts +72 -0
  47. package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
  48. package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
  49. package/dist/types/sdk/elevation/owner.d.ts +48 -0
  50. package/dist/types/sdk/guides/cache.d.ts +98 -0
  51. package/dist/types/sdk/guides/catalog.d.ts +156 -0
  52. package/dist/types/sdk/guides/cli.d.ts +39 -0
  53. package/dist/types/sdk/guides/manifest.d.ts +66 -0
  54. package/dist/types/sdk/guides/verify.d.ts +66 -0
  55. package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
  56. package/dist/types/sdk/host/control/index.d.ts +1 -1
  57. package/dist/types/sdk/host/control/operations.d.ts +3 -2
  58. package/dist/types/sdk/host/query/cursor.d.ts +10 -0
  59. package/dist/types/sdk/host/query/handlers.d.ts +21 -0
  60. package/dist/types/sdk/host/session-runtime.d.ts +9 -0
  61. package/dist/types/sdk/mcp/server.d.ts +2 -2
  62. package/dist/types/sdk/prompt-status.d.ts +11 -0
  63. package/dist/types/sdk/session.d.ts +4 -0
  64. package/dist/types/sdk/transport/index.d.ts +1 -0
  65. package/dist/types/sdk/transport/relay.d.ts +1 -0
  66. package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
  67. package/dist/types/session/agent-session.d.ts +32 -18
  68. package/dist/types/session/blob-store.d.ts +13 -7
  69. package/dist/types/session/fallback-chain-controller.d.ts +15 -0
  70. package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
  71. package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
  72. package/dist/types/session/session-manager.d.ts +139 -10
  73. package/dist/types/session/session-storage.d.ts +48 -0
  74. package/dist/types/setup/provider-onboarding.d.ts +7 -1
  75. package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
  76. package/dist/types/stt/setup.d.ts +2 -0
  77. package/dist/types/task/executor.d.ts +10 -2
  78. package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
  79. package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
  80. package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
  81. package/dist/types/tools/browser/tab-worker.d.ts +2 -0
  82. package/dist/types/tools/browser.d.ts +2 -0
  83. package/dist/types/tools/image-gen.d.ts +14 -18
  84. package/dist/types/tools/index.d.ts +2 -0
  85. package/dist/types/tools/resource-gc.d.ts +2 -0
  86. package/dist/types/utils/pasted-image-path.d.ts +6 -0
  87. package/package.json +9 -9
  88. package/scripts/generate-sdk-operation-inventory.ts +8 -0
  89. package/scripts/run-test-manifest.ts +61 -21
  90. package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
  91. package/src/cli/args.ts +62 -9
  92. package/src/cli/completion-cli.ts +1 -0
  93. package/src/cli/fast-help.ts +1 -0
  94. package/src/cli/root-flags.ts +100 -0
  95. package/src/cli/setup-cli.ts +6 -2
  96. package/src/cli.ts +42 -65
  97. package/src/commands/acp.ts +1 -1
  98. package/src/commands/daemon.ts +2 -31
  99. package/src/commands/gc.ts +1 -1
  100. package/src/commands/launch.ts +8 -129
  101. package/src/commands/quick-lane.ts +51 -0
  102. package/src/commands/sdk.ts +200 -26
  103. package/src/commands/setup.ts +4 -2
  104. package/src/config/file-lock-gc.ts +4 -1
  105. package/src/config/model-equivalence.ts +76 -7
  106. package/src/config/model-profile-activation.ts +1115 -265
  107. package/src/config/model-profiles.ts +134 -2
  108. package/src/config/model-registry.ts +352 -50
  109. package/src/config/model-resolver.ts +289 -38
  110. package/src/config/models-config-schema.ts +11 -4
  111. package/src/config/provider-selection-policy.ts +146 -0
  112. package/src/config/settings-schema.ts +50 -77
  113. package/src/config/settings.ts +40 -0
  114. package/src/coordinator-mcp/server.ts +32 -2
  115. package/src/edit/renderer.ts +175 -223
  116. package/src/edit/streaming.ts +108 -1
  117. package/src/export/html/index.ts +57 -7
  118. package/src/extensibility/custom-tools/types.ts +2 -0
  119. package/src/extensibility/extensions/compact-handler.ts +3 -2
  120. package/src/extensibility/extensions/runner.ts +6 -0
  121. package/src/extensibility/extensions/types.ts +3 -0
  122. package/src/gjc-runtime/gc-runtime.ts +379 -20
  123. package/src/gjc-runtime/launch-worktree.ts +5 -1
  124. package/src/gjc-runtime/team-runtime.ts +59 -2
  125. package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
  126. package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
  127. package/src/internal-urls/docs-index.generated.ts +17 -13
  128. package/src/internal-urls/local-protocol.ts +1 -0
  129. package/src/main.ts +40 -3
  130. package/src/memories/index.ts +4 -2
  131. package/src/modes/acp/acp-agent.ts +235 -91
  132. package/src/modes/components/model-selector.ts +206 -42
  133. package/src/modes/components/provider-order-context.ts +197 -0
  134. package/src/modes/components/provider-order-editor.ts +441 -0
  135. package/src/modes/components/settings-defs.ts +21 -1
  136. package/src/modes/components/settings-selector.ts +45 -2
  137. package/src/modes/components/tool-execution.ts +47 -15
  138. package/src/modes/controllers/command-controller.ts +6 -3
  139. package/src/modes/controllers/extension-ui-controller.ts +2 -0
  140. package/src/modes/controllers/input-controller.ts +73 -2
  141. package/src/modes/controllers/selector-controller.ts +260 -153
  142. package/src/modes/prompt-suggestion-controller.ts +1 -1
  143. package/src/modes/runtime-init.ts +1 -0
  144. package/src/modes/utils/ui-helpers.ts +18 -0
  145. package/src/prompts/tools/browser.md +1 -0
  146. package/src/quick-lane/classify.ts +257 -0
  147. package/src/rlm/index.ts +1 -1
  148. package/src/runtime/stt-settings-setup.ts +57 -0
  149. package/src/sdk/acp/adapter.ts +2 -1
  150. package/src/sdk/broker/broker.ts +780 -12
  151. package/src/sdk/broker/lifecycle.ts +70 -29
  152. package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
  153. package/src/sdk/broker/session-index.ts +463 -48
  154. package/src/sdk/broker/state-version.ts +13 -9
  155. package/src/sdk/broker/transport.ts +14 -1
  156. package/src/sdk/bus/index.ts +34 -13
  157. package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
  158. package/src/sdk/bus/notification-service.ts +47 -1
  159. package/src/sdk/bus/prompt-reconciliation.ts +22 -3
  160. package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
  161. package/src/sdk/bus/telegram-daemon.ts +54 -3
  162. package/src/sdk/cli/rows.ts +247 -0
  163. package/src/sdk/cli/session-cli.ts +930 -79
  164. package/src/sdk/elevation/allowlist.ts +54 -0
  165. package/src/sdk/elevation/audit.ts +208 -0
  166. package/src/sdk/elevation/capability.ts +41 -0
  167. package/src/sdk/elevation/digest.ts +129 -0
  168. package/src/sdk/elevation/dispatch-receipt.ts +184 -0
  169. package/src/sdk/elevation/grant-ledger.ts +1142 -0
  170. package/src/sdk/elevation/owner.ts +109 -0
  171. package/src/sdk/guides/cache.ts +363 -0
  172. package/src/sdk/guides/catalog.ts +554 -0
  173. package/src/sdk/guides/cli.ts +235 -0
  174. package/src/sdk/guides/manifest.ts +152 -0
  175. package/src/sdk/guides/verify.ts +178 -0
  176. package/src/sdk/host/control/dispatch.ts +49 -1
  177. package/src/sdk/host/control/index.ts +1 -0
  178. package/src/sdk/host/control/operations.ts +8 -1
  179. package/src/sdk/host/query/cursor.ts +47 -3
  180. package/src/sdk/host/query/handlers.ts +155 -2
  181. package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
  182. package/src/sdk/host/session-runtime.test.ts +391 -1
  183. package/src/sdk/host/session-runtime.ts +308 -73
  184. package/src/sdk/host/surface-policy.ts +3 -0
  185. package/src/sdk/host/websocket-transport.ts +7 -1
  186. package/src/sdk/mcp/server.ts +88 -15
  187. package/src/sdk/prompt-status.ts +8 -0
  188. package/src/sdk/protocol/operation-inventory.generated.json +62 -0
  189. package/src/sdk/protocol/operation-registry.ts +3 -2
  190. package/src/sdk/session.ts +87 -39
  191. package/src/sdk/transport/index.ts +1 -0
  192. package/src/sdk/transport/relay.ts +3 -0
  193. package/src/sdk/transport/serve-cli.ts +111 -24
  194. package/src/secrets/obfuscator.ts +92 -12
  195. package/src/session/agent-session.ts +322 -93
  196. package/src/session/blob-store.ts +60 -8
  197. package/src/session/fallback-chain-controller.ts +35 -0
  198. package/src/session/internal/managed-session-scope.ts +25 -3
  199. package/src/session/internal/managed-session-storage.ts +574 -73
  200. package/src/session/internal/session-memory-sidecar.ts +19 -29
  201. package/src/session/session-manager.ts +2166 -743
  202. package/src/session/session-storage.ts +715 -48
  203. package/src/setup/provider-onboarding.ts +19 -3
  204. package/src/setup/provider-presets.json +3 -1
  205. package/src/slash-commands/builtin-registry.ts +85 -3
  206. package/src/stt/downloader.ts +2 -3
  207. package/src/stt/setup.ts +29 -4
  208. package/src/task/executor.ts +22 -18
  209. package/src/task/index.ts +24 -1
  210. package/src/tools/archive-reader.ts +11 -2
  211. package/src/tools/browser/runtime-diagnostics.ts +249 -0
  212. package/src/tools/browser/tab-protocol.ts +4 -0
  213. package/src/tools/browser/tab-supervisor.ts +5 -0
  214. package/src/tools/browser/tab-worker.ts +36 -1
  215. package/src/tools/browser.ts +7 -0
  216. package/src/tools/image-gen.ts +110 -166
  217. package/src/tools/index.ts +2 -0
  218. package/src/tools/read.ts +1 -1
  219. package/src/tools/resource-gc.ts +6 -1
  220. package/src/tools/tool-catalog.generated.ts +5 -1
  221. package/src/utils/pasted-image-path.ts +93 -0
  222. package/src/web/search/index.ts +3 -3
@@ -3,12 +3,7 @@ import { getAgentDir } from "@gajae-code/utils";
3
3
  import { ensureBroker } from "../broker/ensure";
4
4
  import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
5
5
  import { SdkClient, SdkClientError } from "../client/client";
6
- import {
7
- listSdkSessionEndpoints,
8
- readSdkBrokerDiscovery,
9
- readSdkSessionEndpoint,
10
- SdkDiscoveryError,
11
- } from "../client/discovery";
6
+ import { readSdkBrokerDiscovery, SdkDiscoveryError } from "../client/discovery";
12
7
  import { validateAdapterControl, validateAdapterSecretFields } from "../protocol/adapter-validation";
13
8
  import { adapterDispositionError, findOperation } from "../protocol/operation-registry";
14
9
 
@@ -30,6 +25,7 @@ export interface SdkMcpServerOptions {
30
25
  agentDir?: string;
31
26
  connect?: (url: string, token: string) => Promise<SdkClient>;
32
27
  }
28
+ const MAX_SESSION_LIST_PAGES = 10_000;
33
29
 
34
30
  export const SDK_MCP_TOOL_NAMES = [
35
31
  "gjc_session_control",
@@ -169,34 +165,111 @@ function textResult(
169
165
  return { content: [{ type: "text", text: JSON.stringify(payload) }], isError };
170
166
  }
171
167
 
172
- /** Creates the model-facing MCP adapter using only SDK discovery records and v3 WebSockets. */
168
+ /** Creates the model-facing MCP adapter; session targeting is broker-bound (C10). */
173
169
  export function createSdkMcpServer(options: SdkMcpServerOptions = {}) {
174
- const repo = options.repo ?? process.cwd();
175
170
  const agentDir = options.agentDir ?? getAgentDir();
176
171
  const connect = options.connect ?? ((url, token) => SdkClient.connect(url, token));
177
172
 
173
+ /**
174
+ * Connects to the agent broker, starting one when absent (mirrors the
175
+ * lifecycle bootstrap path). The broker is the sole session authority; the
176
+ * MCP server never reads endpoint discovery files itself.
177
+ */
178
+ async function brokerClient(): Promise<SdkClient> {
179
+ await ensureBroker({ agentDir });
180
+ const broker = await readSdkBrokerDiscovery(agentDir);
181
+ if (!broker) throw new SdkClientError("not_found", "SDK broker not found");
182
+ return await connect(broker.url, broker.token);
183
+ }
184
+
185
+ /** Extracts a broker `result` envelope, converting an explicit error frame into a typed throw. */
186
+ function brokerResult(value: unknown): Record<string, unknown> {
187
+ if (isObject(value) && value.ok === false) {
188
+ const error = isObject(value.error) ? value.error : {};
189
+ const code = typeof error.code === "string" ? error.code : "unavailable";
190
+ const message = typeof error.message === "string" ? error.message : "SDK broker request failed";
191
+ throw new SdkClientError(code, message, value.error);
192
+ }
193
+ return isObject(value) && isObject(value.result) ? value.result : {};
194
+ }
195
+
196
+ function arrayOf(value: unknown): unknown[] {
197
+ return Array.isArray(value) ? value : [];
198
+ }
199
+ /**
200
+ * Exhausts broker `session.list` cursor pagination (default page limit 100)
201
+ * to one full snapshot. The broker remains the sole session authority; this
202
+ * only aggregates its indexed truth across pages.
203
+ */
204
+ async function paginatedBrokerSessionList(
205
+ client: SdkClient,
206
+ ): Promise<{ sessions: Record<string, unknown>[]; warnings: unknown[] }> {
207
+ const sessions: Record<string, unknown>[] = [];
208
+ let warnings: unknown[] = [];
209
+ let cursor: string | undefined;
210
+ for (let pageCount = 0; pageCount < MAX_SESSION_LIST_PAGES; pageCount++) {
211
+ const listed = brokerResult(await client.global("session.list", cursor === undefined ? {} : { cursor }));
212
+ sessions.push(...arrayOf(listed.sessions).filter(isObject));
213
+ if (Array.isArray(listed.warnings)) warnings = listed.warnings;
214
+ const nextCursor =
215
+ typeof listed.continuationCursor === "string" && listed.continuationCursor.length > 0
216
+ ? listed.continuationCursor
217
+ : undefined;
218
+ if (!nextCursor) return { sessions, warnings };
219
+ cursor = nextCursor;
220
+ }
221
+ throw new SdkClientError("protocol_error", "session.list exceeded the page budget.");
222
+ }
223
+
224
+ /**
225
+ * Resolves one session through broker `session.list` + `session.get_endpoint`
226
+ * (C10): the broker's liveness and ambiguity truth decides reachability, and
227
+ * only the broker mints the endpoint credential.
228
+ */
178
229
  async function withSession(sessionId: string, action: (client: SdkClient) => Promise<unknown>): Promise<unknown> {
230
+ let broker: SdkClient | undefined;
179
231
  let client: SdkClient | undefined;
180
232
  try {
181
- const endpoint = await readSdkSessionEndpoint(repo, sessionId);
182
- if (!endpoint)
183
- return { ok: false, error: { code: "not_found", message: `SDK session not found: ${sessionId}` } };
184
- client = await connect(endpoint.url, endpoint.token);
233
+ broker = await brokerClient();
234
+ const { sessions } = await paginatedBrokerSessionList(broker);
235
+ const row = sessions.find(item => item.sessionId === sessionId) as Record<string, unknown> | undefined;
236
+ if (!row) return { ok: false, error: { code: "not_found", message: `SDK session not found: ${sessionId}` } };
237
+ if (row.ambiguous === true)
238
+ return {
239
+ ok: false,
240
+ error: { code: "ambiguous_session", message: "session id maps to more than one state root" },
241
+ };
242
+ if (row.live !== true)
243
+ return { ok: false, error: { code: "endpoint_stale", message: "session endpoint is not live" } };
244
+ const endpoint = brokerResult(await broker.global("session.get_endpoint", { sessionId }));
245
+ const url = typeof endpoint.url === "string" && endpoint.url ? endpoint.url : undefined;
246
+ const token = typeof endpoint.token === "string" ? endpoint.token : "";
247
+ if (!url)
248
+ return { ok: false, error: { code: "unavailable", message: "SDK broker returned an invalid endpoint" } };
249
+ client = await connect(url, token);
185
250
  return await action(client);
186
251
  } catch (error) {
187
252
  return resultError(error);
188
253
  } finally {
189
254
  await client?.close();
255
+ await broker?.close();
190
256
  }
191
257
  }
192
258
 
193
259
  async function callTool(name: string, args: Arguments = {}): Promise<unknown> {
194
260
  if (name === "gjc_session_list") {
261
+ let broker: SdkClient | undefined;
195
262
  try {
196
- const { endpoints, warnings } = await listSdkSessionEndpoints(repo);
197
- return { ok: true, sessions: endpoints.map(({ sessionId }) => ({ sessionId })), warnings };
263
+ broker = await brokerClient();
264
+ const { sessions, warnings } = await paginatedBrokerSessionList(broker);
265
+ const listed = sessions.flatMap(item =>
266
+ typeof item.sessionId === "string" && item.sessionId ? [{ sessionId: item.sessionId }] : [],
267
+ );
268
+ return { ok: true, sessions: listed, warnings };
198
269
  } catch (error) {
199
270
  return resultError(error);
271
+ } finally {
272
+ await broker?.close();
200
273
  }
201
274
  }
202
275
  if (name === "gjc_session_control") {
@@ -301,7 +374,7 @@ export function createSdkMcpServer(options: SdkMcpServerOptions = {}) {
301
374
  /**
302
375
  * Runs the SDK MCP server over stdio (newline-delimited JSON-RPC), the shipped
303
376
  * `gjc mcp-serve sdk` entrypoint. Pure SDK client: session control/query flows
304
- * through discovery records and v3 WebSockets only.
377
+ * through the broker and v3 WebSockets only.
305
378
  */
306
379
  export async function runSdkMcpStdio(options: SdkMcpServerOptions = {}): Promise<void> {
307
380
  const server = createSdkMcpServer(options);
@@ -24,6 +24,12 @@
24
24
  * - Q26 tracks prompts accepted through the SDK control surface (which always
25
25
  * carries a requesting connection); submissions without a delivery owner are
26
26
  * outside the reconciliation contract and hold no reservation.
27
+ * - A terminal outcome settles once: the first terminal transition wins. A late
28
+ * `agent_failed` (arriving on a different delivery path than the one that
29
+ * claimed the terminal) may still attach its bounded, sanitized reason to an
30
+ * already-terminal record that has none, surfaced as `error` on `terminal_ok`.
31
+ * It never changes status, `terminalAt`, retention order, or the clientRef
32
+ * index, and the first recorded reason always wins over a later frame.
27
33
  */
28
34
 
29
35
  export const PROMPT_CLIENT_REF_MAX_LENGTH = 128;
@@ -79,6 +85,8 @@ export interface TurnPromptReconciliationTerminalOk extends TurnPromptReconcilia
79
85
  /** Epoch milliseconds of the terminal transition. */
80
86
  terminalAt: number;
81
87
  outcome?: SdkPromptTerminalOutcome;
88
+ /** Present when a late `agent_failed` supplied the only failure reason. */
89
+ error?: { code: string; message: string };
82
90
  }
83
91
 
84
92
  export interface TurnPromptReconciliationFailed extends TurnPromptReconciliationIdentity {
@@ -1601,6 +1601,24 @@
1601
1601
  "packages/coding-agent/test/sdk-operation-inventory.test.ts"
1602
1602
  ]
1603
1603
  },
1604
+ {
1605
+ "sourceId": "registry:Q30",
1606
+ "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts",
1607
+ "sourceKind": "registry",
1608
+ "decision": "include",
1609
+ "sdkId": "session.checkpoint",
1610
+ "adapterMappings": {
1611
+ "telegram": "prohibited",
1612
+ "discord": "prohibited",
1613
+ "slack": "prohibited",
1614
+ "mcp": "generic_safe",
1615
+ "acp": "generic_safe",
1616
+ "daemonCli": "generic_safe"
1617
+ },
1618
+ "testIds": [
1619
+ "packages/coding-agent/test/sdk-operation-inventory.test.ts"
1620
+ ]
1621
+ },
1604
1622
  {
1605
1623
  "sourceId": "registry:R01",
1606
1624
  "sourceFile": "packages/coding-agent/src/sdk/protocol/operation-registry.ts",
@@ -3947,6 +3965,17 @@
3947
3965
  "testIds": "not_applicable"
3948
3966
  }
3949
3967
  },
3968
+ {
3969
+ "sourceId": "agent_session:getConfiguredModelChainState",
3970
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
3971
+ "sourceKind": "agent_session",
3972
+ "decision": "exclude",
3973
+ "rationale": "internal model-profile transaction snapshot, not a user-facing SDK control seam",
3974
+ "exclusionMetadata": {
3975
+ "adapterMappings": "not_applicable",
3976
+ "testIds": "not_applicable"
3977
+ }
3978
+ },
3950
3979
  {
3951
3980
  "sourceId": "agent_session:setConfiguredModelChain",
3952
3981
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -3980,6 +4009,28 @@
3980
4009
  "testIds": "not_applicable"
3981
4010
  }
3982
4011
  },
4012
+ {
4013
+ "sourceId": "agent_session:getDefaultFallbackRuntimeState",
4014
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
4015
+ "sourceKind": "agent_session",
4016
+ "decision": "exclude",
4017
+ "rationale": "internal model-profile transaction snapshot, not a user-facing SDK control seam",
4018
+ "exclusionMetadata": {
4019
+ "adapterMappings": "not_applicable",
4020
+ "testIds": "not_applicable"
4021
+ }
4022
+ },
4023
+ {
4024
+ "sourceId": "agent_session:restoreDefaultFallbackRuntimeState",
4025
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
4026
+ "sourceKind": "agent_session",
4027
+ "decision": "exclude",
4028
+ "rationale": "internal rollback compensation, not a user-facing SDK control seam",
4029
+ "exclusionMetadata": {
4030
+ "adapterMappings": "not_applicable",
4031
+ "testIds": "not_applicable"
4032
+ }
4033
+ },
3983
4034
  {
3984
4035
  "sourceId": "agent_session:getSessionDefaultModelSelector",
3985
4036
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -4024,6 +4075,17 @@
4024
4075
  "testIds": "not_applicable"
4025
4076
  }
4026
4077
  },
4078
+ {
4079
+ "sourceId": "agent_session:restoreModelSelectionForRollback",
4080
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
4081
+ "sourceKind": "agent_session",
4082
+ "decision": "exclude",
4083
+ "rationale": "internal rollback compensation, not a user-facing SDK control seam",
4084
+ "exclusionMetadata": {
4085
+ "adapterMappings": "not_applicable",
4086
+ "testIds": "not_applicable"
4087
+ }
4088
+ },
4027
4089
  {
4028
4090
  "sourceId": "agent_session:setModelTemporaryForControl",
4029
4091
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -165,6 +165,7 @@ const queries = [
165
165
  "Read the authoritative reconciliation status of a skill.invoke by command/turn IDs or clientRef.",
166
166
  ],
167
167
  ["providers.list/active", "List active providers."],
168
+ ["session.checkpoint", "Resolve a durable transcript checkpoint into a connection-owned replay cursor."],
168
169
  ] as const;
169
170
 
170
171
  const reverse = [
@@ -237,14 +238,14 @@ function revision(id: string): string | undefined {
237
238
  }
238
239
  function queryContinuityClass(id: string): QueryContinuityClass {
239
240
  if (["Q01", "Q02"].includes(id)) return "stable_prefix";
240
- if (["Q04", "Q05", "Q06", "Q07", "Q08", "Q11", "Q12", "Q13", "Q20", "Q21", "Q22", "Q23", "Q27"].includes(id))
241
+ if (["Q04", "Q05", "Q06", "Q07", "Q08", "Q11", "Q12", "Q13", "Q20", "Q21", "Q22", "Q23", "Q27", "Q30"].includes(id))
241
242
  return "retained_revision";
242
243
  if (id === "Q24") return "content_addressed";
243
244
  return "scalar_snapshot";
244
245
  }
245
246
 
246
247
  function queryDisposition(id: string): Record<Adapter, AdapterDisposition> {
247
- if (["Q23", "Q24", "Q25", "Q26", "Q27", "Q28", "Q29"].includes(id))
248
+ if (["Q23", "Q24", "Q25", "Q26", "Q27", "Q28", "Q29", "Q30"].includes(id))
248
249
  return dispositions({ telegram: "prohibited", discord: "prohibited", slack: "prohibited" });
249
250
  return dispositions();
250
251
  }
@@ -41,6 +41,8 @@ import {
41
41
  import { type AsyncJob, AsyncJobManager, isBackgroundJobSupportEnabled, jobElapsedMs } from "../async";
42
42
  import { loadCapability } from "../capability";
43
43
  import { type Rule, ruleCapability, setActiveRules } from "../capability/rule";
44
+ import { resolveModelProfileName } from "../config/model-profile-contract";
45
+ import { resolveProfileBindings } from "../config/model-profiles";
44
46
  import { kNoAuth, ModelRegistry } from "../config/model-registry";
45
47
  import {
46
48
  formatModelString,
@@ -115,6 +117,7 @@ import {
115
117
  import { NotificationSessionController } from "../sdk/bus/session-control";
116
118
  import { shouldHostSdk } from "../sdk/host";
117
119
  import { createSdkSessionRuntimeExtension, registerSdkOnlyNotificationCommand } from "../sdk/host/session-runtime";
120
+ import { createSdkWebSocketTransport } from "../sdk/host/websocket-transport";
118
121
  import type { SecretObfuscator } from "../secrets";
119
122
  import { AgentSession, type ForkContextSeed } from "../session/agent-session";
120
123
  import { resolveAuthBrokerConfig } from "../session/auth-broker-config";
@@ -325,6 +328,8 @@ export interface CreateAgentSessionOptions {
325
328
  /** Raw model pattern string (e.g. from --model CLI flag) to resolve after extensions load.
326
329
  * Used when model lookup is deferred because extension-provided models aren't registered yet. */
327
330
  modelPattern?: string;
331
+ /** Active profile inherited by a nested SDK/subagent session. */
332
+ activeModelProfile?: string;
328
333
  /** Thinking selector. Default: from settings, else unset */
329
334
  thinkingLevel?: ThinkingLevel;
330
335
  /** Runtime substitution metadata for the initial model_change session event. */
@@ -337,6 +342,8 @@ export interface CreateAgentSessionOptions {
337
342
  /** Optional provider-facing session identifier for prompt caches and sticky auth selection.
338
343
  * Keeps persisted session files isolated while reusing provider-side caches. */
339
344
  providerSessionId?: string;
345
+ /** Optional credential-selection session identity, distinct from provider transport/cache identity. */
346
+ credentialSessionId?: string;
340
347
  /** Runtime credential selector for multi-account auth pools. */
341
348
  credentialSelector?: { provider?: string; selector: AuthCredentialSelector; raw: string };
342
349
 
@@ -1287,31 +1294,6 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1287
1294
  }
1288
1295
  applyConfiguredSearchTimeout(settings);
1289
1296
 
1290
- const imageProvider = settings.get("providers.image");
1291
- const imageModel = settings.get("providers.imageModel");
1292
- const imageCustomUrl = settings.get("providers.imageCustomUrl");
1293
- const imageCustomKey = settings.get("providers.imageCustomKey");
1294
- const imageCustomKeyEnv = settings.get("providers.imageCustomKeyEnv");
1295
- if (
1296
- imageProvider === "auto" ||
1297
- imageProvider === "openai" ||
1298
- imageProvider === "gemini" ||
1299
- imageProvider === "openrouter" ||
1300
- imageProvider === "antigravity" ||
1301
- imageProvider === "alibaba" ||
1302
- imageProvider === "custom"
1303
- ) {
1304
- const { setConfiguredImageModel, setPreferredImageProvider } = await import("../tools/image-gen");
1305
- setPreferredImageProvider(imageProvider === "custom" ? "auto" : imageProvider);
1306
- setConfiguredImageModel({
1307
- provider: imageProvider,
1308
- model: imageModel ?? null,
1309
- customUrl: imageCustomUrl,
1310
- customKey: imageCustomKey,
1311
- customKeyEnv: imageCustomKeyEnv,
1312
- });
1313
- }
1314
-
1315
1297
  const sessionManager =
1316
1298
  options.sessionManager ??
1317
1299
  (await logger.time("sessionManager", async () => {
@@ -1323,6 +1305,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1323
1305
  // session_id upstream, where session-owning transports reject the extra
1324
1306
  // downstreams (owner_busy) and degrade those turns to uncached HTTP.
1325
1307
  const providerSessionId = options.providerSessionId ?? logicalSessionId;
1308
+ const credentialSessionId = options.credentialSessionId ?? providerSessionId;
1326
1309
  const modelApiKeyAvailability = new Map<string, boolean>();
1327
1310
  const getModelAvailabilityKey = (candidate: Model): string =>
1328
1311
  `${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
@@ -1345,7 +1328,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1345
1328
  return false;
1346
1329
  }
1347
1330
  const key = await modelRegistry
1348
- .getApiKey(candidate, providerSessionId, { credentialSelector })
1331
+ .getApiKey(candidate, credentialSessionId, { credentialSelector })
1349
1332
  .catch(error => {
1350
1333
  if (credentialSelector) {
1351
1334
  logger.debug("Credential selector did not match model availability candidate", {
@@ -1395,6 +1378,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1395
1378
  const modelMatchPreferences = {
1396
1379
  usageOrder: settings.getStorage()?.getModelUsageOrder(),
1397
1380
  };
1381
+ const persistedProfiles = modelRegistry.getModelProfiles();
1382
+ const resolvedInheritedProfileName = options.activeModelProfile
1383
+ ? resolveModelProfileName(options.activeModelProfile, persistedProfiles)
1384
+ : undefined;
1385
+ const acceptedInheritedProfileName =
1386
+ resolvedInheritedProfileName && persistedProfiles.has(resolvedInheritedProfileName)
1387
+ ? resolvedInheritedProfileName
1388
+ : undefined;
1389
+ const inheritedProfileOwnsDefault = acceptedInheritedProfileName
1390
+ ? resolveProfileBindings(persistedProfiles.get(acceptedInheritedProfileName)!).defaultSelector !== undefined
1391
+ : false;
1392
+ const settingsProfileAliasIntent: { aliasIntent: "preset-equivalent" } | undefined = inheritedProfileOwnsDefault
1393
+ ? { aliasIntent: "preset-equivalent" }
1394
+ : undefined;
1398
1395
  const allowedModels = await logger.time("resolveAllowedModels", () =>
1399
1396
  resolveAllowedModels(modelRegistry, settings, modelMatchPreferences),
1400
1397
  );
@@ -1403,20 +1400,41 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1403
1400
  settings,
1404
1401
  matchPreferences: modelMatchPreferences,
1405
1402
  modelRegistry,
1403
+ ...(settingsProfileAliasIntent ?? {}),
1404
+ sessionId: providerSessionId,
1405
+ credentialSessionId,
1406
1406
  }),
1407
1407
  );
1408
1408
  let model = options.model;
1409
1409
  let modelFallbackMessage: string | undefined;
1410
1410
  const resumeModelBehavior = settings.get("session.resumeModelBehavior");
1411
- const persistedDefaultChain = existingSession.configuredModelChains.default?.entries;
1411
+ const persistedDefaultChain = existingSession.configuredModelChains.default;
1412
1412
  const defaultModelEntries =
1413
1413
  resumeModelBehavior === "useCurrentDefault"
1414
1414
  ? []
1415
- : persistedDefaultChain && persistedDefaultChain.length > 0
1416
- ? persistedDefaultChain
1415
+ : persistedDefaultChain?.entries && persistedDefaultChain.entries.length > 0
1416
+ ? persistedDefaultChain.entries
1417
1417
  : existingSession.models.default
1418
1418
  ? [existingSession.models.default]
1419
1419
  : [];
1420
+ const persistedProfileName =
1421
+ persistedDefaultChain?.origin === "profile-activation" ? persistedDefaultChain.identity : undefined;
1422
+ const resolvedPersistedProfileName = persistedProfileName
1423
+ ? resolveModelProfileName(persistedProfileName, persistedProfiles)
1424
+ : undefined;
1425
+ const acceptedPersistedProfileName =
1426
+ resolvedPersistedProfileName &&
1427
+ persistedProfiles.has(resolvedPersistedProfileName) &&
1428
+ (!acceptedInheritedProfileName || acceptedInheritedProfileName === resolvedPersistedProfileName) &&
1429
+ resumeModelBehavior !== "useCurrentDefault"
1430
+ ? resolvedPersistedProfileName
1431
+ : undefined;
1432
+ const persistedProfileOwnsDefault = acceptedPersistedProfileName
1433
+ ? resolveProfileBindings(persistedProfiles.get(acceptedPersistedProfileName)!).defaultSelector !== undefined
1434
+ : false;
1435
+ const startupActiveModelProfile =
1436
+ acceptedInheritedProfileName ??
1437
+ (!hasExplicitModel && acceptedPersistedProfileName ? acceptedPersistedProfileName : undefined);
1420
1438
  // If session has data, restore its configured default chain rather than the
1421
1439
  // scalar runtime model, which may be a stale fallback from the prior run.
1422
1440
  if (!hasExplicitModel && !model && hasExistingSession && defaultModelEntries.length > 0) {
@@ -1425,8 +1443,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1425
1443
  defaultModelEntries,
1426
1444
  modelRegistry,
1427
1445
  settings,
1428
- providerSessionId,
1429
- { managedFallback: defaultModelEntries.length > 1 },
1446
+ credentialSessionId,
1447
+ {
1448
+ managedFallback: defaultModelEntries.length > 1,
1449
+ canonicalSessionId: providerSessionId,
1450
+ ...(persistedProfileOwnsDefault ? { aliasIntent: "preset-equivalent" as const } : {}),
1451
+ },
1430
1452
  );
1431
1453
  model = restoredDefaultResolution.model;
1432
1454
  if (!model) modelFallbackMessage = `Could not restore model ${defaultModelEntries.join(" -> ")}`;
@@ -1671,6 +1693,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1671
1693
  trackEvalExecution: (execution, abortController) =>
1672
1694
  session ? session.trackEvalExecution(execution, abortController) : execution,
1673
1695
  getSessionId: () => sessionManager.getSessionId?.() ?? null,
1696
+ getCredentialSessionId: () => session?.credentialSessionId ?? credentialSessionId,
1674
1697
  getMcpManager: () => mcpManager ?? options.inheritedMcpManager,
1675
1698
  isManagedSessionDestination: () => sessionManager.isManagedDestination(),
1676
1699
  getActiveSkillState: () => session?.getActiveSkillState(),
@@ -1771,6 +1794,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1771
1794
  isManagedDestination: () => sessionManager.isManagedDestination(),
1772
1795
  getManagedLegacyLocalMigrationSource: () => sessionManager.getManagedLegacyLocalMigrationSource(),
1773
1796
  getSessionId: () => sessionManager.getSessionId(),
1797
+ getCredentialSessionId: () => credentialSessionId,
1774
1798
  };
1775
1799
  if (!options.parentTaskPrefix) {
1776
1800
  setActiveSkills(skills);
@@ -1782,6 +1806,11 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1782
1806
  disposeLocalProtocolOverride = LocalProtocolHandler.installOverride(options.localProtocolOptions);
1783
1807
  }
1784
1808
  toolSession.getArtifactsDir = getArtifactsDir;
1809
+ // Live parent profile accessor for task/subagent dispatch: profile-owned
1810
+ // persisted model overrides may resolve through preset-equivalent aliases
1811
+ // only while an active profile claims them (see task/executor.ts).
1812
+ (toolSession as ToolSession & { getActiveModelProfile?: () => string | undefined }).getActiveModelProfile = () =>
1813
+ session?.getActiveModelProfile();
1785
1814
  toolSession.getAuthorizedArtifactsDirs = () => {
1786
1815
  const manager = sessionManager.getArtifactManager();
1787
1816
  return manager ? [manager.dir] : [];
@@ -1806,9 +1835,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1806
1835
  const pluginMcpToolNames: string[] = [];
1807
1836
  let deferredExactMcpConfig: { manager: MCPManager; configPath: string } | undefined;
1808
1837
 
1809
- // Add image tools when the active model or configured image providers can generate images.
1838
+ // Add image tools when an image role model is configured.
1810
1839
  const { getImageGenTools } = await import("../tools/image-gen");
1811
- const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
1840
+ const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, settings));
1812
1841
  if (imageGenTools.length > 0) {
1813
1842
  customTools.push(...(imageGenTools as unknown as CustomTool[]));
1814
1843
  }
@@ -2121,8 +2150,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2121
2150
  });
2122
2151
  } else if (sdkHostEligible) {
2123
2152
  registerSdkOnlyNotificationCommand(api);
2124
- const { createSdkWebSocketTransport } = await import("../sdk/host/websocket-transport");
2125
2153
  createSdkSessionRuntimeExtension(api, {
2154
+ agentDir,
2155
+ brokerRegistrationRequired: lifecycleStartupCapability !== undefined,
2126
2156
  createTransport: input => createSdkWebSocketTransport(input),
2127
2157
  settings,
2128
2158
  configOverrides: new Map(),
@@ -2196,6 +2226,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2196
2226
  };
2197
2227
  const { model: resolved } = parseModelPattern(options.modelPattern, availableModels, matchPreferences, {
2198
2228
  modelRegistry,
2229
+ sessionId: logicalSessionId,
2230
+ credentialSessionId,
2199
2231
  });
2200
2232
  if (resolved) {
2201
2233
  model = resolved;
@@ -2283,6 +2315,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2283
2315
  const getSessionContext = () => ({
2284
2316
  sessionManager: createReadonlySessionManager(sessionManager),
2285
2317
  modelRegistry,
2318
+ get credentialSessionId() {
2319
+ return session.credentialSessionId;
2320
+ },
2286
2321
  model: agent.state.model,
2287
2322
  isIdle: () => !session.isStreaming,
2288
2323
  hasQueuedMessages: () => session.queuedMessageCount > 0,
@@ -2312,6 +2347,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2312
2347
  const customToolContext = (): CustomToolContext => ({
2313
2348
  sessionManager: createReadonlySessionManager(sessionManager),
2314
2349
  modelRegistry,
2350
+ get credentialSessionId() {
2351
+ return session?.credentialSessionId ?? credentialSessionId;
2352
+ },
2315
2353
  model: agent?.state.model,
2316
2354
  isIdle: () => !session?.isStreaming,
2317
2355
  hasQueuedMessages: () => (session?.queuedMessageCount ?? 0) > 0,
@@ -2843,14 +2881,20 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2843
2881
  getApiKey: async provider => {
2844
2882
  // Read agent.sessionId at call time so credential selection stays aligned
2845
2883
  // with metadataResolver after /new, fork, resume, or branch switches.
2846
- const key = await modelRegistry.getApiKeyForProvider(provider, agent.providerSessionId ?? agent.sessionId);
2884
+ const key = await modelRegistry.getApiKeyForProvider(
2885
+ provider,
2886
+ options.credentialSessionId ?? agent.providerSessionId ?? agent.sessionId,
2887
+ );
2847
2888
  if (!key) {
2848
2889
  throw new Error(`No API key found for provider "${provider}"`);
2849
2890
  }
2850
2891
  return key;
2851
2892
  },
2852
2893
  getAuthCredentialType: provider =>
2853
- modelRegistry.getSessionCredentialType(provider, agent.providerSessionId ?? agent.sessionId),
2894
+ modelRegistry.getSessionCredentialType(
2895
+ provider,
2896
+ options.credentialSessionId ?? agent.providerSessionId ?? agent.sessionId,
2897
+ ),
2854
2898
  streamFn: async (streamModel, context, streamOptions) => {
2855
2899
  const requestStartedAt = performance.now();
2856
2900
  let stream: Awaited<ReturnType<typeof streamSimple>>;
@@ -2858,15 +2902,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2858
2902
  stream = await streamSimple(streamModel, context, {
2859
2903
  ...streamOptions,
2860
2904
  onAuthError: async (provider, oldKey, error) => {
2905
+ const credentialSessionId =
2906
+ options.credentialSessionId ?? agent.providerSessionId ?? agent.sessionId;
2861
2907
  await modelRegistry.authStorage.invalidateCredentialMatching(provider, oldKey, {
2862
2908
  signal: streamOptions?.signal,
2863
- sessionId: agent.sessionId,
2909
+ sessionId: credentialSessionId,
2864
2910
  });
2865
2911
  logger.debug("Retrying provider request after credential invalidation", {
2866
2912
  provider,
2867
2913
  error: error instanceof Error ? error.message : String(error),
2868
2914
  });
2869
- return modelRegistry.getApiKeyForProvider(provider, agent.sessionId);
2915
+ return modelRegistry.getApiKeyForProvider(provider, credentialSessionId);
2870
2916
  },
2871
2917
  });
2872
2918
  } catch (error) {
@@ -3028,10 +3074,12 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3028
3074
  agentId: resolvedAgentId,
3029
3075
  agentRegistry,
3030
3076
  providerSessionId: options.providerSessionId,
3077
+ credentialSessionId: options.credentialSessionId,
3031
3078
  providerCacheSessionId: providerSessionId,
3032
3079
  forkContextSeed: options.forkContextSeed,
3033
3080
  providerSessionState: options.providerSessionState,
3034
3081
  });
3082
+ session.setActiveModelProfile(startupActiveModelProfile);
3035
3083
  session.configWarnings.push(...contextFileWarnings);
3036
3084
  hasSession = true;
3037
3085
  if (asyncJobManager) {
@@ -3081,7 +3129,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
3081
3129
  if (codexTransport.websocketPreferred) {
3082
3130
  void (async () => {
3083
3131
  try {
3084
- const codexPrewarmApiKey = await modelRegistry.getApiKey(codexModel, providerSessionId);
3132
+ const codexPrewarmApiKey = await modelRegistry.getApiKey(codexModel, credentialSessionId);
3085
3133
  if (!codexPrewarmApiKey) return;
3086
3134
  await logger.time("prewarmOpenAICodexResponses", prewarmOpenAICodexResponses, codexModel, {
3087
3135
  apiKey: codexPrewarmApiKey,
@@ -10,6 +10,7 @@ export type ServeOptions = {
10
10
  url: string;
11
11
  token: string;
12
12
  pendingCeilingBytes: number;
13
+ validateDownstreamFrame?: (frame: string) => boolean;
13
14
  };
14
15
 
15
16
  export type ServeHandle = {
@@ -44,6 +44,7 @@ export type RelayOptions = {
44
44
  onTransportError: (error: TransportError) => void;
45
45
  webSocketFactory?: (url: string) => RelayWebSocket;
46
46
  signal?: AbortSignal;
47
+ validateDownstreamFrame?: (frame: string) => boolean;
47
48
  };
48
49
 
49
50
  type QueuedFrame = { bytes: Buffer; accounted: boolean };
@@ -193,6 +194,8 @@ export async function startRelayPair(options: RelayOptions): Promise<RelayPair>
193
194
  return fail({ type: "transport_error", code: "protocol_error", direction: "downstream->ws" });
194
195
  if (line.length > REQUEST_FRAME_BYTES)
195
196
  return fail({ type: "transport_error", code: "frame_oversize", direction: "downstream->ws" });
197
+ if (options.validateDownstreamFrame && !options.validateDownstreamFrame(line.toString("utf8")))
198
+ return fail({ type: "transport_error", code: "protocol_error", direction: "downstream->ws" });
196
199
  enqueue(toWs, "downstream->ws", line);
197
200
  if (closed) return;
198
201
  newline = downstreamBuffer.indexOf(0x0a);