import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; export const SNAPSHOT_STALLED_AFTER_MS = 60_000; export const DEFAULT_STATUS_LINE_LIMIT = 4; export const MAX_STATUS_NAME_LENGTH = 72; export const MAX_STATUS_LINE_LENGTH = 120; const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../.."); const DEFAULT_STATUS_CONFIG_PATH = join(PACKAGE_ROOT, "config.json"); const STATUS_CONFIG_EXAMPLE_PATH = join(PACKAGE_ROOT, "config.json.example"); export type SubagentStatusKind = "starting" | "active" | "waiting" | "stalled" | "running"; export type SubagentStatusSource = "pi" | "claude"; export type SubagentStatusTransition = "stalled" | "recovered" | null; export type StatusSnapshotState = "unseen" | "present" | "missing" | "invalid" | "wrong-id"; export type StatusActivityPhase = "starting" | "active" | "waiting" | "done"; export interface StatusConfig { enabled: boolean; lineLimit: number; } export type StatusObservation = | { snapshot: "present"; updatedAt: number; sequence: number; phase: StatusActivityPhase; active?: boolean; activeScope?: string; activeSince?: number; waitingSince?: number; latestEvent?: string; activityLabel?: string; } | { snapshot: "missing" | "invalid" | "wrong-id"; snapshotError?: string; }; export interface SubagentStatusState { source: SubagentStatusSource; startTimeMs: number; firstObservationAtMs: number | null; lastActivityAtMs: number | null; lastActivitySequence: number | null; localOverrideAtMs: number | null; localOverrideSequence: number | null; activeNow: boolean; activeSinceMs: number | null; activeScope: string | null; waitingSinceMs: number | null; phase: StatusActivityPhase | null; latestEvent: string | null; activityLabel: string | null; snapshotState: StatusSnapshotState; snapshotProblemSinceMs: number | null; snapshotError: string | null; currentKind: SubagentStatusKind; } export interface StatusSnapshot { kind: SubagentStatusKind; elapsedMs: number; elapsedText: string; activeSinceMs: number | null; activeDurationText: string | null; activeScope: string | null; waitingSinceMs: number | null; waitingDurationText: string | null; latestEvent: string | null; activityLabel: string | null; snapshotState: StatusSnapshotState; snapshotError: string | null; snapshotProblemText: string | null; statusLabel: string | null; } export interface CappedStatusLines { visibleLines: string[]; overflow: number; } function invalidStatusConfig(source: string, message: string): never { throw new Error(`Invalid subagent status config in ${source}: ${message}`); } function requireObject(value: unknown, source: string, fieldName: string): Record { if (value == null || typeof value !== "object" || Array.isArray(value)) { invalidStatusConfig(source, `${fieldName} must be an object`); } return value as Record; } function requireBoolean(value: unknown, source: string, fieldName: string): boolean { if (typeof value !== "boolean") { invalidStatusConfig(source, `${fieldName} must be a boolean`); } return value; } function rejectUnsupportedKeys( value: Record, allowedKeys: string[], source: string, fieldName: string, ): void { const unsupportedKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key)); if (unsupportedKeys.length > 0) { invalidStatusConfig(source, `${fieldName} has unsupported key(s): ${unsupportedKeys.join(", ")}`); } } function truncateText(text: string, maxLength: number): string { if (text.length <= maxLength) return text; if (maxLength <= 1) return text.slice(0, maxLength); return `${text.slice(0, maxLength - 1)}…`; } export function normalizeStatusName(name: string): string { const collapsed = name.replace(/\s+/g, " ").trim() || "subagent"; return truncateText(collapsed, MAX_STATUS_NAME_LENGTH); } function boundStatusLine(line: string): string { return truncateText(line.replace(/\s+/g, " ").trim(), MAX_STATUS_LINE_LENGTH); } function snapshotProblemLabel(snapshotState: StatusSnapshotState): string | null { if (snapshotState === "wrong-id") return "wrong activity id"; return null; } function activityLabel(snapshot: Pick): string | null { return snapshot.activityLabel ?? snapshot.activeScope; } export function parseStatusConfig(rawConfig: unknown, source = "config.json"): StatusConfig { const config = requireObject(rawConfig, source, "root"); const status = requireObject(config.status, source, "status"); rejectUnsupportedKeys(status, ["enabled"], source, "status"); const enabled = requireBoolean(status.enabled, source, "status.enabled"); return { enabled, lineLimit: DEFAULT_STATUS_LINE_LIMIT, }; } function readStatusConfigFile(configPath: string, examplePath: string): { sourcePath: string; rawConfig: string } { try { return { sourcePath: configPath, rawConfig: readFileSync(configPath, "utf8") }; } catch (error) { const errno = error as NodeJS.ErrnoException; if (errno.code !== "ENOENT") throw error; } try { return { sourcePath: examplePath, rawConfig: readFileSync(examplePath, "utf8") }; } catch (error) { const errno = error as NodeJS.ErrnoException; if (errno.code === "ENOENT") { throw new Error( `Missing subagent status config. Expected ${configPath} or ${examplePath}.`, ); } throw error; } } export function loadStatusConfig( configPath = DEFAULT_STATUS_CONFIG_PATH, examplePath = STATUS_CONFIG_EXAMPLE_PATH, ): StatusConfig { const { sourcePath, rawConfig } = readStatusConfigFile(configPath, examplePath); let parsed: unknown; try { parsed = JSON.parse(rawConfig) as unknown; } catch (error) { const detail = error instanceof Error ? error.message : String(error); throw new Error(`Invalid JSON in subagent config ${sourcePath}: ${detail}`); } return parseStatusConfig(parsed, sourcePath); } export function formatElapsedDuration(ms: number): string { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); if (totalSeconds < 60) return `${totalSeconds}s`; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); if (hours > 0) return `${hours}h ${minutes}m`; return `${minutes}m`; } export function createStatusState(params: { source: SubagentStatusSource; startTimeMs: number; }): SubagentStatusState { const initialKind = params.source === "claude" ? "running" : "starting"; return { source: params.source, startTimeMs: params.startTimeMs, firstObservationAtMs: null, lastActivityAtMs: null, lastActivitySequence: null, localOverrideAtMs: null, localOverrideSequence: null, activeNow: false, activeSinceMs: null, activeScope: null, waitingSinceMs: null, phase: null, latestEvent: null, activityLabel: null, snapshotState: params.source === "claude" ? "unseen" : "unseen", snapshotProblemSinceMs: null, snapshotError: null, currentKind: initialKind, }; } export function observeStatus( state: SubagentStatusState, observation: StatusObservation, now: number, ): SubagentStatusState { if (state.source === "claude") return state; if (observation.snapshot !== "present") { return { ...state, firstObservationAtMs: state.firstObservationAtMs ?? now, snapshotState: observation.snapshot, snapshotProblemSinceMs: state.snapshotProblemSinceMs ?? now, snapshotError: observation.snapshotError ?? null, }; } const updatedAt = observation.updatedAt; const sequence = observation.sequence; const lastActivityAtMs = state.lastActivityAtMs; const lastActivitySequence = state.lastActivitySequence; const olderThanLastActivity = lastActivityAtMs != null && ( updatedAt < lastActivityAtMs || (updatedAt === lastActivityAtMs && lastActivitySequence != null && sequence < lastActivitySequence) ); if (olderThanLastActivity) return state; const blockedByLocalOverride = state.localOverrideAtMs != null && ( updatedAt < state.localOverrideAtMs || (updatedAt === state.localOverrideAtMs && state.localOverrideSequence != null && sequence <= state.localOverrideSequence) ); if (blockedByLocalOverride) return state; const phase = observation.phase; const activeNow = phase === "active" || observation.active === true; const activeSinceMs = activeNow ? observation.activeSince ?? state.activeSinceMs ?? updatedAt : null; const waitingSinceMs = phase === "waiting" ? observation.waitingSince ?? state.waitingSinceMs ?? updatedAt : null; return { ...state, firstObservationAtMs: state.firstObservationAtMs ?? now, lastActivityAtMs: updatedAt, lastActivitySequence: sequence, activeNow, activeSinceMs, activeScope: activeNow ? observation.activeScope ?? null : null, waitingSinceMs, phase, latestEvent: observation.latestEvent ?? null, activityLabel: observation.activityLabel ?? null, snapshotState: "present", snapshotProblemSinceMs: null, snapshotError: null, localOverrideAtMs: null, localOverrideSequence: null, }; } export function forceStatusAfterInterrupt(state: SubagentStatusState, now: number): SubagentStatusState { if (state.source === "claude") return state; return { ...state, firstObservationAtMs: state.firstObservationAtMs ?? now, lastActivityAtMs: now, localOverrideAtMs: now, localOverrideSequence: state.lastActivitySequence, activeNow: false, activeSinceMs: null, activeScope: null, waitingSinceMs: now, phase: "waiting", latestEvent: "interrupt_requested", activityLabel: "interrupted", snapshotState: "present", snapshotProblemSinceMs: null, snapshotError: null, currentKind: "waiting", }; } function classifyProblemState(state: SubagentStatusState, now: number): Pick { const problemLabel = snapshotProblemLabel(state.snapshotState); const hasValidSnapshot = state.lastActivityAtMs != null; if (!hasValidSnapshot) { const referenceMs = state.firstObservationAtMs ?? state.startTimeMs; const elapsedMs = Math.max(0, now - referenceMs); return elapsedMs >= SNAPSHOT_STALLED_AFTER_MS ? { kind: "stalled", statusLabel: problemLabel } : { kind: "starting", statusLabel: null }; } const problemSinceMs = state.snapshotProblemSinceMs ?? now; const problemMs = Math.max(0, now - problemSinceMs); if (problemMs >= SNAPSHOT_STALLED_AFTER_MS) return { kind: "stalled", statusLabel: problemLabel }; const lastHealthyKind = state.activeNow ? "active" : state.waitingSinceMs != null || state.phase === "done" ? "waiting" : state.currentKind === "stalled" ? "starting" : state.currentKind; return { kind: lastHealthyKind, statusLabel: problemLabel }; } export function classifyStatus(state: SubagentStatusState, now: number): StatusSnapshot { const elapsedMs = Math.max(0, now - state.startTimeMs); const elapsedText = formatElapsedDuration(elapsedMs); if (state.source === "claude") { return { kind: "running", elapsedMs, elapsedText, activeSinceMs: null, activeDurationText: null, activeScope: null, waitingSinceMs: null, waitingDurationText: null, latestEvent: null, activityLabel: null, snapshotState: state.snapshotState, snapshotError: null, snapshotProblemText: null, statusLabel: null, }; } let kind: SubagentStatusKind; let statusLabel: string | null = null; if (state.snapshotState === "present") { if (state.phase === "active" || state.activeNow) { kind = "active"; } else if (state.phase === "waiting") { kind = "waiting"; } else if (state.phase === "done") { kind = "waiting"; statusLabel = "done"; } else { const referenceMs = state.firstObservationAtMs ?? state.startTimeMs; const elapsedSinceObservationMs = Math.max(0, now - referenceMs); kind = elapsedSinceObservationMs >= SNAPSHOT_STALLED_AFTER_MS ? "stalled" : "starting"; statusLabel = null; } } else { const classified = classifyProblemState(state, now); kind = classified.kind; statusLabel = classified.statusLabel; } const activeDurationText = state.activeSinceMs == null ? null : formatElapsedDuration(now - state.activeSinceMs); const waitingDurationText = state.waitingSinceMs == null ? null : formatElapsedDuration(now - state.waitingSinceMs); const snapshotProblemText = state.snapshotProblemSinceMs == null ? null : formatElapsedDuration(now - state.snapshotProblemSinceMs); return { kind, elapsedMs, elapsedText, activeSinceMs: state.activeSinceMs, activeDurationText, activeScope: state.activeScope, waitingSinceMs: state.waitingSinceMs, waitingDurationText, latestEvent: state.latestEvent, activityLabel: state.activityLabel, snapshotState: state.snapshotState, snapshotError: state.snapshotError, snapshotProblemText, statusLabel, }; } export function advanceStatusState( state: SubagentStatusState, now: number, ): { nextState: SubagentStatusState; snapshot: StatusSnapshot; transition: SubagentStatusTransition; } { const snapshot = classifyStatus(state, now); const transition = state.currentKind !== "stalled" && snapshot.kind === "stalled" ? "stalled" : state.currentKind === "stalled" && (snapshot.kind === "active" || snapshot.kind === "waiting") ? "recovered" : null; return { snapshot, transition, nextState: { ...state, currentKind: snapshot.kind, }, }; } function formatActiveDetail(snapshot: StatusSnapshot): string { const label = activityLabel(snapshot); if (!label) return "active"; const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : ""; return `active (${label}${duration})`; } function formatWaitingDetail(snapshot: StatusSnapshot): string { const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : ""; return `waiting${duration}`; } function formatStalledDetail(snapshot: StatusSnapshot): string { const detail = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : ""; const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : ""; return `stalled${duration}${detail}`; } export function formatStatusLine(name: string, snapshot: StatusSnapshot): string { const boundedName = normalizeStatusName(name); if (snapshot.kind === "starting") { const label = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : ""; return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, starting${label}.`); } if (snapshot.kind === "running") { return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}.`); } if (snapshot.kind === "active") { return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatActiveDetail(snapshot)}.`); } if (snapshot.kind === "waiting") { const problem = snapshot.statusLabel && snapshot.statusLabel !== "done" ? ` (${snapshot.statusLabel})` : snapshot.statusLabel === "done" ? " (done)" : ""; return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatWaitingDetail(snapshot)}${problem}.`); } return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatStalledDetail(snapshot)}.`); } export function formatTransitionLine( name: string, snapshot: StatusSnapshot, transition: Exclude, ): string { const boundedName = normalizeStatusName(name); if (transition === "recovered") { const detail = snapshot.kind === "waiting" ? formatWaitingDetail(snapshot) : formatActiveDetail(snapshot); return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, recovered; ${detail}.`); } return formatStatusLine(boundedName, snapshot); } export function capStatusLines(lines: string[], lineLimit: number): CappedStatusLines { const visibleLines = lines.slice(0, lineLimit); return { visibleLines, overflow: Math.max(0, lines.length - visibleLines.length), }; } export function formatStatusAggregate(lines: string[], lineLimit: number): string { const { visibleLines, overflow } = capStatusLines(lines, lineLimit); const bulletLines = visibleLines.map((line) => `• ${line}`); if (overflow > 0) bulletLines.push(`• +${overflow} more running.`); return `Subagent status:\n${bulletLines.join("\n")}`; }