infinity-harness 2.8.10 → 2.8.12

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 (187) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/extensions/infinity-harness/index.js +3614 -0
  3. package/dist/extensions/infinity-harness/index.js.map +1 -0
  4. package/dist/src/approval.js +154 -0
  5. package/dist/src/approval.js.map +1 -0
  6. package/dist/src/core/brief.js +309 -0
  7. package/dist/src/core/brief.js.map +1 -0
  8. package/dist/src/core/config.js +539 -0
  9. package/dist/src/core/config.js.map +1 -0
  10. package/dist/src/core/exec.js +106 -0
  11. package/dist/src/core/exec.js.map +1 -0
  12. package/dist/src/core/featureList.js +365 -0
  13. package/dist/src/core/featureList.js.map +1 -0
  14. package/dist/src/core/fsx.js +123 -0
  15. package/dist/src/core/fsx.js.map +1 -0
  16. package/dist/src/core/gates.js +490 -0
  17. package/dist/src/core/gates.js.map +1 -0
  18. package/dist/src/core/init.js +480 -0
  19. package/dist/src/core/init.js.map +1 -0
  20. package/dist/src/core/lock.js +183 -0
  21. package/dist/src/core/lock.js.map +1 -0
  22. package/dist/src/core/modelRouter.js +185 -0
  23. package/dist/src/core/modelRouter.js.map +1 -0
  24. package/dist/src/core/paths.js +129 -0
  25. package/dist/src/core/paths.js.map +1 -0
  26. package/dist/src/core/phases.js +254 -0
  27. package/dist/src/core/phases.js.map +1 -0
  28. package/dist/src/core/plan.js +15 -0
  29. package/dist/src/core/plan.js.map +1 -0
  30. package/dist/src/core/runState.js +126 -0
  31. package/dist/src/core/runState.js.map +1 -0
  32. package/dist/src/core/settings.js +877 -0
  33. package/dist/src/core/settings.js.map +1 -0
  34. package/dist/src/core/skills.js +319 -0
  35. package/dist/src/core/skills.js.map +1 -0
  36. package/dist/src/core/skillsAudit.js +194 -0
  37. package/dist/src/core/skillsAudit.js.map +1 -0
  38. package/dist/src/core/types.js +77 -0
  39. package/dist/src/core/types.js.map +1 -0
  40. package/dist/src/daemon/budget.js +85 -0
  41. package/dist/src/daemon/budget.js.map +1 -0
  42. package/dist/src/daemon/guard.js +122 -0
  43. package/dist/src/daemon/guard.js.map +1 -0
  44. package/dist/src/daemon/index.js +461 -0
  45. package/dist/src/daemon/index.js.map +1 -0
  46. package/dist/src/daemon/isolation.js +84 -0
  47. package/dist/src/daemon/isolation.js.map +1 -0
  48. package/dist/src/daemon/preflight.js +131 -0
  49. package/dist/src/daemon/preflight.js.map +1 -0
  50. package/dist/src/daemon/server.js +174 -0
  51. package/dist/src/daemon/server.js.map +1 -0
  52. package/dist/src/daemon/supervisorState.js +42 -0
  53. package/dist/src/daemon/supervisorState.js.map +1 -0
  54. package/dist/src/daemon/worker.js +239 -0
  55. package/dist/src/daemon/worker.js.map +1 -0
  56. package/dist/src/daemon/worktree.js +102 -0
  57. package/dist/src/daemon/worktree.js.map +1 -0
  58. package/dist/src/escalate.js +312 -0
  59. package/dist/src/escalate.js.map +1 -0
  60. package/dist/src/exec/piWorker.js +630 -0
  61. package/dist/src/exec/piWorker.js.map +1 -0
  62. package/dist/src/goal.js +317 -0
  63. package/dist/src/goal.js.map +1 -0
  64. package/dist/src/goalLoop.js +374 -0
  65. package/dist/src/goalLoop.js.map +1 -0
  66. package/dist/src/goalSpec.js +397 -0
  67. package/dist/src/goalSpec.js.map +1 -0
  68. package/dist/src/goalState.js +255 -0
  69. package/dist/src/goalState.js.map +1 -0
  70. package/dist/src/handoff.js +205 -0
  71. package/dist/src/handoff.js.map +1 -0
  72. package/dist/src/intake.js +203 -0
  73. package/dist/src/intake.js.map +1 -0
  74. package/dist/src/loop.js +592 -0
  75. package/dist/src/loop.js.map +1 -0
  76. package/dist/src/modelRouter.js +216 -0
  77. package/dist/src/modelRouter.js.map +1 -0
  78. package/dist/src/remote.js +246 -0
  79. package/dist/src/remote.js.map +1 -0
  80. package/dist/src/replan.js +262 -0
  81. package/dist/src/replan.js.map +1 -0
  82. package/dist/src/review.js +54 -0
  83. package/dist/src/review.js.map +1 -0
  84. package/dist/src/rework.js +241 -0
  85. package/dist/src/rework.js.map +1 -0
  86. package/dist/src/runState.js +4 -0
  87. package/dist/src/runState.js.map +1 -0
  88. package/dist/src/scheduler.js +270 -0
  89. package/dist/src/scheduler.js.map +1 -0
  90. package/dist/src/supervisor.js +787 -0
  91. package/dist/src/supervisor.js.map +1 -0
  92. package/dist/src/taskList.js +430 -0
  93. package/dist/src/taskList.js.map +1 -0
  94. package/dist/src/ui/config.js +268 -0
  95. package/dist/src/ui/config.js.map +1 -0
  96. package/dist/src/ui/dashboard.js +1288 -0
  97. package/dist/src/ui/dashboard.js.map +1 -0
  98. package/dist/src/ui/display.js +229 -0
  99. package/dist/src/ui/display.js.map +1 -0
  100. package/dist/src/ui/planTree.js +219 -0
  101. package/dist/src/ui/planTree.js.map +1 -0
  102. package/dist/src/ui/theme.js +298 -0
  103. package/dist/src/ui/theme.js.map +1 -0
  104. package/dist/src/ui/viewState.js +65 -0
  105. package/dist/src/ui/viewState.js.map +1 -0
  106. package/dist/src/ui/widget.js +721 -0
  107. package/dist/src/ui/widget.js.map +1 -0
  108. package/dist/src/ui/wizard.js +540 -0
  109. package/dist/src/ui/wizard.js.map +1 -0
  110. package/dist/src/unstuck.js +210 -0
  111. package/dist/src/unstuck.js.map +1 -0
  112. package/dist/src/worker.js +267 -0
  113. package/dist/src/worker.js.map +1 -0
  114. package/dist/src/workflow.js +250 -0
  115. package/dist/src/workflow.js.map +1 -0
  116. package/dist/tests/approval.test.js +144 -0
  117. package/dist/tests/approval.test.js.map +1 -0
  118. package/dist/tests/brief.test.js +271 -0
  119. package/dist/tests/brief.test.js.map +1 -0
  120. package/dist/tests/config.test.js +320 -0
  121. package/dist/tests/config.test.js.map +1 -0
  122. package/dist/tests/dashboard.test.js +277 -0
  123. package/dist/tests/dashboard.test.js.map +1 -0
  124. package/dist/tests/display.test.js +229 -0
  125. package/dist/tests/display.test.js.map +1 -0
  126. package/dist/tests/escalate.test.js +272 -0
  127. package/dist/tests/escalate.test.js.map +1 -0
  128. package/dist/tests/featureList.test.js +429 -0
  129. package/dist/tests/featureList.test.js.map +1 -0
  130. package/dist/tests/gates.test.js +333 -0
  131. package/dist/tests/gates.test.js.map +1 -0
  132. package/dist/tests/goal.test.js +229 -0
  133. package/dist/tests/goal.test.js.map +1 -0
  134. package/dist/tests/goalLoop.test.js +170 -0
  135. package/dist/tests/goalLoop.test.js.map +1 -0
  136. package/dist/tests/goalSpec.test.js +146 -0
  137. package/dist/tests/goalSpec.test.js.map +1 -0
  138. package/dist/tests/goalState.test.js +202 -0
  139. package/dist/tests/goalState.test.js.map +1 -0
  140. package/dist/tests/handoff.test.js +127 -0
  141. package/dist/tests/handoff.test.js.map +1 -0
  142. package/dist/tests/init.test.js +264 -0
  143. package/dist/tests/init.test.js.map +1 -0
  144. package/dist/tests/intake.test.js +288 -0
  145. package/dist/tests/intake.test.js.map +1 -0
  146. package/dist/tests/loop.test.js +453 -0
  147. package/dist/tests/loop.test.js.map +1 -0
  148. package/dist/tests/modelRouter.test.js +131 -0
  149. package/dist/tests/modelRouter.test.js.map +1 -0
  150. package/dist/tests/phases.test.js +244 -0
  151. package/dist/tests/phases.test.js.map +1 -0
  152. package/dist/tests/planTree.test.js +190 -0
  153. package/dist/tests/planTree.test.js.map +1 -0
  154. package/dist/tests/remote.test.js +380 -0
  155. package/dist/tests/remote.test.js.map +1 -0
  156. package/dist/tests/replan.test.js +113 -0
  157. package/dist/tests/replan.test.js.map +1 -0
  158. package/dist/tests/review.test.js +77 -0
  159. package/dist/tests/review.test.js.map +1 -0
  160. package/dist/tests/rework.test.js +109 -0
  161. package/dist/tests/rework.test.js.map +1 -0
  162. package/dist/tests/routing-live.test.js +121 -0
  163. package/dist/tests/routing-live.test.js.map +1 -0
  164. package/dist/tests/runState.test.js +73 -0
  165. package/dist/tests/runState.test.js.map +1 -0
  166. package/dist/tests/settings.test.js +311 -0
  167. package/dist/tests/settings.test.js.map +1 -0
  168. package/dist/tests/skills.test.js +387 -0
  169. package/dist/tests/skills.test.js.map +1 -0
  170. package/dist/tests/supervisor.test.js +437 -0
  171. package/dist/tests/supervisor.test.js.map +1 -0
  172. package/dist/tests/surface.test.js +203 -0
  173. package/dist/tests/surface.test.js.map +1 -0
  174. package/dist/tests/taskList.test.js +376 -0
  175. package/dist/tests/taskList.test.js.map +1 -0
  176. package/dist/tests/theme.test.js +149 -0
  177. package/dist/tests/theme.test.js.map +1 -0
  178. package/dist/tests/unstuck.test.js +268 -0
  179. package/dist/tests/unstuck.test.js.map +1 -0
  180. package/dist/tests/widget.test.js +424 -0
  181. package/dist/tests/widget.test.js.map +1 -0
  182. package/dist/tests/worker.test.js +306 -0
  183. package/dist/tests/worker.test.js.map +1 -0
  184. package/dist/tests/workflow.test.js +187 -0
  185. package/dist/tests/workflow.test.js.map +1 -0
  186. package/extensions/infinity-harness/index.ts +16 -11
  187. package/package.json +5 -3
