infinity-harness 2.7.0 → 2.8.1

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,151 @@
1
+ /**
2
+ * infinity-harness — core/runState.ts (Core owns type + path, Daemon owns writes).
3
+ *
4
+ * v2.7's `src/runState.ts` had { armed, runId, startedAt, sessions, stoppedAt, stopReason }.
5
+ * v3 extends it with: baseModel, tiers (preflight results), budget (byTier UsageTotals + caps).
6
+ * Existing fields are kept; the file stays `harness/run.json`.
7
+ *
8
+ * Core owns the type, the path spelling, and the read helpers used by Interfaces.
9
+ * Daemon owns the writes (arm, heartbeat, budget, preflight) — but the types
10
+ * are here so Interfaces and Core can read the same truth without importing
11
+ * the Daemon.
12
+ */
13
+
14
+ import { runStatePath } from "./paths.ts";
15
+ import { readJsonSafe, writeJsonAtomic, removeFile } from "./fsx.ts";
16
+
17
+ export type ProviderModel = { provider: string; id: string; thinkingLevel?: string };
18
+
19
+ export type TierPreflight = { provider: string; id: string; preflight: "ok" | "fail"; servedModel?: string; reason?: string };
20
+
21
+ export type TierResults = Partial<Record<"A" | "B" | "C" | "D" | "X", TierPreflight>>;
22
+
23
+ export type UsageTotals = { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; calls: number };
24
+
25
+ export function createUsageTotals(): UsageTotals {
26
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, calls: 0 };
27
+ }
28
+
29
+ export type Budget = {
30
+ byTier: Partial<Record<"A" | "B" | "C" | "D" | "X", UsageTotals>>;
31
+ cap: { totalTokens?: number | null; costUsd?: number | null; wallClockMs?: number | null };
32
+ stopOnExhaustion?: boolean;
33
+ };
34
+
35
+ export type RunState = {
36
+ armed: boolean;
37
+ runId: string;
38
+ startedAt: string;
39
+ sessions: number;
40
+ stoppedAt: string | null;
41
+ stopReason: string | null;
42
+ /** Captured from ctx.model at arm time. The detached Daemon has no ctx. */
43
+ baseModel: ProviderModel | null;
44
+ /** Preflight outcome per tier. A failing tier blocks arming. */
45
+ tiers: TierResults;
46
+ /** Per-tier spend and caps. X outside consultation is a defect signal, not a budget. */
47
+ budget: Budget;
48
+ wallClockMs?: number;
49
+ escalation?: { level: string | null; since: string | null };
50
+ };
51
+
52
+ export function newRunState(runId: string, now = new Date()): RunState {
53
+ return {
54
+ armed: true,
55
+ runId,
56
+ startedAt: now.toISOString(),
57
+ sessions: 1,
58
+ stoppedAt: null,
59
+ stopReason: null,
60
+ baseModel: null,
61
+ tiers: {},
62
+ budget: { byTier: {}, cap: {}, stopOnExhaustion: true },
63
+ };
64
+ }
65
+
66
+ export function loadRunState(targetDir: string): RunState | null {
67
+ const raw = readJsonSafe<Record<string, unknown> | null>(runStatePath(targetDir), null);
68
+ if (!raw || typeof raw.runId !== "string" || !raw.runId) return null;
69
+ const sessions = typeof raw.sessions === "number" && (raw.sessions as number) > 0 ? (raw.sessions as number) : 1;
70
+ // Back-compat: older file had no baseModel/tiers/budget — treat as null/empty.
71
+ const baseModel = (() => {
72
+ const bm = (raw as Record<string, unknown>).baseModel;
73
+ if (!bm || typeof bm !== "object" || Array.isArray(bm)) return null;
74
+ const b = bm as Record<string, unknown>;
75
+ if (typeof b.provider === "string" && typeof b.id === "string") return { provider: String(b.provider), id: String(b.id), ...(typeof b.thinkingLevel === "string" ? { thinkingLevel: b.thinkingLevel } : {}) } as ProviderModel;
76
+ // legacy: baseModel was a string "provider/id"
77
+ if (typeof raw.baseModel === "string" && String(raw.baseModel).trim()) {
78
+ const s = String(raw.baseModel).trim();
79
+ const parts = s.split("/");
80
+ if (parts.length >= 2) return { provider: parts[0]!, id: parts.slice(1).join("/") };
81
+ return { provider: "anthropic", id: s };
82
+ }
83
+ return null;
84
+ })();
85
+ const tiersRaw = (raw as Record<string, unknown>).tiers;
86
+ const tiers: TierResults = (tiersRaw && typeof tiersRaw === "object" && !Array.isArray(tiersRaw) ? tiersRaw : {}) as TierResults;
87
+ const budgetRaw = (raw as Record<string, unknown>).budget;
88
+ const budget: Budget = (budgetRaw && typeof budgetRaw === "object" && !Array.isArray(budgetRaw)
89
+ ? budgetRaw as Budget
90
+ : { byTier: {}, cap: {}, stopOnExhaustion: true });
91
+ if (!budget.byTier || typeof budget.byTier !== "object") budget.byTier = {};
92
+ if (!budget.cap || typeof budget.cap !== "object") budget.cap = {};
93
+ return {
94
+ armed: raw.armed === true,
95
+ runId: raw.runId as string,
96
+ startedAt: typeof raw.startedAt === "string" ? (raw.startedAt as string) : new Date(0).toISOString(),
97
+ sessions,
98
+ stoppedAt: typeof raw.stoppedAt === "string" ? (raw.stoppedAt as string) : null,
99
+ stopReason: typeof raw.stopReason === "string" ? (raw.stopReason as string) : null,
100
+ baseModel,
101
+ tiers,
102
+ budget,
103
+ ...(typeof (raw as Record<string, unknown>).wallClockMs === "number" ? { wallClockMs: (raw as Record<string, unknown>).wallClockMs as number } : {}),
104
+ ...(typeof (raw as Record<string, unknown>).escalation === "object" ? { escalation: (raw as Record<string, unknown>).escalation as RunState["escalation"] } : {}),
105
+ };
106
+ }
107
+
108
+ export function saveRunState(targetDir: string, state: RunState): void {
109
+ try { writeJsonAtomic(runStatePath(targetDir), state); } catch { /* run bookkeeping must not kill the session */ }
110
+ }
111
+
112
+ export function armRun(targetDir: string, runId: string, now = new Date()): RunState {
113
+ const existing = loadRunState(targetDir);
114
+ const state = existing && existing.armed ? { ...existing, stoppedAt: null, stopReason: null } : newRunState(runId, now);
115
+ saveRunState(targetDir, state);
116
+ return state;
117
+ }
118
+
119
+ export function disarmRun(targetDir: string, reason: string, now = new Date()): RunState | null {
120
+ const existing = loadRunState(targetDir);
121
+ if (!existing) return null;
122
+ const state: RunState = { ...existing, armed: false, stoppedAt: now.toISOString(), stopReason: reason };
123
+ saveRunState(targetDir, state);
124
+ return state;
125
+ }
126
+
127
+ export function countSession(targetDir: string): RunState | null {
128
+ const existing = loadRunState(targetDir);
129
+ if (!existing) return null;
130
+ const state = { ...existing, sessions: existing.sessions + 1 };
131
+ saveRunState(targetDir, state);
132
+ return state;
133
+ }
134
+
135
+ export function clearRunState(targetDir: string): void {
136
+ try { removeFile(runStatePath(targetDir)); } catch { /* nothing to clear */ }
137
+ }
138
+
139
+ export function runIdFor(targetDir: string, fallback: string): string {
140
+ const state = loadRunState(targetDir);
141
+ return state && state.armed ? state.runId : fallback;
142
+ }
143
+
144
+ /** Parse a legacy baseModel string "provider/id" into a ProviderModel. */
145
+ export function parseBaseModelString(s: string | null | undefined): ProviderModel | null {
146
+ if (!s || !String(s).trim()) return null;
147
+ const str = String(s).trim();
148
+ const parts = str.split("/");
149
+ if (parts.length >= 2) return { provider: parts[0]!, id: parts.slice(1).join("/") };
150
+ return { provider: "anthropic", id: str };
151
+ }
@@ -188,6 +188,125 @@ export const SETTINGS: SettingsGroup[] = [
188
188
  help: "A paused pipeline refuses to advance and stops the continuous run.",
189
189
  type: { kind: "boolean" },
190
190
  },
