gsd-pi 2.17.0 → 2.19.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 (217) 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 +14 -2
  7. package/dist/resources/extensions/gsd/auto-prompts.ts +65 -16
  8. package/dist/resources/extensions/gsd/auto-worktree.ts +33 -4
  9. package/dist/resources/extensions/gsd/auto.ts +399 -29
  10. package/dist/resources/extensions/gsd/captures.ts +384 -0
  11. package/dist/resources/extensions/gsd/commands.ts +382 -23
  12. package/dist/resources/extensions/gsd/complexity-classifier.ts +322 -0
  13. package/dist/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  14. package/dist/resources/extensions/gsd/dispatch-guard.ts +7 -19
  15. package/dist/resources/extensions/gsd/docs/preferences-reference.md +201 -2
  16. package/dist/resources/extensions/gsd/files.ts +123 -1
  17. package/dist/resources/extensions/gsd/guided-flow.ts +237 -4
  18. package/dist/resources/extensions/gsd/index.ts +47 -3
  19. package/dist/resources/extensions/gsd/metrics.ts +48 -0
  20. package/dist/resources/extensions/gsd/model-cost-table.ts +65 -0
  21. package/dist/resources/extensions/gsd/model-router.ts +256 -0
  22. package/dist/resources/extensions/gsd/paths.ts +9 -0
  23. package/dist/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  24. package/dist/resources/extensions/gsd/preferences.ts +132 -1
  25. package/dist/resources/extensions/gsd/prompt-loader.ts +45 -9
  26. package/dist/resources/extensions/gsd/prompts/execute-task.md +6 -5
  27. package/dist/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  28. package/dist/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  29. package/dist/resources/extensions/gsd/prompts/system.md +2 -0
  30. package/dist/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  31. package/dist/resources/extensions/gsd/queue-order.ts +231 -0
  32. package/dist/resources/extensions/gsd/queue-reorder-ui.ts +263 -0
  33. package/dist/resources/extensions/gsd/state.ts +15 -3
  34. package/dist/resources/extensions/gsd/templates/knowledge.md +19 -0
  35. package/dist/resources/extensions/gsd/templates/preferences.md +14 -0
  36. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +20 -0
  37. package/dist/resources/extensions/gsd/tests/captures.test.ts +438 -0
  38. package/dist/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  39. package/dist/resources/extensions/gsd/tests/derive-state-deps.test.ts +99 -0
  40. package/dist/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -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/milestone-transition-worktree.test.ts +144 -0
  45. package/dist/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  46. package/dist/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  47. package/dist/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts +168 -0
  48. package/dist/resources/extensions/gsd/tests/queue-order.test.ts +204 -0
  49. package/dist/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +281 -0
  50. package/dist/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  51. package/dist/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  52. package/dist/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +139 -0
  53. package/dist/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  54. package/dist/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  55. package/dist/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  56. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  57. package/dist/resources/extensions/gsd/triage-resolution.ts +200 -0
  58. package/dist/resources/extensions/gsd/triage-ui.ts +175 -0
  59. package/dist/resources/extensions/gsd/visualizer-data.ts +154 -0
  60. package/dist/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  61. package/dist/resources/extensions/gsd/visualizer-views.ts +293 -0
  62. package/dist/resources/extensions/gsd/worktree-manager.ts +8 -5
  63. package/dist/resources/extensions/gsd/worktree.ts +22 -0
  64. package/dist/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  65. package/dist/resources/extensions/remote-questions/format.ts +12 -6
  66. package/dist/resources/extensions/remote-questions/manager.ts +8 -0
  67. package/dist/resources/extensions/shared/next-action-ui.ts +16 -1
  68. package/package.json +1 -1
  69. package/packages/pi-coding-agent/dist/cli/args.d.ts +5 -0
  70. package/packages/pi-coding-agent/dist/cli/args.d.ts.map +1 -1
  71. package/packages/pi-coding-agent/dist/cli/args.js +21 -0
  72. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  73. package/packages/pi-coding-agent/dist/cli/list-models.d.ts +14 -3
  74. package/packages/pi-coding-agent/dist/cli/list-models.d.ts.map +1 -1
  75. package/packages/pi-coding-agent/dist/cli/list-models.js +52 -17
  76. package/packages/pi-coding-agent/dist/cli/list-models.js.map +1 -1
  77. package/packages/pi-coding-agent/dist/core/discovery-cache.d.ts +27 -0
  78. package/packages/pi-coding-agent/dist/core/discovery-cache.d.ts.map +1 -0
  79. package/packages/pi-coding-agent/dist/core/discovery-cache.js +79 -0
  80. package/packages/pi-coding-agent/dist/core/discovery-cache.js.map +1 -0
  81. package/packages/pi-coding-agent/dist/core/discovery-cache.test.d.ts +2 -0
  82. package/packages/pi-coding-agent/dist/core/discovery-cache.test.d.ts.map +1 -0
  83. package/packages/pi-coding-agent/dist/core/discovery-cache.test.js +140 -0
  84. package/packages/pi-coding-agent/dist/core/discovery-cache.test.js.map +1 -0
  85. package/packages/pi-coding-agent/dist/core/model-discovery.d.ts +35 -0
  86. package/packages/pi-coding-agent/dist/core/model-discovery.d.ts.map +1 -0
  87. package/packages/pi-coding-agent/dist/core/model-discovery.js +162 -0
  88. package/packages/pi-coding-agent/dist/core/model-discovery.js.map +1 -0
  89. package/packages/pi-coding-agent/dist/core/model-discovery.test.d.ts +2 -0
  90. package/packages/pi-coding-agent/dist/core/model-discovery.test.d.ts.map +1 -0
  91. package/packages/pi-coding-agent/dist/core/model-discovery.test.js +100 -0
  92. package/packages/pi-coding-agent/dist/core/model-discovery.test.js.map +1 -0
  93. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.d.ts +2 -0
  94. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.d.ts.map +1 -0
  95. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.js +113 -0
  96. package/packages/pi-coding-agent/dist/core/model-registry-discovery.test.js.map +1 -0
  97. package/packages/pi-coding-agent/dist/core/model-registry.d.ts +26 -0
  98. package/packages/pi-coding-agent/dist/core/model-registry.d.ts.map +1 -1
  99. package/packages/pi-coding-agent/dist/core/model-registry.js +98 -0
  100. package/packages/pi-coding-agent/dist/core/model-registry.js.map +1 -1
  101. package/packages/pi-coding-agent/dist/core/models-json-writer.d.ts +62 -0
  102. package/packages/pi-coding-agent/dist/core/models-json-writer.d.ts.map +1 -0
  103. package/packages/pi-coding-agent/dist/core/models-json-writer.js +145 -0
  104. package/packages/pi-coding-agent/dist/core/models-json-writer.js.map +1 -0
  105. package/packages/pi-coding-agent/dist/core/models-json-writer.test.d.ts +2 -0
  106. package/packages/pi-coding-agent/dist/core/models-json-writer.test.d.ts.map +1 -0
  107. package/packages/pi-coding-agent/dist/core/models-json-writer.test.js +118 -0
  108. package/packages/pi-coding-agent/dist/core/models-json-writer.test.js.map +1 -0
  109. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts +9 -0
  110. package/packages/pi-coding-agent/dist/core/settings-manager.d.ts.map +1 -1
  111. package/packages/pi-coding-agent/dist/core/settings-manager.js +11 -0
  112. package/packages/pi-coding-agent/dist/core/settings-manager.js.map +1 -1
  113. package/packages/pi-coding-agent/dist/core/slash-commands.d.ts.map +1 -1
  114. package/packages/pi-coding-agent/dist/core/slash-commands.js +1 -0
  115. package/packages/pi-coding-agent/dist/core/slash-commands.js.map +1 -1
  116. package/packages/pi-coding-agent/dist/index.d.ts +5 -1
  117. package/packages/pi-coding-agent/dist/index.d.ts.map +1 -1
  118. package/packages/pi-coding-agent/dist/index.js +4 -1
  119. package/packages/pi-coding-agent/dist/index.js.map +1 -1
  120. package/packages/pi-coding-agent/dist/main.d.ts.map +1 -1
  121. package/packages/pi-coding-agent/dist/main.js +17 -2
  122. package/packages/pi-coding-agent/dist/main.js.map +1 -1
  123. package/packages/pi-coding-agent/dist/modes/interactive/components/index.d.ts +1 -0
  124. package/packages/pi-coding-agent/dist/modes/interactive/components/index.d.ts.map +1 -1
  125. package/packages/pi-coding-agent/dist/modes/interactive/components/index.js +1 -0
  126. package/packages/pi-coding-agent/dist/modes/interactive/components/index.js.map +1 -1
  127. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js +1 -1
  128. package/packages/pi-coding-agent/dist/modes/interactive/components/model-selector.js.map +1 -1
  129. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.d.ts +25 -0
  130. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.d.ts.map +1 -0
  131. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js +121 -0
  132. package/packages/pi-coding-agent/dist/modes/interactive/components/provider-manager.js.map +1 -0
  133. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +1 -0
  134. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  135. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +32 -0
  136. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  137. package/packages/pi-coding-agent/src/cli/args.ts +21 -0
  138. package/packages/pi-coding-agent/src/cli/list-models.ts +70 -17
  139. package/packages/pi-coding-agent/src/core/discovery-cache.test.ts +170 -0
  140. package/packages/pi-coding-agent/src/core/discovery-cache.ts +97 -0
  141. package/packages/pi-coding-agent/src/core/model-discovery.test.ts +125 -0
  142. package/packages/pi-coding-agent/src/core/model-discovery.ts +231 -0
  143. package/packages/pi-coding-agent/src/core/model-registry-discovery.test.ts +135 -0
  144. package/packages/pi-coding-agent/src/core/model-registry.ts +107 -0
  145. package/packages/pi-coding-agent/src/core/models-json-writer.test.ts +145 -0
  146. package/packages/pi-coding-agent/src/core/models-json-writer.ts +188 -0
  147. package/packages/pi-coding-agent/src/core/settings-manager.ts +21 -0
  148. package/packages/pi-coding-agent/src/core/slash-commands.ts +1 -0
  149. package/packages/pi-coding-agent/src/index.ts +5 -0
  150. package/packages/pi-coding-agent/src/main.ts +19 -2
  151. package/packages/pi-coding-agent/src/modes/interactive/components/index.ts +1 -0
  152. package/packages/pi-coding-agent/src/modes/interactive/components/model-selector.ts +1 -1
  153. package/packages/pi-coding-agent/src/modes/interactive/components/provider-manager.ts +163 -0
  154. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +37 -0
  155. package/src/resources/extensions/gsd/activity-log.ts +37 -7
  156. package/src/resources/extensions/gsd/auto-dashboard.ts +14 -2
  157. package/src/resources/extensions/gsd/auto-prompts.ts +65 -16
  158. package/src/resources/extensions/gsd/auto-worktree.ts +33 -4
  159. package/src/resources/extensions/gsd/auto.ts +399 -29
  160. package/src/resources/extensions/gsd/captures.ts +384 -0
  161. package/src/resources/extensions/gsd/commands.ts +382 -23
  162. package/src/resources/extensions/gsd/complexity-classifier.ts +322 -0
  163. package/src/resources/extensions/gsd/dashboard-overlay.ts +10 -0
  164. package/src/resources/extensions/gsd/dispatch-guard.ts +7 -19
  165. package/src/resources/extensions/gsd/docs/preferences-reference.md +201 -2
  166. package/src/resources/extensions/gsd/files.ts +123 -1
  167. package/src/resources/extensions/gsd/guided-flow.ts +237 -4
  168. package/src/resources/extensions/gsd/index.ts +47 -3
  169. package/src/resources/extensions/gsd/metrics.ts +48 -0
  170. package/src/resources/extensions/gsd/model-cost-table.ts +65 -0
  171. package/src/resources/extensions/gsd/model-router.ts +256 -0
  172. package/src/resources/extensions/gsd/paths.ts +9 -0
  173. package/src/resources/extensions/gsd/post-unit-hooks.ts +2 -1
  174. package/src/resources/extensions/gsd/preferences.ts +132 -1
  175. package/src/resources/extensions/gsd/prompt-loader.ts +45 -9
  176. package/src/resources/extensions/gsd/prompts/execute-task.md +6 -5
  177. package/src/resources/extensions/gsd/prompts/reassess-roadmap.md +6 -0
  178. package/src/resources/extensions/gsd/prompts/replan-slice.md +8 -0
  179. package/src/resources/extensions/gsd/prompts/system.md +2 -0
  180. package/src/resources/extensions/gsd/prompts/triage-captures.md +62 -0
  181. package/src/resources/extensions/gsd/queue-order.ts +231 -0
  182. package/src/resources/extensions/gsd/queue-reorder-ui.ts +263 -0
  183. package/src/resources/extensions/gsd/state.ts +15 -3
  184. package/src/resources/extensions/gsd/templates/knowledge.md +19 -0
  185. package/src/resources/extensions/gsd/templates/preferences.md +14 -0
  186. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +20 -0
  187. package/src/resources/extensions/gsd/tests/captures.test.ts +438 -0
  188. package/src/resources/extensions/gsd/tests/complexity-classifier.test.ts +181 -0
  189. package/src/resources/extensions/gsd/tests/derive-state-deps.test.ts +99 -0
  190. package/src/resources/extensions/gsd/tests/feature-branch-lifecycle-integration.test.ts +434 -0
  191. package/src/resources/extensions/gsd/tests/in-flight-tool-tracking.test.ts +79 -0
  192. package/src/resources/extensions/gsd/tests/knowledge.test.ts +161 -0
  193. package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +87 -0
  194. package/src/resources/extensions/gsd/tests/milestone-transition-worktree.test.ts +144 -0
  195. package/src/resources/extensions/gsd/tests/model-cost-table.test.ts +69 -0
  196. package/src/resources/extensions/gsd/tests/model-router.test.ts +167 -0
  197. package/src/resources/extensions/gsd/tests/preferences-wizard-fields.test.ts +168 -0
  198. package/src/resources/extensions/gsd/tests/queue-order.test.ts +204 -0
  199. package/src/resources/extensions/gsd/tests/queue-reorder-e2e.test.ts +281 -0
  200. package/src/resources/extensions/gsd/tests/remote-questions.test.ts +227 -1
  201. package/src/resources/extensions/gsd/tests/routing-history.test.ts +215 -62
  202. package/src/resources/extensions/gsd/tests/stale-worktree-cwd.test.ts +139 -0
  203. package/src/resources/extensions/gsd/tests/triage-dispatch.test.ts +224 -0
  204. package/src/resources/extensions/gsd/tests/triage-resolution.test.ts +215 -0
  205. package/src/resources/extensions/gsd/tests/visualizer-data.test.ts +198 -0
  206. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +255 -0
  207. package/src/resources/extensions/gsd/triage-resolution.ts +200 -0
  208. package/src/resources/extensions/gsd/triage-ui.ts +175 -0
  209. package/src/resources/extensions/gsd/visualizer-data.ts +154 -0
  210. package/src/resources/extensions/gsd/visualizer-overlay.ts +193 -0
  211. package/src/resources/extensions/gsd/visualizer-views.ts +293 -0
  212. package/src/resources/extensions/gsd/worktree-manager.ts +8 -5
  213. package/src/resources/extensions/gsd/worktree.ts +22 -0
  214. package/src/resources/extensions/remote-questions/discord-adapter.ts +33 -0
  215. package/src/resources/extensions/remote-questions/format.ts +12 -6
  216. package/src/resources/extensions/remote-questions/manager.ts +8 -0
  217. package/src/resources/extensions/shared/next-action-ui.ts +16 -1
