infinity-harness 2.6.6 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +68 -15
  3. package/extensions/infinity-harness/index.ts +600 -26
  4. package/harness/docs/ARCHITECTURE.md +13 -7
  5. package/harness/docs/CONSTRAINTS.md +13 -5
  6. package/harness/docs/DECISIONS.md +44 -0
  7. package/harness/docs/DOMAIN.md +44 -8
  8. package/package.json +1 -1
  9. package/src/core/config.ts +88 -1
  10. package/src/core/featureList.ts +85 -17
  11. package/src/core/gates.ts +8 -6
  12. package/src/core/init.ts +33 -3
  13. package/src/core/modelRouter.ts +149 -0
  14. package/src/core/paths.ts +29 -0
  15. package/src/core/plan.ts +39 -0
  16. package/src/core/runState.ts +151 -0
  17. package/src/core/settings.ts +138 -4
  18. package/src/core/types.ts +49 -0
  19. package/src/daemon/budget.ts +94 -0
  20. package/src/daemon/guard.ts +113 -0
  21. package/src/daemon/index.ts +421 -0
  22. package/src/daemon/isolation.ts +95 -0
  23. package/src/daemon/preflight.ts +132 -0
  24. package/src/daemon/server.ts +153 -0
  25. package/src/daemon/supervisorState.ts +83 -0
  26. package/src/daemon/worker.ts +239 -0
  27. package/src/daemon/worktree.ts +95 -0
  28. package/src/exec/piWorker.ts +706 -0
  29. package/src/goalState.ts +2 -22
  30. package/src/intake.ts +4 -1
  31. package/src/loop.ts +35 -34
  32. package/src/modelRouter.ts +0 -0
  33. package/src/remote.ts +28 -7
  34. package/src/replan.ts +7 -3
  35. package/src/rework.ts +9 -3
  36. package/src/runState.ts +15 -121
  37. package/src/scheduler.ts +115 -135
  38. package/src/supervisor.ts +955 -0
  39. package/src/taskList.ts +41 -3
  40. package/src/ui/dashboard.ts +127 -0
  41. package/src/ui/viewState.ts +77 -0
  42. package/src/ui/widget.ts +189 -0
  43. package/src/ui/wizard.ts +43 -7
  44. package/src/unstuck.ts +0 -0
  45. package/src/worker.ts +12 -8
@@ -0,0 +1,95 @@
1
+ /**
2
+ * infinity-harness — daemon/isolation.ts
3
+ *
4
+ * Worker isolation: a worker session must not load the harness extension into
5
+ * the Daemon's process, and must receive the harness tools as customTools.
6
+ *
7
+ * The SDK loads discovered extensions by default (DefaultResourceLoader with
8
+ * reload()). infinity-harness IS an installed pi extension, so a default
9
+ * loader would load it inside the Daemon and each worker would register
10
+ * session_start, commands and widgets — sharing module state and driving runs.
11
+ *
12
+ * This module produces:
13
+ * - a harness-free ResourceLoader (noExtensions, noSkills where appropriate)
14
+ * - the harness ToolDefinitions to hand to workers as customTools
15
+ * - a helper to assert isolation in tests
16
+ */
17
+
18
+ import type { ResourceLoader, ToolDefinition } from "@earendil-works/pi-coding-agent";
19
+
20
+ export type IsolationOpts = {
21
+ cwd: string;
22
+ agentDir?: string;
23
+ };
24
+
25
+ export type HarnessedLoader = ResourceLoader;
26
+
27
+ /**
28
+ * Build a ResourceLoader that WILL NOT discover the harness extension.
29
+ * Workers receive this as `resourceLoader`.
30
+ */
31
+ export async function createIsolatedLoader(opts: IsolationOpts): Promise<HarnessedLoader> {
32
+ const { DefaultResourceLoader, SettingsManager } = await import("@earendil-works/pi-coding-agent");
33
+ const settingsManager = SettingsManager.create(opts.cwd, opts.agentDir);
34
+ const loader = new DefaultResourceLoader({
35
+ cwd: opts.cwd,
36
+ agentDir: opts.agentDir ?? "",
37
+ settingsManager,
38
+ noExtensions: true,
39
+ noSkills: true,
40
+ noContextFiles: true,
41
+ } as unknown as ConstructorParameters<typeof DefaultResourceLoader>[0]);
42
+ await loader.reload();
43
+ return loader as HarnessedLoader;
44
+ }
45
+
46
+ /**
47
+ * Minimal harness tools that a worker needs to function.
48
+ * Daemon hands these as `customTools` so the worker's ability to record work
49
+ * is declared, not discovered. A worker that cannot record is a worker whose
50
+ * unit loops forever.
51
+ */
52
+ export function harnessToolsForWorker(): ToolDefinition[] {
53
+ return [
54
+ {
55
+ name: "infinity_plan",
56
+ description: "Atomic plan editor: submit the full task list. Omission=deletion, baseRevision guard, cycle/missingDep checks.",
57
+ parameters: {
58
+ type: "object",
59
+ properties: {
60
+ baseRevision: { type: "number" },
61
+ tasks: { type: "array", items: { type: "object" } },
62
+ features: { type: "array", items: { type: "object" } },
63
+ goal: { type: "string" },
64
+ },
65
+ },
66
+ handler: async () => ({ content: [{ type: "text", text: "infinity_plan stub — Daemon replaces this handler" }] }),
67
+ } as unknown as ToolDefinition,
68
+ {
69
+ name: "infinity_validate",
70
+ description: "Run the deterministic gate; model never decides PASS/FAIL.",
71
+ parameters: { type: "object", properties: {} },
72
+ handler: async () => ({ content: [{ type: "text", text: "infinity_validate stub" }] }),
73
+ } as unknown as ToolDefinition,
74
+ {
75
+ name: "infinity_brief",
76
+ description: "Return the rendered brief for the next unit.",
77
+ parameters: { type: "object", properties: {} },
78
+ handler: async () => ({ content: [{ type: "text", text: "infinity_brief stub" }] }),
79
+ } as unknown as ToolDefinition,
80
+ ];
81
+ }
82
+
83
+ /**
84
+ * Test assertion: a worker session's loader has zero harness extension instances.
85
+ * The session's extensionsResult captures what was loaded; we count any factory
86
+ * whose id contains "infinity-harness".
87
+ */
88
+ export function assertZeroHarnessExtensions(extensionsResult: unknown): void {
89
+ const result = extensionsResult as { extensions?: Array<{ id?: string; name?: string }> } | null | undefined;
90
+ const list = result?.extensions ?? [];
91
+ const found = list.filter((e) => String(e?.id ?? e?.name ?? "").includes("infinity-harness"));
92
+ if (found.length !== 0) {
93
+ throw new Error(`isolation violated: worker loaded ${found.length} harness extension(s): ${found.map(f => f.id ?? f.name).join(", ")}`);
94
+ }
95
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * infinity-harness — daemon/preflight.ts
3
+ *
4
+ * Tier preflight: at arm time, prove each configured tier A/B/C/D/X can serve.
5
+ * getModel() and getAvailable() are registry checks, not auth checks. Only a
6
+ * real call proves a tier serves. A tier that fails preflight blocks arming
7
+ * (naming the tier and reason) — not a warning buried in a log.
8
+ *
9
+ * Distinct tiers are probed once. Probe uses SessionManager.inMemory() + a
10
+ * one-token prompt on a throwaway session with noTools.
11
+ */
12
+
13
+ import type { TierId, TierSpec, HarnessConfig } from "../core/types.ts";
14
+ import type { TierResults, TierPreflight } from "../core/runState.ts";
15
+ import { loadConfig } from "../core/config.ts";
16
+
17
+ export type PreflightResult = { tier: TierId; ok: boolean; servedModel?: string; reason?: string };
18
+
19
+ export type PreflightOpts = {
20
+ targetDir: string;
21
+ tiers?: Partial<Record<TierId, TierSpec>>;
22
+ /** For tests: inject a probe fn instead of doing the real SDK call. */
23
+ probe?: (spec: TierSpec) => Promise<{ served: string }>;
24
+ };
25
+
26
+ function tiersFromConfig(config: HarnessConfig): Partial<Record<TierId, TierSpec>> {
27
+ const t = (config as unknown as { tiers?: Partial<Record<TierId, TierSpec>> }).tiers;
28
+ return t && typeof t === "object" && !Array.isArray(t) ? t : {};
29
+ }
30
+
31
+ function dedupeSpecs(tiers: Partial<Record<TierId, TierSpec>>): Map<string, { tiers: TierId[]; spec: TierSpec }> {
32
+ const byKey = new Map<string, { tiers: TierId[]; spec: TierSpec }>();
33
+ for (const [tier, spec] of Object.entries(tiers) as Array<[TierId, TierSpec]>) {
34
+ if (!spec?.provider || !spec?.id) continue;
35
+ const key = `${spec.provider}/${spec.id}`;
36
+ const entry = byKey.get(key);
37
+ if (entry) entry.tiers.push(tier);
38
+ else byKey.set(key, { tiers: [tier], spec });
39
+ }
40
+ return byKey;
41
+ }
42
+
43
+ export async function runPreflight(opts: PreflightOpts): Promise<{ results: PreflightResult[]; tierResults: TierResults; blocked: PreflightResult | null }> {
44
+ const configTiers = opts.tiers ?? tiersFromConfig(loadConfig(opts.targetDir).config);
45
+ const unique = dedupeSpecs(configTiers);
46
+ const results: PreflightResult[] = [];
47
+ const tierResults: TierResults = {};
48
+
49
+ if (unique.size === 0) {
50
+ return { results: [], tierResults: {}, blocked: null };
51
+ }
52
+
53
+ for (const [key, entry] of unique) {
54
+ let ok = false;
55
+ let servedModel: string | undefined;
56
+ let reason: string | undefined;
57
+ try {
58
+ if (opts.probe) {
59
+ const r = await opts.probe(entry.spec);
60
+ servedModel = r.served || key;
61
+ ok = true;
62
+ } else {
63
+ // Real probe (SDK).
64
+ const { ModelRuntime, SessionManager, createAgentSession, DefaultResourceLoader, SettingsManager } = await import("@earendil-works/pi-coding-agent");
65
+ const runtime = await ModelRuntime.create();
66
+ // getModel is a registry lookup; hasConfiguredAuth guards the credential existence.
67
+ const model = runtime.getModel(entry.spec.provider, entry.spec.id);
68
+ if (!model) throw new Error(`unknown model ${key}`);
69
+ const hasAuth = typeof runtime.hasConfiguredAuth === "function" ? runtime.hasConfiguredAuth(entry.spec.provider) : true;
70
+ if (!hasAuth) {
71
+ let check: unknown = undefined;
72
+ try { check = typeof runtime.checkAuth === "function" ? await runtime.checkAuth(entry.spec.provider) : hasAuth; } catch { check = undefined; }
73
+ if (!check) throw new Error(`no credential for provider ${entry.spec.provider}`);
74
+ }
75
+ // Minimal prompt on an in-memory session.
76
+ const { resolve } = await import("node:path");
77
+ const cwd = opts.targetDir;
78
+ const agentDir = "";
79
+ const settingsManager = SettingsManager.create(cwd, agentDir);
80
+ const loader = new DefaultResourceLoader({
81
+ cwd,
82
+ agentDir,
83
+ settingsManager,
84
+ noExtensions: true,
85
+ noSkills: true,
86
+ noContextFiles: true,
87
+ } as unknown as ConstructorParameters<typeof DefaultResourceLoader>[0]);
88
+ await loader.reload();
89
+ const { session } = await createAgentSession({
90
+ model,
91
+ modelRuntime: runtime,
92
+ cwd,
93
+ resourceLoader: loader,
94
+ sessionManager: SessionManager.inMemory(cwd),
95
+ noTools: "all" as unknown as string,
96
+ thinkingLevel: "minimal" as unknown as string,
97
+ } as unknown as Parameters<typeof createAgentSession>[0]);
98
+ try {
99
+ // One-token probe — the only thing that proves the tier serves.
100
+ await session.prompt("Reply with the single word: ok");
101
+ servedModel = `${(session as { model?: { provider?: string; id?: string } }).model?.provider ?? entry.spec.provider}/${(session as { model?: { id?: string } }).model?.id ?? entry.spec.id}`;
102
+ ok = true;
103
+ } finally {
104
+ try { (session as { dispose?: () => void }).dispose?.(); } catch {}
105
+ }
106
+ }
107
+ } catch (e) {
108
+ ok = false;
109
+ reason = e instanceof Error ? e.message : String(e);
110
+ servedModel = undefined;
111
+ }
112
+ for (const tier of entry.tiers) {
113
+ const res: PreflightResult = { tier, ok, ...(servedModel ? { servedModel } : {}), ...(reason ? { reason } : {}) };
114
+ results.push(res);
115
+ tierResults[tier] = {
116
+ provider: entry.spec.provider,
117
+ id: entry.spec.id,
118
+ preflight: ok ? "ok" : "fail",
119
+ ...(servedModel ? { servedModel } : {}),
120
+ ...(reason ? { reason } : {}),
121
+ } as TierPreflight;
122
+ }
123
+ }
124
+
125
+ const blocked = results.find(r => !r.ok) ?? null;
126
+ return { results, tierResults, blocked };
127
+ }
128
+
129
+ export function formatPreflightResults(results: PreflightResult[]): string {
130
+ if (!results.length) return "no tiers configured";
131
+ return results.map(r => `${r.tier}:${r.ok ? "ok" : `fail(${r.reason ?? "unknown"})`}${r.servedModel ? `:${r.servedModel}` : ""}`).join(" | ");
132
+ }
@@ -0,0 +1,153 @@
1
+ /**
2
+ * infinity-harness — daemon/server.ts
3
+ *
4
+ * Tiny localhost HTTP for Interfaces -> Daemon control. Daemon binds port 0,
5
+ * OS picks free, daemon.json records the port+token.
6
+ *
7
+ * GET /status (open) → state
8
+ * POST /run | /halt | /pause | /resume | /approve | /replan | /rework (token-checked)
9
+ * GET /dashboard → dashboard.ts HTML
10
+ */
11
+
12
+ import { createServer, type Server } from "node:http";
13
+ import { daemonPath, runStatePath, supervisorPath, activityPath } from "../core/paths.ts";
14
+ import { readJsonSafe } from "../core/fsx.ts";
15
+ import { isDaemonAlive, loadDaemon } from "./guard.ts";
16
+
17
+ export type ServerOpts = {
18
+ targetDir: string;
19
+ port?: number;
20
+ token?: string;
21
+ onRun?: (req: unknown) => Promise<unknown>;
22
+ onHalt?: (req: unknown) => Promise<unknown>;
23
+ onPause?: (req: unknown) => Promise<unknown>;
24
+ onResume?: (req: unknown) => Promise<unknown>;
25
+ onApprove?: (req: unknown) => Promise<unknown>;
26
+ onReplan?: (req: unknown) => Promise<unknown>;
27
+ onRework?: (req: unknown) => Promise<unknown>;
28
+ onPilot?: (req: unknown) => Promise<unknown>;
29
+ };
30
+
31
+ export function startServer(opts: ServerOpts): Promise<{ server: Server; port: number; token: string }> {
32
+ const targetDir = opts.targetDir;
33
+ // token comes from daemon.json if running, or fresh for a new Daemon — caller should supply.
34
+ const expectedToken = opts.token ?? loadDaemon(targetDir)?.token ?? null;
35
+
36
+ const server = createServer(async (req, res) => {
37
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "127.0.0.1"}`);
38
+ const path = url.pathname;
39
+
40
+ const writeJson = (code: number, body: unknown): void => {
41
+ res.writeHead(code, { "Content-Type": "application/json" });
42
+ res.end(JSON.stringify(body));
43
+ };
44
+
45
+ const requireToken = (): boolean => {
46
+ if (!expectedToken) return true;
47
+ const auth = req.headers.authorization ?? "";
48
+ const queryToken = url.searchParams.get("token") ?? "";
49
+ const headerToken = auth.startsWith("Bearer ") ? auth.slice(7) : "";
50
+ const bodyToken = "";
51
+ // Token is in daemon.json; client reads it and sends via header or query.
52
+ if (headerToken === expectedToken || queryToken === expectedToken) return true;
53
+ return false;
54
+ };
55
+
56
+ if (req.method === "GET" && (path === "/status" || path === "/api/status")) {
57
+ const daemon = loadDaemon(targetDir);
58
+ const alive = isDaemonAlive(daemon);
59
+ const run = readJsonSafe<unknown>(runStatePath(targetDir), null);
60
+ const supervisor = readJsonSafe<unknown>(supervisorPath(targetDir), null);
61
+ const activity = readJsonSafe<unknown>(activityPath(targetDir), null);
62
+ writeJson(200, { ok: true, alive, daemon, run, supervisor, activity: Array.isArray(activity) ? (activity as unknown[]).slice(-20) : [] });
63
+ return;
64
+ }
65
+
66
+ if (req.method === "GET" && (path === "/dashboard" || path === "/" || path === "/api/harness")) {
67
+ // /api/harness is the JSON shape; dashboard / path serves the dashboard HTML.
68
+ if (path === "/api/harness") {
69
+ const { loadFeatureList } = await import("../core/featureList.ts");
70
+ const { list } = loadFeatureList(targetDir);
71
+ writeJson(200, { baseRevision: list.baseRevision, features: list.features, goals: list.goals, sprints: list.sprints });
72
+ return;
73
+ }
74
+ try {
75
+ const { loadFeatureList } = await import("../core/featureList.ts");
76
+ const { loadConfig } = await import("../core/config.ts");
77
+ const { renderDashboard } = await import("../ui/dashboard.ts");
78
+ const { normalizeDisplay } = await import("../ui/display.ts");
79
+ const { readJsonSafe: rjs } = await import("../core/fsx.ts");
80
+ const { list } = loadFeatureList(targetDir);
81
+ const cfg = loadConfig(targetDir).config;
82
+ const daemon = loadDaemon(targetDir);
83
+ // dashboard derives viewState same way widget does, but via server data
84
+ const run = rjs<Record<string,unknown>|null>(runStatePath(targetDir), null);
85
+ const sup = rjs<Record<string,unknown>|null>(supervisorPath(targetDir), null);
86
+ const act = rjs<unknown[]>(activityPath(targetDir), []);
87
+ const html = renderDashboard({
88
+ list,
89
+ phase: (cfg.currentPhase as unknown as import("../core/types.ts").Phase) ?? null,
90
+ enabledPhases: (cfg.phases as { enabled?: readonly string[] })?.enabled ?? null,
91
+ paused: Boolean((cfg as { paused?: boolean }).paused),
92
+ gate: null,
93
+ baseRevision: list.baseRevision,
94
+ timestamp: new Date().toISOString(),
95
+ dashboardUrl: daemon ? `http://127.0.0.1:${(daemon as { port?: number }).port ?? 0}/dashboard` : null,
96
+ handoffModelNote: null,
97
+ awaitingApproval: (cfg as unknown as { awaitingApproval?: string | null }).awaitingApproval ?? null,
98
+ display: normalizeDisplay((cfg as unknown as { display?: unknown }).display as unknown as import("../core/types.ts").DisplayPolicy | undefined),
99
+ engine: "background",
100
+ workers: sup && typeof (sup as Record<string,unknown>).name === "string" ? [sup as unknown as import("../ui/dashboard.ts").DashWorker] : [],
101
+ activity: Array.isArray(act) ? (act as unknown as import("../ui/dashboard.ts").DashActivity[]).slice(-40) : [],
102
+ });
103
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
104
+ res.end(html);
105
+ return;
106
+ } catch (e) {
107
+ // If dashboard render throws, fall back to minimal page — never 500 the only way to see the run.
108
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
109
+ res.end(`<!doctype html><title>infinity-harness</title>< meta name="viewport" content="width=device-width"><h1>infinity-harness</h1><p>Daemon at ${targetDir}</p><p><a href="/status">/status</a> <a href="/api/harness">/api/harness</a></p><p style="color:#c33">dashboard render error: ${String((e as Error).message ?? e).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]??c))}</p>`);
110
+ return;
111
+ }
112
+ }
113
+
114
+ if (req.method === "POST") {
115
+ if (!requireToken()) { writeJson(401, { ok: false, error: "invalid token" }); return; }
116
+ let body = "";
117
+ req.on("data", chunk => { body += String(chunk); if (body.length > 1_000_000) req.destroy(); });
118
+ req.on("end", async () => {
119
+ let parsed: unknown = null;
120
+ try { parsed = body ? JSON.parse(body) : {}; } catch { parsed = {}; }
121
+ try {
122
+ if (path === "/run" && opts.onRun) { const r = await opts.onRun(parsed); writeJson(200, r ?? { ok: true }); return; }
123
+ if (path === "/halt" && opts.onHalt) { const r = await opts.onHalt(parsed); writeJson(200, r ?? { ok: true }); return; }
124
+ if (path === "/pause" && opts.onPause) { const r = await opts.onPause(parsed); writeJson(200, r ?? { ok: true }); return; }
125
+ if (path === "/resume" && opts.onResume) { const r = await opts.onResume(parsed); writeJson(200, r ?? { ok: true }); return; }
126
+ if (path === "/approve" && opts.onApprove) { const r = await opts.onApprove(parsed); writeJson(200, r ?? { ok: true }); return; }
127
+ if (path === "/replan" && opts.onReplan) { const r = await opts.onReplan(parsed); writeJson(200, r ?? { ok: true }); return; }
128
+ if (path === "/rework" && opts.onRework) { const r = await opts.onRework(parsed); writeJson(200, r ?? { ok: true }); return; }
129
+ if (path === "/pilot" && opts.onPilot) { const r = await opts.onPilot(parsed); writeJson(200, r ?? { ok: true }); return; }
130
+ writeJson(404, { ok: false, error: `unknown POST ${path}` });
131
+ } catch (e) {
132
+ writeJson(500, { ok: false, error: e instanceof Error ? e.message : String(e) });
133
+ }
134
+ });
135
+ return;
136
+ }
137
+
138
+ writeJson(404, { ok: false, error: `unknown ${req.method} ${path}` });
139
+ });
140
+
141
+ return new Promise((resolve, reject) => {
142
+ server.once("error", reject);
143
+ server.listen(opts.port ?? 0, "127.0.0.1", () => {
144
+ const addr = server.address() as { port: number } | null;
145
+ const port = addr?.port ?? (opts.port ?? 0);
146
+ resolve({ server, port, token: expectedToken ?? "" });
147
+ });
148
+ });
149
+ }
150
+
151
+ export function stopServer(server: Server): Promise<void> {
152
+ return new Promise((resolve) => server.close(() => resolve()));
153
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * infinity-harness — daemon/supervisorState.ts
3
+ *
4
+ * Daemon-owned files: harness/supervisor.json (live worker) and harness/activity.json
5
+ * (last ~400 lines). Only the Daemon writes them; Interfaces only read.
6
+ *
7
+ * SupervisorView captures askedModel vs servedModel per worker — the proof of
8
+ * routing that v2.7 lacked.
9
+ */
10
+
11
+ import { supervisorPath, activityPath } from "../core/paths.ts";
12
+ import { readJsonSafe, writeJsonAtomic, ensureDir } from "../core/fsx.ts";
13
+ import { existsSync, unlinkSync } from "node:fs";
14
+ import { dirname } from "node:path";
15
+
16
+ export const ACTIVITY_LIMIT = 400;
17
+
18
+ export type ActivityLevel = "info" | "work" | "warn" | "error" | "good";
19
+
20
+ export type ActivityLine = {
21
+ at: string;
22
+ level: ActivityLevel;
23
+ worker: string | null;
24
+ text: string;
25
+ };
26
+
27
+ export type SupervisorWorker = {
28
+ name: string;
29
+ unitKey: string;
30
+ unitLabel: string;
31
+ level: string;
32
+ difficulty: string | null;
33
+ model: string;
34
+ askedModel: string;
35
+ servedModel: string | null;
36
+ thinking: string;
37
+ state: "starting" | "working" | "idle" | "closed" | "failed";
38
+ doing: string | null;
39
+ startedAt: string;
40
+ turns: number;
41
+ recycles: number;
42
+ tokens: { input: number; output: number; cacheRead?: number; cacheWrite?: number; cost?: number; calls?: number };
43
+ contextRatio: number | null;
44
+ sessionId: string | null;
45
+ unit?: string;
46
+ };
47
+
48
+ export type SupervisorState = {
49
+ runId: string;
50
+ updatedAt: string;
51
+ worker: SupervisorWorker | null;
52
+ workers?: SupervisorWorker[];
53
+ };
54
+
55
+ export function loadSupervisor(targetDir: string): SupervisorState | null {
56
+ return readJsonSafe<SupervisorState | null>(supervisorPath(targetDir), null);
57
+ }
58
+
59
+ export function saveSupervisor(targetDir: string, state: SupervisorState): void {
60
+ ensureDir(dirname(supervisorPath(targetDir)));
61
+ writeJsonAtomic(supervisorPath(targetDir), { ...state, updatedAt: new Date().toISOString() });
62
+ }
63
+
64
+ export function loadActivity(targetDir: string): ActivityLine[] {
65
+ const raw = readJsonSafe<ActivityLine[] | null>(activityPath(targetDir), null);
66
+ return Array.isArray(raw) ? raw : [];
67
+ }
68
+
69
+ export function appendActivity(targetDir: string, line: Omit<ActivityLine, "at"> & { at?: string }): ActivityLine {
70
+ const entry: ActivityLine = { at: line.at ?? new Date().toISOString(), level: line.level, worker: line.worker ?? null, text: line.text };
71
+ const cur = loadActivity(targetDir);
72
+ const next = [...cur, entry].slice(-ACTIVITY_LIMIT);
73
+ ensureDir(dirname(activityPath(targetDir)));
74
+ writeJsonAtomic(activityPath(targetDir), next);
75
+ return entry;
76
+ }
77
+
78
+ export function clearSupervisor(targetDir: string): void {
79
+ try {
80
+ const p = supervisorPath(targetDir);
81
+ if (existsSync(p)) unlinkSync(p);
82
+ } catch {}
83
+ }