@kenkaiiii/ggcoder 5.23.3 → 5.25.0

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 (90) hide show
  1. package/dist/app-sidecar.js +318 -12
  2. package/dist/app-sidecar.js.map +1 -1
  3. package/dist/core/agent-session-add-dir.test.d.ts +2 -0
  4. package/dist/core/agent-session-add-dir.test.d.ts.map +1 -0
  5. package/dist/core/agent-session-add-dir.test.js +110 -0
  6. package/dist/core/agent-session-add-dir.test.js.map +1 -0
  7. package/dist/core/agent-session.d.ts +29 -0
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +74 -4
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/local-endpoint-store.d.ts +50 -0
  12. package/dist/core/local-endpoint-store.d.ts.map +1 -0
  13. package/dist/core/local-endpoint-store.js +201 -0
  14. package/dist/core/local-endpoint-store.js.map +1 -0
  15. package/dist/core/local-endpoint-store.test.d.ts +2 -0
  16. package/dist/core/local-endpoint-store.test.d.ts.map +1 -0
  17. package/dist/core/local-endpoint-store.test.js +131 -0
  18. package/dist/core/local-endpoint-store.test.js.map +1 -0
  19. package/dist/core/network-guard.d.ts +47 -0
  20. package/dist/core/network-guard.d.ts.map +1 -0
  21. package/dist/core/network-guard.js +208 -0
  22. package/dist/core/network-guard.js.map +1 -0
  23. package/dist/core/network-guard.test.d.ts +2 -0
  24. package/dist/core/network-guard.test.d.ts.map +1 -0
  25. package/dist/core/network-guard.test.js +72 -0
  26. package/dist/core/network-guard.test.js.map +1 -0
  27. package/dist/core/prompt-commands.d.ts.map +1 -1
  28. package/dist/core/prompt-commands.js +55 -36
  29. package/dist/core/prompt-commands.js.map +1 -1
  30. package/dist/core/prompt-commands.test.js +49 -2
  31. package/dist/core/prompt-commands.test.js.map +1 -1
  32. package/dist/core/session-export.d.ts +98 -0
  33. package/dist/core/session-export.d.ts.map +1 -0
  34. package/dist/core/session-export.js +356 -0
  35. package/dist/core/session-export.js.map +1 -0
  36. package/dist/core/session-export.test.d.ts +2 -0
  37. package/dist/core/session-export.test.d.ts.map +1 -0
  38. package/dist/core/session-export.test.js +353 -0
  39. package/dist/core/session-export.test.js.map +1 -0
  40. package/dist/core/settings-manager.d.ts +5 -0
  41. package/dist/core/settings-manager.d.ts.map +1 -1
  42. package/dist/core/settings-manager.js +10 -0
  43. package/dist/core/settings-manager.js.map +1 -1
  44. package/dist/core/slash-commands.d.ts +18 -0
  45. package/dist/core/slash-commands.d.ts.map +1 -1
  46. package/dist/core/slash-commands.js +32 -0
  47. package/dist/core/slash-commands.js.map +1 -1
  48. package/dist/core/slash-commands.test.d.ts +2 -0
  49. package/dist/core/slash-commands.test.d.ts.map +1 -0
  50. package/dist/core/slash-commands.test.js +59 -0
  51. package/dist/core/slash-commands.test.js.map +1 -0
  52. package/dist/core/workspace-guard.d.ts +2 -0
  53. package/dist/core/workspace-guard.d.ts.map +1 -1
  54. package/dist/core/workspace-guard.js +4 -1
  55. package/dist/core/workspace-guard.js.map +1 -1
  56. package/dist/core/workspace-guard.test.js +21 -0
  57. package/dist/core/workspace-guard.test.js.map +1 -1
  58. package/dist/system-prompt.d.ts +11 -1
  59. package/dist/system-prompt.d.ts.map +1 -1
  60. package/dist/system-prompt.js +19 -4
  61. package/dist/system-prompt.js.map +1 -1
  62. package/dist/system-prompt.test.js +9 -0
  63. package/dist/system-prompt.test.js.map +1 -1
  64. package/dist/tools/bash.d.ts +2 -1
  65. package/dist/tools/bash.d.ts.map +1 -1
  66. package/dist/tools/bash.js +9 -1
  67. package/dist/tools/bash.js.map +1 -1
  68. package/dist/tools/bash.test.js +19 -0
  69. package/dist/tools/bash.test.js.map +1 -1
  70. package/dist/tools/grep.d.ts +3 -1
  71. package/dist/tools/grep.d.ts.map +1 -1
  72. package/dist/tools/grep.js +132 -10
  73. package/dist/tools/grep.js.map +1 -1
  74. package/dist/tools/grep.test.js +53 -0
  75. package/dist/tools/grep.test.js.map +1 -1
  76. package/dist/tools/index.d.ts +6 -0
  77. package/dist/tools/index.d.ts.map +1 -1
  78. package/dist/tools/index.js +3 -3
  79. package/dist/tools/index.js.map +1 -1
  80. package/dist/tools/web-fetch.d.ts +2 -1
  81. package/dist/tools/web-fetch.d.ts.map +1 -1
  82. package/dist/tools/web-fetch.js +17 -6
  83. package/dist/tools/web-fetch.js.map +1 -1
  84. package/dist/tools/web-fetch.test.js +34 -0
  85. package/dist/tools/web-fetch.test.js.map +1 -1
  86. package/dist/tools/web-search.d.ts +2 -1
  87. package/dist/tools/web-search.d.ts.map +1 -1
  88. package/dist/tools/web-search.js +21 -5
  89. package/dist/tools/web-search.js.map +1 -1
  90. package/package.json +4 -4
