@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.
- package/CHANGELOG.md +20 -1
- package/README.md +8 -0
- package/dist/types/cli/local-provider-smoke.d.ts +59 -0
- package/dist/types/cli/notify-cli.d.ts +1 -1
- package/dist/types/cli/update-cli.d.ts +24 -1
- package/dist/types/commands/local-provider.d.ts +34 -0
- package/dist/types/config/model-registry.d.ts +5 -0
- package/dist/types/config/models-config-schema.d.ts +5 -0
- package/dist/types/config/settings-schema.d.ts +45 -0
- package/dist/types/config/settings.d.ts +7 -0
- package/dist/types/coordinator-mcp/server.d.ts +8 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +2 -2
- package/dist/types/gjc-runtime/team-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/tmux-common.d.ts +12 -0
- package/dist/types/modes/components/custom-model-preset-wizard.d.ts +1 -1
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/settings-selector.d.ts +4 -1
- package/dist/types/modes/components/status-line/types.d.ts +5 -7
- package/dist/types/modes/components/status-line.d.ts +4 -3
- package/dist/types/modes/controllers/input-controller.d.ts +1 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
- package/dist/types/modes/prompt-action-autocomplete.d.ts +1 -0
- package/dist/types/modes/tmux-scroll.d.ts +18 -0
- package/dist/types/modes/utils/terminal-bell.d.ts +3 -0
- package/dist/types/notifications/telegram-daemon-cli.d.ts +13 -2
- package/package.json +7 -7
- package/src/cli/local-provider-smoke.ts +632 -0
- package/src/cli/notify-cli.ts +4 -2
- package/src/cli/update-cli.ts +155 -13
- package/src/cli.ts +18 -0
- package/src/commands/launch.ts +46 -1
- package/src/commands/local-provider.ts +62 -0
- package/src/commands/notify.ts +1 -1
- package/src/config/model-registry.ts +55 -1
- package/src/config/models-config-schema.ts +9 -0
- package/src/config/settings-schema.ts +47 -0
- package/src/config/settings.ts +11 -0
- package/src/coordinator-mcp/server.ts +276 -34
- package/src/gjc-runtime/launch-tmux.ts +34 -8
- package/src/gjc-runtime/launch-worktree.ts +16 -2
- package/src/gjc-runtime/team-runtime.ts +21 -6
- package/src/gjc-runtime/tmux-common.ts +16 -0
- package/src/gjc-runtime/tmux-sessions.ts +13 -9
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/modes/components/agent-dashboard.ts +2 -1
- package/src/modes/components/custom-model-preset-wizard.ts +30 -189
- package/src/modes/components/model-selector.ts +182 -5
- package/src/modes/components/session-observer-overlay.ts +3 -3
- package/src/modes/components/settings-selector.ts +491 -5
- package/src/modes/components/status-line/segments.ts +7 -18
- package/src/modes/components/status-line/types.ts +6 -2
- package/src/modes/components/status-line.ts +69 -35
- package/src/modes/controllers/event-controller.ts +98 -4
- package/src/modes/controllers/extension-ui-controller.ts +3 -0
- package/src/modes/controllers/input-controller.ts +15 -0
- package/src/modes/controllers/selector-controller.ts +12 -4
- package/src/modes/prompt-action-autocomplete.ts +8 -0
- package/src/modes/tmux-scroll.ts +92 -0
- package/src/modes/utils/terminal-bell.ts +48 -0
- package/src/notifications/telegram-daemon-cli.ts +112 -9
- package/src/notifications/telegram-daemon.ts +4 -0
- package/src/session/agent-session.ts +74 -5
- 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 {
|
|
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<
|
|
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 =
|
|
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
|
-
|
|
38
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
286
|
-
if (!
|
|
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") {
|