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.
- package/dist/resources/extensions/gsd/auto-start.ts +4 -2
- package/dist/resources/extensions/gsd/commands.ts +19 -0
- package/dist/resources/extensions/gsd/dashboard-overlay.ts +28 -0
- package/dist/resources/extensions/gsd/doctor-environment.ts +497 -0
- package/dist/resources/extensions/gsd/doctor-providers.ts +343 -0
- package/dist/resources/extensions/gsd/doctor-types.ts +14 -1
- package/dist/resources/extensions/gsd/doctor.ts +6 -0
- package/dist/resources/extensions/gsd/health-widget.ts +167 -0
- package/dist/resources/extensions/gsd/index.ts +6 -0
- package/dist/resources/extensions/gsd/progress-score.ts +273 -0
- package/dist/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
- package/dist/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
- package/dist/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
- package/dist/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
- package/dist/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
- package/dist/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
- package/dist/resources/extensions/gsd/visualizer-data.ts +60 -2
- package/dist/resources/extensions/gsd/visualizer-views.ts +54 -0
- package/package.json +1 -1
- package/packages/pi-coding-agent/package.json +1 -1
- package/pkg/package.json +1 -1
- package/src/resources/extensions/gsd/auto-start.ts +4 -2
- package/src/resources/extensions/gsd/commands.ts +19 -0
- package/src/resources/extensions/gsd/dashboard-overlay.ts +28 -0
- package/src/resources/extensions/gsd/doctor-environment.ts +497 -0
- package/src/resources/extensions/gsd/doctor-providers.ts +343 -0
- package/src/resources/extensions/gsd/doctor-types.ts +14 -1
- package/src/resources/extensions/gsd/doctor.ts +6 -0
- package/src/resources/extensions/gsd/health-widget.ts +167 -0
- package/src/resources/extensions/gsd/index.ts +6 -0
- package/src/resources/extensions/gsd/progress-score.ts +273 -0
- package/src/resources/extensions/gsd/tests/doctor-environment.test.ts +314 -0
- package/src/resources/extensions/gsd/tests/doctor-providers.test.ts +298 -0
- package/src/resources/extensions/gsd/tests/export-html-enhancements.test.ts +3 -0
- package/src/resources/extensions/gsd/tests/memory-leak-guards.test.ts +7 -3
- package/src/resources/extensions/gsd/tests/progress-score.test.ts +206 -0
- package/src/resources/extensions/gsd/tests/visualizer-views.test.ts +12 -0
- package/src/resources/extensions/gsd/visualizer-data.ts +60 -2
- 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([
|