@gajae-code/coding-agent 0.18.1 → 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 (128) hide show
  1. package/CHANGELOG.md +161 -0
  2. package/dist/types/cli/stats-cli.d.ts +0 -5
  3. package/dist/types/cli-commands.d.ts +10 -0
  4. package/dist/types/cli-ordinary.d.ts +13 -0
  5. package/dist/types/cli.d.ts +12 -3
  6. package/dist/types/commands/contribution-prep.d.ts +0 -3
  7. package/dist/types/commands/sdk.d.ts +3 -0
  8. package/dist/types/commands/update.d.ts +1 -0
  9. package/dist/types/config/autorouting-tier-map.d.ts +29 -2
  10. package/dist/types/config/model-profile-activation.d.ts +19 -0
  11. package/dist/types/config/model-registry.d.ts +16 -1
  12. package/dist/types/coordinator-mcp/session-reaper.d.ts +5 -0
  13. package/dist/types/edit/modes/replace.d.ts +3 -3
  14. package/dist/types/edit/streaming.d.ts +2 -2
  15. package/dist/types/exa/mcp-client.d.ts +1 -5
  16. package/dist/types/exec/bash-shell-guardian.d.ts +26 -0
  17. package/dist/types/exec/bash-shell-supervisor.d.ts +12 -0
  18. package/dist/types/hashline/parser.d.ts +28 -5
  19. package/dist/types/modes/acp/acp-agent.d.ts +6 -1
  20. package/dist/types/sdk/broker/discovery.d.ts +8 -0
  21. package/dist/types/sdk/broker/ensure.d.ts +1 -0
  22. package/dist/types/sdk/broker/lifecycle-ledger.d.ts +4 -0
  23. package/dist/types/sdk/broker/lifecycle.d.ts +6 -1
  24. package/dist/types/sdk/broker/recovery-backoff.d.ts +62 -0
  25. package/dist/types/sdk/broker/runtime.d.ts +21 -1
  26. package/dist/types/sdk/broker/startup-budget.d.ts +5 -1
  27. package/dist/types/sdk/bus/index.d.ts +1 -0
  28. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  29. package/dist/types/sdk/bus/telegram-daemon.d.ts +1 -0
  30. package/dist/types/sdk/cli/diagnostics-cli.d.ts +18 -0
  31. package/dist/types/sdk/diagnostics/observe-broker.d.ts +185 -0
  32. package/dist/types/sdk/host/control/operations.d.ts +1 -1
  33. package/dist/types/sdk/host/session-runtime.d.ts +7 -0
  34. package/dist/types/sdk/index.d.ts +1 -0
  35. package/dist/types/sdk/prompt-watchdog.d.ts +1 -1
  36. package/dist/types/sdk/router/session-router.d.ts +2 -0
  37. package/dist/types/sdk/session.d.ts +13 -0
  38. package/dist/types/session/agent-session.d.ts +5 -1
  39. package/dist/types/tools/ask-contract.d.ts +75 -254
  40. package/dist/types/tools/bash.d.ts +1 -0
  41. package/package.json +7 -7
  42. package/scripts/build-sdk-package-smoke.ts +5 -0
  43. package/src/cli/public-command-entry.ts +5 -0
  44. package/src/cli/ssh-cli.ts +6 -3
  45. package/src/cli/stats-cli.ts +4 -36
  46. package/src/cli/web-search-cli.ts +9 -3
  47. package/src/cli-commands.ts +14 -0
  48. package/src/cli-main.ts +4 -8
  49. package/src/cli-ordinary.ts +197 -0
  50. package/src/cli.ts +25 -186
  51. package/src/commands/commit.ts +2 -2
  52. package/src/commands/contribution-prep.ts +1 -3
  53. package/src/commands/sdk.ts +60 -2
  54. package/src/commands/update.ts +15 -1
  55. package/src/config/autorouting-tier-map.ts +20 -2
  56. package/src/config/file-lock.ts +91 -2
  57. package/src/config/model-preset-registry.ts +17 -2
  58. package/src/config/model-profile-activation.ts +73 -5
  59. package/src/config/model-profile-contract.ts +4 -1
  60. package/src/config/model-profiles.ts +29 -29
  61. package/src/config/model-registry.ts +45 -17
  62. package/src/config/model-resolver.ts +9 -1
  63. package/src/coordinator-mcp/event-webhook.ts +19 -14
  64. package/src/coordinator-mcp/policy.ts +14 -4
  65. package/src/coordinator-mcp/server.ts +142 -7
  66. package/src/coordinator-mcp/session-reaper.ts +9 -1
  67. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
  68. package/src/edit/modes/replace.ts +6 -6
  69. package/src/edit/streaming.ts +15 -15
  70. package/src/exa/mcp-client.ts +41 -8
  71. package/src/exec/bash-shell-guardian.ts +184 -34
  72. package/src/exec/bash-shell-supervisor.ts +60 -3
  73. package/src/hashline/execute.ts +51 -3
  74. package/src/hashline/parser.ts +63 -16
  75. package/src/internal-urls/docs-index.generated.ts +29 -28
  76. package/src/memories/index.ts +8 -2
  77. package/src/modes/acp/acp-agent.ts +688 -371
  78. package/src/modes/components/tool-execution.ts +1 -1
  79. package/src/modes/controllers/ssh-command-controller.ts +3 -1
  80. package/src/prompts/tools/ask.md +2 -0
  81. package/src/sdk/broker/broker.ts +46 -0
  82. package/src/sdk/broker/daemon-entry.ts +67 -6
  83. package/src/sdk/broker/discovery.ts +8 -0
  84. package/src/sdk/broker/ensure.ts +156 -20
  85. package/src/sdk/broker/lifecycle-ledger.ts +110 -12
  86. package/src/sdk/broker/lifecycle.ts +108 -34
  87. package/src/sdk/broker/recovery-backoff.ts +135 -0
  88. package/src/sdk/broker/runtime.ts +88 -1
  89. package/src/sdk/broker/startup-budget.ts +6 -1
  90. package/src/sdk/broker/transport.ts +5 -1
  91. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  92. package/src/sdk/bus/index.ts +305 -19
  93. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  94. package/src/sdk/bus/telegram-daemon-control.ts +15 -12
  95. package/src/sdk/bus/telegram-daemon.ts +34 -1
  96. package/src/sdk/cli/diagnostics-cli.ts +247 -0
  97. package/src/sdk/diagnostics/observe-broker.ts +654 -0
  98. package/src/sdk/host/control/dispatch.ts +52 -4
  99. package/src/sdk/host/control/operations.ts +1 -1
  100. package/src/sdk/host/session-runtime.test.ts +40 -2
  101. package/src/sdk/host/session-runtime.ts +112 -8
  102. package/src/sdk/index.ts +1 -0
  103. package/src/sdk/lifecycle-session.ts +1 -0
  104. package/src/sdk/prompt-watchdog.ts +17 -4
  105. package/src/sdk/protocol/operation-registry.ts +1 -1
  106. package/src/sdk/router/session-router.ts +7 -1
  107. package/src/sdk/session.ts +37 -8
  108. package/src/session/agent-session.ts +128 -41
  109. package/src/session/blob-store.ts +78 -33
  110. package/src/session/internal/managed-session-storage.ts +119 -3
  111. package/src/setup/hermes/templates/operator-instructions.v1.md +19 -1
  112. package/src/setup/hermes-setup.ts +4 -1
  113. package/src/slash-commands/builtin-registry.ts +8 -5
  114. package/src/tools/ask-contract.test.ts +76 -1
  115. package/src/tools/ask-contract.ts +57 -6
  116. package/src/tools/ask.ts +16 -7
  117. package/src/tools/bash.ts +77 -29
  118. package/src/tools/descriptors.test.ts +29 -0
  119. package/src/tools/tool-catalog.generated.ts +7 -4
  120. package/src/web/search/index.ts +2 -2
  121. package/dist/types/cli/grep-cli.d.ts +0 -17
  122. package/dist/types/cli/shell-cli.d.ts +0 -8
  123. package/dist/types/commands/grep.d.ts +0 -42
  124. package/dist/types/commands/shell.d.ts +0 -21
  125. package/src/cli/grep-cli.ts +0 -160
  126. package/src/cli/shell-cli.ts +0 -176
  127. package/src/commands/grep.ts +0 -48
  128. package/src/commands/shell.ts +0 -29
