@gajae-code/coding-agent 0.18.5 → 0.18.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 (91) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/dist/types/cli-commands.d.ts +10 -0
  3. package/dist/types/cli-ordinary.d.ts +13 -0
  4. package/dist/types/cli.d.ts +12 -3
  5. package/dist/types/commands/contribution-prep.d.ts +0 -3
  6. package/dist/types/commands/sdk.d.ts +3 -0
  7. package/dist/types/commands/update.d.ts +1 -0
  8. package/dist/types/config/model-profile-activation.d.ts +19 -0
  9. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  10. package/dist/types/edit/modes/replace.d.ts +3 -3
  11. package/dist/types/edit/streaming.d.ts +2 -2
  12. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  13. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  14. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  15. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  16. package/dist/types/sdk/broker/runtime.d.ts +1 -1
  17. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  18. package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
  19. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  20. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  21. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  22. package/dist/types/sdk/index.d.ts +1 -0
  23. package/dist/types/sdk/router/session-router.d.ts +2 -0
  24. package/dist/types/sdk/session.d.ts +13 -0
  25. package/dist/types/session/agent-session.d.ts +5 -1
  26. package/dist/types/tools/ask-contract.d.ts +75 -254
  27. package/dist/types/tools/bash.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/scripts/build-sdk-package-smoke.ts +5 -0
  30. package/src/cli/public-command-entry.ts +5 -0
  31. package/src/cli/ssh-cli.ts +6 -3
  32. package/src/cli/web-search-cli.ts +9 -3
  33. package/src/cli-commands.ts +14 -0
  34. package/src/cli-ordinary.ts +197 -0
  35. package/src/cli.ts +25 -182
  36. package/src/commands/contribution-prep.ts +1 -3
  37. package/src/commands/sdk.ts +59 -0
  38. package/src/commands/update.ts +15 -1
  39. package/src/config/file-lock.ts +91 -2
  40. package/src/config/model-profile-activation.ts +70 -4
  41. package/src/config/model-profile-contract.ts +4 -1
  42. package/src/config/model-profiles.ts +23 -31
  43. package/src/config/model-resolver.ts +9 -1
  44. package/src/coordinator-mcp/event-webhook.ts +19 -14
  45. package/src/coordinator-mcp/policy.ts +14 -4
  46. package/src/coordinator-mcp/server.ts +142 -7
  47. package/src/coordinator-mcp/session-reaper.ts +9 -1
  48. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  49. package/src/edit/modes/replace.ts +6 -6
  50. package/src/edit/streaming.ts +15 -15
  51. package/src/exec/bash-shell-supervisor.ts +60 -3
  52. package/src/internal-urls/docs-index.generated.ts +10 -9
  53. package/src/memories/index.ts +8 -2
  54. package/src/modes/acp/acp-agent.ts +649 -367
  55. package/src/modes/components/tool-execution.ts +1 -1
  56. package/src/prompts/tools/ask.md +2 -0
  57. package/src/sdk/broker/broker.ts +46 -0
  58. package/src/sdk/broker/daemon-entry.ts +67 -6
  59. package/src/sdk/broker/discovery.ts +8 -0
  60. package/src/sdk/broker/ensure.ts +156 -20
  61. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  62. package/src/sdk/broker/lifecycle.ts +225 -341
  63. package/src/sdk/broker/runtime.ts +5 -1
  64. package/src/sdk/broker/transport.ts +5 -1
  65. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  66. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  67. package/src/sdk/bus/telegram-daemon.ts +34 -1
  68. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  69. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  70. package/src/sdk/host/control/dispatch.ts +52 -4
  71. package/src/sdk/host/control/operations.ts +1 -1
  72. package/src/sdk/host/session-runtime.test.ts +40 -2
  73. package/src/sdk/host/session-runtime.ts +13 -5
  74. package/src/sdk/index.ts +1 -0
  75. package/src/sdk/lifecycle-session.ts +1 -0
  76. package/src/sdk/protocol/operation-registry.ts +1 -1
  77. package/src/sdk/router/session-router.ts +7 -1
  78. package/src/sdk/session.ts +37 -8
  79. package/src/session/agent-session.ts +114 -39
  80. package/src/session/blob-store.ts +78 -33
  81. package/src/session/internal/managed-session-storage.ts +96 -3
  82. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  83. package/src/setup/hermes-setup.ts +4 -1
  84. package/src/slash-commands/builtin-registry.ts +8 -5
  85. package/src/tools/ask-contract.test.ts +76 -1
  86. package/src/tools/ask-contract.ts +57 -6
  87. package/src/tools/ask.ts +16 -7
  88. package/src/tools/bash.ts +38 -29
  89. package/src/tools/descriptors.test.ts +29 -0
  90. package/src/tools/tool-catalog.generated.ts +7 -4
  91. package/src/web/search/index.ts +2 -2
