@gajae-code/coding-agent 0.7.7 → 0.7.8

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 (63) hide show
  1. package/CHANGELOG.md +20 -1
  2. package/README.md +8 -0
  3. package/dist/types/cli/local-provider-smoke.d.ts +59 -0
  4. package/dist/types/cli/notify-cli.d.ts +1 -1
  5. package/dist/types/cli/update-cli.d.ts +24 -1
  6. package/dist/types/commands/local-provider.d.ts +34 -0
  7. package/dist/types/config/model-registry.d.ts +5 -0
  8. package/dist/types/config/models-config-schema.d.ts +5 -0
  9. package/dist/types/config/settings-schema.d.ts +45 -0
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/coordinator-mcp/server.d.ts +8 -0
  12. package/dist/types/gjc-runtime/launch-tmux.d.ts +2 -2
  13. package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
  14. package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
  15. package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
  16. package/dist/types/modes/components/model-selector.d.ts +2 -0
  17. package/dist/types/modes/components/settings-selector.d.ts +4 -1
  18. package/dist/types/modes/components/status-line/types.d.ts +5 -7
  19. package/dist/types/modes/components/status-line.d.ts +4 -3
  20. package/dist/types/modes/controllers/input-controller.d.ts +1 -0
  21. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  22. package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
  23. package/dist/types/modes/tmux-scroll.d.ts +18 -0
  24. package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
  25. package/dist/types/notifications/telegram-daemon-cli.d.ts +13 -2
  26. package/package.json +7 -7
  27. package/src/cli/local-provider-smoke.ts +632 -0
  28. package/src/cli/notify-cli.ts +4 -2
  29. package/src/cli/update-cli.ts +155 -13
  30. package/src/cli.ts +18 -0
  31. package/src/commands/launch.ts +46 -1
  32. package/src/commands/local-provider.ts +62 -0
  33. package/src/commands/notify.ts +1 -1
  34. package/src/config/model-registry.ts +55 -1
  35. package/src/config/models-config-schema.ts +9 -0
  36. package/src/config/settings-schema.ts +47 -0
  37. package/src/config/settings.ts +11 -0
  38. package/src/coordinator-mcp/server.ts +276 -34
  39. package/src/gjc-runtime/launch-tmux.ts +34 -8
  40. package/src/gjc-runtime/launch-worktree.ts +16 -2
  41. package/src/gjc-runtime/team-runtime.ts +21 -6
  42. package/src/gjc-runtime/tmux-common.ts +16 -0
  43. package/src/gjc-runtime/tmux-sessions.ts +13 -9
  44. package/src/internal-urls/docs-index.generated.ts +5 -4
  45. package/src/modes/components/agent-dashboard.ts +2 -1
  46. package/src/modes/components/custom-model-preset-wizard.ts +30 -189
  47. package/src/modes/components/model-selector.ts +182 -5
  48. package/src/modes/components/session-observer-overlay.ts +3 -3
  49. package/src/modes/components/settings-selector.ts +491 -5
  50. package/src/modes/components/status-line/segments.ts +7 -18
  51. package/src/modes/components/status-line/types.ts +6 -2
  52. package/src/modes/components/status-line.ts +69 -35
  53. package/src/modes/controllers/event-controller.ts +98 -4
  54. package/src/modes/controllers/extension-ui-controller.ts +3 -0
  55. package/src/modes/controllers/input-controller.ts +15 -0
  56. package/src/modes/controllers/selector-controller.ts +12 -4
  57. package/src/modes/prompt-action-autocomplete.ts +8 -0
  58. package/src/modes/tmux-scroll.ts +92 -0
  59. package/src/modes/utils/terminal-bell.ts +48 -0
  60. package/src/notifications/telegram-daemon-cli.ts +112 -9
  61. package/src/notifications/telegram-daemon.ts +4 -0
  62. package/src/session/agent-session.ts +74 -5
  63. package/src/slash-commands/builtin-registry.ts +106 -13
@@ -1,14 +1,16 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { Settings } from "../config/settings";
3
+ import { YAML } from "bun";
4
+ import type { Settings } from "../config/settings";
4
5
  import { getNotificationConfig, isGloballyConfigured } from "./config";
