@gajae-code/coding-agent 0.15.4 → 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 (174) hide show
  1. package/CHANGELOG.md +46 -2
  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 +27 -5
  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 +99 -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 +4 -4
  22. package/dist/types/extensibility/shared-events.d.ts +2 -0
  23. package/dist/types/gjc-runtime/empty-delete-gc.d.ts +58 -0
  24. package/dist/types/gjc-runtime/gc-runtime.d.ts +2 -0
  25. package/dist/types/gjc-runtime/session-state-lock.d.ts +35 -6
  26. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +6 -0
  27. package/dist/types/index.d.ts +1 -0
  28. package/dist/types/main.d.ts +3 -1
  29. package/dist/types/modes/components/jobs-overlay-model.d.ts +4 -2
  30. package/dist/types/modes/jobs-observer.d.ts +36 -0
  31. package/dist/types/sdk/broker/broker.d.ts +3 -0
  32. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +3 -0
  33. package/dist/types/sdk/bus/prompt-reconciliation.d.ts +4 -0
  34. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  35. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  36. package/dist/types/sdk/client/discovery.d.ts +1 -0
  37. package/dist/types/sdk/host/host.d.ts +2 -0
  38. package/dist/types/sdk/host/model-pin.d.ts +7 -2
  39. package/dist/types/sdk/host/sdk-run-capability.d.ts +1 -0
  40. package/dist/types/sdk/host/session-runtime.d.ts +19 -1
  41. package/dist/types/sdk/prompt-deadline-manager.d.ts +11 -1
  42. package/dist/types/sdk/protocol/adapter-validation.d.ts +2 -0
  43. package/dist/types/sdk/router/session-router.d.ts +9 -0
  44. package/dist/types/session/account-inventory.d.ts +1 -0
  45. package/dist/types/session/agent-session.d.ts +19 -5
  46. package/dist/types/session/fold-coordinator.d.ts +201 -0
  47. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  48. package/dist/types/session/sdk-run-capability-internal.d.ts +7 -0
  49. package/dist/types/session/sdk-run-capability.d.ts +2 -0
  50. package/dist/types/telemetry/control.d.ts +11 -0
  51. package/dist/types/telemetry/events.d.ts +20 -0
  52. package/dist/types/telemetry/index.d.ts +3 -0
  53. package/dist/types/telemetry/transport.d.ts +22 -0
  54. package/dist/types/tools/bash-interactive.d.ts +23 -0
  55. package/dist/types/tools/index.d.ts +6 -5
  56. package/dist/types/tools/path-utils.d.ts +5 -6
  57. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  58. package/dist/types/web/search/providers/base.d.ts +9 -4
  59. package/dist/types/web/search/providers/xai.d.ts +2 -1
  60. package/dist/types/web/search/types.d.ts +14 -0
  61. package/package.json +10 -7
  62. package/scripts/generate-hotkeys-docs.ts +4 -0
  63. package/scripts/generate-sdk-operation-inventory.ts +3 -3
  64. package/src/async/job-manager.ts +671 -39
  65. package/src/cli/auth-broker-cli.ts +40 -12
  66. package/src/cli/auth-gateway-cli.ts +273 -97
  67. package/src/cli/config-cli.ts +2 -2
  68. package/src/cli/fast-help.ts +3 -0
  69. package/src/cli/model-presets-cli.ts +100 -0
  70. package/src/cli/update-cli.ts +142 -6
  71. package/src/cli.ts +6 -2
  72. package/src/commands/auth-gateway.ts +12 -8
  73. package/src/commands/gc.ts +19 -0
  74. package/src/commands/model-presets.ts +24 -0
  75. package/src/commands/sdk.ts +5 -0
  76. package/src/config/autorouting-tier-map.ts +33 -0
  77. package/src/config/file-lock-gc.ts +9 -4
  78. package/src/config/file-lock.ts +775 -59
  79. package/src/config/internal/model-preset-registry-test-state.ts +28 -0
  80. package/src/config/internal/model-preset-registry-test-support.ts +11 -0
  81. package/src/config/keybindings.ts +6 -2
  82. package/src/config/model-preset-registry.ts +2799 -0
  83. package/src/config/model-profile-activation.ts +115 -28
  84. package/src/config/model-profile-contract.ts +4 -1
  85. package/src/config/model-profiles.ts +56 -14
  86. package/src/config/model-registry.ts +1028 -123
  87. package/src/config/settings-schema.ts +11 -0
  88. package/src/config/settings.ts +6 -1
  89. package/src/coordinator-mcp/model-preset.ts +32 -17
  90. package/src/coordinator-mcp/policy.ts +1 -1
  91. package/src/coordinator-mcp/projection-scan.ts +571 -0
  92. package/src/coordinator-mcp/server.ts +19 -24
  93. package/src/coordinator-mcp/session-reaper.ts +9 -3
  94. package/src/extensibility/extensions/loader.ts +0 -1
  95. package/src/extensibility/extensions/types.ts +4 -2
  96. package/src/extensibility/gjc-plugins/runtime-adapters.ts +1 -0
  97. package/src/extensibility/shared-events.ts +2 -0
  98. package/src/gjc-runtime/empty-delete-gc.ts +301 -0
  99. package/src/gjc-runtime/gc-render.ts +29 -0
  100. package/src/gjc-runtime/gc-runtime.ts +174 -11
  101. package/src/gjc-runtime/launch-worktree.ts +44 -7
  102. package/src/gjc-runtime/ralplan-runtime.ts +14 -1
  103. package/src/gjc-runtime/session-state-lock.ts +1242 -141
  104. package/src/gjc-runtime/session-state-sidecar.ts +76 -3
  105. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  106. package/src/index.ts +1 -0
  107. package/src/internal-urls/docs-index.generated.ts +14 -13
  108. package/src/lsp/config.ts +4 -3
  109. package/src/lsp/path-trust.ts +12 -6
  110. package/src/main.ts +9 -2
  111. package/src/modes/acp/acp-agent.ts +11 -5
  112. package/src/modes/components/jobs-overlay-model.ts +99 -16
  113. package/src/modes/components/model-selector.ts +158 -18
  114. package/src/modes/components/status-line/segments.ts +6 -1
  115. package/src/modes/controllers/extension-ui-controller.ts +1 -1
  116. package/src/modes/controllers/input-controller.ts +23 -6
  117. package/src/modes/interactive-mode.ts +14 -1
  118. package/src/modes/jobs-observer.ts +149 -16
  119. package/src/modes/prompt-action-autocomplete.ts +26 -47
  120. package/src/modes/runtime-init.ts +1 -1
  121. package/src/modes/utils/injected-user-submission.ts +15 -1
  122. package/src/prompts/tools/bash.md +2 -2
  123. package/src/prompts/tools/fold-receipt.md +3 -0
  124. package/src/prompts/tools/job.md +1 -1
  125. package/src/sdk/acp/adapter.ts +17 -2
  126. package/src/sdk/broker/broker.ts +48 -21
  127. package/src/sdk/broker/lifecycle.ts +32 -6
  128. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  129. package/src/sdk/bus/chat-daemon-runtime.ts +11 -1
  130. package/src/sdk/bus/kind-aware-reconciliation.ts +46 -8
  131. package/src/sdk/bus/prompt-reconciliation.ts +33 -6
  132. package/src/sdk/bus/slack-conversation.ts +5 -1
  133. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  134. package/src/sdk/cli/session-cli.ts +161 -5
  135. package/src/sdk/client/discovery.ts +3 -1
  136. package/src/sdk/host/control/dispatch.ts +3 -0
  137. package/src/sdk/host/host.ts +7 -0
  138. package/src/sdk/host/model-pin.ts +185 -25
  139. package/src/sdk/host/sdk-run-capability.ts +1 -0
  140. package/src/sdk/host/session-runtime.test.ts +1240 -107
  141. package/src/sdk/host/session-runtime.ts +1029 -115
  142. package/src/sdk/prompt-deadline-manager.ts +16 -1
  143. package/src/sdk/protocol/adapter-validation.ts +27 -0
  144. package/src/sdk/protocol/operation-inventory.generated.json +2 -2
  145. package/src/sdk/protocol/operation-registry.ts +4 -1
  146. package/src/sdk/router/session-router.ts +297 -111
  147. package/src/sdk/session.ts +83 -12
  148. package/src/session/account-inventory.ts +27 -11
  149. package/src/session/agent-session.ts +887 -95
  150. package/src/session/fold-coordinator.ts +433 -0
  151. package/src/session/internal/managed-session-storage.ts +64 -26
  152. package/src/session/sdk-run-capability-internal.ts +15 -0
  153. package/src/session/sdk-run-capability.ts +2 -0
  154. package/src/session/session-manager.ts +29 -10
  155. package/src/setup/provider-presets.json +1 -1
  156. package/src/slash-commands/helpers/credential-switch.ts +6 -1
  157. package/src/task/executor.ts +19 -3
  158. package/src/telemetry/control.ts +26 -0
  159. package/src/telemetry/events.ts +142 -0
  160. package/src/telemetry/index.ts +3 -0
  161. package/src/telemetry/transport.ts +87 -0
  162. package/src/tools/bash-interactive.ts +182 -85
  163. package/src/tools/bash.ts +786 -67
  164. package/src/tools/index.ts +7 -5
  165. package/src/tools/path-utils.ts +77 -29
  166. package/src/tools/tmux-self-injection-guard.ts +359 -0
  167. package/src/tools/tool-catalog.generated.ts +1 -1
  168. package/src/web/search/provider.ts +120 -11
  169. package/src/web/search/providers/anthropic.ts +89 -17
  170. package/src/web/search/providers/base.ts +9 -4
  171. package/src/web/search/providers/gemini.ts +87 -15
  172. package/src/web/search/providers/openai-compatible.ts +23 -8
  173. package/src/web/search/providers/xai.ts +98 -12
  174. package/src/web/search/types.ts +16 -0
