gsd-pi 2.31.2-dev.c8d7e03 → 2.32.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 (39) hide show
  1. package/dist/resources/extensions/gsd/auto-start.ts +4 -2
  2. package/dist/resources/extensions/gsd/commands.ts +19 -0
  3. package/dist/resources/extensions/gsd/dashboard-overlay.ts +28 -0
  4. package/dist/resources/extensions/gsd/doctor-environment.ts +497 -0
  5. package/dist/resources/extensions/gsd/doctor-providers.ts +343 -0
  6. package/dist/resources/extensions/gsd/doctor-types.ts +14 -1
  7. package/dist/resources/extensions/gsd/doctor.ts +6 -0
  8. package/dist/resources/extensions/gsd/health-widget.ts +167 -0
  9. package/dist/resources/extensions/gsd/index.ts +6 -0
  10. package/dist/resources/extensions/gsd/progress-score.ts +273 -0
  11. package/dist/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
  12. package/dist/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
  13. package/dist/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
  14. package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
  15. package/dist/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
  16. package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
  17. package/dist/resources/extensions/gsd/visualizer-data.ts +60 -2
  18. package/dist/resources/extensions/gsd/visualizer-views.ts +54 -0
  19. package/package.json +1 -1
  20. package/packages/pi-coding-agent/package.json +1 -1
  21. package/pkg/package.json +1 -1
  22. package/src/resources/extensions/gsd/auto-start.ts +4 -2
  23. package/src/resources/extensions/gsd/commands.ts +19 -0
  24. package/src/resources/extensions/gsd/dashboard-overlay.ts +28 -0
  25. package/src/resources/extensions/gsd/doctor-environment.ts +497 -0
  26. package/src/resources/extensions/gsd/doctor-providers.ts +343 -0
  27. package/src/resources/extensions/gsd/doctor-types.ts +14 -1
  28. package/src/resources/extensions/gsd/doctor.ts +6 -0
  29. package/src/resources/extensions/gsd/health-widget.ts +167 -0
  30. package/src/resources/extensions/gsd/index.ts +6 -0
  31. package/src/resources/extensions/gsd/progress-score.ts +273 -0
  32. package/src/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
  33. package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
  34. package/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
  35. package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
  36. package/src/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
  37. package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
  38. package/src/resources/extensions/gsd/visualizer-data.ts +60 -2
  39. package/src/resources/extensions/gsd/visualizer-views.ts +54 -0
