@kenkaiiii/ggcoder 5.24.0 → 5.26.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 (122) hide show
  1. package/dist/app-sidecar.js +330 -12
  2. package/dist/app-sidecar.js.map +1 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +3 -0
  5. package/dist/cli.js.map +1 -1
  6. package/dist/config.d.ts +2 -0
  7. package/dist/config.d.ts.map +1 -1
  8. package/dist/config.js +3 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/config.test.js +12 -0
  11. package/dist/config.test.js.map +1 -1
  12. package/dist/core/agent-notifications.d.ts +62 -0
  13. package/dist/core/agent-notifications.d.ts.map +1 -0
  14. package/dist/core/agent-notifications.js +92 -0
  15. package/dist/core/agent-notifications.js.map +1 -0
  16. package/dist/core/agent-notifications.test.d.ts +2 -0
  17. package/dist/core/agent-notifications.test.d.ts.map +1 -0
  18. package/dist/core/agent-notifications.test.js +97 -0
  19. package/dist/core/agent-notifications.test.js.map +1 -0
  20. package/dist/core/agent-session-add-dir.test.js +16 -0
  21. package/dist/core/agent-session-add-dir.test.js.map +1 -1
  22. package/dist/core/agent-session-model-switch.test.d.ts +2 -0
  23. package/dist/core/agent-session-model-switch.test.d.ts.map +1 -0
  24. package/dist/core/agent-session-model-switch.test.js +161 -0
  25. package/dist/core/agent-session-model-switch.test.js.map +1 -0
  26. package/dist/core/agent-session-notifications.test.d.ts +2 -0
  27. package/dist/core/agent-session-notifications.test.d.ts.map +1 -0
  28. package/dist/core/agent-session-notifications.test.js +114 -0
  29. package/dist/core/agent-session-notifications.test.js.map +1 -0
  30. package/dist/core/agent-session.d.ts +76 -6
  31. package/dist/core/agent-session.d.ts.map +1 -1
  32. package/dist/core/agent-session.js +246 -25
  33. package/dist/core/agent-session.js.map +1 -1
  34. package/dist/core/event-bus.d.ts +6 -0
  35. package/dist/core/event-bus.d.ts.map +1 -1
  36. package/dist/core/event-bus.js +7 -0
  37. package/dist/core/event-bus.js.map +1 -1
  38. package/dist/core/local-endpoint-store.d.ts +50 -0
  39. package/dist/core/local-endpoint-store.d.ts.map +1 -0
  40. package/dist/core/local-endpoint-store.js +201 -0
  41. package/dist/core/local-endpoint-store.js.map +1 -0
  42. package/dist/core/local-endpoint-store.test.d.ts +2 -0
  43. package/dist/core/local-endpoint-store.test.d.ts.map +1 -0
  44. package/dist/core/local-endpoint-store.test.js +131 -0
  45. package/dist/core/local-endpoint-store.test.js.map +1 -0
  46. package/dist/core/memory/compaction-notes.d.ts +31 -0
  47. package/dist/core/memory/compaction-notes.d.ts.map +1 -0
  48. package/dist/core/memory/compaction-notes.js +162 -0
  49. package/dist/core/memory/compaction-notes.js.map +1 -0
  50. package/dist/core/memory/compaction-notes.test.d.ts +2 -0
  51. package/dist/core/memory/compaction-notes.test.d.ts.map +1 -0
  52. package/dist/core/memory/compaction-notes.test.js +110 -0
  53. package/dist/core/memory/compaction-notes.test.js.map +1 -0
  54. package/dist/core/memory/journal.d.ts +55 -0
  55. package/dist/core/memory/journal.d.ts.map +1 -0
  56. package/dist/core/memory/journal.js +212 -0
  57. package/dist/core/memory/journal.js.map +1 -0
  58. package/dist/core/memory/journal.test.d.ts +2 -0
  59. package/dist/core/memory/journal.test.d.ts.map +1 -0
  60. package/dist/core/memory/journal.test.js +184 -0
  61. package/dist/core/memory/journal.test.js.map +1 -0
  62. package/dist/core/process-manager-notifications.test.d.ts +2 -0
  63. package/dist/core/process-manager-notifications.test.d.ts.map +1 -0
  64. package/dist/core/process-manager-notifications.test.js +113 -0
  65. package/dist/core/process-manager-notifications.test.js.map +1 -0
  66. package/dist/core/process-manager.d.ts +29 -0
  67. package/dist/core/process-manager.d.ts.map +1 -1
  68. package/dist/core/process-manager.js +123 -0
  69. package/dist/core/process-manager.js.map +1 -1
  70. package/dist/core/session-manager.d.ts +3 -1
  71. package/dist/core/session-manager.d.ts.map +1 -1
  72. package/dist/core/session-manager.js +2 -1
  73. package/dist/core/session-manager.js.map +1 -1
  74. package/dist/core/settings-manager.d.ts +1 -0
  75. package/dist/core/settings-manager.d.ts.map +1 -1
  76. package/dist/core/settings-manager.js +7 -0
  77. package/dist/core/settings-manager.js.map +1 -1
  78. package/dist/core/slash-commands.d.ts +8 -0
  79. package/dist/core/slash-commands.d.ts.map +1 -1
  80. package/dist/core/slash-commands.js +17 -1
  81. package/dist/core/slash-commands.js.map +1 -1
  82. package/dist/core/slash-commands.test.js +11 -0
  83. package/dist/core/slash-commands.test.js.map +1 -1
  84. package/dist/core/steering.d.ts +12 -0
  85. package/dist/core/steering.d.ts.map +1 -1
  86. package/dist/core/steering.js +16 -0
  87. package/dist/core/steering.js.map +1 -1
  88. package/dist/core/subagent-manager.d.ts +13 -0
  89. package/dist/core/subagent-manager.d.ts.map +1 -1
  90. package/dist/core/subagent-manager.js +30 -0
  91. package/dist/core/subagent-manager.js.map +1 -1
  92. package/dist/core/subagent-manager.test.js +16 -0
  93. package/dist/core/subagent-manager.test.js.map +1 -1
  94. package/dist/modes/json-mode.d.ts.map +1 -1
  95. package/dist/modes/json-mode.js +5 -0
  96. package/dist/modes/json-mode.js.map +1 -1
  97. package/dist/modes/subagent-worker-mode.d.ts.map +1 -1
  98. package/dist/modes/subagent-worker-mode.js +3 -1
  99. package/dist/modes/subagent-worker-mode.js.map +1 -1
  100. package/dist/tools/index.d.ts +8 -0
  101. package/dist/tools/index.d.ts.map +1 -1
  102. package/dist/tools/index.js +2 -1
  103. package/dist/tools/index.js.map +1 -1
  104. package/dist/tools/subagent-control.d.ts.map +1 -1
  105. package/dist/tools/subagent-control.js +6 -2
  106. package/dist/tools/subagent-control.js.map +1 -1
  107. package/dist/tools/subagent-shared.d.ts +6 -0
  108. package/dist/tools/subagent-shared.d.ts.map +1 -1
  109. package/dist/tools/subagent-shared.js +6 -0
  110. package/dist/tools/subagent-shared.js.map +1 -1
  111. package/dist/tools/task-output.d.ts.map +1 -1
  112. package/dist/tools/task-output.js +3 -1
  113. package/dist/tools/task-output.js.map +1 -1
  114. package/dist/ui/App.d.ts +2 -0
  115. package/dist/ui/App.d.ts.map +1 -1
  116. package/dist/ui/App.js +35 -1
  117. package/dist/ui/App.js.map +1 -1
  118. package/dist/ui/render.d.ts +2 -0
  119. package/dist/ui/render.d.ts.map +1 -1
  120. package/dist/ui/render.js +1 -0
  121. package/dist/ui/render.js.map +1 -1
  122. package/package.json +4 -4