@@ -1,7 +1,9 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import * as fsSync from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
+ import * as path from "node:path";
4
5
  import { getAgentDir } from "@gajae-code/utils";
6
+ import { repo as resolveGitRepository } from "../../utils/git";
5
7
  import { ensureBroker } from "../broker/ensure";
6
8
  import { lifecycleRequestTimeoutMs } from "../broker/startup-budget";
7
9
  import { SdkClientError } from "../client";
@@ -14,7 +16,11 @@ import type {
14
16
  SessionReconcileUncertainTarget,
15
17
  } from "../lifecycle/service";
16
18
  import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../prompt-status";
17
- import { validateAdapterControl, validateAdapterSecretFields } from "../protocol/adapter-validation";
19
+ import {
20
+ validateAdapterControl,
21
+ validateAdapterSecretFields,
22
+ validateRequiredPromptText,
23
+ } from "../protocol/adapter-validation";
18
24
  import { adapterDispositionError, findOperation, type OperationKind } from "../protocol/operation-registry";
19
25
  import { type SessionAttachment, SessionRouter, SessionRouterError, type SessionRouterFrame } from "../router";
20
26
  import { SessionListTraversalError, sessionListPageFromResponse, traverseSessionList } from "../session-list";
@@ -38,10 +44,12 @@ export type SdkSessionCliAction =
38
44
  | "status"
