@pellux/goodvibes-agent 0.1.9 → 0.1.11

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 (97) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +1 -1
  3. package/docs/getting-started.md +1 -1
  4. package/docs/release-and-publishing.md +2 -2
  5. package/package.json +4 -1
  6. package/src/cli/agent-knowledge-command.ts +46 -20
  7. package/src/cli/help.ts +15 -2
  8. package/src/cli/management-commands.ts +3 -3
  9. package/src/cli/management.ts +7 -1
  10. package/src/cli/parser.ts +3 -0
  11. package/src/cli/service-posture.ts +6 -6
  12. package/src/cli/status.ts +9 -9
  13. package/src/cli/surface-command.ts +3 -3
  14. package/src/cli/types.ts +2 -0
  15. package/src/input/commands/cloudflare-runtime.ts +20 -5
  16. package/src/input/commands/confirmation.ts +24 -0
  17. package/src/input/commands/discovery-runtime.ts +16 -7
  18. package/src/input/commands/eval.ts +27 -14
  19. package/src/input/commands/experience-runtime.ts +66 -27
  20. package/src/input/commands/health-runtime.ts +1 -1
  21. package/src/input/commands/hooks-runtime.ts +79 -20
  22. package/src/input/commands/incident-runtime.ts +17 -6
  23. package/src/input/commands/integration-runtime.ts +93 -50
  24. package/src/input/commands/knowledge.ts +38 -12
  25. package/src/input/commands/local-auth-runtime.ts +36 -13
  26. package/src/input/commands/local-provider-runtime.ts +22 -11
  27. package/src/input/commands/local-runtime.ts +21 -11
  28. package/src/input/commands/local-setup.ts +35 -16
  29. package/src/input/commands/managed-runtime.ts +51 -20
  30. package/src/input/commands/marketplace-runtime.ts +31 -16
  31. package/src/input/commands/mcp-runtime.ts +65 -34
  32. package/src/input/commands/memory-product-runtime.ts +72 -35
  33. package/src/input/commands/memory.ts +9 -9
  34. package/src/input/commands/notify-runtime.ts +27 -8
  35. package/src/input/commands/operator-runtime.ts +85 -17
  36. package/src/input/commands/planning-runtime.ts +14 -2
  37. package/src/input/commands/platform-access-runtime.ts +88 -45
  38. package/src/input/commands/platform-services-runtime.ts +51 -25
  39. package/src/input/commands/product-runtime.ts +54 -27
  40. package/src/input/commands/profile-sync-runtime.ts +17 -6
  41. package/src/input/commands/recall-bundle.ts +38 -17
  42. package/src/input/commands/recall-query.ts +15 -4
  43. package/src/input/commands/recall-review.ts +9 -3
  44. package/src/input/commands/remote-runtime-setup.ts +45 -18
  45. package/src/input/commands/remote-runtime.ts +25 -9
  46. package/src/input/commands/replay-runtime.ts +9 -2
  47. package/src/input/commands/services-runtime.ts +21 -10
  48. package/src/input/commands/session-content.ts +53 -51
  49. package/src/input/commands/session-workflow.ts +10 -4
  50. package/src/input/commands/session.ts +1 -1
  51. package/src/input/commands/settings-sync-runtime.ts +40 -17
  52. package/src/input/commands/share-runtime.ts +12 -4
  53. package/src/input/commands/shell-core.ts +3 -3
  54. package/src/input/commands/subscription-runtime.ts +35 -20
  55. package/src/input/commands/teleport-runtime.ts +16 -5
  56. package/src/input/commands/work-plan-runtime.ts +23 -12
  57. package/src/input/handler-content-actions.ts +11 -62
  58. package/src/input/handler-interactions.ts +1 -1
  59. package/src/input/handler-onboarding-cloudflare.ts +48 -117
  60. package/src/input/handler.ts +1 -0
  61. package/src/input/keybindings.ts +1 -1
  62. package/src/input/mcp-workspace.ts +25 -49
  63. package/src/input/onboarding/onboarding-runtime-status.ts +8 -8
  64. package/src/input/onboarding/onboarding-wizard-apply.ts +13 -53
  65. package/src/input/onboarding/onboarding-wizard-cloudflare-step.ts +12 -12
  66. package/src/input/onboarding/onboarding-wizard-cloudflare.ts +2 -7
  67. package/src/input/onboarding/onboarding-wizard-constants.ts +7 -7
  68. package/src/input/onboarding/onboarding-wizard-external-surface-extra-specs.ts +4 -4
  69. package/src/input/onboarding/onboarding-wizard-steps.ts +13 -13
  70. package/src/input/profile-picker-modal.ts +13 -31
  71. package/src/input/session-picker-modal.ts +4 -30
  72. package/src/input/settings-modal-agent-policy.ts +18 -0
  73. package/src/input/settings-modal-subscriptions.ts +3 -3
  74. package/src/input/settings-modal-types.ts +17 -0
  75. package/src/input/settings-modal.ts +30 -29
  76. package/src/main.ts +3 -26
  77. package/src/panels/incident-review-panel.ts +1 -1
  78. package/src/panels/local-auth-panel.ts +4 -4
  79. package/src/panels/provider-account-snapshot.ts +1 -1
  80. package/src/panels/provider-health-domains.ts +2 -2
  81. package/src/panels/settings-sync-panel.ts +2 -2
  82. package/src/panels/subscription-panel.ts +7 -7
  83. package/src/renderer/block-actions.ts +1 -1
  84. package/src/renderer/help-overlay.ts +2 -2
  85. package/src/renderer/mcp-workspace.ts +12 -12
  86. package/src/renderer/process-modal.ts +17 -8
  87. package/src/renderer/profile-picker-modal.ts +3 -11
  88. package/src/renderer/session-picker-modal.ts +2 -10
  89. package/src/renderer/settings-modal.ts +12 -8
  90. package/src/renderer/ui-factory.ts +4 -32
  91. package/src/runtime/bootstrap-shell.ts +0 -13
  92. package/src/runtime/bootstrap.ts +0 -10
  93. package/src/runtime/onboarding/derivation.ts +6 -6
  94. package/src/verification/live-verifier.ts +148 -13
  95. package/src/version.ts +10 -3
  96. package/src/input/commands/quit-shared.ts +0 -162
  97. package/src/renderer/git-status.ts +0 -89
