Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/utils/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ const widgetRegistry = new Map<WidgetItemType, Widget>([
['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 {
Expand Down
111 changes: 111 additions & 0 deletions src/widgets/Babysitter.ts
Original file line number Diff line number Diff line change
@@ -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; }
}
142 changes: 142 additions & 0 deletions src/widgets/__tests__/Babysitter.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof readdirSync>);
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<typeof readdirSync>);
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);
});
});
3 changes: 2 additions & 1 deletion src/widgets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,5 @@ export { CustomTextWidget } from './CustomText';
export { CustomCommandWidget } from './CustomCommand';
export { BlockTimerWidget } from './BlockTimer';
export { CurrentWorkingDirWidget } from './CurrentWorkingDir';
export { ClaudeSessionIdWidget } from './ClaudeSessionId';
export { ClaudeSessionIdWidget } from './ClaudeSessionId';
export { BabysitterWidget } from './Babysitter';