singularity-forge/src/cli-status.ts
Mikael Hugo b24f426f2b batch: snapshot of in-flight v2 work
This commit captures uncommitted modifications that accumulated in the
working tree across multiple in-progress workstreams. It is a snapshot
to clear the deck before sf v3 work begins; individual workstreams
should land separately on top of this.

Notable additions:
- trace-collector.ts, traces.ts, src/tests/trace-export.test.ts —
  trace export plumbing
- biome.json — Biome linter configuration
- .gitignore — exclude native/npm/**/*.node compiled binaries

The bulk of the diff is across src/resources/extensions/sf/ (301 files)
and src/resources/extensions/sf/tests/ (277 files), reflecting the
ongoing sf extension work. Specific feature commits should follow this
snapshot rather than being archaeology'd out of it.

The 76MB native/npm/linux-x64-gnu/forge_engine.node compiled binary
was left out of the commit — it's now gitignored and built locally.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 12:42:31 +02:00

229 lines
5.9 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 } | null | undefined,
): string {
if (!ref) return "n/a";
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 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: { 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)}`);
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, {
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;
}