@@ -38,7 +38,8 @@ import { sessionToMarkdown, defaultExportFilename, } from "./core/session-export
38
38
  import { AuthStorage } from "./core/auth-storage.js";
39
39
  import { cleanupToolOutputs } from "./tools/overflow.js";
40
40
  import { readCappedBody } from "./utils/http-body.js";
41
- 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";
42
43
  import { loginAnthropic } from "./core/oauth/anthropic.js";
43
44
  import { loginOpenAI } from "./core/oauth/openai.js";
44
45
  import { loginGemini } from "./core/oauth/gemini.js";
@@ -46,7 +47,7 @@ import { loginKimi } from "./core/oauth/kimi.js";
46
47
  import { AUTH_PROVIDERS } from "./core/auth-providers.js";
47
48
  import { ensureAppDirs, loadSavedSettings } from "./config.js";
48
49
  import { SettingsManager } from "./core/settings-manager.js";
49
- import { getModel, getDefaultThinkingLevel, getContextWindow, MODELS, } from "./core/model-registry.js";
50
+ import { getModel, getDefaultThinkingLevel, getContextWindow, getAllModels, clearRuntimeModels, registerRuntimeModels, } from "./core/model-registry.js";
50
51
  import { resolveStartOrFallback } from "./core/resolve-start.js";