191
+ {
192
+ path: "pilot",
193
+ file: "config",
194
+ label: "Pilot",
195
+ help: "Run-level preset over per-phase modes. copilot every phase stops, autopilot builds without you, full is hands-off intake->SHIP.",
196
+ type: { kind: "choice", choices: ["copilot", "autopilot", "full"] },
197
+ },
198
+ {
199
+ path: "tiers.A.provider",
200
+ file: "config",
201
+ label: "Tier A provider",
202
+ help: "Provider for general harness work (orchestration, short-lived A workers).",
203
+ type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
204
+ },
205
+ {
206
+ path: "tiers.A.id",
207
+ file: "config",
208
+ label: "Tier A model",
209
+ help: "Model id for tier A general work.",
210
+ type: { kind: "text", placeholder: "claude-sonnet-4-5", allowEmpty: true },
211
+ },
212
+ {
213
+ path: "tiers.B.provider",
214
+ file: "config",
215
+ label: "Tier B provider",
216
+ help: "Provider for easy tasks.",
217
+ type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
218
+ },
219
+ {
220
+ path: "tiers.B.id",
221
+ file: "config",
222
+ label: "Tier B model",
223
+ help: "Model id for B (easy). Empty means use baseModel X.",
224
+ type: { kind: "text", placeholder: "claude-sonnet-4-5", allowEmpty: true },
225
+ },
226
+ {
227
+ path: "tiers.C.provider",
228
+ file: "config",
229
+ label: "Tier C provider",
230
+ help: "Provider for moderate tasks.",
231
+ type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
232
+ },
233
+ {
234
+ path: "tiers.C.id",
235
+ file: "config",
236
+ label: "Tier C model",
237
+ help: "Model id for C (moderate).",
238
+ type: { kind: "text", placeholder: "claude-opus-4-5", allowEmpty: true },
239
+ },
240
+ {
241
+ path: "tiers.D.provider",
242
+ file: "config",
243
+ label: "Tier D provider",
244
+ help: "Provider for difficult tasks.",
245
+ type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
246
+ },
247
+ {
248
+ path: "tiers.D.id",
249
+ file: "config",
250
+ label: "Tier D model",
251
+ help: "Model id for D (difficult).",
252
+ type: { kind: "text", placeholder: "claude-opus-4-5", allowEmpty: true },
253
+ },
254
+ {
255
+ path: "tiers.X.provider",
256
+ file: "config",
257
+ label: "Tier X provider",
258
+ help: "Provider for consultation / escalation (strongest).",
259
+ type: { kind: "text", placeholder: "anthropic", allowEmpty: true },
260
+ },
261
+ {
262
+ path: "tiers.X.id",
263
+ file: "config",
264
+ label: "Tier X model",
265
+ help: "Model id for X (consultation).",
266
+ type: { kind: "text", placeholder: "claude-opus-4-5", allowEmpty: true },
267
+ },
268
+ {
269
+ path: "limits.unitWallClockMs",
270
+ file: "config",
271
+ label: "Unit wall-clock ms",
272
+ help: "How long one worker may run before abort + dispose. Default 30m.",
273
+ type: { kind: "number", min: 60000, max: 7200000, unit: "ms" },
274
+ },
275
+ {
276
+ path: "limits.maxRecycles",
277
+ file: "config",
278
+ label: "Max recycles",
279
+ help: "How many compaction recycles one unit tolerates before stopping. Default 2.",
280
+ type: { kind: "number", min: 0, max: 10 },
281
+ },
282
+ {
283
+ path: "limits.maxReworkPerUnit",
284
+ file: "config",
285
+ label: "Max rework per unit",
286
+ help: "How many times a unit may go back to rework. Default 2.",
287
+ type: { kind: "number", min: 0, max: 10 },
288
+ },
289
+ {
290
+ path: "limits.maxReplansPerPhase",
291
+ file: "config",
292
+ label: "Max replans per phase",
293
+ help: "How many plan mutations a phase may have. Default 3.",
294
+ type: { kind: "number", min: 0, max: 10 },
295
+ },
296
+ {
297
+ path: "limits.tokenCap",
298
+ file: "config",
299
+ label: "Token cap",
300
+ help: "Total tokens before the run stops. Null means unconstrained.",
301
+ type: { kind: "number", min: 1000, max: 100000000 },
302
+ },
303
+ {
304
+ path: "limits.costCap",
305
+ file: "config",
306
+ label: "Cost cap USD",
307
+ help: "Spend ceiling across A/B/C/D/X. Null means unconstrained.",
308
+ type: { kind: "number", min: 0, max: 100000 },
309
+ },
191
310
  ],
