gsd-pi 2.16.0 → 2.18.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 (225) hide show
  1. package/README.md +39 -0
  2. package/dist/onboarding.js +2 -2
  3. package/dist/remote-questions-config.d.ts +10 -0
  4. package/dist/remote-questions-config.js +36 -0
  5. package/dist/resources/extensions/gsd/activity-log.ts +37 -7
  6. package/dist/resources/extensions/gsd/auto-dashboard.ts +4 -0
  7. package/dist/resources/extensions/gsd/auto-dispatch.ts +9 -3
  8. package/dist/resources/extensions/gsd/auto-prompts.ts +91 -42
  9. package/dist/resources/extensions/gsd/auto-recovery.ts +7 -2
  10. package/dist/resources/extensions/gsd/auto-worktree.ts +33 -4
  11. package/dist/resources/extensions/gsd/auto.ts +177 -25
  12. package/dist/resources/extensions/gsd/commands.ts +264 -23
  13. package/dist/resources/extensions/gsd/complexity.ts +236 -0
  14. package/dist/resources/extensions/gsd/dispatch-guard.ts +7 -19
  15. package/dist/resources/extensions/gsd/docs/preferences-reference.md +202 -2
  16. package/dist/resources/extensions/gsd/files.ts +129 -3
  17. package/dist/resources/extensions/gsd/git-service.ts +19 -8
  18. package/dist/resources/extensions/gsd/gitignore.ts +41 -2
  19. package/dist/resources/extensions/gsd/guided-flow.ts +247 -10
  20. package/dist/resources/extensions/gsd/index.ts +47 -3
  21. package/dist/resources/extensions/gsd/metrics.ts +44 -0
  22. package/dist/resources/extensions/gsd/native-git-bridge.ts +5 -0
  23. package/dist/resources/extensions/gsd/native-parser-bridge.ts +5 -0
  24. package/dist/resources/extensions/gsd/paths.ts +9 -0
  25. package/dist/resources/extensions/gsd/preferences.ts +181 -2
  26. package/dist/resources/extensions/gsd/prompts/execute-task.md +6 -5
  27. package/dist/resources/extensions/gsd/prompts/system.md +2 -0
  28. package/dist/resources/extensions/gsd/queue-order.ts +231 -0
  29. package/dist/resources/extensions/gsd/queue-reorder-ui.ts +263 -0
  30. package/dist/resources/extensions/gsd/routing-history.ts +290 -0
  31. package/dist/resources/extensions/gsd/state.ts +15 -3
  32. package/dist/resources/extensions/gsd/templates/knowledge.md +19 -0
  33. package/dist/resources/extensions/gsd/templates/preferences.md +14 -0
  34. package/dist/resources/extensions/gsd/tests/auto-recovery.test.ts +50 -0
  35. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +20 -0
  36. package/dist/resources/extensions/gsd/tests/budget-prediction.test.ts +220 -0
  37. package/dist/resources/extensions/gsd/tests/complexity-routing.test.ts +294 -0
  38. package/dist/resources/extensions/gsd/tests/context-compression.test.ts +180 -0
  39. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +99 -0
  40. package/dist/resources/extensions/gsd/tests/git-service.test.ts +132 -0
  41. package/dist/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +79 -0
  42. package/dist/resources/extensions/gsd/tests/knowledge.test.ts +161 -0
  43. package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +87 -0
  44. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +28 -0
  45. package/dist/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts +168 -0
  46. package/dist/resources/extensions/gsd/tests/queue-order.test.ts +204 -0
  47. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +281 -0
  48. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +87 -0
  49. package/dist/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +139 -0
  50. package/dist/resources/extensions/gsd/tests/stop-auto-remote.test.ts +130 -0
  51. package/dist/resources/extensions/gsd/tests/token-profile.test.ts +263 -0
  52. package/dist/resources/extensions/gsd/types.ts +28 -0
  53. package/dist/resources/extensions/gsd/worktree-manager.ts +8 -5
  54. package/dist/resources/extensions/gsd/worktree.ts +24 -2
  55. package/dist/resources/extensions/shared/next-action-ui.ts +16 -1
  56. package/package.json +1 -1
  57. package/packages/pi-ai/dist/models.generated.d.ts +493 -13
  58. package/packages/pi-ai/dist/models.generated.d.ts.map +1 -1
  59. package/packages/pi-ai/dist/models.generated.js +422 -62
  60. package/packages/pi-ai/dist/models.generated.js.map +1 -1
  61. package/packages/pi-ai/dist/providers/google-shared.d.ts +12 -0
  62. package/packages/pi-ai/dist/providers/google-shared.d.ts.map +1 -1
  63. package/packages/pi-ai/dist/providers/google-shared.js +9 -22
  64. package/packages/pi-ai/dist/providers/google-shared.js.map +1 -1
  65. package/packages/pi-ai/dist/providers/google-shared.test.d.ts +2 -0
  66. package/packages/pi-ai/dist/providers/google-shared.test.d.ts.map +1 -0
  67. package/packages/pi-ai/dist/providers/google-shared.test.js +125 -0
  68. package/packages/pi-ai/dist/providers/google-shared.test.js.map +1 -0
  69. package/packages/pi-ai/src/models.generated.ts +422 -62
  70. package/packages/pi-ai/src/providers/google-shared.test.ts +137 -0
  71. package/packages/pi-ai/src/providers/google-shared.ts +10 -19
  72. package/packages/pi-coding-agent/dist/cli/args.d.ts +5 -0
  73. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  74. package/packages/pi-coding-agent/dist/cli/args.js +21 -0
  75. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  76. package/packages/pi-coding-agent/dist/cli/list-models.d.ts +14 -3
  77. package/packages/pi-coding-agent/dist/cli/list-models.d.ts.map +1 -1
  78. package/packages/pi-coding-agent/dist/cli/list-models.js +52 -17
  79. package/packages/pi-coding-agent/dist/cli/list-models.js.map +1 -1
  80. package/packages/pi-coding-agent/dist/core/discovery-cache.d.ts +27 -0
  81. package/packages/pi-coding-agent/dist/core/discovery-cache.d.ts.map +1 -0
  82. package/packages/pi-coding-agent/dist/core/discovery-cache.js +79 -0
  83. package/packages/pi-coding-agent/dist/core/discovery-cache.js.map +1 -0
  84. package/packages/pi-coding-agent/dist/core/discovery-cache.test.d.ts +2 -0
  85. package/packages/pi-coding-agent/dist/core/discovery-cache.test.d.ts.map +1 -0
  86. package/packages/pi-coding-agent/dist/core/discovery-cache.test.js +140 -0
  87. package/packages/pi-coding-agent/dist/core/discovery-cache.test.js.map +1 -0
  88. package/packages/pi-coding-agent/dist/core/model-discovery.d.ts +35 -0
  89. package/packages/pi-coding-agent/dist/core/model-discovery.d.ts.map +1 -0
  90. package/packages/pi-coding-agent/dist/core/model-discovery.js +162 -0
  91. package/packages/pi-coding-agent/dist/core/model-discovery.js.map +1 -0
  92. package/packages/pi-coding-agent/dist/core/model-discovery.test.d.ts +2 -0
  93. package/packages/pi-coding-agent/dist/core/model-discovery.test.d.ts.map +1 -0
  94. package/packages/pi-coding-agent/dist/core/model-discovery.test.js +100 -0
  95. package/packages/pi-coding-agent/dist/core/model-discovery.test.js.map +1 -0
  96. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.d.ts +2 -0
  97. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.d.ts.map +1 -0
  98. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.js +113 -0
  99. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.js.map +1 -0
  100. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +26 -0
  101. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  102. package/packages/pi-coding-agent/dist/core/model-registry.js +98 -0
  103. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  104. package/packages/pi-coding-agent/dist/core/models-json-writer.d.ts +62 -0
  105. package/packages/pi-coding-agent/dist/core/models-json-writer.d.ts.map +1 -0
  106. package/packages/pi-coding-agent/dist/core/models-json-writer.js +145 -0
  107. package/packages/pi-coding-agent/dist/core/models-json-writer.js.map +1 -0
  108. package/packages/pi-coding-agent/dist/core/models-json-writer.test.d.ts +2 -0
  109. package/packages/pi-coding-agent/dist/core/models-json-writer.test.d.ts.map +1 -0
  110. package/packages/pi-coding-agent/dist/core/models-json-writer.test.js +118 -0
  111. package/packages/pi-coding-agent/dist/core/models-json-writer.test.js.map +1 -0
  112. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +9 -0
  113. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  114. package/packages/pi-coding-agent/dist/core/settings-manager.js +11 -0
  115. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  116. package/packages/pi-coding-agent/dist/core/slash-commands.d.ts.map +1 -1
  117. package/packages/pi-coding-agent/dist/core/slash-commands.js +1 -0
  118. package/packages/pi-coding-agent/dist/core/slash-commands.js.map +1 -1
  119. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts +7 -7
  120. package/packages/pi-coding-agent/dist/core/tools/edit-diff.d.ts.map +1 -1
  121. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js +209 -13
  122. package/packages/pi-coding-agent/dist/core/tools/edit-diff.js.map +1 -1
  123. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.d.ts +2 -0
  124. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.d.ts.map +1 -0
  125. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js +67 -0
  126. package/packages/pi-coding-agent/dist/core/tools/edit-diff.test.js.map +1 -0
  127. package/packages/pi-coding-agent/dist/index.d.ts +5 -1
  128. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  129. package/packages/pi-coding-agent/dist/index.js +4 -1
  130. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  131. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  132. package/packages/pi-coding-agent/dist/main.js +17 -2
  133. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  134. package/packages/pi-coding-agent/dist/modes/interactive/components/index.d.ts +1 -0
  135. package/packages/pi-coding-agent/dist/modes/interactive/components/index.d.ts.map +1 -1
  136. package/packages/pi-coding-agent/dist/modes/interactive/components/index.js +1 -0
  137. package/packages/pi-coding-agent/dist/modes/interactive/components/index.js.map +1 -1
  138. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +1 -1
  139. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  140. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.d.ts +25 -0
  141. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.d.ts.map +1 -0
  142. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js +121 -0
  143. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js.map +1 -0
  144. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -0
  145. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  146. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +32 -0
  147. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  148. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  149. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js +10 -0
  150. package/packages/pi-coding-agent/dist/modes/interactive/theme/theme.js.map +1 -1
  151. package/packages/pi-coding-agent/src/cli/args.ts +21 -0
  152. package/packages/pi-coding-agent/src/cli/list-models.ts +70 -17
  153. package/packages/pi-coding-agent/src/core/discovery-cache.test.ts +170 -0
  154. package/packages/pi-coding-agent/src/core/discovery-cache.ts +97 -0
  155. package/packages/pi-coding-agent/src/core/model-discovery.test.ts +125 -0
  156. package/packages/pi-coding-agent/src/core/model-discovery.ts +231 -0
  157. package/packages/pi-coding-agent/src/core/model-registry-discovery.test.ts +135 -0
  158. package/packages/pi-coding-agent/src/core/model-registry.ts +107 -0
  159. package/packages/pi-coding-agent/src/core/models-json-writer.test.ts +145 -0
  160. package/packages/pi-coding-agent/src/core/models-json-writer.ts +188 -0
  161. package/packages/pi-coding-agent/src/core/settings-manager.ts +21 -0
  162. package/packages/pi-coding-agent/src/core/slash-commands.ts +1 -0
  163. package/packages/pi-coding-agent/src/core/tools/edit-diff.test.ts +85 -0
  164. package/packages/pi-coding-agent/src/core/tools/edit-diff.ts +245 -17
  165. package/packages/pi-coding-agent/src/index.ts +5 -0
  166. package/packages/pi-coding-agent/src/main.ts +19 -2
  167. package/packages/pi-coding-agent/src/modes/interactive/components/index.ts +1 -0
  168. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +1 -1
  169. package/packages/pi-coding-agent/src/modes/interactive/components/provider-manager.ts +163 -0
  170. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +37 -0
  171. package/packages/pi-coding-agent/src/modes/interactive/theme/theme.ts +13 -0
  172. package/pkg/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  173. package/pkg/dist/modes/interactive/theme/theme.js +10 -0
  174. package/pkg/dist/modes/interactive/theme/theme.js.map +1 -1
  175. package/src/resources/extensions/gsd/activity-log.ts +37 -7
  176. package/src/resources/extensions/gsd/auto-dashboard.ts +4 -0
  177. package/src/resources/extensions/gsd/auto-dispatch.ts +9 -3
  178. package/src/resources/extensions/gsd/auto-prompts.ts +91 -42
  179. package/src/resources/extensions/gsd/auto-recovery.ts +7 -2
  180. package/src/resources/extensions/gsd/auto-worktree.ts +33 -4
  181. package/src/resources/extensions/gsd/auto.ts +177 -25
  182. package/src/resources/extensions/gsd/commands.ts +264 -23
  183. package/src/resources/extensions/gsd/complexity.ts +236 -0
  184. package/src/resources/extensions/gsd/dispatch-guard.ts +7 -19
  185. package/src/resources/extensions/gsd/docs/preferences-reference.md +202 -2
  186. package/src/resources/extensions/gsd/files.ts +129 -3
  187. package/src/resources/extensions/gsd/git-service.ts +19 -8
  188. package/src/resources/extensions/gsd/gitignore.ts +41 -2
  189. package/src/resources/extensions/gsd/guided-flow.ts +247 -10
  190. package/src/resources/extensions/gsd/index.ts +47 -3
  191. package/src/resources/extensions/gsd/metrics.ts +44 -0
  192. package/src/resources/extensions/gsd/native-git-bridge.ts +5 -0
  193. package/src/resources/extensions/gsd/native-parser-bridge.ts +5 -0
  194. package/src/resources/extensions/gsd/paths.ts +9 -0
  195. package/src/resources/extensions/gsd/preferences.ts +181 -2
  196. package/src/resources/extensions/gsd/prompts/execute-task.md +6 -5
  197. package/src/resources/extensions/gsd/prompts/system.md +2 -0
  198. package/src/resources/extensions/gsd/queue-order.ts +231 -0
  199. package/src/resources/extensions/gsd/queue-reorder-ui.ts +263 -0
  200. package/src/resources/extensions/gsd/routing-history.ts +290 -0
  201. package/src/resources/extensions/gsd/state.ts +15 -3
  202. package/src/resources/extensions/gsd/templates/knowledge.md +19 -0
  203. package/src/resources/extensions/gsd/templates/preferences.md +14 -0
  204. package/src/resources/extensions/gsd/tests/auto-recovery.test.ts +50 -0
  205. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +20 -0
  206. package/src/resources/extensions/gsd/tests/budget-prediction.test.ts +220 -0
  207. package/src/resources/extensions/gsd/tests/complexity-routing.test.ts +294 -0
  208. package/src/resources/extensions/gsd/tests/context-compression.test.ts +180 -0
  209. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +99 -0
  210. package/src/resources/extensions/gsd/tests/git-service.test.ts +132 -0
  211. package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +79 -0
  212. package/src/resources/extensions/gsd/tests/knowledge.test.ts +161 -0
  213. package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +87 -0
  214. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +28 -0
  215. package/src/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts +168 -0
  216. package/src/resources/extensions/gsd/tests/queue-order.test.ts +204 -0
  217. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +281 -0
  218. package/src/resources/extensions/gsd/tests/routing-history.test.ts +87 -0
  219. package/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +139 -0
  220. package/src/resources/extensions/gsd/tests/stop-auto-remote.test.ts +130 -0
  221. package/src/resources/extensions/gsd/tests/token-profile.test.ts +263 -0
  222. package/src/resources/extensions/gsd/types.ts +28 -0
  223. package/src/resources/extensions/gsd/worktree-manager.ts +8 -5
  224. package/src/resources/extensions/gsd/worktree.ts +24 -2
  225. package/src/resources/extensions/shared/next-action-ui.ts +16 -1