@@ -34,10 +34,12 @@ import { isWorkflowCommandText, countAssistantMessages, shouldStartAutopilotCycl
34
34
  import { driveAutopilotCycle, frameAutopilotInjection } from "./core/autopilot-cycle.js";
35
35
  import { validateKenModelPref, effectiveKenModel } from "./core/ken-model.js";
36
36
  import { normalizeAutopilotMarkersForHistory, normalizeAppMarkersForHistory, normalizeKenTurnsForHistory, restoreUserRow, restoreAssistantTexts, autopilotMarkerCopySeed, } from "./core/session-history.js";
37
+ import { sessionToMarkdown, defaultExportFilename, } from "./core/session-export.js";
37
38
  import { AuthStorage } from "./core/auth-storage.js";
38
39
  import { cleanupToolOutputs } from "./tools/overflow.js";
39
40
  import { readCappedBody } from "./utils/http-body.js";
40
- import { fetchSubscriptionUsage, MOONSHOT_OAUTH_KEY, SubscriptionUsageError, XIAOMI_CREDITS_KEY, } from "@kenkaiiii/gg-core";
41
+ import { fetchSubscriptionUsage, MOONSHOT_OAUTH_KEY, SubscriptionUsageError, XIAOMI_CREDITS_KEY, discoverLocalModels, findProbedModel, formatLocalModelId, parseLocalModelId, probeEndpoint, toModelInfo as localModelInfo, } from "@kenkaiiii/gg-core";
42
+ import { LocalEndpointError, addCustomEndpoint, listAllEndpoints, removeCustomEndpoint, syncEndpointCredentials, } from "./core/local-endpoint-store.js";
41
43
  import { loginAnthropic } from "./core/oauth/anthropic.js";
42
44
  import { loginOpenAI } from "./core/oauth/openai.js";
43
45
  import { loginGemini } from "./core/oauth/gemini.js";
@@ -45,7 +47,7 @@ import { loginKimi } from "./core/oauth/kimi.js";
45
47
  import { AUTH_PROVIDERS } from "./core/auth-providers.js";
46
48
  import { ensureAppDirs, loadSavedSettings } from "./config.js";
47
49
  import { SettingsManager } from "./core/settings-manager.js";
48
- import { getModel, getDefaultThinkingLevel, getContextWindow, MODELS, } from "./core/model-registry.js";
50
+ import { getModel, getDefaultThinkingLevel, getContextWindow, getAllModels, clearRuntimeModels, registerRuntimeModels, } from "./core/model-registry.js";
49
51
  import { resolveStartOrFallback } from "./core/resolve-start.js";
50
52
  import { getGitBranch, getGitDirtyFileCount, isGitRepo } from "./utils/git.js";
51
53
  import { getGitHubOpenCounts, getGitHubRepoSlug } from "./utils/github.js";
@@ -346,6 +348,11 @@ async function searchProjectFiles(cwd, rawQuery) {
346
348
  * hook prompt, by its distinctive opening phrase. Returns the hook kind so the
347
349
  * webview can render the short notice line instead of the full prompt body.
348
350
  */
351
+ /** `?tools=` on /export. Anything unrecognised (including absent) falls back to
352
+ * the human-facing `summary` default rather than dumping every payload. */
353
+ function parseToolDetail(value) {
354
+ return value === "none" || value === "full" ? value : "summary";
355
+ }
349
356
  function detectHookKind(text) {
350
357
  const t = text.trimStart();
351
358
  if (t.startsWith("Ideal? Review the actual work"))
@@ -614,6 +621,13 @@ async function main() {
614
621
  });
615
622
  const usageCache = new Map();
616
623
  const usageRequests = new Map();
624
+ // Last snapshot that actually carried windows, per provider. Replayed while a
625
+ // fetch is failing so the title meter never blinks out of existence. Bounded
626
+ // by USAGE_LAST_GOOD_MAX_AGE_MS — a provider that never recovers must stop
627
+ // reporting rather than freeze a percentage (and a long-past reset time) on
628
+ // screen forever. The 429 backoff alone runs to 24h, far past any usefulness.
629
+ const usageLastGood = new Map();
630
+ const USAGE_LAST_GOOD_MAX_AGE_MS = 30 * 60_000;
617
631
  // 429 backoff: quota endpoints are auxiliary UI data. Honor Retry-After when
618
632
  // provided; otherwise retain the unavailable snapshot for 30 minutes. Clamp
619
633
  // the provider value so a malformed header can neither hammer the endpoint
@@ -628,6 +642,9 @@ async function main() {
628
642
  // Moonshot platform API key is metered per-token, not per plan window.
629
643
  const authKey = provider === "moonshot" ? MOONSHOT_OAUTH_KEY : provider;
630
644
  if (!(await auth.hasProviderAuth(authKey))) {
645
+ // Logged out: drop the replay cache so a later login on a DIFFERENT
646
+ // account can never inherit the previous one's numbers.
647
+ usageLastGood.delete(provider);
631
648
  return { provider, displayName, connected: false, windows: [], fetchedAt: Date.now() };
632
649
  }
633
650
  try {
@@ -671,6 +688,18 @@ async function main() {
671
688
  ...(backoffMs !== undefined && { backoffMs: String(backoffMs) }),
672
689
  });
673
690
  const connected = await auth.hasProviderAuth(authKey);
691
+ // Transient failures (notably the 429s these auxiliary quota endpoints
692
+ // hand out) must not blank the meter. Keep serving the last good
693
+ // snapshot, flagged `stale`, so the bar stays put instead of flickering
694
+ // out for the whole backoff window and back in on the next success.
695
+ // Past the max age it's dropped — no data beats confidently wrong data.
696
+ const lastGood = usageLastGood.get(provider);
697
+ if (lastGood && Date.now() - lastGood.fetchedAt >= USAGE_LAST_GOOD_MAX_AGE_MS) {
698
+ usageLastGood.delete(provider);
699
+ }
700
+ else if (connected && lastGood) {
701
+ return { ...lastGood, stale: true };
702
+ }
674
703
  return {
675
704
  provider,
676
705
  displayName,
@@ -692,6 +721,11 @@ async function main() {
692
721
  usageRequests.set(provider, request);
693
722
  try {
694
723
  const result = await request;
724
+ // Never re-store a replay — it would keep its original `fetchedAt`, but
725
+ // writing it back muddies the "last GOOD" contract for no gain.
726
+ if (result.connected && !result.error && !result.stale && result.windows.length > 0) {
727
+ usageLastGood.set(provider, result);
728
+ }
695
729
  // Anthropic can return utilization before it assigns reset timestamps
696
730
  // (notably before the account's first active request). Retry that partial
697
731
  // snapshot quickly; complete snapshots keep the normal one-minute cache.
@@ -1110,6 +1144,25 @@ async function createSession(deps, opts) {
1110
1144
  // /help
1111
1145
  .replaceAll(/see \/help/gi, "check the help menu"));
1112
1146
  }
1147
+ /**
1148
+ * Guidance for a connection failure against the ACTIVE local endpoint, or
1149
+ * undefined when that isn't the situation (so the generic wording stands).
1150
+ * "Disable your VPN / allow us through the firewall" is useless advice for a
1151
+ * server running on this machine — name what the user has to restart.
1152
+ */
1153
+ function localNetworkGuidance(source) {
1154
+ if (source !== "network")
1155
+ return undefined;
1156
+ const state = session.getState();
1157
+ if (state.provider !== "local")
1158
+ return undefined;
1159
+ const parsed = parseLocalModelId(state.model);
1160
+ const probe = localProbes.find((p) => p.endpoint.id === parsed?.endpointId);
1161
+ if (!probe)
1162
+ return undefined;
1163
+ return (`${probe.endpoint.label} stopped responding at ${probe.endpoint.baseUrl}. ` +
1164
+ "Start it again and retry, or pick another model.");
1165
+ }
1113
1166
  // Turn any thrown value into the same clear headline/message/guidance shape
1114
1167
  // the TUI shows (see gg-ai's formatError) instead of a bare `err.message`, log
1115
1168
  // the full detail, and broadcast it under `type` ("error" or "ken_error").
@@ -1119,7 +1172,7 @@ async function createSession(deps, opts) {
1119
1172
  function broadcastError(type, logLabel, err) {
1120
1173
  const f = formatError(err);
1121
1174
  const message = f.message ? desktopGuidance(f.message) : undefined;
1122
- const guidance = desktopGuidance(f.guidance);
1175
+ const guidance = localNetworkGuidance(f.source) ?? desktopGuidance(f.guidance);
1123
1176
  captureSidecarError(err, `app-sidecar.${logLabel.replaceAll(" ", "-")}`, {
1124
1177
  scope: type,
1125
1178
  ...(f.provider ? { provider: f.provider } : {}),
@@ -1227,6 +1280,99 @@ async function createSession(deps, opts) {
1227
1280
  }
1228
1281
  }
1229
1282
  log("INFO", "app-sidecar", "session ready", { provider, model, mode, chatAgent, cwd });
1283
+ // ── Local models (Ollama / LM Studio / llama.cpp / vLLM) ──
1284
+ // Probing four HTTP endpoints must never delay readiness, so this runs in the
1285
+ // background (same shape as backgroundMcpConnect) and pushes a models_change
1286
+ // frame when it lands.
1287
+ let localProbes = [];
1288
+ async function scanLocalModels(force) {
1289
+ const endpoints = await listAllEndpoints();
1290
+ const { probes, models } = await discoverLocalModels(endpoints, { force });
1291
+ localProbes = probes;
1292
+ // Only endpoints that answered get a credential: writing one for a server
1293
+ // that isn't running would make `hasProviderAuth("local")` true forever.
1294
+ await syncEndpointCredentials(probes.filter((probe) => probe.reachable).map((probe) => probe.endpoint));
1295
+ clearRuntimeModels((m) => m.provider === "local");
1296
+ registerRuntimeModels(models);
1297
+ log("INFO", "app-sidecar", "local model scan", {
1298
+ reachable: probes.filter((p) => p.reachable).length + "/" + probes.length,
1299
+ models: String(models.length),
1300
+ });
1301
+ return probes;
1302
+ }
1303
+ /** Endpoint rows + models, in the shape the Local models UI renders. */
1304
+ function localStatePayload() {
1305
+ return {
1306
+ endpoints: localProbes.map((probe) => ({
1307
+ id: probe.endpoint.id,
1308
+ label: probe.endpoint.label,
1309
+ baseUrl: probe.endpoint.baseUrl,
1310
+ kind: probe.endpoint.kind,
1311
+ custom: probe.endpoint.custom === true,
1312
+ reachable: probe.reachable,
1313
+ ...(probe.reason ? { reason: probe.reason } : {}),
1314
+ models: probe.models.map((m) => ({
1315
+ id: formatLocalModelId(probe.endpoint.id, m.rawId),
1316
+ rawId: m.rawId,
1317
+ contextWindow: m.contextWindow,
1318
+ contextWindowKnown: m.contextWindowKnown,
1319
+ supportsTools: m.supportsTools,
1320
+ supportsImages: m.supportsImages,
1321
+ supportsThinking: m.supportsThinking,
1322
+ ...(m.loaded === undefined ? {} : { loaded: m.loaded }),
1323
+ })),
1324
+ })),
1325
+ };
1326
+ }
1327
+ /**
1328
+ * Why `modelId` can't be selected right now, or `undefined` when it can.
1329
+ * Two real footguns get a clear answer instead of a mid-run provider error:
1330
+ * a model with no tool calling (can't drive the agent at all), and a server
1331
+ * that has since been shut down.
1332
+ */
1333
+ async function localModelBlocker(modelId) {
1334
+ const parsed = parseLocalModelId(modelId);
1335
+ if (!parsed)
1336
+ return undefined;
1337
+ const endpoints = await listAllEndpoints();
1338
+ const endpoint = endpoints.find((e) => e.id === parsed.endpointId);
1339
+ if (!endpoint)
1340
+ return `Unknown local endpoint "${parsed.endpointId}" — re-scan for local models.`;
1341
+ const probe = await probeEndpoint(endpoint);
1342
+ // Keep the cached view honest: this probe is fresher than the last scan.
1343
+ localProbes = localProbes.map((p) => (p.endpoint.id === endpoint.id ? probe : p));
1344
+ if (!probe.reachable) {
1345
+ return `${endpoint.label} isn't running at ${endpoint.baseUrl}. Start it and scan again.`;
1346
+ }
1347
+ const model = probe.models.find((m) => m.rawId === parsed.rawId);
1348
+ if (!model) {
1349
+ return `${endpoint.label} no longer serves "${parsed.rawId}".`;
1350
+ }
1351
+ if (!model.supportsTools) {
1352
+ return `${parsed.rawId} has no tool calling, so it can't run the agent. Pick a tool-capable model.`;
1353
+ }
1354
+ registerRuntimeModels(probe.models.map((m) => localModelInfo(m, endpoint)));
1355
+ return undefined;
1356
+ }
1357
+ /**
1358
+ * A restored per-project pref can ask for thinking on a local model that
1359
+ * turns out not to reason (capabilities are only known after a probe). Drop
1360
+ * the level once we know, so the first prompt doesn't carry a
1361
+ * `reasoning_effort` the server rejects.
1362
+ */
1363
+ function clampLocalThinking() {
1364
+ const st = session.getState();
1365
+ const level = session.getThinkingLevel();
1366
+ if (!level || st.provider !== "local")
1367
+ return;
1368
+ if (isThinkingLevelSupported(st.provider, st.model, level))
1369
+ return;
1370
+ session.setThinkingLevel(undefined);
1371
+ broadcast("thinking_change", {
1372
+ thinkingLevel: null,
1373
+ supportedThinkingLevels: getSupportedThinkingLevels(st.provider, st.model),
1374
+ });
1375
+ }
1230
1376
  // Workspace extras (context window, git status, background tasks). Git state
1231
1377
  // is resolved once at startup and refreshed after every run; the context
1232
1378
  // window follows the active model.
@@ -1260,8 +1406,23 @@ async function createSession(deps, opts) {
1260
1406
  gitHubPRs,
1261
1407
  gitHubRepoUrl: gitHubSlug ? `https://github.com/${gitHubSlug}` : null,
1262
1408
  tasks: session.listBackgroundProcesses(),
1409
+ // Roots added with /add-dir — the header shows a badge when non-empty.
1410
+ additionalRoots: session.getAdditionalRoots(),
1263
1411
  };
1264
1412
  }
1413
+ void scanLocalModels(false)
1414
+ .then(() => {
1415
+ clampLocalThinking();
1416
+ broadcast("extras", footerExtras());
1417
+ })
1418
+ .then(() => broadcast("models_change", { local: localStatePayload() }))
1419
+ .catch((err) => {
1420
+ // A discovery failure is never fatal — the user simply has no local
1421
+ // models. Log it; don't push an error row into the transcript.
1422
+ log("WARN", "app-sidecar", "local model scan failed", {
1423
+ error: err instanceof Error ? err.message : String(err),
1424
+ });
1425
+ });
1265
1426
  // Refresh the GitHub counts and broadcast only on change. Transient failures
1266
1427
  // keep the last-known numbers so the chips don't flicker off on a timeout.
1267
1428
  async function refreshGitHubCounts() {
@@ -2430,6 +2591,30 @@ async function createSession(deps, opts) {
2430
2591
  });
2431
2592
  return;
2432
2593
  }
2594
+ // Markdown transcript export for the app's download button. Serialized
2595
+ // here rather than in the webview because the webview's transcript model
2596
+ // deliberately keeps tool activity in the LiveToolPanel — exporting from
2597
+ // there would hand the user a coding session with the coding missing.
2598
+ // `?name=1` asks for the suggested filename only (the save dialog needs it
2599
+ // before there is a path), so the markdown never crosses IPC twice.
2600
+ if (method === "GET" && (url === "/export" || url.startsWith("/export?"))) {
2601
+ const query = new URLSearchParams(url.slice(url.indexOf("?") + 1));
2602
+ const st = session.getState();
2603
+ const filename = defaultExportFilename(mode);
2604
+ if (query.get("name") === "1") {
2605
+ json(res, 200, { filename });
2606
+ return;
2607
+ }
2608
+ const markdown = sessionToMarkdown({
2609
+ mode,
2610
+ cwd,
2611
+ provider: st.provider,
2612
+ model: st.model,
2613
+ ...(st.sessionId ? { sessionId: st.sessionId } : {}),
2614
+ }, session.getMessages(), { toolDetail: parseToolDetail(query.get("tools")) });
2615
+ json(res, 200, { filename, markdown });
2616
+ return;
2617
+ }
2433
2618
  if (method === "GET" && url === "/history") {
2434
2619
  // Reconstruct the transcript from persisted messages so resume is 1:1 with
2435
2620
  // the live SSE stream. Walks ALL message types (not just user/assistant):
@@ -2748,16 +2933,34 @@ async function createSession(deps, opts) {
2748
2933
  description: c.description,
2749
2934
  source: "built-in",
2750
2935
  }));
2936
+ // Desktop-safe registry actions belong in the same picker as workflows.
2937
+ // Most registry commands have dedicated app controls or TUI-only flows;
2938
+ // multi-root management has no other affordance, so expose only these.
2939
+ const workspaceActions = [
2940
+ {
2941
+ name: "add-dir",
2942
+ aliases: ["adddir"],
2943
+ description: "Add another project folder to this workspace",
2944
+ source: "built-in",
2945
+ },
2946
+ {
2947
+ name: "remove-dir",
2948
+ aliases: ["removedir"],
2949
+ description: "Remove an added project folder from this workspace",
2950
+ source: "built-in",
2951
+ },
2952
+ ];
2751
2953
  const custom = (await loadCustomCommands(cwd))
2752
- // A custom command can't shadow a built-in name.
2753
- .filter((c) => !PROMPT_COMMANDS.some((b) => b.name === c.name))
2954
+ // A custom command can't shadow a built-in name or app action.
2955
+ .filter((c) => !PROMPT_COMMANDS.some((b) => b.name === c.name) &&
2956
+ !workspaceActions.some((action) => action.name === c.name))
2754
2957
  .map((c) => ({
2755
2958
  name: c.name,
2756
2959
  aliases: [],
2757
2960
  description: c.description,
2758
2961
  source: "custom",
2759
2962
  }));
2760
- json(res, 200, { commands: [...builtins, ...custom] });
2963
+ json(res, 200, { commands: [...workspaceActions, ...builtins, ...custom] });
2761
2964
  })();
2762
2965
  return;
2763
2966
  }
@@ -3130,12 +3333,30 @@ async function createSession(deps, opts) {
3130
3333
  loggedIn.push(p);
3131
3334
  }
3132
3335
  // Just the names, grouped by provider in registry order — the UI shows
3133
- // a clean multi-column list of model ids.
3134
- const models = MODELS.filter((m) => loggedIn.includes(m.provider)).map((m) => ({
3135
- id: m.id,
3136
- name: m.name,
3137
- provider: m.provider,
3138
- }));
3336
+ // a clean multi-column list of model ids. Local models come from the
3337
+ // runtime registry (populated by the background scan) and are always
3338
+ // listed: their "login" is the endpoint answering a probe.
3339
+ const models = getAllModels()
3340
+ .filter((m) => m.provider === "local" || loggedIn.includes(m.provider))
3341
+ .map((m) => {
3342
+ if (m.provider !== "local") {
3343
+ return { id: m.id, name: m.name, provider: m.provider };
3344
+ }
3345
+ const probed = findProbedModel(localProbes, m.id);
3346
+ return {
3347
+ id: m.id,
3348
+ name: m.name,
3349
+ provider: m.provider,
3350
+ local: true,
3351
+ endpoint: probed?.endpoint.label ?? parseLocalModelId(m.id)?.endpointId,
3352
+ // A local model that can't call tools can't run the agent — the UI
3353
+ // renders it disabled rather than hiding it, so the user learns why.
3354
+ supportsTools: probed?.model.supportsTools ?? true,
3355
+ contextWindow: m.contextWindow,
3356
+ contextWindowKnown: probed?.model.contextWindowKnown ?? false,
3357
+ supportsThinking: m.supportsThinking,
3358
+ };
3359
+ });
3139
3360
  json(res, 200, { models });
3140
3361
  })();