@@ -0,0 +1,787 @@
1
+ /**
2
+ * infinity-harness — the supervisor.
3
+ *
4
+ * This is the piece that makes the main session a control panel instead of a
5
+ * worker. It runs entirely in the extension's own process — plain JavaScript,
6
+ * no LLM — and its only job is to keep exactly one background pi session
7
+ * doing the right work with the right model.
8
+ *
9
+ * Why it exists
10
+ * -------------
11
+ * Before this, the loop pushed the brief back into the human's own session
12
+ * with `pi.sendUserMessage`. Everything followed from that one line: the
13
+ * human's model did every task regardless of difficulty, the human's context
14
+ * window carried the whole run, the human's token budget paid for it, and a
15
+ * "session handoff" meant replacing the terminal the human was typing into.
16
+ * The difficulty tiers the wizard collects had nowhere to be applied, because
17
+ * a session only ever has one model and it was the human's.
18
+ *
19
+ * The shape now
20
+ * -------------
21
+ * main session the human, the widget, the log. No harness turns at all.
22
+ * supervisor this file. Decides, spawns, watches, records.
23
+ * worker a separate `pi --mode rpc` process with its own model,
24
+ * its own context window and its own session file.
25
+ *
26
+ * The unit is the session is the model
27
+ * ------------------------------------
28
+ * `session.handoff` names one level of the plan — goal, phase, sprint,
29
+ * feature, task, subtask. That level is the *unit*: one worker owns one unit
30
+ * from start to finish, in one session, on one model. When the run crosses a
31
+ * unit boundary the worker is closed and a new one starts, and *that* is the
32
+ * handoff. Because the model is chosen when the worker starts, the model
33
+ * boundary and the session boundary are the same boundary by construction —
34
+ * which is why the difficulty of a unit, not of a task, decides the model
35
+ * (see `effectiveDifficultyForTask`).
36
+ *
37
+ * Nothing in here holds run state in memory. The plan, the phase, the budgets
38
+ * and the ladder are all files, so a supervisor that dies mid-run is restarted
39
+ * by the next session with nothing lost.
40
+ */
41
+ import { mkdirSync } from "node:fs";
42
+ import { join, resolve } from "node:path";
43
+ import { loadConfig } from "./core/config.js";
44
+ import { loadFeatureList, nextActionableTask, findFeature } from "./core/featureList.js";
45
+ import { harnessDir } from "./core/paths.js";
46
+ import { readJsonSafe, writeJsonAtomic, fileExists } from "./core/fsx.js";
47
+ import { decideNext, stopFilePath } from "./loop.js";
48
+ import { loadRunState, countSession, disarmRun } from "./runState.js";
49
+ import { effectiveDifficultyForTask } from "./scheduler.js";
50
+ import { resolveModel as legacyResolveModel, resolveThinking as legacyResolveThinking, loadRouterConfig } from "./modelRouter.js";
51
+ import { routeModel as coreRouteModel } from "./core/modelRouter.js";
52
+ import { WorkerSession, WORKER_DIRECTIVE, workerSessionDir, } from "./exec/piWorker.js";
53
+ // ── on-disk state ───────────────────────────────────────────────────────────
54
+ export const SUPERVISOR_FILE = "supervisor.json";
55
+ export const ACTIVITY_FILE = "activity.json";
56
+ /** Activity lines kept. Enough to scroll back through a phase, not a run. */
57
+ export const ACTIVITY_LIMIT = 400;
58
+ export function supervisorStatePath(targetDir) {
59
+ return resolve(harnessDir(targetDir), SUPERVISOR_FILE);
60
+ }
61
+ export function activityPath(targetDir) {
62
+ return resolve(harnessDir(targetDir), ACTIVITY_FILE);
63
+ }
64
+ /** A heartbeat older than this means the owner is gone, not busy. */
65
+ export const OWNER_STALE_MS = 90_000;
66
+ /** How often the owner refreshes its claim. Comfortably inside the stale window. */
67
+ export const HEARTBEAT_MS = 20_000;
68
+ const HISTORY_LIMIT = 20;
69
+ export function emptySupervisorState(runId) {
70
+ return {
71
+ version: 1,
72
+ runId,
73
+ owner: null,
74
+ status: "idle",
75
+ startedAt: null,
76
+ updatedAt: new Date().toISOString(),
77
+ baseModel: null,
78
+ handoff: "task",
79
+ unit: null,
80
+ worker: null,
81
+ history: [],
82
+ sessions: 0,
83
+ lastDecision: null,
84
+ stopReason: null,
85
+ };
86
+ }
87
+ export function loadSupervisorState(targetDir) {
88
+ const raw = readJsonSafe(supervisorStatePath(targetDir), null);
89
+ if (!raw || typeof raw.runId !== "string")
90
+ return null;
91
+ return { ...emptySupervisorState(raw.runId), ...raw };
92
+ }
93
+ /** Is someone else already driving this project? Returns their claim, or null. */
94
+ export function activeOwner(targetDir, selfPid = process.pid, now = Date.now()) {
95
+ const st = loadSupervisorState(targetDir);
96
+ const owner = st?.owner ?? null;
97
+ if (!owner || typeof owner.pid !== "number")
98
+ return null;
99
+ if (owner.pid === selfPid)
100
+ return null;
101
+ const at = Date.parse(owner.at ?? "");
102
+ if (!Number.isFinite(at) || now - at > OWNER_STALE_MS)
103
+ return null;
104
+ // A heartbeat can be fresh and the process still gone — a hard kill leaves
105
+ // the last one behind. Ask the OS rather than trusting the timestamp alone.
106
+ if (!processAlive(owner.pid))
107
+ return null;
108
+ return owner;
109
+ }
110
+ /** Does this pid exist? `kill(pid, 0)` throws ESRCH when it does not. */
111
+ export function processAlive(pid) {
112
+ if (typeof pid !== "number" || pid <= 0)
113
+ return false;
114
+ try {
115
+ process.kill(pid, 0);
116
+ return true;
117
+ }
118
+ catch (e) {
119
+ return e?.code === "EPERM";
120
+ }
121
+ }
122
+ /**
123
+ * Kill a pi child left behind by a supervisor that died.
124
+ *
125
+ * A `SIGKILL`ed pi cannot close its worker, and that worker keeps editing the
126
+ * project — with nothing watching it and nothing able to stop it. The next
127
+ * session picks up the pid from the state file and ends it.
128
+ */
129
+ export function reapOrphanWorker(targetDir) {
130
+ const st = loadSupervisorState(targetDir);
131
+ const pid = st?.owner?.workerPid ?? null;
132
+ if (!pid || !processAlive(pid))
133
+ return null;
134
+ if (processAlive(st?.owner?.pid))
135
+ return null; // its supervisor is alive; not an orphan
136
+ try {
137
+ process.kill(pid, "SIGKILL");
138
+ return pid;
139
+ }
140
+ catch {
141
+ return null;
142
+ }
143
+ }
144
+ export function saveSupervisorState(targetDir, state) {
145
+ state.updatedAt = new Date().toISOString();
146
+ writeJsonAtomic(supervisorStatePath(targetDir), state);
147
+ }
148
+ export function loadActivity(targetDir) {
149
+ const raw = readJsonSafe(activityPath(targetDir), null);
150
+ return Array.isArray(raw?.lines) ? raw.lines : [];
151
+ }
152
+ /**
153
+ * Append one line to the background log.
154
+ *
155
+ * This is the only channel the human has into what the workers are doing, so
156
+ * it is a file rather than a notification: a run that spans days outlives
157
+ * every terminal that watched part of it.
158
+ */
159
+ export function appendActivity(targetDir, line) {
160
+ const lines = loadActivity(targetDir);
161
+ const next = [...lines, { at: new Date().toISOString(), ...line }].slice(-ACTIVITY_LIMIT);
162
+ try {
163
+ writeJsonAtomic(activityPath(targetDir), { lines: next });
164
+ }
165
+ catch {
166
+ /* a log that cannot be written must not stop the run */
167
+ }
168
+ return next;
169
+ }
170
+ /** The handoff knob, as a unit level. `off` and `goal` both mean one session. */
171
+ export function unitLevelFor(handoff) {
172
+ switch (handoff) {
173
+ case "off":
174
+ return "run";
175
+ case "goal":
176
+ return "goal";
177
+ case "phase":
178
+ return "phase";
179
+ case "sprint":
180
+ return "sprint";
181
+ case "feature":
182
+ return "feature";
183
+ case "subtask":
184
+ return "subtask";
185
+ default:
186
+ return "task";
187
+ }
188
+ }
189
+ const SCOPE_TEXT = {
190
+ run: "You own this entire run. Keep going through every phase until the harness stops you.",
191
+ goal: "You own this goal. Keep going until every feature under it is done.",
192
+ phase: "You own this whole phase. Work through every task in it before you stop.",
193
+ sprint: "You own this sprint. Work through every feature and task in it before you stop.",
194
+ feature: "You own this feature. Work through all of its tasks before you stop.",
195
+ task: "You own this one task. Finish it, then stop — the harness starts the next one.",
196
+ subtask: "You own this one subtask. Finish it, then stop — the harness starts the next one.",
197
+ };
198
+ /**
199
+ * What is being worked on right now, at the configured granularity.
200
+ *
201
+ * Returns null when there is nothing actionable: an empty plan in an early
202
+ * phase is normal, and the phase's own seeded work is what the brief will
203
+ * name, so the unit falls back to the phase.
204
+ */
205
+ export function currentUnit(targetDir, baseModel) {
206
+ const { config } = loadConfig(targetDir);
207
+ const { list } = loadFeatureList(targetDir);
208
+ const handoff = (config.session?.handoff ?? "task");
209
+ const level = unitLevelFor(handoff);
210
+ const phase = (config.currentPhase ?? null);
211
+ const phaseTask = phase ? nextActionableTask(list, phase) : null;
212
+ const task = (phaseTask ?? nextActionableTask(list));
213
+ const feature = task ? findFeature(list, task.featureId) ?? null : null;
214
+ const sprintId = feature?.sprintId ?? null;
215
+ const sprint = sprintId ? (list.sprints ?? []).find((s) => s.id === sprintId) ?? null : null;
216
+ const goalId = feature?.goalId ??
217
+ sprint?.goalId ??
218
+ (list.goals ?? [])[0]?.id ??
219
+ null;
220
+ const difficulty = task
221
+ ? (effectiveDifficultyForTask(task, handoff, list) ?? task.difficulty ?? null)
222
+ : null;
223
+ // The active subtask, when the plan goes that deep.
224
+ let subtaskKey = null;
225
+ let subtaskTitle = null;
226
+ if (task && Array.isArray(task.subtasks)) {
227
+ const subs = task.subtasks ?? [];
228
+ const open = subs.find((s) => s.status !== "complete") ?? null;
229
+ if (open) {
230
+ subtaskKey = `${task.compositeKey}#${open.id ?? open.title}`;
231
+ subtaskTitle = open.title;
232
+ }
233
+ }
234
+ const identity = (() => {
235
+ switch (level) {
236
+ case "run":
237
+ return { key: "run", label: "whole run" };
238
+ case "goal":
239
+ return goalId ? { key: `goal:${goalId}`, label: goalGuess(list, goalId) } : { key: "goal:*", label: "the goal" };
240
+ case "phase":
241
+ return phase ? { key: `phase:${phase}`, label: phase.toUpperCase() } : null;
242
+ case "sprint":
243
+ return sprintId
244
+ ? { key: `sprint:${sprintId}`, label: `${sprintId}${sprint?.name ? ` · ${sprint.name}` : ""}` }
245
+ : phase
246
+ ? { key: `phase:${phase}`, label: phase.toUpperCase() }
247
+ : null;
248
+ case "feature":
249
+ return feature
250
+ ? { key: `feature:${feature.id}`, label: `${feature.id} · ${feature.name}` }
251
+ : phase
252
+ ? { key: `phase:${phase}`, label: phase.toUpperCase() }
253
+ : null;
254
+ case "subtask":
255
+ if (subtaskKey)
256
+ return { key: `subtask:${subtaskKey}`, label: `${task.compositeKey} › ${subtaskTitle}` };
257
+ return task ? { key: `task:${task.compositeKey}`, label: task.compositeKey } : phase ? { key: `phase:${phase}`, label: phase.toUpperCase() } : null;
258
+ default:
259
+ return task
260
+ ? { key: `task:${task.compositeKey}`, label: `${task.compositeKey} · ${short(task.description)}` }
261
+ : phase
262
+ ? { key: `phase:${phase}`, label: phase.toUpperCase() }
263
+ : null;
264
+ }
265
+ })();
266
+ if (!identity)
267
+ return null;
268
+ // C: prefer config.tiers (core/modelRouter) — single source. Fallback to legacy model-router.json.
269
+ const useCore = (() => { try {
270
+ return Object.keys(config.tiers ?? {}).length > 0;
271
+ }
272
+ catch {
273
+ return false;
274
+ } })();
275
+ const routedLegacy = legacyResolveModel({
276
+ projectDir: targetDir,
277
+ task: task
278
+ ? {
279
+ difficulty: difficulty ?? undefined,
280
+ modelHint: task.modelHint,
281
+ id: task.id,
282
+ key: task.compositeKey,
283
+ }
284
+ : undefined,
285
+ feature: (feature ?? undefined),
286
+ sprint: (sprint ?? undefined),
287
+ phase: phase ?? undefined,
288
+ role: (config.currentRole ?? undefined),
289
+ });
290
+ const thinkingLegacy = legacyResolveThinking({
291
+ projectDir: targetDir,
292
+ task: difficulty ? { difficulty } : undefined,
293
+ feature: (feature ?? undefined),
294
+ sprint: (sprint ?? undefined),
295
+ });
296
+ let routed = routedLegacy;
297
+ let thinking = thinkingLegacy;
298
+ if (useCore) {
299
+ try {
300
+ const mod = require("./runState.ts");
301
+ const rs2 = mod.loadRunState(targetDir);
302
+ const coreRes = coreRouteModel({ difficulty: difficulty ?? undefined, config, runState: rs2 });
303
+ routed = coreRes.provider + "/" + coreRes.id;
304
+ const tierThinking = (() => { try {
305
+ const tiers = config.tiers ?? {};
306
+ const tk = { easy: "B", moderate: "C", difficult: "D" }[String(difficulty ?? "")] ?? "A";
307
+ const th = tiers[tk]?.thinkingLevel;
308
+ return typeof th === "string" && th ? th : undefined;
309
+ }
310
+ catch {
311
+ return undefined;
312
+ } })();
313
+ if (tierThinking)
314
+ thinking = tierThinking;
315
+ }
316
+ catch { }
317
+ }
318
+ return {
319
+ level,
320
+ key: identity.key,
321
+ label: identity.label,
322
+ phase,
323
+ difficulty,
324
+ model: (routed && routed.trim()) || (baseModel ?? "") || "",
325
+ thinking: thinking || "",
326
+ taskKey: task?.compositeKey ?? null,
327
+ featureId: feature?.id ?? null,
328
+ scope: SCOPE_TEXT[level],
329
+ };
330
+ }
331
+ function goalGuess(list, goalId) {
332
+ const g = (list.goals ?? []).find((x) => x.id === goalId);
333
+ return g ? `${g.id} · ${short(g.title)}` : goalId;
334
+ }
335
+ function short(s, n = 44) {
336
+ const v = (s ?? "").replace(/\s+/g, " ").trim();
337
+ return v.length > n ? v.slice(0, n - 1) + "…" : v;
338
+ }
339
+ /** One line for the widget: what this unit is and which model owns it. */
340
+ export function describeUnit(unit, baseModel) {
341
+ const model = unit.model || baseModel || "pi default";
342
+ const diff = unit.difficulty ? ` (${unit.difficulty})` : "";
343
+ return `${unit.level} ${unit.label}${diff} → ${model}${unit.thinking ? ` · ${unit.thinking}` : ""}`;
344
+ }
345
+ export function isRefusal(r) {
346
+ return r.started === false;
347
+ }
348
+ const DEFAULT_IDLE_MS = 750;
349
+ export function startSupervisor(options) {
350
+ const dir = options.targetDir;
351
+ if (!options.takeOver) {
352
+ const owner = activeOwner(dir);
353
+ if (owner) {
354
+ return {
355
+ started: false,
356
+ reason: `another pi session (pid ${owner.pid}) is already driving this run. ` +
357
+ `Two supervisors would put two workers in the same tree.`,
358
+ owner,
359
+ };
360
+ }
361
+ }
362
+ // A supervisor that was killed cannot close its worker, and that worker
363
+ // keeps editing the project with nothing watching it.
364
+ const orphan = reapOrphanWorker(dir);
365
+ const decide = options.decide ?? decideNext;
366
+ const idleMs = options.idleMs ?? DEFAULT_IDLE_MS;
367
+ let stopping = false;
368
+ let stopReason = null;
369
+ /**
370
+ * Resolved by `stop()`. The loop races every await that can block for a
371
+ * long time against it, so halting is immediate even when the thing being
372
+ * awaited is a wedged worker rather than a healthy one.
373
+ */
374
+ let signalStop = () => { };
375
+ const stopSignal = new Promise((r) => {
376
+ signalStop = r;
377
+ });
378
+ let worker = null;
379
+ let workerView = null;
380
+ let workerSeq = 0;
381
+ let heartbeat = null;
382
+ /** True once the loop has left: the ownership claim must never come back. */
383
+ let released = false;
384
+ const state = loadSupervisorState(dir) ?? emptySupervisorState(options.runId);
385
+ state.runId = options.runId;
386
+ state.owner = { pid: process.pid, sessionId: options.sessionId ?? String(process.pid), at: new Date().toISOString(), workerPid: null };
387
+ state.status = "running";
388
+ state.startedAt = new Date().toISOString();
389
+ state.baseModel = options.baseModel ?? null;
390
+ state.stopReason = null;
391
+ state.worker = null;
392
+ saveSupervisorState(dir, state);
393
+ const publish = () => {
394
+ state.worker = workerView;
395
+ // The claim is refreshed on every publish, which happens on every worker
396
+ // event — far more often than the stale window, so a live run never looks
397
+ // abandoned and an abandoned one always does. Once released it stays
398
+ // released: re-stamping it on the way out would leave a dead claim behind
399
+ // for the next session to wait out.
400
+ state.owner = released
401
+ ? null
402
+ : {
403
+ pid: process.pid,
404
+ sessionId: options.sessionId ?? String(process.pid),
405
+ at: new Date().toISOString(),
406
+ workerPid: worker?.pid ?? null,
407
+ };
408
+ saveSupervisorState(dir, state);
409
+ try {
410
+ options.hooks?.onState?.(state);
411
+ }
412
+ catch {
413
+ /* a broken host must not stop the run */
414
+ }
415
+ };
416
+ const say = (level, text, who = null) => {
417
+ const line = { at: new Date().toISOString(), level, worker: who, text };
418
+ appendActivity(dir, { level, worker: who, text });
419
+ try {
420
+ options.hooks?.onActivity?.(line);
421
+ }
422
+ catch {
423
+ /* ditto */
424
+ }
425
+ };
426
+ const closeWorker = async (why) => {
427
+ if (!worker)
428
+ return;
429
+ const name = workerView?.name ?? "worker";
430
+ await worker.close();
431
+ if (workerView) {
432
+ workerView.state = "closed";
433
+ workerView.doing = why;
434
+ state.history = [...state.history, workerView].slice(-HISTORY_LIMIT);
435
+ }
436
+ worker = null;
437
+ workerView = null;
438
+ say("info", `${name} finished — ${why}`, name);
439
+ publish();
440
+ };
441
+ const openWorker = async (unit) => {
442
+ const name = `W${++workerSeq}`;
443
+ const attemptDir = join(resolve(dir, "tmp", "infinity-harness"), sanitize(options.runId), sanitize(unit.key), `session-${workerSeq}`);
444
+ mkdirSync(attemptDir, { recursive: true });
445
+ workerView = {
446
+ name,
447
+ unitKey: unit.key,
448
+ unitLabel: unit.label,
449
+ level: unit.level,
450
+ difficulty: unit.difficulty,
451
+ model: unit.model || options.baseModel || "",
452
+ servedModel: null,
453
+ thinking: unit.thinking,
454
+ state: "starting",
455
+ doing: "starting a fresh pi session",
456
+ startedAt: new Date().toISOString(),
457
+ turns: 0,
458
+ tokens: { inputTokens: 0, outputTokens: 0 },
459
+ contextRatio: null,
460
+ sessionId: null,
461
+ attemptDir,
462
+ };
463
+ publish();
464
+ say("info", `${name} starting — ${unit.level} ${unit.label} on ${workerView.model || "pi's default model"}` +
465
+ (unit.difficulty ? ` (${unit.difficulty})` : ""), name);
466
+ const spec = {
467
+ projectDir: dir,
468
+ attemptDir,
469
+ model: unit.model || options.baseModel || null,
470
+ thinking: unit.thinking || null,
471
+ sessionDir: workerSessionDir(dir),
472
+ sessionName: `infinity ${unit.key}`,
473
+ unitKey: unit.key,
474
+ runId: options.runId,
475
+ turnTimeoutMs: options.turnTimeoutMs,
476
+ extraArgs: options.workerArgs,
477
+ env: options.workerEnv,
478
+ };
479
+ let session = options.createWorker ? options.createWorker(spec) : new WorkerSession(spec);
480
+ session.on((e) => onWorkerEvent(name, e));
481
+ session.start();
482
+ let ok = await session.ready();
483
+ // A worker that did not discover the harness cannot record what it did,
484
+ // and a run whose gate never sees the work loops forever on a finished
485
+ // task. Restart it once with the extension named explicitly.
486
+ if (ok && options.harnessExtension && !(await session.hasHarnessTools())) {
487
+ say("warn", `${name} started without the harness tools — restarting it with the extension loaded`, name);
488
+ await session.close();
489
+ const withExt = { ...spec, harnessExtension: options.harnessExtension };
490
+ session = options.createWorker ? options.createWorker(withExt) : new WorkerSession(withExt);
491
+ session.on((e) => onWorkerEvent(name, e));
492
+ session.start();
493
+ ok = await session.ready();
494
+ }
495
+ if (!ok) {
496
+ if (workerView)
497
+ workerView.state = "failed";
498
+ say("error", `${name} could not start pi — ${session.startError ?? "no response"}`, name);
499
+ publish();
500
+ await session.close();
501
+ worker = null;
502
+ workerView = null;
503
+ return null;
504
+ }
505
+ if (workerView) {
506
+ workerView.state = "working";
507
+ workerView.sessionId = session.sessionId;
508
+ workerView.doing = "reading the brief";
509
+ }
510
+ worker = session;
511
+ state.sessions += 1;
512
+ countSession(dir);
513
+ publish();
514
+ return session;
515
+ };
516
+ const onWorkerEvent = (name, e) => {
517
+ if (!workerView || workerView.name !== name)
518
+ return;
519
+ switch (e.kind) {
520
+ case "tool":
521
+ workerView.doing = e.summary;
522
+ say("work", e.summary, name);
523
+ break;
524
+ case "model":
525
+ workerView.servedModel = e.provider ? `${e.provider}/${e.model}` : e.model;
526
+ break;
527
+ case "usage":
528
+ workerView.tokens = { inputTokens: e.inputTokens, outputTokens: e.outputTokens };
529
+ break;
530
+ case "compaction":
531
+ say("warn", `${name} compacted its context`, name);
532
+ break;
533
+ case "error":
534
+ say("error", e.message, name);
535
+ break;
536
+ default:
537
+ return;
538
+ }
539
+ publish();
540
+ };
541
+ const humanStopped = () => {
542
+ if (fileExists(stopFilePath(dir)))
543
+ return "a STOP file is present";
544
+ const run = loadRunState(dir);
545
+ if (!run || !run.armed)
546
+ return run?.stopReason ?? "the run is not armed";
547
+ const { config } = loadConfig(dir);
548
+ if (config.paused)
549
+ return "the run is paused";
550
+ return null;
551
+ };
552
+ const loop = async () => {
553
+ let cycles = 0;
554
+ try {
555
+ if (orphan)
556
+ say("warn", `killed an orphaned worker (pid ${orphan}) left by a previous session`);
557
+ say("info", `supervisor started — handoff at ${loadConfig(dir).config.session?.handoff ?? "task"} level`);
558
+ // A long quiet turn must not let the claim go stale under a second
559
+ // session that would then start a worker of its own.
560
+ heartbeat = setInterval(() => {
561
+ if (stopping)
562
+ return;
563
+ try {
564
+ publish();
565
+ }
566
+ catch {
567
+ /* a claim that cannot be written is not worth crashing a run for */
568
+ }
569
+ }, HEARTBEAT_MS);
570
+ heartbeat.unref?.();
571
+ while (!stopping) {
572
+ if (options.maxCycles !== undefined && cycles >= options.maxCycles) {
573
+ stopReason = stopReason ?? "cycle ceiling reached";
574
+ break;
575
+ }
576
+ cycles += 1;
577
+ const brake = humanStopped();
578
+ if (brake) {
579
+ stopReason = brake;
580
+ break;
581
+ }
582
+ const { decision } = await decide({ targetDir: dir, runId: options.runId });
583
+ state.lastDecision = decision.action;
584
+ state.handoff = (loadConfig(dir).config.session?.handoff ?? "task");
585
+ if (decision.action === "stop" || decision.action === "wait") {
586
+ stopReason = decision.detail;
587
+ await closeWorker(decision.action === "stop" ? "run finished" : "run parked");
588
+ disarmRun(dir, decision.detail);
589
+ say(decision.reason === "complete" ? "good" : "warn", `run ${decision.action}: ${decision.detail}`);
590
+ try {
591
+ options.hooks?.onStop?.(decision.reason, decision.detail);
592
+ }
593
+ catch {
594
+ /* host */
595
+ }
596
+ publish();
597
+ return;
598
+ }
599
+ if (decision.action === "approve") {
600
+ // A signature is a human boundary, so the worker is closed rather
601
+ // than left holding a context window open for however long the
602
+ // human takes to come back.
603
+ await closeWorker(`waiting for your signature on ${decision.phase.toUpperCase()}`);
604
+ state.unit = null;
605
+ publish();
606
+ say("warn", `${decision.phase.toUpperCase()} passed its gate and needs your signature — /infinity:approve`);
607
+ try {
608
+ options.hooks?.onApproval?.(decision.phase, decision.message);
609
+ }
610
+ catch {
611
+ /* host */
612
+ }
613
+ await Promise.race([waitWhile(() => !stopping && stillAwaiting(dir), idleMs, 0), stopSignal]);
614
+ continue;
615
+ }
616
+ // -- continue / advanced: there is work, and someone has to do it ----
617
+ const unit = currentUnit(dir, options.baseModel);
618
+ if (!unit) {
619
+ stopReason = "nothing actionable and no phase to work on";
620
+ break;
621
+ }
622
+ state.unit = unit;
623
+ if (decision.action === "advanced") {
624
+ say("good", `gate passed → ${decision.toPhase.toUpperCase()}`);
625
+ }
626
+ // The unit boundary IS the handoff: a different unit means a different
627
+ // session and, because the model is chosen at spawn, a different model.
628
+ if (worker && workerView && workerView.unitKey !== unit.key) {
629
+ await closeWorker(`handoff — ${workerView.unitLabel} → ${unit.label}`);
630
+ }
631
+ // An escalation that names a stronger model is also a model boundary,
632
+ // and therefore also a new session: `set_model` mid-session would put
633
+ // the stronger model in front of the weaker one's failed reasoning,
634
+ // which is the context we are trying to get away from.
635
+ const escalated = decision.action === "continue" ? (decision.escalation ?? null) : null;
636
+ if (escalated?.model && worker && workerView && workerView.model !== escalated.model) {
637
+ await closeWorker(`escalating to ${escalated.model} (${escalated.strategy})`);
638
+ say("warn", `escalation: ${escalated.strategy} → ${escalated.model}`);
639
+ unit.model = escalated.model;
640
+ }
641
+ else if (escalated?.model) {
642
+ unit.model = escalated.model;
643
+ }
644
+ // Context pressure inside a worker is the other reason to replace it,
645
+ // and it is the one that matters on a small model: a session that
646
+ // compacts has already lost the detail the next turn needed.
647
+ if (worker && workerView) {
648
+ const threshold = loadConfig(dir).config.session?.contextThreshold ?? 0;
649
+ const ratio = workerView.contextRatio;
650
+ if (threshold > 0 && typeof ratio === "number" && ratio >= threshold) {
651
+ await closeWorker(`context ${Math.round(ratio * 100)}% full — fresh session`);
652
+ }
653
+ }
654
+ if (!worker) {
655
+ const started = await openWorker(unit);
656
+ if (!started) {
657
+ stopReason = "could not start a background pi session";
658
+ break;
659
+ }
660
+ }
661
+ if (!worker || !workerView)
662
+ break;
663
+ // A fresh session needs the whole brief. A session that has been on
664
+ // this unit since the last cycle already has it, and re-sending it
665
+ // every cycle is the largest avoidable cost in a long run.
666
+ const returning = workerView.turns > 0;
667
+ const body = returning ? (decision.headline ?? decision.message) : decision.message;
668
+ const prompt = returning
669
+ ? `${body}\n\n${WORKER_DIRECTIVE}`
670
+ : `${body}\n\n---\n\nYOUR REMIT: ${unit.scope}\n\n${WORKER_DIRECTIVE}`;
671
+ workerView.turns += 1;
672
+ workerView.state = "working";
673
+ publish();
674
+ const turn = await Promise.race([
675
+ worker.prompt(prompt, options.turnTimeoutMs),
676
+ stopSignal.then(() => ({ summary: "", tools: [], usage: { inputTokens: 0, outputTokens: 0 }, contextRatio: null, aborted: true, error: null })),
677
+ ]);
678
+ if (stopping)
679
+ break;
680
+ if (workerView) {
681
+ workerView.contextRatio = turn.contextRatio;
682
+ workerView.state = "idle";
683
+ workerView.doing = short(turn.summary, 96) || "waiting for the gate";
684
+ publish();
685
+ }
686
+ if (turn.error) {
687
+ say("warn", `${workerView?.name ?? "worker"} — ${turn.error}`, workerView?.name ?? null);
688
+ // A worker that cannot take a prompt is not a worker. Replace it
689
+ // rather than asking a corpse the same question forever.
690
+ await closeWorker(turn.error);
691
+ }
692
+ else if (turn.summary) {
693
+ say("info", short(turn.summary, 160), workerView?.name ?? null);
694
+ }
695
+ if (idleMs > 0)
696
+ await Promise.race([sleep(idleMs), stopSignal]);
697
+ }
698
+ }
699
+ catch (e) {
700
+ stopReason = `supervisor error: ${e instanceof Error ? e.message : String(e)}`;
701
+ say("error", stopReason);
702
+ disarmRun(dir, stopReason);
703
+ }
704
+ finally {
705
+ if (heartbeat)
706
+ clearInterval(heartbeat);
707
+ heartbeat = null;
708
+ released = true;
709
+ await closeWorker(stopReason ?? "stopped");
710
+ state.status = "stopped";
711
+ state.stopReason = stopReason;
712
+ state.unit = null;
713
+ publish();
714
+ if (stopReason)
715
+ say("info", `supervisor stopped — ${stopReason}`);
716
+ }
717
+ };
718
+ const done = loop();
719
+ return {
720
+ runId: options.runId,
721
+ isRunning: () => !stopping && state.status === "running",
722
+ async stop(reason = "stopped by the human") {
723
+ if (stopping) {
724
+ await done;
725
+ return;
726
+ }
727
+ stopping = true;
728
+ stopReason = stopReason ?? reason;
729
+ signalStop();
730
+ // Kill the child first: the loop may be parked on its turn, and that
731
+ // turn is the only thing keeping the loop from noticing the stop.
732
+ if (worker) {
733
+ try {
734
+ await worker.close();
735
+ }
736
+ catch {
737
+ /* already gone */
738
+ }
739
+ }
740
+ await done;
741
+ },
742
+ done,
743
+ };
744
+ }
745
+ function stillAwaiting(targetDir) {
746
+ try {
747
+ return Boolean(loadConfig(targetDir).config.awaitingApproval);
748
+ }
749
+ catch {
750
+ return false;
751
+ }
752
+ }
753
+ function sleep(ms) {
754
+ return new Promise((r) => {
755
+ const t = setTimeout(r, ms);
756
+ t.unref?.();
757
+ });
758
+ }
759
+ /** Poll a predicate. `limit` of 0 means forever (until the supervisor stops). */
760
+ async function waitWhile(pred, everyMs, limit) {
761
+ let waited = 0;
762
+ while (pred()) {
763
+ await sleep(Math.max(200, everyMs));
764
+ waited += Math.max(200, everyMs);
765
+ if (limit > 0 && waited >= limit)
766
+ return;
767
+ }
768
+ }
769
+ function sanitize(s) {
770
+ return s.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 80) || "unit";
771
+ }
772
+ /** Read-only view for the widget, the dashboard and `/infinity:workers`. */
773
+ export function supervisorView(targetDir) {
774
+ return {
775
+ state: loadSupervisorState(targetDir),
776
+ activity: loadActivity(targetDir),
777
+ routerEnabled: (() => {
778
+ try {
779
+ return loadRouterConfig(targetDir).enabled;
780
+ }
781
+ catch {
782
+ return false;
783
+ }
784
+ })(),
785
+ };
786
+ }
787
+ //# sourceMappingURL=supervisor.js.map