@sayknow-cli/coding-agent 0.6.5 → 0.6.7

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 (69) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/dist/types/config/keybindings.d.ts +5 -0
  3. package/dist/types/config/settings-schema.d.ts +79 -0
  4. package/dist/types/i18n/messages/en.d.ts +6 -29
  5. package/dist/types/modes/components/welcome.d.ts +34 -40
  6. package/dist/types/modes/interactive-mode.d.ts +7 -4
  7. package/dist/types/modes/types.d.ts +4 -0
  8. package/dist/types/sdk/broker/broker.d.ts +22 -0
  9. package/dist/types/sdk/broker/process-guard.d.ts +71 -0
  10. package/dist/types/sdk/broker/transport.d.ts +2 -0
  11. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
  12. package/dist/types/session/agent-session.d.ts +9 -0
  13. package/dist/types/session/auth-storage-discovery.d.ts +15 -0
  14. package/dist/types/session/auto-fallback.d.ts +27 -0
  15. package/dist/types/session/fallback-chain-controller.d.ts +5 -0
  16. package/dist/types/session/response-language.d.ts +25 -0
  17. package/dist/types/setup/model-onboarding-guidance.d.ts +8 -1
  18. package/dist/types/setup/provider-onboarding.d.ts +2 -0
  19. package/dist/types/tools/debug.d.ts +2 -2
  20. package/dist/types/tools/index.d.ts +1 -0
  21. package/dist/types/tools/locate-core.d.ts +97 -0
  22. package/dist/types/tools/locate.d.ts +38 -0
  23. package/package.json +7 -7
  24. package/scripts/generate-sdk-operation-inventory.ts +4 -0
  25. package/src/cli/setup-cli.ts +7 -4
  26. package/src/commands/sdk.ts +3 -0
  27. package/src/commands/setup.ts +4 -1
  28. package/src/config/keybindings.ts +7 -0
  29. package/src/config/settings-schema.ts +82 -0
  30. package/src/decisions/typesafe-backend.ts +38 -4
  31. package/src/i18n/messages/de.settings.ts +26 -0
  32. package/src/i18n/messages/de.ts +4 -27
  33. package/src/i18n/messages/en.ts +6 -29
  34. package/src/i18n/messages/es.settings.ts +26 -0
  35. package/src/i18n/messages/es.ts +4 -27
  36. package/src/i18n/messages/fr.settings.ts +26 -0
  37. package/src/i18n/messages/fr.ts +4 -27
  38. package/src/i18n/messages/ja.settings.ts +26 -0
  39. package/src/i18n/messages/ja.ts +4 -27
  40. package/src/i18n/messages/ko.settings.ts +25 -0
  41. package/src/i18n/messages/ko.ts +5 -28
  42. package/src/i18n/messages/zh.settings.ts +23 -0
  43. package/src/i18n/messages/zh.ts +4 -27
  44. package/src/internal-urls/docs-index.generated.ts +8 -7
  45. package/src/modes/action-registry.ts +1 -0
  46. package/src/modes/components/welcome.ts +385 -387
  47. package/src/modes/controllers/input-controller.ts +15 -0
  48. package/src/modes/controllers/selector-controller.ts +13 -0
  49. package/src/modes/interactive-mode.ts +160 -98
  50. package/src/modes/types.ts +4 -0
  51. package/src/prompts/system/system-prompt.md +7 -2
  52. package/src/prompts/tools/locate.md +12 -0
  53. package/src/sdk/broker/broker.ts +75 -1
  54. package/src/sdk/broker/process-guard.ts +160 -0
  55. package/src/sdk/broker/transport.ts +15 -1
  56. package/src/sdk/bus/chat-daemon-runtime.ts +13 -1
  57. package/src/sdk/protocol/operation-inventory.generated.json +22 -0
  58. package/src/sdk/session.ts +4 -1
  59. package/src/session/agent-session.ts +118 -2
  60. package/src/session/auth-storage-discovery.ts +22 -7
  61. package/src/session/auto-fallback.ts +59 -0
  62. package/src/session/fallback-chain-controller.ts +5 -0
  63. package/src/session/response-language.ts +71 -0
  64. package/src/setup/model-onboarding-guidance.ts +29 -14
  65. package/src/setup/provider-onboarding.ts +5 -0
  66. package/src/slash-commands/builtin-registry.ts +112 -2
  67. package/src/tools/index.ts +3 -0
  68. package/src/tools/locate-core.ts +720 -0
  69. package/src/tools/locate.ts +197 -0