@@ -93,6 +93,7 @@ const IDEMPOTENCY_TTL_MS = 15 * 60 * 1_000;
93
93
  const MAX_IDEMPOTENCY_ENTRIES = 256;
94
94
 
95
95
  const sessionChains = new WeakMap<ControlSurface, Promise<void>>();
96
+ const pendingReplacementBarriers = new WeakMap<ControlSurface, Promise<void>>();
96
97
  interface IdempotencyEntry {
97
98
  hash: string;
98
99
  expiresAt: number;
@@ -189,6 +190,19 @@ function invalidInput(message: string): never {
189
190
  throw new TypedControlError("invalid_input", message);
190
191
  }
191
192
 
193
+ const EXPECTED_SDK_RUN_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}:[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$/;
194
+
195
+ function enqueueReplacementBarrier(surface: ControlSurface): () => void {
196
+ const previous = pendingReplacementBarriers.get(surface) ?? Promise.resolve();
197
+ const { promise: current, resolve: resolveCurrent } = Promise.withResolvers<void>();
198
+ const barrier = previous.then(() => current);
199
+ pendingReplacementBarriers.set(surface, barrier);
200
+ return () => {
201
+ resolveCurrent();
202
+ if (pendingReplacementBarriers.get(surface) === barrier) pendingReplacementBarriers.delete(surface);
203
+ };
204
+ }
205
+
192
206
  /**
193
207
  * C04 `turn.abort` dispatch.
194
208
  *
@@ -244,7 +258,11 @@ function invoke(
244
258
  case "turn.prompt":
245
259
  return surface.prompt(text(input), input.images, input.clientRef as string | undefined);
246
260
  case "turn.steer":
247
- return surface.steer(text(input), typeof input.clientRef === "string" ? input.clientRef : undefined);
261
+ return surface.steer(
262
+ text(input),
263
+ typeof input.clientRef === "string" ? input.clientRef : undefined,
264
+ Object.hasOwn(input, "expectedSdkRunToken") ? (input.expectedSdkRunToken as string) : undefined,
265
+ );
248
266
  case "turn.follow_up":
249
267
  return surface.followUp(text(input));
250
268
  case "turn.abort":
@@ -486,6 +504,15 @@ export function dispatchControl(
486
504
  );
487
505
  if (!isInput(request.input))
488
506
  return Promise.resolve(failure(request.id, "invalid_input", "Control input must be an object."));
507
+ if (
508
+ row.sdkId === "turn.steer" &&
509
+ Object.hasOwn(request.input, "expectedSdkRunToken") &&
510
+ (typeof request.input.expectedSdkRunToken !== "string" ||
511
+ !EXPECTED_SDK_RUN_TOKEN_PATTERN.test(request.input.expectedSdkRunToken))
512
+ )
513
+ return Promise.resolve(
514
+ failure(request.id, "invalid_input", "expectedSdkRunToken must be a commandId:turnId pair."),
515
+ );
489
516
  if (
490
517
  row.sdkId === "turn.abort" &&
491
518
  ((brokerAbortFieldPresent && !brokerAbortAuthorized) ||
@@ -538,10 +565,31 @@ export function dispatchControl(
538
565
  const work = () => execute(surface, row, dispatchRequest);
539
566
  if (row.sdkId === "turn.abort_and_prompt") {
540
567
  const cancellable = surface as PreflightCancellableSurface;
541
- if (Object.hasOwn(cancellable, "cancelPendingPreflights")) cancellable.cancelPendingPreflights?.();
542
- return serialize(surface, work);
568
+ const releaseBarrier = enqueueReplacementBarrier(surface);
569
+ try {
570
+ if (Object.hasOwn(cancellable, "cancelPendingPreflights")) cancellable.cancelPendingPreflights?.();
571
+ return serialize(surface, async () => {
572
+ try {
573
+ return await work();
574
+ } finally {
575
+ releaseBarrier();
576
+ }
577
+ });
578
+ } catch (error) {
579
+ releaseBarrier();
580
+ throw error;
581
+ }
543
582
  }
544
583
  if (row.idempotency === "idempotent" && dispatchRequest.idempotencyKey)
545
584
  return idempotent(surface, row, dispatchRequest, work);
546
- return row.idempotency === "ordered" && row.sdkId !== "retry.now" ? serialize(surface, work) : work();
585
+ if (row.sdkId === "turn.steer") {
586
+ const replacementBarrier = pendingReplacementBarriers.get(surface);
587
+ return replacementBarrier ? replacementBarrier.then(work) : work();
588
+ }
589
+ // Active feedback must reach the worker even while an earlier ordered control
590
+ // waits for that worker. Correlated steer admission remains deduplicated by
591
+ // the runtime reconciliation store, independently of this dispatch chain.
592
+ return row.idempotency === "ordered" && row.sdkId !== "retry.now" && row.sdkId !== "turn.steer"
593
+ ? serialize(surface, work)
594
+ : work();
547
595
  }
@@ -22,7 +22,7 @@ export type ControlInput = Record<string, unknown>;
22
22
  */
23
23
  export interface ControlSurface {
24
24
  prompt(text: string, images?: ControlValue, clientRef?: string): Promise<ControlValue> | ControlValue;
25
- steer(text: string, clientRef?: string): Promise<ControlValue> | ControlValue;
25
+ steer(text: string, clientRef?: string, expectedSdkRunToken?: string): Promise<ControlValue> | ControlValue;
26
26
  followUp(text: string): Promise<ControlValue> | ControlValue;
27
27
  abort(): Promise<ControlValue> | ControlValue;
28
28
  /** Terminal abort: stop the current root turn (and optionally exact owned work). */
@@ -290,7 +290,7 @@ test("session.last_assistant returns the latest projected readable text past non
290
290
  });
291
291
  });