3141
3362
  return;
@@ -3161,6 +3382,13 @@ async function createSession(deps, opts) {
3161
3382
  json(res, 409, { error: "cannot switch model while running" });
3162
3383
  return;
3163
3384
  }
3385
+ if (target.provider === "local") {
3386
+ const problem = await localModelBlocker(target.id);
3387
+ if (problem) {
3388
+ json(res, 409, { error: problem });
3389
+ return;
3390
+ }
3391
+ }
3164
3392
  await session.switchModel(target.provider, target.id);
3165
3393
  // Ken follows GG Coder's model only while un-pinned; a user-set Ken
3166
3394
  // override survives GG model switches untouched.
@@ -3587,6 +3815,84 @@ async function createSession(deps, opts) {
3587
3815
  return;
3588
3816
  }
3589
3817
  // ── Telegram config (mirrors `ggcoder telegram`) ─────────
3818
+ // ── Local models ──────────────────────────────────────
3819
+ // GET returns the last scan (cheap, no probing) so opening the modal is
3820
+ // instant; POST /local/scan is the explicit refresh.
3821
+ if (method === "GET" && url === "/local") {
3822
+ json(res, 200, localStatePayload());
3823
+ return;
3824
+ }
3825
+ if (method === "POST" && url === "/local/scan") {
3826
+ void scanLocalModels(true)
3827
+ .then(() => {
3828
+ broadcast("models_change", { local: localStatePayload() });
3829
+ json(res, 200, localStatePayload());
3830
+ })
3831
+ .catch((err) => {
3832
+ broadcastError("error", "local model scan failed", err);
3833
+ json(res, 500, { error: "Local model scan failed — see the sidecar log." });
3834
+ });
3835
+ return;
3836
+ }
3837
+ if (method === "POST" && url === "/local/endpoints") {
3838
+ void readBody(req, res).then(async (raw) => {
3839
+ if (raw === null)
3840
+ return;
3841
+ let body;
3842
+ try {
3843
+ body = JSON.parse(raw);
3844
+ }
3845
+ catch {
3846
+ json(res, 400, { error: "invalid JSON body" });
3847
+ return;
3848
+ }
3849
+ try {
3850
+ const endpoint = await addCustomEndpoint({
3851
+ baseUrl: body.baseUrl ?? "",
3852
+ ...(body.label ? { label: body.label } : {}),
3853
+ ...(body.apiKey ? { apiKey: body.apiKey } : {}),
3854
+ });
3855
+ await scanLocalModels(true);
3856
+ broadcast("models_change", { local: localStatePayload() });
3857
+ json(res, 200, {
3858
+ endpoint: { id: endpoint.id, label: endpoint.label },
3859
+ ...localStatePayload(),
3860
+ });
3861
+ }
3862
+ catch (err) {
3863
+ // Validation errors are the user's typo, not a system fault — 400 with
3864
+ // the exact reason, and nothing in the transcript.
3865
+ if (err instanceof LocalEndpointError) {
3866
+ json(res, 400, { error: err.message });
3867
+ return;
3868
+ }
3869
+ broadcastError("error", "add local endpoint failed", err);
3870
+ json(res, 500, { error: "Could not save the endpoint — see the sidecar log." });
3871
+ }
3872
+ });
3873
+ return;
3874
+ }
3875
+ if (method === "DELETE" && url.startsWith("/local/endpoints/")) {
3876
+ const id = decodeURIComponent(url.slice("/local/endpoints/".length));
3877
+ void (async () => {
3878
+ try {
3879
+ await removeCustomEndpoint(id);
3880
+ clearRuntimeModels((m) => m.provider === "local" && parseLocalModelId(m.id)?.endpointId === id);
3881
+ await scanLocalModels(true);
3882
+ broadcast("models_change", { local: localStatePayload() });
3883
+ json(res, 200, localStatePayload());
3884
+ }
3885
+ catch (err) {
3886
+ if (err instanceof LocalEndpointError) {
3887
+ json(res, 400, { error: err.message });
3888
+ return;
3889
+ }
3890
+ broadcastError("error", "remove local endpoint failed", err);
3891
+ json(res, 500, { error: "Could not remove the endpoint — see the sidecar log." });
3892
+ }
3893
+ })();
3894
+ return;
3895
+ }
3590
3896
  if (method === "GET" && url === "/telegram") {
3591
3897
  void loadTelegramConfig().then((cfg) => {
3592
3898
  if (!cfg) {