51
52
  import { getGitBranch, getGitDirtyFileCount, isGitRepo } from "./utils/git.js";
52
53
  import { getGitHubOpenCounts, getGitHubRepoSlug } from "./utils/github.js";
@@ -620,6 +621,13 @@ async function main() {
620
621
  });
621
622
  const usageCache = new Map();
622
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;
623
631
  // 429 backoff: quota endpoints are auxiliary UI data. Honor Retry-After when
624
632
  // provided; otherwise retain the unavailable snapshot for 30 minutes. Clamp
625
633
  // the provider value so a malformed header can neither hammer the endpoint
@@ -634,6 +642,9 @@ async function main() {
634
642
  // Moonshot platform API key is metered per-token, not per plan window.
635
643
  const authKey = provider === "moonshot" ? MOONSHOT_OAUTH_KEY : provider;
636
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);
637
648
  return { provider, displayName, connected: false, windows: [], fetchedAt: Date.now() };
638
649
  }
639
650
  try {
@@ -677,6 +688,18 @@ async function main() {
677
688
  ...(backoffMs !== undefined && { backoffMs: String(backoffMs) }),
678
689
  });
679
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
+ }
680
703
  return {
681
704
  provider,
682
705
  displayName,
@@ -698,6 +721,11 @@ async function main() {
698
721
  usageRequests.set(provider, request);
699
722
  try {
700
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
+ }
701
729
  // Anthropic can return utilization before it assigns reset timestamps
702
730
  // (notably before the account's first active request). Retry that partial
703
731
  // snapshot quickly; complete snapshots keep the normal one-minute cache.
@@ -1116,6 +1144,25 @@ async function createSession(deps, opts) {
1116
1144
  // /help
1117
1145
  .replaceAll(/see \/help/gi, "check the help menu"));
1118
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
+ }
1119
1166
  // Turn any thrown value into the same clear headline/message/guidance shape
1120
1167
  // the TUI shows (see gg-ai's formatError) instead of a bare `err.message`, log
1121
1168
  // the full detail, and broadcast it under `type` ("error" or "ken_error").
@@ -1125,7 +1172,7 @@ async function createSession(deps, opts) {
1125
1172
  function broadcastError(type, logLabel, err) {
1126
1173
  const f = formatError(err);
1127
1174
  const message = f.message ? desktopGuidance(f.message) : undefined;
1128
- const guidance = desktopGuidance(f.guidance);
1175
+ const guidance = localNetworkGuidance(f.source) ?? desktopGuidance(f.guidance);
1129
1176
  captureSidecarError(err, `app-sidecar.${logLabel.replaceAll(" ", "-")}`, {
1130
1177
  scope: type,
1131
1178
  ...(f.provider ? { provider: f.provider } : {}),
@@ -1233,6 +1280,99 @@ async function createSession(deps, opts) {
1233
1280
  }
1234
1281
  }
1235
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
+ }
1236
1376
  // Workspace extras (context window, git status, background tasks). Git state
1237
1377
  // is resolved once at startup and refreshed after every run; the context
1238
1378
  // window follows the active model.
@@ -1270,6 +1410,19 @@ async function createSession(deps, opts) {
1270
1410
  additionalRoots: session.getAdditionalRoots(),
1271
1411
  };
1272
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
+ });
1273
1426
  // Refresh the GitHub counts and broadcast only on change. Transient failures
1274
1427
  // keep the last-known numbers so the chips don't flicker off on a timeout.
1275
1428
  async function refreshGitHubCounts() {
@@ -2249,6 +2402,50 @@ async function createSession(deps, opts) {
2249
2402
  });
2250
2403
  return;
2251
2404
  }