39
45
  | "tail"
40
46
  | "retire"
47
+ | "global"
41
48
  | "raw"
42
49
  | "control"
43
50
  | "query"
44
51
  | "global";
52
+ export type SdkSessionListScope = "repo" | "cwd" | "worktree" | "all";
45
53
  export type SdkSessionCliRawKind = "control" | "query" | "global";
46
54
 
47
55
  export interface SdkSessionCliArgs {
@@ -56,6 +64,7 @@ export interface SdkSessionCliArgs {
56
64
  jsonInputFile?: string;
57
65
  jsonInputStdin?: boolean;
58
66
  idempotencyKey?: string;
67
+ scope?: string;
59
68
  confirm?: boolean;
60
69
  cursor?: string;
61
70
  wait?: boolean;
@@ -358,16 +367,156 @@ async function sessionRows(agentDir: string): Promise<SessionRows> {
358
367
  });
359
368
  }
360
369
 
361
- async function runList(agentDir: string): Promise<unknown> {
370
+ const SESSION_LIST_SCOPES: readonly SdkSessionListScope[] = ["repo", "cwd", "worktree", "all"];
371
+ /** Row workspaces whose locator cannot be canonicalized still get one deterministic identity. */
372
+ const WORKSPACE_IDENTITY_CACHE_LIMIT = 4096;
373
+
374
+ interface WorkspaceIdentity {
375
+ /** Canonical workspace path (realpath when it exists, lexical resolve otherwise). */
376
+ canonicalPath: string;
377
+ /** Canonical containing worktree root; null outside a Git checkout. */
378
+ repoRoot: string | null;
379
+ /** Canonical Git common dir; shared by the main checkout and linked worktrees. */
380
+ commonDir: string | null;
381
+ }
382
+
383
+ export interface SdkSessionListSelection {
384
+ scope: SdkSessionListScope;
385
+ selection: WorkspaceIdentity;
386
+ /** Bounded, credential-free descriptor included in list output. */
387
+ descriptor: {
388
+ scope: SdkSessionListScope;
389
+ path: string;
390
+ worktreeRoot?: string;
391
+ commonDir?: string;
392
+ };
393
+ }
394
+
395
+ /** Parses `--scope`; missing means `repo`, anything else invalid is a usage error. */
396
+ export function parseSessionListScope(value: string | undefined): SdkSessionListScope {
397
+ if (value === undefined) return "repo";
398
+ if ((SESSION_LIST_SCOPES as readonly string[]).includes(value)) return value as SdkSessionListScope;
399
+ throw new SdkSessionCliError(
400
+ "usage",
401
+ `Invalid scope "${value}". Expected one of: ${SESSION_LIST_SCOPES.join(", ")}.`,
402
+ 2,
403
+ );
404
+ }
405
+
406
+ async function canonicalWorkspacePath(target: string): Promise<string> {
407
+ try {
408
+ return await fs.realpath(target);
409
+ } catch {
410
+ // A removed or unreadable workspace keeps a deterministic lexical identity
411
+ // instead of guessing a broader or narrower one.
412
+ return path.resolve(target);
413
+ }
414
+ }
415
+
416
+ const workspaceIdentityCache = new Map<string, WorkspaceIdentity>();
417
+
418
+ /** Resolves (and caches) the Git identity of one workspace locator. */
419
+ async function workspaceIdentity(target: string): Promise<WorkspaceIdentity> {
420
+ const canonicalPath = await canonicalWorkspacePath(target);
421
+ const cached = workspaceIdentityCache.get(canonicalPath);
422
+ if (cached) return cached;
423
+ if (target === "unknown") {
424
+ const unavailable = { canonicalPath, repoRoot: null, commonDir: null };
425
+ workspaceIdentityCache.set(canonicalPath, unavailable);
426
+ return unavailable;
427
+ }
428
+ try {
429
+ if (!(await fs.stat(target)).isDirectory()) throw new Error("workspace is not a directory");
430
+ } catch {
431
+ // A removed or unreadable locator cannot prove Git membership. Keeping it
432
+ // outside every identity is the narrowest safe result for row filtering.
433
+ const unavailable = { canonicalPath, repoRoot: null, commonDir: null };
434
+ workspaceIdentityCache.set(canonicalPath, unavailable);
435
+ return unavailable;
436
+ }
437
+ const repository = await resolveGitRepository.resolve(canonicalPath);
438
+ const identity: WorkspaceIdentity = repository
439
+ ? {
440
+ canonicalPath,
441
+ repoRoot: await canonicalWorkspacePath(repository.repoRoot),
442
+ commonDir: await canonicalWorkspacePath(repository.commonDir),
443
+ }
444
+ : { canonicalPath, repoRoot: null, commonDir: null };
445
+ if (workspaceIdentityCache.size >= WORKSPACE_IDENTITY_CACHE_LIMIT) workspaceIdentityCache.clear();
446
+ workspaceIdentityCache.set(canonicalPath, identity);
447
+ return identity;
448
+ }
449
+
450
+ /**
451
+ * Resolves the list selection from `--repo` (default: process cwd) under the
452
+ * effective scope. Outside Git, `repo`/`worktree` fail typed and actionable —
453
+ * they never broaden to `all`; `cwd` remains an exact canonical match.
454
+ */
455
+ export async function resolveSessionListSelection(
456
+ scope: SdkSessionListScope,
457
+ repoArg: string | undefined,
458
+ ): Promise<SdkSessionListSelection> {
459
+ const selection = await workspaceIdentity(repoArg ?? process.cwd());
460
+ if (scope !== "cwd" && !selection.repoRoot)
461
+ throw new SdkSessionCliError(
462
+ "not_a_repository",
463
+ `--scope ${scope} requires a Git repository, but "${selection.canonicalPath}" is outside any Git checkout. ` +
464
+ "Use --scope cwd for an exact workspace match or --scope all for the full broker listing.",
465
+ 1,
466
+ );
467
+ const descriptor: SdkSessionListSelection["descriptor"] = {
468
+ scope,
469
+ path: selection.canonicalPath,
470
+ ...(selection.repoRoot ? { worktreeRoot: selection.repoRoot, commonDir: selection.commonDir! } : {}),
471
+ };
472
+ return { scope, selection, descriptor };
473
+ }
474
+
475
+ /**
476
+ * Filters fully traversed broker rows by the selection scope. Row workspaces
477
+ * are canonicalized and cached per distinct locator; `repo` matches the shared
478
+ * common dir across the main checkout and linked worktrees, `worktree` the
479
+ * containing worktree only, and `cwd` the exact canonical workspace.
480
+ */
481
+ export async function filterSessionRowsByScope(
482
+ rows: readonly SdkSessionRowV1[],
483
+ scope: SdkSessionListScope,
484
+ selection: SdkSessionListSelection,
485
+ ): Promise<{ sessions: SdkSessionRowV1[]; warnings: string[] }> {
486
+ if (scope === "all") return { sessions: [...rows], warnings: [] };
487
+ const warnings: string[] = [];
488
+ const sessions: SdkSessionRowV1[] = [];
489
+ for (const row of rows) {
490
+ const identity = await workspaceIdentity(row.locator.repo);
491
+ let keep: boolean;
492
+ if (scope === "cwd") keep = identity.canonicalPath === selection.selection.canonicalPath;
493
+ else if (scope === "worktree")
494
+ keep = identity.repoRoot !== null && identity.repoRoot === selection.selection.repoRoot;
495
+ else keep = identity.commonDir !== null && identity.commonDir === selection.selection.commonDir;
496
+ if (keep) sessions.push(row);
497
+ else if (identity.repoRoot === null && identity.commonDir === null)
498
+ warnings.push(
499
+ `Session ${row.sessionId} workspace "${row.locator.repo}" is outside Git; excluded by scope ${scope}.`,
500
+ );
501
+ }
502
+ return { sessions, warnings };
503
+ }
504
+
505
+ async function runList(agentDir: string, args: SdkSessionCliArgs): Promise<unknown> {
506
+ const scope = parseSessionListScope(args.scope);
507
+ const { selection, descriptor } = await resolveSessionListSelection(scope, args.repo);
362
508
  const listing = await sessionRows(agentDir);
509
+ const filtered = await filterSessionRowsByScope(listing.sessions, scope, { scope, selection, descriptor });
363
510
  return {
364
511
  ok: true,
365
512
  result: {
366
513
  version: SESSION_ROWS_VERSION,
367
514
  source: "broker",
515
+ scope,
516
+ selection: descriptor,
368
517
  ...(listing.indexSeq === undefined ? {} : { indexSeq: listing.indexSeq }),
369
- sessions: listing.sessions,
370
- warnings: listing.warnings,
518
+ sessions: filtered.sessions,
519
+ warnings: [...listing.warnings, ...filtered.warnings],
371
520
  },
372
521
  };
373
522
  }
@@ -509,6 +658,8 @@ async function runSend(agentDir: string, sessionId: string, args: SdkSessionCliA
509
658
  if (args.text !== undefined && Object.keys(input).length > 0)
510
659
  throw new SdkSessionCliError("usage", "Use either --text or one JSON input source for the prompt, not both.", 2);
511
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);
512
663
  const inputRef = clientRefFromInput(promptInput);
513
664
  const clientRef = inputRef ?? args.opRef?.trim() ?? createOperationRef();
514
665
  if (args.opRef !== undefined && inputRef !== undefined && inputRef !== args.opRef.trim())
@@ -769,6 +920,11 @@ function publicationSequence(publicationId: string): number | undefined {
769
920
 
770
921
  function tailItemFromRouterFrame(frame: SessionRouterFrame): SdkTailItemV1 | undefined {
771
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;
772
928
  const seq = frame.publicationId === undefined ? undefined : publicationSequence(frame.publicationId);
773
929
  return toTailItemV1(
774
930
  {
@@ -1367,7 +1523,7 @@ export async function runSdkSessionCli(
1367
1523
  );
1368
1524
  const agentDir = args.agentDir ?? getAgentDir();
1369
1525
  if (action === "list") {
1370
- writeOutput(stripSecretFields(await runList(agentDir)));
1526
+ writeOutput(stripSecretFields(await runList(agentDir, args)));
1371
1527
  return;
1372
1528
  }
1373
1529
  if (action === "inspect") {
@@ -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."),
@@ -49,6 +49,8 @@ export interface SessionSdkHostOptions extends HostEndpointAdapters {
49
49
  query?: (connectionId: string, frame: SdkFrame) => unknown | Promise<unknown>;
50
50
  /** Test/lifecycle seam invoked synchronously before fire-and-forget dispatch. */
51
51
  onFrameAdmitted?: (connectionId: string, frame: SdkFrame) => void;
52
+ /** Synchronously rejects external input while a runtime is being replaced. */
53
+ onFrameAdmission?: (connectionId: string, frame: SdkFrame) => SdkFrame | undefined;
52
54
  /** Best-effort diagnostic observation of accepted control/query frames. */
53
55
  onRequest?: SdkRequestObserver;
54
56
  /** Runs before a control response is sent; identity transitions use sendTerminal. */
@@ -408,6 +410,11 @@ export class SessionSdkHost {
408
410
 
409
411
  async #onFrame(connectionId: string, frame: SdkFrame): Promise<void> {
410
412
  try {
413
+ const admissionRejection = this.#options.onFrameAdmission?.(connectionId, frame);
414
+ if (admissionRejection !== undefined) {
415
+ await this.#send(connectionId, admissionRejection);
416
+ return;
417
+ }
411
418
  switch (frame.type) {
412
419
  case "control_request": {
413
420
  this.#observeRequest("control", connectionId, frame);
@@ -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";