@sayknow-cli/coding-agent 0.5.10 → 0.5.13

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 (31) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dist/types/config/file-lock.d.ts +8 -0
  3. package/dist/types/config/model-profile-activation.d.ts +3 -1
  4. package/dist/types/config/model-registry.d.ts +3 -0
  5. package/dist/types/config/models-config-schema.d.ts +2 -0
  6. package/dist/types/session-import/redact.d.ts +1 -1
  7. package/dist/types/skc-runtime/ultragoal-runtime.d.ts +13 -1
  8. package/dist/types/skc-runtime/ultragoal-succession.d.ts +251 -0
  9. package/dist/types/skc-runtime/workflow-placeholder.d.ts +6 -0
  10. package/dist/types/tools/ask.d.ts +460 -172
  11. package/package.json +7 -7
  12. package/src/cli/telegram-cli.ts +1 -1
  13. package/src/config/file-lock-gc.ts +39 -2
  14. package/src/config/file-lock.ts +22 -1
  15. package/src/config/model-profile-activation.ts +7 -11
  16. package/src/config/model-registry.ts +192 -44
  17. package/src/config/model-resolver.ts +4 -2
  18. package/src/config/models-config-schema.ts +1 -0
  19. package/src/defaults/skc/skills/deep-interview/SKILL.md +12 -0
  20. package/src/defaults/skc/skills/ultragoal/SKILL.md +3 -0
  21. package/src/modes/components/settings-selector.ts +39 -21
  22. package/src/modes/controllers/selector-controller.ts +35 -15
  23. package/src/modes/rpc/rpc-client.ts +20 -4
  24. package/src/sdk/bus/index.ts +1 -3
  25. package/src/session-import/redact.ts +6 -3
  26. package/src/skc-runtime/state-writer.ts +24 -27
  27. package/src/skc-runtime/ultragoal-runtime.ts +125 -45
  28. package/src/skc-runtime/ultragoal-succession.ts +1667 -0
  29. package/src/skc-runtime/workflow-placeholder.ts +33 -0
  30. package/src/tools/ask.ts +366 -108
  31. package/src/tools/bisect.ts +4 -1
@@ -148,7 +148,7 @@ export async function runTelegramCommand(cmd: TelegramCommandArgs): Promise<void
148
148
  case "__gateway": {
149
149
  // Hidden entrypoint: run the Telegram Remote gateway in-process. Reached
150
150
  // only via the self-spawn from runStart()/autostart, never by users.
151
- const { loadConfigFromEnv, runService } = await import("../../../telegram-remote/src/index");
151
+ const { loadConfigFromEnv, runService } = await import("@sayknow-cli/telegram-remote");
152
152
  await runService(loadConfigFromEnv(process.env));
153
153
  return;
154
154
  }
@@ -58,9 +58,33 @@ function keptMalformedRecord(lockDir: string): GcRecord {
58
58
  };
59
59
  }
60
60
 