292
292
 
293
- test("session.last_assistant returns resource_gone when the projected transcript has no readable assistant text", async () => {
293
+ test("session.last_assistant returns null when the projected transcript has no readable assistant text", async () => {
294
294
  const sessionId = "last-assistant-empty";
295
295
  const ctx = extensionContext(sessionId, "/tmp", {
296
296
  transcript: [
@@ -311,7 +311,7 @@ test("session.last_assistant returns resource_gone when the projected transcript
311
311
  ],
312
312
  });
313
313
 
314
- expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({ ok: false, error: { code: "resource_gone" } });
314
+ expect(await queryLastAssistant(ctx, sessionId)).toMatchObject({ ok: true, page: { items: [null] } });
315
315
  });
316
316
 
317
317
  test("native prompt reconciliation fails closed for an explicitly empty assistant result", () => {
@@ -4142,6 +4142,7 @@ describe("SessionSdkSessionRuntime", () => {
4142
4142
  interface PreflightHooks {
4143
4143
  onPreflightAccepted?: () => void;
4144
4144
  onPreflightAcceptCommit?: () => void | Promise<void>;
4145
+ expectedSdkRunToken?: string;
4145
4146
  }
4146
4147
 
4147
4148
  interface ResponseFrame {
@@ -4482,6 +4483,43 @@ function neverSettlingPromise(): Promise<void> {
4482
4483
  return Promise.withResolvers<void>().promise;
4483
4484
  }
4484
4485
 
4486
+ test("SDK turn.steer preserves its expected run token and propagates a stale-run rejection", async () => {
4487
+ const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-turn-steer-run-token-"));
4488
+ const staleToken = "ended-command:ended-turn";
4489
+ const clientRef = "stale-steer-ref";
4490
+ const calls: Array<{ content: unknown; deliverAs?: string; expectedSdkRunToken?: string }> = [];
4491
+ let harness: InvocationHarness | undefined;
4492
+ try {
4493
+ harness = await invocationHarness("steer-run-token", cwd, {
4494
+ sendUserMessage: async (content, options) => {
4495
+ calls.push({
4496
+ content,
4497
+ deliverAs: options?.deliverAs,
4498
+ expectedSdkRunToken: options?.expectedSdkRunToken,
4499
+ });
4500
+ if (options?.expectedSdkRunToken === staleToken)
4501
+ throw Object.assign(new Error("The expected SDK run is not active."), { code: "turn_not_active" });
4502
+ return "completed";
4503
+ },
4504
+ });
4505
+
4506
+ const rejected = await harness.control("turn.steer", {
4507
+ text: "stale steer",
4508
+ expectedSdkRunToken: staleToken,
4509
+ clientRef,
4510
+ });
4511
+ expect(rejected).toMatchObject({ ok: false, error: { code: "turn_not_active" } });
4512
+ expect(calls).toEqual([{ content: "stale steer", deliverAs: "steer", expectedSdkRunToken: staleToken }]);
4513
+ expect(await harness.query("turn.steer_status", { clientRef })).toMatchObject({
4514
+ ok: true,
4515
+ result: { status: "rejected" },
4516
+ });
4517
+ } finally {
4518
+ if (harness) await harness.stop();
4519
+ await rm(cwd, { recursive: true, force: true });
4520
+ }
4521
+ });
4522
+
4485
4523
  describe("post-acceptance invocation terminalization", () => {
4486
4524
  test.each([
4487
4525
  { status: 402, code: "provider_http_402" },
@@ -2059,7 +2059,7 @@ function createQuerySurface(
2059
2059
  : entry.content?.flatMap(block => (block.type === "text" ? [block.text] : [])).join("\n");
2060
2060
  if (text !== undefined && text.trim().length > 0) return text;
2061
2061
  }
2062
- return undefined;
2062
+ return null;
2063
2063
  };
2064
2064
  const getProfileCredentialSessionId = () => ctx.credentialSessionId ?? id;
2065
2065
  const profileSettings = (options.settings ?? ctx.settings) as Pick<Settings, "get"> | undefined;
@@ -2914,6 +2914,7 @@ function createControlSurface(
2914
2914
  };
2915
2915
  type InternalSendOptions = NonNullable<Parameters<ExtensionAPI["sendUserMessage"]>[1]> & {
2916
2916
  sdkRunCapability?: SdkRunCapability;
2917
+ expectedSdkRunToken?: string;
2917
2918
  };
2918
2919
  type InternalSdkApi = Omit<ExtensionAPI, "sendUserMessage"> & {
2919
2920
  sendUserMessage: (
@@ -4243,23 +4244,30 @@ function createControlSurface(
4243
4244
  ),
4244
4245
  );
4245
4246
  },
4246
- steer: async (text, clientRef) => {
4247
+ steer: async (text, clientRef, expectedSdkRunToken) => {
4247
4248
  const invalid = validateRequiredPromptText("turn.steer", { text });
4248
4249
  if (invalid) throw Object.assign(new Error(invalid.message), { code: invalid.code });
4249
4250
  const retainedClientRef = normalizeClientRef(clientRef);
4251
+ const sendSteer = () =>
4252
+ sendSdkUserMessage(text, {
4253
+ deliverAs: "steer",
4254
+ ...(expectedSdkRunToken === undefined ? {} : { expectedSdkRunToken }),
4255
+ });
4250
4256
  if (retainedClientRef === undefined) {
4251
4257
  const correlation = newCorrelation();
4252
- await sendSdkUserMessage(text, { deliverAs: "steer" });
4258
+ await sendSteer();
4253
4259
  return { accepted: true, ...correlation };
4254
4260
  }
4255
4261
  const durable = steerReconciliation;
4256
4262
  const reservation = await durable.reserveSteer(retainedClientRef, text);
4257
4263
  if (reservation.replay) return { accepted: reservation.result.status === "accepted", ...reservation.result };
4258
4264
  try {
4259
- await sendSdkUserMessage(text, { deliverAs: "steer" });
4265
+ await sendSteer();
4260
4266
  return { accepted: true, ...(await durable.settleSteer(retainedClientRef, "accepted")) };
4261
4267
  } catch (error) {
4262
- return { accepted: false, ...(await durable.settleSteer(retainedClientRef, "rejected", error)) };
4268
+ const settled = await durable.settleSteer(retainedClientRef, "rejected", error);
4269
+ if (expectedSdkRunToken !== undefined) throw error;
4270
+ return { accepted: false, ...settled };
4263
4271
  }
4264
4272
  },
4265
4273
  followUp: async text => {
package/src/sdk/index.ts CHANGED
@@ -21,6 +21,7 @@ export type {
21
21
  TrackedSendUserMessageOptions,
22
22
  } from "../session/agent-session";
23
23
  export * as bus from "./bus";
24
+ export * from "./diagnostics/observe-broker";
24
25
  export * as host from "./host";
25
26
  export * as lifecycle from "./lifecycle";
26
27
  export * as mcp from "./mcp";
@@ -123,6 +123,7 @@ export async function createLifecycleAgentSession(
123
123
  // inside the broker's readiness window is what kills the child at the
124
124
  // cutoff; the host resumes it once readiness is published.
125
125
  deferMemoryBackendStartup: true,
126
+ deferOptionalModelRefresh: true,
126
127
  [lifecycleStartupCapabilityOption]: capability,
127
128
  ...(mcpStartupTimeoutMs !== undefined ? { [lifecycleMcpStartupTimeoutOption]: mcpStartupTimeoutMs } : {}),
128
129
  } as CreateAgentSessionOptions & {
@@ -267,7 +267,7 @@ function controlErrors(id: string): string[] {
267
267
  // refuses a session with no selected model before admission. The code is
268
268
  // appended: ACP machine-only mapping reads errorCodes[0].
269
269
  C01: ["client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed", "model_not_selected"],
270
- C02: ["client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed"],
270
+ C02: ["turn_not_active", "client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed"],
271
271
  C09: ["client_ref_conflict", "reconciliation_capacity", "reconciliation_persist_failed"],
272
272
  C06: ["action_claimed"],
273
273
  C07: ["action_claimed", "terminal_uncertain"],
@@ -287,6 +287,8 @@ export interface SessionRouterOptions {
287
287
  agentDir: string;
288
288
  /** Limits attachment to exact ids selected by a Broker-scoped operation. */
289
289
  sessionIds?: readonly string[];
290
+ /** Dynamically limits attachment to sessions currently owned by the caller. */
291
+ attachFilter?: (sessionId: string) => boolean;
290
292
  /** Marks default SDK clients as notification-only observers; demanding by default. */
291
293
  observer?: boolean;
292
294
  deps?: SessionRouterDeps;
@@ -615,6 +617,7 @@ type AdoptedSession = {
615
617
  export class SessionRouter {
616
618
  readonly #agentDir: string;
617
619
  readonly #sessionIds: ReadonlySet<string> | undefined;
620
+ readonly #attachFilter: ((sessionId: string) => boolean) | undefined;
618
621
  readonly #observer: boolean;
619
622
  readonly #deps: SessionRouterDeps;
620
623
  readonly #correlateFrame: SessionRouterFrameCorrelator;
@@ -657,6 +660,7 @@ export class SessionRouter {
657
660
  constructor(options: SessionRouterOptions) {
658
661
  this.#agentDir = options.agentDir;
659
662
  this.#sessionIds = options.sessionIds === undefined ? undefined : new Set(options.sessionIds);
663
+ this.#attachFilter = options.attachFilter;
660
664
  this.#observer = options.observer === true;
661
665
  this.#deps = options.deps ?? {};
662
666
  this.#correlateFrame = options.correlateFrame ?? fallbackCorrelation;
@@ -797,6 +801,7 @@ export class SessionRouter {
797
801
  if (
798
802
  sessionId !== fallback.sessionId ||
799
803
  (this.#sessionIds !== undefined && !this.#sessionIds.has(sessionId ?? "")) ||
804
+ (this.#attachFilter !== undefined && !this.#attachFilter(sessionId ?? "")) ||
800
805
  endpointGeneration === undefined ||
801
806
  pid === undefined ||
802
807
  endpointMtimeMs === undefined ||
@@ -1294,7 +1299,8 @@ export class SessionRouter {
1294
1299
  session.live &&
1295
1300
  isSessionAuthorityEligible(session) &&
1296
1301
  !session.terminalUncertain &&
1297
- (this.#sessionIds === undefined || this.#sessionIds.has(session.sessionId)),
1302
+ (this.#sessionIds === undefined || this.#sessionIds.has(session.sessionId)) &&
1303
+ (this.#attachFilter === undefined || this.#attachFilter(session.sessionId)),
1298
1304
  );
1299
1305
  const liveIds = new Set(live.map(session => session.sessionId));
1300
1306
  const attachedIds = new Set<string>();
@@ -59,7 +59,11 @@ import { loadCapability, reset as resetCapabilities } from "../capability";
59
59
  import { type Rule, ruleCapability, setActiveRules } from "../capability/rule";
60
60
  import type { SourceMeta } from "../capability/types";
61
61
  import { AUTOROUTING_INACTIVE_WARNING } from "../config/autorouting-contract";
62
- import { ModelProfileCredentialError, resolveMissingSessionModelRecovery } from "../config/model-profile-activation";
62
+ import {
63
+ isSessionCredentialPinBlocking,
64
+ ModelProfileCredentialError,
65
+ resolveMissingSessionModelRecovery,
66
+ } from "../config/model-profile-activation";
63
67
  import { resolveModelProfileName } from "../config/model-profile-contract";
64
68
  import { resolveProfileBindings } from "../config/model-profiles";
65
69
  import { kNoAuth, ModelRegistry } from "../config/model-registry";
@@ -577,6 +581,12 @@ export interface CreateAgentSessionOptions {
577
581
  * @internal CLI-only ordering guard; SDK callers retain immediate startup by default.
578
582
  */
579
583
  deferMemoryBackendStartup?: boolean;
584
+ /**
585
+ * Skip optional model-catalog discovery during lifecycle startup. ACP hosts must
586
+ * publish their endpoint before unrelated provider credential refreshes begin.
587
+ * @internal lifecycle-only startup guard.
588
+ */
589
+ deferOptionalModelRefresh?: boolean;
580
590
 
581
591
  /** Enable LSP integration (tool, formatting, diagnostics, warmup). Default: true */
582
592
  enableLsp?: boolean;
@@ -695,6 +705,22 @@ export interface CreateAgentSessionResult {
695
705
  gjcRuntimeSnapshot?: GjcRuntimeSnapshotProvider;
696
706
  }
697
707
 
708
+ /**
709
+ * Start optional provider/model discovery only when it is safe to add work to
710
+ * the caller's startup graph. Lifecycle hosts deliberately skip this during
711
+ * construction: discovery preflights every configured provider and may refresh
712
+ * an unrelated OAuth credential while several hosts are opening the same DB.
713
+ */
714
+ export function startOptionalModelRefresh(
715
+ modelRegistry: Pick<ModelRegistry, "refreshInBackground">,
716
+ credentialSessionId: string | undefined,
717
+ deferred: boolean,
718
+ ): boolean {
719
+ if (deferred) return false;
720
+ modelRegistry.refreshInBackground("online-if-uncached", credentialSessionId);
721
+ return true;
722
+ }
723
+
698
724
  export interface DeferredMcpConfigStartupResult {
699
725
  loadedToolCount: number;
700
726
  hasErrors: boolean;
@@ -1970,11 +1996,15 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1970
1996
  options.preferredCredentialSelector.selector,
1971
1997
  );
1972
1998
  }
1999
+ // A runtime key or this registry's literal models.yml apiKey outranks an
2000
+ // unavailable pin; without one the provider stays blocked (never retargeted).
2001
+ const isCredentialPinBlocking = (provider: string): boolean =>
2002
+ isSessionCredentialPinBlocking(modelRegistry, provider, credentialSessionId);
1973
2003
  const modelApiKeyAvailability = new Map<string, boolean>();
1974
2004
  const getModelAvailabilityKey = (candidate: Model): string =>
1975
2005
  `${candidate.provider}\u0000${candidate.baseUrl ?? ""}`;
1976
2006
  const hasModelApiKey = async (candidate: Model): Promise<boolean> => {
1977
- if (authStorage.hasSessionCredentialUnavailable(candidate.provider, credentialSessionId)) return false;
2007
+ if (isCredentialPinBlocking(candidate.provider)) return false;
1978
2008
  const availabilityKey = getModelAvailabilityKey(candidate);
1979
2009
  const cached = modelApiKeyAvailability.get(availabilityKey);
1980
2010
  if (cached !== undefined) {
@@ -2000,6 +2030,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2000
2030
  const key = await modelRegistry
2001
2031
  .getApiKey(candidate, credentialSessionId, { credentialSelector })
2002
2032
  .catch(error => {
2033
+ if (isCredentialPinBlocking(candidate.provider)) return undefined;
2003
2034
  if (credentialSelector) {
2004
2035
  logger.debug("Credential selector did not match model availability candidate", {
2005
2036
  provider: candidate.provider,
@@ -2148,7 +2179,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2148
2179
  settings,
2149
2180
  );
2150
2181
  await refreshMissingQualifiedModelProviders(startupModelSelectors, modelRegistry, credentialSessionId);
2151
- modelRegistry.refreshInBackground("online-if-uncached", credentialSessionId);
2182
+ startOptionalModelRefresh(modelRegistry, credentialSessionId, options.deferOptionalModelRefresh === true);
2152
2183
  }
2153
2184
 
2154
2185
  const hasExplicitModel = options.model !== undefined || options.modelPattern !== undefined;
@@ -2241,8 +2272,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2241
2272
  {
2242
2273
  managedFallback: defaultModelEntries.length > 1,
2243
2274
  canonicalSessionId: providerSessionId,
2244
- isCredentialUnavailable: provider =>
2245
- authStorage.hasSessionCredentialUnavailable(provider, credentialSessionId),
2275
+ isCredentialUnavailable: isCredentialPinBlocking,
2246
2276
  ...(persistedProfileOwnsDefault ? { aliasIntent: "preset-equivalent" as const } : {}),
2247
2277
  },
2248
2278
  );
@@ -2297,7 +2327,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2297
2327
  !hasExplicitModel &&
2298
2328
  !model &&
2299
2329
  defaultRoleSpec.model &&
2300
- !authStorage.hasSessionCredentialUnavailable(defaultRoleSpec.model.provider, credentialSessionId) &&
2330
+ !isCredentialPinBlocking(defaultRoleSpec.model.provider) &&
2301
2331
  (!preferredCredentialProvider || defaultRoleSpec.model.provider === preferredCredentialProvider)
2302
2332
  ) {
2303
2333
  const settingsDefaultModel = defaultRoleSpec.model;
@@ -4236,8 +4266,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
4236
4266
  {
4237
4267
  managedFallback: defaultModelEntries.length > 1,
4238
4268
  canonicalSessionId: providerSessionId,
4239
- isCredentialUnavailable: provider =>
4240
- authStorage.hasSessionCredentialUnavailable(provider, credentialSessionId),
4269
+ isCredentialUnavailable: isCredentialPinBlocking,
4241
4270
  ...(persistedProfileOwnsDefault ? { aliasIntent: "preset-equivalent" as const } : {}),
4242
4271
  },
4243
4272
  );