@pellux/goodvibes-agent 0.1.69 → 0.1.71

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 (60) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/package.json +42 -1
  3. package/src/agent/skill-discovery.ts +119 -0
  4. package/src/input/commands/delegation-runtime.ts +0 -8
  5. package/src/input/commands/experience-runtime.ts +0 -177
  6. package/src/input/commands/guidance-runtime.ts +9 -77
  7. package/src/input/commands/local-runtime.ts +1 -57
  8. package/src/input/commands/local-setup-review.ts +1 -1
  9. package/src/input/commands/operator-runtime.ts +1 -145
  10. package/src/input/commands/platform-access-runtime.ts +2 -195
  11. package/src/input/commands/product-runtime.ts +0 -116
  12. package/src/input/commands/security-runtime.ts +88 -0
  13. package/src/input/commands/session-content.ts +0 -97
  14. package/src/input/commands/shell-core.ts +1 -22
  15. package/src/input/commands.ts +2 -43
  16. package/src/panels/builtin/operations.ts +3 -184
  17. package/src/panels/index.ts +0 -11
  18. package/src/version.ts +1 -1
  19. package/src/input/commands/branch-runtime.ts +0 -72
  20. package/src/input/commands/control-room-runtime.ts +0 -234
  21. package/src/input/commands/discovery-runtime.ts +0 -61
  22. package/src/input/commands/hooks-runtime.ts +0 -207
  23. package/src/input/commands/incident-runtime.ts +0 -106
  24. package/src/input/commands/integration-runtime.ts +0 -437
  25. package/src/input/commands/local-setup.ts +0 -288
  26. package/src/input/commands/managed-runtime.ts +0 -240
  27. package/src/input/commands/marketplace-runtime.ts +0 -305
  28. package/src/input/commands/memory-product-runtime.ts +0 -148
  29. package/src/input/commands/operator-panel-runtime.ts +0 -146
  30. package/src/input/commands/platform-services-runtime.ts +0 -271
  31. package/src/input/commands/profile-sync-runtime.ts +0 -110
  32. package/src/input/commands/provider.ts +0 -363
  33. package/src/input/commands/remote-runtime-pool.ts +0 -89
  34. package/src/input/commands/remote-runtime-setup.ts +0 -226
  35. package/src/input/commands/remote-runtime.ts +0 -432
  36. package/src/input/commands/replay-runtime.ts +0 -25
  37. package/src/input/commands/services-runtime.ts +0 -220
  38. package/src/input/commands/settings-sync-runtime.ts +0 -197
  39. package/src/input/commands/share-runtime.ts +0 -127
  40. package/src/input/commands/skills-runtime.ts +0 -226
  41. package/src/input/commands/teleport-runtime.ts +0 -68
  42. package/src/panels/cockpit-panel.ts +0 -183
  43. package/src/panels/communication-panel.ts +0 -153
  44. package/src/panels/control-plane-panel.ts +0 -211
  45. package/src/panels/forensics-panel.ts +0 -364
  46. package/src/panels/hooks-panel.ts +0 -239
  47. package/src/panels/incident-review-panel.ts +0 -197
  48. package/src/panels/marketplace-panel.ts +0 -212
  49. package/src/panels/ops-control-panel.ts +0 -150
  50. package/src/panels/ops-strategy-panel.ts +0 -235
  51. package/src/panels/orchestration-panel.ts +0 -272
  52. package/src/panels/plugins-panel.ts +0 -178
  53. package/src/panels/remote-panel.ts +0 -449
  54. package/src/panels/routes-panel.ts +0 -178
  55. package/src/panels/services-panel.ts +0 -231
  56. package/src/panels/settings-sync-panel.ts +0 -120
  57. package/src/panels/skills-panel.ts +0 -431
  58. package/src/panels/watchers-panel.ts +0 -193
  59. package/src/verification/live-verifier.ts +0 -588
  60. package/src/verification/verification-ledger.ts +0 -239