192
311
  },
193
312
  {
@@ -388,16 +507,23 @@ export const SETTINGS: SettingsGroup[] = [
388
507
  path: "execution.parallelAt",
389
508
  file: "config",
390
509
  label: "Parallel at",
391
- help: "Legacy engine only. The background engine runs one unit at a time, in its own session, at the level you chose for session handoff.",
510
+ help: "The plan-phase for parallel work. The background engine runs one worker until worktrees land, so this is only the future parallelism knob.",
392
511
  type: { kind: "choice", choices: ["off", "goal", "phase", "sprint", "feature", "task", "subtask"] },
393
512
  },
394
513
  {
395
514
  path: "execution.maxWorkers",
396
515
  file: "config",
397
516
  label: "Max workers",
398
- help: "Legacy engine only. The background engine runs one worker; parallel background workers are not wired up yet.",
517
+ help: "Hard cap on concurrent workers (1..16). Guarded by lock and budget; 1 until worktrees exist. Effective from Daemon.",
399
518
  type: { kind: "number", min: 1, max: 16 },
400
519
  },
520
+ {
521
+ path: "execution.isolation",
522
+ file: "config",
523
+ label: "Isolation",
524
+ help: "How concurrent workers stay out of each others way. none forces maxWorkers=1 and no worktree.",
525
+ type: { kind: "choice", choices: ["worktree", "none"] },
526
+ },
401
527
  ],
