import { existsSync, watch, type FSWatcher } from "node:fs"; import { CONFIG_DIR_NAME, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; import { formatStatus } from "../src/formatter.ts"; import { getLocaleStrings, resolveLocale, type LocaleStrings } from "../src/locale.ts"; import { listScheduleDirectories, readScheduleSnapshots, scheduleRootPath } from "../src/schedule-reader.ts"; import type { ScheduleSnapshot, SupportedLocale } from "../src/types.ts"; const STATUS_KEY = "schedule-status"; const REFRESH_INTERVAL_MS = 60_000; const CLOCK_INTERVAL_MS = 1_000; const SPINNER_INTERVAL_MS = 180; const WATCH_DEBOUNCE_MS = 200; /** * 注册持久化 Schedule 状态栏. * * @param pi Pi 扩展 API */ export default function registerScheduleStatus(pi: ExtensionAPI): void { let currentContext: ExtensionContext | undefined; let locale: SupportedLocale = resolveLocale(process.env.PI_SCHEDULE_STATUS_LOCALE); let strings: LocaleStrings = getLocaleStrings(locale); let schedules: ScheduleSnapshot[] = []; let spinnerIndex = 0; let refreshTimer: ReturnType | undefined; let clockTimer: ReturnType | undefined; let spinnerTimer: ReturnType | undefined; let refreshDebounceTimer: ReturnType | undefined; let rootWatcher: FSWatcher | undefined; const scheduleWatchers = new Map(); const clearRefreshDebounce = (): void => { if (!refreshDebounceTimer) return; clearTimeout(refreshDebounceTimer); refreshDebounceTimer = undefined; }; const clearWatchers = (): void => { rootWatcher?.close(); rootWatcher = undefined; for (const watcher of scheduleWatchers.values()) watcher.close(); scheduleWatchers.clear(); }; const clearTimers = (): void => { if (refreshTimer) clearInterval(refreshTimer); if (clockTimer) clearInterval(clockTimer); if (spinnerTimer) clearInterval(spinnerTimer); refreshTimer = undefined; clockTimer = undefined; spinnerTimer = undefined; clearRefreshDebounce(); }; const render = (): void => { if (!currentContext) return; try { const theme = currentContext.ui.theme; const text = formatStatus(schedules, strings, new Date(), spinnerIndex, (tone, value) => theme.fg(tone, value)); currentContext.ui.setStatus(STATUS_KEY, text); } catch { return; } }; const syncAnimationTimers = (): void => { const hasPending = schedules.some((schedule) => schedule.nextRunAt !== undefined); const hasRunning = schedules.some((schedule) => schedule.state === "running"); if (hasPending && !clockTimer) { clockTimer = setInterval(render, CLOCK_INTERVAL_MS); clockTimer.unref?.(); } else if (!hasPending && clockTimer) { clearInterval(clockTimer); clockTimer = undefined; } if (hasRunning && !spinnerTimer) { spinnerTimer = setInterval(() => { spinnerIndex = (spinnerIndex + 1) % 10; render(); }, SPINNER_INTERVAL_MS); spinnerTimer.unref?.(); } else if (!hasRunning && spinnerTimer) { clearInterval(spinnerTimer); spinnerTimer = undefined; spinnerIndex = 0; } }; const scheduleRefresh = (): void => { if (!currentContext || refreshDebounceTimer) return; refreshDebounceTimer = setTimeout(() => { refreshDebounceTimer = undefined; refresh(); }, WATCH_DEBOUNCE_MS); refreshDebounceTimer.unref?.(); }; const watchDirectory = (directory: string): FSWatcher | undefined => { try { const watcher = watch(directory, { persistent: false }, scheduleRefresh); watcher.on("error", () => { clearWatchers(); scheduleRefresh(); }); return watcher; } catch { return undefined; } }; const refreshWatchers = (): void => { if (!currentContext) return; const root = scheduleRootPath(currentContext.cwd, CONFIG_DIR_NAME); if (!existsSync(root)) { clearWatchers(); return; } if (!rootWatcher) rootWatcher = watchDirectory(root); const directories = new Set(listScheduleDirectories(currentContext.cwd, CONFIG_DIR_NAME)); for (const [directory, watcher] of scheduleWatchers.entries()) { if (directories.has(directory)) continue; watcher.close(); scheduleWatchers.delete(directory); } for (const directory of directories) { if (scheduleWatchers.has(directory)) continue; const watcher = watchDirectory(directory); if (watcher) scheduleWatchers.set(directory, watcher); } }; const refresh = (): void => { if (!currentContext) return; try { schedules = readScheduleSnapshots( currentContext.cwd, currentContext.sessionManager.getSessionFile(), Date.now(), CONFIG_DIR_NAME, ); render(); syncAnimationTimers(); refreshWatchers(); } catch { return; } }; pi.on("session_start", async (_event, ctx) => { clearTimers(); clearWatchers(); currentContext = ctx; locale = resolveLocale(process.env.PI_SCHEDULE_STATUS_LOCALE); strings = getLocaleStrings(locale); spinnerIndex = 0; refresh(); refreshTimer = setInterval(refresh, REFRESH_INTERVAL_MS); refreshTimer.unref?.(); }); pi.on("session_shutdown", async (_event, ctx) => { clearTimers(); clearWatchers(); currentContext = undefined; schedules = []; ctx.ui.setStatus(STATUS_KEY, undefined); }); }