@gajae-code/coding-agent 0.15.5 → 0.15.6

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 (155) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/types/async/job-manager.d.ts +83 -0
  3. package/dist/types/cli/auth-gateway-cli.d.ts +17 -0
  4. package/dist/types/cli/model-presets-cli.d.ts +9 -0
  5. package/dist/types/cli/update-cli.d.ts +2 -0
  6. package/dist/types/commands/auth-gateway.d.ts +3 -0
  7. package/dist/types/commands/gc.d.ts +13 -0
  8. package/dist/types/commands/model-presets.d.ts +21 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +96 -0
  10. package/dist/types/config/file-lock.d.ts +49 -2
  11. package/dist/types/config/internal/model-preset-registry-test-state.d.ts +4 -0
  12. package/dist/types/config/internal/model-preset-registry-test-support.d.ts +3 -0
  13. package/dist/types/config/keybindings.d.ts +3 -2
  14. package/dist/types/config/model-preset-registry.d.ts +368 -0
  15. package/dist/types/config/model-profile-activation.d.ts +39 -0
  16. package/dist/types/config/model-profiles.d.ts +5 -3
  17. package/dist/types/config/model-registry.d.ts +11 -5
  18. package/dist/types/config/settings-schema.d.ts +9 -0
  19. package/dist/types/coordinator-mcp/model-preset.d.ts +7 -5
  20. package/dist/types/coordinator-mcp/projection-scan.d.ts +46 -0
  21. package/dist/types/extensibility/extensions/types.d.ts +2 -5
  22. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  23. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  24. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  25. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  26. package/dist/types/index.d.ts +1 -0
  27. package/dist/types/main.d.ts +3 -1
  28. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  29. package/dist/types/modes/jobs-observer.d.ts +36 -0
  30. package/dist/types/sdk/broker/broker.d.ts +3 -0
  31. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  32. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  33. package/dist/types/sdk/client/discovery.d.ts +1 -0
  34. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  35. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  36. package/dist/types/sdk/host/session-runtime.d.ts +15 -1
  37. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  38. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  39. package/dist/types/sdk/router/session-router.d.ts +9 -0
  40. package/dist/types/session/account-inventory.d.ts +1 -0
  41. package/dist/types/session/agent-session.d.ts +19 -7
  42. package/dist/types/session/fold-coordinator.d.ts +201 -0
  43. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  44. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  45. package/dist/types/telemetry/control.d.ts +11 -0
  46. package/dist/types/telemetry/events.d.ts +20 -0
  47. package/dist/types/telemetry/index.d.ts +3 -0
  48. package/dist/types/telemetry/transport.d.ts +22 -0
  49. package/dist/types/tools/bash-interactive.d.ts +23 -0
  50. package/dist/types/tools/index.d.ts +6 -5
  51. package/dist/types/web/search/providers/base.d.ts +9 -4
  52. package/dist/types/web/search/providers/xai.d.ts +2 -1
  53. package/dist/types/web/search/types.d.ts +14 -0
  54. package/package.json +10 -7
  55. package/scripts/generate-hotkeys-docs.ts +4 -0
  56. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  57. package/src/async/job-manager.ts +671 -39
  58. package/src/cli/auth-broker-cli.ts +40 -12
  59. package/src/cli/auth-gateway-cli.ts +273 -97
  60. package/src/cli/config-cli.ts +2 -2
  61. package/src/cli/fast-help.ts +3 -0
  62. package/src/cli/model-presets-cli.ts +100 -0
  63. package/src/cli/update-cli.ts +40 -5
  64. package/src/cli.ts +6 -2
  65. package/src/commands/auth-gateway.ts +12 -8
  66. package/src/commands/gc.ts +19 -0
  67. package/src/commands/model-presets.ts +24 -0
  68. package/src/config/autorouting-tier-map.ts +32 -0
  69. package/src/config/file-lock-gc.ts +9 -4
  70. package/src/config/file-lock.ts +775 -59
  71. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  72. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  73. package/src/config/keybindings.ts +6 -2
  74. package/src/config/model-preset-registry.ts +2799 -0
  75. package/src/config/model-profile-activation.ts +115 -28
  76. package/src/config/model-profile-contract.ts +4 -1
  77. package/src/config/model-profiles.ts +51 -9
  78. package/src/config/model-registry.ts +1021 -122
  79. package/src/config/settings-schema.ts +11 -0
  80. package/src/config/settings.ts +6 -1
  81. package/src/coordinator-mcp/model-preset.ts +32 -17
  82. package/src/coordinator-mcp/projection-scan.ts +571 -0
  83. package/src/coordinator-mcp/server.ts +19 -24
  84. package/src/coordinator-mcp/session-reaper.ts +9 -3
  85. package/src/extensibility/extensions/loader.ts +0 -1
  86. package/src/extensibility/extensions/types.ts +2 -3
  87. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  88. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  89. package/src/gjc-runtime/gc-render.ts +29 -0
  90. package/src/gjc-runtime/gc-runtime.ts +174 -11
  91. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  92. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  93. package/src/index.ts +1 -0
  94. package/src/internal-urls/docs-index.generated.ts +9 -8
  95. package/src/lsp/config.ts +4 -3
  96. package/src/lsp/path-trust.ts +12 -6
  97. package/src/main.ts +9 -2
  98. package/src/modes/acp/acp-agent.ts +11 -5
  99. package/src/modes/components/jobs-overlay-model.ts +99 -16
  100. package/src/modes/components/model-selector.ts +158 -18
  101. package/src/modes/components/status-line/segments.ts +6 -1
  102. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  103. package/src/modes/controllers/input-controller.ts +23 -6
  104. package/src/modes/interactive-mode.ts +14 -1
  105. package/src/modes/jobs-observer.ts +149 -16
  106. package/src/modes/runtime-init.ts +1 -1
  107. package/src/modes/utils/injected-user-submission.ts +15 -1
  108. package/src/prompts/tools/bash.md +2 -2
  109. package/src/prompts/tools/fold-receipt.md +3 -0
  110. package/src/prompts/tools/job.md +1 -1
  111. package/src/sdk/acp/adapter.ts +17 -2
  112. package/src/sdk/broker/broker.ts +48 -21
  113. package/src/sdk/broker/lifecycle.ts +32 -6
  114. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  115. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  116. package/src/sdk/bus/kind-aware-reconciliation.ts +36 -6
  117. package/src/sdk/bus/prompt-reconciliation.ts +28 -4
  118. package/src/sdk/bus/slack-conversation.ts +5 -1
  119. package/src/sdk/cli/session-cli.ts +12 -1
  120. package/src/sdk/client/discovery.ts +3 -1
  121. package/src/sdk/host/control/dispatch.ts +3 -0
  122. package/src/sdk/host/model-pin.ts +185 -25
  123. package/src/sdk/host/sdk-run-capability.ts +1 -0
  124. package/src/sdk/host/session-runtime.test.ts +371 -37
  125. package/src/sdk/host/session-runtime.ts +364 -52
  126. package/src/sdk/prompt-deadline-manager.ts +16 -1
  127. package/src/sdk/protocol/adapter-validation.ts +27 -0
  128. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  129. package/src/sdk/protocol/operation-registry.ts +4 -1
  130. package/src/sdk/router/session-router.ts +228 -96
  131. package/src/sdk/session.ts +83 -12
  132. package/src/session/account-inventory.ts +27 -11
  133. package/src/session/agent-session.ts +678 -103
  134. package/src/session/fold-coordinator.ts +433 -0
  135. package/src/session/sdk-run-capability-internal.ts +15 -0
  136. package/src/session/sdk-run-capability.ts +2 -0
  137. package/src/session/session-manager.ts +5 -4
  138. package/src/setup/provider-presets.json +1 -1
  139. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  140. package/src/task/executor.ts +19 -3
  141. package/src/telemetry/control.ts +26 -0
  142. package/src/telemetry/events.ts +142 -0
  143. package/src/telemetry/index.ts +3 -0
  144. package/src/telemetry/transport.ts +87 -0
  145. package/src/tools/bash-interactive.ts +182 -85
  146. package/src/tools/bash.ts +772 -67
  147. package/src/tools/index.ts +7 -5
  148. package/src/tools/tool-catalog.generated.ts +1 -1
  149. package/src/web/search/provider.ts +120 -11
  150. package/src/web/search/providers/anthropic.ts +89 -17
  151. package/src/web/search/providers/base.ts +9 -4
  152. package/src/web/search/providers/gemini.ts +87 -15
  153. package/src/web/search/providers/openai-compatible.ts +23 -8
  154. package/src/web/search/providers/xai.ts +98 -12
  155. package/src/web/search/types.ts +16 -0
