514 lines
16 KiB
TypeScript
514 lines
16 KiB
TypeScript
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<string, unknown> {
|
|
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
invalidStatusConfig(source, `${fieldName} must be an object`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
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<string, unknown>,
|
|
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<StatusSnapshot, "activityLabel" | "activeScope">): 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<StatusSnapshot, "kind" | "statusLabel"> {
|
|
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<SubagentStatusTransition, null>,
|
|
): 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")}`;
|
|
}
|