@@ -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" },
@@ -143,7 +143,11 @@ export {
143
143
  // Footer data provider (git branch + extension statuses - data not otherwise available to extensions)
144
144
  export type { ReadonlyFooterDataProvider } from "./core/footer-data-provider.js";
145
145
  export { convertToLlm } from "./core/messages.js";
146
+ export { ModelDiscoveryCache } from "./core/discovery-cache.js";
147
+ export type { DiscoveredModel, DiscoveryResult, ProviderDiscoveryAdapter } from "./core/model-discovery.js";
148
+ export { getDiscoverableProviders, getDiscoveryAdapter } from "./core/model-discovery.js";
146
149
  export { ModelRegistry } from "./core/model-registry.js";
150
+ export { ModelsJsonWriter } from "./core/models-json-writer.js";
147
151
  export type {
148
152
  PackageManager,
149
153
  PathMetadata,
@@ -307,6 +311,7 @@ export {
307
311
  LoginDialogComponent,
308
312
  ModelSelectorComponent,
309
313
  OAuthSelectorComponent,
314
+ ProviderManagerComponent,
310
315
  type RenderDiffOptions,
311
316
  rawKeyHint,
312
317
  renderDiff,
@@ -11,7 +11,7 @@ import { createInterface } from "readline";
11
11
  import { type Args, parseArgs, printHelp } from "./cli/args.js";
12
12
  import { selectConfig } from "./cli/config-selector.js";
13
13
  import { processFileArguments } from "./cli/file-processor.js";
14
- import { listModels } from "./cli/list-models.js";
14
+ import { discoverAndPrintModels, listModels } from "./cli/list-models.js";
15
15
  import { selectSession } from "./cli/session-picker.js";
16
16
  import { APP_NAME, getAgentDir, getModelsPath, VERSION } from "./config.js";
17
17
  import { AuthStorage } from "./core/auth-storage.js";
@@ -660,9 +660,26 @@ export async function main(args: string[]) {
660
660
  process.exit(0);
661
661
  }
662
662
 
663
+ if (parsed.addProvider) {
664
+ const { ModelsJsonWriter } = await import("./core/models-json-writer.js");
665
+ const writer = new ModelsJsonWriter();
666
+ writer.setProvider(parsed.addProvider, {
667
+ baseUrl: parsed.addProviderBaseUrl,
668
+ apiKey: parsed.apiKey,
669
+ });
670
+ console.log(`Provider "${parsed.addProvider}" added to models.json`);
671
+ process.exit(0);
672
+ }
673
+
674
+ if (parsed.discoverModels !== undefined) {
675
+ const provider = typeof parsed.discoverModels === "string" ? parsed.discoverModels : undefined;
676
+ await discoverAndPrintModels(modelRegistry, provider);
677
+ process.exit(0);
678
+ }
679
+
663
680
  if (parsed.listModels !== undefined) {
664
681
  const searchPattern = typeof parsed.listModels === "string" ? parsed.listModels : undefined;
665
- await listModels(modelRegistry, searchPattern);
682
+ await listModels(modelRegistry, { searchPattern, discover: parsed.discover });
666
683
  process.exit(0);
667
684
  }
668
685
 
@@ -18,6 +18,7 @@ export { appKey, appKeyHint, editorKey, keyHint, rawKeyHint } from "./keybinding
18
18
  export { LoginDialogComponent } from "./login-dialog.js";
19
19
  export { ModelSelectorComponent } from "./model-selector.js";
20
20
  export { OAuthSelectorComponent } from "./oauth-selector.js";
21
+ export { ProviderManagerComponent } from "./provider-manager.js";
21
22
  export { type ModelsCallbacks, type ModelsConfig, ScopedModelsSelectorComponent } from "./scoped-models-selector.js";
22
23
  export { SessionSelectorComponent } from "./session-selector.js";
23
24
  export { type SettingsCallbacks, type SettingsConfig, SettingsSelectorComponent } from "./settings-selector.js";
@@ -160,7 +160,7 @@ export class ModelSelectorComponent extends Container implements Focusable {
160
160
 
161
161
  // Load available models (built-in models still work even if models.json failed)
162
162
  try {
163
- const availableModels = await this.modelRegistry.getAvailable();
163
+ const availableModels = this.modelRegistry.getAvailable();
164
164
  models = availableModels.map((model: Model<any>) => ({
165
165
  provider: model.provider,
166
166
  id: model.id,
@@ -0,0 +1,163 @@
1
+ /**
2
+ * TUI component for managing provider configurations.
3
+ * Shows providers with auth status, discovery support, and model counts.
4
+ */
5
+
6
+ import {
7
+ Container,
8
+ type Focusable,
9
+ getEditorKeybindings,
10
+ Spacer,
11
+ Text,
12
+ type TUI,
13
+ } from "@gsd/pi-tui";
14
+ import type { AuthStorage } from "../../../core/auth-storage.js";
15
+ import { getDiscoverableProviders } from "../../../core/model-discovery.js";
16
+ import type { ModelRegistry } from "../../../core/model-registry.js";
17
+ import { theme } from "../theme/theme.js";
18
+ import { rawKeyHint } from "./keybinding-hints.js";
19
+
20
+ interface ProviderInfo {
21
+ name: string;
22
+ hasAuth: boolean;
23
+ supportsDiscovery: boolean;
24
+ modelCount: number;
25
+ }
26
+
27
+ export class ProviderManagerComponent extends Container implements Focusable {
28
+ private _focused = false;
29
+ get focused(): boolean {
30
+ return this._focused;
31
+ }
32
+ set focused(value: boolean) {
33
+ this._focused = value;
34
+ }
35
+
36
+ private providers: ProviderInfo[] = [];
37
+ private selectedIndex = 0;
38
+ private listContainer: Container;
39
+ private tui: TUI;
40
+ private authStorage: AuthStorage;
41
+ private modelRegistry: ModelRegistry;
42
+ private onDone: () => void;
43
+ private onDiscover: (provider: string) => void;
44
+
45
+ constructor(
46
+ tui: TUI,
47
+ authStorage: AuthStorage,
48
+ modelRegistry: ModelRegistry,
49
+ onDone: () => void,
50
+ onDiscover: (provider: string) => void,
51
+ ) {
52
+ super();
53
+
54
+ this.tui = tui;
55
+ this.authStorage = authStorage;
56
+ this.modelRegistry = modelRegistry;
57
+ this.onDone = onDone;
58
+ this.onDiscover = onDiscover;
59
+
60
+ // Header
61
+ this.addChild(new Text(theme.fg("accent", "Provider Manager"), 0, 0));
62
+ this.addChild(new Spacer(1));
63
+
64
+ // Hints
65
+ const hints = [
66
+ rawKeyHint("d", "discover"),
67
+ rawKeyHint("r", "remove auth"),
68
+ rawKeyHint("esc", "close"),
69
+ ].join(" ");
70
+ this.addChild(new Text(hints, 0, 0));
71
+ this.addChild(new Spacer(1));
72
+
73
+ // List
74
+ this.listContainer = new Container();
75
+ this.addChild(this.listContainer);
76
+
77
+ this.loadProviders();
78
+ this.updateList();
79
+ }
80
+
81
+ private loadProviders(): void {
82
+ const discoverableSet = new Set(getDiscoverableProviders());
83
+ const allModels = this.modelRegistry.getAll();
84
+
85
+ // Group models by provider
86
+ const providerModelCounts = new Map<string, number>();
87
+ for (const model of allModels) {
88
+ providerModelCounts.set(model.provider, (providerModelCounts.get(model.provider) ?? 0) + 1);
89
+ }
90
+
91
+ // Build provider list from all known providers
92
+ const providerNames = new Set([
93
+ ...providerModelCounts.keys(),
94
+ ...discoverableSet,
95
+ ]);
96
+
97
+ this.providers = Array.from(providerNames)
98
+ .sort()
99
+ .map((name) => ({
100
+ name,
101
+ hasAuth: this.authStorage.hasAuth(name),
102
+ supportsDiscovery: discoverableSet.has(name),
103
+ modelCount: providerModelCounts.get(name) ?? 0,
104
+ }));
105
+ }
106
+
107
+ private updateList(): void {
108
+ this.listContainer.clear();
109
+
110
+ for (let i = 0; i < this.providers.length; i++) {
111
+ const p = this.providers[i];
112
+ const isSelected = i === this.selectedIndex;
113
+
114
+ const authBadge = p.hasAuth ? theme.fg("success", "[auth]") : theme.fg("muted", "[no auth]");
115
+ const discoveryBadge = p.supportsDiscovery ? theme.fg("accent", "[discovery]") : "";
116
+ const countBadge = theme.fg("muted", `(${p.modelCount} models)`);
117
+
118
+ const prefix = isSelected ? theme.fg("accent", "> ") : " ";
119
+ const nameText = isSelected ? theme.fg("accent", p.name) : p.name;
120
+
121
+ const parts = [prefix, nameText, " ", authBadge];
122
+ if (discoveryBadge) parts.push(" ", discoveryBadge);
123
+ parts.push(" ", countBadge);
124
+
125
+ this.listContainer.addChild(new Text(parts.join(""), 0, 0));
126
+ }
127
+
128
+ if (this.providers.length === 0) {
129
+ this.listContainer.addChild(new Text(theme.fg("muted", " No providers configured"), 0, 0));
130
+ }
131
+ }
132
+
133
+ handleInput(keyData: string): void {
134
+ const kb = getEditorKeybindings();
135
+
136
+ if (kb.matches(keyData, "selectUp")) {
137
+ if (this.providers.length === 0) return;
138
+ this.selectedIndex = this.selectedIndex === 0 ? this.providers.length - 1 : this.selectedIndex - 1;
139
+ this.updateList();
140
+ this.tui.requestRender();
141
+ } else if (kb.matches(keyData, "selectDown")) {
142
+ if (this.providers.length === 0) return;
143
+ this.selectedIndex = this.selectedIndex === this.providers.length - 1 ? 0 : this.selectedIndex + 1;
144
+ this.updateList();
145
+ this.tui.requestRender();
146
+ } else if (kb.matches(keyData, "selectCancel")) {
147
+ this.onDone();
148
+ } else if (keyData === "d" || keyData === "D") {
149
+ const provider = this.providers[this.selectedIndex];
150
+ if (provider?.supportsDiscovery) {
151
+ this.onDiscover(provider.name);
152
+ }
153
+ } else if (keyData === "r" || keyData === "R") {
154
+ const provider = this.providers[this.selectedIndex];
155
+ if (provider?.hasAuth) {
156
+ this.authStorage.remove(provider.name);
157
+ this.loadProviders();
158
+ this.updateList();
159
+ this.tui.requestRender();
160
+ }
161
+ }
162
+ }
163
+ }
@@ -83,6 +83,7 @@ import { appKey, appKeyHint, editorKey, formatKeyForDisplay, keyHint, rawKeyHint
83
83
  import { LoginDialogComponent } from "./components/login-dialog.js";
84
84
  import { ModelSelectorComponent } from "./components/model-selector.js";
85
85
  import { OAuthSelectorComponent } from "./components/oauth-selector.js";
86
+ import { ProviderManagerComponent } from "./components/provider-manager.js";
86
87
  import { ScopedModelsSelectorComponent } from "./components/scoped-models-selector.js";
87
88
  import { SessionSelectorComponent } from "./components/session-selector.js";
88
89
  import { SelectSubmenu, SettingsSelectorComponent, THINKING_DESCRIPTIONS } from "./components/settings-selector.js";
@@ -1997,6 +1998,11 @@ export class InteractiveMode {
1997
1998
  this.editor.setText("");
1998
1999
  return;
1999
2000
  }
2001
+ if (text === "/provider") {
2002
+ this.showProviderManager();
2003
+ this.editor.setText("");
2004
+ return;
2005
+ }
2000
2006
  if (text === "/login") {
2001
2007
  this.showOAuthSelector("login");
2002
2008
  this.editor.setText("");
@@ -3746,6 +3752,37 @@ export class InteractiveMode {
3746
3752
  this.showStatus("Resumed session");
3747
3753
  }
3748
3754
 
3755
+ private showProviderManager(): void {
3756
+ this.showSelector((done) => {
3757
+ const component = new ProviderManagerComponent(
3758
+ this.ui,
3759
+ this.session.modelRegistry.authStorage,
3760
+ this.session.modelRegistry,
3761
+ () => {
3762
+ done();
3763
+ this.ui.requestRender();
3764
+ },
3765
+ async (provider: string) => {
3766
+ this.showStatus(`Discovering models for ${provider}...`);
3767
+ try {
3768
+ const results = await this.session.modelRegistry.discoverModels([provider]);
3769
+ const result = results[0];
3770
+ if (result?.error) {
3771
+ this.showError(`Discovery failed: ${result.error}`);
3772
+ } else {
3773
+ this.showStatus(`Discovered ${result?.models.length ?? 0} models from ${provider}`);
3774
+ }
3775
+ } catch (error) {
3776
+ this.showError(error instanceof Error ? error.message : String(error));
3777
+ }
3778
+ done();
3779
+ this.ui.requestRender();
3780
+ },
3781
+ );
3782
+ return { component, focus: component };
3783
+ });
3784
+ }
3785
+
3749
3786
  private async showOAuthSelector(mode: "login" | "logout"): Promise<void> {
3750
3787
  if (mode === "logout") {
3751
3788
  const providers = this.session.modelRegistry.authStorage.list();
@@ -8,7 +8,7 @@
8
8
  * Diagnostic extraction is handled by session-forensics.ts.
9
9
  */
10
10
 
11
- import { writeFileSync, mkdirSync, readdirSync, unlinkSync, statSync, openSync, closeSync, constants } from "node:fs";
11
+ import { writeFileSync, writeSync, mkdirSync, readdirSync, unlinkSync, statSync, openSync, closeSync, constants } from "node:fs";
12
12
  import { createHash } from "node:crypto";
13
13
  import { join } from "node:path";
14
14
 
@@ -23,6 +23,15 @@ interface ActivityLogState {
23
23
 
24
24
  const activityLogState = new Map<string, ActivityLogState>();
25
25
 
26
+ /**
27
+ * Clear accumulated activity log state (#611).
28
+ * Call when auto-mode stops to prevent unbounded memory growth
29
+ * from lastSnapshotKeyByUnit maps accumulating across units.
30
+ */
31
+ export function clearActivityLogState(): void {
32
+ activityLogState.clear();
33
+ }
34
+
26
35
  function scanNextSequence(activityDir: string): number {
27
36
  let maxSeq = 0;
28
37
  try {
@@ -46,9 +55,21 @@ function getActivityState(activityDir: string): ActivityLogState {
46
55
  return state;
47
56
  }
48
57
 
49
- function snapshotKey(unitType: string, unitId: string, content: string): string {
50
- const digest = createHash("sha1").update(content).digest("hex");
51
- return `${unitType}\0${unitId}\0${digest}`;
58
+ /**
59
+ * Build a lightweight dedup key from session entries without serializing
60
+ * the entire content to a string (#611). Uses entry count + hash of
61
+ * the last few entries as a fingerprint instead of hashing megabytes.
62
+ */
63
+ function snapshotKey(unitType: string, unitId: string, entries: unknown[]): string {
64
+ const hash = createHash("sha1");
65
+ hash.update(`${unitType}\0${unitId}\0${entries.length}\0`);
66
+ // Hash only the last 3 entries as a fingerprint — if the session grew,
67
+ // the count change alone detects it; if content changed, the tail hash catches it.
68
+ const tail = entries.slice(-3);
69
+ for (const entry of tail) {
70
+ hash.update(JSON.stringify(entry));
71
+ }
72
+ return hash.digest("hex");
52
73
  }
53
74
 
54
75
  function nextActivityFilePath(
@@ -91,14 +112,23 @@ export function saveActivityLog(
91
112
  mkdirSync(activityDir, { recursive: true });
92
113
 
93
114
  const safeUnitId = unitId.replace(/\//g, "-");
94
- const content = `${entries.map(entry => JSON.stringify(entry)).join("\n")}\n`;
95
115
  const state = getActivityState(activityDir);
96
116
  const unitKey = `${unitType}\0${safeUnitId}`;
97
- const key = snapshotKey(unitType, safeUnitId, content);
117
+ // Use lightweight fingerprint instead of serializing all entries (#611)
118
+ const key = snapshotKey(unitType, safeUnitId, entries);
98
119
  if (state.lastSnapshotKeyByUnit.get(unitKey) === key) return;
99
120
 
100
121
  const filePath = nextActivityFilePath(activityDir, state, unitType, safeUnitId);
101
- writeFileSync(filePath, content, "utf-8");
122
+ // Stream entries to disk line-by-line instead of building one massive string (#611).
123
+ // For large sessions, the single-string approach allocated hundreds of MB.
124
+ const fd = openSync(filePath, "w");
125
+ try {
126
+ for (const entry of entries) {
127
+ writeSync(fd, JSON.stringify(entry) + "\n");
128
+ }
129
+ } finally {
130
+ closeSync(fd);
131
+ }
102
132
  state.nextSeq += 1;
103
133
  state.lastSnapshotKeyByUnit.set(unitKey, key);
104
134
  } catch (e) {
@@ -10,7 +10,7 @@ import type { ExtensionContext, ExtensionCommandContext } from "@gsd/pi-coding-a
10
10
  import type { GSDState } from "./types.js";
11
11
  import { getCurrentBranch } from "./worktree.js";
12
12
  import { getActiveHook } from "./post-unit-hooks.js";
13
- import { getLedger, getProjectTotals, formatCost, formatTokenCount } from "./metrics.js";
13
+ import { getLedger, getProjectTotals, formatCost, formatTokenCount, formatTierSavings } from "./metrics.js";
14
14
  import {
15
15
  resolveMilestoneFile,
16
16
  resolveSliceFile,
@@ -39,6 +39,8 @@ export interface AutoDashboardData {
39
39
  projectedRemainingCost?: number;
40
40
  /** Whether token profile has been auto-downgraded due to budget prediction */
41
41
  profileDowngraded?: boolean;
42
+ /** Number of pending captures awaiting triage (0 if none or file missing) */
43
+ pendingCaptureCount: number;
42
44
  }
43
45
 
44
46
  // ─── Unit Description Helpers ─────────────────────────────────────────────────
@@ -239,6 +241,7 @@ export function updateProgressWidget(
239
241
  unitId: string,
240
242
  state: GSDState,
241
243
  accessors: WidgetStateAccessors,
244
+ tierBadge?: string,
242
245
  ): void {
243
246
  if (!ctx.hasUI) return;
244
247
 
@@ -319,7 +322,8 @@ export function updateProgressWidget(
319
322
 
320
323
  const target = task ? `${task.id}: ${task.title}` : unitId;
321
324
  const actionLeft = `${pad}${theme.fg("accent", "▸")} ${theme.fg("accent", verb)} ${theme.fg("text", target)}`;
322
- const phaseBadge = theme.fg("dim", phaseLabel);
325
+ const tierTag = tierBadge ? theme.fg("dim", `[${tierBadge}] `) : "";
326
+ const phaseBadge = `${tierTag}${theme.fg("dim", phaseLabel)}`;
323
327
  lines.push(rightAlign(actionLeft, phaseBadge, width));
324
328
  lines.push("");
325
329
 
@@ -414,6 +418,14 @@ export function updateProgressWidget(
414
418
  ? `${modelPhase}${theme.fg("dim", modelDisplay)}`
415
419
  : "";
416
420
  lines.push(rightAlign(`${pad}${sLeft}`, sRight, width));
421
+
422
+ // Dynamic routing savings summary
423
+ if (mLedger && mLedger.units.some(u => u.tier)) {
424
+ const savings = formatTierSavings(mLedger.units);
425
+ if (savings) {
426
+ lines.push(truncateToWidth(theme.fg("dim", `${pad}${savings}`), width));
427
+ }
428
+ }
417
429
  }
418
430
 
419
431
  const hintParts: string[] = [];