2405
+ // Durable per-project journal (.gg/memory.md). Distinct from /memories,
2406
+ // which is the personal chat-memory store shared by the chat agents.
2407
+ if (method === "GET" && url === "/project-memory") {
2408
+ void (async () => {
2409
+ try {
2410
+ const sm = new SettingsManager(paths.settingsFile);
2411
+ await sm.load();
2412
+ json(res, 200, { enabled: sm.get("memoryEnabled") });
2413
+ }
2414
+ catch (error) {
2415
+ captureSidecarError(error, "app-sidecar.project-memory.get");
2416
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
2417
+ }
2418
+ })();
2419
+ return;
2420
+ }
2421
+ if (method === "POST" && url === "/project-memory") {
2422
+ void readBody(req, res).then(async (raw) => {
2423
+ if (raw === null)
2424
+ return;
2425
+ let enabled;
2426
+ try {
2427
+ enabled = JSON.parse(raw).enabled === true;
2428
+ }
2429
+ catch {
2430
+ json(res, 400, { error: "invalid JSON body" });
2431
+ return;
2432
+ }
2433
+ try {
2434
+ const sm = new SettingsManager(paths.settingsFile);
2435
+ await sm.load();
2436
+ await sm.set("memoryEnabled", enabled);
2437
+ // Applies from the next session: the journal handle is built during
2438
+ // AgentSession.initialize(), so a live run keeps its current behaviour
2439
+ // rather than half-applying the change mid-task.
2440
+ json(res, 200, { enabled });
2441
+ }
2442
+ catch (error) {
2443
+ captureSidecarError(error, "app-sidecar.project-memory.set");
2444
+ json(res, 500, { error: error instanceof Error ? error.message : String(error) });
2445
+ }
2446
+ });
2447
+ return;
2448
+ }
2252
2449
  if (method === "GET" && url === "/jiwa") {
2253
2450
  void jiwaStore
2254
2451
  .snapshot()
@@ -2780,16 +2977,34 @@ async function createSession(deps, opts) {
2780
2977
  description: c.description,
2781
2978
  source: "built-in",
2782
2979
  }));
2980
+ // Desktop-safe registry actions belong in the same picker as workflows.
2981
+ // Most registry commands have dedicated app controls or TUI-only flows;
2982
+ // multi-root management has no other affordance, so expose only these.
2983
+ const workspaceActions = [
2984
+ {
2985
+ name: "add-dir",
2986
+ aliases: ["adddir"],
2987
+ description: "Add another project folder to this workspace",
2988
+ source: "built-in",
2989
+ },
2990
+ {
2991
+ name: "remove-dir",
2992
+ aliases: ["removedir"],
2993
+ description: "Remove an added project folder from this workspace",
2994
+ source: "built-in",
2995
+ },
2996
+ ];
2783
2997
  const custom = (await loadCustomCommands(cwd))
2784
- // A custom command can't shadow a built-in name.
2785
- .filter((c) => !PROMPT_COMMANDS.some((b) => b.name === c.name))
2998
+ // A custom command can't shadow a built-in name or app action.
2999
+ .filter((c) => !PROMPT_COMMANDS.some((b) => b.name === c.name) &&
3000
+ !workspaceActions.some((action) => action.name === c.name))
2786
3001
  .map((c) => ({
2787
3002
  name: c.name,
2788
3003
  aliases: [],
2789
3004
  description: c.description,
2790
3005
  source: "custom",
2791
3006
  }));
2792
- json(res, 200, { commands: [...builtins, ...custom] });
3007
+ json(res, 200, { commands: [...workspaceActions, ...builtins, ...custom] });
2793
3008
  })();
2794
3009
  return;
2795
3010
  }
@@ -3162,12 +3377,30 @@ async function createSession(deps, opts) {
3162
3377
  loggedIn.push(p);
3163
3378
  }
3164
3379
  // Just the names, grouped by provider in registry order — the UI shows
3165
- // a clean multi-column list of model ids.
3166
- const models = MODELS.filter((m) => loggedIn.includes(m.provider)).map((m) => ({
3167
- id: m.id,
3168
- name: m.name,
3169
- provider: m.provider,
3170
- }));
3380
+ // a clean multi-column list of model ids. Local models come from the
3381
+ // runtime registry (populated by the background scan) and are always
3382
+ // listed: their "login" is the endpoint answering a probe.
3383
+ const models = getAllModels()
3384
+ .filter((m) => m.provider === "local" || loggedIn.includes(m.provider))
3385
+ .map((m) => {
3386
+ if (m.provider !== "local") {
3387
+ return { id: m.id, name: m.name, provider: m.provider };
3388
+ }
3389
+ const probed = findProbedModel(localProbes, m.id);
3390
+ return {
3391
+ id: m.id,
3392
+ name: m.name,
3393
+ provider: m.provider,
3394
+ local: true,
3395
+ endpoint: probed?.endpoint.label ?? parseLocalModelId(m.id)?.endpointId,
3396
+ // A local model that can't call tools can't run the agent — the UI
3397
+ // renders it disabled rather than hiding it, so the user learns why.
3398
+ supportsTools: probed?.model.supportsTools ?? true,
3399
+ contextWindow: m.contextWindow,
3400
+ contextWindowKnown: probed?.model.contextWindowKnown ?? false,
3401
+ supportsThinking: m.supportsThinking,
3402
+ };
3403
+ });
3171
3404
  json(res, 200, { models });