@@ -610,7 +610,7 @@ async function classifyFileLockRemovalTransition(
610
610
  if (!observation) return "active";
611
611
  const info = parseLockInfoBytes(observation.bytes);
612
612
  if (info) {
613
- const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds);
613
+ const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds, undefined, true);
614
614
  return stale.stale ? "abandoned" : "active";
615
615
  }
616
616
 
@@ -698,6 +698,74 @@ async function adoptOrphanedFileLockRemovalTransition(lockPath: string, orphanAg
698
698
  return false;
699
699
  }
700
700
 
701
+ async function adoptAbandonedFileLockRemovalTransition(
702
+ lockPath: string,
703
+ ownerHostId?: string,
704
+ previousOwnerHostIds: readonly string[] = [],
705
+ ): Promise<boolean> {
706
+ const transitionPath = fileLockRemovalTransitionPath(lockPath);
707
+ const stale = await staleLockSnapshot(transitionPath, 0, ownerHostId, previousOwnerHostIds, undefined, true);
708
+ if (!stale.stale || !stale.identity) return false;
709
+ if (!(await isNativeExactRemovalUsable())) return false;
710
+
711
+ let captured: NativeDirectoryTreeResult;
712
+ try {
713
+ captured = nativeFileLockBindings().snapshotDirectoryTree(transitionPath);
714
+ } catch (error) {
715
+ if (isTransientReleaseError(error)) return false;
716
+ throw error;
717
+ }
718
+ if (!captured.ok || !captured.snapshot) return false;
719
+ const snapshot = captured.snapshot;
720
+ const root = snapshot.entries.find(entry => entry.relativePath === "");
721
+ const infoEntry = snapshot.entries.find(entry => entry.relativePath === "info");
722
+ if (
723
+ root?.kind !== "directory" ||
724
+ infoEntry?.kind !== "file" ||
725
+ snapshot.entries.some(
726
+ entry =>
727
+ entry.relativePath !== "" &&
728
+ entry.relativePath !== "info" &&
729
+ entry.kind !== "directory" &&
730
+ (entry.kind !== "file" || entry.size !== "0"),
731
+ )
732
+ )
733
+ return false;
734
+ const currentIdentity = await captureFileLockDirIdentity(transitionPath);
735
+ if (!currentIdentity || !sameStableFileLockIdentity(currentIdentity, stale.identity)) return false;
736
+ if (
737
+ !nativeFileLockInfoMatchesStableIdentity(snapshot.rootDev, snapshot.rootIno, infoEntry, stale.identity) ||
738
+ !nativeFileLockInfoMatchesContentEvidence(infoEntry, stale.identity)
739
+ )
740
+ return false;
741
+
742
+ let removal: NativeExactUnlinkResult;
743
+ try {
744
+ removal = nativeFileLockBindings().exactRemoveDirectoryTree(transitionPath, snapshot);
745
+ } catch (error) {
746
+ if (isTransientReleaseError(error)) return false;
747
+ throw error;
748
+ }
749
+ if (removal.ok === true) return removal.code === undefined && Object.keys(removal).every(key => key === "ok");
750
+ if (
751
+ removal.ok === false &&
752
+ removal.code === "cleanup_pending" &&
753
+ removal.payloadDurable === true &&
754
+ removal.detachedPath !== undefined &&
755
+ path.resolve(removal.detachedPath) === path.resolve(transitionPath) &&
756
+ removal.retainedSuccessorPath === undefined &&
757
+ removal.retainedPlaceholderPath === undefined &&
758
+ removal.retainedUnknownPath === undefined &&
759
+ Object.keys(removal).every(key => ["ok", "code", "payloadDurable", "detachedPath"].includes(key))
760
+ )
761
+ return await removeDetachedLockQuarantineOnDisk(transitionPath, snapshot.rootDev, snapshot.rootIno);
762
+ return (
763
+ removal.ok === false &&
764
+ removal.code === "not_found" &&
765
+ Object.keys(removal).every(key => key === "ok" || key === "code")
766
+ );
767
+ }
768
+
701
769
  function sameFileLockTreeAfterPublication(
702
770
  staged: NativeDirectoryTreeSnapshot,
703
771
  published: NativeDirectoryTreeSnapshot,
@@ -1488,6 +1556,14 @@ async function staleLockSnapshot(
1488
1556
  ownerHostId?: string,
1489
1557
  previousOwnerHostIds: readonly string[] = [],
1490
1558
  startTimeCache?: Map<string, string | null>,
1559
+ // `.removing` transitions are internal cleanup state this codebase's own removal
1560
+ // machinery produced, never a live holder's public identity: a hostless transition
1561
+ // record predates host-id stamping and is reclaimable by local liveness proof alone,
1562
+ // regardless of the acquirer's own host id. Active `<file>.lock` holders keep the
1563
+ // stricter fail-closed rule (see `lockRecordIsForeignHost`) because that record can
1564
+ // belong to a genuinely foreign process; only the two transition-specific callers
1565
+ // below set this.
1566
+ hostlessTransitionIsLocal = false,
1491
1567
  ): Promise<LockStaleSnapshot> {
1492
1568
  // Capture the root and info inode BEFORE asking whether the owner is stale. A later
1493
1569
  // snapshot alone would let a copied successor inherit the stale verdict's authority.
@@ -1521,7 +1597,8 @@ async function staleLockSnapshot(
1521
1597
  // A host-qualified lock may only be reclaimed after proving that its owner is
1522
1598
  // local. Foreign and malformed host-qualified records fail closed: PID values
1523
1599
  // and clocks are not meaningful across hosts.
1524
- if (lockRecordIsForeignHost(info, ownerHostId, previousOwnerHostIds)) return { stale: false };
1600
+ const foreignCheckHostId = hostlessTransitionIsLocal && info.owner_host_id === undefined ? undefined : ownerHostId;
1601
+ if (lockRecordIsForeignHost(info, foreignCheckHostId, previousOwnerHostIds)) return { stale: false };
1525
1602
  if (ownerIncarnationChanged(info, startTimeCache)) {
1526
1603
  if (!judgedIdentity) return { stale: false };
1527
1604
  let currentIdentity: GenericFileLockDirIdentity | null;
@@ -1752,6 +1829,18 @@ async function tryAcquireLock(
1752
1829
  }
1753
1830
  if (transitionState === "orphan_transition")
1754
1831
  return { kind: "orphan_transition", path: fileLockRemovalTransitionPath(destinationPath) };
1832
+ if (transitionState === "abandoned") {
1833
+ if (await adoptAbandonedFileLockRemovalTransition(destinationPath, ownerHostId, previousOwnerHostIds)) {
1834
+ transitionState = await classifyFileLockRemovalTransition(
1835
+ destinationPath,
1836
+ orphanTransitionAgeMs,
1837
+ ownerHostId,
1838
+ previousOwnerHostIds,
1839
+ );
1840
+ } else {
1841
+ return null;
1842
+ }
1843
+ }
1755
1844
  if (transitionState !== null) return null;
1756
1845
  const staged = snapshotDirectoryTree(canonicalPendingPath);
1757
1846
  if (!staged.ok || !staged.snapshot) {
@@ -652,6 +652,9 @@ function assertRegistryIdentityPolicy(value: unknown): void {
652
652
  }
653
653
 
654
654
  function assertCanonicalUnicode(value: string): void {
655
+ // Native well-formedness check first; the scan below only runs to name the
656
+ // offending surrogate when the string is malformed.
657
+ if (value.isWellFormed()) return;
655
658
  for (let index = 0; index < value.length; index++) {
656
659
  const code = value.charCodeAt(index);
657
660
  if (code >= 0xd800 && code <= 0xdbff) {
@@ -665,6 +668,18 @@ function assertCanonicalUnicode(value: string): void {
665
668
  }
666
669
  }
667
670
 
671
+ /** Characters JSON.stringify escapes in strings (lone surrogates are rejected earlier). */
672
+ const JSON_STRING_ESCAPES = /["\\\u0000-\u001f]/;
673
+
674
+ /**
675
+ * Quote a string exactly as JSON.stringify does. Registry documents are almost
676
+ * entirely plain ASCII identifiers, so skipping the per-call JSON.stringify for
677
+ * strings with nothing to escape removes most of the serializer's cost.
678
+ */
679
+ function quoteCanonicalString(value: string): string {
680
+ return JSON_STRING_ESCAPES.test(value) ? JSON.stringify(value) : `"${value}"`;
681
+ }
682
+
668
683
  function serializeCanonicalJson(value: unknown, stack: Set<object>): string {
669
684
  if (value === null) return "null";
670
685
  if (typeof value === "boolean") return value ? "true" : "false";
@@ -674,7 +689,7 @@ function serializeCanonicalJson(value: unknown, stack: Set<object>): string {
674
689
  }
675
690
  if (typeof value === "string") {
676
691
  assertCanonicalUnicode(value);
677
- return JSON.stringify(value);
692
+ return quoteCanonicalString(value);
678
693
  }
679
694
  if (typeof value !== "object") throw new TypeError(`Canonical JSON rejects ${typeof value}.`);
680
695
  if (stack.has(value)) throw new TypeError("Canonical JSON rejects cycles.");
@@ -694,7 +709,7 @@ function serializeCanonicalJson(value: unknown, stack: Set<object>): string {
694
709
  const entries: string[] = [];
695
710
  for (const key of Object.keys(record).sort()) {
696
711
  assertCanonicalUnicode(key);
697
- entries.push(`${JSON.stringify(key)}:${serializeCanonicalJson(record[key], stack)}`);
712
+ entries.push(`${quoteCanonicalString(key)}:${serializeCanonicalJson(record[key], stack)}`);
698
713
  }
699
714
  return `{${entries.join(",")}}`;
700
715
  } finally {
@@ -17,6 +17,7 @@ import {
17
17
  export { resolveModelProfileName } from "./model-profile-contract";
18
18
 
19
19
  import {
20
+ createRegistrySelectorIndex,
20
21
  GJC_MODEL_ASSIGNMENT_TARGETS,
21
22
  type GjcModelAssignmentTargetId,
22
23
  isAuthenticated,
@@ -114,6 +115,7 @@ export interface PrepareModelProfileActivationOptions {
114
115
  >
115
116
  > & {
116
117
  getError?: ModelRegistry["getError"];
118
+ authStorage?: ModelRegistry["authStorage"];
117
119
  };
118
120
  settings: Pick<Settings, "get" | "getGlobal" | "getOverride">;
119
121
  profileName: string;
@@ -474,6 +476,53 @@ export class ModelProfileCredentialError extends Error {
474
476
  }
475
477
  }
476
478
 
479
+ interface SessionPinRegistry {
480
+ authStorage?: {
481
+ hasRuntimeApiKey(provider: string): boolean;
482
+ hasLiteralConfigApiKey(provider: string, owner?: object): boolean;
483
+ hasSessionCredentialUnavailable(provider: string, scopeId?: string): boolean;
484
+ };
485
+ getAuthStorageOwner?(): object;
486
+ }
487
+
488
+ /**
489
+ * Whether an unavailable session credential pin blocks `provider`.
490
+ *
491
+ * Mirrors `AuthStorage.getApiKey` precedence: a runtime `--api-key` override or a literal
492
+ * `models.yml` `apiKey` registered for this registry's owner is resolved before the
493
+ * unavailable-pin marker, so either one keeps the provider usable. An `apiKeyEnv` key does
494
+ * not: `getApiKey` prefers another stored api_key account over it, which would silently
495
+ * retarget the unavailable pin.
496
+ */
497
+ export function isSessionCredentialPinBlocking(
498
+ registry: SessionPinRegistry,
499
+ provider: string,
500
+ sessionId: string | undefined,
501
+ ): boolean {
502
+ const authStorage = registry.authStorage;
503
+ if (!authStorage) return false;
504
+ if (authStorage.hasRuntimeApiKey(provider)) return false;
505
+ if (authStorage.hasLiteralConfigApiKey(provider, registry.getAuthStorageOwner?.())) return false;
506
+ return authStorage.hasSessionCredentialUnavailable(provider, sessionId);
507
+ }
508
+
509
+ async function getProfileProviderApiKey(
510
+ registry: PrepareModelProfileActivationOptions["modelRegistry"],
511
+ provider: string,
512
+ sessionId: string,
513
+ profileLabel: string,
514
+ ): Promise<string | undefined> {
515
+ const pinBlocking = () => isSessionCredentialPinBlocking(registry, provider, sessionId);
516
+ if (pinBlocking()) throw new ModelProfileCredentialError(profileLabel, [provider]);
517
+ try {
518
+ return await registry.getApiKeyForProvider(provider, sessionId);
519
+ } catch (error) {
520
+ // OAuth selection can invalidate a pin while the credential probe is running.
521
+ if (pinBlocking()) throw new ModelProfileCredentialError(profileLabel, [provider]);
522
+ throw error;
523
+ }
524
+ }
525
+
477
526
  export function formatModelProfileCredentialError(profileLabel: string, providers: readonly string[]): string {
478
527
  return `Model profile "${profileLabel}" requires credentials for: ${providers.join(", ")}. Run /login and configure the missing provider(s), then retry.`;
479
528
  }
@@ -675,6 +724,7 @@ export async function resolveMissingSessionModelRecovery(options: {
675
724
  options.skips.every(skip => skip.reason === "unknown_model");
676
725
  if (!allSelectorsUnknown) return undefined;
677
726
  const fullCatalog = options.modelRegistry.getAll();
727
+ const catalogIndex = createRegistrySelectorIndex(fullCatalog);
678
728
  const savedSelectorsMissingFromCatalog = resolveConfiguredModelPatterns(
679
729
  options.defaultEntries,
680
730
  options.settings,
@@ -688,7 +738,7 @@ export async function resolveMissingSessionModelRecovery(options: {
688
738
  }).model
689
739
  )
690
740
  return false;
691
- return !registrySelectorResolvesToModel(selector, fullCatalog);
741
+ return !registrySelectorResolvesToModel(selector, catalogIndex);
692
742
  });
693
743
  const savedConcreteDefault = options.savedDefault ? parseModelString(options.savedDefault) : undefined;
694
744
  const savedConcreteDefaultMissingFromCatalog =
@@ -760,7 +810,12 @@ export async function resolveModelProfileDefaultChain(options: {
760
810
  ])) {
761
811
  let apiKey: string | undefined;
762
812
  try {
763
- apiKey = await options.modelRegistry.getApiKeyForProvider(provider, options.credentialSessionId);
813
+ apiKey = await getProfileProviderApiKey(
814
+ options.modelRegistry,
815
+ provider,
816
+ options.credentialSessionId,
817
+ profileLabel,
818
+ );
764
819
  } catch (error) {
765
820
  if (requiredProviderSet.has(provider) && !alternativeSet.has(provider)) throw error;
766
821
  continue;
@@ -785,7 +840,12 @@ export async function resolveModelProfileDefaultChain(options: {
785
840
  const proxyApiKey =
786
841
  proxyProvider === undefined
787
842
  ? undefined
788
- : await options.modelRegistry.getApiKeyForProvider(proxyProvider, options.credentialSessionId);
843
+ : await getProfileProviderApiKey(
844
+ options.modelRegistry,
845
+ proxyProvider,
846
+ options.credentialSessionId,
847
+ profileLabel,
848
+ );
789
849
  if (
790
850
  proxyProvider !== undefined &&
791
851
  !isModelProfileProxyConfigured(proxyProvider, configuredProviderIds, proxyApiKey === kNoAuth)
@@ -884,6 +944,8 @@ export async function resolveModelProfileDefaultChain(options: {
884
944
  aliasIntent: "preset-equivalent",
885
945
  canonicalSessionId: null,
886
946
  credentialSessionId: options.credentialSessionId,
947
+ isCredentialUnavailable: provider =>
948
+ isSessionCredentialPinBlocking(options.modelRegistry, provider, options.credentialSessionId),
887
949
  },
888
950
  );
889
951
  return { profileName, entries: defaultChain, ...resolution };
@@ -1081,6 +1143,8 @@ async function resolveAndClampSelectorValue(
1081
1143
  aliasIntent: options.aliasIntent,
1082
1144
  canonicalSessionId: options.sessionId,
1083
1145
  credentialSessionId: options.credentialSessionId,
1146
+ isCredentialUnavailable: provider =>
1147
+ isSessionCredentialPinBlocking(options.modelRegistry, provider, options.credentialSessionId),
1084
1148
  },
1085
1149
  );
1086
1150
  resolved = {
@@ -1211,6 +1275,8 @@ async function concretizeProfileSelectorValue(
1211
1275
  aliasIntent: "preset-equivalent",
1212
1276
  canonicalSessionId: prepared.session.sessionId,
1213
1277
  credentialSessionId,
1278
+ isCredentialUnavailable: provider =>
1279
+ isSessionCredentialPinBlocking(prepared.modelRegistry, provider, credentialSessionId),
1214
1280
  },
1215
1281
  )
1216
1282
  : resolveModelRoleValue(selector, candidates, {
@@ -1320,7 +1386,7 @@ export async function prepareModelProfileActivation(
1320
1386
  for (const provider of authenticationProbeProviders) {
1321
1387
  let apiKey: string | undefined;
1322
1388
  try {
1323
- apiKey = await options.modelRegistry.getApiKeyForProvider(provider, credentialSessionId);
1389
+ apiKey = await getProfileProviderApiKey(options.modelRegistry, provider, credentialSessionId, profileLabel);
1324
1390
  } catch (error) {
1325
1391
  if (requiredProviderSet.has(provider) && !alternativeSet.has(provider)) throw error;
1326
1392
  continue;
@@ -1355,7 +1421,7 @@ export async function prepareModelProfileActivation(
1355
1421
  const proxyApiKey =
1356
1422
  proxyProvider === undefined
1357
1423
  ? undefined
1358
- : await options.modelRegistry.getApiKeyForProvider(proxyProvider, credentialSessionId);
1424
+ : await getProfileProviderApiKey(options.modelRegistry, proxyProvider, credentialSessionId, profileLabel);
1359
1425
  if (proxyProvider !== undefined) {
1360
1426
  const configuredProxyProviders = options.modelRegistry.getConfiguredProviderIds?.();
1361
1427
  if (!isModelProfileProxyConfigured(proxyProvider, configuredProxyProviders, proxyApiKey === kNoAuth)) {
@@ -1460,6 +1526,8 @@ export async function prepareModelProfileActivation(
1460
1526
  aliasIntent: "preset-equivalent",
1461
1527
  canonicalSessionId: options.session.sessionId,
1462
1528
  credentialSessionId,
1529
+ isCredentialUnavailable: provider =>
1530
+ isSessionCredentialPinBlocking(options.modelRegistry, provider, credentialSessionId),
1463
1531
  },
1464
1532
  );
1465
1533
  const defaultModel = defaultResolution.model;
@@ -4,7 +4,10 @@ export const MODEL_PROFILE_DISCOVERY_QUERY = "models.profiles.list";
4
4
  export const MODEL_PROFILE_ERROR_DETAIL_MAX_BYTES = 2048;
5
5
  const REQUESTED_PROFILE_MAX_BYTES = 256;
6
6
 
7
- const LEGACY_MODEL_PROFILE_ALIASES: ReadonlyMap<string, string> = new Map([["codex-standard", "codex-medium"]]);
7
+ const LEGACY_MODEL_PROFILE_ALIASES: ReadonlyMap<string, string> = new Map([
8
+ ["codex-standard", "codex-medium"],
9
+ ["codex-sol61", "codex-pro"],
10
+ ]);
8
11
 
9
12
  export interface ModelProfileCatalogItem {
10
13
  id: string;
@@ -73,25 +73,25 @@ const profile = (
73
73
 
74
74
  export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
75
75
  profile("codex-eco", ["openai-codex"], {
76
- default: "openai-codex/gpt-5.6-terra:low",
76
+ default: "openai-codex/gpt-6-luna:low",
77
77
  executor: "openai-codex/gpt-6-luna:low",
78
78
  planner: "openai-codex/gpt-6-luna:high",
79
- critic: "openai-codex/gpt-5.6-terra:xhigh",
80
- architect: "openai-codex/gpt-5.6-terra:high",
79
+ critic: "openai-codex/gpt-6-luna:xhigh",
80
+ architect: "openai-codex/gpt-6-luna:high",
81
81
  }),
82
82
  profile("codex-medium", ["openai-codex"], {
83
- default: "openai-codex/gpt-6-sol:low",
84
- executor: "openai-codex/gpt-5.6-terra:low",
85
- planner: "openai-codex/gpt-5.6-terra:high",
86
- critic: "openai-codex/gpt-6-sol:xhigh",
87
- architect: "openai-codex/gpt-6-sol:high",
83
+ default: "openai-codex/gpt-6.1-sol:low",
84
+ executor: "openai-codex/gpt-6.1-sol:low",
85
+ planner: "openai-codex/gpt-6.1-sol:high",
86
+ critic: "openai-codex/gpt-6.1-sol:xhigh",
87
+ architect: "openai-codex/gpt-6.1-sol:high",
88
88
  }),
89
89
  profile("codex-pro", ["openai-codex"], {
90
- default: "openai-codex/gpt-6-sol:medium",
91
- executor: "openai-codex/gpt-5.6-terra:medium",
92
- planner: "openai-codex/gpt-6-sol:high",
93
- critic: "openai-codex/gpt-6-sol:max",
94
- architect: "openai-codex/gpt-6-sol:xhigh",
90
+ default: "openai-codex/gpt-6.1-sol:medium",
91
+ executor: "openai-codex/gpt-6.1-sol:medium",
92
+ planner: "openai-codex/gpt-6.1-sol:high",
93
+ critic: "openai-codex/gpt-6.1-sol:max",
94
+ architect: "openai-codex/gpt-6.1-sol:xhigh",
95
95
  }),
96
96
  profile("lunamaxxing", ["openai-codex"], {
97
97
  default: "openai-codex/gpt-6-luna:medium",
@@ -104,20 +104,20 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
104
104
  default: "openai-codex/gpt-6-astra:medium",
105
105
  executor: "openai-codex/gpt-6-luna:xhigh",
106
106
  planner: "openai-codex/gpt-6-astra:xhigh",
107
- critic: "openai-codex/gpt-6-sol:high",
108
- architect: "openai-codex/gpt-5.6-terra:xhigh",
107
+ critic: "openai-codex/gpt-6.1-sol:high",
108
+ architect: "openai-codex/gpt-6.1-sol:xhigh",
109
109
  }),
110
110
  profile("astra-default", ["openai-codex"], {
111
111
  default: "openai-codex/gpt-6-astra:medium",
112
112
  executor: "openai-codex/gpt-6-luna:max",
113
113
  planner: "openai-codex/gpt-6-astra:xhigh",
114
- critic: "openai-codex/gpt-6-sol:xhigh",
115
- architect: "openai-codex/gpt-5.6-terra:xhigh",
114
+ critic: "openai-codex/gpt-6.1-sol:xhigh",
115
+ architect: "openai-codex/gpt-6.1-sol:xhigh",
116
116
  }),
117
117
  profile("astra-heavy", ["openai-codex"], {
118
118
  default: "openai-codex/gpt-6-astra:medium",
119
- executor: "openai-codex/gpt-6-sol:max",
120
- planner: "openai-codex/gpt-6-sol:max",
119
+ executor: "openai-codex/gpt-6.1-sol:max",
120
+ planner: "openai-codex/gpt-6.1-sol:max",
121
121
  critic: "openai-codex/gpt-6-astra:max",
122
122
  architect: "openai-codex/gpt-6-astra:max",
123
123
  }),
@@ -256,14 +256,14 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
256
256
  }),
257
257
  profile("claude-opus", ["anthropic"], {
258
258
  default: ["anthropic/claude-opus-5-5:medium", "anthropic/claude-opus-4-6:xhigh"],
259
- executor: "anthropic/claude-sonnet-5",
259
+ executor: "anthropic/claude-sonnet-5-5",
260
260
  planner: ["anthropic/claude-opus-5-5:medium", "anthropic/claude-opus-4-6:low"],
261
261
  critic: ["anthropic/claude-opus-5-5:high", "anthropic/claude-opus-4-6:high"],
262
262
  architect: ["anthropic/claude-opus-5-5:high", "anthropic/claude-opus-4-6:xhigh"],
263
263
  }),
264
264
  profile("claude-fable", ["anthropic"], {
265
265
  default: "anthropic/claude-fable-5-1:xhigh",
266
- executor: "anthropic/claude-sonnet-5",
266
+ executor: "anthropic/claude-sonnet-5-5",
267
267
  planner: "anthropic/claude-fable-5-1:low",
268
268
  critic: "anthropic/claude-fable-5-1:high",
269
269
  architect: "anthropic/claude-fable-5-1:xhigh",
@@ -490,24 +490,24 @@ export const BUILTIN_MODEL_PROFILES: readonly ModelProfileDefinition[] = [
490
490
  }),
491
491
  profile("opus-codex", ["anthropic", "openai-codex"], {
492
492
  default: "anthropic/claude-opus-5-5:medium",
493
- executor: "openai-codex/gpt-5.6-terra:low",
494
- planner: "anthropic/claude-sonnet-5",
495
- critic: "openai-codex/gpt-6-sol:xhigh",
496
- architect: "openai-codex/gpt-6-sol:high",
493
+ executor: "openai-codex/gpt-6.1-sol:low",
494
+ planner: "anthropic/claude-sonnet-5-5",
495
+ critic: "openai-codex/gpt-6.1-sol:xhigh",
496
+ architect: "openai-codex/gpt-6.1-sol:high",
497
497
  }),
498
498
  profile("codex-opencodego", ["openai-codex", "opencode-go"], {
499
- default: "openai-codex/gpt-6-sol:low",
499
+ default: "openai-codex/gpt-6.1-sol:low",
500
500
  executor: "opencode-go/deepseek-v4-pro",
501
501
  planner: "opencode-go/kimi-k3",
502
502
  critic: "opencode-go/mimo-v2.5-pro",
503
- architect: "openai-codex/gpt-6-sol:high",
503
+ architect: "openai-codex/gpt-6.1-sol:high",
504
504
  }),
505
505
  profile("fable-opus-codex", ["anthropic", "openai-codex"], {
506
506
  default: "anthropic/claude-fable-5-1:high",
507
- executor: "openai-codex/gpt-5.6-terra:medium",
507
+ executor: "openai-codex/gpt-6.1-sol:medium",
508
508
  planner: "anthropic/claude-opus-5-5:medium",
509
509
  critic: "anthropic/claude-opus-5-5:high",
510
- architect: "openai-codex/gpt-6-sol:xhigh",
510
+ architect: "openai-codex/gpt-6.1-sol:xhigh",
511
511
  }),
512
512
  profile("astra-fable", ["openai-codex", "anthropic"], {
513
513
  default: "openai-codex/gpt-6-astra:medium",
@@ -782,26 +782,53 @@ function registryModelMetadataWithoutApiSpecificFields(model: Model<Api>): Parti
782
782
  return metadata;
783
783
  }
784
784
 
785
- export function registrySelectorResolvesToModel(selector: string, models: readonly Model<Api>[]): boolean {
785
+ /**
786
+ * Lowercased lookup keys for a model catalog, so selector resolution is a set
787
+ * lookup instead of a scan that re-lowercases every model id per selector.
788
+ */
789
+ export interface RegistrySelectorIndex {
790
+ /** `id` and `provider/id`. */
791
+ exact: ReadonlySet<string>;
792
+ /** `id` alone. */
793
+ ids: ReadonlySet<string>;
794
+ /** Model ids per provider, for parsed `provider/id` selectors (nested, so no delimiter can collide). */
795
+ providerIds: ReadonlyMap<string, ReadonlySet<string>>;
796
+ /** Every `/`-delimited suffix of each id (`a/b/c` → `b/c`, `c`). */
797
+ idSuffixes: ReadonlySet<string>;
798
+ }
799
+
800
+ export function createRegistrySelectorIndex(models: readonly Model<Api>[]): RegistrySelectorIndex {
801
+ const exact = new Set<string>();
802
+ const ids = new Set<string>();
803
+ const providerIds = new Map<string, Set<string>>();
804
+ const idSuffixes = new Set<string>();
805
+ for (const model of models) {
806
+ const id = model.id.toLowerCase();
807
+ const provider = model.provider.toLowerCase();
808
+ ids.add(id);
809
+ exact.add(id);
810
+ exact.add(`${provider}/${id}`);
811
+ let providerModelIds = providerIds.get(provider);
812
+ if (!providerModelIds) {
813
+ providerModelIds = new Set<string>();
814
+ providerIds.set(provider, providerModelIds);
815
+ }
816
+ providerModelIds.add(id);
817
+ for (let slash = id.indexOf("/"); slash !== -1; slash = id.indexOf("/", slash + 1)) {
818
+ idSuffixes.add(id.slice(slash + 1));
819
+ }
820
+ }
821
+ return { exact, ids, providerIds, idSuffixes };
822
+ }
823
+
824
+ export function registrySelectorResolvesToModel(selector: string, index: RegistrySelectorIndex): boolean {
786
825
  const normalizedSelector = selector.trim().toLowerCase();
787
- if (
788
- models.some(
789
- model =>
790
- model.id.toLowerCase() === normalizedSelector ||
791
- `${model.provider}/${model.id}`.toLowerCase() === normalizedSelector,
792
- )
793
- )
794
- return true;
826
+ if (index.exact.has(normalizedSelector)) return true;
795
827
  const suffix = splitSelectorThinkingSuffix(normalizedSelector);
796
828
  const baseSelector = suffix.thinkingLevel === undefined ? normalizedSelector : suffix.selector;
797
829
  const parsed = parseModelString(baseSelector);
798
- if (parsed)
799
- return models.some(
800
- model => model.provider.toLowerCase() === parsed.provider && model.id.toLowerCase() === parsed.id,
801
- );
802
- return models.some(
803
- model => model.id.toLowerCase() === baseSelector || model.id.toLowerCase().endsWith(`/${baseSelector}`),
804
- );
830
+ if (parsed) return index.providerIds.get(parsed.provider)?.has(parsed.id) ?? false;
831
+ return index.ids.has(baseSelector) || index.idSuffixes.has(baseSelector);
805
832
  }
806
833
 
807
834
  function filterMaterializedRegistryProfiles(
@@ -810,6 +837,7 @@ function filterMaterializedRegistryProfiles(
810
837
  dynamicProviders: ReadonlySet<string>,
811
838
  ): Map<string, ModelProfileDefinition> {
812
839
  const filtered = new Map<string, ModelProfileDefinition>();
840
+ const index = createRegistrySelectorIndex(models);
813
841
  for (const [name, profile] of profiles) {
814
842
  if (
815
843
  profile.source === "registry" &&
@@ -818,7 +846,7 @@ function filterMaterializedRegistryProfiles(
818
846
  return (
819
847
  selectors.length > 0 &&
820
848
  !selectors.some(selector => {
821
- if (registrySelectorResolvesToModel(selector, models)) return true;
849
+ if (registrySelectorResolvesToModel(selector, index)) return true;
822
850
  const suffix = splitSelectorThinkingSuffix(selector);
823
851
  const parsed = parseModelString(suffix.thinkingLevel ? suffix.selector : selector);
824
852
  return parsed !== undefined && dynamicProviders.has(parsed.provider);
@@ -1189,7 +1189,15 @@ async function resolveModelChainEntries(
1189
1189
  skips.push({ selector, reason: "credential_unavailable" });
1190
1190
  break;
1191
1191
  }
1192
- const key = await modelRegistry.getApiKey(candidate.model, credentialSessionId, { signal: options?.signal });
1192
+ let key: string | undefined;
1193
+ try {
1194
+ key = await modelRegistry.getApiKey(candidate.model, credentialSessionId, { signal: options?.signal });
1195
+ } catch (error) {
1196
+ // OAuth selection can invalidate a pin after the preflight check.
1197
+ if (!options?.isCredentialUnavailable?.(candidate.model.provider)) throw error;
1198
+ skips.push({ selector, reason: "credential_unavailable" });
1199
+ break;
1200
+ }
1193
1201
  if (isAuthenticatedOrKeyless(key)) {
1194
1202
  return { ...candidate, activeIndex, skips };
1195
1203
  }
@@ -116,6 +116,17 @@ function webhookEnvValue(explicitEnv: NodeJS.ProcessEnv | undefined, name: strin
116
116
  return $credentialEnv(name)?.trim() || undefined;
117
117
  }
118
118
 
119
+ // Number.parseInt reads a leading digit prefix ("1.5" -> 1, "1e4" -> 1,
120
+ // "10s" -> 10), which would silently turn a timeout into one millisecond or a
121
+ // retry budget into a single attempt. Only plain decimal digits count; anything
122
+ // else falls back to the default, matching the coordinator policy env values.
123
+ function boundedWebhookEnvInt(raw: string | undefined, fallback: number, max: number): number {
124
+ if (raw === undefined || !/^\d+$/.test(raw)) return fallback;
125
+ const parsed = Number.parseInt(raw, 10);
126
+ // An oversized digit string still means "as large as allowed", so it caps.
127
+ return parsed > 0 ? Math.min(parsed, max) : fallback;
128
+ }
129
+
119
130
  export function parseEventWebhookConfig(explicitEnv?: NodeJS.ProcessEnv): EventWebhookConfig | null {
120
131
  const rawUrl = webhookEnvValue(explicitEnv, "GJC_COORDINATOR_MCP_EVENT_WEBHOOK_URL");
121
132
  if (!rawUrl) return null;
@@ -139,22 +150,16 @@ export function parseEventWebhookConfig(explicitEnv?: NodeJS.ProcessEnv): EventW
139
150
  : null;
140
151
  const tokenFile = webhookEnvValue(explicitEnv, "GJC_COORDINATOR_MCP_EVENT_WEBHOOK_TOKEN_FILE") ?? "";
141
152
  if (tokenFile !== "" && !path.isAbsolute(tokenFile)) throw new Error("coordinator_event_webhook_token_file_invalid");
142
- const timeoutRaw = Number.parseInt(
143
- webhookEnvValue(explicitEnv, "GJC_COORDINATOR_MCP_EVENT_WEBHOOK_TIMEOUT_MS") ?? "",
144
- 10,
153
+ const timeoutMs = boundedWebhookEnvInt(
154
+ webhookEnvValue(explicitEnv, "GJC_COORDINATOR_MCP_EVENT_WEBHOOK_TIMEOUT_MS"),
155
+ DEFAULT_EVENT_WEBHOOK_TIMEOUT_MS,
156
+ MAX_EVENT_WEBHOOK_TIMEOUT_MS,
145
157
  );
146
- const timeoutMs =
147
- Number.isFinite(timeoutRaw) && timeoutRaw > 0
148
- ? Math.min(timeoutRaw, MAX_EVENT_WEBHOOK_TIMEOUT_MS)
149
- : DEFAULT_EVENT_WEBHOOK_TIMEOUT_MS;
150
- const attemptsRaw = Number.parseInt(
151
- webhookEnvValue(explicitEnv, "GJC_COORDINATOR_MCP_EVENT_WEBHOOK_MAX_ATTEMPTS") ?? "",
152
- 10,
158
+ const maxAttempts = boundedWebhookEnvInt(
159
+ webhookEnvValue(explicitEnv, "GJC_COORDINATOR_MCP_EVENT_WEBHOOK_MAX_ATTEMPTS"),
160
+ DEFAULT_EVENT_WEBHOOK_MAX_ATTEMPTS,
161
+ MAX_EVENT_WEBHOOK_MAX_ATTEMPTS,
153
162
  );
154
- const maxAttempts =
155
- Number.isFinite(attemptsRaw) && attemptsRaw > 0
156
- ? Math.min(attemptsRaw, MAX_EVENT_WEBHOOK_MAX_ATTEMPTS)
157
- : DEFAULT_EVENT_WEBHOOK_MAX_ATTEMPTS;
158
163
  return { url: rawUrl, tokenFile: tokenFile || null, sessionIds, timeoutMs, maxAttempts };
159
164
  }
160
165
 
@@ -86,9 +86,19 @@ const LEGACY_MUTATION_CLASS_ALIASES = new Map<string, CoordinatorMutationClass>(
86
86
  ["report", "reports"],
87
87
  ]);
88
88
 
89
+ // Number.parseInt reads a leading digit prefix ("64KB" -> 64, "1e6" -> 1,
90
+ // "1.5" -> 1), which would silently shrink a cap or TTL to a tiny value. Only
91
+ // plain decimal digits count; anything else falls back to the default.
92
+ function parsePositiveInt(value: string | undefined): number | undefined {
93
+ const trimmed = (value ?? "").trim();
94
+ if (!/^\d+$/.test(trimmed)) return undefined;
95
+ const parsed = Number.parseInt(trimmed, 10);
96
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
97
+ }
98
+
89
99
  function parsePositiveIntMs(value: string | undefined, fallback: number, floor: number): number {
90
- const parsed = Number.parseInt((value ?? "").trim(), 10);
91
- if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
100
+ const parsed = parsePositiveInt(value);
101
+ if (parsed === undefined) return fallback;
92
102
  return Math.max(floor, parsed);
93
103
  }
94
104
 
@@ -133,8 +143,8 @@ function parseMutationClasses(value: string | undefined): Set<CoordinatorMutatio
133
143
  }
134
144
 
135
145
  function parseByteCap(value: string | undefined): number {
136
- const parsed = Number.parseInt(value ?? "", 10);
137
- if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_ARTIFACT_BYTE_CAP;
146
+ const parsed = parsePositiveInt(value);
147
+ if (parsed === undefined) return DEFAULT_ARTIFACT_BYTE_CAP;
138
148
  return Math.min(parsed, MAX_ARTIFACT_BYTE_CAP);
139
149
  }
140
150