5
6
  import { daemonPaths, TelegramNotificationDaemon } from "./telegram-daemon";
6
7
  import { clearTelegramControlRequest, readTelegramControlRequest } from "./telegram-daemon-control";
7
8
 
8
9
  export interface RunDaemonInternalDeps {
9
- SettingsImpl?: Pick<typeof Settings, "init">;
10
+ SettingsImpl?: { init: (options?: { agentDir?: string }) => Promise<Pick<Settings, "get" | "getAgentDir">> };
10
11
  DaemonImpl?: typeof TelegramNotificationDaemon;
11
12
  processPid?: number;
13
+ pidAlive?: (pid: number) => boolean;
12
14
  }
13
15
 
14
16
  function argValue(argv: string[], name: string): string | undefined {
@@ -16,9 +18,105 @@ function argValue(argv: string[], name: string): string | undefined {
16
18
  return i >= 0 ? argv[i + 1] : undefined;
17
19
  }
18
20
 
21
+ function getByPath(obj: unknown, pathSegments: string[]): unknown {
22
+ let current = obj;
23
+ for (const segment of pathSegments) {
24
+ if (!current || typeof current !== "object" || Array.isArray(current)) return undefined;
25
+ current = (current as Record<string, unknown>)[segment];
26
+ }
27
+ return current;
28
+ }
29
+
30
+ function asString(value: unknown): string | undefined {
31
+ return typeof value === "string" && value.length > 0 ? value : undefined;
32
+ }
33
+
34
+ function asBoolean(value: unknown, fallback: boolean): boolean {
35
+ return typeof value === "boolean" ? value : fallback;
36
+ }
37
+
38
+ function asIdleTimeoutMs(value: unknown): number {
39
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 60_000;
40
+ }
41
+
42
+ export function createLightweightDaemonSettings(input: {
43
+ agentDir: string;
44
+ rawConfig?: unknown;
45
+ }): Pick<Settings, "get" | "getAgentDir"> {
46
+ const rawConfig = input.rawConfig && typeof input.rawConfig === "object" ? input.rawConfig : {};
47
+ return {
48
+ get(pathName: string): unknown {
49
+ const value = getByPath(rawConfig, pathName.split("."));
50
+ switch (pathName) {
51
+ case "notifications.enabled":
52
+ return asBoolean(value, false);
53
+ case "notifications.telegram.botToken":
54
+ case "notifications.telegram.chatId":
55
+ case "notifications.discord.botToken":
56
+ case "notifications.discord.channelId":
57
+ case "notifications.slack.botToken":
58
+ case "notifications.slack.channelId":
59
+ return asString(value);
60
+ case "notifications.redact":
61
+ return asBoolean(value, false);
62
+ case "notifications.verbosity":
63
+ return value === "verbose" ? "verbose" : "lean";
64
+ case "notifications.daemon.idleTimeoutMs":
65
+ return asIdleTimeoutMs(value);
66
+ default:
67
+ return undefined;
68
+ }
69
+ },
70
+ getAgentDir(): string {
71
+ return input.agentDir;
72
+ },
73
+ } as Pick<Settings, "get" | "getAgentDir">;
74
+ }
75
+
76
+ export async function loadLightweightDaemonSettings(agentDir: string): Promise<Pick<Settings, "get" | "getAgentDir">> {
77
+ const configPath = path.join(agentDir, "config.yml");
78
+ let rawConfig: unknown = {};
79
+ try {
80
+ rawConfig = YAML.parse(await fs.promises.readFile(configPath, "utf8"));
81
+ } catch (error) {
82
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
83
+ }
84
+ return createLightweightDaemonSettings({ agentDir, rawConfig });
85
+ }
86
+
87
+ async function resolveDaemonSettings(
88
+ agentDir: string,
89
+ deps: RunDaemonInternalDeps,
90
+ ): Promise<Pick<Settings, "get" | "getAgentDir">> {
91
+ if (deps.SettingsImpl) return await deps.SettingsImpl.init({ agentDir });
92
+ return await loadLightweightDaemonSettings(agentDir);
93
+ }
94
+
95
+ export function ownerPidFromOwnerId(ownerId: string): number | undefined {
96
+ const match = /^(\d+)(?:-|$)/.exec(ownerId);
97
+ if (!match) return undefined;
98
+ const pid = Number(match[1]);
99
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : undefined;
100
+ }
101
+
102
+ function defaultPidAlive(pid: number): boolean {
103
+ try {
104
+ process.kill(pid, 0);
105
+ return true;
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ function ownerProcessIsAlive(ownerId: string, deps: RunDaemonInternalDeps): boolean {
112
+ const ownerPid = ownerPidFromOwnerId(ownerId);
113
+ if (ownerPid === undefined) return true;
114
+ return (deps.pidAlive ?? defaultPidAlive)(ownerPid);
115
+ }
116
+
19
117
  export async function runDaemonSmoke(opts: { agentDir?: string } = {}): Promise<void> {
20
118
  const agentDir = opts.agentDir ?? fs.mkdtempSync(path.join(process.cwd(), ".telegram-daemon-smoke-"));
21
- const settings = Settings.isolated({});
119
+ const settings = createLightweightDaemonSettings({ agentDir, rawConfig: {} });
22
120
  const paths = daemonPaths(agentDir);
23
121
  await fs.promises.mkdir(paths.dir, { recursive: true, mode: 0o700 });
24
122
  const tempLock = `${paths.lock}.smoke.${process.pid}`;
@@ -34,12 +132,17 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
34
132
  if (smoke) return runDaemonSmoke({ agentDir });
35
133
  const ownerId = argValue(argv, "--owner-id");
36
134
  if (!ownerId) throw new Error("missing --owner-id");
37
- const settings = await (deps.SettingsImpl ?? Settings).init(agentDir ? { agentDir } : {});
38
- const cfg = getNotificationConfig(settings);
135
+ if (!ownerProcessIsAlive(ownerId, deps)) {
136
+ process.stderr.write(`GJC notify daemon exiting: owner process from --owner-id ${ownerId} is not alive.\n`);
137
+ return;
138
+ }
139
+ const resolvedAgentDir = agentDir ?? process.env.GJC_CODING_AGENT_DIR ?? path.join(process.cwd(), ".gjc", "agent");
140
+ const settings = await resolveDaemonSettings(resolvedAgentDir, deps);
141
+ const cfg = getNotificationConfig(settings as Settings);
39
142
  if (!isGloballyConfigured(cfg) || !cfg.botToken || !cfg.chatId) return;
40
143
  const Daemon = deps.DaemonImpl ?? TelegramNotificationDaemon;
41
144
  const daemon = new Daemon({
42
- settings,
145
+ settings: settings as Settings,
43
146
  ownerId,
44
147
  botToken: cfg.botToken,
45
148
  chatId: cfg.chatId,
@@ -47,15 +150,15 @@ export async function runDaemonInternal(argv: string[], deps: RunDaemonInternalD
47
150
  pid: deps.processPid ?? process.pid,
48
151
  control: {
49
152
  shouldStop: async owner => {
50
- const req = await readTelegramControlRequest(settings);
153
+ const req = await readTelegramControlRequest(settings as Settings);
51
154
  return Boolean(req && (!req.ownerId || req.ownerId === owner));
52
155
  },
53
156
  clear: async owner => {
54
- const req = await readTelegramControlRequest(settings);
157
+ const req = await readTelegramControlRequest(settings as Settings);
55
158
  // Only clear a request that targets this daemon owner, so an exiting
56
159
  // daemon never erases a newer request meant for a different owner.
57
160
  if (req && (!req.ownerId || req.ownerId === owner)) {
58
- await clearTelegramControlRequest(settings, req.requestId);
161
+ await clearTelegramControlRequest(settings as Settings, req.requestId);
59
162
  }
60
163
  },
61
164
  },
@@ -2005,6 +2005,10 @@ export class TelegramNotificationDaemon {
2005
2005
  { command: "verbose", description: "Mirror full tool output + reasoning in this thread" },
2006
2006
  { command: "lean", description: "Mirror assistant text + tool names only (default)" },
2007
2007
  { command: "redact", description: "Toggle redaction of streamed content: /redact <on|off>" },
2008
+ { command: "session_create", description: "Create a GJC session: path, worktree, or dir" },
2009
+ { command: "session_recent", description: "List recent GJC sessions" },
2010
+ { command: "session_close", description: "Close a GJC-managed session" },
2011
+ { command: "session_resume", description: "Resume or reattach a session" },
2008
2012
  ],
2009
2013
  });
2010
2014
  } catch {
@@ -526,6 +526,15 @@ export interface SessionStats {
526
526
  type RetryFallbackChains = Record<string, string[]>;
527
527
 
528
528
  type RetryFallbackRevertPolicy = "never" | "cooldown-expiry";
529
+ type RetryErrorClassification =
530
+ | "none"
531
+ | "overflow"
532
+ | "terminal"
533
+ | "usage_limit"
534
+ | "first_event_timeout"
535
+ | "transient"
536
+ | "local_unavailable"
537
+ | "unknown";
529
538
 
530
539
  interface RetryFallbackSelector {
531
540
  raw: string;
@@ -563,6 +572,37 @@ function formatRetryFallbackBaseSelector(selector: RetryFallbackSelector): strin
563
572
  return `${selector.provider}/${selector.id}`;
564
573
  }
565
574
 
575
+ function isLocalModelEndpoint(model: Model | undefined): boolean {
576
+ if (!model) return false;
577
+ if (model.provider === "ollama" || model.provider === "lm-studio" || model.provider === "llama.cpp") {
578
+ return true;
579
+ }
580
+ try {
581
+ const hostname = new URL(model.baseUrl).hostname.toLowerCase();
582
+ if (
583
+ hostname === "localhost" ||
584
+ hostname === "0.0.0.0" ||
585
+ hostname === "::1" ||
586
+ hostname === "[::1]" ||
587
+ hostname.endsWith(".local")
588
+ ) {
589
+ return true;
590
+ }
591
+ if (/^127\./.test(hostname) || /^10\./.test(hostname) || /^192\.168\./.test(hostname)) {
592
+ return true;
593
+ }
594
+ const private172 = /^172\.(\d{1,2})\./.exec(hostname);
595
+ if (private172) {
596
+ const secondOctet = Number(private172[1]);
597
+ return secondOctet >= 16 && secondOctet <= 31;
598
+ }
599
+ } catch {
600
+ // A malformed base URL is configuration, not availability. Keep it visible.
601
+ return false;
602
+ }
603
+ return false;
604
+ }
605
+
566
606
  const IRC_REPLY_MAX_BYTES = 4096;
567
607
 
568
608
  /**
@@ -8221,6 +8261,12 @@ export class AgentSession {
8221
8261
  */
8222
8262
  #isRetryableError(message: AssistantMessage): boolean {
8223
8263
  const classification = this.#classifyErrorForRetry(message);
8264
+ if (classification === "local_unavailable") {
8265
+ return this.#hasRetryFallbackCandidate({
8266
+ currentSelector: this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined,
8267
+ requireNonLocal: true,
8268
+ });
8269
+ }
8224
8270
  return (
8225
8271
  classification === "usage_limit" ||
8226
8272
  classification === "transient" ||
@@ -8260,6 +8306,12 @@ export class AgentSession {
8260
8306
  return /timed?\s*out while waiting for the first event|timeout waiting for first/i.test(errorMessage);
8261
8307
  }
8262
8308
 
8309
+ #isLocalProviderAvailabilityErrorMessage(errorMessage: string): boolean {
8310
+ return /connection.?refused|econnrefused|timed?\s*out|timeout|fetch failed|network.?error|socket hang up|terminated|service.?unavailable|server.?error|internal.?error|503|model_not_found|model not found|no such model|unknown model|model .*not.*(found|available|loaded)|not ready|not.?ready|warming|loading|currently loading|try again|out of memory|\boom\b|memory guard|insufficient memory|not enough memory|failed to allocate|kv.?cache|malformed (stream|streaming|sse)|invalid (stream|streaming|sse)|stream envelope error|unexpected end of (json|input)|unterminated json|no error details in response/i.test(
8311
+ errorMessage,
8312
+ );
8313
+ }
8314
+
8263
8315
  /**
8264
8316
  * Whether a first-event timeout on the error's provider should fail closed —
8265
8317
  * i.e. retry a bounded number of times (capped at retry.maxRetries) and then
@@ -8303,13 +8355,14 @@ export class AgentSession {
8303
8355
  * -> usage_limit (rotation) -> first_event_timeout (bounded retry) ->
8304
8356
  * transient (retry) -> unknown (retry).
8305
8357
  */
8306
- #classifyErrorForRetry(
8307
- message: AssistantMessage,
8308
- ): "none" | "overflow" | "terminal" | "usage_limit" | "first_event_timeout" | "transient" | "unknown" {
8358
+ #classifyErrorForRetry(message: AssistantMessage): RetryErrorClassification {
8309
8359
  if (message.stopReason !== "error" || !message.errorMessage) return "none";
8310
8360
  const contextWindow = this.model?.contextWindow ?? 0;
8311
8361
  if (isContextOverflow(message, contextWindow)) return "overflow";
8312
8362
  const err = message.errorMessage;
8363
+ if (isLocalModelEndpoint(this.model) && this.#isLocalProviderAvailabilityErrorMessage(err)) {
8364
+ return "local_unavailable";
8365
+ }
8313
8366
  // Stream-envelope errors are only transient in the pre-message_start
8314
8367
  // variant; any other envelope failure is structural and must surface.
8315
8368
  if (/anthropic stream envelope error:/i.test(err)) {
@@ -8444,6 +8497,20 @@ export class AgentSession {
8444
8497
  return chain;
8445
8498
  }
8446
8499
 
8500
+ #hasRetryFallbackCandidate(options: { currentSelector: string | undefined; requireNonLocal?: boolean }): boolean {
8501
+ if (!options.currentSelector) return false;
8502
+ const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(options.currentSelector);
8503
+ if (!role) return false;
8504
+ for (const selector of this.#findRetryFallbackCandidates(role, options.currentSelector)) {
8505
+ if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
8506
+ const candidate = this.#modelRegistry.find(selector.provider, selector.id);
8507
+ if (!candidate) continue;
8508
+ if (options.requireNonLocal && isLocalModelEndpoint(candidate)) continue;
8509
+ return true;
8510
+ }
8511
+ return false;
8512
+ }
8513
+
8447
8514
  #findRetryFallbackCandidates(role: string, currentSelector: string): RetryFallbackSelector[] {
8448
8515
  const chain = this.#getRetryFallbackEffectiveChain(role);
8449
8516
  if (chain.length <= 1) return [];
@@ -8497,7 +8564,7 @@ export class AgentSession {
8497
8564
  });
8498
8565
  }
8499
8566
 
8500
- async #tryRetryModelFallback(currentSelector: string): Promise<boolean> {
8567
+ async #tryRetryModelFallback(currentSelector: string, options?: { requireNonLocal?: boolean }): Promise<boolean> {
8501
8568
  const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
8502
8569
  if (!role) return false;
8503
8570
 
@@ -8505,6 +8572,7 @@ export class AgentSession {
8505
8572
  if (this.#isRetryFallbackSelectorSuppressed(selector)) continue;
8506
8573
  const candidate = this.#modelRegistry.find(selector.provider, selector.id);
8507
8574
  if (!candidate) continue;
8575
+ if (options?.requireNonLocal && isLocalModelEndpoint(candidate)) continue;
8508
8576
  const apiKey = await this.#modelRegistry.getApiKey(candidate, this.sessionId);
8509
8577
  if (!apiKey) continue;
8510
8578
  await this.#applyRetryFallbackCandidate(role, selector, currentSelector);
@@ -8615,6 +8683,7 @@ export class AgentSession {
8615
8683
  if (!retrySettings.enabled) return false;
8616
8684
  const retryClassification = this.#classifyErrorForRetry(message);
8617
8685
  const unboundedClass = retryClassification === "transient" || retryClassification === "unknown";
8686
+ const localUnavailable = retryClassification === "local_unavailable";
8618
8687
 
8619
8688
  const generation = this.#promptGeneration;
8620
8689
  this.#retryAttempt++;
@@ -8668,7 +8737,7 @@ export class AgentSession {
8668
8737
  const currentSelector = this.model ? formatRetryFallbackSelector(this.model, this.thinkingLevel) : undefined;
8669
8738
  if (!switchedCredential && currentSelector) {
8670
8739
  this.#noteRetryFallbackCooldown(currentSelector, parsedRetryAfterMs, errorMessage);
8671
- switchedModel = await this.#tryRetryModelFallback(currentSelector);
8740
+ switchedModel = await this.#tryRetryModelFallback(currentSelector, { requireNonLocal: localUnavailable });
8672
8741
  if (switchedModel) {
8673
8742
  delayMs = 0;
8674
8743
  } else if (parsedRetryAfterMs && parsedRetryAfterMs > delayMs) {
@@ -12,7 +12,12 @@ import {
12
12
  GJC_MODEL_ASSIGNMENT_TARGETS,
13
13
  type GjcModelAssignmentTargetId,
14
14
  } from "../config/model-registry";
15
- import { extractExplicitThinkingSelector, formatModelSelectorValue, parseModelPattern } from "../config/model-resolver";
15
+ import {
16
+ extractExplicitThinkingSelector,
17
+ formatModelSelectorValue,
18
+ parseModelPattern,
19
+ parseModelString,
20
+ } from "../config/model-resolver";
16
21
  import { clearPluginRootsAndCaches, resolveActiveProjectRegistryPath } from "../discovery/helpers.js";
17
22
  import { resolveMemoryBackend } from "../memory-backend";
18
23
  import { DynamicBorder } from "../modes/components/dynamic-border";
@@ -169,11 +174,32 @@ function splitExplicitThinkingSelector(selector: string): { baseSelector: string
169
174
  return thinkingLevel ? { baseSelector: trimmed.slice(0, colonIndex), thinkingLevel } : { baseSelector: trimmed };
170
175
  }
171
176
 
172
- function resolveModelCommandSelection(
177
+ interface ModelCommandSelection {
178
+ model: Model;
179
+ selector: string;
180
+ thinkingLevel?: ThinkingLevel;
181
+ }
182
+
183
+ interface ModelCommandResolutionFailure {
184
+ message: string;
185
+ }
186
+
187
+ type ModelCommandResolution =
188
+ | { ok: true; selection: ModelCommandSelection }
189
+ | { ok: false; failure: ModelCommandResolutionFailure };
190
+
191
+ function parseProviderQualifiedSelector(selector: string): { provider: string; modelId: string } | undefined {
192
+ const splitSelector = splitExplicitThinkingSelector(selector);
193
+ const parsed = parseModelString(splitSelector.baseSelector);
194
+ if (!parsed) return undefined;
195
+ return { provider: parsed.provider, modelId: parsed.id };
196
+ }
197
+
198
+ function resolveModelCommandSelectionFromAvailable(
173
199
  runtime: SlashCommandRuntime,
174
200
  selector: string,
175
- ): { model: Model; selector: string; thinkingLevel?: ThinkingLevel } | undefined {
176
- const availableModels = runtime.session.getAvailableModels?.() ?? [];
201
+ availableModels: Model[],
202
+ ): ModelCommandSelection | undefined {
177
203
  const matchPreferences = { usageOrder: runtime.settings.getStorage()?.getModelUsageOrder() };
178
204
  const resolved = parseModelPattern(selector, availableModels, matchPreferences, {
179
205
  modelRegistry: runtime.session.modelRegistry,
@@ -198,6 +224,78 @@ function resolveModelCommandSelection(
198
224
  };
199
225
  }
200
226
 
227
+ function formatDiscoverableProviderFailure(
228
+ selector: string,
229
+ provider: string,
230
+ modelId: string,
231
+ runtime: SlashCommandRuntime,
232
+ ): string {
233
+ const state = runtime.session.modelRegistry.getProviderDiscoveryState?.(provider);
234
+ const discovered = state?.models ?? [];
235
+ const base = `Unknown model: ${selector}.`;
236
+ if (!modelId.trim()) {
237
+ return `${base} Local provider model selectors must use provider/model-id syntax with a non-empty model id.`;
238
+ }
239
+ if (!state) {
240
+ return `${base} Provider ${provider} is configured for discovery but has not reported models yet.`;
241
+ }
242
+ if (state.status === "unavailable") {
243
+ const details = state.error ? ` (${state.error})` : "";
244
+ return `${base} Provider ${provider} discovery is unavailable${details}. Check the local endpoint and run /model again.`;
245
+ }
246
+ if (state.status === "unauthenticated") {
247
+ return `${base} Provider ${provider} requires authentication before model discovery.`;
248
+ }
249
+ if (state.status === "empty") {
250
+ return `${base} Provider ${provider} discovery succeeded but returned no models.`;
251
+ }
252
+ if (discovered.length > 0) {
253
+ const preview = discovered.slice(0, 8).join(", ");
254
+ const suffix = discovered.length > 8 ? ", …" : "";
255
+ return `${base} Provider ${provider} did not report model ${modelId}. Available local models: ${preview}${suffix}.`;
256
+ }
257
+ return `${base} Provider ${provider} did not report model ${modelId}.`;
258
+ }
259
+
260
+ async function resolveModelCommandSelection(
261
+ runtime: SlashCommandRuntime,
262
+ selector: string,
263
+ ): Promise<ModelCommandResolution> {
264
+ let availableModels = runtime.session.getAvailableModels?.() ?? [];
265
+ const initialSelection = resolveModelCommandSelectionFromAvailable(runtime, selector, availableModels as Model[]);
266
+ if (initialSelection) {
267
+ return { ok: true, selection: initialSelection };
268
+ }
269
+
270
+ const providerRef = parseProviderQualifiedSelector(selector);
271
+ const discoverableProviders = runtime.session.modelRegistry?.getDiscoverableProviders?.() ?? [];
272
+ if (providerRef && discoverableProviders.includes(providerRef.provider)) {
273
+ await runtime.session.modelRegistry.refreshProvider?.(providerRef.provider, "online");
274
+ availableModels = runtime.session.getAvailableModels?.() ?? [];
275
+ const refreshedSelection = resolveModelCommandSelectionFromAvailable(
276
+ runtime,
277
+ selector,
278
+ availableModels as Model[],
279
+ );
280
+ if (refreshedSelection) {
281
+ return { ok: true, selection: refreshedSelection };
282
+ }
283
+ return {
284
+ ok: false,
285
+ failure: {
286
+ message: formatDiscoverableProviderFailure(selector, providerRef.provider, providerRef.modelId, runtime),
287
+ },
288
+ };
289
+ }
290
+
291
+ return {
292
+ ok: false,
293
+ failure: {
294
+ message: `Unknown model: ${selector}. Configure or login to a provider first, then list/select models with /model.`,
295
+ },
296
+ };
297
+ }
298
+
201
299
  function modelSelectionUsage(runtime: SlashCommandRuntime, currentModelLine?: string): string {
202
300
  return [
203
301
  currentModelLine,
@@ -282,16 +380,11 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
282
380
  runtime,
283
381
  );
284
382
  }
285
- const selection = resolveModelCommandSelection(runtime, modelId);
286
- if (!selection) {
287
- return usage(
288
- modelSelectionUsage(
289
- runtime,
290
- `Unknown model: ${modelId}. Configure or login to a provider first, then list/select models with /model.`,
291
- ),
292
- runtime,
293
- );
383
+ const resolution = await resolveModelCommandSelection(runtime, modelId);
384
+ if (!resolution.ok) {
385
+ return usage(modelSelectionUsage(runtime, resolution.failure.message), runtime);
294
386
  }
387
+ const { selection } = resolution;
295
388
  try {
296
389
  const persistedSelector = formatModelSelectorValue(selection.selector, selection.thinkingLevel);
297
390
  if (parsedArgs.targetId === "default") {