From c541bce857ec524ea0ba831d09ce9d041d006796 Mon Sep 17 00:00:00 2001 From: Yossi Elkrief Date: Tue, 17 Feb 2026 16:32:18 +0200 Subject: [PATCH] Add Babysitter widget for a5c.ai babysitter plugin status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `babysitter` widget that displays iteration progress when a babysitter run is active. The widget reads the babysitter plugin's state file to show current iteration and max iterations (e.g., "🤖 Babysitter #3/256"). - Supports raw value mode (shows just "#3/256") - Auto-discovers plugin cache directory across versions - Gracefully returns null when no babysitter run is active - Includes 12 unit tests covering all edge cases --- src/utils/widgets.ts | 3 +- src/widgets/Babysitter.ts | 111 ++++++++++++++++++ src/widgets/__tests__/Babysitter.test.ts | 142 +++++++++++++++++++++++ src/widgets/index.ts | 3 +- 4 files changed, 257 insertions(+), 2 deletions(-) create mode 100644 src/widgets/Babysitter.ts create mode 100644 src/widgets/__tests__/Babysitter.test.ts diff --git a/src/utils/widgets.ts b/src/utils/widgets.ts index 15a2510..4a5bb58 100644 --- a/src/utils/widgets.ts +++ b/src/utils/widgets.ts @@ -27,7 +27,8 @@ const widgetRegistry = new Map([ ['version', new widgets.VersionWidget()], ['custom-text', new widgets.CustomTextWidget()], ['custom-command', new widgets.CustomCommandWidget()], - ['claude-session-id', new widgets.ClaudeSessionIdWidget()] + ['claude-session-id', new widgets.ClaudeSessionIdWidget()], + ['babysitter', new widgets.BabysitterWidget()] ]); export function getWidget(type: WidgetItemType): Widget | null { diff --git a/src/widgets/Babysitter.ts b/src/widgets/Babysitter.ts new file mode 100644 index 0000000..91c5587 --- /dev/null +++ b/src/widgets/Babysitter.ts @@ -0,0 +1,111 @@ +import { + existsSync, + readFileSync, + readdirSync +} from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +import type { RenderContext } from '../types/RenderContext'; +import type { Settings } from '../types/Settings'; +import type { + Widget, + WidgetEditorDisplay, + WidgetItem +} from '../types/Widget'; + +interface BabysitterState { + active: boolean; + iteration: number; + maxIterations: number; + runId: string; +} + +function findBabysitterStateDir(): string | null { + const pluginCacheDir = join(homedir(), '.claude', 'plugins', 'cache', 'a5c-ai', 'babysitter'); + try { + const versions = readdirSync(pluginCacheDir); + if (versions.length === 0) + return null; + // Use the latest version directory (highest semver) + const latest = versions.sort().pop(); + if (!latest) + return null; + return join(pluginCacheDir, latest, 'skills', 'babysit', 'state'); + } catch { + return null; + } +} + +function parseBabysitterState(sessionId: string): BabysitterState | null { + const stateDir = findBabysitterStateDir(); + if (!stateDir) + return null; + + const stateFile = join(stateDir, `${sessionId}.md`); + if (!existsSync(stateFile)) + return null; + + try { + const content = readFileSync(stateFile, 'utf-8'); + + // Parse YAML frontmatter between --- delimiters + const frontmatterMatch = /^---\n([\s\S]*?)\n---/.exec(content); + if (!frontmatterMatch?.[1]) + return null; + + const frontmatter = frontmatterMatch[1]; + const getValue = (key: string): string | undefined => { + const match = new RegExp(`^${key}:\\s*(.+)$`, 'm').exec(frontmatter); + return match?.[1]?.trim().replace(/^["']|["']$/g, ''); + }; + + const active = getValue('active'); + if (active !== 'true') + return null; + + const iteration = parseInt(getValue('iteration') ?? '', 10); + const maxIterations = parseInt(getValue('max_iterations') ?? '', 10); + + if (isNaN(iteration) || isNaN(maxIterations)) + return null; + + return { + active: true, + iteration, + maxIterations, + runId: getValue('run_id') ?? '' + }; + } catch { + return null; + } +} + +export class BabysitterWidget implements Widget { + getDefaultColor(): string { return 'green'; } + getDescription(): string { return 'Shows babysitter run status and iteration progress when active'; } + getDisplayName(): string { return 'Babysitter'; } + getEditorDisplay(item: WidgetItem): WidgetEditorDisplay { + return { displayText: this.getDisplayName() }; + } + + render(item: WidgetItem, context: RenderContext, settings: Settings): string | null { + if (context.isPreview) { + return item.rawValue ? '#3/256' : '\u{1F916} Babysitter #3/256'; + } + + const sessionId = context.data?.session_id; + if (!sessionId) + return null; + + const state = parseBabysitterState(sessionId); + if (!state) + return null; + + const progress = `#${state.iteration}/${state.maxIterations}`; + return item.rawValue ? progress : `\u{1F916} Babysitter ${progress}`; + } + + supportsRawValue(): boolean { return true; } + supportsColors(item: WidgetItem): boolean { return true; } +} \ No newline at end of file diff --git a/src/widgets/__tests__/Babysitter.test.ts b/src/widgets/__tests__/Babysitter.test.ts new file mode 100644 index 0000000..47dfe34 --- /dev/null +++ b/src/widgets/__tests__/Babysitter.test.ts @@ -0,0 +1,142 @@ +import { + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; + +vi.mock('fs', () => ({ + existsSync: vi.fn(), + readFileSync: vi.fn(), + readdirSync: vi.fn() +})); + +vi.mock('os', () => ({ homedir: vi.fn(() => '/home/testuser') })); + +import { + existsSync, + readFileSync, + readdirSync +} from 'fs'; + +import type { + RenderContext, + Settings, + WidgetItem +} from '../../types'; +import { DEFAULT_SETTINGS } from '../../types/Settings'; +import { BabysitterWidget } from '../Babysitter'; + +const mockExistsSync = vi.mocked(existsSync); +const mockReadFileSync = vi.mocked(readFileSync); +const mockReaddirSync = vi.mocked(readdirSync); + +const VALID_STATE = `--- +active: true +iteration: 3 +max_iterations: 256 +run_id: "run-abc123" +started_at: "2026-02-17T14:30:45Z" +last_iteration_at: "2026-02-17T14:35:22Z" +iteration_times: "120,118,125" +--- + +Build a REST API with authentication`; + +const settings: Settings = DEFAULT_SETTINGS; + +function makeItem(rawValue = false): WidgetItem { + return { id: 'babysitter', type: 'babysitter', rawValue }; +} + +function makeContext(sessionId?: string, isPreview = false): RenderContext { + return { + isPreview, + data: sessionId ? { session_id: sessionId } : undefined + }; +} + +function setupMocks(stateContent: string | null) { + mockReaddirSync.mockReturnValue(['4.0.136'] as unknown as ReturnType); + if (stateContent) { + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(stateContent); + } else { + mockExistsSync.mockReturnValue(false); + } +} + +describe('BabysitterWidget', () => { + const widget = new BabysitterWidget(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should render preview', () => { + expect(widget.render(makeItem(), makeContext(undefined, true), settings)).toBe('\u{1F916} Babysitter #3/256'); + }); + + it('should render preview with raw value', () => { + expect(widget.render(makeItem(true), makeContext(undefined, true), settings)).toBe('#3/256'); + }); + + it('should return null when no session_id', () => { + expect(widget.render(makeItem(), makeContext(), settings)).toBeNull(); + }); + + it('should return null when state file does not exist', () => { + setupMocks(null); + expect(widget.render(makeItem(), makeContext('test-session'), settings)).toBeNull(); + }); + + it('should render iteration progress when active', () => { + setupMocks(VALID_STATE); + expect(widget.render(makeItem(), makeContext('test-session'), settings)).toBe('\u{1F916} Babysitter #3/256'); + }); + + it('should render raw iteration progress', () => { + setupMocks(VALID_STATE); + expect(widget.render(makeItem(true), makeContext('test-session'), settings)).toBe('#3/256'); + }); + + it('should return null when active is false', () => { + setupMocks(VALID_STATE.replace('active: true', 'active: false')); + expect(widget.render(makeItem(), makeContext('test-session'), settings)).toBeNull(); + }); + + it('should return null when frontmatter is missing', () => { + setupMocks('No frontmatter here'); + expect(widget.render(makeItem(), makeContext('test-session'), settings)).toBeNull(); + }); + + it('should return null when iteration is not a number', () => { + setupMocks(VALID_STATE.replace('iteration: 3', 'iteration: abc')); + expect(widget.render(makeItem(), makeContext('test-session'), settings)).toBeNull(); + }); + + it('should return null when plugin directory does not exist', () => { + mockReaddirSync.mockImplementation(() => { throw new Error('ENOENT'); }); + expect(widget.render(makeItem(), makeContext('test-session'), settings)).toBeNull(); + }); + + it('should use latest version directory', () => { + mockReaddirSync.mockReturnValue(['4.0.100', '4.0.136', '4.0.120'] as unknown as ReturnType); + mockExistsSync.mockReturnValue(true); + mockReadFileSync.mockReturnValue(VALID_STATE); + + widget.render(makeItem(), makeContext('test-session'), settings); + + expect(mockExistsSync).toHaveBeenCalledWith( + expect.stringContaining('4.0.136') + ); + }); + + it('should have correct metadata', () => { + expect(widget.getDefaultColor()).toBe('green'); + expect(widget.getDisplayName()).toBe('Babysitter'); + expect(widget.supportsRawValue()).toBe(true); + expect(widget.supportsColors(makeItem())).toBe(true); + }); +}); \ No newline at end of file diff --git a/src/widgets/index.ts b/src/widgets/index.ts index faaa705..986e46c 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -18,4 +18,5 @@ export { CustomTextWidget } from './CustomText'; export { CustomCommandWidget } from './CustomCommand'; export { BlockTimerWidget } from './BlockTimer'; export { CurrentWorkingDirWidget } from './CurrentWorkingDir'; -export { ClaudeSessionIdWidget } from './ClaudeSessionId'; \ No newline at end of file +export { ClaudeSessionIdWidget } from './ClaudeSessionId'; +export { BabysitterWidget } from './Babysitter'; \ No newline at end of file