267 lines
7.1 KiB
TypeScript
267 lines
7.1 KiB
TypeScript
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import {
|
|
collectRecentLogEvents,
|
|
formatMergedLogEvent,
|
|
getProjectSessionKey,
|
|
} from "./cli-logs.js";
|
|
import type { QuerySnapshot } from "./headless-query.js";
|
|
|
|
interface StatusArgs {
|
|
watch: boolean;
|
|
}
|
|
|
|
interface StatusDeps {
|
|
basePath: string;
|
|
sfHome?: string;
|
|
stdout?: Pick<typeof process.stdout, "write" | "isTTY">;
|
|
stderr?: Pick<typeof process.stderr, "write">;
|
|
}
|
|
|
|
interface CurrentModel {
|
|
provider: string;
|
|
model: string;
|
|
}
|
|
|
|
function parseStatusArgs(argv: string[]): StatusArgs {
|
|
const args = argv.slice(1);
|
|
return {
|
|
watch: args.includes("--watch"),
|
|
};
|
|
}
|
|
|
|
function formatRef(
|
|
ref: { id: string; title?: string } | string | null | undefined,
|
|
): string {
|
|
if (!ref) return "n/a";
|
|
if (typeof ref === "string") return ref;
|
|
return ref.title ? `${ref.id} ${ref.title}` : ref.id;
|
|
}
|
|
|
|
function formatDispatch(snapshot: QuerySnapshot): string {
|
|
const next = snapshot.next;
|
|
if (next.action === "dispatch") {
|
|
return `${next.unitType ?? "unit"} ${next.unitId ?? "n/a"}`;
|
|
}
|
|
if (next.action === "stop")
|
|
return `stop: ${next.reason ?? snapshot.state.nextAction}`;
|
|
return `skip: ${next.reason ?? snapshot.state.nextAction}`;
|
|
}
|
|
|
|
function formatCost(snapshot: QuerySnapshot): string {
|
|
const total = snapshot.cost.total;
|
|
const workers = snapshot.cost.workers.length;
|
|
return `$${total.toFixed(4)}${workers > 0 ? ` (${workers} worker${workers === 1 ? "" : "s"})` : ""}`;
|
|
}
|
|
|
|
function readSolverStatus(basePath: string): string | null {
|
|
let state: Record<string, any>;
|
|
try {
|
|
state = JSON.parse(
|
|
readFileSync(
|
|
join(basePath, ".sf", "runtime", "autonomous-solver", "active.json"),
|
|
"utf-8",
|
|
),
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
const checkpoint = state.latestCheckpoint ?? {};
|
|
const parts = [
|
|
`${state.unitType ?? "unit"} ${state.unitId ?? "n/a"}`,
|
|
`iter ${state.iteration ?? "?"}/${state.maxIterations ?? "?"}`,
|
|
`outcome ${checkpoint.outcome ?? "none"}`,
|
|
];
|
|
const remaining = Array.isArray(checkpoint.remainingItems)
|
|
? checkpoint.remainingItems.length
|
|
: null;
|
|
if (remaining !== null) parts.push(`${remaining} remaining`);
|
|
if (checkpoint.blockerReason)
|
|
parts.push(`blocker: ${checkpoint.blockerReason}`);
|
|
if (checkpoint.decisionQuestion)
|
|
parts.push(`decision: ${checkpoint.decisionQuestion}`);
|
|
if (checkpoint.summary) parts.push(String(checkpoint.summary));
|
|
return parts.join(" · ");
|
|
}
|
|
|
|
function latestJsonlFile(dir: string): string | null {
|
|
try {
|
|
const entries = readdirSync(dir)
|
|
.filter((file) => file.endsWith(".jsonl"))
|
|
.map((file) => {
|
|
const path = join(dir, file);
|
|
try {
|
|
return { path, mtimeMs: statSync(path).mtimeMs };
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(
|
|
(entry): entry is { path: string; mtimeMs: number } => entry !== null,
|
|
);
|
|
entries.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
return entries[0]?.path ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractCurrentModelFromSession(filePath: string): CurrentModel | null {
|
|
let current: CurrentModel | null = null;
|
|
let raw: string;
|
|
try {
|
|
raw = readFileSync(filePath, "utf-8");
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
for (const line of raw.split(/\n/)) {
|
|
if (!line.trim()) continue;
|
|
let entry: Record<string, unknown>;
|
|
try {
|
|
entry = JSON.parse(line) as Record<string, unknown>;
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
if (entry.type === "model_change") {
|
|
const provider = String(entry.provider ?? "");
|
|
const model = String(entry.modelId ?? "");
|
|
if (provider || model) current = { provider, model };
|
|
continue;
|
|
}
|
|
|
|
if (entry.type === "message") {
|
|
const message = entry.message as Record<string, unknown> | undefined;
|
|
if (message?.role === "assistant") {
|
|
const provider = String(message.provider ?? "");
|
|
const model = String(message.model ?? "");
|
|
if (provider || model) current = { provider, model };
|
|
}
|
|
}
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
function getCurrentModel(
|
|
basePath: string,
|
|
sfHome: string,
|
|
): CurrentModel | null {
|
|
const key = getProjectSessionKey(basePath);
|
|
const sessionFile =
|
|
latestJsonlFile(join(sfHome, "agent", "sessions", key)) ??
|
|
latestJsonlFile(join(sfHome, "sessions", key));
|
|
return sessionFile ? extractCurrentModelFromSession(sessionFile) : null;
|
|
}
|
|
|
|
function formatModel(model: CurrentModel | null): string {
|
|
if (!model) return "n/a";
|
|
if (model.provider && model.model) return `${model.provider}/${model.model}`;
|
|
return model.model || model.provider || "n/a";
|
|
}
|
|
|
|
export function renderLiveStatus(
|
|
snapshot: QuerySnapshot,
|
|
opts: {
|
|
basePath?: string;
|
|
model: CurrentModel | null;
|
|
recentEvents: string[];
|
|
},
|
|
): string {
|
|
const lines: string[] = [];
|
|
lines.push("SF Status");
|
|
lines.push("---------");
|
|
lines.push(
|
|
`Milestone: ${formatRef(snapshot.state.activeMilestone ?? snapshot.state.lastCompletedMilestone)}`,
|
|
);
|
|
lines.push(`Slice: ${formatRef(snapshot.state.activeSlice)}`);
|
|
lines.push(`Task: ${formatRef(snapshot.state.activeTask)}`);
|
|
lines.push(`Phase: ${snapshot.state.phase}`);
|
|
lines.push(`Dispatch: ${formatDispatch(snapshot)}`);
|
|
lines.push(`Cost: ${formatCost(snapshot)}`);
|
|
lines.push(`Model: ${formatModel(opts.model)}`);
|
|
const solverStatus = opts.basePath ? readSolverStatus(opts.basePath) : null;
|
|
if (solverStatus) lines.push(`Solver: ${solverStatus}`);
|
|
lines.push("");
|
|
lines.push("Last Events:");
|
|
if (opts.recentEvents.length === 0) {
|
|
lines.push(" n/a");
|
|
} else {
|
|
for (const event of opts.recentEvents) {
|
|
lines.push(` ${event.trimEnd()}`);
|
|
}
|
|
}
|
|
return lines.join("\n") + "\n";
|
|
}
|
|
|
|
async function buildStatusText(
|
|
basePath: string,
|
|
sfHome: string,
|
|
): Promise<string> {
|
|
const { buildQuerySnapshot } = await import("./headless-query.js");
|
|
const snapshot = await buildQuerySnapshot(basePath);
|
|
const recentEvents = collectRecentLogEvents({
|
|
basePath,
|
|
sfHome,
|
|
limit: 50,
|
|
})
|
|
.filter((event) => event.source === "notif" || event.source === "activity")
|
|
.slice(-10)
|
|
.map(formatMergedLogEvent);
|
|
|
|
return renderLiveStatus(snapshot, {
|
|
basePath,
|
|
model: getCurrentModel(basePath, sfHome),
|
|
recentEvents,
|
|
});
|
|
}
|
|
|
|
export async function runStatusCli(
|
|
argv: string[],
|
|
deps: StatusDeps,
|
|
): Promise<number> {
|
|
const stdout = deps.stdout ?? process.stdout;
|
|
const stderr = deps.stderr ?? process.stderr;
|
|
const sfHome = deps.sfHome ?? process.env.SF_HOME ?? join(homedir(), ".sf");
|
|
const args = parseStatusArgs(argv);
|
|
|
|
const renderOnce = async () => {
|
|
try {
|
|
const text = await buildStatusText(deps.basePath, sfHome);
|
|
if (args.watch && stdout.isTTY) stdout.write("\x1b[2J\x1b[H");
|
|
stdout.write(text);
|
|
} catch (err) {
|
|
stderr.write(
|
|
`sf status: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
if (!args.watch) {
|
|
await renderOnce();
|
|
return 0;
|
|
}
|
|
|
|
await renderOnce();
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setInterval(() => {
|
|
renderOnce().catch(() => {
|
|
clearInterval(timer);
|
|
resolve();
|
|
});
|
|
}, 2000);
|
|
const stop = () => {
|
|
clearInterval(timer);
|
|
process.off("SIGINT", stop);
|
|
process.off("SIGTERM", stop);
|
|
resolve();
|
|
};
|
|
process.on("SIGINT", stop);
|
|
process.on("SIGTERM", stop);
|
|
});
|
|
return 0;
|
|
}
|