@@ -0,0 +1,135 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdirSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, it } from "node:test";
6
+ import { AuthStorage } from "./auth-storage.js";
7
+ import { ModelDiscoveryCache } from "./discovery-cache.js";
8
+ import { getDefaultTTL, getDiscoverableProviders, getDiscoveryAdapter } from "./model-discovery.js";
9
+
10
+ let testDir: string;
11
+
12
+ beforeEach(() => {
13
+ testDir = join(tmpdir(), `model-registry-discovery-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
14
+ mkdirSync(testDir, { recursive: true });
15
+ });
16
+
17
+ afterEach(() => {
18
+ try {
19
+ rmSync(testDir, { recursive: true, force: true });
20
+ } catch {
21
+ // Cleanup best-effort
22
+ }
23
+ });
24
+
25
+ // ─── discovery cache integration ─────────────────────────────────────────────
26
+
27
+ describe("ModelDiscoveryCache — integration with discovery", () => {
28
+ it("cache respects provider-specific TTLs", () => {
29
+ const cachePath = join(testDir, "cache.json");
30
+ const cache = new ModelDiscoveryCache(cachePath);
31
+
32
+ cache.set("ollama", [{ id: "llama2" }]);
33
+ const entry = cache.get("ollama");
34
+ assert.ok(entry);
35
+ assert.equal(entry.ttlMs, getDefaultTTL("ollama"));
36
+ });
37
+
38
+ it("cache uses custom TTL when provided", () => {
39
+ const cachePath = join(testDir, "cache.json");
40
+ const cache = new ModelDiscoveryCache(cachePath);
41
+
42
+ cache.set("openai", [{ id: "gpt-4o" }], 999);
43
+ const entry = cache.get("openai");
44
+ assert.ok(entry);
45
+ assert.equal(entry.ttlMs, 999);
46
+ });
47
+ });
48
+
49
+ // ─── adapter resolution ─────────────────────────────────────────────────────
50
+
51
+ describe("Discovery adapter resolution", () => {
52
+ it("all discoverable providers have adapters", () => {
53
+ const providers = getDiscoverableProviders();
54
+ for (const provider of providers) {
55
+ const adapter = getDiscoveryAdapter(provider);
56
+ assert.equal(adapter.supportsDiscovery, true, `${provider} should support discovery`);
57
+ }
58
+ });
59
+
60
+ it("static adapters return empty model lists", async () => {
61
+ const staticProviders = ["anthropic", "bedrock", "azure-openai", "groq", "cerebras"];
62
+ for (const provider of staticProviders) {
63
+ const adapter = getDiscoveryAdapter(provider);
64
+ assert.equal(adapter.supportsDiscovery, false, `${provider} should not support discovery`);
65
+ const models = await adapter.fetchModels("dummy-key");
66
+ assert.deepEqual(models, [], `${provider} should return empty models`);
67
+ }
68
+ });
69
+ });
70
+
71
+ // ─── AuthStorage hasAuth for discovery ───────────────────────────────────────
72
+
73
+ describe("AuthStorage — hasAuth for discovery providers", () => {
74
+ it("returns false for providers without auth", () => {
75
+ const storage = AuthStorage.inMemory({});
76
+ assert.equal(storage.hasAuth("openai"), false);
77
+ assert.equal(storage.hasAuth("ollama"), false);
78
+ });
79
+
80
+ it("returns true for providers with stored keys", () => {
81
+ const storage = AuthStorage.inMemory({
82
+ openai: { type: "api_key" as const, key: "sk-test" },
83
+ });
84
+ assert.equal(storage.hasAuth("openai"), true);
85
+ assert.equal(storage.hasAuth("ollama"), false);
86
+ });
87
+ });
88
+
89
+ // ─── cache persistence across instances ──────────────────────────────────────
90
+
91
+ describe("ModelDiscoveryCache — persistence", () => {
92
+ it("data survives across cache instances", () => {
93
+ const cachePath = join(testDir, "persist.json");
94
+
95
+ const cache1 = new ModelDiscoveryCache(cachePath);
96
+ cache1.set("openai", [
97
+ { id: "gpt-4o", name: "GPT-4o", contextWindow: 128000 },
98
+ { id: "gpt-4o-mini", name: "GPT-4o Mini" },
99
+ ]);
100
+
101
+ const cache2 = new ModelDiscoveryCache(cachePath);
102
+ const entry = cache2.get("openai");
103
+ assert.ok(entry);
104
+ assert.equal(entry.models.length, 2);
105
+ assert.equal(entry.models[0].contextWindow, 128000);
106
+ });
107
+
108
+ it("clear persists across instances", () => {
109
+ const cachePath = join(testDir, "clear.json");
110
+
111
+ const cache1 = new ModelDiscoveryCache(cachePath);
112
+ cache1.set("openai", [{ id: "gpt-4o" }]);
113
+ cache1.clear("openai");
114
+
115
+ const cache2 = new ModelDiscoveryCache(cachePath);
116
+ assert.equal(cache2.get("openai"), undefined);
117
+ });
118
+ });
119
+
120
+ // ─── discovery TTL values ────────────────────────────────────────────────────
121
+
122
+ describe("Discovery TTL configuration", () => {
123
+ it("ollama has shortest TTL (local models change often)", () => {
124
+ const ollamaTTL = getDefaultTTL("ollama");
125
+ const openaiTTL = getDefaultTTL("openai");
126
+ assert.ok(ollamaTTL < openaiTTL, "ollama TTL should be shorter than openai");
127
+ });
128
+
129
+ it("unknown providers get default TTL", () => {
130
+ const customTTL = getDefaultTTL("my-custom-provider");
131
+ const defaultTTL = getDefaultTTL("default");
132
+ // Unknown providers should get the same TTL as the explicit "default" key
133
+ assert.equal(customTTL, defaultTTL);
134
+ });
135
+ });
@@ -24,6 +24,9 @@ import { existsSync, readFileSync } from "fs";
24
24
  import { join } from "path";
25
25
  import { getAgentDir } from "../config.js";
26
26
  import type { AuthStorage } from "./auth-storage.js";
27
+ import { ModelDiscoveryCache } from "./discovery-cache.js";
28
+ import type { DiscoveredModel, DiscoveryResult } from "./model-discovery.js";
29
+ import { getDefaultTTL, getDiscoverableProviders, getDiscoveryAdapter } from "./model-discovery.js";
27
30
  import { clearConfigValueCache, resolveConfigValue, resolveHeaders } from "./resolve-config-value.js";
28
31
 
29
32
  const Ajv = (AjvModule as any).default || AjvModule;
@@ -221,6 +224,8 @@ export const clearApiKeyCache = clearConfigValueCache;
221
224
  */
222
225
  export class ModelRegistry {
223
226
  private models: Model<Api>[] = [];
227
+ private discoveredModels: Model<Api>[] = [];
228
+ private discoveryCache: ModelDiscoveryCache;
224
229
  private customProviderApiKeys: Map<string, string> = new Map();
225
230
  private registeredProviders: Map<string, ProviderConfigInput> = new Map();
226
231
  private loadError: string | undefined = undefined;
@@ -229,6 +234,8 @@ export class ModelRegistry {
229
234
  readonly authStorage: AuthStorage,
230
235
  private modelsJsonPath: string | undefined = join(getAgentDir(), "models.json"),
231
236
  ) {
237
+ this.discoveryCache = new ModelDiscoveryCache();
238
+
232
239
  // Set up fallback resolver for custom provider API keys
233
240
  this.authStorage.setFallbackResolver((provider) => {
234
241
  const keyConfig = this.customProviderApiKeys.get(provider);
@@ -666,6 +673,106 @@ export class ModelRegistry {
666
673
  });
667
674
  }
668
675
  }
676
+
677
+ /**
678
+ * Discover models from all providers that support discovery.
679
+ * Results are cached and merged into the registry (never overrides existing models).
680
+ */
681
+ async discoverModels(providers?: string[]): Promise<DiscoveryResult[]> {
682
+ const targetProviders = providers ?? getDiscoverableProviders();
683
+ const results: DiscoveryResult[] = [];
684
+
685
+ for (const providerName of targetProviders) {
686
+ const adapter = getDiscoveryAdapter(providerName);
687
+ if (!adapter.supportsDiscovery) continue;
688
+
689
+ // Skip if cache is still fresh
690
+ if (!this.discoveryCache.isStale(providerName)) {
691
+ const cached = this.discoveryCache.get(providerName);
692
+ if (cached) {
693
+ results.push({
694
+ provider: providerName,
695
+ models: cached.models,
696
+ fetchedAt: cached.fetchedAt,
697
+ });
698
+ continue;
699
+ }
700
+ }
701
+
702
+ try {
703
+ const apiKey = await this.authStorage.getApiKey(providerName);
704
+ if (!apiKey && providerName !== "ollama") continue;
705
+
706
+ const models = await adapter.fetchModels(apiKey ?? "", undefined);
707
+ this.discoveryCache.set(providerName, models);
708
+ results.push({
709
+ provider: providerName,
710
+ models,
711
+ fetchedAt: Date.now(),
712
+ });
713
+ } catch (error) {
714
+ results.push({
715
+ provider: providerName,
716
+ models: [],
717
+ fetchedAt: Date.now(),
718
+ error: error instanceof Error ? error.message : String(error),
719
+ });
720
+ }
721
+ }
722
+
723
+ // Convert and merge discovered models
724
+ this.discoveredModels = this.convertDiscoveredModels(results);
725
+ return results;
726
+ }
727
+
728
+ /**
729
+ * Get all models including discovered ones.
730
+ * Discovered models are appended but never override existing models.
731
+ */
732
+ getAllWithDiscovered(): Model<Api>[] {
733
+ const existingIds = new Set(this.models.map((m) => `${m.provider}/${m.id}`));
734
+ const unique = this.discoveredModels.filter((m) => !existingIds.has(`${m.provider}/${m.id}`));
735
+ return [...this.models, ...unique];
736
+ }
737
+
738
+ /**
739
+ * Check if a model was added via discovery (not built-in or custom).
740
+ */
741
+ isDiscovered(model: Model<Api>): boolean {
742
+ return this.discoveredModels.some((m) => m.provider === model.provider && m.id === model.id);
743
+ }
744
+
745
+ /**
746
+ * Get the discovery cache instance.
747
+ */
748
+ getDiscoveryCache(): ModelDiscoveryCache {
749
+ return this.discoveryCache;
750
+ }
751
+
752
+ /**
753
+ * Convert DiscoveryResult[] into Model<Api>[] with default values.
754
+ */
755
+ private convertDiscoveredModels(results: DiscoveryResult[]): Model<Api>[] {
756
+ const converted: Model<Api>[] = [];
757
+ for (const result of results) {
758
+ if (result.error) continue;
759
+ for (const dm of result.models) {
760
+ converted.push({
761
+ id: dm.id,
762
+ name: dm.name ?? dm.id,
763
+ api: "openai" as Api,
764
+ provider: result.provider,
765
+ baseUrl: "",
766
+ reasoning: dm.reasoning ?? false,
767
+ input: dm.input ?? ["text"],
768
+ cost: dm.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
769
+ contextWindow: dm.contextWindow ?? 128000,
770
+ maxTokens: dm.maxTokens ?? 16384,
771
+ } as Model<Api>);
772
+ }
773
+ }
774
+ return converted;
775
+ }
669
776
  }
670
777
 
671
778
  /**
@@ -0,0 +1,145 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { afterEach, beforeEach, describe, it } from "node:test";
6
+ import { ModelsJsonWriter } from "./models-json-writer.js";
7
+
8
+ let testDir: string;
9
+ let modelsJsonPath: string;
10
+
11
+ beforeEach(() => {
12
+ testDir = join(tmpdir(), `models-json-writer-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
13
+ mkdirSync(testDir, { recursive: true });
14
+ modelsJsonPath = join(testDir, "models.json");
15
+ });
16
+
17
+ afterEach(() => {
18
+ try {
19
+ rmSync(testDir, { recursive: true, force: true });
20
+ } catch {
21
+ // Cleanup best-effort
22
+ }
23
+ });
24
+
25
+ function readModels(): Record<string, unknown> {
26
+ return JSON.parse(readFileSync(modelsJsonPath, "utf-8"));
27
+ }
28
+
29
+ // ─── addModel ────────────────────────────────────────────────────────────────
30
+
31
+ describe("ModelsJsonWriter — addModel", () => {
32
+ it("creates file and adds model to new provider", () => {
33
+ const writer = new ModelsJsonWriter(modelsJsonPath);
34
+ writer.addModel("openai", { id: "gpt-4o", name: "GPT-4o" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
35
+
36
+ const config = readModels() as any;
37
+ assert.ok(config.providers.openai);
38
+ assert.equal(config.providers.openai.models.length, 1);
39
+ assert.equal(config.providers.openai.models[0].id, "gpt-4o");
40
+ });
41
+
42
+ it("appends model to existing provider", () => {
43
+ const writer = new ModelsJsonWriter(modelsJsonPath);
44
+ writer.addModel("openai", { id: "gpt-4o" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
45
+ writer.addModel("openai", { id: "gpt-4o-mini" });
46
+
47
+ const config = readModels() as any;
48
+ assert.equal(config.providers.openai.models.length, 2);
49
+ });
50
+
51
+ it("replaces model with same id", () => {
52
+ const writer = new ModelsJsonWriter(modelsJsonPath);
53
+ writer.addModel("openai", { id: "gpt-4o", name: "Old" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
54
+ writer.addModel("openai", { id: "gpt-4o", name: "New" });
55
+
56
+ const config = readModels() as any;
57
+ assert.equal(config.providers.openai.models.length, 1);
58
+ assert.equal(config.providers.openai.models[0].name, "New");
59
+ });
60
+ });
61
+
62
+ // ─── removeModel ─────────────────────────────────────────────────────────────
63
+
64
+ describe("ModelsJsonWriter — removeModel", () => {
65
+ it("removes a model from provider", () => {
66
+ const writer = new ModelsJsonWriter(modelsJsonPath);
67
+ writer.addModel("openai", { id: "gpt-4o" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
68
+ writer.addModel("openai", { id: "gpt-4o-mini" });
69
+
70
+ writer.removeModel("openai", "gpt-4o");
71
+
72
+ const config = readModels() as any;
73
+ assert.equal(config.providers.openai.models.length, 1);
74
+ assert.equal(config.providers.openai.models[0].id, "gpt-4o-mini");
75
+ });
76
+
77
+ it("removes provider when last model is removed", () => {
78
+ const writer = new ModelsJsonWriter(modelsJsonPath);
79
+ writer.addModel("openai", { id: "gpt-4o" }, { baseUrl: "https://api.openai.com", apiKey: "env:OPENAI_API_KEY", api: "openai" });
80
+
81
+ writer.removeModel("openai", "gpt-4o");
82
+
83
+ const config = readModels() as any;
84
+ assert.equal(config.providers.openai, undefined);
85
+ });
86
+
87
+ it("handles removing from nonexistent provider", () => {
88
+ const writer = new ModelsJsonWriter(modelsJsonPath);
89
+ // Should not throw
90
+ writer.removeModel("nonexistent", "model-id");
91
+ });
92
+ });
93
+
94
+ // ─── setProvider / removeProvider ────────────────────────────────────────────
95
+
96
+ describe("ModelsJsonWriter — provider operations", () => {
97
+ it("sets a provider configuration", () => {
98
+ const writer = new ModelsJsonWriter(modelsJsonPath);
99
+ writer.setProvider("custom", {
100
+ baseUrl: "http://localhost:8080",
101
+ apiKey: "test-key",
102
+ api: "openai",
103
+ models: [{ id: "local-model" }],
104
+ });
105
+
106
+ const config = readModels() as any;
107
+ assert.ok(config.providers.custom);
108
+ assert.equal(config.providers.custom.baseUrl, "http://localhost:8080");
109
+ });
110
+
111
+ it("removes a provider", () => {
112
+ const writer = new ModelsJsonWriter(modelsJsonPath);
113
+ writer.setProvider("custom", { baseUrl: "http://localhost:8080" });
114
+ writer.removeProvider("custom");
115
+
116
+ const config = readModels() as any;
117
+ assert.equal(config.providers.custom, undefined);
118
+ });
119
+
120
+ it("handles removing nonexistent provider", () => {
121
+ const writer = new ModelsJsonWriter(modelsJsonPath);
122
+ writer.removeProvider("nonexistent");
123
+ // Should not throw
124
+ });
125
+ });
126
+
127
+ // ─── listProviders ───────────────────────────────────────────────────────────
128
+
129
+ describe("ModelsJsonWriter — listProviders", () => {
130
+ it("returns empty config when file does not exist", () => {
131
+ const writer = new ModelsJsonWriter(join(testDir, "nonexistent.json"));
132
+ const config = writer.listProviders();
133
+ assert.deepEqual(config, { providers: {} });
134
+ });
135
+
136
+ it("returns current provider config", () => {
137
+ const writer = new ModelsJsonWriter(modelsJsonPath);
138
+ writer.setProvider("openai", { baseUrl: "https://api.openai.com" });
139
+ writer.setProvider("ollama", { baseUrl: "http://localhost:11434" });
140
+
141
+ const config = writer.listProviders();
142
+ assert.ok(config.providers.openai);
143
+ assert.ok(config.providers.ollama);
144
+ });
145
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Safe read-modify-write for models.json with file locking.
3
+ * Prevents concurrent writes from corrupting the config file.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
7
+ import { dirname, join } from "path";
8
+ import lockfile from "proper-lockfile";
9
+ import { getAgentDir } from "../config.js";
10
+
11
+ interface ModelDefinition {
12
+ id: string;
13
+ name?: string;
14
+ api?: string;
15
+ baseUrl?: string;
16
+ reasoning?: boolean;
17
+ input?: ("text" | "image")[];
18
+ cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
19
+ contextWindow?: number;
20
+ maxTokens?: number;
21
+ }
22
+
23
+ interface ProviderConfig {
24
+ baseUrl?: string;
25
+ apiKey?: string;
26
+ api?: string;
27
+ headers?: Record<string, string>;
28
+ authHeader?: boolean;
29
+ models?: ModelDefinition[];
30
+ modelOverrides?: Record<string, Record<string, unknown>>;
31
+ }
32
+
33
+ interface ModelsConfig {
34
+ providers: Record<string, ProviderConfig>;
35
+ }
36
+
37
+ export class ModelsJsonWriter {
38
+ private modelsJsonPath: string;
39
+
40
+ constructor(modelsJsonPath?: string) {
41
+ this.modelsJsonPath = modelsJsonPath ?? join(getAgentDir(), "models.json");
42
+ }
43
+
44
+ /**
45
+ * Add a model to a provider. Creates the provider if it doesn't exist.
46
+ */
47
+ addModel(provider: string, model: ModelDefinition, providerConfig?: Partial<ProviderConfig>): void {
48
+ this.withLock((config) => {
49
+ if (!config.providers[provider]) {
50
+ config.providers[provider] = {
51
+ ...providerConfig,
52
+ models: [],
53
+ };
54
+ }
55
+
56
+ const providerEntry = config.providers[provider];
57
+ if (!providerEntry.models) {
58
+ providerEntry.models = [];
59
+ }
60
+
61
+ // Replace existing model with same id, or append
62
+ const existingIndex = providerEntry.models.findIndex((m) => m.id === model.id);
63
+ if (existingIndex >= 0) {
64
+ providerEntry.models[existingIndex] = model;
65
+ } else {
66
+ providerEntry.models.push(model);
67
+ }
68
+
69
+ return config;
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Remove a model from a provider. Removes the provider if no models remain.
75
+ */
76
+ removeModel(provider: string, modelId: string): void {
77
+ this.withLock((config) => {
78
+ const providerEntry = config.providers[provider];
79
+ if (!providerEntry?.models) return config;
80
+
81
+ providerEntry.models = providerEntry.models.filter((m) => m.id !== modelId);
82
+
83
+ // Clean up empty provider (no models and no overrides)
84
+ if (providerEntry.models.length === 0 && !providerEntry.modelOverrides) {
85
+ delete config.providers[provider];
86
+ }
87
+
88
+ return config;
89
+ });
90
+ }
91
+
92
+ /**
93
+ * Set or update an entire provider configuration.
94
+ */
95
+ setProvider(provider: string, providerConfig: ProviderConfig): void {
96
+ this.withLock((config) => {
97
+ config.providers[provider] = providerConfig;
98
+ return config;
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Remove a provider and all its models.
104
+ */
105
+ removeProvider(provider: string): void {
106
+ this.withLock((config) => {
107
+ delete config.providers[provider];
108
+ return config;
109
+ });
110
+ }
111
+
112
+ /**
113
+ * List all providers and their configurations.
114
+ */
115
+ listProviders(): ModelsConfig {
116
+ return this.readConfig();
117
+ }
118
+
119
+ private readConfig(): ModelsConfig {
120
+ if (!existsSync(this.modelsJsonPath)) {
121
+ return { providers: {} };
122
+ }
123
+ try {
124
+ const content = readFileSync(this.modelsJsonPath, "utf-8");
125
+ return JSON.parse(content) as ModelsConfig;
126
+ } catch {
127
+ return { providers: {} };
128
+ }
129
+ }
130
+
131
+ private writeConfig(config: ModelsConfig): void {
132
+ const dir = dirname(this.modelsJsonPath);
133
+ if (!existsSync(dir)) {
134
+ mkdirSync(dir, { recursive: true });
135
+ }
136
+ writeFileSync(this.modelsJsonPath, JSON.stringify(config, null, 2), "utf-8");
137
+ }
138
+
139
+ private acquireLockWithRetry(): () => void {
140
+ const maxAttempts = 10;
141
+ const delayMs = 20;
142
+ let lastError: unknown;
143
+
144
+ // Ensure file exists for locking
145
+ const dir = dirname(this.modelsJsonPath);
146
+ if (!existsSync(dir)) {
147
+ mkdirSync(dir, { recursive: true });
148
+ }
149
+ if (!existsSync(this.modelsJsonPath)) {
150
+ writeFileSync(this.modelsJsonPath, JSON.stringify({ providers: {} }, null, 2), "utf-8");
151
+ }
152
+
153
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
154
+ try {
155
+ return lockfile.lockSync(this.modelsJsonPath, { realpath: false });
156
+ } catch (error) {
157
+ const code =
158
+ typeof error === "object" && error !== null && "code" in error
159
+ ? String((error as { code?: unknown }).code)
160
+ : undefined;
161
+ if (code !== "ELOCKED" || attempt === maxAttempts) {
162
+ throw error;
163
+ }
164
+ lastError = error;
165
+ const start = Date.now();
166
+ while (Date.now() - start < delayMs) {
167
+ // Busy-wait (same pattern as auth-storage.ts)
168
+ }
169
+ }
170
+ }
171
+
172
+ throw (lastError as Error) ?? new Error("Failed to acquire models.json lock");
173
+ }
174
+
175
+ private withLock(fn: (config: ModelsConfig) => ModelsConfig): void {
176
+ let release: (() => void) | undefined;
177
+ try {
178
+ release = this.acquireLockWithRetry();
179
+ const config = this.readConfig();
180
+ const updated = fn(config);
181
+ this.writeConfig(updated);
182
+ } finally {
183
+ if (release) {
184
+ release();
185
+ }
186
+ }
187
+ }
188
+ }
@@ -79,6 +79,13 @@ export interface FallbackSettings {
79
79
  chains?: Record<string, FallbackChainEntry[]>; // keyed by chain name
80
80
  }
81
81
 
82
+ export interface ModelDiscoverySettings {
83
+ enabled?: boolean; // default: false
84
+ providers?: string[]; // limit discovery to specific providers
85
+ ttlMinutes?: number; // override default TTLs (in minutes)
86
+ autoRefreshOnModelSelect?: boolean; // default: false - refresh discovery when opening model selector
87
+ }
88
+
82
89
  export type TransportSetting = Transport;
83
90
 
84
91
  /**
@@ -134,6 +141,7 @@ export interface Settings {
134
141
  bashInterceptor?: BashInterceptorSettings;
135
142
  taskIsolation?: TaskIsolationSettings;
136
143
  fallback?: FallbackSettings;
144
+ modelDiscovery?: ModelDiscoverySettings;
137
145
  }
138
146
 
139
147
  /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
@@ -1076,4 +1084,17 @@ export class SettingsManager {
1076
1084
  chains: this.getFallbackChains(),
1077
1085
  };
1078
1086
  }
1087
+
1088
+ getModelDiscoverySettings(): ModelDiscoverySettings {
1089
+ return this.settings.modelDiscovery ?? {};
1090
+ }
1091
+
1092
+ setModelDiscoveryEnabled(enabled: boolean): void {
1093
+ if (!this.globalSettings.modelDiscovery) {
1094
+ this.globalSettings.modelDiscovery = {};
1095
+ }
1096
+ this.globalSettings.modelDiscovery.enabled = enabled;
1097
+ this.markModified("modelDiscovery", "enabled");
1098
+ this.save();
1099
+ }
1079
1100
  }
@@ -28,6 +28,7 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
28
28
  { name: "hotkeys", description: "Show all keyboard shortcuts" },
29
29
  { name: "fork", description: "Create a new fork from a previous message" },
30
30
  { name: "tree", description: "Navigate session tree (switch branches)" },
31
+ { name: "provider", description: "Manage provider configuration" },
31
32
  { name: "login", description: "Login with OAuth provider" },
32
33
  { name: "logout", description: "Logout from OAuth provider" },
33
34
  { name: "new", description: "Start a new session" },