@@ -1,431 +0,0 @@
1
- import { promises as fsPromises } from 'node:fs';
2
- import { join } from 'node:path';
3
- import type { Line } from '../types/grid.ts';
4
- import { createEmptyLine } from '../types/grid.ts';
5
- import { type ConfirmState, handleConfirmInput, renderConfirmLines } from './confirm-state.ts';
6
- import { getDisplayWidth, truncateDisplay } from '../utils/terminal-width.ts';
7
- import { SearchableListPanel } from './scrollable-list-panel.ts';
8
- import type { ComponentHealthMonitor } from '../runtime/perf/panel-health-monitor.ts';
9
- import type { ShellPathService } from '@/runtime/index.ts';
10
- import {
11
- buildPanelLine,
12
- buildPanelWorkspace,
13
- DEFAULT_PANEL_PALETTE,
14
- } from './polish.ts';
15
- import {
16
- getPanelSearchFocusTransition,
17
- isPanelSearchCancel,
18
- } from './search-focus.ts';
19
- import { GOODVIBES_AGENT_SURFACE_ROOT } from '../config/surface.ts';
20
-
21
- const C = {
22
- ...DEFAULT_PANEL_PALETTE,
23
- header: '#94a3b8',
24
- headerBg: '#1e293b',
25
- searchFg: '#f97316',
26
- searchBg: '#1e293b',
27
- label: '#64748b',
28
- value: '#e2e8f0',
29
- dim: '#64748b',
30
- empty: '#334155',
31
- selectedFg: '#e2e8f0',
32
- selectedBg: '#1e3a5f',
33
- project: '#38bdf8',
34
- global: '#a78bfa',
35
- hint: '#475569',
36
- path: '#94a3b8',
37
- selectBg: '#1e3a5f',
38
- } as const;
39
-
40
- export type SkillOrigin = 'project-local' | 'global' | 'custom';
41
-
42
- export interface SkillRecord {
43
- name: string;
44
- description: string;
45
- path: string;
46
- origin: SkillOrigin;
47
- dependencies: string[];
48
- includes: string[];
49
- frontmatter: Record<string, string>;
50
- }
51
-
52
- export interface SkillsPanelOptions {
53
- shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>;
54
- componentHealthMonitor?: ComponentHealthMonitor;
55
- }
56
-
57
- function parseFrontmatter(content: string): Record<string, string> {
58
- const match = content.match(/^---\n([\s\S]*?)\n---/);
59
- if (!match) return {};
60
- const result: Record<string, string> = {};
61
- for (const line of match[1].split('\n')) {
62
- const [key, ...rest] = line.split(':');
63
- if (key && rest.length > 0) {
64
- result[key.trim()] = rest.join(':').trim();
65
- }
66
- }
67
- return result;
68
- }
69
-
70
- function getSkillDirectories(cwd: string, homeDir: string): Array<{ root: string; origin: SkillOrigin }> {
71
- return [
72
- { root: join(cwd, '.goodvibes', 'skills'), origin: 'project-local' },
73
- { root: join(cwd, '.goodvibes', GOODVIBES_AGENT_SURFACE_ROOT, 'skills'), origin: 'project-local' },
74
- { root: join(homeDir, '.goodvibes', 'skills'), origin: 'global' },
75
- { root: join(homeDir, '.goodvibes', GOODVIBES_AGENT_SURFACE_ROOT, 'skills'), origin: 'global' },
76
- ];
77
- }
78
-
79
- async function readSkillFile(path: string, origin: SkillOrigin): Promise<SkillRecord | null> {
80
- let content = '';
81
- try {
82
- content = await fsPromises.readFile(path, 'utf-8');
83
- } catch {
84
- return null;
85
- }
86
-
87
- const frontmatter = parseFrontmatter(content);
88
- const body = content.replace(/^---\n[\s\S]*?\n---\n?/, '');
89
- const name = frontmatter.name ?? path.split(/[\\/]/).pop()?.replace(/\.md$/, '') ?? 'skill';
90
- const description = frontmatter.description ?? frontmatter.summary ?? '';
91
- const dependencies = frontmatter.depends_on
92
- ? frontmatter.depends_on.split(',').map((item) => item.trim()).filter(Boolean)
93
- : [];
94
- const includes: string[] = [];
95
- const includeRegex = /^@([\w/-]+)/gm;
96
- let match: RegExpExecArray | null;
97
- while ((match = includeRegex.exec(body)) !== null) {
98
- includes.push(match[1]);
99
- }
100
-
101
- return {
102
- name,
103
- description,
104
- path,
105
- origin,
106
- dependencies,
107
- includes,
108
- frontmatter,
109
- };
110
- }
111
-
112
- async function scanSkillDirectory(root: string, origin: SkillOrigin): Promise<SkillRecord[]> {
113
- let entries: string[] = [];
114
- try {
115
- entries = await fsPromises.readdir(root);
116
- } catch {
117
- return [];
118
- }
119
-
120
- const records: SkillRecord[] = [];
121
- for (const entry of entries.sort((a, b) => a.localeCompare(b))) {
122
- if (entry.endsWith('.md')) {
123
- const record = await readSkillFile(join(root, entry), origin);
124
- if (record) records.push(record);
125
- continue;
126
- }
127
-
128
- const markerPath = join(root, entry, 'SKILL.md');
129
- const record = await readSkillFile(markerPath, origin);
130
- if (record) records.push(record);
131
- }
132
-
133
- return records;
134
- }
135
-
136
- export async function discoverSkills(shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>): Promise<SkillRecord[]> {
137
- const cwd = shellPaths.workingDirectory;
138
- const homeDir = shellPaths.homeDirectory;
139
- const seen = new Set<string>();
140
- const records: SkillRecord[] = [];
141
-
142
- for (const { root, origin } of getSkillDirectories(cwd, homeDir)) {
143
- for (const record of await scanSkillDirectory(root, origin)) {
144
- if (seen.has(record.name.toLowerCase())) continue;
145
- seen.add(record.name.toLowerCase());
146
- records.push(record);
147
- }
148
- }
149
-
150
- return records.sort((a, b) => {
151
- const originRank = a.origin === b.origin
152
- ? 0
153
- : a.origin === 'project-local'
154
- ? -1
155
- : 1;
156
- return originRank || a.name.localeCompare(b.name);
157
- });
158
- }
159
-
160
- function wordWrap(text: string, maxWidth: number): string[] {
161
- if (maxWidth <= 0) return [''];
162
- const words = text.split(/\s+/).filter(Boolean);
163
- if (words.length === 0) return [''];
164
- const lines: string[] = [];
165
- let line = '';
166
- for (const word of words) {
167
- if (!line) {
168
- line = word;
169
- continue;
170
- }
171
- if (line.length + 1 + word.length <= maxWidth) {
172
- line += ` ${word}`;
173
- } else {
174
- lines.push(line);
175
- line = word;
176
- }
177
- }
178
- if (line) lines.push(line);
179
- return lines.length > 0 ? lines : [''];
180
- }
181
-
182
- function truncatePathDisplay(path: string, width: number): string {
183
- if (width <= 0) return '';
184
- if (getDisplayWidth(path) <= width) return path;
185
-
186
- const ellipsis = '…';
187
- const ellipsisWidth = getDisplayWidth(ellipsis);
188
- if (ellipsisWidth >= width) return truncateDisplay(path, width);
189
-
190
- const available = width - ellipsisWidth;
191
- const prefixBudget = Math.max(1, Math.floor(available * 0.35));
192
- const suffixBudget = Math.max(1, available - prefixBudget);
193
- const prefix = truncateDisplay(path, prefixBudget, '');
194
-
195
- let suffix = '';
196
- let suffixWidth = 0;
197
- for (let index = path.length - 1; index >= 0; index -= 1) {
198
- const char = path[index]!;
199
- const charWidth = getDisplayWidth(char);
200
- if (suffixWidth + charWidth > suffixBudget) break;
201
- suffix = char + suffix;
202
- suffixWidth += charWidth;
203
- }
204
-
205
- return `${prefix}${ellipsis}${suffix}`;
206
- }
207
-
208
- function originLabel(origin: SkillOrigin): string {
209
- switch (origin) {
210
- case 'project-local':
211
- return 'project';
212
- case 'global':
213
- return 'global';
214
- case 'custom':
215
- return 'custom';
216
- }
217
- }
218
-
219
- function originColor(origin: SkillOrigin): string {
220
- switch (origin) {
221
- case 'project-local':
222
- return C.project;
223
- case 'global':
224
- return C.global;
225
- case 'custom':
226
- return C.dim;
227
- }
228
- }
229
-
230
- export class SkillsPanel extends SearchableListPanel<SkillRecord> {
231
- private readonly shellPaths: Pick<ShellPathService, 'workingDirectory' | 'homeDirectory'>;
232
- /** Whether the filter input row is focused for typing (vs. list navigation). */
233
- private filterFocused = false;
234
- private cached: SkillRecord[] | null = null;
235
- private cacheDirty = true;
236
- // I1: confirm state for destructive delete
237
- private confirm: ConfirmState | null = null;
238
- private readyPromise: Promise<void> | null = null;
239
-
240
- public constructor(options: SkillsPanelOptions) {
241
- super('skills', 'Skills', 'K', 'monitoring', options.componentHealthMonitor);
242
- this.showSelectionGutter = true; // I5: non-color selection affordance
243
- this.shellPaths = options.shellPaths;
244
- }
245
-
246
- // -------------------------------------------------------------------------
247
- // SearchableListPanel implementation
248
- // -------------------------------------------------------------------------
249
-
250
- protected getAllItems(): readonly SkillRecord[] {
251
- return this.cached ?? [];
252
- }
253
-
254
- private _loadSkillsAsync(): Promise<void> {
255
- const p = (async () => {
256
- try {
257
- await this.withLoading('Scanning skills\u2026', async () => {
258
- this.cached = await discoverSkills(this.shellPaths);
259
- this.cacheDirty = false;
260
- this.invalidateFilter();
261
- });
262
- } catch (err) {
263
- this.setError(err instanceof Error ? err.message : String(err));
264
- }
265
- this.markDirty();
266
- })();
267
- this.readyPromise = p;
268
- return p;
269
- }
270
-
271
- /** Resolves when the current load cycle has settled. */
272
- public awaitReady(): Promise<void> {
273
- return this.readyPromise ?? Promise.resolve();
274
- }
275
-
276
- protected matchesSearch(skill: SkillRecord, query: string): boolean {
277
- const q = query.trim().toLowerCase();
278
- if (!q) return true;
279
- const haystack = [
280
- skill.name,
281
- skill.description,
282
- skill.path,
283
- skill.origin,
284
- skill.dependencies.join(' '),
285
- skill.includes.join(' '),
286
- ].join(' ').toLowerCase();
287
- return haystack.includes(q);
288
- }
289
-
290
- protected renderItem(skill: SkillRecord, index: number, selected: boolean, width: number): Line {
291
- const bg = selected ? C.selectBg : undefined;
292
- const dot = skill.origin === 'project-local' ? '\u25c6' : '\u2022';
293
- const desc = skill.description || 'No description provided.';
294
- const descWidth = Math.max(1, width - 4 - skill.name.length - 6);
295
- const descLines = wordWrap(desc, descWidth);
296
- return buildPanelLine(width, [
297
- [selected ? '\u25b8' : ' ', C.selectedFg, bg],
298
- [' ', C.dim, bg],
299
- [dot, originColor(skill.origin), bg],
300
- [' ', C.dim, bg],
301
- [skill.name, selected ? C.selectedFg : C.value, bg],
302
- [' ', C.dim, bg],
303
- [descLines[0] ?? '', selected ? C.selectedFg : C.dim, bg],
304
- ]);
305
- }
306
-
307
- protected override getPalette() { return C; }
308
- protected override getEmptyStateMessage() { return ' No skills discovered.'; }
309
- protected override getEmptyStateActions() {
310
- return [
311
- { command: '.goodvibes/skills', summary: 'place skill .md files here (project-local) or ~/.goodvibes/skills (global)' },
312
- { command: '/registry search skills', summary: 'inspect the same skill directories from the shell' },
313
- ];
314
- }
315
-
316
- public override onActivate(): void {
317
- super.onActivate();
318
- this.searchQuery = '';
319
- this.invalidateFilter();
320
- this.filterFocused = false;
321
- this.cacheDirty = true;
322
- void this._loadSkillsAsync();
323
- }
324
-
325
- public override onDestroy(): void {}
326
-
327
- public handleInput(key: string): boolean {
328
- // I1: y/n confirmation dialog for delete
329
- const confirmResult = handleConfirmInput(this.confirm, key);
330
- if (confirmResult === 'confirmed') {
331
- const toDelete = this.confirm!.subject;
332
- this.confirm = null;
333
- // Skills are read from the filesystem — deletion requires a shell command.
334
- // Surface an error directing the user to remove the file manually.
335
- this.setError(`Delete via shell: rm "${toDelete}"`);
336
- this.markDirty();
337
- return true;
338
- }
339
- if (confirmResult === 'cancelled') {
340
- this.confirm = null;
341
- this.markDirty();
342
- return true;
343
- }
344
- if (confirmResult === 'absorbed') return true;
345
-
346
- const items = this.getItems();
347
-
348
- // Filter-focus mode: typing goes into the search query
349
- if (this.filterFocused) {
350
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
351
- if (transition === 'focus-list') {
352
- this.filterFocused = false;
353
- this.markDirty();
354
- return true;
355
- }
356
- // Escape: also blur filter focus (clear + return to list navigation)
357
- if (isPanelSearchCancel(key)) {
358
- this.filterFocused = false;
359
- // Delegate to super to clear the query. If the query is empty, super
360
- // returns false and escape propagates to the panel dismissal handler —
361
- // this is the intentional double-escape UX (blur filter, then close).
362
- return super.handleInput(key);
363
- }
364
- // Delegate backspace/printable to SearchableListPanel.handleInput
365
- return super.handleInput(key);
366
- }
367
-
368
- const transition = getPanelSearchFocusTransition(key, { selectedIndex: this.selectedIndex, itemCount: items.length });
369
- if (transition === 'focus-search') {
370
- this.filterFocused = true;
371
- this.markDirty();
372
- return true;
373
- }
374
-
375
- // I1: 'd' prompts delete confirmation
376
- if (key === 'd') {
377
- const skill = items[this.selectedIndex];
378
- if (skill) {
379
- this.confirm = { subject: skill.path, label: skill.name };
380
- this.markDirty();
381
- }
382
- return true;
383
- }
384
-
385
- // Navigation + search: delegate to SearchableListPanel (up/down/g/G/page/enter + backspace/escape)
386
- return super.handleInput(key);
387
- }
388
-
389
- public render(width: number, height: number): Line[] {
390
- return this.trackedRender(() => {
391
- this.needsRender = false;
392
-
393
- // I1: show confirm dialog in place of normal content
394
- if (this.confirm) {
395
- const lines = buildPanelWorkspace(width, height, {
396
- title: 'Skills - confirm action',
397
- intro: '',
398
- sections: [{ title: 'Confirmation', lines: renderConfirmLines(width, this.confirm) }],
399
- palette: C,
400
- });
401
- while (lines.length < height) lines.push(createEmptyLine(width));
402
- return lines.slice(0, height);
403
- }
404
-
405
- // Build filter input line (provided by SearchableListPanel base)
406
- const filterLine = this.buildFilterInputLine(width, 'Filter', this.filterFocused);
407
-
408
- // Build detail footer for the currently selected skill
409
- const items = this.getItems();
410
- const selected = items[this.selectedIndex];
411
- const detailLines: Line[] = [];
412
- if (selected) {
413
- detailLines.push(
414
- buildPanelLine(width, [[' Selected: ', C.label], [selected.name, C.value], [' [', C.dim], [originLabel(selected.origin), originColor(selected.origin)], [']', C.dim]]),
415
- buildPanelLine(width, [[' Path: ', C.label], [truncatePathDisplay(selected.path, Math.max(1, width - 8)), C.path]]),
416
- buildPanelLine(width, [[' Desc: ', C.label], [selected.description || 'No description provided.', C.value]]),
417
- buildPanelLine(width, [[' Depends: ', C.label], [selected.dependencies.length > 0 ? selected.dependencies.join(', ') : 'none', C.dim]]),
418
- buildPanelLine(width, [[' Includes: ', C.label], [selected.includes.length > 0 ? selected.includes.join(', ') : 'none', C.dim]]),
419
- );
420
- }
421
- detailLines.push(buildPanelLine(width, [[' Up/Down navigate / or Up-at-top focus filter Esc blur Backspace clear', C.hint]]));
422
-
423
- const lines = this.renderList(width, height, {
424
- title: 'Skills - discover project-local and global skill packs',
425
- header: [filterLine],
426
- footer: detailLines,
427
- });
428
- return lines;
429
- });
430
- }
431
- }
@@ -1,193 +0,0 @@
1
- import type { Line } from '../types/grid.ts';
2
- import { createEmptyLine } from '../types/grid.ts';
3
- import { ScrollableListPanel } from './scrollable-list-panel.ts';
4
- import type { UiReadModel, UiWatchersSnapshot } from '../runtime/ui-read-models.ts';
5
- import { truncateDisplay } from '../utils/terminal-width.ts';
6
- import {
7
- buildEmptyState,
8
- buildGuidanceLine,
9
- buildKeyValueLine,
10
- buildPanelLine,
11
- buildPanelWorkspace,
12
- DEFAULT_PANEL_PALETTE,
13
- type PanelPalette,
14
- } from './polish.ts';
15
-
16
- const C = {
17
- ...DEFAULT_PANEL_PALETTE,
18
- header: '#94a3b8',
19
- headerBg: '#1e293b',
20
- ok: '#22c55e',
21
- warn: '#eab308',
22
- error: '#ef4444',
23
- info: '#38bdf8',
24
- selectBg: '#0f172a',
25
- } as const;
26
-
27
- function stateColor(state: string): string {
28
- if (state === 'running') return C.ok;
29
- if (state === 'degraded') return C.warn;
30
- if (state === 'failed') return C.error;
31
- return C.dim;
32
- }
33
-
34
- function sourceStatusColor(state?: string): string {
35
- if (state === 'healthy') return C.ok;
36
- if (state === 'lagging' || state === 'stale' || state === 'degraded') return C.warn;
37
- if (state === 'failed') return C.error;
38
- return C.dim;
39
- }
40
-
41
- function formatLag(value?: number): string {
42
- if (!value || value <= 0) return 'n/a';
43
- if (value < 1000) return `${value}ms`;
44
- if (value < 60_000) return `${Math.round(value / 1000)}s`;
45
- return `${Math.round(value / 60_000)}m`;
46
- }
47
-
48
- function formatTime(value?: number): string {
49
- if (!value) return 'n/a';
50
- return new Date(value).toLocaleString();
51
- }
52
-
53
- type WatcherEntry = UiWatchersSnapshot['watchers'][number];
54
-
55
- export class WatchersPanel extends ScrollableListPanel<WatcherEntry> {
56
- private readonly readModel?: UiReadModel<UiWatchersSnapshot>;
57
- private readonly unsub: (() => void) | null;
58
-
59
- public constructor(readModel?: UiReadModel<UiWatchersSnapshot>) {
60
- super('watchers', 'Watchers', 'W', 'monitoring');
61
- this.showSelectionGutter = true; // I5: non-color selection affordance
62
- this.readModel = readModel;
63
- this.unsub = readModel ? readModel.subscribe(() => this.markDirty()) : null;
64
- }
65
-
66
- public override onDestroy(): void {
67
- this.unsub?.();
68
- }
69
-
70
- protected override getPalette(): PanelPalette {
71
- return C;
72
- }
73
-
74
- protected getItems(): readonly WatcherEntry[] {
75
- if (!this.readModel) return [];
76
- return this.readModel.getSnapshot().watchers;
77
- }
78
-
79
- protected renderItem(watcher: WatcherEntry, _index: number, selected: boolean, width: number): Line {
80
- const bg = selected ? C.selectBg : undefined;
81
- return buildPanelLine(width, [
82
- [' ', C.label, bg],
83
- [watcher.state.padEnd(10), stateColor(watcher.state), bg],
84
- [` ${truncateDisplay(watcher.label, 18).padEnd(18)}`, C.value, bg],
85
- [` ${String(watcher.sourceStatus ?? 'unknown').padEnd(10)}`, sourceStatusColor(watcher.sourceStatus), bg],
86
- [` ${truncateDisplay(formatLag(watcher.sourceLagMs), Math.max(0, width - 43))}`, C.dim, bg],
87
- ]);
88
- }
89
-
90
- protected override getEmptyStateMessage(): string {
91
- return ' No watchers registered.';
92
- }
93
-
94
- protected override getEmptyStateActions(): Array<{ command: string; summary: string }> {
95
- return [
96
- { command: '/schedule list', summary: 'review automation that will consume watcher events' },
97
- { command: '/services auth-review', summary: 'validate integration credentials before enabling remote watchers' },
98
- ];
99
- }
100
-
101
- public render(width: number, height: number): Line[] {
102
- const intro = 'Managed watchers and source health used to trigger automation, refresh routes, and surface degraded upstream conditions.';
103
-
104
- if (!this.readModel) {
105
- const workspace = buildPanelWorkspace(width, height, {
106
- title: 'Watchers',
107
- intro,
108
- sections: [{
109
- lines: buildEmptyState(
110
- width,
111
- ' Runtime store not wired.',
112
- 'This panel needs the shared runtime store to inspect watcher health and source lag.',
113
- [{ command: '/services auth-review', summary: 'inspect supporting services until watcher wiring is available' }],
114
- C,
115
- ),
116
- }],
117
- palette: C,
118
- });
119
- while (workspace.length < height) workspace.push(createEmptyLine(width));
120
- return workspace;
121
- }
122
-
123
- const snapshot = this.readModel.getSnapshot();
124
- const watchers = this.getItems();
125
-
126
- const headerLines: Line[] = [
127
- buildKeyValueLine(width, [
128
- { label: 'watchers', value: String(snapshot.totalWatchers), valueColor: snapshot.totalWatchers > 0 ? C.info : C.dim },
129
- { label: 'active', value: String(snapshot.activeWatcherIds.length), valueColor: snapshot.activeWatcherIds.length > 0 ? C.ok : C.dim },
130
- { label: 'degraded', value: String(snapshot.totalDegraded), valueColor: snapshot.totalDegraded > 0 ? C.warn : C.dim },
131
- { label: 'lagged', value: String(snapshot.totalLagged), valueColor: snapshot.totalLagged > 0 ? C.warn : C.dim },
132
- ], C),
133
- buildGuidanceLine(width, '/schedule list', 'verify jobs consuming these sources; Agent keeps watcher lifecycle read-only here', C),
134
- ];
135
-
136
- if (watchers.length === 0) {
137
- return this.renderList(width, height, {
138
- title: 'Watchers',
139
- header: headerLines,
140
- emptyMessage: ' No watchers registered.',
141
- });
142
- }
143
-
144
- this.clampSelection();
145
- const selected = watchers[this.selectedIndex]!;
146
-
147
- const footerLines: Line[] = [
148
- buildPanelLine(width, [
149
- [' Watcher: ', C.label],
150
- [selected.label, C.value],
151
- [' Kind: ', C.label],
152
- [selected.kind, C.info],
153
- ]),
154
- buildPanelLine(width, [
155
- [' State: ', C.label],
156
- [selected.state, stateColor(selected.state)],
157
- [' Source: ', C.label],
158
- [selected.source.kind, C.value],
159
- ]),
160
- buildPanelLine(width, [
161
- [' Source status: ', C.label],
162
- [selected.sourceStatus ?? 'unknown', sourceStatusColor(selected.sourceStatus)],
163
- [' Lag: ', C.label],
164
- [formatLag(selected.sourceLagMs), selected.sourceLagMs ? C.warn : C.dim],
165
- ]),
166
- buildPanelLine(width, [
167
- [' Heartbeat: ', C.label],
168
- [formatTime(selected.lastHeartbeatAt), C.dim],
169
- [' Checkpoint: ', C.label],
170
- [truncateDisplay(selected.lastCheckpoint ?? 'n/a', Math.max(0, width - 38)), C.dim],
171
- ]),
172
- ];
173
- if (selected.degradedReason) {
174
- footerLines.push(buildPanelLine(width, [
175
- [' Reason: ', C.label],
176
- [truncateDisplay(selected.degradedReason, Math.max(0, width - 11)), C.warn],
177
- ]));
178
- }
179
- if (selected.lastError) {
180
- footerLines.push(buildPanelLine(width, [
181
- [' Error: ', C.label],
182
- [truncateDisplay(selected.lastError, Math.max(0, width - 10)), C.error],
183
- ]));
184
- }
185
- footerLines.push(buildPanelLine(width, [[' Up/Down move through watchers', C.dim]]));
186
-
187
- return this.renderList(width, height, {
188
- title: 'Watchers',
189
- header: headerLines,
190
- footer: footerLines,
191
- });
192
- }
193
- }