@@ -213,6 +213,54 @@ async function fetchCheck(
213
213
  }
214
214
  }
215
215
 
216
+ async function fetchJsonCheck(
217
+ id: string,
218
+ title: string,
219
+ url: string,
220
+ token: string | undefined,
221
+ options: {
222
+ readonly method?: 'GET' | 'POST';
223
+ readonly body?: unknown;
224
+ readonly validate: (status: number, body: string) => { status: LiveVerificationStatus; summary: string; detail?: string };
225
+ },
226
+ ): Promise<LiveVerificationCheck> {
227
+ if (!token) {
228
+ return {
229
+ id,
230
+ title,
231
+ status: 'skip',
232
+ summary: 'No daemon bearer token was available.',
233
+ };
234
+ }
235
+ try {
236
+ const response = await fetch(url, {
237
+ method: options.method ?? 'GET',
238
+ headers: {
239
+ Authorization: `Bearer ${token}`,
240
+ 'Content-Type': 'application/json',
241
+ },
242
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
243
+ signal: AbortSignal.timeout(5000),
244
+ });
245
+ const body = await response.text();
246
+ const validated = options.validate(response.status, body);
247
+ return {
248
+ id,
249
+ title,
250
+ ...validated,
251
+ detail: validated.detail ?? compact(body),
252
+ };
253
+ } catch (error) {
254
+ return {
255
+ id,
256
+ title,
257
+ status: 'fail',
258
+ summary: 'Request failed.',
259
+ detail: error instanceof Error ? error.message : String(error),
260
+ };
261
+ }
262
+ }
263
+
216
264
  function countStatuses(checks: readonly LiveVerificationCheck[]): Record<LiveVerificationStatus, number> {
217
265
  return checks.reduce<Record<LiveVerificationStatus, number>>(
218
266
  (counts, check) => {
@@ -256,7 +304,7 @@ export async function buildLiveVerificationReport(options: LiveVerificationOptio
256
304
 
257
305
  checks.push({
258
306
  id: 'compiled-cli-present',
259
- title: 'Compiled GoodVibes CLI binary',
307
+ title: 'Compiled GoodVibes Agent CLI binary',
260
308
  status: existsSync(binaryPath) ? 'pass' : 'fail',
261
309
  summary: existsSync(binaryPath) ? `Found ${binaryPath}.` : `Missing ${binaryPath}.`,
262
310
  });
@@ -264,47 +312,61 @@ export async function buildLiveVerificationReport(options: LiveVerificationOptio
264
312
  if (existsSync(binaryPath)) {
265
313
  checks.push(commandCheck(
266
314
  'cli-version',
267
- 'CLI version command',
268
- await runCommand(binaryPath, ['version'], projectRoot),
269
- 'CLI version returned successfully.',
315
+ 'Agent CLI version command',
316
+ await runCommand(binaryPath, ['--version'], projectRoot),
317
+ 'Agent CLI version returned successfully.',
270
318
  ));
271
319
  checks.push(commandCheck(
272
320
  'cli-status-json',
273
- 'CLI status JSON command',
274
- await runCommand(binaryPath, ['status', '--output', 'json'], projectRoot),
275
- 'CLI status returned parseable JSON.',
321
+ 'Agent CLI status JSON command',
322
+ await runCommand(binaryPath, ['status', '--json'], projectRoot),
323
+ 'Agent CLI status returned parseable JSON.',
324
+ { parseJson: true },
325
+ ));
326
+ checks.push(commandCheck(
327
+ 'cli-compat-json',
328
+ 'Agent CLI compatibility JSON command',
329
+ await runCommand(binaryPath, ['compat', '--json'], projectRoot),
330
+ 'Agent CLI compatibility returned parseable JSON.',
276
331
  { parseJson: true },
277
332
  ));
333
+ checks.push(commandCheck(
334
+ 'cli-agent-knowledge-status',
335
+ 'Agent Knowledge CLI status command',
336
+ await runCommand(binaryPath, ['knowledge', 'status', '--json'], projectRoot),
337
+ 'Agent Knowledge status returned parseable JSON.',
338
+ { parseJson: true, warnOnNonZero: true },
339
+ ));
278
340
  checks.push(commandCheck(
279
341
  'cli-providers',
280
- 'CLI providers command',
342
+ 'Agent CLI providers command',
281
343
  await runCommand(binaryPath, ['providers'], projectRoot),
282
344
  'Provider inventory rendered successfully.',
283
345
  ));
284
346
  checks.push(commandCheck(
285
347
  'cli-control-plane-status',
286
- 'CLI control-plane status command',
348
+ 'Read-only control-plane status command',
287
349
  await runCommand(binaryPath, ['control-plane', 'status'], projectRoot),
288
350
  'Control-plane status rendered successfully.',
289
351
  { warnOnNonZero: true },
290
352
  ));
291
353
  checks.push(commandCheck(
292
354
  'cli-listener-test',
293
- 'CLI listener readiness command',
355
+ 'Read-only listener readiness command',
294
356
  await runCommand(binaryPath, ['listener', 'test'], projectRoot),
295
357
  'HTTP listener readiness rendered successfully.',
296
358
  { warnOnNonZero: true },
297
359
  ));
298
360
  checks.push(commandCheck(
299
361
  'cli-surfaces-check',
300
- 'CLI surfaces readiness command',
362
+ 'Read-only surfaces readiness command',
301
363
  await runCommand(binaryPath, ['surfaces', 'check'], projectRoot),
302
364
  'Surface readiness rendered successfully.',
303
365
  { warnOnNonZero: true },
304
366
  ));
305
367
  checks.push(commandCheck(
306
368
  'cli-service-check',
307
- 'CLI service posture command',
369
+ 'Read-only service posture command',
308
370
  await runCommand(binaryPath, ['service', 'check'], projectRoot),
309
371
  'Service posture rendered successfully.',
310
372
  { warnOnNonZero: true },
@@ -376,6 +438,79 @@ export async function buildLiveVerificationReport(options: LiveVerificationOptio
376
438
  },
377
439
  ));
378
440
 
441
+ checks.push(await fetchJsonCheck(
442
+ 'agent-knowledge-status',
443
+ 'Agent Knowledge isolated /status',
444
+ `${daemonBaseUrl}/api/goodvibes-agent/knowledge/status`,
445
+ token,
446
+ {
447
+ validate: (status, body) => {
448
+ if (status !== 200) return { status: 'fail', summary: `/api/goodvibes-agent/knowledge/status returned ${status}.` };
449
+ try {
450
+ JSON.parse(body);
451
+ return { status: 'pass', summary: 'Agent Knowledge status route returned parseable JSON.' };
452
+ } catch {
453
+ return { status: 'fail', summary: 'Agent Knowledge status was not parseable JSON.' };
454
+ }
455
+ },
456
+ },
457
+ ));
458
+
459
+ checks.push(await fetchJsonCheck(
460
+ 'agent-knowledge-ask-isolated',
461
+ 'Agent Knowledge isolated ask',
462
+ `${daemonBaseUrl}/api/goodvibes-agent/knowledge/ask`,
463
+ token,
464
+ {
465
+ method: 'POST',
466
+ body: {
467
+ query: 'What is GoodVibes Agent?',
468
+ limit: 5,
469
+ mode: 'concise',
470
+ includeSources: true,
471
+ includeConfidence: true,
472
+ includeLinkedObjects: true,
473
+ },
474
+ validate: (status, body) => {
475
+ if (status !== 200) return { status: 'fail', summary: `/api/goodvibes-agent/knowledge/ask returned ${status}.` };
476
+ try {
477
+ JSON.parse(body);
478
+ } catch {
479
+ return { status: 'fail', summary: 'Agent Knowledge ask was not parseable JSON.' };
480
+ }
481
+ const lower = body.toLowerCase();
482
+ if (lower.includes('home assistant') || lower.includes('homegraph') || lower.includes('home graph')) {
483
+ return { status: 'fail', summary: 'Agent Knowledge ask returned HomeGraph/Home Assistant contamination.' };
484
+ }
485
+ return { status: 'pass', summary: 'Agent Knowledge ask stayed on the isolated Agent route.' };
486
+ },
487
+ },
488
+ ));
489
+
490
+ checks.push(await fetchJsonCheck(
491
+ 'agent-knowledge-search-isolated',
492
+ 'Agent Knowledge isolated search',
493
+ `${daemonBaseUrl}/api/goodvibes-agent/knowledge/search`,
494
+ token,
495
+ {
496
+ method: 'POST',
497
+ body: { query: 'What is GoodVibes Agent?', limit: 5 },
498
+ validate: (status, body) => {
499
+ if (status !== 200) return { status: 'fail', summary: `/api/goodvibes-agent/knowledge/search returned ${status}.` };
500
+ try {
501
+ JSON.parse(body);
502
+ } catch {
503
+ return { status: 'fail', summary: 'Agent Knowledge search was not parseable JSON.' };
504
+ }
505
+ const lower = body.toLowerCase();
506
+ if (lower.includes('home assistant') || lower.includes('homegraph') || lower.includes('home graph')) {
507
+ return { status: 'fail', summary: 'Agent Knowledge search returned HomeGraph/Home Assistant contamination.' };
508
+ }
509
+ return { status: 'pass', summary: 'Agent Knowledge search stayed on the isolated Agent route.' };
510
+ },
511
+ },
512
+ ));
513
+
379
514
  const counts = countStatuses(checks);
380
515
  const ok = counts.fail === 0 && (!options.strict || counts.warn === 0);
381
516
  return {
@@ -392,7 +527,7 @@ export async function buildLiveVerificationReport(options: LiveVerificationOptio
392
527
 
393
528
  export function renderLiveVerificationReportMarkdown(report: LiveVerificationReport): string {
394
529
  const lines: string[] = [
395
- '# GoodVibes Live Verification',
530
+ '# GoodVibes Agent Live Verification',
396
531
  '',
397
532
  `Generated: ${report.generatedAt}`,
398
533
  `Home: \`${report.homeDir}\``,
package/src/version.ts CHANGED
@@ -6,12 +6,19 @@ import { join } from 'node:path';
6
6
  // The prebuild script updates the fallback value before compilation.
7
7
  // Uses import.meta.dir (Bun) to locate package.json relative to this file,
8
8
  // which is correct regardless of the process working directory.
9
- let _version = '0.1.9';
9
+ let _version = '0.1.11';
10
+ let _sdkVersion = '0.33.35';
10
11
  try {
11
- const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
12
- _version = pkg.version ?? _version;
12
+ const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8')) as {
13
+ readonly version?: unknown;
14
+ readonly dependencies?: Record<string, unknown>;
15
+ };
16
+ _version = typeof pkg.version === 'string' ? pkg.version : _version;
17
+ const packageSdkVersion = pkg.dependencies?.['@pellux/goodvibes-sdk'];
18
+ _sdkVersion = typeof packageSdkVersion === 'string' ? packageSdkVersion : _sdkVersion;
13
19
  } catch {
14
20
  // Compiled binary or missing package.json — use fallback
15
21
  }
16
22
 
17
23
  export const VERSION = _version;
24
+ export const SDK_VERSION = _sdkVersion;
@@ -1,162 +0,0 @@
1
- import type { StatusResult } from 'simple-git';
2
- import { basename } from 'path';
3
- import type { CommandContext } from '../command-registry.ts';
4
- import { GitService } from '@pellux/goodvibes-sdk/platform/git';
5
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
6
-
7
- type GitLike = Pick<GitService, 'addAll' | 'status' | 'commit'>;
8
-
9
- export type GitChange = {
10
- action: 'add' | 'update' | 'delete' | 'rename';
11
- path: string;
12
- from?: string;
13
- to?: string;
14
- };
15
-
16
- type GitStatusLike = Pick<StatusResult, 'staged' | 'modified' | 'not_added' | 'deleted' | 'created' | 'renamed'> & {
17
- isClean?: () => boolean;
18
- };
19
-
20
- export function collectGitChanges(status: GitStatusLike): GitChange[] {
21
- const changes = new Map<string, GitChange>();
22
-
23
- for (const rename of status.renamed ?? []) {
24
- const to = rename.to || rename.from;
25
- if (!to) continue;
26
- changes.set(to, { action: 'rename', path: to, from: rename.from, to: rename.to });
27
- }
28
-
29
- for (const path of status.created ?? []) {
30
- if (!changes.has(path)) changes.set(path, { action: 'add', path });
31
- }
32
-
33
- for (const path of status.not_added ?? []) {
34
- if (!changes.has(path)) changes.set(path, { action: 'add', path });
35
- }
36
-
37
- for (const path of status.deleted ?? []) {
38
- if (!changes.has(path)) changes.set(path, { action: 'delete', path });
39
- }
40
-
41
- for (const path of status.modified ?? []) {
42
- if (!changes.has(path)) changes.set(path, { action: 'update', path });
43
- }
44
-
45
- for (const path of status.staged ?? []) {
46
- if (!changes.has(path)) changes.set(path, { action: 'update', path });
47
- }
48
-
49
- return Array.from(changes.values()).sort((a, b) => a.path.localeCompare(b.path));
50
- }
51
-
52
- function formatList(items: string[]): string {
53
- if (items.length === 0) return '';
54
- if (items.length === 1) return items[0]!;
55
- if (items.length === 2) return `${items[0]} and ${items[1]}`;
56
- return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`;
57
- }
58
-
59
- function topLevelScope(path: string): string {
60
- const normalized = path.replace(/\\/g, '/').replace(/^\.\/+/, '');
61
- if (!normalized) return 'root';
62
- const [first] = normalized.split('/');
63
- return first && first.length > 0 ? first : 'root';
64
- }
65
-
66
- function summarizeScopes(changes: GitChange[]): string | null {
67
- const scopes = Array.from(new Set(changes.map((change) => topLevelScope(change.path)).filter(Boolean)));
68
- if (scopes.length === 0) return null;
69
- if (scopes.length <= 3) {
70
- return scopes.length === 1 ? `${scopes[0]} files` : `${formatList(scopes)} files`;
71
- }
72
- return `${changes.length} files`;
73
- }
74
-
75
- function shortPath(path: string): string {
76
- return path.length > 42 ? basename(path) : path;
77
- }
78
-
79
- export function buildWriteQuitCommitMessage(changes: GitChange[]): string {
80
- if (changes.length === 0) return 'Update working tree';
81
-
82
- if (changes.length === 1) {
83
- const [change] = changes;
84
- if (!change) return 'Update working tree';
85
- if (change.action === 'rename') {
86
- return `Rename ${shortPath(change.from ?? change.path)} to ${shortPath(change.to ?? change.path)}`;
87
- }
88
- const verb = change.action === 'add'
89
- ? 'Add'
90
- : change.action === 'delete'
91
- ? 'Delete'
92
- : 'Update';
93
- return `${verb} ${shortPath(change.path)}`;
94
- }
95
-
96
- const uniqueActions = Array.from(new Set(changes.map((change) => change.action)));
97
- if (uniqueActions.length === 1) {
98
- const [action] = uniqueActions;
99
- const verb = action === 'add'
100
- ? 'Add'
101
- : action === 'delete'
102
- ? 'Delete'
103
- : action === 'rename'
104
- ? 'Rename'
105
- : 'Update';
106
- const scopeLabel = summarizeScopes(changes);
107
- if (scopeLabel) return `${verb} ${scopeLabel}`;
108
- return `${verb} ${changes.length} files`;
109
- }
110
-
111
- const scopeLabel = summarizeScopes(changes);
112
- if (scopeLabel) return `Update ${scopeLabel}`;
113
- return `Update ${changes.length} files`;
114
- }
115
-
116
- export type ExecuteWriteQuitOptions = {
117
- cwd?: string;
118
- isGitRepo?: (cwd: string) => boolean;
119
- getRepoRoot?: (cwd: string) => string | null;
120
- gitFactory?: (cwd: string) => GitLike;
121
- };
122
-
123
- export async function executeWriteQuit(
124
- ctx: Pick<CommandContext, 'print' | 'exit'> & {
125
- workspace?: CommandContext['workspace'];
126
- },
127
- options: ExecuteWriteQuitOptions = {},
128
- ): Promise<void> {
129
- const cwd = options.cwd ?? ctx.workspace?.shellPaths?.workingDirectory;
130
- if (!cwd) {
131
- throw new Error('commandContext.workspace.shellPaths is required when executeWriteQuit() is called without an explicit cwd');
132
- }
133
- const isGitRepo = options.isGitRepo ?? ((dir: string) => GitService.isGitRepo(dir));
134
- if (!isGitRepo(cwd)) {
135
- ctx.exit();
136
- return;
137
- }
138
-
139
- const repoRoot = options.getRepoRoot?.(cwd) ?? GitService.getRepoRoot(cwd) ?? cwd;
140
- const git = options.gitFactory?.(repoRoot) ?? new GitService(repoRoot);
141
-
142
- try {
143
- ctx.print(`[wq] Staging changes in ${repoRoot}...`);
144
- await git.addAll();
145
- const status = await git.status();
146
- if (status.isClean()) {
147
- ctx.print('[wq] Working tree clean. Exiting without creating a commit.');
148
- ctx.exit();
149
- return;
150
- }
151
-
152
- const changes = collectGitChanges(status);
153
- const message = buildWriteQuitCommitMessage(changes);
154
- ctx.print(`[wq] Committing ${changes.length} change${changes.length === 1 ? '' : 's'}: ${message}`);
155
- const result = await git.commit(message);
156
- const shortHash = result.hash ? result.hash.slice(0, 7) : 'unknown';
157
- ctx.print(`[wq] Commit complete: ${shortHash} ${message}`);
158
- ctx.exit();
159
- } catch (error) {
160
- ctx.print(`[wq] Commit failed: ${summarizeError(error)}`);
161
- }
162
- }
@@ -1,89 +0,0 @@
1
- import { GitService } from '@pellux/goodvibes-sdk/platform/git';
2
- import { logger } from '@pellux/goodvibes-sdk/platform/utils';
3
- import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
4
-
5
- /** Git state shown in the header bar. */
6
- export interface GitHeaderInfo {
7
- branch: string;
8
- dirty: boolean;
9
- ahead: number;
10
- behind: number;
11
- }
12
-
13
- const FALLBACK: GitHeaderInfo = { branch: '?', dirty: false, ahead: 0, behind: 0 };
14
-
15
- /**
16
- * GitStatusProvider — Fetches git state for the header bar.
17
- *
18
- * Results are cached for 2 seconds (TTL). The next call after expiry triggers
19
- * a fresh fetch and returns the cached value immediately (stale-while-revalidate).
20
- * Never throws — returns FALLBACK on any error.
21
- */
22
- export class GitStatusProvider {
23
- private cache: GitHeaderInfo = { ...FALLBACK };
24
- private lastFetch = 0;
25
- private readonly ttlMs = 2000;
26
- private fetching = false;
27
-
28
- constructor(private readonly workingDirectory: string) {}
29
-
30
- /** Returns cached info immediately; refreshes in background if TTL expired. */
31
- async getStatus(): Promise<GitHeaderInfo> {
32
- const now = Date.now();
33
- if (now - this.lastFetch < this.ttlMs) {
34
- return this.cache;
35
- }
36
- // Fetch synchronously on first call (no cache yet), otherwise return stale
37
- if (this.lastFetch === 0) {
38
- await this._fetch().catch(() => {
39
- // Ensure fallback is set if _fetch failed before setting lastFetch
40
- if (this.lastFetch === 0) {
41
- this.lastFetch = Date.now();
42
- }
43
- });
44
- } else if (!this.fetching) {
45
- this._fetch().catch(err => { logger.debug('GitStatusProvider: background refresh failed', { error: summarizeError(err) }); });
46
- }
47
- return this.cache;
48
- }
49
-
50
- /** Force a fresh fetch and update the cache. Returns updated info. */
51
- async refresh(): Promise<GitHeaderInfo> {
52
- await this._fetch();
53
- return this.cache;
54
- }
55
-
56
- private async _fetch(): Promise<void> {
57
- if (this.fetching) return;
58
- this.fetching = true;
59
- try {
60
- const git = new GitService(this.workingDirectory);
61
- const [statusResult, branchResult] = await Promise.all([
62
- git.status(),
63
- git.branch(),
64
- ]);
65
- const dirty =
66
- statusResult.modified.length > 0 ||
67
- statusResult.created.length > 0 ||
68
- statusResult.deleted.length > 0 ||
69
- statusResult.renamed.length > 0 ||
70
- statusResult.conflicted.length > 0 ||
71
- statusResult.not_added.length > 0;
72
- this.cache = {
73
- branch: branchResult.current || '?',
74
- dirty,
75
- ahead: statusResult.ahead ?? 0,
76
- behind: statusResult.behind ?? 0,
77
- };
78
- this.lastFetch = Date.now();
79
- } catch {
80
- // Never throw — return fallback
81
- if (this.lastFetch === 0) {
82
- this.cache = { ...FALLBACK };
83
- this.lastFetch = Date.now();
84
- }
85
- } finally {
86
- this.fetching = false;
87
- }
88
- }
89
- }