@@ -0,0 +1,160 @@
1
+ import * as fs from "node:fs";
2
+
3
+ /**
4
+ * Guards for the SDK's long-lived background processes (`sdk broker-internal`,
5
+ * `sdk session-host-internal`). They are spawned detached and outlive whoever
6
+ * started them, so each one has to notice on its own when it has no reason, or no
7
+ * way, to keep running.
8
+ */
9
+
10
+ /** How often the guards run. Cheap: one `stat`, plus a small JSON read for hosts. */
11
+ export const PROCESS_GUARD_INTERVAL_MS = 5_000;
12
+
13
+ /**
14
+ * The entry script of a process run from source (`bun …/src/cli.ts sdk …`), or
15
+ * undefined for a compiled binary, whose entry lives in Bun's virtual filesystem
16
+ * and cannot disappear from under it.
17
+ */
18
+ export function sourceEntryPath(main: string | undefined = Bun.main): string | undefined {
19
+ if (!main) return undefined;
20
+ if (main.startsWith("/$bunfs/") || /^[A-Za-z]:[\\/]~BUN[\\/]/.test(main)) return undefined;
21
+ return main;
22
+ }
23
+
24
+ /** Errors that mean the file is gone, or the volume holding it is: unmounted, ejected, or failing. */
25
+ const SOURCE_GONE_CODES = new Set(["ENOENT", "ENOTDIR", "EIO", "ENXIO", "ENODEV"]);
26
+
27
+ /** Whether the source entry is still reachable. A compiled binary (no entry) always is. */
28
+ export function sourceEntryAvailable(
29
+ entry: string | undefined,
30
+ stat: (file: string) => unknown = file => fs.statSync(file),
31
+ ): boolean {
32
+ if (!entry) return true;
33
+ try {
34
+ stat(entry);
35
+ return true;
36
+ } catch (error) {
37
+ return !SOURCE_GONE_CODES.has((error as NodeJS.ErrnoException).code ?? "");
38
+ }
39
+ }
40
+
41
+ /**
42
+ * End this process at once when the source it runs from has vanished — typically
43
+ * an external drive with the checkout was unplugged. The native addon is mapped
44
+ * from that checkout and paged in lazily, so the next call into it faults on a
45
+ * page that can no longer be read, and Bun's fault handler then spins on the same
46
+ * unreadable mapping at 100% CPU forever. No cleanup is safe at that point (it
47
+ * would call into the same addon), so the process kills itself; readers already
48
+ * treat its discovery and markers as stale once the pid is gone.
49
+ *
50
+ * Call this before any native call in a periodic task. Returns true if it fired.
51
+ */
52
+ export function exitIfSourceGone(
53
+ entry: string | undefined = sourceEntryPath(),
54
+ options: { stat?: (file: string) => unknown; kill?: () => void } = {},
55
+ ): boolean {
56
+ if (sourceEntryAvailable(entry, options.stat)) return false;
57
+ (options.kill ?? (() => process.kill(process.pid, "SIGKILL")))();
58
+ return true;
59
+ }
60
+
61
+ /** What a session host needs to recognise that its session is still its own. */
62
+ export interface SessionHostAuthority {
63
+ /** `<stateRoot>/sdk/<sessionId>.lifecycle.json`, written once by the broker when it spawned this host. */
64
+ markerPath: string;
65
+ pid: number;
66
+ effectMarker: string;
67
+ incarnation: string;
68
+ /** The host's worktree. */
69
+ cwd: string;
70
+ }
71
+
72
+ /**
73
+ * `held`: the marker still names this process and the worktree exists. `lost`: the
74
+ * marker or worktree is gone, or the marker names another process — the session was
75
+ * deleted, taken over, or its whole state root was removed (a finished test). `unknown`:
76
+ * something could not be read for another reason; that is not evidence either way.
77
+ */
78
+ export type SessionHostAuthorityState = "held" | "lost" | "unknown";
79
+
80
+ const GONE_CODES = new Set(["ENOENT", "ENOTDIR"]);
81
+
82
+ export function checkSessionHostAuthority(
83
+ authority: SessionHostAuthority,
84
+ io: { readFile?: (file: string) => string; stat?: (file: string) => unknown } = {},
85
+ ): SessionHostAuthorityState {
86
+ const readFile = io.readFile ?? (file => fs.readFileSync(file, "utf8"));
87
+ const stat = io.stat ?? (file => fs.statSync(file));
88
+ const code = (error: unknown) => (error as NodeJS.ErrnoException).code ?? "";
89
+ try {
90
+ stat(authority.cwd);
91
+ } catch (error) {
92
+ return GONE_CODES.has(code(error)) ? "lost" : "unknown";
93
+ }
94
+ let raw: string;
95
+ try {
96
+ raw = readFile(authority.markerPath);
97
+ } catch (error) {
98
+ return GONE_CODES.has(code(error)) ? "lost" : "unknown";
99
+ }
100
+ let marker: { pid?: unknown; effectMarker?: unknown; incarnation?: unknown };
101
+ try {
102
+ marker = JSON.parse(raw) as typeof marker;
103
+ } catch {
104
+ // The broker publishes the marker by atomic rename, so a torn read is not
105
+ // expected; still, one unparsable read alone does not end a live session.
106
+ return "unknown";
107
+ }
108
+ return marker.pid === authority.pid &&
109
+ marker.effectMarker === authority.effectMarker &&
110
+ marker.incarnation === authority.incarnation
111
+ ? "held"
112
+ : "lost";
113
+ }
114
+
115
+ /** Consecutive `lost` checks before a host gives up its session (≈15s at the default interval). */
116
+ export const SESSION_HOST_LOST_CHECKS = 3;
117
+
118
+ /**
119
+ * Watch a running session host. It stops (through `onLost`, the host's normal
120
+ * shutdown) once its ownership marker or worktree has been gone for
121
+ * {@link SESSION_HOST_LOST_CHECKS} checks in a row, and kills itself at once if the
122
+ * source checkout it runs from disappears. Before this, a host whose session state
123
+ * had been deleted kept running until someone killed it — for days, after tests.
124
+ * Returns a function that stops the watch.
125
+ */
126
+ export function startSessionHostGuard(
127
+ options: SessionHostAuthority & {
128
+ onLost: () => void;
129
+ intervalMs?: number;
130
+ lostChecks?: number;
131
+ check?: (authority: SessionHostAuthority) => SessionHostAuthorityState;
132
+ sourceGuard?: () => boolean;
133
+ setInterval?: typeof setInterval;
134
+ clearInterval?: typeof clearInterval;
135
+ },
136
+ ): () => void {
137
+ const testInterval = Number(process.env.SKC_SDK_TEST_HOST_GUARD_MS);
138
+ const intervalMs =
139
+ options.intervalMs ??
140
+ (Number.isFinite(testInterval) && testInterval > 0 ? testInterval : PROCESS_GUARD_INTERVAL_MS);
141
+ const lostChecks = options.lostChecks ?? SESSION_HOST_LOST_CHECKS;
142
+ const check = options.check ?? checkSessionHostAuthority;
143
+ const sourceGuard = options.sourceGuard ?? (() => exitIfSourceGone());
144
+ const set = options.setInterval ?? setInterval;
145
+ const clear = options.clearInterval ?? clearInterval;
146
+ let misses = 0;
147
+ let stopped = false;
148
+ const timer = set(() => {
149
+ if (stopped || sourceGuard()) return;
150
+ misses = check(options) === "lost" ? misses + 1 : 0;
151
+ if (misses < lostChecks) return;
152
+ stopped = true;
153
+ clear(timer);
154
+ options.onLost();
155
+ }, intervalMs);
156
+ return () => {
157
+ stopped = true;
158
+ clear(timer);
159
+ };
160
+ }
@@ -45,11 +45,16 @@ export class BrokerTransport {
45
45
  readonly #requestedPort: number;
46
46
  #server: Bun.Server<undefined> | null = null;
47
47
  #port = 0;
48
+ #openConnections = 0;
48
49
  constructor(broker: Broker, token: string, port = 0) {
49
50
  this.#broker = broker;
50
51
  this.#token = token;
51
52
  this.#requestedPort = port;
52
53
  }
54
+ /** Client WebSockets currently open. The broker does not idle out while any is. */
55
+ get openConnections(): number {
56
+ return this.#openConnections;
57
+ }
53
58
  get port(): number {
54
59
  if (!this.#server) throw new Error("Broker transport is not running");
55
60
  return this.#port;
@@ -71,7 +76,15 @@ export class BrokerTransport {
71
76
  },
72
77
  websocket: {
73
78
  maxPayloadLength: MAX_BROKER_JSON_FRAME_BYTES * 2,
74
- open: socket => send(socket, { type: "broker_hello", protocolVersion: PROTOCOL_VERSION }),
79
+ open: socket => {
80
+ this.#openConnections += 1;
81
+ this.#broker.noteActivity();
82
+ send(socket, { type: "broker_hello", protocolVersion: PROTOCOL_VERSION });
83
+ },
84
+ close: () => {
85
+ this.#openConnections = Math.max(0, this.#openConnections - 1);
86
+ this.#broker.noteActivity();
87
+ },
75
88
  message: (socket, message) => void this.#handleMessage(socket, message),
76
89
  },
77
90
  });
@@ -82,6 +95,7 @@ export class BrokerTransport {
82
95
  const server = this.#server;
83
96
  this.#server = null;
84
97
  if (server) await server.stop(true);
98
+ this.#openConnections = 0;
85
99
  }
86
100
  async #handleMessage(socket: ServerWebSocket<unknown>, raw: string | Buffer): Promise<void> {
87
101
  if (Buffer.byteLength(raw) > MAX_BROKER_JSON_FRAME_BYTES) {
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { ensureBroker } from "../broker/ensure";
2
3
  import { type IndexedSession, SessionIndex } from "../broker/session-index";
3
4
  import { SdkClient, SdkClientError } from "../client/client";
4
5
  import { readSdkBrokerDiscovery, readSdkSessionEndpoint, type SdkSessionEndpoint } from "../client/discovery";
@@ -61,6 +62,8 @@ export interface ChatDaemonRuntimeDeps {
61
62
  createClient?: (endpoint: SdkSessionEndpoint) => Promise<ChatDaemonSdkClient>;
62
63
  createIndex?: (agentDir: string) => SessionIndex;
63
64
  createBrokerClient?: (endpoint: { url: string; token: string }) => Promise<ChatDaemonSdkClient>;
65
+ /** Starts the agent broker when none is running (default: `ensureBroker`). */
66
+ ensureBroker?: (settings: { agentDir: string }) => Promise<unknown>;
64
67
  onReconciled?: () => void;
65
68
  setInterval?: typeof setInterval;
66
69
  clearInterval?: typeof clearInterval;
@@ -480,7 +483,16 @@ export class ChatDaemonRuntime {
480
483
  input: Record<string, unknown>,
481
484
  idempotencyKey: string,
482
485
  ): Promise<Record<string, unknown>> {
483
- const discovery = await readSdkBrokerDiscovery(this.input.agentDir);
486
+ let discovery = await readSdkBrokerDiscovery(this.input.agentDir);
487
+ if (!discovery) {
488
+ // A broker with nothing to serve stops on its own; start a fresh one for this command.
489
+ try {
490
+ await (this.deps.ensureBroker ?? ensureBroker)({ agentDir: this.input.agentDir });
491
+ } catch {
492
+ throw new ChatDeliveryError("pre_send");
493
+ }
494
+ discovery = await readSdkBrokerDiscovery(this.input.agentDir);
495
+ }
484
496
  if (!discovery) throw new ChatDeliveryError("pre_send");
485
497
  let client: ChatDaemonSdkClient;
486
498
  try {
@@ -1771,6 +1771,17 @@
1771
1771
  "packages/coding-agent/test/sdk-operation-inventory.test.ts"
1772
1772
  ]
1773
1773
  },
1774
+ {
1775
+ "sourceId": "slash_command:fallback",
1776
+ "sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
1777
+ "sourceKind": "slash_command",
1778
+ "decision": "exclude",
1779
+ "rationale": "local fallback-chain configuration (fallback.models / fallback.auto settings); no SDK operation counterpart, and SDK clients observe switches via model_fallback_switched",
1780
+ "exclusionMetadata": {
1781
+ "adapterMappings": "not_applicable",
1782
+ "testIds": "not_applicable"
1783
+ }
1784
+ },
1774
1785
  {
1775
1786
  "sourceId": "slash_command:effort",
1776
1787
  "sourceFile": "packages/coding-agent/src/slash-commands/builtin-registry.ts",
@@ -4419,6 +4430,17 @@
4419
4430
  "packages/coding-agent/test/sdk-operation-inventory.test.ts"
4420
4431
  ]
4421
4432
  },
4433
+ {
4434
+ "sourceId": "agent_session:getDefaultFallbackChain",
4435
+ "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
4436
+ "sourceKind": "agent_session",
4437
+ "decision": "exclude",
4438
+ "rationale": "internal profile and fallback-chain state, not a user-facing SDK control seam",
4439
+ "exclusionMetadata": {
4440
+ "adapterMappings": "not_applicable",
4441
+ "testIds": "not_applicable"
4442
+ }
4443
+ },
4422
4444
  {
4423
4445
  "sourceId": "agent_session:abortRetry",
4424
4446
  "sourceFile": "packages/coding-agent/src/session/agent-session.ts",
@@ -110,7 +110,7 @@ import {
110
110
  } from "../secrets";
111
111
  import { AgentSession, type ForkContextSeed } from "../session/agent-session";
112
112
  import type { AuthStorage } from "../session/auth-storage";
113
- import { discoverAuthStorage } from "../session/auth-storage-discovery";
113
+ import { applyCredentialRankingModeSetting, discoverAuthStorage } from "../session/auth-storage-discovery";
114
114
  import { type CustomMessage, convertToLlm } from "../session/messages";
115
115
  import { createReadonlySessionManager, SessionManager } from "../session/session-manager";
116
116
  import { formatNoModelsAvailableFallback } from "../setup/model-onboarding-guidance";
@@ -1057,6 +1057,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1057
1057
  }
1058
1058
  const settings = options.settings ?? (await logger.time("settings", Settings.init, { cwd, agentDir }));
1059
1059
  modelRegistry.applyConfiguredModelBindings(settings);
1060
+ // Multi-account order comes from settings (the env var still overrides it); apply it
1061
+ // before the first model-availability probe picks an account for this session.
1062
+ applyCredentialRankingModeSetting(authStorage, settings);
1060
1063
  logger.time("initializeWithSettings", initializeWithSettings, settings);
1061
1064
  const canRefreshModelsBeforeCredentialSelector =
1062
1065
  !options.credentialSelector || runtimeCredentialSelectorInstalled || options.modelRegistry !== undefined;
@@ -366,6 +366,7 @@ import { buildNamedToolChoice, buildNamedToolChoiceResult } from "../utils/tool-
366
366
  import { buildWorkflowIntentDiff, WORKFLOW_INTENT_DIFF_CUSTOM_TYPE } from "../workflow/workflow-intent-diff";
367
367
  import { buildWorkspaceTree, type WorkspaceTree } from "../workspace-tree";
368
368
  import type { AuthStorage } from "./auth-storage";
369
+ import { autoFallbackSelectors, selectorProvider } from "./auto-fallback";
369
370
  import {
370
371
  DefaultModelSelectionRecoveryError,
371
372
  type DefaultModelSelectionResult,
@@ -378,6 +379,7 @@ import {
378
379
  effectiveFallbackDelay,
379
380
  FallbackChainController,
380
381
  } from "./fallback-chain-controller";
382
+ import { buildResponseLanguageReminder, detectResponseLanguage } from "./response-language";
381
383
 
382
384
  export { DefaultModelSelectionRecoveryError } from "./default-model-selection";
383
385
 
@@ -868,6 +870,14 @@ const KIMI_CODE_FIRST_EVENT_TIMEOUT_MESSAGES = {
868
870
  ]),
869
871
  } as const;
870
872
 
873
+ /**
874
+ * Configured default-chain origins that mean "exactly this model": a subagent call
875
+ * composed by the task router, and an explicit `--model` at startup. These never get
876
+ * `fallback.models` or automatic entries appended. (A temporary pick installs its own
877
+ * runtime controller and never reaches the configured-chain path at all.)
878
+ */
879
+ const EXACT_FALLBACK_CHAIN_ORIGINS: ReadonlySet<string> = new Set(["subagent", "startup-override"]);
880
+
871
881
  const ALIBABA_TOKEN_PLAN_PROVIDER = "alibaba-token-plan";
872
882
  const ALIBABA_TOKEN_PLAN_FIRST_EVENT_TIMEOUT_MESSAGES = {
873
883
  "openai-responses": new Set([
@@ -7832,6 +7842,7 @@ export class AgentSession {
7832
7842
  const eagerTodoPrelude =
7833
7843
  !options?.synthetic && !hasPendingUserDirective ? this.#createEagerTodoPrelude(expandedText) : undefined;
7834
7844
  const uiSkillPrelude = options?.synthetic ? undefined : this.#createUiSkillPrelude(expandedText);
7845
+ const languagePrelude = options?.synthetic ? undefined : this.#createResponseLanguagePrelude(expandedText);
7835
7846
 
7836
7847
  const userContent: (TextContent | ImageContent)[] = [{ type: "text", text: expandedText }];
7837
7848
  if (options?.images) {
@@ -7861,8 +7872,9 @@ export class AgentSession {
7861
7872
  await this.#promptWithMessage(message, expandedText, {
7862
7873
  ...options,
7863
7874
  prependMessages:
7864
- eagerTodoPrelude || uiSkillPrelude
7875
+ eagerTodoPrelude || uiSkillPrelude || languagePrelude
7865
7876
  ? [
7877
+ ...(languagePrelude ? [languagePrelude] : []),
7866
7878
  ...(uiSkillPrelude ? [uiSkillPrelude] : []),
7867
7879
  ...(eagerTodoPrelude ? [eagerTodoPrelude.message] : []),
7868
7880
  ]
@@ -12136,6 +12148,21 @@ export class AgentSession {
12136
12148
  * routing and therefore costs nothing extra. The patterns stay in front of it: when
12137
12149
  * they match, the model is never asked.
12138
12150
  */
12151
+ /**
12152
+ * Name the user's language at the start of the turn when their message is clearly in a
12153
+ * non-Latin script, so the final report after long English tool output stays in it.
12154
+ */
12155
+ #createResponseLanguagePrelude(promptText: string): AgentMessage | undefined {
12156
+ const language = detectResponseLanguage(promptText);
12157
+ if (!language) return undefined;
12158
+ return {
12159
+ role: "developer",
12160
+ content: [{ type: "text", text: buildResponseLanguageReminder(language) }],
12161
+ attribution: "agent",
12162
+ timestamp: Date.now(),
12163
+ };
12164
+ }
12165
+
12139
12166
  #createUiSkillPrelude(promptText: string): AgentMessage | undefined {
12140
12167
  if (this.#planModeState?.enabled) return undefined;
12141
12168
  const matched = buildUiSkillActivationContext(promptText);
@@ -13903,10 +13930,28 @@ export class AgentSession {
13903
13930
  controller.seedResolution(activeIndex, [...controller.skips, ...resolution.skips]);
13904
13931
  }
13905
13932
  if (!resolution.model) throw new Error(this.#fallbackExhaustionError(controller));
13933
+ if ((controller.chain.appendedFrom ?? controller.chain.entries.length) < 2) {
13934
+ // A one-model configuration is a chain only through appended fallbacks. With its
13935
+ // model usable and the session not sitting on an appended fallback, leave the live
13936
+ // model alone exactly as before anything was appended: a context promotion or a
13937
+ // temporary scope stays, and an unchanged model is not re-set (which would close
13938
+ // provider sessions such as the Codex websocket on every prompt).
13939
+ if (activeIndex === resolutionStart && !this.#isOnAppendedFallback(controller)) return;
13940
+ if (this.model && modelsAreEqual(this.model, resolution.model)) return;
13941
+ }
13906
13942
  this.#setModelAuthoritatively(resolution.model, "restore");
13907
13943
  this.setThinkingLevel(resolution.explicitThinkingLevel ? resolution.thinkingLevel : this.thinkingLevel);
13908
13944
  }
13909
13945
 
13946
+ /** True when the live model is one of the entries appended after the configured chain. */
13947
+ #isOnAppendedFallback(controller: FallbackChainController): boolean {
13948
+ const model = this.model;
13949
+ const appendedFrom = controller.chain.appendedFrom;
13950
+ if (!model || appendedFrom === undefined) return false;
13951
+ const key = `${model.provider}/${model.id}`;
13952
+ return controller.chain.entries.slice(appendedFrom).some(entry => entry === key || entry.startsWith(`${key}:`));
13953
+ }
13954
+
13910
13955
  /**
13911
13956
  * Materialize the default controller from the persisted configured-chain
13912
13957
  * metadata. Consumers seed only resolution state; role/origin/identity stay
@@ -13925,11 +13970,12 @@ export class AgentSession {
13925
13970
  if (materializeSettingsChain) {
13926
13971
  this.setConfiguredModelChain("default", settingsEntries, "modelRoles");
13927
13972
  }
13928
- const chain: ConfiguredFallbackChain = materializeSettingsChain
13973
+ const configured: ConfiguredFallbackChain = materializeSettingsChain
13929
13974
  ? { role: "default", entries: settingsEntries, origin: "modelRoles", explicitHead: true }
13930
13975
  : configuredChain
13931
13976
  ? { ...configuredChain, entries: [...configuredChain.entries] }
13932
13977
  : { role: "default", entries: settingsEntries, origin: "session", explicitHead: true };
13978
+ const chain = this.#withFallbackExtras(configured);
13933
13979
  const existing = this.#defaultFallbackController;
13934
13980
  if (
13935
13981
  existing &&
@@ -13943,6 +13989,76 @@ export class AgentSession {
13943
13989
  return this.#defaultFallbackController;
13944
13990
  }
13945
13991
 
13992
+ /**
13993
+ * Append what runs after the configured chain when it is blocked: the user's
13994
+ * `fallback.models`, then (with `fallback.auto`) one model from each other
13995
+ * logged-in provider. Extras live only in the controller; the configured chain
13996
+ * the session persists is untouched. Temporary picks, subagent calls and an
13997
+ * explicit `--model` stay exactly what was asked for.
13998
+ */
13999
+ #withFallbackExtras(chain: ConfiguredFallbackChain): ConfiguredFallbackChain {
14000
+ if (EXACT_FALLBACK_CHAIN_ORIGINS.has(chain.origin)) return chain;
14001
+ const entries = [...chain.entries];
14002
+ const seen = new Set(entries);
14003
+ const push = (selector: string): void => {
14004
+ const trimmed = selector.trim();
14005
+ if (!trimmed || seen.has(trimmed)) return;
14006
+ seen.add(trimmed);
14007
+ entries.push(trimmed);
14008
+ };
14009
+ for (const selector of this.settings.get("fallback.models")) push(selector);
14010
+ // Automatic picks stay out of an `enabledModels` allow-list (a deliberate scope) and
14011
+ // out of an explicit `retry.enabled: false` (the user asked for failures to surface).
14012
+ if (
14013
+ this.settings.get("fallback.auto") &&
14014
+ this.settings.get("retry.enabled") !== false &&
14015
+ this.settings.get("enabledModels").length === 0
14016
+ ) {
14017
+ // Depends only on configured intent (never the live model), so the chain stays
14018
+ // stable while a fallback is active and when the primary comes back.
14019
+ const excludeProviders = new Set<string>();
14020
+ for (const entry of entries) {
14021
+ const provider = selectorProvider(entry);
14022
+ if (provider) excludeProviders.add(provider);
14023
+ }
14024
+ for (const selector of this.#autoFallbackTail(excludeProviders)) push(selector);
14025
+ }
14026
+ return entries.length === chain.entries.length
14027
+ ? chain
14028
+ : { ...chain, entries, appendedFrom: chain.entries.length };
14029
+ }
14030
+
14031
+ #autoFallbackTail(excludeProviders: ReadonlySet<string>): string[] {
14032
+ try {
14033
+ const registry = this.#modelRegistry;
14034
+ return autoFallbackSelectors({
14035
+ available: registry.getAvailable(),
14036
+ hasCredentials: provider => registry.authStorage.hasAuth(provider),
14037
+ excludeProviders,
14038
+ usageOrder: this.settings.getStorage()?.getModelUsageOrder(),
14039
+ });
14040
+ } catch (error) {
14041
+ logger.debug("Automatic model fallback candidates unavailable", {
14042
+ error: error instanceof Error ? error.message : String(error),
14043
+ });
14044
+ return [];
14045
+ }
14046
+ }
14047
+
14048
+ /**
14049
+ * The default fallback chain the next prompt walks, automatic entries included,
14050
+ * and the position currently in use.
14051
+ */
14052
+ getDefaultFallbackChain(): { entries: readonly string[]; activeIndex: number; appendedFrom: number } {
14053
+ const controller = this.#defaultFallbackChain();
14054
+ const entries = [...controller.chain.entries];
14055
+ return {
14056
+ entries,
14057
+ activeIndex: controller.activeIndex,
14058
+ appendedFrom: controller.chain.appendedFrom ?? entries.length,
14059
+ };
14060
+ }
14061
+
13946
14062
  async #handleManagedAttemptOutcome(outcome: ManagedAttemptOutcome): Promise<ManagedAttemptDecision> {
13947
14063
  if (outcome.type === "run_terminal") {
13948
14064
  this.#defaultFallbackChain().resetAttemptBudget();
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { getAgentDbPath, getAgentDir } from "@sayknow-cli/utils";
11
11
  import { resolveConfigValue } from "../config/resolve-config-value";
12
+ import type { Settings } from "../config/settings";
12
13
  import { resolveAuthBrokerConfig } from "./auth-broker-config";
13
14
  import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./auth-storage";
14
15
 
@@ -25,7 +26,7 @@ import { AuthBrokerClient, AuthStorage, RemoteAuthCredentialStore } from "./auth
25
26
  */
26
27
  export async function discoverAuthStorage(agentDir: string = getAgentDir()): Promise<AuthStorage> {
27
28
  const brokerConfig = await resolveAuthBrokerConfig();
28
- const credentialRankingMode = resolveCredentialRankingMode();
29
+ const credentialRankingMode = credentialRankingModeFromEnv();
29
30
  if (brokerConfig) {
30
31
  const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
31
32
  const initialResult = await client.fetchSnapshot();
@@ -70,14 +71,28 @@ export async function discoverAuthStorage(agentDir: string = getAgentDir()): Pro
70
71
  }
71
72
 
72
73
  /**
73
- * Opt-in multi-account credential ranking mode, read from the
74
- * `SKC_CREDENTIAL_RANKING_MODE` env var. Unset/unknown → `undefined`, leaving
75
- * {@link AuthStorage}'s default (`balanced`) untouched. `earliest-reset`
76
- * switches to earliest-expiry-first selection so soon-to-reset tumbling-window
77
- * quota is drained before it is lost.
74
+ * Per-machine multi-account ranking override from `SKC_CREDENTIAL_RANKING_MODE`.
75
+ * Unset/unknown → `undefined`, so the `auth.credentialRankingMode` setting (or
76
+ * {@link AuthStorage}'s `balanced` default) decides. `earliest-reset` switches to
77
+ * earliest-expiry-first selection so soon-to-reset tumbling-window quota is
78
+ * drained before it is lost.
78
79
  */
79
- function resolveCredentialRankingMode(): "balanced" | "earliest-reset" | undefined {
80
+ export function credentialRankingModeFromEnv(): "balanced" | "earliest-reset" | undefined {
80
81
  const raw = process.env.SKC_CREDENTIAL_RANKING_MODE?.trim();
81
82
  if (raw === "balanced" || raw === "earliest-reset") return raw;
82
83
  return undefined;
83
84
  }
85
+
86
+ /**
87
+ * Apply the `auth.credentialRankingMode` setting to a credential store. The env
88
+ * var still wins, so a machine that pins a mode keeps it whatever config says.
89
+ * Returns the mode now in effect.
90
+ */
91
+ export function applyCredentialRankingModeSetting(
92
+ storage: Pick<AuthStorage, "setCredentialRankingMode">,
93
+ settings: Pick<Settings, "get">,
94
+ ): "balanced" | "earliest-reset" {
95
+ const mode = credentialRankingModeFromEnv() ?? settings.get("auth.credentialRankingMode");
96
+ storage.setCredentialRankingMode(mode);
97
+ return mode;
98
+ }
@@ -0,0 +1,59 @@
1
+ import type { Api, Model } from "@sayknow-cli/ai";
2
+ import { defaultModelPerProvider } from "../config/model-resolver";
3
+
4
+ /**
5
+ * How many other providers the automatic tail may add. Each entry can cost up to
6
+ * `fallback.maxAttempts` tries before the chain moves on, so the tail stays short.
7
+ */
8
+ export const AUTO_FALLBACK_PROVIDER_LIMIT = 3;
9
+
10
+ /** Provider of a `provider/model[:level]` selector, or undefined for bare aliases. */
11
+ export function selectorProvider(selector: string): string | undefined {
12
+ const slash = selector.indexOf("/");
13
+ return slash > 0 ? selector.slice(0, slash) : undefined;
14
+ }
15
+
16
+ export interface AutoFallbackInput {
17
+ /** Models whose provider has auth configured (`ModelRegistry.getAvailable()`). */
18
+ available: readonly Model<Api>[];
19
+ /** True when the provider has a stored credential or API key (keyless local providers do not count). */
20
+ hasCredentials: (provider: string) => boolean;
21
+ /** Providers already in the chain; the tail adds a different provider or nothing. */
22
+ excludeProviders: ReadonlySet<string>;
23
+ /** Most-recently-used `provider/id` keys, newest first. */
24
+ usageOrder?: readonly string[];
25
+ limit?: number;
26
+ }
27
+
28
+ /**
29
+ * One model per other logged-in provider, for the automatic tail of the default
30
+ * fallback chain. Within a provider the model the user used most recently wins,
31
+ * then the provider's curated default; a provider with neither is skipped rather
32
+ * than guessed, because catalog order would land on an old model. Providers used
33
+ * recently come first, then the rest in curated-default order.
34
+ */
35
+ export function autoFallbackSelectors(input: AutoFallbackInput): string[] {
36
+ const limit = input.limit ?? AUTO_FALLBACK_PROVIDER_LIMIT;
37
+ if (limit <= 0) return [];
38
+ const availableKeys = new Set(input.available.map(model => `${model.provider}/${model.id}`));
39
+ const picked = new Map<string, { selector: string; rank: number }>();
40
+ const eligible = (provider: string): boolean =>
41
+ !picked.has(provider) && !input.excludeProviders.has(provider) && input.hasCredentials(provider);
42
+
43
+ const usageOrder = input.usageOrder ?? [];
44
+ usageOrder.forEach((key, index) => {
45
+ const provider = selectorProvider(key);
46
+ if (!provider || !availableKeys.has(key) || !eligible(provider)) return;
47
+ picked.set(provider, { selector: key, rank: index });
48
+ });
49
+ Object.entries(defaultModelPerProvider).forEach(([provider, modelId], index) => {
50
+ const key = `${provider}/${modelId}`;
51
+ if (!availableKeys.has(key) || !eligible(provider)) return;
52
+ picked.set(provider, { selector: key, rank: usageOrder.length + index });
53
+ });
54
+
55
+ return [...picked.values()]
56
+ .sort((left, right) => left.rank - right.rank)
57
+ .slice(0, limit)
58
+ .map(entry => entry.selector);
59
+ }
@@ -7,6 +7,11 @@ export interface ConfiguredFallbackChain {
7
7
  origin: string;
8
8
  identity?: string;
9
9
  explicitHead: boolean;
10
+ /**
11
+ * Index of the first entry appended at runtime after the configured intent
12
+ * (`fallback.models`, then automatic picks). Absent when nothing was appended.
13
+ */
14
+ appendedFrom?: number;
10
15
  }
11
16
 
12
17
  export interface FallbackFailure {