@@ -0,0 +1,343 @@
1
+ /**
2
+ * GSD Doctor — Provider & Integration Health Checks
3
+ *
4
+ * Fast, deterministic checks for external service configuration.
5
+ * Checks key presence in auth.json and environment variables — no HTTP calls,
6
+ * no network I/O, always sub-10ms.
7
+ *
8
+ * Covers:
9
+ * - LLM providers required by the effective model preferences (per phase)
10
+ * - Remote questions channel if configured (Slack/Discord/Telegram token)
11
+ * - Optional search/tool integrations (Brave, Tavily, Jina, Context7)
12
+ */
13
+
14
+ import { existsSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { AuthStorage } from "@gsd/pi-coding-agent";
17
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
18
+ import { getAuthPath, PROVIDER_REGISTRY, type ProviderCategory } from "./key-manager.js";
19
+
20
+ // ── Types ──────────────────────────────────────────────────────────────────────
21
+
22
+ export type ProviderCheckStatus = "ok" | "warning" | "error" | "unconfigured";
23
+
24
+ export interface ProviderCheckResult {
25
+ /** Provider id from PROVIDER_REGISTRY (e.g. "anthropic", "slack_bot") */
26
+ name: string;
27
+ /** Human-readable label */
28
+ label: string;
29
+ /** Functional grouping */
30
+ category: ProviderCategory;
31
+ status: ProviderCheckStatus;
32
+ message: string;
33
+ /** Optional extra detail (e.g. which env var to set) */
34
+ detail?: string;
35
+ /** True if this provider is actively required by preferences */
36
+ required: boolean;
37
+ }
38
+
39
+ // ── Model → Provider ID mapping ───────────────────────────────────────────────
40
+
41
+ /**
42
+ * Infer the auth provider ID from a model string.
43
+ * Handles plain model IDs ("claude-sonnet-4-6") and prefixed ones ("openrouter/deepseek").
44
+ */
45
+ function modelToProviderId(model: string): string | null {
46
+ if (!model) return null;
47
+
48
+ // Explicit provider prefix (e.g. "openrouter/deepseek-r1")
49
+ if (model.includes("/")) {
50
+ const prefix = model.split("/")[0].toLowerCase();
51
+ // Map known prefixes to registry IDs
52
+ const prefixMap: Record<string, string> = {
53
+ openrouter: "openrouter",
54
+ groq: "groq",
55
+ mistral: "mistral",
56
+ google: "google",
57
+ anthropic: "anthropic",
58
+ openai: "openai",
59
+ };
60
+ if (prefixMap[prefix]) return prefixMap[prefix];
61
+ }
62
+
63
+ const lower = model.toLowerCase();
64
+ if (lower.startsWith("claude")) return "anthropic";
65
+ if (lower.startsWith("gpt-") || lower.startsWith("o1") || lower.startsWith("o3")) return "openai";
66
+ if (lower.startsWith("gemini")) return "google";
67
+ if (lower.startsWith("llama") || lower.startsWith("mixtral")) return "groq";
68
+ if (lower.startsWith("grok")) return "xai";
69
+ if (lower.startsWith("mistral") || lower.startsWith("codestral")) return "mistral";
70
+
71
+ return null;
72
+ }
73
+
74
+ /** Collect all model strings from effective preferences across all phases. */
75
+ function collectConfiguredModelProviders(): Set<string> {
76
+ const providers = new Set<string>();
77
+
78
+ try {
79
+ const loaded = loadEffectiveGSDPreferences();
80
+ const models = loaded?.preferences?.models;
81
+ if (!models) {
82
+ // Default: Anthropic
83
+ providers.add("anthropic");
84
+ return providers;
85
+ }
86
+
87
+ const modelEntries = typeof models === "object" ? Object.values(models) : [];
88
+ for (const entry of modelEntries) {
89
+ const modelId = typeof entry === "string" ? entry
90
+ : typeof entry === "object" && entry !== null && "model" in entry
91
+ ? String((entry as { model: unknown }).model)
92
+ : null;
93
+ if (modelId) {
94
+ const pid = modelToProviderId(modelId);
95
+ if (pid) providers.add(pid);
96
+ }
97
+ }
98
+ } catch {
99
+ // Preferences not readable — assume Anthropic as default
100
+ providers.add("anthropic");
101
+ }
102
+
103
+ if (providers.size === 0) providers.add("anthropic");
104
+ return providers;
105
+ }
106
+
107
+ // ── Key resolution ─────────────────────────────────────────────────────────────
108
+
109
+ interface KeyLookup {
110
+ found: boolean;
111
+ source: "auth.json" | "env" | "none";
112
+ backedOff: boolean;
113
+ }
114
+
115
+ function resolveKey(providerId: string): KeyLookup {
116
+ const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
117
+
118
+ // Check auth.json
119
+ const authPath = getAuthPath();
120
+ if (existsSync(authPath)) {
121
+ try {
122
+ const auth = AuthStorage.create(authPath);
123
+ const creds = auth.getCredentialsForProvider(providerId);
124
+ if (creds.length > 0) {
125
+ // Filter out empty placeholder keys (from skipped onboarding)
126
+ const hasRealKey = creds.some(c =>
127
+ c.type === "oauth" || (c.type === "api_key" && (c as { key?: string }).key)
128
+ );
129
+ if (hasRealKey) {
130
+ return {
131
+ found: true,
132
+ source: "auth.json",
133
+ backedOff: auth.areAllCredentialsBackedOff(providerId),
134
+ };
135
+ }
136
+ }
137
+ } catch {
138
+ // auth.json malformed — fall through to env check
139
+ }
140
+ }
141
+
142
+ // Check environment variable
143
+ if (info?.envVar && process.env[info.envVar]) {
144
+ return { found: true, source: "env", backedOff: false };
145
+ }
146
+
147
+ return { found: false, source: "none", backedOff: false };
148
+ }
149
+
150
+ // ── Individual check groups ────────────────────────────────────────────────────
151
+
152
+ function checkLlmProviders(): ProviderCheckResult[] {
153
+ const required = collectConfiguredModelProviders();
154
+ const results: ProviderCheckResult[] = [];
155
+
156
+ for (const providerId of required) {
157
+ const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
158
+ const label = info?.label ?? providerId;
159
+ const lookup = resolveKey(providerId);
160
+
161
+ if (!lookup.found) {
162
+ const envVar = info?.envVar ?? `${providerId.toUpperCase()}_API_KEY`;
163
+ results.push({
164
+ name: providerId,
165
+ label,
166
+ category: "llm",
167
+ status: "error",
168
+ message: `${label} — no API key found`,
169
+ detail: info?.hasOAuth
170
+ ? `Run /gsd keys to authenticate`
171
+ : `Set ${envVar} or run /gsd keys`,
172
+ required: true,
173
+ });
174
+ } else if (lookup.backedOff) {
175
+ results.push({
176
+ name: providerId,
177
+ label,
178
+ category: "llm",
179
+ status: "warning",
180
+ message: `${label} — all credentials backed off (rate limited)`,
181
+ detail: `GSD will retry automatically`,
182
+ required: true,
183
+ });
184
+ } else {
185
+ results.push({
186
+ name: providerId,
187
+ label,
188
+ category: "llm",
189
+ status: "ok",
190
+ message: `${label} — key present (${lookup.source})`,
191
+ required: true,
192
+ });
193
+ }
194
+ }
195
+
196
+ return results;
197
+ }
198
+
199
+ function checkRemoteQuestionsProvider(): ProviderCheckResult | null {
200
+ try {
201
+ const loaded = loadEffectiveGSDPreferences();
202
+ const rq = loaded?.preferences?.remote_questions;
203
+ if (!rq) return null;
204
+
205
+ const channel = rq.channel as string | undefined;
206
+ if (!channel) return null;
207
+
208
+ const providerMap: Record<string, string> = {
209
+ slack: "slack_bot",
210
+ discord: "discord_bot",
211
+ telegram: "telegram_bot",
212
+ };
213
+
214
+ const providerId = providerMap[channel.toLowerCase()];
215
+ if (!providerId) return null;
216
+
217
+ const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
218
+ const label = info?.label ?? channel;
219
+ const lookup = resolveKey(providerId);
220
+
221
+ if (!lookup.found) {
222
+ return {
223
+ name: providerId,
224
+ label,
225
+ category: "remote",
226
+ status: "warning",
227
+ message: `${label} — channel configured but token not found`,
228
+ detail: info?.envVar ? `Set ${info.envVar} or run /gsd keys` : `Run /gsd keys to configure`,
229
+ required: true,
230
+ };
231
+ }
232
+
233
+ return {
234
+ name: providerId,
235
+ label,
236
+ category: "remote",
237
+ status: "ok",
238
+ message: `${label} — token present (${lookup.source})`,
239
+ required: true,
240
+ };
241
+ } catch {
242
+ return null;
243
+ }
244
+ }
245
+
246
+ function checkOptionalProviders(): ProviderCheckResult[] {
247
+ const optional = ["brave", "tavily", "jina", "context7"] as const;
248
+ const results: ProviderCheckResult[] = [];
249
+
250
+ for (const providerId of optional) {
251
+ const info = PROVIDER_REGISTRY.find(p => p.id === providerId);
252
+ if (!info) continue;
253
+
254
+ const lookup = resolveKey(providerId);
255
+ results.push({
256
+ name: providerId,
257
+ label: info.label,
258
+ category: info.category as ProviderCategory,
259
+ status: lookup.found ? "ok" : "unconfigured",
260
+ message: lookup.found
261
+ ? `${info.label} — key present (${lookup.source})`
262
+ : `${info.label} — not configured (optional)`,
263
+ detail: !lookup.found && info.envVar ? `Set ${info.envVar} to enable` : undefined,
264
+ required: false,
265
+ });
266
+ }
267
+
268
+ return results;
269
+ }
270
+
271
+ // ── Public API ─────────────────────────────────────────────────────────────────
272
+
273
+ /**
274
+ * Run all provider checks: required LLM keys, remote questions channel, optional tools.
275
+ * Fast (sub-10ms) — reads auth.json and env vars only, no network I/O.
276
+ */
277
+ export function runProviderChecks(): ProviderCheckResult[] {
278
+ const results: ProviderCheckResult[] = [];
279
+
280
+ results.push(...checkLlmProviders());
281
+
282
+ const remoteCheck = checkRemoteQuestionsProvider();
283
+ if (remoteCheck) results.push(remoteCheck);
284
+
285
+ results.push(...checkOptionalProviders());
286
+
287
+ return results;
288
+ }
289
+
290
+ /**
291
+ * Format provider check results as a human-readable report string.
292
+ */
293
+ export function formatProviderReport(results: ProviderCheckResult[]): string {
294
+ if (results.length === 0) return "No provider checks run.";
295
+
296
+ const lines: string[] = [];
297
+
298
+ const groups: Record<string, ProviderCheckResult[]> = {};
299
+ for (const r of results) {
300
+ (groups[r.category] ??= []).push(r);
301
+ }
302
+
303
+ const categoryLabels: Record<string, string> = {
304
+ llm: "LLM Providers",
305
+ remote: "Notifications",
306
+ search: "Search",
307
+ tool: "Tools",
308
+ };
309
+
310
+ for (const [cat, items] of Object.entries(groups)) {
311
+ lines.push(`${categoryLabels[cat] ?? cat}:`);
312
+ for (const item of items) {
313
+ const icon = item.status === "ok" ? "✓"
314
+ : item.status === "warning" ? "⚠"
315
+ : item.status === "error" ? "✗"
316
+ : "·";
317
+ lines.push(` ${icon} ${item.message}`);
318
+ if (item.detail && item.status !== "ok") {
319
+ lines.push(` ${item.detail}`);
320
+ }
321
+ }
322
+ }
323
+
324
+ return lines.join("\n");
325
+ }
326
+
327
+ /**
328
+ * Summarise check results to a compact widget-friendly string.
329
+ * Returns null if all required providers are ok.
330
+ */
331
+ export function summariseProviderIssues(results: ProviderCheckResult[]): string | null {
332
+ const errors = results.filter(r => r.required && r.status === "error");
333
+ const warnings = results.filter(r => r.required && r.status === "warning");
334
+
335
+ if (errors.length === 0 && warnings.length === 0) return null;
336
+
337
+ const parts: string[] = [];
338
+ if (errors.length > 0) parts.push(`✗ ${errors[0].label} key missing`);
339
+ if (warnings.length > 0 && errors.length === 0) parts.push(`⚠ ${warnings[0].label} backed off`);
340
+ if (errors.length + warnings.length > 1) parts.push(`(+${errors.length + warnings.length - 1} more)`);
341
+
342
+ return parts.join(" ");
343
+ }
@@ -32,7 +32,20 @@ export type DoctorIssueCode =
32
32
  | "gitignore_missing_patterns"
33
33
  | "unresolvable_dependency"
34
34
  | "failed_migration"
35
- | "broken_symlink";
35
+ | "broken_symlink"
36
+ // Environment health checks (#1221)
37
+ | "env_node_version"
38
+ | "env_dependencies"
39
+ | "env_env_file"
40
+ | "env_port_conflict"
41
+ | "env_disk_space"
42
+ | "env_docker"
43
+ | "env_package_manager"
44
+ | "env_typescript"
45
+ | "env_python"
46
+ | "env_cargo"
47
+ | "env_go"
48
+ | "env_git_remote";
36
49
 
37
50
  /**
38
51
  * Issue codes that represent expected completion-transition states.
@@ -10,12 +10,15 @@ import { loadEffectiveGSDPreferences, type GSDPreferences } from "./preferences.
10
10
  import type { DoctorIssue, DoctorIssueCode } from "./doctor-types.js";
11
11
  import { COMPLETION_TRANSITION_CODES } from "./doctor-types.js";
12
12
  import { checkGitHealth, checkRuntimeHealth } from "./doctor-checks.js";
13
+ import { checkEnvironmentHealth } from "./doctor-environment.js";
13
14
 
14
15
  // ── Re-exports ─────────────────────────────────────────────────────────────
15
16
  // All public types and functions from extracted modules are re-exported here
16
17
  // so that existing imports from "./doctor.js" continue to work unchanged.
17
18
  export type { DoctorSeverity, DoctorIssueCode, DoctorIssue, DoctorReport, DoctorSummary } from "./doctor-types.js";
18
19
  export { summarizeDoctorIssues, filterDoctorIssues, formatDoctorReport, formatDoctorIssuesForPrompt } from "./doctor-format.js";
20
+ export { runEnvironmentChecks, runFullEnvironmentChecks, formatEnvironmentReport, type EnvironmentCheckResult } from "./doctor-environment.js";
21
+ export { computeProgressScore, computeProgressScoreWithContext, formatProgressLine, formatProgressReport, type ProgressScore, type ProgressLevel } from "./progress-score.js";
19
22
 
20
23
  /**
21
24
  * Characters that are used as delimiters in GSD state management documents
@@ -390,6 +393,9 @@ export async function runGSDDoctor(basePath: string, options?: { fix?: boolean;
390
393
  // Runtime health checks (crash locks, completed-units, hook state, activity logs, STATE.md, gitignore)
391
394
  await checkRuntimeHealth(basePath, issues, fixesApplied, shouldFix);
392
395
 
396
+ // Environment health checks (#1221: missing tools, port conflicts, stale deps, disk space)
397
+ await checkEnvironmentHealth(basePath, issues, { includeRemote: !options?.scope });
398
+
393
399
  const milestonesPath = milestonesDir(basePath);
394
400
  if (!existsSync(milestonesPath)) {
395
401
  return { ok: issues.every(issue => issue.severity !== "error"), basePath, issues, fixesApplied };
@@ -0,0 +1,167 @@
1
+ /**
2
+ * GSD Health Widget — always-on ambient health signal rendered belowEditor.
3
+ *
4
+ * Shows a compact 1-2 line summary: progress score, budget, provider key
5
+ * status, and doctor/environment issue count. Refreshes every 60 seconds.
6
+ * Quiet when everything is healthy; turns amber/red when issues arise.
7
+ *
8
+ * Widget key: "gsd-health", placement: "belowEditor"
9
+ */
10
+
11
+ import type { ExtensionContext } from "@gsd/pi-coding-agent";
12
+ import { runProviderChecks, summariseProviderIssues } from "./doctor-providers.js";
13
+ import { runEnvironmentChecks } from "./doctor-environment.js";
14
+ import { loadEffectiveGSDPreferences } from "./preferences.js";
15
+ import { loadLedgerFromDisk, getProjectTotals } from "./metrics.js";
16
+ import { projectRoot } from "./commands.js";
17
+
18
+ // ── Types ──────────────────────────────────────────────────────────────────────
19
+
20
+ interface HealthWidgetData {
21
+ hasProject: boolean;
22
+ budgetCeiling: number | undefined;
23
+ budgetSpent: number;
24
+ providerIssue: string | null; // compact summary from summariseProviderIssues()
25
+ environmentErrorCount: number;
26
+ environmentWarningCount: number;
27
+ lastRefreshed: number;
28
+ }
29
+
30
+ // ── Data loader ────────────────────────────────────────────────────────────────
31
+
32
+ function loadHealthWidgetData(basePath: string): HealthWidgetData {
33
+ let hasProject = false;
34
+ let budgetCeiling: number | undefined;
35
+ let budgetSpent = 0;
36
+ let providerIssue: string | null = null;
37
+ let environmentErrorCount = 0;
38
+ let environmentWarningCount = 0;
39
+
40
+ try {
41
+ const prefs = loadEffectiveGSDPreferences();
42
+ budgetCeiling = prefs?.preferences?.budget_ceiling;
43
+
44
+ const ledger = loadLedgerFromDisk(basePath);
45
+ if (ledger) {
46
+ hasProject = true;
47
+ const totals = getProjectTotals(ledger.units ?? []);
48
+ budgetSpent = totals.cost;
49
+ }
50
+ } catch { /* non-fatal */ }
51
+
52
+ try {
53
+ const providerResults = runProviderChecks();
54
+ providerIssue = summariseProviderIssues(providerResults);
55
+ } catch { /* non-fatal */ }
56
+
57
+ try {
58
+ const envResults = runEnvironmentChecks(basePath);
59
+ for (const r of envResults) {
60
+ if (r.status === "error") environmentErrorCount++;
61
+ else if (r.status === "warning") environmentWarningCount++;
62
+ }
63
+ } catch { /* non-fatal */ }
64
+
65
+ return {
66
+ hasProject,
67
+ budgetCeiling,
68
+ budgetSpent,
69
+ providerIssue,
70
+ environmentErrorCount,
71
+ environmentWarningCount,
72
+ lastRefreshed: Date.now(),
73
+ };
74
+ }
75
+
76
+ // ── Rendering ──────────────────────────────────────────────────────────────────
77
+
78
+ function formatCost(n: number): string {
79
+ return n >= 1 ? `$${n.toFixed(2)}` : `${(n * 100).toFixed(1)}¢`;
80
+ }
81
+
82
+ /**
83
+ * Build compact health lines for the widget.
84
+ * Returns a string array suitable for setWidget().
85
+ */
86
+ export function buildHealthLines(data: HealthWidgetData): string[] {
87
+ if (!data.hasProject) {
88
+ return [" GSD No project loaded — run /gsd to start"];
89
+ }
90
+
91
+ const parts: string[] = [];
92
+
93
+ // System status signal
94
+ const totalIssues = data.environmentErrorCount + data.environmentWarningCount + (data.providerIssue ? 1 : 0);
95
+ if (totalIssues === 0) {
96
+ parts.push("● System OK");
97
+ } else if (data.environmentErrorCount > 0 || data.providerIssue?.includes("✗")) {
98
+ parts.push(`✗ ${totalIssues} issue${totalIssues > 1 ? "s" : ""}`);
99
+ } else {
100
+ parts.push(`⚠ ${totalIssues} warning${totalIssues > 1 ? "s" : ""}`);
101
+ }
102
+
103
+ // Budget
104
+ if (data.budgetCeiling !== undefined && data.budgetCeiling > 0) {
105
+ const pct = Math.min(100, (data.budgetSpent / data.budgetCeiling) * 100);
106
+ parts.push(`Budget: ${formatCost(data.budgetSpent)}/${formatCost(data.budgetCeiling)} (${pct.toFixed(0)}%)`);
107
+ } else if (data.budgetSpent > 0) {
108
+ parts.push(`Spent: ${formatCost(data.budgetSpent)}`);
109
+ }
110
+
111
+ // Provider issue (if any)
112
+ if (data.providerIssue) {
113
+ parts.push(data.providerIssue);
114
+ }
115
+
116
+ // Environment issues
117
+ if (data.environmentErrorCount > 0) {
118
+ parts.push(`Env: ${data.environmentErrorCount} error${data.environmentErrorCount > 1 ? "s" : ""}`);
119
+ } else if (data.environmentWarningCount > 0) {
120
+ parts.push(`Env: ${data.environmentWarningCount} warning${data.environmentWarningCount > 1 ? "s" : ""}`);
121
+ }
122
+
123
+ return [` ${parts.join(" │ ")}`];
124
+ }
125
+
126
+ // ── Widget init ────────────────────────────────────────────────────────────────
127
+
128
+ const REFRESH_INTERVAL_MS = 60_000;
129
+
130
+ /**
131
+ * Initialize the always-on gsd-health widget (belowEditor).
132
+ * Call once from the extension entry point after context is available.
133
+ */
134
+ export function initHealthWidget(ctx: ExtensionContext): void {
135
+ if (!ctx.hasUI) return;
136
+
137
+ const basePath = projectRoot();
138
+
139
+ // String-array fallback — used in RPC mode (factory is a no-op there)
140
+ const initialData = loadHealthWidgetData(basePath);
141
+ ctx.ui.setWidget("gsd-health", buildHealthLines(initialData), { placement: "belowEditor" });
142
+
143
+ // Factory-based widget for TUI mode — replaces the string-array above
144
+ ctx.ui.setWidget("gsd-health", (_tui, _theme) => {
145
+ let data = initialData;
146
+ let cachedLines: string[] | undefined;
147
+
148
+ const refreshTimer = setInterval(() => {
149
+ try {
150
+ data = loadHealthWidgetData(basePath);
151
+ cachedLines = undefined;
152
+ _tui.requestRender();
153
+ } catch { /* non-fatal */ }
154
+ }, REFRESH_INTERVAL_MS);
155
+
156
+ return {
157
+ render(_width: number): string[] {
158
+ if (!cachedLines) cachedLines = buildHealthLines(data);
159
+ return cachedLines;
160
+ },
161
+ invalidate(): void { cachedLines = undefined; },
162
+ dispose(): void {
163
+ clearInterval(refreshTimer);
164
+ },
165
+ };
166
+ }, { placement: "belowEditor" });
167
+ }
@@ -607,6 +607,12 @@ export default function (pi: ExtensionAPI) {
607
607
  // Load tool API keys from auth.json into environment
608
608
  loadToolApiKeys();
609
609
 
610
+ // Always-on health widget — ambient system health signal below the editor
611
+ try {
612
+ const { initHealthWidget } = await import("./health-widget.js");
613
+ initHealthWidget(ctx);
614
+ } catch { /* non-fatal — widget is best-effort */ }
615
+
610
616
  // Notify remote questions status if configured
611
617
  try {
612
618
  const [{ getRemoteConfigStatus }, { getLatestPromptSummary }] = await Promise.all([