402
528
  },
403
529
  {
@@ -465,7 +591,7 @@ export const SETTINGS: SettingsGroup[] = [
465
591
  path: "gates.antiPlaceholder.enabled",
466
592
  file: "config",
467
593
  label: "Reject placeholders",
468
- help: "Fail the gate on TODO-implement, FIXME, 'not implemented' and friends in source.",
594
+ help: "Fail the gate when source still has unfinished markers.",
469
595
  type: { kind: "boolean" },
470
596
  },
471
597
  ],
@@ -681,6 +807,7 @@ export function writeSetting(targetDir: string, setting: Setting, value: unknown
681
807
 
682
808
  /** How a value is shown in the menu. Empty model slots read as inherited. */
683
809
  export function formatValue(setting: Setting, value: unknown): string {
810
+ if (setting.path === "execution.isolation") return typeof value === "string" && value.trim() ? value : "worktree";
684
811
  switch (setting.type.kind) {
685
812
  case "boolean":
686
813
  return value ? "on" : "off";
package/src/core/types.ts CHANGED
@@ -74,6 +74,22 @@ export type SubtaskStatus = (typeof SUBTASK_STATUSES)[number];
74
74
 
75
75
  export type Difficulty = "easy" | "moderate" | "difficult";
76
76
 
77
+ export type PilotMode = "copilot" | "autopilot" | "full";
78
+
79
+ export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
80
+
81
+ export type TierSpec = {
82
+ provider: string;
83
+ id: string;
84
+ thinkingLevel?: ThinkingLevel | "";
85
+ };
86
+
87
+ export type TierId = "A" | "B" | "C" | "D" | "X";
88
+
89
+ export type TierMap = Partial<Record<TierId, TierSpec>>;
90
+
91
+ export type ExecutionIsolation = "worktree" | "none";
92
+
77
93
  // ── Feature list (the SSOT on disk) ─────────────────────────────────────────
78
94
 
79
95
  export type Subtask = {
@@ -95,6 +111,8 @@ export type Task = {
95
111
  difficulty?: Difficulty;
96
112
  modelHint?: string;
97
113
  criteria?: string[];
114
+ /** When true this task must run alone — whole-tree isolation (lockfile regen, codemod). */
115
+ serialize?: boolean;
98
116
  /** Free-form extras are preserved verbatim on round-trip. */
99
117
  [k: string]: unknown;
100
118
  };
@@ -185,6 +203,8 @@ export type ExecutionPolicy = {
185
203
  parallelAt: HandoffGranularity;
186
204
  /** Max parallel workers (1..16). Guarded by lock and budget. */
187
205
  maxWorkers: number;
206
+ /** How concurrent workers stay out of each other's way. */
207
+ isolation: ExecutionIsolation;
188
208
  };
189
209
 
190
210
  export type ExecutionEngine = "background" | "main-session";
@@ -319,6 +339,19 @@ export type HarnessConfig = {
319
339
  workflow: { id: string; name: string } | null;
320
340
  display: DisplayPolicy;
321
341
  intake: IntakeState;
342
+ /** Run-level pilot preset over per-phase modes. `full` = hands-off intake→SHIP. */
343
+ pilot: PilotMode;
344
+ /** Tier definitions A/B/C/D/X. Each is provider+id+thinking. Empty means use baseModel. */
345
+ tiers: TierMap;
346
+ /** Global caps that bound a run. */
347
+ limits: {
348
+ unitWallClockMs: number;
349
+ maxRecycles: number;
350
+ maxReworkPerUnit: number;
351
+ maxReplansPerPhase: number;
352
+ tokenCap: number | null;
353
+ costCap: number | null;
354
+ };
322
355
  /** Set when a gate passed but the phase needs a human signature first. */
323
356
  awaitingApproval: Phase | null;
324
357
  /** Budgets that bound an unattended continuous run. See src/loop.ts. */
@@ -0,0 +1,94 @@
1
+ /**
2
+ * infinity-harness — daemon/budget.ts
3
+ *
4
+ * Per-tier token+cost accounting. Uses pi's UsageTotals shape.
5
+ * - `byTier` holds cumulative per-tier spend (one UsageTotals per A/B/C/D/X).
6
+ * - `cap` is the budget ceiling (costUsd / totalTokens / wallClockMs).
7
+ * - The X-leak tripwire: X tokens outside a consultation worker are a defect.
8
+ *
9
+ * pi reports usage cumulatively per session (last message_end usage IS the
10
+ * session total, not a delta). So per-session we keep the MAX, and per-tier
11
+ * we sum finished sessions' totals via addUsageToTotals semantics.
12
+ */
13
+
14
+ import type { UsageTotals, Budget, RunState } from "../core/runState.ts";
15
+ import type { TierId } from "../core/types.ts";
16
+ import { createUsageTotals } from "../core/runState.ts";
17
+
18
+ export type Tier = TierId;
19
+
20
+ export type Caps = { costUsd?: number | null; totalTokens?: number | null; wallClockMs?: number | null };
21
+
22
+ export type BudgetState = Budget; // alias for the RunState's budget shape
23
+
24
+ export function emptyBudget(): Budget {
25
+ return { byTier: {}, cap: {}, stopOnExhaustion: true };
26
+ }
27
+
28
+ export function addUsageForTier(budget: Budget, tier: Tier, usage: Partial<UsageTotals> & { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; cost?: number; calls?: number }): void {
29
+ const byTier = (budget.byTier ??= {});
30
+ const cur: UsageTotals = byTier[tier] ?? createUsageTotals();
31
+ cur.input += usage.input ?? 0;
32
+ cur.output += usage.output ?? 0;
33
+ cur.cacheRead += usage.cacheRead ?? 0;
34
+ cur.cacheWrite += usage.cacheWrite ?? 0;
35
+ cur.cost += usage.cost ?? 0;
36
+ cur.calls += usage.calls ?? 1;
37
+ byTier[tier] = cur;
38
+ }
39
+
40
+ export function totalTokens(budget: Budget): number {
41
+ let n = 0;
42
+ for (const u of Object.values(budget.byTier ?? {})) n += (u.input ?? 0) + (u.output ?? 0) + (u.cacheRead ?? 0) + (u.cacheWrite ?? 0);
43
+ return n;
44
+ }
45
+
46
+ export function totalCost(budget: Budget): number {
47
+ let c = 0;
48
+ for (const u of Object.values(budget.byTier ?? {})) c += u.cost ?? 0;
49
+ return c;
50
+ }
51
+
52
+ export function isCapExceeded(budget: Budget): { exceeded: boolean; reason: string | null } {
53
+ const cap = budget.cap ?? {};
54
+ const tokens = totalTokens(budget);
55
+ const cost = totalCost(budget);
56
+ if (typeof cap.totalTokens === "number" && cap.totalTokens > 0 && tokens >= cap.totalTokens) return { exceeded: true, reason: `token cap ${cap.totalTokens} exceeded (${tokens})` };
57
+ if (typeof cap.costUsd === "number" && cap.costUsd > 0 && cost >= cap.costUsd) return { exceeded: true, reason: `cost cap $${cap.costUsd} exceeded ($${cost.toFixed(2)})` };
58
+ return { exceeded: false, reason: null };
59
+ }
60
+
61
+ /**
62
+ * X-leak tripwire. True when X has accrued cost/tokens while no consultation
63
+ * worker is alive. The Daemon treats this as a hard stop with reason.
64
+ */
65
+ export function hasXLeak(budget: Budget, hasConsultationWorker: boolean): boolean {
66
+ if (hasConsultationWorker) return false;
67
+ const x = (budget.byTier as Record<string, UsageTotals | undefined>)["X"];
68
+ if (!x) return false;
69
+ return (x.cost ?? 0) > 0 || (x.input ?? 0) + (x.output ?? 0) > 0 || (x.calls ?? 0) > 0;
70
+ }
71
+
72
+ export function xLeakReason(budget: Budget): string {
73
+ const x = (budget.byTier as Record<string, UsageTotals | undefined>)["X"];
74
+ const cost = x?.cost ?? 0;
75
+ const tokens = (x?.input ?? 0) + (x?.output ?? 0);
76
+ return `X accrued ${tokens} tokens ($${cost.toFixed(2)}) with no consultation worker — routing leak`;
77
+ }
78
+
79
+ export function budgetFromRunState(runState: RunState | null): Budget {
80
+ if (!runState?.budget) return emptyBudget();
81
+ return runState.budget;
82
+ }
83
+
84
+ export function budgetSummary(budget: Budget): string {
85
+ const parts: string[] = [];
86
+ for (const tier of ["A","B","C","D","X"] as Tier[]) {
87
+ const u = (budget.byTier as Record<string, UsageTotals | undefined>)[tier];
88
+ if (!u || (!u.calls && !u.cost && !u.input && !u.output)) continue;
89
+ parts.push(`${tier}:${u.input}+${u.output} $${(u.cost ?? 0).toFixed(2)} ×${u.calls}`);
90
+ }
91
+ const cap = budget.cap ?? {};
92
+ const capStr = [cap.totalTokens ? `tokens ${cap.totalTokens}` : null, cap.costUsd ? `$${cap.costUsd}` : null].filter(Boolean).join(" / ");
93
+ return parts.length ? `${parts.join(" | ")}${capStr ? ` — cap ${capStr}` : ""}` : (capStr ? `cap ${capStr}` : "no spend yet");
94
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * infinity-harness — daemon/guard.ts
3
+ *
4
+ * Single-owner lock + daemon.json heartbeat + bounded stop.
5
+ * The Daemon is the single writer; Interfaces are readers. A second pi window
6
+ * on the same project must become a viewer, not a rival Daemon.
7
+ *
8
+ * daemon.json is the trust root: { pid, port, token, startedAt, heartbeatAt, runId }.
9
+ * Token is a random per-run secret; file is 0600. Server binds 127.0.0.1 only.
10
+ * Guides: Daemon writes it, liveness is process.kill(pid,0), heartbeat every 20s,
11
+ * stale after 90s. v2.7 shipped HEARTBEAT_MS/OWNER_STALE_MS at 20s/90s.
12
+ */
13
+
14
+ import { randomBytes, randomUUID } from "node:crypto";
15
+ import { existsSync, writeFileSync, readFileSync, chmodSync, unlinkSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import { daemonPath } from "../core/paths.ts";
18
+ import { readJsonSafe, writeJsonAtomic, fileExists, ensureDir } from "../core/fsx.ts";
19
+ import { dirname } from "node:path";
20
+
21
+ export const HEARTBEAT_MS = 20_000;
22
+ export const OWNER_STALE_MS = 90_000;
23
+
24
+ export type DaemonInfo = {
25
+ pid: number;
26
+ port: number;
27
+ token: string;
28
+ startedAt: string;
29
+ heartbeatAt: string;
30
+ runId: string;
31
+ };
32
+
33
+ function readDaemonRaw(targetDir: string): DaemonInfo | null {
34
+ const raw = readJsonSafe<DaemonInfo | null>(daemonPath(targetDir), null);
35
+ if (!raw || typeof raw.pid !== "number" || !raw.runId) return null;
36
+ return raw as DaemonInfo;
37
+ }
38
+
39
+ export function loadDaemon(targetDir: string): DaemonInfo | null {
40
+ const info = readDaemonRaw(targetDir);
41
+ if (!info) return null;
42
+ return info;
43
+ }
44
+
45
+ export function isDaemonAlive(info: DaemonInfo | null): boolean {
46
+ if (!info) return false;
47
+ // Heartbeat stale?
48
+ const age = Date.now() - new Date(info.heartbeatAt).getTime();
49
+ if (Number.isFinite(age) && age > OWNER_STALE_MS) {
50
+ // Also check pid liveness as tiebreaker.
51
+ try { process.kill(info.pid, 0); return false; } catch { return false; }
52
+ }
53
+ try { process.kill(info.pid, 0); return true; } catch { return false; }
54
+ }
55
+
56
+ export function isDaemonRunning(targetDir: string): boolean {
57
+ const info = loadDaemon(targetDir);
58
+ if (!info) return false;
59
+ return isDaemonAlive(info);
60
+ }
61
+
62
+ export function writeDaemon(targetDir: string, info: DaemonInfo): void {
63
+ ensureDir(dirname(daemonPath(targetDir)));
64
+ writeJsonAtomic(daemonPath(targetDir), info);
65
+ try { chmodSync(daemonPath(targetDir), 0o600); } catch {}
66
+ }
67
+
68
+ export function heartbeat(targetDir: string): DaemonInfo | null {
69
+ const info = loadDaemon(targetDir);
70
+ if (!info || !isDaemonAlive(info)) return null;
71
+ const next: DaemonInfo = { ...info, heartbeatAt: new Date().toISOString() };
72
+ writeDaemon(targetDir, next);
73
+ return next;
74
+ }
75
+
76
+ export function clearDaemon(targetDir: string): void {
77
+ try { if (existsSync(daemonPath(targetDir))) unlinkSync(daemonPath(targetDir)); } catch {}
78
+ }
79
+
80
+ export function newDaemonInfo(runId: string, port: number): DaemonInfo {
81
+ return {
82
+ pid: process.pid,
83
+ port,
84
+ token: randomBytes(24).toString("base64url"),
85
+ startedAt: new Date().toISOString(),
86
+ heartbeatAt: new Date().toISOString(),
87
+ runId,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Guard: refuse to start a second Daemon when one is alive.
93
+ * Returns null when clear, or the live DaemonInfo when blocked.
94
+ */
95
+ export function guardSingleOwner(targetDir: string): DaemonInfo | null {
96
+ const live = loadDaemon(targetDir);
97
+ if (live && isDaemonAlive(live)) return live;
98
+ // Stale file — clear it so the next start succeeds.
99
+ if (live && !isDaemonAlive(live)) clearDaemon(targetDir);
100
+ return null;
101
+ }
102
+
103
+ /**
104
+ * Start heartbeat interval. Returns stop function.
105
+ */
106
+ export function startHeartbeat(targetDir: string): () => void {
107
+ const timer = setInterval(() => {
108
+ try { heartbeat(targetDir); } catch {}
109
+ }, HEARTBEAT_MS);
110
+ // Don't keep process alive just for heartbeat if nothing else is running.
111
+ if (typeof (timer as NodeJS.Timeout & { unref?: () => void }).unref === "function") (timer as unknown as { unref: () => void }).unref();
112
+ return () => clearInterval(timer);
113
+ }