@@ -21,6 +21,8 @@ import type {
21
21
  ReconciliationStore,
22
22
  } from "./reconciliation-store";
23
23
 
24
+ const EMPTY_PROMPT_FAILURE = { code: "prompt_failed", message: "Prompt submission failed." } as const;
25
+
24
26
  export type { ReconciliationKind };
25
27
 
26
28
  export interface KindCorrelation extends PromptCorrelation {
@@ -51,8 +53,13 @@ export interface KindAwareReconciliation {
51
53
  kind: ReconciliationKind,
52
54
  correlation: PromptCorrelation | undefined,
53
55
  frame:
54
- | { type: "agent_start" | "agent_end"; content?: TurnResultContent }
55
- | { type: "agent_failed"; error: unknown; content?: TurnResultContent },
56
+ | {
57
+ type: "agent_start" | "agent_end";
58
+ content?: TurnResultContent;
59
+ hasActivity?: boolean;
60
+ outcome?: SdkPromptTerminalOutcome;
61
+ }
62
+ | { type: "agent_failed"; error: unknown; content?: TurnResultContent; hasActivity?: boolean },
56
63
  ): Promise<void>;
57
64
  claimPendingOutcome(
58
65
  kind: ReconciliationKind,
@@ -340,8 +347,13 @@ export function createKindAwareReconciliation(
340
347
  kind: ReconciliationKind,
341
348
  correlation: PromptCorrelation | undefined,
342
349
  frame:
343
- | { type: "agent_start" | "agent_end"; content?: TurnResultContent }
344
- | { type: "agent_failed"; error: unknown; content?: TurnResultContent },
350
+ | {
351
+ type: "agent_start" | "agent_end";
352
+ content?: TurnResultContent;
353
+ hasActivity?: boolean;
354
+ outcome?: SdkPromptTerminalOutcome;
355
+ }
356
+ | { type: "agent_failed"; error: unknown; content?: TurnResultContent; hasActivity?: boolean },
345
357
  ) => {
346
358
  if (!correlation) return;
347
359
  await queueMutation(candidate => {
@@ -412,7 +424,17 @@ export function createKindAwareReconciliation(
412
424
  record.receiptState = record.pendingReceiptState ?? "missing";
413
425
  record.pendingReceiptState = undefined;
414
426
  } else {
415
- record.status = record.error === undefined ? "terminal_ok" : "failed";
427
+ if (
428
+ kind === "prompt" &&
429
+ record.error === undefined &&
430
+ frame.type === "agent_end" &&
431
+ frame.outcome?.kind !== "stopped" &&
432
+ !frame.content?.text.trim() &&
433
+ !frame.hasActivity
434
+ ) {
435
+ record.status = "failed";
436
+ record.error = EMPTY_PROMPT_FAILURE;
437
+ } else record.status = record.error === undefined ? "terminal_ok" : "failed";
416
438
  record.receiptState = frame.content?.text?.trim() ? "present" : "missing";
417
439
  }
418
440
  cleanupRecords(candidate);
@@ -453,6 +475,7 @@ export function createKindAwareReconciliation(
453
475
  : undefined;
454
476
  const content =
455
477
  typeof arg4 === "function" ? arg6 : typeof arg5 === "object" && arg5 !== null && "code" in arg5 ? arg6 : arg5;
478
+ const sanitizedContent = sanitizeTurnResultContent(content);
456
479
  await queueMutation(candidate => {
457
480
  if (isCurrent !== undefined && !isCurrent()) return { value: undefined, changed: false };
458
481
  const record = candidate.get(keyOf(kind, correlation));
@@ -463,8 +486,15 @@ export function createKindAwareReconciliation(
463
486
  if (finalOutcome?.kind === "failed") {
464
487
  record.status = "failed";
465
488
  record.error = recordError ?? { code: finalOutcome.code, message: finalOutcome.message };
489
+ } else if (
490
+ kind === "prompt" &&
491
+ ((finalOutcome === undefined && !sanitizedContent?.text.trim()) ||
492
+ (finalOutcome?.kind !== "stopped" && content !== undefined && !sanitizedContent?.text.trim()))
493
+ ) {
494
+ record.status = "failed";
495
+ record.error = EMPTY_PROMPT_FAILURE;
466
496
  } else record.status = "terminal_ok";
467
- record.content = sanitizeTurnResultContent(content);
497
+ record.content = sanitizedContent;
468
498
  record.outcome = finalOutcome;
469
499
  record.receiptState = record.pendingReceiptState ?? "unknown";
470
500
  record.pendingOutcome = undefined;
@@ -23,11 +23,13 @@
23
23
  */
24
24
 
25
25
  import { PROMPT_FAILURE_CODE_MAX, sanitizePromptFailure } from "../prompt-failure";
26
+ import type { SdkPromptTerminalOutcome } from "../prompt-status";
26
27
  import type { ReceiptState } from "../receipt-state";
27
28
 
28
29
  export { PROMPT_FAILURE_CODE_MAX, sanitizePromptFailure };
29
30
  export const PROMPT_RECONCILIATION_ACTIVE_CAPACITY = 128;
30
31
  export const PROMPT_RECONCILIATION_TERMINAL_CAPACITY = 256;
32
+ const EMPTY_PROMPT_FAILURE = { code: "prompt_failed", message: "Prompt submission failed." } as const;
31
33
 
32
34
  export type PromptReconciliationStatus = "accepted" | "in_flight" | "terminal_ok" | "failed";
33
35
 
@@ -101,8 +103,13 @@ export interface PromptReconciliation {
101
103
  correlation: PromptCorrelation | undefined,
102
104
  frame:
103
105
  | { type: "agent_start" }
104
- | { type: "agent_end"; finalText?: string }
105
- | { type: "agent_failed"; error: unknown; finalText?: string },
106
+ | {
107
+ type: "agent_end";
108
+ finalText?: string;
109
+ hasActivity?: boolean;
110
+ outcome?: SdkPromptTerminalOutcome;
111
+ }
112
+ | { type: "agent_failed"; error: unknown; finalText?: string; hasActivity?: boolean },
106
113
  ): void;
107
114
  lookup(selector: { commandId?: string; turnId?: string; clientRef?: string }): TurnPromptReconciliation;
108
115
  cleanup(): void;
@@ -190,8 +197,13 @@ export function createPromptReconciliation(options: { now?: () => number } = {})
190
197
  correlation: PromptCorrelation | undefined,
191
198
  frame:
192
199
  | { type: "agent_start" }
193
- | { type: "agent_end"; finalText?: string }
194
- | { type: "agent_failed"; error: unknown; finalText?: string },
200
+ | {
201
+ type: "agent_end";
202
+ finalText?: string;
203
+ hasActivity?: boolean;
204
+ outcome?: SdkPromptTerminalOutcome;
205
+ }
206
+ | { type: "agent_failed"; error: unknown; finalText?: string; hasActivity?: boolean },
195
207
  ) => {
196
208
  if (!correlation) return;
197
209
  const record = records.get(keyOf(correlation));
@@ -221,6 +233,18 @@ export function createPromptReconciliation(options: { now?: () => number } = {})
221
233
  if (frame.type === "agent_failed") {
222
234
  record.status = "failed";
223
235
  record.error = sanitizePromptFailure(frame.error);
236
+ } else if (record.error !== undefined) {
237
+ // Preserve an actionable provider failure already recorded before a late
238
+ // empty agent_end; the empty frame cannot downgrade or replace its cause.
239
+ record.status = "failed";
240
+ } else if (frame.type === "agent_end" && frame.outcome?.kind === "stopped") {
241
+ record.status = "terminal_ok";
242
+ } else if (!frame.finalText?.trim() && !frame.hasActivity) {
243
+ // A prompt cannot claim successful completion without assistant content.
244
+ // Keep the terminal transition first-wins, but fail closed instead of
245
+ // exposing an empty zero-token turn as terminal_ok.
246
+ record.status = "failed";
247
+ record.error = EMPTY_PROMPT_FAILURE;
224
248
  } else {
225
249
  record.status = "terminal_ok";
226
250
  }
@@ -74,5 +74,9 @@ export function normalizeSlackConversation(record: SlackConversation): SlackConv
74
74
  }
75
75
 
76
76
  export function acceptsSlackInbound(record: SlackConversation, rootTs: string, endpointGeneration: number): boolean {
77
- return record.state === "active" && record.rootTs === rootTs && record.endpointGeneration === endpointGeneration;
77
+ return (
78
+ (record.state === "active" || record.state === "resumed_root") &&
79
+ record.rootTs === rootTs &&
80
+ record.endpointGeneration === endpointGeneration
81
+ );
78
82
  }
@@ -16,7 +16,11 @@ import type {
16
16
  SessionReconcileUncertainTarget,
17
17
  } from "../lifecycle/service";
18
18
  import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../prompt-status";
19
- import { validateAdapterControl, validateAdapterSecretFields } from "../protocol/adapter-validation";
19
+ import {
20
+ validateAdapterControl,
21
+ validateAdapterSecretFields,
22
+ validateRequiredPromptText,
23
+ } from "../protocol/adapter-validation";
20
24
  import { adapterDispositionError, findOperation, type OperationKind } from "../protocol/operation-registry";
21
25
  import { type SessionAttachment, SessionRouter, SessionRouterError, type SessionRouterFrame } from "../router";
22
26
  import { SessionListTraversalError, sessionListPageFromResponse, traverseSessionList } from "../session-list";
@@ -654,6 +658,8 @@ async function runSend(agentDir: string, sessionId: string, args: SdkSessionCliA
654
658
  if (args.text !== undefined && Object.keys(input).length > 0)
655
659
  throw new SdkSessionCliError("usage", "Use either --text or one JSON input source for the prompt, not both.", 2);
656
660
  const promptInput: JsonRecord = args.text === undefined ? { ...input } : { text: args.text };
661
+ const promptError = validateRequiredPromptText("turn.prompt", promptInput);
662
+ if (promptError) throw new SdkSessionCliError(promptError.code, promptError.message, 2);
657
663
  const inputRef = clientRefFromInput(promptInput);
658
664
  const clientRef = inputRef ?? args.opRef?.trim() ?? createOperationRef();
659
665
  if (args.opRef !== undefined && inputRef !== undefined && inputRef !== args.opRef.trim())
@@ -914,6 +920,11 @@ function publicationSequence(publicationId: string): number | undefined {
914
920
 
915
921
  function tailItemFromRouterFrame(frame: SessionRouterFrame): SdkTailItemV1 | undefined {
916
922
  if (frame.name === undefined) return undefined;
923
+ // The replay response is an out-of-band control frame, not an event-ring
924
+ // item. Its generation/lastSeq metadata is a response position, and it
925
+ // intentionally has no event `seq`; projecting it as a tail item would make
926
+ // the strict event-position validator reject every otherwise valid tail.
927
+ if (frame.name === "event_replay_result") return undefined;
917
928
  const seq = frame.publicationId === undefined ? undefined : publicationSequence(frame.publicationId);
918
929
  return toTailItemV1(
919
930
  {
@@ -35,7 +35,7 @@ export class SdkDiscoveryError extends Error {
35
35
 
36
36
  export type SdkSessionEndpointScope = "default" | "chat";
37
37
 
38
- function endpointDirectory(repo: string, scope: SdkSessionEndpointScope = "default"): string {
38
+ export function endpointDirectory(repo: string, scope: SdkSessionEndpointScope = "default"): string {
39
39
  return scope === "chat" ? path.join(repo, ".gjc", "state", "chat", "sdk") : path.join(repo, ".gjc", "state", "sdk");
40
40
  }
41
41
  function isUsableSessionId(sessionId: string): boolean {
@@ -132,6 +132,8 @@ export async function readSdkSessionEndpoint(
132
132
  if (!isUsableSessionId(sessionId)) return null;
133
133
  const file = path.join(endpointDirectory(repo, scope), `${sessionId}.json`);
134
134
  try {
135
+ const stat = await fs.lstat(file);
136
+ if (!stat.isFile()) return null;
135
137
  return parseEndpoint(sessionId, file, JSON.parse(await fs.readFile(file, "utf8")));
136
138
  } catch (error) {
137
139
  if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
@@ -3,6 +3,7 @@ import {
3
3
  DEFAULT_MODEL_SELECTION_RECOVERY_MESSAGE,
4
4
  parseDefaultModelSelectionRecovery,
5
5
  } from "../../../session/default-model-selection";
6
+ import { validateRequiredPromptText } from "../../protocol/adapter-validation";
6
7
  import { OPERATIONS, type Operation } from "../../protocol/operation-registry";
7
8
  import type { ControlInput, ControlSurface, ControlValue } from "./operations";
8
9
  import { brokerRuntimeCloseCapability, hasBrokerRuntimeCloseCapability } from "./runtime-gate";
@@ -462,6 +463,8 @@ export function dispatchControl(
462
463
  );
463
464
  if (!isInput(request.input))
464
465
  return Promise.resolve(failure(request.id, "invalid_input", "Control input must be an object."));
466
+ const promptError = validateRequiredPromptText(row.sdkId, request.input);
467
+ if (promptError) return Promise.resolve(failure(request.id, promptError.code, promptError.message));
465
468
  if ((row.sdkId === "context.clear" || row.sdkId === "session.delete") && request.confirm !== true)
466
469
  return Promise.resolve(
467
470
  failure(request.id, "invalid_input", "confirm: true is required for this destructive operation."),
@@ -17,9 +17,19 @@ export interface SdkHostModelResolveContext {
17
17
  }
18
18
 
19
19
  /** Registry loader owned by the SDK host, not by machine-facing adapters. */
20
- export type SdkHostModelRegistryLoader = (
20
+ export type SdkHostModelRegistryLoader = ((
21
21
  context?: SdkHostModelResolveContext,
22
- ) => ModelRegistry | Promise<ModelRegistry>;
22
+ ) => ModelRegistry | Promise<ModelRegistry>) & {
23
+ acquire?: (context?: SdkHostModelResolveContext) => () => void;
24
+ dispose?: () => Promise<void>;
25
+ };
26
+
27
+ type OwnedModelRegistry = {
28
+ registry: ModelRegistry;
29
+ storage: AuthStorage;
30
+ };
31
+
32
+ const MAX_CACHED_MODEL_REGISTRIES = 32;
23
33
 
24
34
  /**
25
35
  * Builds an offline model resolver for a single SDK host process.
@@ -32,16 +42,112 @@ export function createSdkHostModelRegistryLoader(
32
42
  modelsPath?: string,
33
43
  loadSettings?: (context?: SdkHostModelResolveContext) => Promise<Pick<Settings, "get" | "getGlobal">>,
34
44
  ): SdkHostModelRegistryLoader {
35
- const cachedRegistries = new Map<string, Promise<ModelRegistry>>();
36
- return async (context?: SdkHostModelResolveContext) => {
37
- const scopeKey = context?.cwd ?? "";
45
+ const cachedRegistries = new Map<string, Promise<OwnedModelRegistry>>();
46
+ const retiredRegistries = new Map<string, Promise<OwnedModelRegistry>[]>();
47
+ const activeScopes = new Map<string, number>();
48
+ const disposedEntries = new WeakSet<Promise<OwnedModelRegistry>>();
49
+ const pendingDisposals = new Set<Promise<void>>();
50
+ const disposalBarrier = Promise.withResolvers<void>();
51
+ const registryAgentDir = path.resolve(path.dirname(modelsPath ?? "."));
52
+ let disposed = false;
53
+ const scopeKeyFor = (context?: SdkHostModelResolveContext): string =>
54
+ context?.cwd === undefined ? "" : path.resolve(context.cwd);
55
+ const disposeEntry = async (entry: Promise<OwnedModelRegistry>): Promise<void> => {
56
+ let owned: OwnedModelRegistry;
57
+ try {
58
+ owned = await entry;
59
+ } catch {
60
+ // Initialization failures close their storage before rejecting. Disposal is
61
+ // best-effort and must not block broker shutdown on one broken scope.
62
+ return;
63
+ }
64
+ try {
65
+ owned.registry.dispose();
66
+ } finally {
67
+ owned.storage.close();
68
+ }
69
+ };
70
+ const maybeResolveDisposal = (): void => {
71
+ if (
72
+ disposed &&
73
+ cachedRegistries.size === 0 &&
74
+ retiredRegistries.size === 0 &&
75
+ activeScopes.size === 0 &&
76
+ pendingDisposals.size === 0
77
+ )
78
+ disposalBarrier.resolve();
79
+ };
80
+ const disposeEntries = (entries: Promise<OwnedModelRegistry>[]): void => {
81
+ const seen = new Set<Promise<OwnedModelRegistry>>();
82
+ const unique = entries.filter(entry => {
83
+ if (seen.has(entry) || disposedEntries.has(entry)) return false;
84
+ seen.add(entry);
85
+ return true;
86
+ });
87
+ if (unique.length === 0) {
88
+ maybeResolveDisposal();
89
+ return;
90
+ }
91
+ for (const entry of unique) disposedEntries.add(entry);
92
+ const pending = Promise.all(unique.map(entry => disposeEntry(entry))).then(
93
+ () => undefined,
94
+ () => undefined,
95
+ );
96
+ pendingDisposals.add(pending);
97
+ void pending.finally(() => {
98
+ pendingDisposals.delete(pending);
99
+ maybeResolveDisposal();
100
+ });
101
+ };
102
+ const retireEntry = (scopeKey: string, entry: Promise<OwnedModelRegistry>): void => {
103
+ if ((activeScopes.get(scopeKey) ?? 0) > 0) {
104
+ const retired = retiredRegistries.get(scopeKey) ?? [];
105
+ if (!retired.includes(entry)) retired.push(entry);
106
+ retiredRegistries.set(scopeKey, retired);
107
+ return;
108
+ }
109
+ disposeEntries([entry]);
110
+ };
111
+ const disposeRetired = (scopeKey: string): void => {
112
+ const retired = retiredRegistries.get(scopeKey);
113
+ if (!retired || (activeScopes.get(scopeKey) ?? 0) > 0) return;
114
+ retiredRegistries.delete(scopeKey);
115
+ disposeEntries(retired);
116
+ };
117
+ const evictIdleEntries = (): Promise<OwnedModelRegistry>[] => {
118
+ const evicted: Promise<OwnedModelRegistry>[] = [];
119
+ while (cachedRegistries.size > MAX_CACHED_MODEL_REGISTRIES) {
120
+ const oldest = [...cachedRegistries.keys()].find(key => (activeScopes.get(key) ?? 0) === 0);
121
+ if (oldest === undefined) break;
122
+ const entry = cachedRegistries.get(oldest);
123
+ cachedRegistries.delete(oldest);
124
+ if (entry) evicted.push(entry);
125
+ }
126
+ return evicted;
127
+ };
128
+ const loadRegistry = async (context?: SdkHostModelResolveContext): Promise<ModelRegistry> => {
129
+ if (disposed) throw new Error("SDK host model registry loader is disposed.");
130
+ const scopeKey = scopeKeyFor(context);
38
131
  let cachedRegistry = cachedRegistries.get(scopeKey);
39
132
  if (cachedRegistry === undefined) {
40
- const initializing = Promise.all([discoverStorage(), loadSettings?.(context)]).then(
41
- ([storage, registrySettings]) => new ModelRegistry(storage, modelsPath, registrySettings),
42
- );
133
+ const initializing = discoverStorage().then(async storage => {
134
+ try {
135
+ const registrySettings = await loadSettings?.(context);
136
+ return {
137
+ storage,
138
+ registry: new ModelRegistry(storage, modelsPath, registrySettings, {
139
+ agentDir: registryAgentDir,
140
+ automaticRefresh: false,
141
+ }),
142
+ };
143
+ } catch (error) {
144
+ storage.close();
145
+ throw error;
146
+ }
147
+ });
43
148
  cachedRegistries.set(scopeKey, initializing);
44
149
  cachedRegistry = initializing;
150
+ disposeEntries(evictIdleEntries());
45
151
  try {
46
152
  await initializing;
47
153
  } catch (error) {
@@ -49,21 +155,67 @@ export function createSdkHostModelRegistryLoader(
49
155
  throw error;
50
156
  }
51
157
  }
52
- const registry = await cachedRegistry;
53
- if (loadSettings) registry.setScopedSettings(await loadSettings(context));
158
+ const owned = await cachedRegistry;
159
+ if (disposed || disposedEntries.has(cachedRegistry) || cachedRegistries.get(scopeKey) !== cachedRegistry) {
160
+ retireEntry(scopeKey, cachedRegistry);
161
+ throw new Error("SDK host model registry loader is disposed.");
162
+ }
163
+ cachedRegistries.delete(scopeKey);
164
+ cachedRegistries.set(scopeKey, cachedRegistry);
165
+ const registry = owned.registry;
166
+ if (loadSettings) {
167
+ const registrySettings = await loadSettings(context);
168
+ if (disposed || disposedEntries.has(cachedRegistry)) {
169
+ retireEntry(scopeKey, cachedRegistry);
170
+ throw new Error("SDK host model registry loader is disposed.");
171
+ }
172
+ registry.setScopedSettings(registrySettings);
173
+ }
54
174
  await registry.refresh("offline");
175
+ if (disposed || disposedEntries.has(cachedRegistry)) {
176
+ retireEntry(scopeKey, cachedRegistry);
177
+ throw new Error("SDK host model registry loader is disposed.");
178
+ }
55
179
  return registry;
56
180
  };
181
+ return Object.assign(loadRegistry, {
182
+ dispose: async (): Promise<void> => {
183
+ if (disposed) return await disposalBarrier.promise;
184
+ disposed = true;
185
+ for (const [scopeKey, entry] of cachedRegistries) retireEntry(scopeKey, entry);
186
+ cachedRegistries.clear();
187
+ for (const scopeKey of retiredRegistries.keys()) disposeRetired(scopeKey);
188
+ maybeResolveDisposal();
189
+ await disposalBarrier.promise;
190
+ },
191
+ acquire: (context?: SdkHostModelResolveContext): (() => void) => {
192
+ const scopeKey = scopeKeyFor(context);
193
+ activeScopes.set(scopeKey, (activeScopes.get(scopeKey) ?? 0) + 1);
194
+ let released = false;
195
+ return () => {
196
+ if (released) return;
197
+ released = true;
198
+ const next = (activeScopes.get(scopeKey) ?? 1) - 1;
199
+ if (next <= 0) activeScopes.delete(scopeKey);
200
+ else activeScopes.set(scopeKey, next);
201
+ disposeRetired(scopeKey);
202
+ disposeEntries(evictIdleEntries());
203
+ maybeResolveDisposal();
204
+ };
205
+ },
206
+ });
57
207
  }
58
208
 
59
209
  export type SdkHostModelResolution =
60
210
  | { ok: true; model: string | null }
61
211
  | { ok: false; reason: "unknown_model"; model: string; error: string };
62
212
 
63
- export type SdkHostModelResolver = (
213
+ export type SdkHostModelResolver = ((
64
214
  raw: unknown,
65
215
  context?: SdkHostModelResolveContext,
66
- ) => Promise<SdkHostModelResolution>;
216
+ ) => Promise<SdkHostModelResolution>) & {
217
+ dispose?: () => Promise<void>;
218
+ };
67
219
 
68
220
  /** Resolve the explicit model pin at the SDK host boundary. */
69
221
  export async function resolveSdkHostModel(
@@ -74,19 +226,24 @@ export async function resolveSdkHostModel(
74
226
  if (raw === undefined || raw === null) return { ok: true, model: null };
75
227
  const requested = typeof raw === "string" ? raw : "";
76
228
  const echoed = requested.trim().slice(0, MAX_ECHOED_MODEL_LENGTH);
77
- const registry = await loadRegistry(context);
78
- const resolved: ResolveCliModelResult = resolveCliModel({ cliModel: requested, modelRegistry: registry });
79
- if (!resolved.model)
229
+ const release = loadRegistry.acquire?.(context) ?? (() => {});
230
+ try {
231
+ const registry = await loadRegistry(context);
232
+ const resolved: ResolveCliModelResult = resolveCliModel({ cliModel: requested, modelRegistry: registry });
233
+ if (!resolved.model)
234
+ return {
235
+ ok: false,
236
+ reason: "unknown_model",
237
+ model: echoed,
238
+ error: resolved.error ?? "No models available. Check your installation or add models to models.json.",
239
+ };
80
240
  return {
81
- ok: false,
82
- reason: "unknown_model",
83
- model: echoed,
84
- error: resolved.error ?? "No models available. Check your installation or add models to models.json.",
241
+ ok: true,
242
+ model: formatModelSelectorValue(formatModelString(resolved.model), resolved.thinkingLevel),
85
243
  };
86
- return {
87
- ok: true,
88
- model: formatModelSelectorValue(formatModelString(resolved.model), resolved.thinkingLevel),
89
- };
244
+ } finally {
245
+ release();
246
+ }
90
247
  }
91
248
 
92
249
  /** Default resolver used by the SDK broker host. */
@@ -96,6 +253,9 @@ export function createDefaultSdkHostModelResolver(agentDir: string): SdkHostMode
96
253
  path.join(agentDir, "models.yml"),
97
254
  context => Settings.loadReadonly({ agentDir, cwd: context?.cwd }),
98
255
  );
99
- return (raw: unknown, context?: SdkHostModelResolveContext): Promise<SdkHostModelResolution> =>
100
- resolveSdkHostModel(raw, loadRegistry, context);
256
+ return Object.assign(
257
+ (raw: unknown, context?: SdkHostModelResolveContext): Promise<SdkHostModelResolution> =>
258
+ resolveSdkHostModel(raw, loadRegistry, context),
259
+ { dispose: loadRegistry.dispose },
260
+ );
101
261
  }
@@ -0,0 +1 @@
1
+ export { createSdkRunCapability } from "../../session/sdk-run-capability-internal";