3172
3405
  })();
3173
3406
  return;
@@ -3193,6 +3426,13 @@ async function createSession(deps, opts) {
3193
3426
  json(res, 409, { error: "cannot switch model while running" });
3194
3427
  return;
3195
3428
  }
3429
+ if (target.provider === "local") {
3430
+ const problem = await localModelBlocker(target.id);
3431
+ if (problem) {
3432
+ json(res, 409, { error: problem });
3433
+ return;
3434
+ }
3435
+ }
3196
3436
  await session.switchModel(target.provider, target.id);
3197
3437
  // Ken follows GG Coder's model only while un-pinned; a user-set Ken
3198
3438
  // override survives GG model switches untouched.
@@ -3619,6 +3859,84 @@ async function createSession(deps, opts) {
3619
3859
  return;
3620
3860
  }
3621
3861
  // ── Telegram config (mirrors `ggcoder telegram`) ─────────
3862
+ // ── Local models ──────────────────────────────────────
3863
+ // GET returns the last scan (cheap, no probing) so opening the modal is
3864
+ // instant; POST /local/scan is the explicit refresh.
3865
+ if (method === "GET" && url === "/local") {
3866
+ json(res, 200, localStatePayload());
3867
+ return;
3868
+ }
3869
+ if (method === "POST" && url === "/local/scan") {
3870
+ void scanLocalModels(true)
3871
+ .then(() => {
3872
+ broadcast("models_change", { local: localStatePayload() });
3873
+ json(res, 200, localStatePayload());
3874
+ })
3875
+ .catch((err) => {
3876
+ broadcastError("error", "local model scan failed", err);
3877
+ json(res, 500, { error: "Local model scan failed — see the sidecar log." });
3878
+ });
3879
+ return;
3880
+ }
3881
+ if (method === "POST" && url === "/local/endpoints") {
3882
+ void readBody(req, res).then(async (raw) => {
3883
+ if (raw === null)
3884
+ return;
3885
+ let body;
3886
+ try {
3887
+ body = JSON.parse(raw);
3888
+ }
3889
+ catch {
3890
+ json(res, 400, { error: "invalid JSON body" });
3891
+ return;
3892
+ }
3893
+ try {
3894
+ const endpoint = await addCustomEndpoint({
3895
+ baseUrl: body.baseUrl ?? "",
3896
+ ...(body.label ? { label: body.label } : {}),
3897
+ ...(body.apiKey ? { apiKey: body.apiKey } : {}),
3898
+ });
3899
+ await scanLocalModels(true);
3900
+ broadcast("models_change", { local: localStatePayload() });
3901
+ json(res, 200, {
3902
+ endpoint: { id: endpoint.id, label: endpoint.label },
3903
+ ...localStatePayload(),
3904
+ });
3905
+ }
3906
+ catch (err) {
3907
+ // Validation errors are the user's typo, not a system fault — 400 with
3908
+ // the exact reason, and nothing in the transcript.
3909
+ if (err instanceof LocalEndpointError) {
3910
+ json(res, 400, { error: err.message });
3911
+ return;
3912
+ }
3913
+ broadcastError("error", "add local endpoint failed", err);
3914
+ json(res, 500, { error: "Could not save the endpoint — see the sidecar log." });
3915
+ }
3916
+ });
3917
+ return;
3918
+ }
3919
+ if (method === "DELETE" && url.startsWith("/local/endpoints/")) {
3920
+ const id = decodeURIComponent(url.slice("/local/endpoints/".length));
3921
+ void (async () => {
3922
+ try {
3923
+ await removeCustomEndpoint(id);
3924
+ clearRuntimeModels((m) => m.provider === "local" && parseLocalModelId(m.id)?.endpointId === id);
3925
+ await scanLocalModels(true);
3926
+ broadcast("models_change", { local: localStatePayload() });
3927
+ json(res, 200, localStatePayload());
3928
+ }
3929
+ catch (err) {
3930
+ if (err instanceof LocalEndpointError) {
3931
+ json(res, 400, { error: err.message });
3932
+ return;
3933
+ }
3934
+ broadcastError("error", "remove local endpoint failed", err);
3935
+ json(res, 500, { error: "Could not remove the endpoint — see the sidecar log." });
3936
+ }
3937
+ })();
3938
+ return;
3939
+ }
3622
3940
  if (method === "GET" && url === "/telegram") {
3623
3941
  void loadTelegramConfig().then((cfg) => {
3624
3942
  if (!cfg) {