61
+ function emptyLockDirRecord(lockDir: string): GcRecord {
62
+ return {
63
+ store: "file_locks",
64
+ id: lockDir,
65
+ path: lockDir,
66
+ pid_status: "none",
67
+ status: "stale",
68
+ stale: true,
69
+ removable: true,
70
+ action: "none",
71
+ reason: "empty_file_lock_dir",
72
+ };
73
+ }
74
+
61
75
  async function collectLockRecord(lockDir: string, ctx: GcContext): Promise<GcRecord> {
62
76
  const info = await readFileLockInfoForGc(lockDir);
63
- if (!info) return keptMalformedRecord(lockDir);
77
+ if (!info) {
78
+ // mkdir-before-info crash leftover: an empty `.lock` dir has no owner token
79
+ // and is safe to reclaim. Any other info-less shape stays fail-closed.
80
+ try {
81
+ const entries = await fs.readdir(lockDir);
82
+ if (entries.length === 0) return emptyLockDirRecord(lockDir);
83
+ } catch (error) {
84
+ if (!isEnoent(error)) throw error;
85
+ }
86
+ return keptMalformedRecord(lockDir);
87
+ }
64
88
 
65
89
  const probeResult = ctx.probe(info.pid);
66
90
  const pidStatus = gcPidStatusLabel(probeResult);
@@ -165,7 +189,20 @@ export const fileLocksGcAdapter: GcStoreAdapter = {
165
189
  async prune(record: GcRecord, ctx: GcContext): Promise<GcPruneOutcome> {
166
190
  const lockDir = record.path ?? record.id;
167
191
  const info = await readFileLockInfoForGc(lockDir);
168
- if (!info) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
192
+ if (!info) {
193
+ if (record.reason !== "empty_file_lock_dir") {
194
+ return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
195
+ }
196
+ try {
197
+ const entries = await fs.readdir(lockDir);
198
+ if (entries.length !== 0) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
199
+ await fs.rmdir(lockDir);
200
+ return { removed: true };
201
+ } catch (error) {
202
+ if (isEnoent(error)) return { removed: false, skipped: "lock_no_longer_dead_or_missing" };
203
+ return { removed: false, error: errorMessage(error) };
204
+ }
205
+ }
169
206
 
170
207
  const probeResult = ctx.probe(info.pid);
171
208
  if (probeResult.status !== "dead") {
@@ -14,6 +14,21 @@ const DEFAULT_OPTIONS: Required<FileLockOptions> = {
14
14
  retries: 50,
15
15
  retryDelayMs: 100,
16
16
  };
17
+ export class FileLockAcquireError extends Error {
18
+ readonly code = "acquire_timeout";
19
+
20
+ constructor(
21
+ readonly filePath: string,
22
+ readonly lockPath: string,
23
+ readonly attempts: number,
24
+ readonly holder: string,
25
+ ) {
26
+ super(
27
+ `Failed to acquire lock for ${filePath} after ${attempts} attempts: ${holder} (${lockPath}); a live owner is never displaced`,
28
+ );
29
+ this.name = "FileLockAcquireError";
30
+ }
31
+ }
17
32
 
18
33
  type LockInfo = FileLockOwnerToken;
19
34
 
@@ -243,6 +258,12 @@ async function releaseLock(lockPath: string, owner: FileLockOwnerToken): Promise
243
258
  const outcome = await removeFileLockDirForGc(lockPath, owner);
244
259
  if (outcome !== "removed") throw new Error(`Failed to release file lock: ${outcome}.`);
245
260
  }
261
+ async function lockHolderDescription(lockPath: string): Promise<string> {
262
+ const info = await readLockInfo(lockPath);
263
+ if (!info) return "unknown holder";
264
+ return `pid ${info.pid}`;
265
+ }
266
+
246
267
  async function acquireLock(filePath: string, options: FileLockOptions = {}): Promise<() => Promise<void>> {
247
268
  const opts = { ...DEFAULT_OPTIONS, ...options };
248
269
  const lockPath = getLockPath(filePath);
@@ -255,7 +276,7 @@ async function acquireLock(filePath: string, options: FileLockOptions = {}): Pro
255
276
  if (await removeStaleLockForAcquire(lockPath, stale)) continue;
256
277
  await Bun.sleep(opts.retryDelayMs);
257
278
  }
258
- throw new Error(`Failed to acquire lock for ${filePath} after ${opts.retries} attempts`);
279
+ throw new FileLockAcquireError(filePath, lockPath, opts.retries, await lockHolderDescription(lockPath));
259
280
  }
260
281
 
261
282
  /**
@@ -2,11 +2,8 @@ import { ThinkingLevel } from "@sayknow-cli/agent-core";
2
2
  import type { Api, Model } from "@sayknow-cli/ai";
3
3
  import type { AgentSession } from "../session/agent-session";
4
4
  import { formatClampedModelSelector } from "../thinking";
5
- import {
6
- aggregateModelProfileRequiredProviders,
7
- formatAvailableProfileNames,
8
- resolveProfileBindings,
9
- } from "./model-profiles";
5
+ import { UnknownModelProfileError, validateModelProfileName } from "./model-profile-contract";
6
+ import { aggregateModelProfileRequiredProviders, resolveProfileBindings } from "./model-profiles";
10
7
  import {
11
8
  isAuthenticated,
12
9
  kNoAuth,
@@ -49,7 +46,7 @@ export interface PrepareModelProfileActivationOptions {
49
46
  | "resolveCanonicalModel"
50
47
  | "getCanonicalVariants"
51
48
  | "getCanonicalId"
52
- >;
49
+ > & { getError?: ModelRegistry["getError"] };
53
50
  settings: Pick<Settings, "get">;
54
51
  profileName: string;
55
52
  }
@@ -347,12 +344,11 @@ export async function prepareModelProfileActivation(
347
344
  options: PrepareModelProfileActivationOptions,
348
345
  ): Promise<PreparedModelProfileActivation> {
349
346
  const profiles = options.modelRegistry.getModelProfiles();
350
- const profileName = resolveModelProfileName(options.profileName, profiles);
347
+ // Typed contract errors (`unknown_model_profile` / `model_profile_registry_error`)
348
+ // so SDK lifecycle readiness and BrokerResponse preserve the code and details.
349
+ const profileName = validateModelProfileName(options.profileName, profiles, options.modelRegistry.getError?.());
351
350
  const profile = profiles.get(profileName) ?? options.modelRegistry.getModelProfile(profileName);
352
- if (!profile) {
353
- const available = formatAvailableProfileNames(profiles);
354
- throw new Error(`Unknown model profile "${options.profileName}". Available profiles: ${available}`);
355
- }
351
+ if (!profile) throw new UnknownModelProfileError(options.profileName, profiles);
356
352
  const profileLabel = options.profileName;
357
353
 
358
354
  const requiredProviders = aggregateModelProfileRequiredProviders(profile.requiredProviders, profile);
@@ -29,6 +29,7 @@ import {
29
29
  UNK_MAX_TOKENS,
30
30
  unregisterCustomApis,
31
31
  } from "@sayknow-cli/ai";
32
+ import { detectDiscoveredApiFamily } from "@sayknow-cli/ai/utils/discovery/openai-compatible";
32
33
 
33
34
  // Sentinel for local-only OAuth token (LM Studio, vLLM) — declared inline to avoid loading
34
35
  // any provider module at startup. Must match `DEFAULT_LOCAL_TOKEN` in oauth/lm-studio.ts.
@@ -69,6 +70,36 @@ import { type Settings, settings } from "./settings";
69
70
 
70
71
  export type { CanonicalModelIndex, CanonicalModelRecord, CanonicalModelVariant, ModelEquivalenceConfig };
71
72
 
73
+ /**
74
+ * Strip userinfo and query strings from a discovery URL before it is surfaced
75
+ * in an error message, so credentials embedded in the URL never reach logs or UI.
76
+ */
77
+ function redactDiscoveryUrl(value: string | URL): string {
78
+ try {
79
+ const url = typeof value === "string" ? new URL(value) : value;
80
+ return `${url.origin}${url.pathname}`;
81
+ } catch {
82
+ return "(invalid URL)";
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Fingerprint of the environment variables that can change provider
88
+ * availability without touching AuthStorage. Keys the `getAvailable()` cache
89
+ * so a process.env mutation invalidates it.
90
+ */
91
+ function envAvailabilityFingerprint(): string {
92
+ return Object.entries(process.env)
93
+ .filter(
94
+ ([name]) =>
95
+ /(?:_API_KEY|_OAUTH_TOKEN|_ACCESS_TOKEN)$/.test(name) ||
96
+ /^(?:GH_TOKEN|GITHUB_TOKEN|HF_TOKEN|COPILOT_GITHUB_TOKEN)$/.test(name),
97
+ )
98
+ .sort(([left], [right]) => left.localeCompare(right))
99
+ .map(([name, value]) => `${name}=${value ?? ""}`)
100
+ .join("\u0000");
101
+ }
102
+
72
103
  export const kNoAuth = "N/A";
73
104
 
74
105
  export function isAuthenticated(apiKey: string | undefined | null): apiKey is string {
@@ -182,6 +213,36 @@ type ProviderValidationMode = "models-config" | "runtime-register";
182
213
 
183
214
  const OPENAI_REQUEST_TRANSFORM_APIS = new Set<Api>(["openai-completions", "openai-responses"]);
184
215
 
216
+ const OPENAI_FAMILY_APIS = new Set<Api>([
217
+ "openai-completions",
218
+ "openai-responses",
219
+ "openai-codex-responses",
220
+ "azure-openai-responses",
221
+ ]);
222
+
223
+ function isOpenAIFamilyApi(api: Api): boolean {
224
+ return OPENAI_FAMILY_APIS.has(api);
225
+ }
226
+
227
+ function dominantOpenAIFamilyModelApi(models: readonly { api?: string }[] | undefined): Api | undefined {
228
+ if (!models || models.length === 0) return undefined;
229
+ const counts = new Map<Api, number>();
230
+ for (const model of models) {
231
+ const api = model.api as Api | undefined;
232
+ if (api === undefined || !isOpenAIFamilyApi(api)) continue;
233
+ counts.set(api, (counts.get(api) ?? 0) + 1);
234
+ }
235
+ let best: Api | undefined;
236
+ let bestCount = 0;
237
+ for (const [api, count] of counts) {
238
+ if (count > bestCount) {
239
+ best = api;
240
+ bestCount = count;
241
+ }
242
+ }
243
+ return best;
244
+ }
245
+
185
246
  function getKnownProviderApis(providerName: string): Set<Api> {
186
247
  const apis = new Set<Api>();
187
248
  for (const model of getBundledModels(providerName as Parameters<typeof getBundledModels>[0])) {
@@ -544,6 +605,8 @@ export interface ProviderDiscoveryState {
544
605
  export interface CanonicalModelQueryOptions {
545
606
  availableOnly?: boolean;
546
607
  candidates?: readonly Model<Api>[];
608
+ /** Session whose canonical stickiness should scope the lookup, when the caller has one. */
609
+ sessionId?: string;
547
610
  }
548
611
 
549
612
  /** Result of loading custom models from models.json */
@@ -1021,6 +1084,9 @@ export class ModelRegistry {
1021
1084
  #providerWebSearchModes: Map<string, WebSearchMode> = new Map();
1022
1085
  #keylessProviders: Set<string> = new Set();
1023
1086
  #discoverableProviders: DiscoveryProviderConfig[] = [];
1087
+ #availableModelsCache: Model<Api>[] | undefined;
1088
+ #availableModelsDisabledProviders: string | undefined;
1089
+ #availableModelsEnvFingerprint: string | undefined;
1024
1090
  #customModelOverlays: CustomModelOverlay[] = [];
1025
1091
  #providerOverrides: Map<string, ProviderOverride> = new Map();
1026
1092
  #modelOverrides: Map<string, Map<string, ModelOverride>> = new Map();
@@ -1074,6 +1140,8 @@ export class ModelRegistry {
1074
1140
  const keyConfig = this.#customProviderApiKeys.get(provider);
1075
1141
  return keyConfig;
1076
1142
  });
1143
+ // Any credential mutation (runtime/config keys, OAuth refresh) changes availability.
1144
+ this.authStorage.onGenerationChanged(() => this.#invalidateAvailableModels());
1077
1145
  // Load models synchronously in constructor
1078
1146
  this.#loadModels();
1079
1147
  }
@@ -1526,17 +1594,30 @@ export class ModelRegistry {
1526
1594
  keylessProviders.add(providerName);
1527
1595
  }
1528
1596
 
1529
- if (providerConfig.discovery && providerConfig.api) {
1597
+ const effectiveDiscoveryBaseUrl = providerConfig.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName);
1598
+ const providerApi =
1599
+ (providerConfig.api as Api | undefined) ??
1600
+ dominantOpenAIFamilyModelApi(providerConfig.models as { api?: string }[] | undefined);
1601
+ const autoDiscovery: ProviderDiscovery | undefined =
1602
+ !providerConfig.discovery &&
1603
+ !localOpenAICompat &&
1604
+ providerApi !== undefined &&
1605
+ isOpenAIFamilyApi(providerApi) &&
1606
+ effectiveDiscoveryBaseUrl !== undefined
1607
+ ? { type: "openai-models-list" }
1608
+ : undefined;
1609
+ const effectiveDiscovery = providerConfig.discovery ?? autoDiscovery;
1610
+ if (effectiveDiscovery && providerApi) {
1530
1611
  discoverableProviders.push({
1531
1612
  provider: providerName,
1532
- api: providerConfig.api as Api,
1533
- baseUrl: providerConfig.baseUrl ?? resolveProviderBaseUrlFromEnv(providerName),
1613
+ api: providerApi,
1614
+ baseUrl: effectiveDiscoveryBaseUrl,
1534
1615
  headers: providerConfig.headers,
1535
1616
  compat: providerConfig.compat,
1536
1617
  requestTransform: providerConfig.requestTransform,
1537
1618
  cacheRetention: providerConfig.cacheRetention,
1538
- discovery: providerConfig.discovery,
1539
- optional: false,
1619
+ discovery: effectiveDiscovery,
1620
+ optional: !providerConfig.discovery,
1540
1621
  });
1541
1622
  }
1542
1623
 
@@ -2232,51 +2313,93 @@ export class ModelRegistry {
2232
2313
  headers,
2233
2314
  signal: AbortSignal.timeout(providerConfig.provider === "sglang" ? 500 : 250),
2234
2315
  fetch: (input, init) => fetch(input, { ...init, redirect: "error" }),
2235
- throwOnStatus: response => new Error(`HTTP ${response.status} from ${baseUrl}/models`),
2236
- mapModel: (item, defaults) => ({
2237
- ...defaults,
2238
- reasoning: isOmlx,
2239
- ...(isOmlx
2240
- ? {
2241
- thinking: {
2242
- mode: "effort" as const,
2243
- minLevel: Effort.Low,
2244
- maxLevel: Effort.High,
2245
- defaultLevel: Effort.Medium,
2246
- levels: [Effort.Low, Effort.Medium, Effort.High],
2247
- },
2248
- }
2249
- : {}),
2250
- contextWindow:
2251
- parseDiscoveryLimit(item.max_model_len) ??
2252
- parseDiscoveryLimit(item.context_length) ??
2253
- parseDiscoveryLimit(item.context_window) ??
2254
- parseDiscoveryLimit(item.max_context_length) ??
2255
- UNK_CONTEXT_WINDOW,
2256
- maxTokens:
2257
- parseDiscoveryLimit(item.max_completion_tokens) ??
2258
- parseDiscoveryLimit(item.max_tokens) ??
2259
- parseDiscoveryLimit(item.max_output_tokens) ??
2260
- UNK_MAX_TOKENS,
2261
- headers,
2262
- compat: {
2263
- ...(providerConfig.compat ?? {}),
2264
- supportsStore: false,
2265
- supportsDeveloperRole: false,
2266
- supportsReasoningEffort: isOmlx,
2316
+ throwOnStatus: response => {
2317
+ const modelsUrl = redactDiscoveryUrl(`${baseUrl}/models`);
2318
+ if (response.status === 401 || response.status === 403) {
2319
+ // Redacted by construction: name the provider, endpoint, and the
2320
+ // config surface to fix, never the resolved key.
2321
+ return new Error(
2322
+ `HTTP ${response.status} from ${modelsUrl}: provider "${providerConfig.provider}" credential was rejected for OpenAI models-list discovery; check providers.${providerConfig.provider}.apiKey/apiKeyEnv.`,
2323
+ );
2324
+ }
2325
+ return new Error(`HTTP ${response.status} from ${modelsUrl}`);
2326
+ },
2327
+ mapModel: (item, defaults) => {
2328
+ const api = this.#resolveDiscoveredModelApi(
2329
+ providerConfig,
2330
+ typeof item.id === "string" ? item.id : "",
2331
+ item,
2332
+ );
2333
+ return {
2334
+ ...defaults,
2335
+ api,
2336
+ reasoning: isOmlx,
2267
2337
  ...(isOmlx
2268
2338
  ? {
2269
- thinkingFormat: "qwen-chat-template" as const,
2270
- reasoningContentField: "reasoning_content" as const,
2339
+ thinking: {
2340
+ mode: "effort" as const,
2341
+ minLevel: Effort.Low,
2342
+ maxLevel: Effort.High,
2343
+ defaultLevel: Effort.Medium,
2344
+ levels: [Effort.Low, Effort.Medium, Effort.High],
2345
+ },
2271
2346
  }
2272
2347
  : {}),
2273
- },
2274
- }),
2348
+ contextWindow:
2349
+ parseDiscoveryLimit(item.max_model_len) ??
2350
+ parseDiscoveryLimit(item.context_length) ??
2351
+ parseDiscoveryLimit(item.context_window) ??
2352
+ parseDiscoveryLimit(item.max_context_length) ??
2353
+ UNK_CONTEXT_WINDOW,
2354
+ maxTokens:
2355
+ parseDiscoveryLimit(item.max_completion_tokens) ??
2356
+ parseDiscoveryLimit(item.max_tokens) ??
2357
+ parseDiscoveryLimit(item.max_output_tokens) ??
2358
+ UNK_MAX_TOKENS,
2359
+ headers,
2360
+ compat: {
2361
+ ...(providerConfig.compat ?? {}),
2362
+ supportsStore: false,
2363
+ supportsDeveloperRole: false,
2364
+ supportsReasoningEffort: isOmlx,
2365
+ ...(isOmlx
2366
+ ? {
2367
+ thinkingFormat: "qwen-chat-template" as const,
2368
+ reasoningContentField: "reasoning_content" as const,
2369
+ }
2370
+ : {}),
2371
+ },
2372
+ };
2373
+ },
2275
2374
  });
2276
2375
  if (discovered === null) throw new Error(`Invalid OpenAI-compatible model catalog from ${baseUrl}`);
2277
2376
  return this.#applyProviderModelOverrides(providerConfig.provider, discovered);
2278
2377
  }
2279
2378
 
2379
+ #resolveDiscoveredModelApi(
2380
+ providerConfig: DiscoveryProviderConfig,
2381
+ modelId: string,
2382
+ entry?: { id?: unknown; owned_by?: unknown },
2383
+ ): Api {
2384
+ let matchedPrefixLength = -1;
2385
+ let prefixApi: Api | undefined;
2386
+ for (const [prefix, routedApi] of Object.entries(providerConfig.discovery.apiByModelPrefix ?? {})) {
2387
+ if (modelId.startsWith(prefix) && prefix.length > matchedPrefixLength) {
2388
+ prefixApi = routedApi as Api;
2389
+ matchedPrefixLength = prefix.length;
2390
+ }
2391
+ }
2392
+ if (prefixApi !== undefined) return prefixApi;
2393
+ if (providerConfig.discovery.type === "openai-models-list") {
2394
+ const detected = detectDiscoveredApiFamily(entry ?? { id: modelId });
2395
+ if (detected === "anthropic-messages") return "anthropic-messages";
2396
+ if (detected === "openai-completions") {
2397
+ return isOpenAIFamilyApi(providerConfig.api) ? providerConfig.api : "openai-completions";
2398
+ }
2399
+ }
2400
+ return providerConfig.api;
2401
+ }
2402
+
2280
2403
  #normalizeLlamaCppBaseUrl(baseUrl?: string): string {
2281
2404
  const defaultBaseUrl = "http://127.0.0.1:8080";
2282
2405
  const raw = baseUrl || defaultBaseUrl;
@@ -2434,6 +2557,9 @@ export class ModelRegistry {
2434
2557
  }
2435
2558
 
2436
2559
  #rebuildCanonicalIndex(): void {
2560
+ // #models has already changed by the time a rebuild is requested; drop the
2561
+ // availability cache even when the index rebuild itself is deferred.
2562
+ this.#invalidateAvailableModels();
2437
2563
  if (this.#rebuildSuspended > 0) {
2438
2564
  this.#rebuildPending = true;
2439
2565
  return;
@@ -2442,6 +2568,12 @@ export class ModelRegistry {
2442
2568
  this.#rebuildPending = false;
2443
2569
  }
2444
2570
 
2571
+ #invalidateAvailableModels(): void {
2572
+ this.#availableModelsCache = undefined;
2573
+ this.#availableModelsDisabledProviders = undefined;
2574
+ this.#availableModelsEnvFingerprint = undefined;
2575
+ }
2576
+
2445
2577
  #suspendRebuild(): void {
2446
2578
  this.#rebuildSuspended += 1;
2447
2579
  }
@@ -2453,6 +2585,7 @@ export class ModelRegistry {
2453
2585
  if (this.#rebuildSuspended === 0 && this.#rebuildPending) {
2454
2586
  this.#rebuildPending = false;
2455
2587
  this.#canonicalIndex = buildCanonicalModelIndex(this.#models, this.#equivalenceConfig);
2588
+ this.#invalidateAvailableModels();
2456
2589
  }
2457
2590
  }
2458
2591
 
@@ -2503,8 +2636,10 @@ export class ModelRegistry {
2503
2636
  return this.#models;
2504
2637
  }
2505
2638
 
2506
- #isModelAvailable(model: Model<Api>): boolean {
2507
- const disabledProviders = getDisabledProviderIdsFromSettings();
2639
+ #isModelAvailable(
2640
+ model: Model<Api>,
2641
+ disabledProviders: ReadonlySet<string> = getDisabledProviderIdsFromSettings(),
2642
+ ): boolean {
2508
2643
  return (
2509
2644
  !disabledProviders.has(model.provider) &&
2510
2645
  (this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider))
@@ -2643,7 +2778,20 @@ export class ModelRegistry {
2643
2778
  * This is a fast check that doesn't refresh OAuth tokens.
2644
2779
  */
2645
2780
  getAvailable(): Model<Api>[] {
2646
- return this.#models.filter(model => this.#isModelAvailable(model));
2781
+ const disabledProviders = getDisabledProviderIdsFromSettings();
2782
+ const disabledProviderKey = [...disabledProviders].sort().join("\u0000");
2783
+ const envFingerprint = envAvailabilityFingerprint();
2784
+ if (
2785
+ this.#availableModelsCache &&
2786
+ this.#availableModelsDisabledProviders === disabledProviderKey &&
2787
+ this.#availableModelsEnvFingerprint === envFingerprint
2788
+ ) {
2789
+ return this.#availableModelsCache;
2790
+ }
2791
+ this.#availableModelsCache = this.#models.filter(model => this.#isModelAvailable(model, disabledProviders));
2792
+ this.#availableModelsDisabledProviders = disabledProviderKey;
2793
+ this.#availableModelsEnvFingerprint = envFingerprint;
2794
+ return this.#availableModelsCache;
2647
2795
  }
2648
2796
 
2649
2797
  /**
@@ -355,7 +355,7 @@ function findExactCanonicalModelMatch(
355
355
  modelReference: string,
356
356
  availableModels: Model<Api>[],
357
357
  modelRegistry: CanonicalModelRegistry | undefined,
358
- _sessionId?: string,
358
+ sessionId?: string,
359
359
  ): Model<Api> | undefined {
360
360
  if (!modelRegistry) {
361
361
  return undefined;
@@ -367,6 +367,7 @@ function findExactCanonicalModelMatch(
367
367
  return modelRegistry.resolveCanonicalModel?.(trimmedReference, {
368
368
  availableOnly: false,
369
369
  candidates: availableModels,
370
+ sessionId,
370
371
  });
371
372
  }
372
373
 
@@ -378,7 +379,7 @@ function findExactEquivalentModelMatch(
378
379
  modelReference: string,
379
380
  availableModels: Model<Api>[],
380
381
  modelRegistry: CanonicalModelRegistry | undefined,
381
- _sessionId?: string,
382
+ sessionId?: string,
382
383
  ): Model<Api> | undefined {
383
384
  if (!modelRegistry?.getCanonicalId || !modelRegistry.resolveCanonicalModel) return undefined;
384
385
  const trimmedReference = modelReference.trim();
@@ -394,6 +395,7 @@ function findExactEquivalentModelMatch(
394
395
  return modelRegistry.resolveCanonicalModel([...canonicalIds][0]!, {
395
396
  availableOnly: false,
396
397
  candidates: availableModels,
398
+ sessionId,
397
399
  });
398
400
  }
399
401
 
@@ -185,6 +185,7 @@ export type ModelOverride = z.infer<typeof ModelOverrideSchema>;
185
185
 
186
186
  export const ProviderDiscoverySchema = z.object({
187
187
  type: z.enum(["ollama", "llama.cpp", "lm-studio", "omlx", "sglang", "openai-models-list"]),
188
+ apiByModelPrefix: z.record(z.string().min(1), z.string().min(1)).optional(),
188
189
  });
189
190
 
190
191
  const LocalOpenAICompatSchema = z
@@ -635,6 +635,18 @@ A transition occurs whenever the band changes versus the prior scored round —
635
635
 
636
636
  **Bookkeeping:** record each convened panel in `state.lateral_reviews` (round, milestone transition or pre-answer trigger, personas dispatched, findings folded). On panel spawn or validation failure, fall back silently to the normal generated question and increment `lateral_panel_failures`; do not expose tool noise unless it changes the next user-facing question. The panel is a prompt-budgeted assist layer — summarize oversized context before dispatch.
637
637
 
638
+ ### Per-question advisory fanout lanes (distinct from the milestone panel)
639
+
640
+ Separate from the milestone-triggered lateral panel above, a lightweight **advisory fanout** may assist any single question the main session is about to synthesize or route — especially when the user is terse, uncertain, or would benefit from selectable options instead of another open-ended prompt. Adopted from ouroboros's ooo interview, the standard lanes are:
641
+
642
+ - `code_context` — inspect repo-local facts and reuse existing exploration before asking the user.
643
+ - `web_context` — browse/search only when current external facts genuinely affect the answer.
644
+ - `ambiguity_contrarian` — find hidden assumptions, vague terms, missing decisions, and risky defaults.
645
+ - `answer_simplifier` — turn the question into 2-3 easy choices or one concise draft answer.
646
+ - `architecture_implications` — check whether the answer changes ownership, interfaces, rollout, or system shape.
647
+
648
+ Advisory fanout is an assist layer, not a decision maker: it never replaces or delays the single user-facing question, never adds a second question, and never forwards a synthesized answer without the user's approval, edit, or explicit auto-confirm request. It differs from the milestone panel in trigger (per-question, not band-transition) and intent (help the human answer this one question). When both would fire on the same round, run the milestone panel and fold advisory lanes into the same single question. Runtimes without a parallel subagent primitive process lanes sequentially; on lane failure, fall back silently to the normal generated question.
649
+
638
650
  ## Phase 4: Crystallize Spec
639
651
 
640
652
  When ambiguity ≤ threshold (or hard cap / early exit):
@@ -37,6 +37,9 @@ skc ultragoal complete-goals --retry-failed
37
37
  skc ultragoal checkpoint --goal-id <id> --status complete --evidence "<evidence>" --quality-gate-json <quality-gate-json-or-path>
38
38
  skc ultragoal checkpoint --goal-id <id> --status failed --evidence "<blocker/evidence>"
39
39
  skc ultragoal record-review-blockers --goal-id <id> --title "Resolve final review blockers" --objective "<blocker-resolution objective>" --evidence "<review findings>"
40
+ skc ultragoal succession offer --target-repo <path> --goal-id <id> --authorize "<statement>" --authorized-by <identity>
41
+ skc ultragoal succession adopt --offer <path>
42
+ skc ultragoal succession status --json
40
43
  ```
41
44
 
42
45
  Use these exact goal-tool calls for the inline goal state: