infinity-harness 2.7.0 → 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.
@@ -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
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * infinity-harness — daemon/worker.ts
3
+ *
4
+ * One AgentSession: create → prompt → settle → dispose, plus the
5
+ * events->TurnResult adapter. prompt() returns void in the SDK, so every
6
+ * turn field (servedModel, usage, tools, summary, contextRatio, compacted)
7
+ * comes via subscribe().
8
+ *
9
+ * v2.7's WorkerSession.prompt returned TurnResult; the SDK's prompt returns void.
10
+ * This file rebuilds the same shape by accumulating events.
11
+ */
12
+
13
+ // Usage type is from pi session — use a loose shape to avoid hard dep on pi-ai path
14
+ type Usage = { input?: number; output?: number; inputTokens?: number; outputTokens?: number; cacheRead?: number; cacheWrite?: number; cost?: number };
15
+
16
+ export type TurnResult = {
17
+ summary: string;
18
+ tools: Array<{ name: string; ok: boolean }>;
19
+ usage: { input: number; output: number; cacheRead?: number; cacheWrite?: number; cost?: number };
20
+ contextRatio: number | null;
21
+ servedModel: string | null;
22
+ askedModel: string;
23
+ compacted: boolean;
24
+ aborted: boolean;
25
+ error: string | null;
26
+ modelFallbackMessage?: string | null;
27
+ };
28
+
29
+ export type WorkerEvents = {
30
+ onMessageStart?: (ev: unknown) => void;
31
+ onMessageUpdate?: (ev: unknown) => void;
32
+ onMessageEnd?: (ev: unknown) => void;
33
+ onToolStart?: (ev: unknown) => void;
34
+ onToolEnd?: (ev: unknown) => void;
35
+ onCompactionStart?: (ev: unknown) => void;
36
+ };
37
+
38
+ export type CreateWorkerOpts = {
39
+ cwd: string;
40
+ agentDir?: string;
41
+ modelSpec: { provider: string; id: string; thinkingLevel?: string };
42
+ askedModel: string;
43
+ sessionManagerDir?: string;
44
+ customTools?: unknown[];
45
+ thinkingLevel?: string;
46
+ resourceLoader?: unknown;
47
+ runId?: string;
48
+ unitKey?: string;
49
+ isolationBypassForTest?: boolean;
50
+ };
51
+
52
+ export type PromptOpts = {
53
+ text: string;
54
+ timeoutMs?: number;
55
+ };
56
+
57
+ /**
58
+ * Create one SDK AgentSession with harness-free loader + customTools.
59
+ * Returns session + unsubscribe + dispose handles.
60
+ */
61
+ export async function createWorker(opts: CreateWorkerOpts): Promise<{
62
+ session: unknown;
63
+ unsubscribe: () => void;
64
+ dispose: () => void;
65
+ modelFallbackMessage?: string | null;
66
+ events: { servedModel: string | null; usage: Usage | null; tools: TurnResult["tools"]; summary: string; compacted: boolean };
67
+ }> {
68
+ const mod = await import("@earendil-works/pi-coding-agent");
69
+ const { createAgentSession, ModelRuntime, SessionManager, DefaultResourceLoader, SettingsManager } = mod as unknown as {
70
+ createAgentSession: (opts: unknown) => Promise<{ session: unknown; modelFallbackMessage?: string | null; extensionsResult?: unknown }>;
71
+ ModelRuntime: { create: () => Promise<{ getModel: (p: string, id: string) => unknown; hasConfiguredAuth: (p: string) => boolean; checkAuth: (p: string) => Promise<unknown> }> };
72
+ SessionManager: { create: (cwd: string, dir?: string) => unknown; inMemory: (cwd: string) => unknown };
73
+ DefaultResourceLoader: new (opts: unknown) => { reload: () => Promise<void> };
74
+ SettingsManager: { create: (cwd: string, agentDir?: string) => unknown };
75
+ };
76
+
77
+ const runtime = await ModelRuntime.create();
78
+ const model = runtime.getModel(opts.modelSpec.provider, opts.modelSpec.id);
79
+ if (!model) throw new Error(`unknown model ${opts.modelSpec.provider}/${opts.modelSpec.id}`);
80
+
81
+ let loader: unknown = opts.resourceLoader;
82
+ if (!loader && !opts.isolationBypassForTest) {
83
+ const sm = SettingsManager.create(opts.cwd, opts.agentDir);
84
+ const l = new DefaultResourceLoader({
85
+ cwd: opts.cwd,
86
+ agentDir: opts.agentDir ?? "",
87
+ settingsManager: sm,
88
+ noExtensions: true,
89
+ noSkills: true,
90
+ noPromptTemplates: true,
91
+ noThemes: true,
92
+ noContextFiles: true,
93
+ } as unknown as ConstructorParameters<typeof DefaultResourceLoader>[0]);
94
+ await l.reload();
95
+ loader = l;
96
+ }
97
+
98
+ // Verify isolation: if loader discovery includes harness, fail fast (unless bypassed for test).
99
+ if (!opts.isolationBypassForTest && loader && typeof (loader as { getExtensions?: () => unknown }).getExtensions === "function") {
100
+ try {
101
+ const ext = (loader as { getExtensions: () => { extensions?: Array<{ id?: string }> } }).getExtensions();
102
+ const found = (ext?.extensions ?? []).filter(e => String(e?.id ?? "").includes("infinity-harness"));
103
+ if (found.length) throw new Error(`isolation violated: loader has ${found.length} harness extension(s)`);
104
+ } catch (e) {
105
+ if (e instanceof Error && e.message.includes("isolation violated")) throw e;
106
+ }
107
+ }
108
+
109
+ const sessionManager = opts.sessionManagerDir
110
+ ? SessionManager.create(opts.cwd, opts.sessionManagerDir)
111
+ : SessionManager.inMemory(opts.cwd);
112
+
113
+ const thinkingLevel = (opts.thinkingLevel ?? opts.modelSpec.thinkingLevel ?? "medium") as unknown as string;
114
+
115
+ const agentTools = opts.customTools as unknown as import("@earendil-works/pi-coding-agent").ToolDefinition[] | undefined;
116
+
117
+ const created = await createAgentSession({
118
+ model: model as never,
119
+ modelRuntime: runtime as never,
120
+ cwd: opts.cwd,
121
+ thinkingLevel: thinkingLevel as never,
122
+ resourceLoader: loader as never,
123
+ customTools: agentTools as never,
124
+ sessionManager: sessionManager as never,
125
+ } as never);
126
+
127
+ const session = created.session as {
128
+ subscribe: (fn: (ev: { type: string; [k: string]: unknown }) => void) => () => void;
129
+ prompt: (text: string, opts?: unknown) => Promise<void>;
130
+ steer: (text: string) => Promise<void>;
131
+ dispose: () => void;
132
+ abort?: () => void;
133
+ model?: { provider?: string; id?: string };
134
+ };
135
+
136
+ if (created.modelFallbackMessage) throw new Error(`modelFallbackMessage: ${created.modelFallbackMessage}`);
137
+
138
+ const state = { servedModel: null as string | null, usage: null as Usage | null, tools: [] as TurnResult["tools"], summary: "", compacted: false };
139
+
140
+ const unsubscribe = session.subscribe((ev: { type: string; [k: string]: unknown }) => {
141
+ if (ev.type === "message_start") {
142
+ // message_start carries provider/model in some SDK versions
143
+ const prov = (ev as { provider?: string }).provider ?? (ev as { model?: { provider?: string } }).model?.provider;
144
+ const mid = (ev as { modelId?: string }).modelId ?? (ev as { model?: { id?: string } }).model?.id;
145
+ if (prov || mid) state.servedModel = `${prov ?? "?"}:${mid ?? "?"}`;
146
+ } else if (ev.type === "message_end") {
147
+ // usage is cumulative per session
148
+ const usage = (ev as { usage?: Usage }).usage;
149
+ if (usage) state.usage = usage as Usage;
150
+ const text = (ev as { content?: unknown }).content ?? (ev as { text?: string }).text;
151
+ if (typeof text === "string" && text) state.summary = text;
152
+ else if (Array.isArray((ev as { content?: unknown }).content)) {
153
+ const c = (ev as { content?: unknown }).content as Array<{ type?: string; text?: string }>;
154
+ const t = c.filter(x => x?.type === "text").map(x => x.text ?? "").join("\n");
155
+ if (t) state.summary = t;
156
+ }
157
+ } else if (ev.type === "tool_execution_start" || ev.type === "tool_execution_end") {
158
+ const name = String((ev as { toolName?: string }).toolName ?? (ev as { name?: string }).name ?? "tool");
159
+ const ok = ev.type === "tool_execution_end" ? ((ev as { ok?: boolean }).ok ?? true) : true;
160
+ // dedupe: keep last ok per name per end event
161
+ if (ev.type === "tool_execution_end") state.tools.push({ name, ok: Boolean(ok) });
162
+ else if (ev.type === "tool_execution_start") state.tools.push({ name, ok: true });
163
+ } else if (ev.type === "compaction_start") {
164
+ state.compacted = true;
165
+ } else if (ev.type === "entry_appended") {
166
+ // session entries include model_change and compaction; capture compaction usage
167
+ const entry = (ev as { entry?: { type?: string; usage?: Usage } }).entry;
168
+ if (entry?.type === "compaction" && entry.usage) state.compacted = true;
169
+ }
170
+ });
171
+
172
+ return {
173
+ session,
174
+ unsubscribe,
175
+ dispose: () => { try { unsubscribe(); } catch {} try { session.dispose(); } catch {} },
176
+ modelFallbackMessage: created.modelFallbackMessage ?? null,
177
+ events: state,
178
+ };
179
+ }
180
+
181
+ export async function promptWorker(
182
+ worker: { session: { prompt: (t: string, o?: unknown) => Promise<void> }; events: { servedModel: string | null; usage: Usage | null; tools: TurnResult["tools"]; summary: string; compacted: boolean } },
183
+ opts: PromptOpts,
184
+ ): Promise<TurnResult> {
185
+ // We need the askedModel — fall back to events.servedModel if not known.
186
+ const askedModel = "asked";
187
+ const startedUsage = worker.events.usage;
188
+ try {
189
+ const timeoutMs = opts.timeoutMs ?? 30 * 60 * 1000;
190
+ let settled = false;
191
+ const timer = timeoutMs > 0 ? setTimeout(() => { if (!settled) try { (worker.session as { abort?: () => void }).abort?.(); } catch {} }, timeoutMs) : null;
192
+ try {
193
+ await worker.session.prompt(opts.text);
194
+ settled = true;
195
+ } finally {
196
+ if (timer) clearTimeout(timer);
197
+ settled = true;
198
+ }
199
+ } catch (e) {
200
+ const msg = e instanceof Error ? e.message : String(e);
201
+ // CredentialSynchronizationError is handled by caller; bubble it.
202
+ if (msg.includes("CredentialSynchronizationError") || (e as { name?: string })?.name === "CredentialSynchronizationError") throw e;
203
+ return {
204
+ summary: worker.events.summary,
205
+ tools: worker.events.tools,
206
+ usage: toUsageTotals(worker.events.usage, startedUsage),
207
+ contextRatio: null,
208
+ servedModel: worker.events.servedModel,
209
+ askedModel,
210
+ compacted: worker.events.compacted,
211
+ aborted: msg.toLowerCase().includes("abort"),
212
+ error: msg,
213
+ };
214
+ }
215
+ return {
216
+ summary: worker.events.summary,
217
+ tools: worker.events.tools,
218
+ usage: toUsageTotals(worker.events.usage, startedUsage),
219
+ contextRatio: null,
220
+ servedModel: worker.events.servedModel,
221
+ askedModel,
222
+ compacted: worker.events.compacted,
223
+ aborted: false,
224
+ error: null,
225
+ };
226
+ }
227
+
228
+ function toUsageTotals(cur: Usage | null, _prev: Usage | null): { input: number; output: number; cacheRead?: number; cacheWrite?: number; cost?: number } {
229
+ if (!cur) return { input: 0, output: 0 };
230
+ // pi usage is cumulative per session; the last reading IS the total.
231
+ const input = typeof (cur as { input?: number }).input === "number" ? (cur as { input: number }).input
232
+ : typeof (cur as { inputTokens?: number }).inputTokens === "number" ? (cur as unknown as { inputTokens: number }).inputTokens : 0;
233
+ const output = typeof (cur as { output?: number }).output === "number" ? (cur as { output: number }).output
234
+ : typeof (cur as { outputTokens?: number }).outputTokens === "number" ? (cur as unknown as { outputTokens: number }).outputTokens : 0;
235
+ const cacheRead = (cur as { cacheRead?: number }).cacheRead ?? 0;
236
+ const cacheWrite = (cur as { cacheWrite?: number }).cacheWrite ?? 0;
237
+ const cost = (cur as { cost?: number }).cost ?? 0;
238
+ return { input, output, cacheRead, cacheWrite, cost };
239
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * infinity-harness — daemon/worktree.ts
3
+ *
4
+ * Git worktree per concurrent worker. Gate in worktree, merge lock, unlock.
5
+ *
6
+ * v3.0 is sequential (maxWorkers:1) — this file is the isolation that will be
7
+ * used when we lift that. Kept isolated in its own module so it cannot leak
8
+ * into the single-owner path prematurely.
9
+ */
10
+
11
+ import { existsSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { worktreePath, worktreesDir } from "../core/paths.ts";
14
+ import { run } from "../core/exec.ts";
15
+ import { ensureDir } from "../core/fsx.ts";
16
+
17
+ export async function isWorktreeSupported(targetDir: string): Promise<boolean> {
18
+ const r = await run("git rev-parse --is-inside-work-tree", { cwd: targetDir, timeoutMs: 10_000 });
19
+ return r.ok && r.stdout.trim() === "true";
20
+ }
21
+
22
+ export async function createWorktree(targetDir: string, branch: string): Promise<{ path: string; error: string | null }> {
23
+ if (!await isWorktreeSupported(targetDir)) return { path: "", error: "not a git repo — cannot create worktree" };
24
+ const worktree = worktreePath(targetDir, branch);
25
+ if (existsSync(worktree)) return { path: worktree, error: null };
26
+ try { ensureDir(worktreesDir(targetDir)); } catch {}
27
+ // Safe to create on current HEAD: `git worktree add <path>` with no branch creates detached worktree
28
+ const r = await run(`git worktree add --detach "${worktree.replace(/"/g, '\\"')}"`, { cwd: targetDir, timeoutMs: 30_000 });
29
+ if (!r.ok) return { path: "", error: (r.stderr || r.stdout || r.spawnError || `git worktree add exited ${r.code}`).slice(0, 400) };
30
+ return { path: worktree, error: null };
31
+ }
32
+
33
+ export async function removeWorktree(targetDir: string, branch: string): Promise<{ ok: boolean; error: string | null }> {
34
+ const worktree = worktreePath(targetDir, branch);
35
+ if (!existsSync(worktree)) return { ok: true, error: null };
36
+ const r = await run(`git worktree remove --force "${worktree.replace(/"/g, '\\"')}"`, { cwd: targetDir, timeoutMs: 30_000 });
37
+ if (!r.ok && !r.stderr?.includes("not a valid path")) return { ok: false, error: (r.stderr || r.stdout || r.spawnError || `exit ${r.code}`).slice(0,400) };
38
+ // Also prune any stale branch/worktree registration
39
+ await run("git worktree prune", { cwd: targetDir, timeoutMs: 15_000 });
40
+ return { ok: true, error: null };
41
+ }
42
+
43
+ export async function removeAllWorktrees(targetDir: string): Promise<void> {
44
+ await run("git worktree prune", { cwd: targetDir, timeoutMs: 15_000 });
45
+ // Best-effort remove each directory under harness/worktrees
46
+ const root = worktreesDir(targetDir);
47
+ if (!existsSync(root)) return;
48
+ let entries: string[] = [];
49
+ try { const { readdirSync } = await import("node:fs"); entries = readdirSync(root); } catch {}
50
+ for (const e of entries) {
51
+ await removeWorktree(targetDir, e);
52
+ }
53
+ }
54
+
55
+ export async function gateInWorktree(targetDir: string, worktree: string, phase: string): Promise<{ pass: boolean; reason?: string }> {
56
+ const { runChecks } = await import("../core/gates.ts");
57
+ const g = await runChecks(worktree, phase as never, { record: false });
58
+ if (g.overall) return { pass: true };
59
+ return { pass: false, reason: g.failures.join(", ") };
60
+ }
61
+
62
+ export async function mergeWorktreeBranch(targetDir: string, branch: string, worktree: string, gatePhase?: string): Promise<{ ok: boolean; conflict?: boolean; reason?: string }> {
63
+ // We use a harness/<unit> branch name when creating the worktree. Merge with `git merge`.
64
+ const branchName = `harness/${branch}`;
65
+ // Ensure branch exists (worktree add --detach doesn't create one). Create branch from worktree HEAD.
66
+ const currentHead = (await run(`git -C "${worktree.replace(/"/g,'\\"')}" rev-parse HEAD`, { cwd: targetDir, timeoutMs: 10_000 })).stdout?.trim() ?? null;
67
+ if (currentHead) {
68
+ const create = await run(`git branch "${branchName}" "${currentHead}"`, { cwd: targetDir, timeoutMs: 15_000 });
69
+ // exists is ok — we will merge the branch if already there.
70
+ if (!create.ok && !(create.stderr||"").includes("already exists")) {
71
+ // best-effort: continue to merge attempt
72
+ }
73
+ }
74
+ const merge = await run(`git merge --no-ff --no-edit "${branchName}"`, { cwd: targetDir, timeoutMs: 30_000 });
75
+ if (merge.ok) {
76
+ await run(`git branch -D "${branchName}"`, { cwd: targetDir, timeoutMs: 10_000 });
77
+ const { runChecks: _runChecks } = await import("../core/gates.ts");
78
+ const { loadConfig } = await import("../core/config.ts");
79
+ const phase = gatePhase ?? ((loadConfig(targetDir).config?.currentPhase as unknown as string) ?? "build");
80
+ const post = await _runChecks(targetDir, phase as never, { record: false });
81
+ if (!post.overall) {
82
+ await run("git reset --hard HEAD~1", { cwd: targetDir, timeoutMs: 15_000 }).catch(()=>null as never);
83
+ return { ok: false, conflict: false, reason: `post-merge gate FAIL: ${post.failures.join(", ")}` };
84
+ }
85
+ return { ok: true };
86
+ }
87
+ const out = (merge.stdout ?? "") + "\n" + (merge.stderr ?? "");
88
+ const conflict = /conflict/i.test(out);
89
+ if (conflict) {
90
+ await run("git merge --abort", { cwd: targetDir, timeoutMs: 10_000 }).catch(()=>null as never);
91
+ return { ok: false, conflict: true, reason: out.slice(0,500) };
92
+ }
93
+ await run("git merge --abort", { cwd: targetDir, timeoutMs: 10_000 }).catch(()=>null as never);
94
+ return { ok: false, conflict: false, reason: out.slice(0,500) };
95
+ }
@@ -26,7 +26,7 @@
26
26
  * pi and a stream of events. It has no idea what a phase is.
27
27
  */
28
28
 
29
- import { spawn, type ChildProcess } from "node:child_process";
29
+ import { spawn, execFileSync, type ChildProcess } from "node:child_process";
30
30
  import { createRequire } from "node:module";
31
31
  import { existsSync, mkdirSync, writeFileSync, appendFileSync } from "node:fs";
32
32
  import { join, resolve, dirname } from "node:path";
@@ -137,7 +137,6 @@ export function resolvePiCli(env: NodeJS.ProcessEnv = process.env, argv: string[
137
137
  /** Where a command lives, or null. Sync on purpose: it runs once per worker. */
138
138
  function whichSync(name: string): string | null {
139
139
  try {
140
- const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
141
140
  const cmd = process.platform === "win32" ? "where" : "which";
142
141
  const out = String(execFileSync(cmd, [name], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }));
143
142
  const first = out.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)[0];
package/src/goalState.ts CHANGED
@@ -9,9 +9,8 @@ import {
9
9
  validateGoalLoopState,
10
10
  } from "./goalLoop.ts";
11
11
  import { type GoalSpecification, validateGoalSpecification } from "./goalSpec.ts";
12
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
13
13
  import { dirname, resolve } from "node:path";
14
- declare const require: any;
15
14
 
16
15
  export const GOAL_STATE_FILE = "GOAL_STATE.json";
17
16
  export const GOAL_TRACE_FILE = "GOAL_TRACE.jsonl";
@@ -25,26 +24,7 @@ export function canonicalGoalSpecPath(projectDir = process.cwd()): string {
25
24
  return resolve(projectDir, CANONICAL_GOAL_SPEC_DIR, CANONICAL_GOAL_SPEC_FILE);
26
25
  }
27
26
 
28
- function writeCanonicalWithLockSync(projectDir: string, content: string): void {
29
- const target = canonicalGoalSpecPath(projectDir);
30
- // try proper-lockfile sync-ish via dynamic import fallback to plain write
31
- try {
32
- mkdirSync(dirname(target), { recursive: true });
33
- // use proper-lockfile if available (async variant would need async; use sync file write with lock attempt)
34
- // For sync canonical we rely on atomic tmp+rename and ignore lock if unavailable — async wrapper below handles lock
35
- const tmp = `${target}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
36
- writeFileSync(tmp, content, "utf8");
37
- // rename via node:fs renameSync equivalent (import already has rename async, but we use writeFileSync+rename via fs)
38
- const { renameSync } = require("node:fs");
39
- renameSync(tmp, target);
40
- } catch {
41
- // fallback simple write
42
- try {
43
- mkdirSync(dirname(target), { recursive: true });
44
- writeFileSync(target, content, "utf8");
45
- } catch {}
46
- }
47
- }
27
+
48
28
 
49
29
 
50
30
  export interface GoalStateStoreOptions {
package/src/intake.ts CHANGED
@@ -177,7 +177,7 @@ export function planIntake(answers: IntakeAnswers): IntakePlan {
177
177
  plan: phaseModes.plan === "copilot",
178
178
  },
179
179
  session,
180
- execution: { engine, parallelAt, maxWorkers },
180
+ execution: { engine, parallelAt, maxWorkers, isolation: "worktree" as const },
181
181
  display,
182
182
  router: answers.router,
183
183
  summary: summarize(workflow, phases, phaseModes, session, display, brief, _researchDepth),
File without changes
package/src/remote.ts CHANGED
@@ -27,6 +27,7 @@ import { loadRunState } from "./runState.ts";
27
27
  import { normalizeDisplay } from "./ui/display.ts";
28
28
  import { supervisorStatePath, activityPath } from "./supervisor.ts";
29
29
  import { renderDashboard, escapeHtml, type DashboardState } from "./ui/dashboard.ts";
30
+ import { executionPolicyOf } from "./scheduler.ts";
30
31
 
31
32
  export { escapeHtml };
32
33
 
@@ -125,8 +126,8 @@ export function buildRemoteState(projectDir?: string): RemoteState {
125
126
  rework: readJsonSafe<unknown>(reworkPath(dir), null),
126
127
  awaitingApproval: config.awaitingApproval ?? null,
127
128
  sessions: loadRunState(dir)?.sessions ?? null,
128
- execution: (() => { try { const { executionPolicyOf } = require("./scheduler.ts"); return executionPolicyOf(config); } catch { return null; } })(),
129
- engine: (() => { try { const { executionPolicyOf } = require("./scheduler.ts"); return executionPolicyOf(config).engine as "background" | "main-session"; } catch { return null; } })(),
129
+ execution: (() => { try { return executionPolicyOf(config); } catch { return null; } })(),
130
+ engine: (() => { try { return executionPolicyOf(config).engine as "background" | "main-session"; } catch { return null; } })(),
130
131
  // The supervisor's own state is the truth about what is running. This used
131
132
  // to scan the attempt-directory tree, which reported every attempt ever
132
133
  // made as a live worker.