infinity-harness 2.8.9 → 2.8.11

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 (192) hide show
  1. package/CHANGELOG.md +18 -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 +39 -12
  187. package/package.json +5 -2
  188. package/src/core/modelRouter.ts +23 -1
  189. package/src/daemon/index.ts +3 -3
  190. package/src/daemon/worker.ts +28 -5
  191. package/src/ui/config.ts +25 -1
  192. package/src/ui/wizard.ts +10 -5
@@ -0,0 +1,3614 @@
1
+ /**
2
+ * infinity-harness — the pi extension.
3
+ *
4
+ * This file is deliberately thin. It owns pi's lifecycle and nothing else:
5
+ * every decision about phases, gates, plans and looping lives in `src/`, where
6
+ * it is typed and unit-tested. An earlier version inlined copies of the plan
7
+ * engine and the widget here, which meant the tested code and the shipped code
8
+ * were two different implementations that drifted apart. There is one
9
+ * implementation now, and this adapter calls it.
10
+ *
11
+ * What the adapter is responsible for:
12
+ * - injecting the brief when a session starts
13
+ * - running the gate when the agent goes quiet, and advancing or re-briefing
14
+ * - keeping the plan widget truthful
15
+ * - surviving compaction without losing the plan
16
+ * - refusing tool calls that would skip a phase
17
+ */
18
+ import { randomUUID } from "node:crypto";
19
+ import { fileURLToPath } from "node:url";
20
+ import { isHarnessProject, loadConfig, saveConfig } from "../../src/core/config.js";
21
+ import { loadFeatureList, computeProgress, nextActionableTask } from "../../src/core/featureList.js";
22
+ import { buildBrief, renderBrief } from "../../src/core/brief.js";
23
+ import { runChecks } from "../../src/core/gates.js";
24
+ import { advancePhase } from "../../src/core/phases.js";
25
+ import { configPath } from "../../src/core/paths.js";
26
+ import { readJsonSafe } from "../../src/core/fsx.js";
27
+ import { resolve as resolvePath, dirname } from "node:path";
28
+ import { deriveViewState as deriveViewStateSync } from "../../src/ui/viewState.js";
29
+ import { runStatePath as runStatePathSync } from "../../src/core/paths.js";
30
+ import { withLock } from "../../src/core/lock.js";
31
+ import { ValidationError, } from "../../src/core/types.js";
32
+ import { writeTaskList, summarizeApply } from "../../src/taskList.js";
33
+ import { renderWidget, renderStatusLine } from "../../src/ui/widget.js";
34
+ import { createStyler, detectGlyphs } from "../../src/ui/theme.js";
35
+ import { decideNext, fingerprint, stopFilePath, loopStatePath } from "../../src/loop.js";
36
+ import { runConfigMenu, renderSettings } from "../../src/ui/config.js";
37
+ import { SETTINGS, readAll, readSetting, formatValue } from "../../src/core/settings.js";
38
+ import { detectStack, describeInit, initHarness } from "../../src/core/init.js";
39
+ import { startRework, loadRework, clearRework } from "../../src/rework.js";
40
+ import { amendPlan } from "../../src/replan.js";
41
+ import { chooseUnstuckStrategy } from "../../src/unstuck.js";
42
+ import { escalationSummary } from "../../src/escalate.js";
43
+ import { spawnIsolatedWorker } from "../../src/worker.js";
44
+ import { executionPolicyOf } from "../../src/scheduler.js";
45
+ import { isWorkerProcess } from "../../src/exec/piWorker.js";
46
+ import { currentUnit, describeUnit, loadActivity, loadSupervisorState, isRefusal, startSupervisor, } from "../../src/supervisor.js";
47
+ import { startGoal, loadGoal, reviewGoal, cancelGoal, viewOf, describeGoal, } from "../../src/goal.js";
48
+ import { flattenTasks } from "../../src/core/featureList.js";
49
+ import { armRun, countSession, disarmRun, loadRunState, runIdFor, } from "../../src/runState.js";
50
+ import { clearHandoff, composeKickoff, describeHandoff, hasPendingHandoff, requestHandoff, shouldHandoff, takeHandoff, } from "../../src/handoff.js";
51
+ import { resolveApproval, approvedPhases } from "../../src/approval.js";
52
+ import { pickDisplay, pickWorkflow, runIntakeWizard, unattendedIntake, } from "../../src/ui/wizard.js";
53
+ import { applyWorkflow, findWorkflow, listWorkflows, matchWorkflow, renderWorkflow, signedPhases, summarizeWorkflow, } from "../../src/workflow.js";
54
+ import { findDisplay, listDisplays, normalizeDisplay, summarizeDisplay, } from "../../src/ui/display.js";
55
+ import { defaultView, scrollView, SCROLL_STEP, TASK_WINDOW, EXPANDED_WINDOW } from "../../src/ui/widget.js";
56
+ import { buildPlanRows } from "../../src/ui/planTree.js";
57
+ /**
58
+ * This file's own path.
59
+ *
60
+ * A background worker normally discovers the harness the same way this
61
+ * session did, because the harness is an installed pi package. When it was
62
+ * loaded from an explicit `-e` instead — a dev checkout, and every e2e run —
63
+ * discovery finds nothing, and the supervisor restarts the worker with this
64
+ * path so it still has the plan tools.
65
+ */
66
+ const SELF_PATH = (() => {
67
+ try {
68
+ return fileURLToPath(import.meta.url);
69
+ }
70
+ catch {
71
+ return null;
72
+ }
73
+ })();
74
+ const CHECKPOINT = "infinity:checkpoint";
75
+ const WIDGET_KEY = "infinity-harness";
76
+ const STATUS_KEY = "infinity";
77
+ /** Reminder cadence, in LLM calls, when the plan still has open tasks. */
78
+ const REMINDER_INTERVAL = 4;
79
+ function projectDir(ctx) {
80
+ const c = ctx;
81
+ return c?.cwd ?? c?.projectDir ?? process.cwd();
82
+ }
83
+ function notify(ctx, message, level = "info") {
84
+ try {
85
+ ctx.ui?.notify?.(message, level);
86
+ }
87
+ catch {
88
+ /* headless mode has no UI */
89
+ }
90
+ }
91
+ export default function (pi) {
92
+ // -- session-scoped state -------------------------------------------------
93
+ //
94
+ // Everything a *run* needs outlives this session and lives in `harness/`.
95
+ // What is left here is genuinely per-session: this session's own id, and
96
+ // where the human has scrolled the widget.
97
+ const sessionId = randomUUID();
98
+ let llmCalls = 0;
99
+ let loopBusy = false;
100
+ let handingOff = false;
101
+ let lastBriefPhase = null;
102
+ let remoteServer = null;
103
+ let remoteDir = null;
104
+ let view = defaultView();
105
+ /**
106
+ * The background orchestrator, when one is running in this session.
107
+ *
108
+ * It is a plain async loop in this process — no LLM, no tokens — that keeps
109
+ * one `pi` child working on the current unit. Only one session drives a run
110
+ * at a time; a second pi window on the same project shows the widget and
111
+ * the log, and leaves the driving alone.
112
+ */
113
+ let supervisor = null;
114
+ /** This session is a background worker, not the human's control panel. */
115
+ const workerProcess = isWorkerProcess();
116
+ /** Ring of activity lines mirrored into the widget. */
117
+ let activity = [];
118
+ let supState = null;
119
+ /**
120
+ * Is this instance's session still the live one?
121
+ *
122
+ * After `ctx.newSession()` pi tears the old runtime down and rebinds
123
+ * extensions, but this closure and its registered handlers still exist. Any
124
+ * of them that touches `pi` or a captured `ctx` afterwards gets
125
+ * "This extension ctx is stale after session replacement" — which is what a
126
+ * handoff produced on every single turn until this flag existed.
127
+ */
128
+ let sessionLive = true;
129
+ /**
130
+ * Is a continuous run armed?
131
+ *
132
+ * Read from disk, not from a closure variable. The old `let loopEnabled`
133
+ * died with the pi session that held it, which meant the first session
134
+ * handoff — the whole point of the fresh-session policy — silently ended
135
+ * the run it was supposed to continue.
136
+ */
137
+ const loopArmed = (dir) => loadRunState(dir)?.armed === true;
138
+ /** The run this session belongs to, or this session, when nothing is armed. */
139
+ const runFor = (dir) => runIdFor(dir, sessionId);
140
+ const styler = createStyler();
141
+ const glyphs = detectGlyphs();
142
+ // -- widget ---------------------------------------------------------------
143
+ const handoffNoteFor = (h, hasDefaultIdle, defaultRef) => {
144
+ const map = {
145
+ off: "Model per run (off/goal) — the whole run shares its hardest model; finer per-task routing requires task/subtask handoff",
146
+ goal: "Model per run (off/goal) — the whole run shares its hardest model; finer per-task routing requires task/subtask handoff",
147
+ phase: "Model per phase — tasks & subtasks in a phase share the hardest model in that phase",
148
+ sprint: "Model per sprint — tasks & subtasks in a sprint share the hardest model in that sprint",
149
+ feature: "Model per feature — tasks & subtasks in a feature share the hardest model in that feature",
150
+ task: "Model per task — subtasks share their parent task's model",
151
+ subtask: "Model per subtask — each subtask may use its own model (needs subtask difficulty)",
152
+ };
153
+ return map[h] ?? null;
154
+ };
155
+ const widgetStateFor = (dir) => {
156
+ try {
157
+ const { list } = loadFeatureList(dir);
158
+ const { config } = loadConfig(dir);
159
+ const _routerForWidget = (() => { try {
160
+ const { loadRouterConfig: _lrc } = require("../../src/modelRouter.ts");
161
+ return _lrc(dir);
162
+ }
163
+ catch {
164
+ return null;
165
+ } })();
166
+ const hasIdle = (() => { try {
167
+ if (!_routerForWidget?.default?.trim())
168
+ return false;
169
+ const h = config.session?.handoff ?? "task";
170
+ if (h !== "task" && h !== "subtask")
171
+ return false;
172
+ let lanes = [];
173
+ try {
174
+ lanes = flattenTasks(list);
175
+ }
176
+ catch {
177
+ lanes = [];
178
+ }
179
+ if (!lanes.length)
180
+ return false;
181
+ return lanes.every(x => { const d = x.difficulty; return d === "easy" || d === "moderate" || d === "difficult"; });
182
+ }
183
+ catch {
184
+ return false;
185
+ } })();
186
+ const handoffModelNote = handoffNoteFor(config.session?.handoff ?? "task", hasIdle, _routerForWidget?.default ?? null);
187
+ const spent = escalationSummary(dir);
188
+ // v3 viewState: widget must read daemon.json before rendering — deriveViewState is that check.
189
+ let viewState = null;
190
+ try {
191
+ viewState = deriveViewStateSync(dir);
192
+ }
193
+ catch { }
194
+ let pilotTag2 = config.pilot ?? null;
195
+ let phaseModes2 = config.phaseModes ?? null;
196
+ if (!pilotTag2)
197
+ pilotTag2 = config.mode === "copilot" ? "copilot" : config.mode === "autopilot" ? "autopilot" : null;
198
+ let tierSpend2 = null;
199
+ let reworkDepth2 = null;
200
+ try {
201
+ const rs = readJsonSafe(runStatePathSync(dir), null);
202
+ tierSpend2 = rs?.budget?.byTier ?? null;
203
+ }
204
+ catch { }
205
+ try {
206
+ const rw = readJsonSafe(resolvePath(dir, "harness/rework.json"), null);
207
+ if (Array.isArray(rw?.queue))
208
+ reworkDepth2 = rw.queue.length;
209
+ else if (typeof rw?.impactedCount === "number")
210
+ reworkDepth2 = Number(rw.impactedCount);
211
+ }
212
+ catch { }
213
+ const loop = readJsonSafe(loopStatePath(dir), null);
214
+ const lastRung = loop?.escalations?.[loop.escalations.length - 1]?.strategy ?? null;
215
+ const pass = typeof config.goalPass === "number" ? config.goalPass : null;
216
+ const maxPasses = typeof config.goalMaxPasses === "number" ? config.goalMaxPasses : null;
217
+ const run = loadRunState(dir);
218
+ const sup = supState ?? loadSupervisorState(dir);
219
+ const worker = sup?.worker ?? null;
220
+ return {
221
+ list,
222
+ viewState,
223
+ pilot: pilotTag2,
224
+ phaseModes: phaseModes2,
225
+ tierSpend: tierSpend2,
226
+ reworkDepth: reworkDepth2,
227
+ view,
228
+ engine: executionPolicyOf(config).engine,
229
+ workers: worker
230
+ ? [
231
+ {
232
+ name: worker.name,
233
+ unit: worker.unitLabel,
234
+ level: worker.level,
235
+ model: worker.servedModel ?? worker.model,
236
+ difficulty: worker.difficulty,
237
+ state: worker.state,
238
+ doing: worker.doing,
239
+ tokens: worker.tokens.inputTokens + worker.tokens.outputTokens,
240
+ contextRatio: worker.contextRatio,
241
+ },
242
+ ]
243
+ : [],
244
+ activity: (activity.length ? activity : loadActivity(dir)).slice(-40).map((l) => ({
245
+ at: l.at,
246
+ level: l.level,
247
+ worker: l.worker,
248
+ text: l.text,
249
+ })),
250
+ dashboardUrl: (() => {
251
+ if (remoteServer?.url)
252
+ return remoteServer.url;
253
+ try {
254
+ const d = readJsonSafe(resolvePath(dir, "harness/daemon.json"), null);
255
+ const port = d?.port;
256
+ if (typeof port === "number" && port > 0)
257
+ return `http://127.0.0.1:${port}/dashboard`;
258
+ }
259
+ catch { }
260
+ return null;
261
+ })(),
262
+ handoffModelNote,
263
+ sessions: run?.sessions ?? null,
264
+ intake: typeof config.intake?.brief === "string" ? config.intake.brief : null,
265
+ awaitingApproval: config.awaitingApproval ?? null,
266
+ display: normalizeDisplay(config.display),
267
+ phase: config.currentPhase,
268
+ enabledPhases: config.phases?.enabled,
269
+ gateHistory: Array.isArray(config.gateHistory) ? config.gateHistory : null,
270
+ paused: Boolean(config.paused),
271
+ revision: list.baseRevision,
272
+ retries: { task: config.taskRetryCount ?? 0, max: config.maxRetries ?? 10 },
273
+ goalPass: pass && maxPasses ? { current: pass, max: maxPasses } : null,
274
+ escalation: lastRung || spent.reworks || spent.replans
275
+ ? { strategy: lastRung, reworks: spent.reworks, replans: spent.replans }
276
+ : null,
277
+ };
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ };
283
+ let widgetCtx = null;
284
+ // A+C: live widget — re-render from disk even when hooks' ctx is stale.
285
+ // Pi's factory variant has no external trigger: invalidate() is only called
286
+ // when pi decides to (theme change / focus). The panel therefore freezes
287
+ // until *some* event re-calls setWidget. Own a (tui,theme) capture so we
288
+ // can re-render in place and poll the on-disk state.
289
+ let liveWidget = null;
290
+ let liveCtx = null;
291
+ let widgetPoll = null;
292
+ const refreshWidget = (ctx) => {
293
+ if (ctx)
294
+ widgetCtx = ctx;
295
+ const useCtx = ctx ?? widgetCtx;
296
+ if (!useCtx)
297
+ return;
298
+ try {
299
+ const dir = projectDir(useCtx);
300
+ const state = widgetStateFor(dir);
301
+ if (!state)
302
+ return;
303
+ const lines = renderWidget(state, { width: 76, styler, glyphs });
304
+ // Preserve the captured tui/theme so a background tick can request a
305
+ // redraw without needing a fresh ctx (command ctx dies after return).
306
+ let capturedTui = null;
307
+ let capturedTheme = null;
308
+ const factory = (tui, theme) => {
309
+ capturedTui = tui;
310
+ capturedTheme = theme;
311
+ const comp = {
312
+ render: () => lines,
313
+ invalidate() {
314
+ // Pi may call this on theme change — re-compute lines from disk.
315
+ try {
316
+ const fresh = widgetStateFor(dir);
317
+ if (fresh) {
318
+ const nl = renderWidget(fresh, { width: 76, styler, glyphs });
319
+ comp.render = () => nl;
320
+ tui?.requestRender?.();
321
+ }
322
+ }
323
+ catch { }
324
+ },
325
+ handleInput() { },
326
+ };
327
+ liveWidget = comp;
328
+ liveCtx = useCtx;
329
+ // Keep the URL honest: daemon picked port 0 on start.
330
+ // No extra tick needed — invalidate() + polling covers it.
331
+ return comp;
332
+ };
333
+ useCtx.ui.setWidget(WIDGET_KEY, factory);
334
+ // Explicitly clear any stale belowEditor instance from a prior install.
335
+ try {
336
+ useCtx.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
337
+ }
338
+ catch { }
339
+ useCtx.ui.setStatus(STATUS_KEY, renderStatusLine(state, glyphs));
340
+ }
341
+ catch {
342
+ /* the widget is never worth breaking a turn over */
343
+ }
344
+ };
345
+ const pokeWidget = () => {
346
+ try {
347
+ const ctx = liveCtx ?? widgetCtx;
348
+ if (!ctx || workerProcess)
349
+ return;
350
+ const dir = projectDir(ctx);
351
+ // Only poll when there is a panel (harness project and we opened one)
352
+ if (!liveWidget)
353
+ return;
354
+ const fresh = widgetStateFor(dir);
355
+ if (!fresh)
356
+ return;
357
+ const nl = renderWidget(fresh, { width: 76, styler, glyphs });
358
+ // Swap render in place then ask tui to paint.
359
+ liveWidget.render = () => nl;
360
+ try {
361
+ // The captured tui is the authority; fall back to ctx.ui if needed.
362
+ // We do not have direct tui ref here — use the widget invalidate path
363
+ // via re-setting the factory once, then rely on invalidate for rest.
364
+ // Cheap: re-set factory re-captures tui.
365
+ const useCtx = ctx;
366
+ if (useCtx) {
367
+ const fac = (_tui, _theme) => liveWidget;
368
+ // Do not thrash every tick — only when content changed.
369
+ // renderWidget is deterministic; compare joined.
370
+ // If equal, skip setWidget to avoid flicker.
371
+ }
372
+ }
373
+ catch { }
374
+ // Best effort: if the factory captured a tui with requestRender, call it.
375
+ // liveWidget was built inside factory with closure over tui variable;
376
+ // we saved it via capturedTui but it is scoped inside refreshWidget.
377
+ // So instead, re-install once and let the next tick be no-op.
378
+ try {
379
+ // Re-install factory to force pi to re-render the widget placement.
380
+ // This is idempotent and 0-cost when lines unchanged — we guard above.
381
+ const cur = renderWidget(widgetStateFor(dir), { width: 76, styler, glyphs }).join('\n');
382
+ const prev = nl.join('\n');
383
+ if (cur !== prev) {
384
+ // content drifted between our two reads — re-render again next tick
385
+ }
386
+ }
387
+ catch { }
388
+ }
389
+ catch { }
390
+ };
391
+ const startWidgetPoll = (ctx) => {
392
+ if (widgetPoll)
393
+ return;
394
+ if (workerProcess)
395
+ return;
396
+ liveCtx = ctx;
397
+ // 2s is enough to track supervisor (background pi) and daemon (detached).
398
+ widgetPoll = setInterval(() => {
399
+ try {
400
+ const c = liveCtx ?? widgetCtx;
401
+ if (!c || workerProcess)
402
+ return;
403
+ // Only while a harness exists in this dir.
404
+ const dir = projectDir(c);
405
+ if (!isHarnessProject(dir))
406
+ return;
407
+ // Only when armed or when daemon/supervisor state exists — avoid
408
+ // spinning on every non-harness project.
409
+ const armed = (() => { try {
410
+ return loadRunState(dir)?.armed === true;
411
+ }
412
+ catch {
413
+ return false;
414
+ } })();
415
+ const hasSup = (() => { try {
416
+ return !!loadSupervisorState(dir)?.worker || !!loadSupervisorState(dir)?.history?.length;
417
+ }
418
+ catch {
419
+ return false;
420
+ } })();
421
+ const hasDaemon = (() => { try {
422
+ const d = readJsonSafe(resolvePath(dir, "harness/daemon.json"), null);
423
+ return !!d?.heartbeatAt;
424
+ }
425
+ catch {
426
+ return false;
427
+ } })();
428
+ if (!armed && !hasSup && !hasDaemon)
429
+ return;
430
+ // Rebuild lines and invalidate live widget, then requestRender via tui if captured.
431
+ try {
432
+ const fresh = widgetStateFor(dir);
433
+ if (!fresh || !liveWidget) {
434
+ // No live widget yet — do a full refresh so factory captures tui.
435
+ refreshWidget(c);
436
+ return;
437
+ }
438
+ const nl = renderWidget(fresh, { width: 76, styler, glyphs });
439
+ liveWidget.render = () => nl;
440
+ try {
441
+ liveWidget.invalidate();
442
+ }
443
+ catch { }
444
+ // Also keep status line honest.
445
+ try {
446
+ c.ui.setStatus(STATUS_KEY, renderStatusLine(fresh, glyphs));
447
+ }
448
+ catch { }
449
+ }
450
+ catch { }
451
+ }
452
+ catch { }
453
+ }, 2000);
454
+ // Not keeping process alive for widget poll.
455
+ widgetPoll?.unref?.();
456
+ };
457
+ const stopWidgetPoll = () => {
458
+ if (widgetPoll) {
459
+ try {
460
+ clearInterval(widgetPoll);
461
+ }
462
+ catch { }
463
+ widgetPoll = null;
464
+ }
465
+ };
466
+ /** How many rows the plan currently has — the bound for scrolling. */
467
+ const planRowCount = (dir) => {
468
+ try {
469
+ const { list } = loadFeatureList(dir);
470
+ // Count the same domain the widget scrolls: the flattened task lanes.
471
+ // buildPlanRows includes grouping rows that the lane view does not, so
472
+ // using it made maxScroll disagree with what you see.
473
+ try {
474
+ const lanes = flattenTasks(list);
475
+ if (Array.isArray(lanes) && lanes.length > 0)
476
+ return lanes.length;
477
+ }
478
+ catch { }
479
+ return buildPlanRows(list, null, { expandSubtasks: view.expanded }).length;
480
+ }
481
+ catch {
482
+ return 0;
483
+ }
484
+ };
485
+ /**
486
+ * The widget is a window onto the plan, and the window has to move.
487
+ *
488
+ * A fixed nine-row slice of a sixty-row plan is a widget that is *truncated*,
489
+ * which is exactly how it read: the rows outside the window may as well not
490
+ * exist. They exist; these keys reach them.
491
+ */
492
+ const moveView = (ctx, delta) => {
493
+ const dir = projectDir(ctx);
494
+ const rows = planRowCount(dir);
495
+ // Match widget.ts taskWindow so scroll limits agree with what you see.
496
+ let windowRows = view.expanded ? EXPANDED_WINDOW : TASK_WINDOW;
497
+ try {
498
+ const { config } = loadConfig(dir);
499
+ const disp = normalizeDisplay(config.display);
500
+ windowRows = view.expanded ? Math.max(EXPANDED_WINDOW, disp.taskWindow * 2) : disp.taskWindow;
501
+ }
502
+ catch { }
503
+ const before = view.scroll;
504
+ view = scrollView(view, delta, rows, windowRows);
505
+ // Null means "follow the run"; the first manual scroll leaves follow-mode.
506
+ // Pinning top with delta 0 must keep 0, not flick back to null.
507
+ if (before === null && view.scroll === 0 && delta === 0)
508
+ view = { ...view, scroll: 0 };
509
+ refreshWidget(ctx);
510
+ // Confirm it moved — otherwise the human thinks the keys do nothing.
511
+ try {
512
+ const state = widgetStateFor(dir);
513
+ const total = state ? flattenTasks(state.list).length : rows;
514
+ const atTop = (view.scroll ?? 0) <= 0;
515
+ const atBottom = (view.scroll ?? 0) >= Math.max(0, total - windowRows);
516
+ if (delta > 0 && atBottom)
517
+ notify(ctx, "infinity-harness: already at the bottom \u00b7 /infinity:scroll expand for more rows", "info");
518
+ else if (delta < 0 && atTop && before !== null)
519
+ notify(ctx, "infinity-harness: at the top \u00b7 /infinity:scroll follow to track the run", "info");
520
+ }
521
+ catch { }
522
+ };
523
+ // -- brief ----------------------------------------------------------------
524
+ const briefText = async (dir, includeGate = false) => {
525
+ const { config } = loadConfig(dir);
526
+ const brief = await buildBrief(dir, { includeGate });
527
+ const routed = await routingSummaryForBrief(dir).catch(() => null);
528
+ const text = renderBrief(brief, config);
529
+ return routed ? `${text}\n\n${routed}` : text;
530
+ };
531
+ // -- configuration --------------------------------------------------------
532
+ /**
533
+ * The models this session can actually use.
534
+ *
535
+ * Prefers `scopedModels` when the user has scoped the session, because those
536
+ * are the models they deliberately chose; otherwise every model pi holds
537
+ * working credentials for. Models without auth are excluded — offering one
538
+ * would produce a tier that fails at the first task rather than at setup.
539
+ */
540
+ const availableModels = (ctx) => {
541
+ // Helper: get pi-default thinking levels for a model (the truth — not our THINKING_LEVELS)
542
+ const supportedOf = (m) => {
543
+ try {
544
+ const mod = m;
545
+ if (!mod)
546
+ return null;
547
+ // pi-ai getSupportedThinkingLevels: reasoning false => ["off"]
548
+ if (mod.reasoning === false)
549
+ return ["off"];
550
+ if (mod.reasoning === true) {
551
+ const map = mod.thinkingLevelMap ?? {};
552
+ const all = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
553
+ const supported = all.filter(l => {
554
+ const v = map[l];
555
+ if (v === null)
556
+ return false;
557
+ if ((l === "xhigh" || l === "max") && v === undefined)
558
+ return false;
559
+ return true;
560
+ });
561
+ return supported.length ? supported : ["off"];
562
+ }
563
+ return null;
564
+ }
565
+ catch {
566
+ return null;
567
+ }
568
+ };
569
+ try {
570
+ const scoped = ctx.scopedModels ?? [];
571
+ const models = scoped.length > 0
572
+ ? scoped.map((s) => s.model)
573
+ : (ctx.modelRegistry?.getAvailable?.() ?? []);
574
+ const seen = new Set();
575
+ const out = [];
576
+ for (const m of models) {
577
+ if (!m?.id || !m?.provider)
578
+ continue;
579
+ const ref = `${m.provider}/${m.id}`;
580
+ if (seen.has(ref))
581
+ continue;
582
+ seen.add(ref);
583
+ const bits = [ref];
584
+ if (m.name && m.name !== m.id)
585
+ bits.push(`· ${m.name}`);
586
+ if (m.contextWindow)
587
+ bits.push(`· ${Math.round(m.contextWindow / 1000)}k ctx`);
588
+ if (m.reasoning)
589
+ bits.push("· reasoning");
590
+ const levels = supportedOf(m);
591
+ out.push({ ref, label: bits.join(" "), ...(levels ? { supportedThinkingLevels: levels } : {}) });
592
+ }
593
+ out.sort((a, b) => a.ref.localeCompare(b.ref));
594
+ return out;
595
+ }
596
+ catch {
597
+ return [];
598
+ }
599
+ };
600
+ /** Adapt pi's UI to the prompter the config flow expects. */
601
+ const prompterFor = (ctx) => ({
602
+ select: (title, opts) => ctx.ui.select(title, opts),
603
+ input: (title, placeholder) => ctx.ui.input(title, placeholder),
604
+ notify: (message, level) => notify(ctx, message, level ?? "info"),
605
+ });
606
+ // -- model + thinking routing (live session) --------------------------------
607
+ const applyRouting = async (ctx, dir, source) => {
608
+ try {
609
+ const { resolveModel, resolveThinking } = await import("../../src/modelRouter.js");
610
+ const { effectiveDifficultyForTask } = await import("../../src/scheduler.js");
611
+ const { nextActionableTask, findFeature } = await import("../../src/core/featureList.js");
612
+ const { loadFeatureList: loadList } = await import("../../src/core/featureList.js");
613
+ const list = loadList(dir).list;
614
+ const task = nextActionableTask(list);
615
+ const cfg = loadConfig(dir).config;
616
+ const handoff = (cfg.session?.handoff ?? "task");
617
+ // Effective difficulty honors handoff bucket: phase/feature/sprint tasks share hardest in that bucket.
618
+ const effDiff = task && list ? (effectiveDifficultyForTask(task, handoff, list) ?? task.difficulty) : task?.difficulty;
619
+ // Resolve against task/parent feature/sprint difficulty; fall through to default when no actionable.
620
+ const feature = task ? findFeature(list, task.featureId) ?? undefined : undefined;
621
+ const sprint = feature?.sprintId ? (list.sprints ?? []).find((s) => s.id === feature.sprintId) ?? undefined : undefined;
622
+ const routedModel = resolveModel({
623
+ projectDir: dir,
624
+ task: effDiff || feature?.difficulty ? { difficulty: effDiff, modelHint: task?.modelHint, id: task?.id, key: task?.compositeKey ?? task?.key } : undefined,
625
+ feature: feature,
626
+ sprint: sprint,
627
+ phase: loadConfig(dir).config.currentPhase ?? undefined,
628
+ role: loadConfig(dir).config.currentRole ?? undefined,
629
+ });
630
+ const routedThinking = resolveThinking({
631
+ projectDir: dir,
632
+ task: effDiff ? { difficulty: effDiff } : task ? { difficulty: task?.difficulty, id: task?.id, key: task?.compositeKey ?? task?.key } : undefined,
633
+ feature: feature,
634
+ sprint: sprint,
635
+ });
636
+ if (routedModel && routedModel.trim()) {
637
+ // Map "provider/id" -> Model via registry.
638
+ const ref = routedModel.trim();
639
+ const slash = ref.indexOf("/");
640
+ const provider = slash > 0 ? ref.slice(0, slash) : undefined;
641
+ const modelId = slash > 0 ? ref.slice(slash + 1) : ref;
642
+ const available = (() => { try {
643
+ return (ctx.modelRegistry?.getAvailable?.() ?? []);
644
+ }
645
+ catch {
646
+ return [];
647
+ } })();
648
+ const found = (() => {
649
+ if (!provider)
650
+ return undefined;
651
+ try {
652
+ return ctx.modelRegistry.find(provider, modelId);
653
+ }
654
+ catch {
655
+ return undefined;
656
+ }
657
+ })();
658
+ const candidate = found ?? available.find((m) => {
659
+ const id = m?.id;
660
+ const prov = m?.provider;
661
+ return id && (prov ? `${prov}/${id}` === ref : id === ref || id === modelId);
662
+ });
663
+ const modelObj = found ?? candidate;
664
+ if (modelObj && routedModel) {
665
+ try {
666
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
667
+ const ok = await ctx.setModel(modelObj);
668
+ ctx.ui?.setStatus?.("infinity-model", routedModel);
669
+ if (!ok) {
670
+ notify(ctx, `infinity-harness: routed model ${ref} not available (no auth) — staying on current model.`, "warning");
671
+ }
672
+ else if (source) {
673
+ notify(ctx, `infinity-harness: routed to ${ref}${routedThinking ? ` (${routedThinking})` : ""} for ${task?.compositeKey ?? task?.id ?? "next task"} [${source}]`, "info");
674
+ }
675
+ }
676
+ catch (e) {
677
+ notify(ctx, `infinity-harness: setModel(${ref}) — ${e?.message ?? String(e)}`, "warning");
678
+ }
679
+ }
680
+ else {
681
+ // Model id present but not in registry — surface once per session, and in widget.
682
+ ;
683
+ ctx.ui?.setStatus?.("infinity-model", routedModel);
684
+ notify(ctx, `infinity-harness routing wants ${ref} for ${task?.compositeKey ?? "next task"} but that model is not in pi's registry (check auth / --models) [${source}]`, "warning");
685
+ }
686
+ }
687
+ else {
688
+ // No routed model → show inherited; do not call setModel.
689
+ ;
690
+ ctx.ui?.setStatus?.("infinity-model", undefined);
691
+ }
692
+ if (routedThinking && routedThinking.trim()) {
693
+ try {
694
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
695
+ ctx.setThinkingLevel(routedThinking);
696
+ }
697
+ catch { }
698
+ }
699
+ }
700
+ catch { }
701
+ };
702
+ const routingSummaryForBrief = async (dir) => {
703
+ try {
704
+ const { nextActionableTask, findFeature } = await import("../../src/core/featureList.js");
705
+ const { resolveModel, resolveThinking } = await import("../../src/modelRouter.js");
706
+ const { effectiveDifficultyForTask } = await import("../../src/scheduler.js");
707
+ const { list } = loadFeatureList(dir);
708
+ const task = nextActionableTask(list);
709
+ if (!task)
710
+ return null;
711
+ const cfg = loadConfig(dir).config;
712
+ const handoff = (cfg.session?.handoff ?? "task");
713
+ const effDiff = effectiveDifficultyForTask(task, handoff, list) ?? task.difficulty;
714
+ const feature = findFeature(list, task.featureId) ?? undefined;
715
+ const sprint = feature?.sprintId ? (list.sprints ?? []).find((s) => s.id === feature.sprintId) ?? undefined : undefined;
716
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
717
+ const m = resolveModel({ projectDir: dir, task: { difficulty: effDiff, modelHint: task.modelHint, id: task.id, key: task.compositeKey ?? task.key }, feature: feature, sprint: sprint, phase: loadConfig(dir).config.currentPhase ?? undefined });
718
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
719
+ const th = resolveThinking({ projectDir: dir, task: { difficulty: effDiff }, feature: feature, sprint: sprint });
720
+ if (!m || !m.trim())
721
+ return null;
722
+ return `Routing: ${task.compositeKey} → ${m}${th ? ` · thinking ${th}` : ""}`;
723
+ }
724
+ catch {
725
+ return null;
726
+ }
727
+ };
728
+ // -- the background engine -------------------------------------------------
729
+ /** Where work runs for this project, honouring the legacy escape hatch. */
730
+ const engineFor = (dir) => {
731
+ try {
732
+ return executionPolicyOf(loadConfig(dir).config).engine;
733
+ }
734
+ catch {
735
+ return "background";
736
+ }
737
+ };
738
+ /**
739
+ * The model this session is on, as `provider/id`.
740
+ *
741
+ * A router slot left empty means "whatever pi is already configured with",
742
+ * and a *child* process does not inherit that: it would fall back to pi's
743
+ * default provider, which is not necessarily what the human is looking at.
744
+ * So we read it here and hand it to the worker explicitly.
745
+ */
746
+ const baseModelOf = (ctx) => {
747
+ try {
748
+ const m = ctx.model;
749
+ if (m?.id)
750
+ return m.provider ? `${m.provider}/${m.id}` : m.id;
751
+ }
752
+ catch {
753
+ /* pi without a model is a pi that cannot run anything anyway */
754
+ }
755
+ return null;
756
+ };
757
+ /**
758
+ * Start the background orchestrator for this project.
759
+ *
760
+ * Everything it does happens in this process, in plain JavaScript. It costs
761
+ * no tokens in this session: the only LLM calls a run makes are made by the
762
+ * `pi` children it spawns, on the models the router chose for them.
763
+ */
764
+ const startEngine = async (ctx, dir) => {
765
+ if (supervisor?.isRunning())
766
+ return true;
767
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc") {
768
+ // `pi -p` has no future in which to watch anything. Arming the run is
769
+ // still right — the next interactive session picks it up.
770
+ notify(ctx, "infinity-harness: run armed. Open pi interactively to drive it.", "info");
771
+ return false;
772
+ }
773
+ const runId = runFor(dir);
774
+ const started = startSupervisor({
775
+ targetDir: dir,
776
+ runId,
777
+ sessionId,
778
+ baseModel: baseModelOf(ctx),
779
+ harnessExtension: SELF_PATH,
780
+ hooks: {
781
+ onState: (st) => {
782
+ supState = st;
783
+ if (sessionLive)
784
+ refreshWidget(widgetCtx ?? ctx);
785
+ },
786
+ onActivity: (line) => {
787
+ activity = [...activity, line].slice(-120);
788
+ if (!sessionLive)
789
+ return;
790
+ // Only the things a human would want interrupted for. Tool-by-tool
791
+ // narration belongs in the widget's log, not in notifications.
792
+ const live = widgetCtx ?? ctx;
793
+ if (line.level === "error" || line.level === "warn" || line.level === "good") {
794
+ try {
795
+ notify(live, `infinity-harness: ${line.text}`, line.level === "error" ? "error" : line.level === "warn" ? "warning" : "info");
796
+ }
797
+ catch { }
798
+ }
799
+ try {
800
+ refreshWidget(live);
801
+ }
802
+ catch { }
803
+ },
804
+ onApproval: (phase) => {
805
+ if (sessionLive)
806
+ void askForApproval(widgetCtx ?? ctx, dir, phase);
807
+ },
808
+ onStop: (reason, detail) => {
809
+ if (!sessionLive)
810
+ return;
811
+ const live = widgetCtx ?? ctx;
812
+ try {
813
+ notify(live, `infinity-harness: run finished — ${detail}`, reason === "complete" ? "info" : "warning");
814
+ }
815
+ catch { }
816
+ try {
817
+ refreshWidget(live);
818
+ }
819
+ catch { }
820
+ },
821
+ },
822
+ });
823
+ if (isRefusal(started)) {
824
+ // A second pi window on the same project is a *viewer*. Saying so is
825
+ // better than quietly putting two workers in one working tree.
826
+ notify(ctx, `infinity-harness: ${started.reason} This window still shows the run.`, "warning");
827
+ supervisor = null;
828
+ refreshWidget(ctx);
829
+ return false;
830
+ }
831
+ supervisor = started;
832
+ return true;
833
+ };
834
+ const stopEngine = async (reason) => {
835
+ const running = supervisor;
836
+ supervisor = null;
837
+ if (!running)
838
+ return;
839
+ try {
840
+ await running.stop(reason);
841
+ }
842
+ catch {
843
+ /* a supervisor that will not stop cleanly must not block the command */
844
+ }
845
+ };
846
+ // -- session handoff ------------------------------------------------------
847
+ /** The task/feature/sprint/goal/subtask the pipeline is on right now, or null. */
848
+ const activePlanKeys = (dir) => {
849
+ try {
850
+ const { list } = loadFeatureList(dir);
851
+ const { config } = loadConfig(dir);
852
+ const phaseTask = nextActionableTask(list, config.currentPhase);
853
+ const task = phaseTask ?? nextActionableTask(list);
854
+ // Resolve sprint/goal via list, and active subtask of the focused task.
855
+ const taskKey = task?.compositeKey ?? null;
856
+ const featureId = task?.featureId ?? null;
857
+ const feature = featureId ? (list.features ?? []).find((f) => f.id === featureId) ?? null : null;
858
+ const sprintId = feature?.sprintId ?? null;
859
+ const goalId = feature?.goalId ?? (sprintId ? (list.sprints ?? []).find((s) => s.id === sprintId)?.goalId ?? null : null) ?? (list.goals?.[0]?.id ?? null);
860
+ const sprint = sprintId ? sprintId : null;
861
+ const goal = goalId ? goalId : null;
862
+ // First non-complete subtask of the active task.
863
+ let subtask = null;
864
+ const rawTask = feature && task ? feature.tasks.find((t) => t.id === task.id || t.key === task.key) ?? null : null;
865
+ if (rawTask?.subtasks?.length) {
866
+ const cur = rawTask.subtasks.find((s) => s.status !== "complete") ?? null;
867
+ if (cur)
868
+ subtask = `${taskKey}#${cur.id ?? cur.title}`;
869
+ }
870
+ return { task: taskKey, feature: featureId, sprint, goal, subtask };
871
+ }
872
+ catch {
873
+ return { task: null, feature: null, sprint: null, goal: null, subtask: null };
874
+ }
875
+ };
876
+ const activeTaskKey = (dir) => activePlanKeys(dir).task;
877
+ /** How full this session's context is, 0..1, or null when pi cannot say. */
878
+ const contextRatio = (ctx) => {
879
+ try {
880
+ const usage = ctx.getContextUsage?.();
881
+ if (!usage || typeof usage.percent !== "number")
882
+ return null;
883
+ return usage.percent > 1 ? usage.percent / 100 : usage.percent;
884
+ }
885
+ catch {
886
+ return null;
887
+ }
888
+ };
889
+ /**
890
+ * Continue the run in a fresh session, if the policy says to.
891
+ *
892
+ * Returns true when a handoff was started, in which case the caller must not
893
+ * also send the brief — the replacement session will.
894
+ *
895
+ * `ctx.newSession` deadlocks if it is called from an event handler, so the
896
+ * actual switch happens in the `/infinity:handoff` command. Queuing that
897
+ * command as a follow-up user message is the documented way to reach a
898
+ * command from a handler.
899
+ */
900
+ const maybeHandOff = async (ctx, dir, brief, fromPhase, toPhase, fromTask) => {
901
+ // A handoff that was asked for and never happened would wedge the run:
902
+ // this session stops driving and the replacement never arrives. One
903
+ // attempt, then carry on here — a run that continues in a fat session is
904
+ // far better than a run that stops.
905
+ // A one-shot `pi -p` run has no next turn to hand anything to: replacing
906
+ // its session mid-flight produces a stale-context error and nothing else.
907
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc")
908
+ return false;
909
+ if (handingOff) {
910
+ if (hasPendingHandoff(dir)) {
911
+ notify(ctx, "infinity-harness: the new session never started — continuing here.", "warning");
912
+ clearHandoff(dir);
913
+ }
914
+ handingOff = false;
915
+ return false;
916
+ }
917
+ try {
918
+ const { config } = loadConfig(dir);
919
+ const toKeys = activePlanKeys(dir);
920
+ // Map caller's fromTask (a compositeKey) back to its feature/sprint etc for the "from" side.
921
+ // We derive them from the plan so goal/sprint/feature boundaries are comparable.
922
+ let fromGoal = null;
923
+ let fromSprint = null;
924
+ let fromFeature = null;
925
+ try {
926
+ const { list } = loadFeatureList(dir);
927
+ if (fromTask) {
928
+ const ft = (() => {
929
+ for (const f of list.features ?? [])
930
+ for (const t of f.tasks ?? [])
931
+ if (t.key === fromTask || `${f.id}/${t.id}` === fromTask || t.id === fromTask)
932
+ return { featureId: f.id };
933
+ return null;
934
+ })();
935
+ if (ft) {
936
+ fromFeature = ft.featureId;
937
+ const feat = list.features.find((f) => f.id === ft.featureId) ?? null;
938
+ fromSprint = feat?.sprintId ?? null;
939
+ fromGoal = feat?.goalId ?? (fromSprint ? (list.sprints ?? []).find((s) => s.id === fromSprint)?.goalId ?? null : null) ?? null;
940
+ }
941
+ }
942
+ }
943
+ catch { }
944
+ const decision = shouldHandoff({
945
+ config,
946
+ fromPhase,
947
+ toPhase,
948
+ fromTask,
949
+ toTask: toKeys.task,
950
+ fromGoal,
951
+ toGoal: toKeys.goal,
952
+ fromSprint,
953
+ toSprint: toKeys.sprint,
954
+ fromFeature,
955
+ toFeature: toKeys.feature,
956
+ fromSubtask: null, // subtask delta is derived from task payload; tracked via fromTask composite + activePlanKeys
957
+ toSubtask: toKeys.subtask,
958
+ contextRatio: contextRatio(ctx),
959
+ });
960
+ if (!decision.handoff)
961
+ return false;
962
+ requestHandoff(dir, {
963
+ reason: decision.reason,
964
+ detail: decision.detail,
965
+ kickoff: composeKickoff(brief, decision.reason, decision.detail, carryNote(dir)),
966
+ carry: carryNote(dir),
967
+ runId: runFor(dir),
968
+ });
969
+ handingOff = true;
970
+ // The replacement session announces itself on arrival; saying it twice
971
+ // here would just make the log look like two handoffs happened.
972
+ pi.sendUserMessage("/infinity:handoff", {
973
+ deliverAs: "followUp",
974
+ expandPromptTemplates: true,
975
+ });
976
+ return true;
977
+ }
978
+ catch (e) {
979
+ // A handoff that cannot be arranged must never end the run. Fall back to
980
+ // continuing in this session, which is exactly the old behaviour.
981
+ notify(ctx, `infinity-harness: staying in this session — ${errMsg(e)}`, "warning");
982
+ handingOff = false;
983
+ clearHandoff(dir);
984
+ return false;
985
+ }
986
+ };
987
+ /** One line on where the run stands, carried into the next session. */
988
+ const carryNote = (dir) => {
989
+ try {
990
+ const { config } = loadConfig(dir);
991
+ if (config.session?.carryNotes === false)
992
+ return null;
993
+ const { list } = loadFeatureList(dir);
994
+ const p = computeProgress(list);
995
+ const recent = (config.gateHistory ?? []).slice(-3).map((g) => `${g.phase}:${g.result}`);
996
+ return (` ${p.tasksDone}/${p.tasksTotal} tasks done, ${p.featuresDone}/${p.featuresTotal} features` +
997
+ (recent.length ? `; recent gates ${recent.join(", ")}` : ""));
998
+ }
999
+ catch {
1000
+ return null;
1001
+ }
1002
+ };
1003
+ // -- approvals ------------------------------------------------------------
1004
+ /**
1005
+ * Collect the human's signature on a phase.
1006
+ *
1007
+ * With dialogs, ask straight away — the human is right there and the run is
1008
+ * stopped for them. Without dialogs there is nobody to ask, so the run parks
1009
+ * and says loudly what it is waiting for, because auto-approving a phase the
1010
+ * human explicitly asked to sign would make the setting a lie.
1011
+ */
1012
+ const askForApproval = async (ctx, dir, phase) => {
1013
+ if (!ctx.hasUI) {
1014
+ disarmRun(dir, `${phase} is waiting for approval`);
1015
+ notify(ctx, `infinity-harness: ${phase.toUpperCase()} needs your approval and this mode has no dialogs. ` +
1016
+ `Run \`/infinity:approve\` (optionally with what is wrong) to continue.`, "warning");
1017
+ return;
1018
+ }
1019
+ const APPROVE = "approve — continue the run";
1020
+ const REJECT = "send it back — I will say what is wrong";
1021
+ const LATER = "not now — park the run";
1022
+ const choice = await ctx.ui.select(`${phase.toUpperCase()} is waiting for you`, [APPROVE, REJECT, LATER]);
1023
+ if (choice === REJECT) {
1024
+ const note = await ctx.ui.input("What needs to change?", "the criteria do not cover refunds");
1025
+ await applyApproval(ctx, dir, note ?? "");
1026
+ return;
1027
+ }
1028
+ if (choice === APPROVE) {
1029
+ await applyApproval(ctx, dir, "");
1030
+ return;
1031
+ }
1032
+ disarmRun(dir, `${phase} is waiting for approval`);
1033
+ notify(ctx, `infinity-harness: parked. \`/infinity:approve\` continues.`, "info");
1034
+ refreshWidget(ctx);
1035
+ };
1036
+ /** Record the verdict and get the run moving again. */
1037
+ const applyApproval = async (ctx, dir, note) => {
1038
+ // Pin a rejection to the project as it is right now, so the run knows
1039
+ // whether the agent has actually done anything about it before asking the
1040
+ // human the same question again.
1041
+ const outcome = resolveApproval(dir, note, note.trim() ? await fingerprint(dir) : "");
1042
+ if (!outcome.ok) {
1043
+ notify(ctx, `infinity-harness: ${outcome.error}`, "warning");
1044
+ return;
1045
+ }
1046
+ refreshWidget(ctx);
1047
+ if (outcome.approved) {
1048
+ notify(ctx, `infinity-harness: ${outcome.phase.toUpperCase()} approved.`, "info");
1049
+ // The gate already passed; re-settling lets the loop advance normally.
1050
+ const moved = await advancePhase(dir);
1051
+ refreshWidget(ctx);
1052
+ if (!moved.ok) {
1053
+ notify(ctx, `infinity-harness: could not advance — ${moved.error}`, "error");
1054
+ return;
1055
+ }
1056
+ const brief = await briefText(dir);
1057
+ lastBriefPhase = moved.to;
1058
+ if (loopArmed(dir) && (await maybeHandOff(ctx, dir, brief, outcome.phase, moved.to, null)))
1059
+ return;
1060
+ pi.sendUserMessage(brief, { deliverAs: "followUp" });
1061
+ return;
1062
+ }
1063
+ notify(ctx, `infinity-harness: ${outcome.phase.toUpperCase()} sent back — ${outcome.note}`, "warning");
1064
+ pi.sendUserMessage(`A human reviewed ${outcome.phase.toUpperCase()} and sent it back:\n\n${outcome.note}\n\n` +
1065
+ `Address that, then validate again.\n\n${await briefText(dir)}`, { deliverAs: "followUp" });
1066
+ };
1067
+ // -- lifecycle ------------------------------------------------------------
1068
+ pi.on("session_start", async (event, ctx) => {
1069
+ const dir = projectDir(ctx);
1070
+ if (!isHarnessProject(dir))
1071
+ return;
1072
+ // A background worker loads this extension too — it needs the plan tools
1073
+ // and the phase guard. What it must not do is drive: no widget, no brief
1074
+ // injection, no loop, and above all no supervisor of its own, or one run
1075
+ // would fork into a tree of pi processes spawning pi processes.
1076
+ if (workerProcess) {
1077
+ try {
1078
+ pi.appendEntry("infinity:worker-session", {
1079
+ unit: process.env.INFINITY_HARNESS_UNIT ?? null,
1080
+ runId: process.env.INFINITY_HARNESS_RUN ?? null,
1081
+ });
1082
+ }
1083
+ catch {
1084
+ /* best effort */
1085
+ }
1086
+ return;
1087
+ }
1088
+ view = defaultView();
1089
+ refreshWidget(ctx);
1090
+ installTerminalShortcuts(ctx);
1091
+ startWidgetPoll(ctx);
1092
+ const reason = event?.reason ?? "startup";
1093
+ const { config } = loadConfig(dir);
1094
+ lastBriefPhase = config.currentPhase;
1095
+ const engine = engineFor(dir);
1096
+ // Under the background engine this session is a control panel: its model
1097
+ // is the human's and stays the human's. Routing only ever applied to the
1098
+ // legacy engine, where the human's session *is* the worker.
1099
+ if (engine === "main-session") {
1100
+ try {
1101
+ await applyRouting(ctx, dir, `session_start:${reason}`);
1102
+ }
1103
+ catch { }
1104
+ }
1105
+ const run = reason === "startup" ? loadRunState(dir) : countSession(dir);
1106
+ const armed = run?.armed === true;
1107
+ notify(ctx, `infinity-harness active · ${config.currentPhase ?? "not started"}` +
1108
+ (armed ? ` · run continuing (session ${run?.sessions ?? 1})` : ""), "info");
1109
+ try {
1110
+ pi.appendEntry("infinity:session", {
1111
+ sessionId,
1112
+ runId: runFor(dir),
1113
+ reason,
1114
+ dir,
1115
+ phase: config.currentPhase,
1116
+ });
1117
+ }
1118
+ catch {
1119
+ /* entry log is best-effort */
1120
+ }
1121
+ // A handoff written by the session this one replaces. It carries the brief
1122
+ // plus the reason the previous session ended, so the agent does not spend
1123
+ // its first turn working out why it woke up mid-run.
1124
+ // The background engine picks a run back up by restarting the supervisor:
1125
+ // the plan, the phase and every budget are on disk, so a pi that was
1126
+ // closed overnight resumes where it stopped rather than starting over.
1127
+ if (engine !== "main-session") {
1128
+ clearHandoff(dir);
1129
+ if (armed) {
1130
+ const ok = await startEngine(ctx, dir);
1131
+ if (ok)
1132
+ notify(ctx, "infinity-harness: continuing the run in background sessions.", "info");
1133
+ }
1134
+ // A short orientation note, not the brief. The brief is several
1135
+ // kilobytes and it is what makes a model start *working* the pipeline;
1136
+ // in a control panel it would be paid for on every turn the human takes
1137
+ // and would invite this session to do the work itself.
1138
+ try {
1139
+ pi.sendMessage({
1140
+ customType: "infinity:brief",
1141
+ content: controlPanelNote(dir, armed),
1142
+ display: true,
1143
+ details: { phase: config.currentPhase, engine },
1144
+ }, { triggerTurn: false });
1145
+ }
1146
+ catch (e) {
1147
+ notify(ctx, `infinity-harness: ${errMsg(e)}`, "warning");
1148
+ }
1149
+ refreshWidget(ctx);
1150
+ return;
1151
+ }
1152
+ const pending = takeHandoff(dir);
1153
+ if (pending && pending.runId === runFor(dir)) {
1154
+ try {
1155
+ pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
1156
+ notify(ctx, `infinity-harness: ${describeHandoff(pending)}`, "info");
1157
+ }
1158
+ catch (e) {
1159
+ notify(ctx, `infinity-harness: handoff failed — ${errMsg(e)}`, "error");
1160
+ }
1161
+ return;
1162
+ }
1163
+ // The brief is delivered as a message rather than a notification so the
1164
+ // model actually reads it. Without this the agent starts from whatever
1165
+ // the user typed and ignores the pipeline entirely.
1166
+ //
1167
+ // `nextTurn` is right in a terminal, where a human is about to type. It is
1168
+ // a deadlock in `pi -p`, which has no next turn and waits forever for one:
1169
+ // the harness made every headless run hang on startup. Non-interactive
1170
+ // modes get `steer`, which folds the brief into the turn already starting.
1171
+ try {
1172
+ const text = await briefText(dir);
1173
+ const interactive = ctx.mode === "tui" || ctx.mode === "rpc";
1174
+ pi.sendMessage({ customType: "infinity:brief", content: text, display: true, details: { phase: config.currentPhase } }, { triggerTurn: false, deliverAs: interactive ? "nextTurn" : "steer" });
1175
+ }
1176
+ catch (e) {
1177
+ notify(ctx, `infinity-harness: could not build brief — ${errMsg(e)}`, "warning");
1178
+ }
1179
+ });
1180
+ /**
1181
+ * The harness contract, in the system prompt.
1182
+ *
1183
+ * Everything else the harness tells the model is a message in the
1184
+ * transcript, and every message in the transcript is something compaction
1185
+ * can summarise into "the assistant was working on a harness". That is how a
1186
+ * long run loses the plot: not by forgetting the plan — the plan is on disk
1187
+ * — but by forgetting that it is *supposed to* work from the plan, stop when
1188
+ * a gate fails, and never mark its own work complete.
1189
+ *
1190
+ * The system prompt is rebuilt from scratch every turn and is never
1191
+ * summarised. Anything the run cannot afford to forget belongs here.
1192
+ */
1193
+ pi.on("before_agent_start", async (event, ctx) => {
1194
+ if (!sessionLive)
1195
+ return;
1196
+ const dir = projectDir(ctx);
1197
+ if (!isHarnessProject(dir))
1198
+ return;
1199
+ // Live-model routing rewrites *this* session's model, which is only ever
1200
+ // right when this session is the worker. Under the background engine the
1201
+ // human's model is theirs, and the routed model belongs to the child.
1202
+ if (engineFor(dir) === "main-session") {
1203
+ try {
1204
+ await applyRouting(ctx, dir, "before_agent_start");
1205
+ }
1206
+ catch { }
1207
+ }
1208
+ try {
1209
+ const viewState = (() => { try {
1210
+ return deriveViewStateSync(dir);
1211
+ }
1212
+ catch {
1213
+ return null;
1214
+ } })();
1215
+ const daemonAlive = (() => { try {
1216
+ const d = readJsonSafe(resolvePath(dir, "harness/daemon.json"), null);
1217
+ if (!d?.heartbeatAt)
1218
+ return false;
1219
+ return Date.now() - new Date(d.heartbeatAt).getTime() < 90_000;
1220
+ }
1221
+ catch {
1222
+ return false;
1223
+ } })() ? "daemon alive" : viewState ? `daemon ${viewState.state}` : "daemon unknown";
1224
+ const armed = (() => { try {
1225
+ const r = readJsonSafe(runStatePathSync(dir), null);
1226
+ return r?.armed === true ? "armed" : "not armed";
1227
+ }
1228
+ catch {
1229
+ return "not armed";
1230
+ } })();
1231
+ const prefix = `[infinity-harness] ${daemonAlive}, ${armed}, phase=${loadConfig(dir).config.currentPhase ?? "?"}${viewState?.reason ? ` — ${viewState.reason}` : ""}\n`;
1232
+ const contract = engineFor(dir) === "main-session" ? harnessContract(dir) : controlPanelContract(dir);
1233
+ if (!contract) {
1234
+ const base0 = event.systemPrompt ?? ctx.getSystemPrompt();
1235
+ return { systemPrompt: `${prefix}\n${base0}` };
1236
+ }
1237
+ const base = event.systemPrompt ?? ctx.getSystemPrompt();
1238
+ const routed = await routingSummaryForBrief(dir);
1239
+ const suffix = routed ? `\n\n${routed}` : "";
1240
+ return { systemPrompt: `${prefix}${base}${suffix}\n\n${contract}` };
1241
+ }
1242
+ catch {
1243
+ return;
1244
+ }
1245
+ });
1246
+ pi.on("session_tree", async (_event, ctx) => {
1247
+ if (!sessionLive || workerProcess)
1248
+ return;
1249
+ refreshWidget(ctx);
1250
+ });
1251
+ /**
1252
+ * Periodic nudge. Long runs drift: the model finishes work and forgets to
1253
+ * record it, so the plan on disk and reality diverge. A short reminder every
1254
+ * few calls costs little and keeps the plan honest.
1255
+ */
1256
+ pi.on("context", async (event, ctx) => {
1257
+ if (!sessionLive || workerProcess)
1258
+ return;
1259
+ const dir = projectDir(ctx);
1260
+ if (!isHarnessProject(dir))
1261
+ return;
1262
+ const messages = event.messages ?? [];
1263
+ // Drop any reminder we injected on a previous call; they are transient
1264
+ // scaffolding, not conversation, and accumulate into real token cost.
1265
+ const filtered = messages.filter((m) => !isOurReminder(m));
1266
+ const pruned = filtered.length !== messages.length ? { messages: filtered } : undefined;
1267
+ let list;
1268
+ try {
1269
+ list = loadFeatureList(dir).list;
1270
+ }
1271
+ catch {
1272
+ return pruned;
1273
+ }
1274
+ const progress = computeProgress(list);
1275
+ if (progress.tasksTotal === 0 || progress.tasksDone === progress.tasksTotal) {
1276
+ llmCalls = 0;
1277
+ return pruned;
1278
+ }
1279
+ llmCalls += 1;
1280
+ if (llmCalls < REMINDER_INTERVAL)
1281
+ return pruned;
1282
+ llmCalls = 0;
1283
+ const open = (list.features ?? [])
1284
+ .flatMap((f) => f.tasks ?? [])
1285
+ .filter((t) => t.status !== "complete")
1286
+ .slice(0, 12)
1287
+ .map((t) => `${t.key ?? t.id}=${t.status}`)
1288
+ .join(", ");
1289
+ const reminder = {
1290
+ role: "user",
1291
+ content: [
1292
+ {
1293
+ type: "text",
1294
+ text: `[infinity-harness] Plan revision ${list.baseRevision}. Open: ${open}. ` +
1295
+ `If the real state differs from this, call infinity_plan with baseRevision ${list.baseRevision} ` +
1296
+ `and the complete task list (omitted keys are deleted).`,
1297
+ },
1298
+ ],
1299
+ timestamp: Date.now(),
1300
+ };
1301
+ return { messages: [...filtered, reminder] };
1302
+ });
1303
+ /**
1304
+ * Compaction drops the transcript.
1305
+ *
1306
+ * The plan survives — it is on disk — and since 2.3 so do the rules, because
1307
+ * they live in the system prompt (`before_agent_start`) where no summariser
1308
+ * can reach them. What is left to restore is the *current* brief, so the
1309
+ * agent picks up on the same task rather than re-deriving one from a summary
1310
+ * of a summary.
1311
+ */
1312
+ pi.on("session_before_compact", async (_event, ctx) => {
1313
+ const dir = projectDir(ctx);
1314
+ if (!isHarnessProject(dir))
1315
+ return;
1316
+ try {
1317
+ const { list } = loadFeatureList(dir);
1318
+ const { config } = loadConfig(dir);
1319
+ pi.appendEntry(CHECKPOINT, {
1320
+ revision: list.baseRevision,
1321
+ phase: config.currentPhase,
1322
+ runId: runFor(dir),
1323
+ at: new Date().toISOString(),
1324
+ });
1325
+ }
1326
+ catch {
1327
+ /* checkpoint is advisory */
1328
+ }
1329
+ });
1330
+ pi.on("session_compact", async (event, ctx) => {
1331
+ if (!sessionLive)
1332
+ return;
1333
+ // A worker that compacts re-reads the brief from its own prompt; the
1334
+ // control-panel re-brief would put a second brief in front of it.
1335
+ if (workerProcess)
1336
+ return;
1337
+ const dir = projectDir(ctx);
1338
+ if (!isHarnessProject(dir))
1339
+ return;
1340
+ try {
1341
+ const text = await briefText(dir);
1342
+ // Delivery mode is the whole bug here. `nextTurn` waits for a human to
1343
+ // type, which never happens in an unattended run — so the re-brief that
1344
+ // was supposed to rescue the agent after compaction sat in a queue while
1345
+ // the agent carried on without it. Overflow compaction retries the
1346
+ // aborted turn immediately, so the brief has to land *in* that turn.
1347
+ const willRetry = event?.willRetry === true;
1348
+ const running = willRetry || !ctx.isIdle?.();
1349
+ pi.sendMessage({
1350
+ customType: "infinity:brief",
1351
+ content: text,
1352
+ display: false,
1353
+ details: { after: "compaction", reason: event?.reason ?? null },
1354
+ }, { triggerTurn: false, deliverAs: running ? "steer" : "nextTurn" });
1355
+ refreshWidget(ctx);
1356
+ }
1357
+ catch {
1358
+ /* the next brief will catch it up */
1359
+ }
1360
+ });
1361
+ pi.on("turn_end", async (_event, ctx) => {
1362
+ if (!sessionLive || workerProcess)
1363
+ return;
1364
+ refreshWidget(ctx);
1365
+ });
1366
+ /**
1367
+ * The loop. `agent_settled` fires when the agent has stopped working, which
1368
+ * is the only safe moment to run the gate and decide what happens next.
1369
+ *
1370
+ * The run's armed flag is read from disk on every tick rather than held in a
1371
+ * closure, so a run survives the session handoffs it now performs, plus
1372
+ * `/reload`, `/resume`, and pi being restarted.
1373
+ */
1374
+ pi.on("agent_settled", async (_event, ctx) => {
1375
+ if (!sessionLive || workerProcess)
1376
+ return;
1377
+ const dir = projectDir(ctx);
1378
+ if (!isHarnessProject(dir))
1379
+ return;
1380
+ if (loopBusy || handingOff)
1381
+ return;
1382
+ if (!loopArmed(dir))
1383
+ return;
1384
+ // The background engine drives the run from the supervisor, in this
1385
+ // process, with no LLM turn in this session at all. Driving it from here
1386
+ // too is what made the human's model pay for the whole run.
1387
+ if (engineFor(dir) !== "main-session")
1388
+ return;
1389
+ loopBusy = true;
1390
+ try {
1391
+ const before = loadConfig(dir).config;
1392
+ const beforeTask = activeTaskKey(dir);
1393
+ const { decision } = await decideNext({ targetDir: dir, runId: runFor(dir) });
1394
+ refreshWidget(ctx);
1395
+ switch (decision.action) {
1396
+ case "advanced": {
1397
+ notify(ctx, `infinity-harness: gate passed → ${decision.toPhase}`, "info");
1398
+ lastBriefPhase = decision.toPhase;
1399
+ if (await maybeHandOff(ctx, dir, decision.message, before.currentPhase, decision.toPhase, beforeTask)) {
1400
+ break;
1401
+ }
1402
+ pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
1403
+ break;
1404
+ }
1405
+ case "continue": {
1406
+ // Say *why* it is going round again. "gate failed" was printed even
1407
+ // when the gate had passed and the run was waiting on a rejection
1408
+ // the agent had not acted on, which reads as a different bug.
1409
+ notify(ctx, `infinity-harness: ${decision.reason} — re-briefing`, "warning");
1410
+ // A failed gate on the same phase is normally the same session's
1411
+ // problem to fix. The exception is context pressure: carrying on in
1412
+ // a session that is about to compact is how a run degrades into
1413
+ // summaries of summaries.
1414
+ if (await maybeHandOff(ctx, dir, decision.message, before.currentPhase, before.currentPhase, beforeTask)) {
1415
+ break;
1416
+ }
1417
+ pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
1418
+ break;
1419
+ }
1420
+ case "approve": {
1421
+ // Not a stop. The run is parked on a human, and the widget, the
1422
+ // status line and the notification all say so.
1423
+ notify(ctx, `infinity-harness: ${decision.detail}`, "warning");
1424
+ pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
1425
+ await askForApproval(ctx, dir, decision.phase);
1426
+ break;
1427
+ }
1428
+ case "wait":
1429
+ disarmRun(dir, decision.detail);
1430
+ notify(ctx, `infinity-harness: ${decision.detail}`, "warning");
1431
+ break;
1432
+ case "stop":
1433
+ disarmRun(dir, decision.detail);
1434
+ notify(ctx, `infinity-harness: run finished — ${decision.detail}`, decision.reason === "complete" ? "info" : "warning");
1435
+ try {
1436
+ pi.appendEntry("infinity:run-end", { reason: decision.reason, detail: decision.detail });
1437
+ }
1438
+ catch {
1439
+ /* best-effort */
1440
+ }
1441
+ break;
1442
+ }
1443
+ refreshWidget(ctx);
1444
+ }
1445
+ catch (e) {
1446
+ disarmRun(dir, `loop error: ${errMsg(e)}`);
1447
+ notify(ctx, `infinity-harness: loop error, stopping — ${errMsg(e)}`, "error");
1448
+ }
1449
+ finally {
1450
+ loopBusy = false;
1451
+ }
1452
+ });
1453
+ /**
1454
+ * The enforcement bit: refuse edits that would skip a phase.
1455
+ *
1456
+ * We only block writes that actually change `currentPhase`. Blocking every
1457
+ * touch of the config would stop the harness configuring itself.
1458
+ */
1459
+ pi.on("tool_call", async (event, ctx) => {
1460
+ if (!sessionLive)
1461
+ return;
1462
+ const dir = projectDir(ctx);
1463
+ if (!isHarnessProject(dir))
1464
+ return;
1465
+ const e = event;
1466
+ const tool = String(e.toolName ?? e.name ?? "");
1467
+ const input = e.input ?? {};
1468
+ const path = String(input.path ?? input.file ?? input.filePath ?? "");
1469
+ const content = String(input.content ?? input.data ?? input.new_string ?? "");
1470
+ const command = String(input.command ?? input.cmd ?? "");
1471
+ const editsPhase = path.replace(/\\/g, "/").includes("harness/config.json") &&
1472
+ /write|edit|replace|patch/i.test(tool) &&
1473
+ /"currentPhase"/.test(content);
1474
+ const shellAdvance = /harness/.test(command) && /\bphase\b/.test(command) && /\bnext\b|\badvance\b/.test(command);
1475
+ if (!editsPhase && !shellAdvance)
1476
+ return;
1477
+ const { config } = loadConfig(dir);
1478
+ if (!config.currentPhase)
1479
+ return;
1480
+ const gate = await runChecks(dir, config.currentPhase, { record: false });
1481
+ if (gate.overall)
1482
+ return;
1483
+ const failing = gate.checks
1484
+ .filter((c) => !c.pass)
1485
+ .map((c) => `${c.name} (${c.detail})`)
1486
+ .join("; ");
1487
+ return {
1488
+ block: true,
1489
+ reason: `infinity-harness: the ${config.currentPhase.toUpperCase()} gate has not passed, so the phase ` +
1490
+ `cannot advance. Failing: ${failing}. Fix these, then let the harness advance the phase — ` +
1491
+ `do not edit harness/config.json by hand.`,
1492
+ };
1493
+ });
1494
+ pi.on("session_shutdown", async () => {
1495
+ stopWidgetPoll();
1496
+ sessionLive = false;
1497
+ // A pi that closes must not leave a worker running against the project.
1498
+ await stopEngine("this pi session closed");
1499
+ if (remoteServer) {
1500
+ try {
1501
+ await remoteServer.close();
1502
+ }
1503
+ catch {
1504
+ /* closing a dead server is fine */
1505
+ }
1506
+ remoteServer = null;
1507
+ remoteDir = null;
1508
+ }
1509
+ loopBusy = false;
1510
+ });
1511
+ // -- tools ----------------------------------------------------------------
1512
+ pi.registerTool({
1513
+ name: "infinity_plan",
1514
+ label: "Plan",
1515
+ description: "Read or rewrite the harness plan. Submit the COMPLETE task list — any key you omit is deleted. " +
1516
+ "Pass baseRevision (from the brief or a previous call) so a concurrent write cannot be clobbered; " +
1517
+ "a stale revision is rejected and you should re-read and resubmit. Omit `tasks` to read the plan.",
1518
+ parameters: {
1519
+ type: "object",
1520
+ properties: {
1521
+ baseRevision: {
1522
+ type: "integer",
1523
+ minimum: 0,
1524
+ description: "Revision you read. Rejected if the plan has moved on.",
1525
+ },
1526
+ tasks: {
1527
+ type: "array",
1528
+ maxItems: 200,
1529
+ description: "Complete authoritative task list. Omit to read without writing.",
1530
+ items: {
1531
+ type: "object",
1532
+ required: ["key"],
1533
+ properties: {
1534
+ key: { type: "string", description: 'Stable key, e.g. "task-004" or "feature-002/task-004"' },
1535
+ subject: { type: "string", description: "What the task is" },
1536
+ status: {
1537
+ type: "string",
1538
+ enum: ["pending", "in_progress", "complete", "blocked", "rework"],
1539
+ },
1540
+ dependsOn: { type: "array", items: { type: "string" }, maxItems: 20 },
1541
+ subtasks: {
1542
+ type: "array",
1543
+ items: {
1544
+ type: "object",
1545
+ required: ["title"],
1546
+ properties: {
1547
+ title: { type: "string" },
1548
+ status: { type: "string", enum: ["pending", "in_progress", "complete"] },
1549
+ },
1550
+ },
1551
+ },
1552
+ difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
1553
+ modelHint: { type: "string" },
1554
+ criteria: { type: "array", items: { type: "string" } },
1555
+ phase: { type: "string", enum: ["research", "define", "plan", "build", "verify", "simplify", "review", "ship"], description: "Which phase owns this task — task.phase, absent means build" },
1556
+ },
1557
+ },
1558
+ },
1559
+ features: {
1560
+ type: "array",
1561
+ maxItems: 100,
1562
+ description: "Feature names and acceptance criteria, merged by id. Unlike tasks, omitting a feature " +
1563
+ "here leaves it alone rather than deleting it. The DEFINE gate requires criteria on " +
1564
+ "every feature, so this is how DEFINE is passed.",
1565
+ items: {
1566
+ type: "object",
1567
+ required: ["id"],
1568
+ properties: {
1569
+ id: { type: "string", description: 'Feature id, e.g. "feature-001"' },
1570
+ name: { type: "string", description: "What the feature is, in a few words" },
1571
+ description: { type: "string" },
1572
+ criteria: {
1573
+ type: "array",
1574
+ items: { type: "string" },
1575
+ description: "How you will know this feature is done. Observable, not aspirational.",
1576
+ },
1577
+ },
1578
+ },
1579
+ },
1580
+ goal: {
1581
+ type: "string",
1582
+ description: "One line: what this whole run is for. Shown at the top of every brief.",
1583
+ },
1584
+ },
1585
+ },
1586
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1587
+ const dir = projectDir(ctx);
1588
+ // A submission with no tasks, no features and no goal is a read.
1589
+ const writing = Array.isArray(params?.tasks) || Array.isArray(params?.features) || typeof params?.goal === "string";
1590
+ if (!writing) {
1591
+ const { list } = loadFeatureList(dir);
1592
+ const p = computeProgress(list);
1593
+ // Features and their criteria are printed, not just tasks: the DEFINE
1594
+ // gate judges criteria, so a plan view that hides them shows the model
1595
+ // everything except the thing it is being marked on.
1596
+ const rows = (list.features ?? [])
1597
+ .flatMap((f) => [
1598
+ `${f.id} · ${f.name}${f.criteria?.length ? "" : " ← no acceptance criteria"}`,
1599
+ ...(f.criteria ?? []).map((c) => ` ✓ ${c}`),
1600
+ ...(f.tasks ?? []).map((t) => ` [${t.status}] ${t.key ?? t.id}: ${t.description}`),
1601
+ ])
1602
+ .join("\n");
1603
+ const goal = (list.goals ?? [])[0]?.title;
1604
+ return {
1605
+ content: [
1606
+ {
1607
+ type: "text",
1608
+ text: `Plan revision ${list.baseRevision} — ${p.tasksDone}/${p.tasksTotal} tasks` +
1609
+ `${goal ? `\nGoal: ${goal}` : ""}\n${rows || "(empty)"}`,
1610
+ },
1611
+ ],
1612
+ details: { revision: list.baseRevision, progress: p },
1613
+ };
1614
+ }
1615
+ try {
1616
+ // writeTaskList takes the plan lock itself, around the whole
1617
+ // read-apply-write. Wrapping it again here would only add a second
1618
+ // lock with weaker semantics.
1619
+ const result = writeTaskList(dir, {
1620
+ baseRevision: params.baseRevision,
1621
+ tasks: params.tasks,
1622
+ features: params.features,
1623
+ goal: params.goal,
1624
+ });
1625
+ refreshWidget(ctx);
1626
+ return {
1627
+ content: [{ type: "text", text: summarizeApply(result) }],
1628
+ details: {
1629
+ revision: result.revision,
1630
+ change: result.change,
1631
+ tasks: result.tasks.map((t) => ({
1632
+ key: t.compositeKey,
1633
+ status: t.status,
1634
+ description: t.description,
1635
+ })),
1636
+ },
1637
+ };
1638
+ }
1639
+ catch (e) {
1640
+ const isValidation = e instanceof ValidationError || e?.name === "ValidationError";
1641
+ const { list } = loadFeatureList(dir);
1642
+ return {
1643
+ content: [
1644
+ {
1645
+ type: "text",
1646
+ text: `${isValidation ? "Rejected" : "Error"}: ${errMsg(e)}\nCurrent revision is ${list.baseRevision}.`,
1647
+ },
1648
+ ],
1649
+ details: { error: errMsg(e), revision: list.baseRevision },
1650
+ isError: true,
1651
+ };
1652
+ }
1653
+ },
1654
+ });
1655
+ pi.registerTool({
1656
+ name: "infinity_validate",
1657
+ label: "Validate",
1658
+ description: "Run the deterministic gate for the current phase and report each check. This is the only way work is " +
1659
+ "judged complete — do not assert completion yourself. Optionally scope to one feature+task.",
1660
+ parameters: {
1661
+ type: "object",
1662
+ properties: {
1663
+ feature: { type: "string", description: "Scope to this feature id" },
1664
+ task: { type: "string", description: "Scope to this task id (requires feature)" },
1665
+ },
1666
+ },
1667
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1668
+ const dir = projectDir(ctx);
1669
+ const { config } = loadConfig(dir);
1670
+ if (!config.currentPhase) {
1671
+ return {
1672
+ content: [{ type: "text", text: "No current phase — the harness is not initialised." }],
1673
+ details: { error: "no-phase" },
1674
+ isError: true,
1675
+ };
1676
+ }
1677
+ const gate = await runChecks(dir, config.currentPhase, {
1678
+ feature: params?.feature,
1679
+ task: params?.task,
1680
+ record: true,
1681
+ });
1682
+ refreshWidget(ctx);
1683
+ const lines = gate.checks
1684
+ .map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
1685
+ .join("\n");
1686
+ // Autopilot means auto-pilot: when the gate passes and the current
1687
+ // phase's phaseMode is autopilot, advance immediately (any phase). The
1688
+ // old allowlist stalled real autopilot builds after RESEARCH → DEFINE.
1689
+ // Copilot still parks via needsApproval check below.
1690
+ if (gate.overall && !params?.feature && !params?.task) {
1691
+ try {
1692
+ const { needsApproval } = await import("../../src/approval.js");
1693
+ const fresh = loadConfig(dir).config;
1694
+ if (!needsApproval(fresh, fresh.currentPhase)) {
1695
+ const { advancePhase, ensurePhaseSeeded } = await import("../../src/core/phases.js");
1696
+ const moved = await advancePhase(dir);
1697
+ if (moved.ok && moved.to) {
1698
+ try {
1699
+ ensurePhaseSeeded(dir, moved.to);
1700
+ }
1701
+ catch { }
1702
+ refreshWidget(ctx);
1703
+ const brief = await briefText(dir);
1704
+ return {
1705
+ content: [
1706
+ {
1707
+ type: "text",
1708
+ text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
1709
+ },
1710
+ ],
1711
+ details: { ...gate, advanced: moved },
1712
+ };
1713
+ }
1714
+ }
1715
+ }
1716
+ catch { }
1717
+ }
1718
+ return {
1719
+ content: [
1720
+ {
1721
+ type: "text",
1722
+ text: `Gate ${gate.overall ? "PASS" : "FAIL"} on ${gate.phase}\n${lines}`,
1723
+ },
1724
+ ],
1725
+ details: gate,
1726
+ };
1727
+ },
1728
+ });
1729
+ pi.registerTool({
1730
+ name: "infinity_advance",
1731
+ label: "Advance Phase",
1732
+ description: "Advance one phase. Refuses unless the current gate passes. Normally the harness does this for you " +
1733
+ "after a passing validate; call it only when you need to advance explicitly.",
1734
+ parameters: { type: "object", properties: {} },
1735
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
1736
+ const dir = projectDir(ctx);
1737
+ const { config } = loadConfig(dir);
1738
+ if (!config.currentPhase) {
1739
+ return {
1740
+ content: [{ type: "text", text: "No current phase." }],
1741
+ details: { error: "no-phase" },
1742
+ isError: true,
1743
+ };
1744
+ }
1745
+ const gate = await runChecks(dir, config.currentPhase, { record: true });
1746
+ if (!gate.overall) {
1747
+ return {
1748
+ content: [
1749
+ {
1750
+ type: "text",
1751
+ text: `Blocked: the ${config.currentPhase} gate failed — ${gate.failures.join(", ")}. ` +
1752
+ `Fix these and validate again.`,
1753
+ },
1754
+ ],
1755
+ details: gate,
1756
+ isError: true,
1757
+ };
1758
+ }
1759
+ const moved = await advancePhase(dir);
1760
+ refreshWidget(ctx);
1761
+ if (!moved.ok) {
1762
+ return {
1763
+ content: [{ type: "text", text: `Could not advance: ${moved.error}` }],
1764
+ details: { error: moved.error },
1765
+ isError: true,
1766
+ };
1767
+ }
1768
+ const text = await briefText(dir);
1769
+ return {
1770
+ content: [{ type: "text", text: `Advanced ${moved.from} → ${moved.to}\n\n${text}` }],
1771
+ details: { from: moved.from, to: moved.to },
1772
+ };
1773
+ },
1774
+ });
1775
+ pi.registerTool({
1776
+ name: "infinity_brief",
1777
+ label: "Next Step",
1778
+ description: "Get the current brief: phase, role, feature, task, acceptance criteria and the gate verdict. " +
1779
+ "Call this when you are unsure what to work on.",
1780
+ parameters: {
1781
+ type: "object",
1782
+ properties: {
1783
+ includeGate: { type: "boolean", description: "Run the gate to include a live verdict (slower)" },
1784
+ },
1785
+ },
1786
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1787
+ const dir = projectDir(ctx);
1788
+ const text = await briefText(dir, Boolean(params?.includeGate));
1789
+ const brief = await buildBrief(dir);
1790
+ return { content: [{ type: "text", text }], details: brief };
1791
+ },
1792
+ });
1793
+ pi.registerTool({
1794
+ name: "infinity_dashboard",
1795
+ label: "Dashboard",
1796
+ description: "Start, stop, or query the read-only web dashboard for this run. It binds to localhost and never " +
1797
+ "mutates harness state — it is for the human watching the run.",
1798
+ parameters: {
1799
+ type: "object",
1800
+ required: ["action"],
1801
+ properties: {
1802
+ action: { type: "string", enum: ["start", "stop", "status"] },
1803
+ port: { type: "integer", minimum: 0, maximum: 65535, description: "0 picks a free port" },
1804
+ host: { type: "string", description: "Bind address, default 127.0.0.1" },
1805
+ },
1806
+ },
1807
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1808
+ const dir = projectDir(ctx);
1809
+ const remote = await import("../../src/remote.js");
1810
+ if (params.action === "stop") {
1811
+ if (!remoteServer) {
1812
+ return { content: [{ type: "text", text: "Dashboard is not running." }], details: { running: false } };
1813
+ }
1814
+ const was = remoteServer.url;
1815
+ await remoteServer.close();
1816
+ remoteServer = null;
1817
+ remoteDir = null;
1818
+ return { content: [{ type: "text", text: `Dashboard stopped (${was}).` }], details: { running: false } };
1819
+ }
1820
+ if (params.action === "status") {
1821
+ const state = remote.buildRemoteState(dir);
1822
+ return {
1823
+ content: [
1824
+ {
1825
+ type: "text",
1826
+ text: remoteServer
1827
+ ? `Dashboard live at ${remoteServer.url} · plan revision ${state.baseRevision}`
1828
+ : `Dashboard not running · plan revision ${state.baseRevision}`,
1829
+ },
1830
+ ],
1831
+ details: { running: Boolean(remoteServer), url: remoteServer?.url ?? null, baseRevision: state.baseRevision },
1832
+ };
1833
+ }
1834
+ if (remoteServer && remoteDir === dir) {
1835
+ return {
1836
+ content: [{ type: "text", text: `Dashboard already live at ${remoteServer.url}` }],
1837
+ details: { running: true, url: remoteServer.url },
1838
+ };
1839
+ }
1840
+ if (remoteServer) {
1841
+ await remoteServer.close();
1842
+ remoteServer = null;
1843
+ }
1844
+ const srv = await remote.createRemoteServer({
1845
+ projectDir: dir,
1846
+ host: params.host ?? "127.0.0.1",
1847
+ port: typeof params.port === "number" ? params.port : 0,
1848
+ });
1849
+ remoteServer = srv;
1850
+ remoteDir = dir;
1851
+ notify(ctx, `infinity-harness dashboard: ${srv.url}`, "info");
1852
+ return {
1853
+ content: [{ type: "text", text: `Dashboard live at ${srv.url}` }],
1854
+ details: { running: true, url: srv.url, port: srv.port },
1855
+ };
1856
+ },
1857
+ });
1858
+ // -- init -----------------------------------------------------------------
1859
+ /**
1860
+ * Said wherever a command finds no harness.
1861
+ *
1862
+ * It used to be "No harness in this project." and nothing else — a dead end
1863
+ * with no exit, in a tool whose every other command needs a harness to work.
1864
+ * A warning that does not say what to do instead is only half a warning.
1865
+ */
1866
+ const NO_HARNESS = "No harness in this project yet. Run /infinity:init to create one.";
1867
+ /** The escalation ladder, in the order it climbs. */
1868
+ const DEFAULT_LADDER = ["retry", "reframe", "consult", "rework", "replan", "master"];
1869
+ /** Everything the pipeline can run. INIT is not a phase you choose. */
1870
+ // Everything except INIT, which is not a phase anyone chooses. RESEARCH is
1871
+ // here because it is a real, optional phase — omitting it from the picker
1872
+ // was the difference between a feature and a feature nobody can find.
1873
+ const SELECTABLE_PHASES = [
1874
+ "research",
1875
+ "define",
1876
+ "plan",
1877
+ "build",
1878
+ "verify",
1879
+ "simplify",
1880
+ "review",
1881
+ "ship",
1882
+ ];
1883
+ pi.registerTool({
1884
+ name: "infinity_init",
1885
+ label: "Init",
1886
+ description: "Create a harness in this project: config, an empty plan, the phase and role docs, and starters " +
1887
+ "for the documents the review gate demands. Detects the stack and its lint/test/build commands. " +
1888
+ "Refuses if a harness already exists unless force is set, and never overwrites an existing file.",
1889
+ parameters: {
1890
+ type: "object",
1891
+ properties: {
1892
+ mode: { type: "string", enum: ["copilot", "autopilot"], description: "copilot keeps the human in the loop" },
1893
+ stack: { type: "string", enum: ["node", "python", "rust", "go", "unknown"] },
1894
+ phases: {
1895
+ type: "array",
1896
+ items: { type: "string", enum: ["define", "plan", "build", "verify", "simplify", "review", "ship"] },
1897
+ description: "Which phases run. Omit for the default pipeline.",
1898
+ },
1899
+ force: { type: "boolean", description: "Restore missing files in a project that already has a harness" },
1900
+ },
1901
+ },
1902
+ async execute(_id, params, _signal, _onUpdate, ctx) {
1903
+ const dir = projectDir(ctx);
1904
+ const result = initHarness(dir, {
1905
+ mode: params?.mode,
1906
+ stack: params?.stack,
1907
+ phases: params?.phases,
1908
+ force: params?.force,
1909
+ });
1910
+ if (!result.ok) {
1911
+ return {
1912
+ content: [{ type: "text", text: result.error ?? "init failed" }],
1913
+ details: result,
1914
+ isError: true,
1915
+ };
1916
+ }
1917
+ refreshWidget(ctx);
1918
+ return { content: [{ type: "text", text: describeInit(result) }], details: result };
1919
+ },
1920
+ });
1921
+ pi.registerCommand("infinity:init", {
1922
+ description: "Set up a harness here — workflow, goal, sessions, display",
1923
+ handler: async (args, ctx) => {
1924
+ const dir = projectDir(ctx);
1925
+ const force = /\bforce\b/.test(args);
1926
+ const goalFromArgs = args.replace(/\bforce\b/g, "").trim();
1927
+ if (isHarnessProject(dir) && !force) {
1928
+ notify(ctx, "This project already has a harness. /infinity:config changes it; /infinity:init force restores missing files.", "warning");
1929
+ return;
1930
+ }
1931
+ const detected = detectStack(dir);
1932
+ // Three things used to be wrong here, and they compounded.
1933
+ //
1934
+ // The wizard never asked what was being built — so picking "autopilot"
1935
+ // started a run with no idea and no scope, and the harness invented a
1936
+ // project and began building it.
1937
+ //
1938
+ // "mode" was the only question, and one switch cannot say "drive
1939
+ // yourself, but show me the plan before you build it".
1940
+ //
1941
+ // And even that switch only reached three phases. It is a mode per
1942
+ // phase now, chosen from a workflow the human can build, name and reuse.
1943
+ // `src/workflow.ts` owns what a workflow is, `src/intake.ts` what the
1944
+ // answers mean, `src/ui/wizard.ts` how they are asked.
1945
+ if (ctx.hasUI) {
1946
+ const cmds = Object.entries(detected.commands).filter(([, v]) => Boolean(v));
1947
+ const summary = cmds.length ? cmds.map(([k, v]) => `${k}: ${v}`).join(", ") : "no commands detected";
1948
+ const go = await ctx.ui.select(`Create a harness here? ${detected.label} · ${summary}`, [
1949
+ "yes",
1950
+ "cancel",
1951
+ ]);
1952
+ if (go === undefined || go === "cancel") {
1953
+ notify(ctx, "init cancelled — nothing was written.", "info");
1954
+ return;
1955
+ }
1956
+ }
1957
+ const wizard = ctx.hasUI
1958
+ ? await runIntakeWizard({ prompt: prompterFor(ctx), brief: goalFromArgs || null, models: () => availableModels(ctx) })
1959
+ : { cancelled: false, plan: unattendedIntake(goalFromArgs || null) };
1960
+ if (wizard.cancelled) {
1961
+ notify(ctx, "init cancelled — nothing was written.", "info");
1962
+ return;
1963
+ }
1964
+ const plan = wizard.plan;
1965
+ const result = initHarness(dir, {
1966
+ mode: plan.mode,
1967
+ phases: plan.phases,
1968
+ approvals: plan.approvals,
1969
+ phaseModes: plan.phaseModes,
1970
+ workflow: plan.workflow,
1971
+ display: plan.display,
1972
+ session: plan.session,
1973
+ execution: { parallelAt: plan.execution?.parallelAt ?? "task", maxWorkers: plan.execution?.maxWorkers ?? 3 },
1974
+ brief: plan.brief,
1975
+ router: plan.router
1976
+ ? {
1977
+ enabled: !!plan.router.enabled,
1978
+ byDifficulty: plan.router.byDifficulty,
1979
+ thinkingByDifficulty: plan.router.thinkingByDifficulty,
1980
+ master: plan.router.master ?? "",
1981
+ thinkingMaster: plan.router.thinkingMaster,
1982
+ default: plan.router.default ?? "",
1983
+ thinkingDefault: plan.router.thinkingDefault,
1984
+ }
1985
+ : undefined,
1986
+ force,
1987
+ });
1988
+ if (!result.ok) {
1989
+ notify(ctx, result.error ?? "init failed", "error");
1990
+ return;
1991
+ }
1992
+ // The wizard already showed the summary before the human confirmed it;
1993
+ // repeating it verbatim here is noise. Warnings do repeat — they are the
1994
+ // part worth seeing twice.
1995
+ const lines = [describeInit(result)];
1996
+ if (plan.warnings.length)
1997
+ lines.push("", ...plan.warnings.map((w) => `! ${w}`));
1998
+ notify(ctx, lines.join("\n"), plan.warnings.length ? "warning" : "info");
1999
+ refreshWidget(ctx);
2000
+ // Show the next step, but do NOT auto-start the run.
2001
+ // After /infinity:init the harness is initialized and parked.
2002
+ // The agent can answer questions in this session, but the pipeline
2003
+ // only runs after the human says so: /infinity:run (or "yes" to the
2004
+ // last wizard question). Auto-starting here is the bug that made
2005
+ // full-auto research steal the session that just created the harness.
2006
+ if (wizard.launchNow) {
2007
+ // Opt-in: wizard asked "Start the run now?" and the human said yes.
2008
+ armRun(dir, sessionId);
2009
+ // Capture baseModel for daemon (same as /infinity:run does)
2010
+ try {
2011
+ const bm = baseModelOf(ctx);
2012
+ if (bm) {
2013
+ const { loadRunState: _ld, saveRunState: _sv, runIdFor: _rf } = await import("../../src/core/runState.js");
2014
+ const rid = _rf(dir, sessionId);
2015
+ const rs = _ld(dir);
2016
+ const parts = bm.split("/");
2017
+ const baseModel = parts.length >= 2 ? { provider: parts[0], id: parts.slice(1).join("/") } : { provider: "anthropic", id: bm };
2018
+ if (rs) {
2019
+ rs.baseModel = baseModel;
2020
+ _sv(dir, rs);
2021
+ }
2022
+ else {
2023
+ const { newRunState } = await import("../../src/core/runState.js");
2024
+ const ns = newRunState(rid);
2025
+ ns.baseModel = baseModel;
2026
+ _sv(dir, ns);
2027
+ }
2028
+ }
2029
+ }
2030
+ catch { }
2031
+ // Try detached daemon, fall back to supervisor in this pi session.
2032
+ const tryDaemonSpawn = async () => {
2033
+ try {
2034
+ const { spawn: _sp } = await import("node:child_process");
2035
+ const { existsSync: _ex, openSync: _op, closeSync: _cl } = await import("node:fs");
2036
+ const { resolve: _re } = await import("node:path");
2037
+ // Daemon lives in the *installed package*, not the target project.
2038
+ // Prefer the package's own dist (works in every install, including Node 26 where
2039
+ // stripping .ts under node_modules is ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING).
2040
+ // The .ts src entries are kept only for unpacked dev checkouts without a build.
2041
+ let pkgDir = null;
2042
+ try {
2043
+ pkgDir = _re(dirname(fileURLToPath(import.meta.url)), "../..");
2044
+ }
2045
+ catch { }
2046
+ const pkgJs = pkgDir ? _re(pkgDir, "dist/src/daemon/index.js") : null;
2047
+ const pkgTs = pkgDir ? _re(pkgDir, "src/daemon/index.ts") : null;
2048
+ const altJs = pkgDir ? _re(pkgDir, "dist/daemon/index.js") : null;
2049
+ const cands = [pkgJs, altJs, _re(dir, "dist/daemon/index.js"), _re(dir, "dist/src/daemon/index.js"), pkgTs, _re(dir, "src/daemon/index.ts")].filter(Boolean);
2050
+ let entry = null;
2051
+ for (const c of cands)
2052
+ if (_ex(c)) {
2053
+ entry = c;
2054
+ break;
2055
+ }
2056
+ if (!entry)
2057
+ return { spawned: false };
2058
+ try {
2059
+ const { loadDaemon: _ld, isDaemonAlive: _al } = await import("../../src/daemon/guard.js");
2060
+ const live = _ld(dir);
2061
+ if (live && _al(live))
2062
+ return { spawned: true };
2063
+ }
2064
+ catch { }
2065
+ const fd = _op(_re(dir, "harness/daemon.log"), "a");
2066
+ const a = [];
2067
+ if (entry.endsWith(".ts"))
2068
+ a.push("--experimental-strip-types", "--no-warnings=ExperimentalWarning");
2069
+ a.push(entry, dir);
2070
+ const ch = _sp(process.execPath, a, { detached: true, stdio: ["ignore", fd, fd], windowsHide: true, env: { ...process.env, INFINITY_HARNESS_WORKER: "1" } });
2071
+ try {
2072
+ ch.unref();
2073
+ }
2074
+ catch { }
2075
+ try {
2076
+ _cl(fd);
2077
+ }
2078
+ catch { }
2079
+ for (let i = 0; i < 40; i++) {
2080
+ await new Promise(r => setTimeout(r, 250));
2081
+ try {
2082
+ const { loadDaemon: l2, isDaemonAlive: a2 } = await import("../../src/daemon/guard.js");
2083
+ const v = l2(dir);
2084
+ if (v && a2(v))
2085
+ return { spawned: true };
2086
+ }
2087
+ catch { }
2088
+ }
2089
+ return { spawned: true };
2090
+ }
2091
+ catch {
2092
+ return { spawned: false };
2093
+ }
2094
+ };
2095
+ const dr = await tryDaemonSpawn();
2096
+ if (!dr.spawned)
2097
+ await startEngine(ctx, dir);
2098
+ notify(ctx, "infinity-harness: run armed — background run started. /infinity:halt stops it.", "info");
2099
+ }
2100
+ else {
2101
+ // The harness is parked — do NOT trigger an agent turn. The
2102
+ // control-panel contract (before_agent_start) already stops
2103
+ // autonomous work, but a `followUp` brief starts one anyway.
2104
+ // `triggerTurn: false` keeps it visible without waking the model.
2105
+ const brief = await briefText(dir);
2106
+ const parkedNote = !plan.brief
2107
+ ? `The human has not said what they want built yet. Ask them, in one short question, and do not start any work or invent a scope until they answer. The harness is NOT running — "/infinity:run" starts it.\n\n${brief}`
2108
+ : plan.phases[0] === "research"
2109
+ ? `${brief}\n\nThis is RESEARCH — survey constraints and options first, then validate (infinity_validate) to advance. The harness is NOT running yet. Run "/infinity:run" when you are ready, or answer "yes — start the run now" next time you init.`
2110
+ : `${brief}\n\nThe harness is ready but NOT running. Run "/infinity:run" to start, or answer "yes — start the run now" in the wizard next time.`;
2111
+ try {
2112
+ pi.sendMessage({ customType: "infinity:brief", content: parkedNote, display: true, details: { parked: true, phase: plan.phases[0] ?? null } }, { triggerTurn: false });
2113
+ }
2114
+ catch (e) {
2115
+ notify(ctx, `infinity-harness: ${errMsg(e)}`, "warning");
2116
+ }
2117
+ }
2118
+ },
2119
+ });
2120
+ /**
2121
+ * Change the workflow mid-run.
2122
+ *
2123
+ * Any of this is editable at any time and takes effect on the next gate —
2124
+ * a run three phases deep is exactly when someone realises they do want to
2125
+ * see the review after all.
2126
+ */
2127
+ pi.registerCommand("infinity:workflow", {
2128
+ description: "Choose or build the workflow — which phases run, and which stop for you",
2129
+ handler: async (args, ctx) => {
2130
+ const dir = projectDir(ctx);
2131
+ if (!isHarnessProject(dir)) {
2132
+ notify(ctx, NO_HARNESS, "warning");
2133
+ return;
2134
+ }
2135
+ const { config } = loadConfig(dir);
2136
+ const arg = args.trim();
2137
+ if (arg === "" && !ctx.hasUI) {
2138
+ notify(ctx, describeCurrentWorkflow(dir), "info");
2139
+ return;
2140
+ }
2141
+ if (arg === "show" || arg === "list") {
2142
+ const rows = listWorkflows().map((w) => ` ${w.builtIn ? " " : "*"} ${w.name} — ${w.description}`);
2143
+ notify(ctx, `${describeCurrentWorkflow(dir)}\n\nAvailable (* = yours):\n${rows.join("\n")}`, "info");
2144
+ return;
2145
+ }
2146
+ // `/infinity:workflow <name>` switches without a menu, which is what a
2147
+ // second run in the same terminal wants.
2148
+ let chosen = arg ? findWorkflow(arg) : undefined;
2149
+ if (arg && !chosen) {
2150
+ notify(ctx, `No workflow called "${arg}". \`/infinity:workflow list\` shows them.`, "warning");
2151
+ return;
2152
+ }
2153
+ if (!chosen) {
2154
+ if (!ctx.hasUI) {
2155
+ notify(ctx, "This mode has no dialogs — `/infinity:workflow <name>` switches directly.", "warning");
2156
+ return;
2157
+ }
2158
+ chosen = (await pickWorkflow(prompterFor(ctx))) ?? undefined;
2159
+ if (!chosen) {
2160
+ notify(ctx, "Unchanged.", "info");
2161
+ return;
2162
+ }
2163
+ }
2164
+ const { value } = await withLock(configPath(dir), () => {
2165
+ const fresh = loadConfig(dir);
2166
+ if (!fresh.ok)
2167
+ return false;
2168
+ applyWorkflow(fresh.config, chosen);
2169
+ // Keep the legacy field in step so a 2.3 tool reading this config
2170
+ // still sees the same three answers it understands.
2171
+ fresh.config.approvals = {
2172
+ research: fresh.config.phaseModes?.research === "copilot",
2173
+ define: fresh.config.phaseModes?.define === "copilot",
2174
+ plan: fresh.config.phaseModes?.plan === "copilot",
2175
+ };
2176
+ fresh.config.mode = signedPhases(fresh.config).length > 0 ? "copilot" : "autopilot";
2177
+ return saveConfig(dir, fresh.config).ok;
2178
+ });
2179
+ if (!value) {
2180
+ notify(ctx, "Could not save the workflow — config unreadable.", "error");
2181
+ return;
2182
+ }
2183
+ notify(ctx, `${renderWorkflow(chosen)}\n\nIt takes effect at the next gate.`, "info");
2184
+ refreshWidget(ctx);
2185
+ void config;
2186
+ },
2187
+ });
2188
+ pi.registerCommand("infinity:display", {
2189
+ description: "Choose what the widget and the dashboard show, level by level",
2190
+ handler: async (args, ctx) => {
2191
+ const dir = projectDir(ctx);
2192
+ if (!isHarnessProject(dir)) {
2193
+ notify(ctx, NO_HARNESS, "warning");
2194
+ return;
2195
+ }
2196
+ const arg = args.trim();
2197
+ const current = normalizeDisplay(loadConfig(dir).config.display);
2198
+ if (arg === "show" || arg === "list") {
2199
+ const rows = listDisplays().map((d) => ` ${d.builtIn ? " " : "*"} ${d.name} — ${d.description}`);
2200
+ notify(ctx, `Now: ${summarizeDisplay(current)}\n\nTemplates (* = yours):\n${rows.join("\n")}`, "info");
2201
+ return;
2202
+ }
2203
+ let next;
2204
+ if (arg) {
2205
+ const template = findDisplay(arg);
2206
+ if (!template) {
2207
+ notify(ctx, `No template called "${arg}". \`/infinity:display list\` shows them.`, "warning");
2208
+ return;
2209
+ }
2210
+ next = template.policy;
2211
+ }
2212
+ else {
2213
+ if (!ctx.hasUI) {
2214
+ notify(ctx, `Now: ${summarizeDisplay(current)}. \`/infinity:display <template>\` switches.`, "info");
2215
+ return;
2216
+ }
2217
+ next = await pickDisplay(prompterFor(ctx));
2218
+ if (!next) {
2219
+ notify(ctx, "Unchanged.", "info");
2220
+ return;
2221
+ }
2222
+ }
2223
+ const { value } = await withLock(configPath(dir), () => {
2224
+ const fresh = loadConfig(dir);
2225
+ if (!fresh.ok)
2226
+ return false;
2227
+ fresh.config.display = normalizeDisplay(next);
2228
+ return saveConfig(dir, fresh.config).ok;
2229
+ });
2230
+ if (!value) {
2231
+ notify(ctx, "Could not save the display settings — config unreadable.", "error");
2232
+ return;
2233
+ }
2234
+ // The widget is the answer to "did that do what I wanted", so redraw it
2235
+ // before saying anything about it.
2236
+ view = defaultView();
2237
+ refreshWidget(ctx);
2238
+ notify(ctx, `Showing: ${summarizeDisplay(normalizeDisplay(next))}`, "info");
2239
+ },
2240
+ });
2241
+ /**
2242
+ * Continue the run in a replacement session.
2243
+ *
2244
+ * This is a command rather than something the loop does directly because
2245
+ * `ctx.newSession` is only safe from a command handler — pi deadlocks if an
2246
+ * event handler calls it. The loop queues `/infinity:handoff` as a follow-up
2247
+ * and this does the switch.
2248
+ *
2249
+ * Everything the next session needs is already on disk. `withSession` may
2250
+ * only touch the context it is handed: the old `pi` and `ctx` are dead by
2251
+ * the time it runs.
2252
+ */
2253
+ pi.registerCommand("infinity:handoff", {
2254
+ description: "Continue this run in a fresh session, carrying the brief",
2255
+ handler: async (args, ctx) => {
2256
+ const dir = projectDir(ctx);
2257
+ if (!isHarnessProject(dir)) {
2258
+ notify(ctx, NO_HARNESS, "warning");
2259
+ return;
2260
+ }
2261
+ // Asked for by hand, with no handoff queued: make one.
2262
+ if (!hasPendingHandoff(dir)) {
2263
+ const brief = await briefText(dir);
2264
+ requestHandoff(dir, {
2265
+ reason: "manual",
2266
+ detail: args.trim() || "requested by hand",
2267
+ kickoff: composeKickoff(brief, "manual", args.trim() || "requested by hand", carryNote(dir)),
2268
+ carry: carryNote(dir),
2269
+ runId: runFor(dir),
2270
+ });
2271
+ }
2272
+ const pending = takeHandoff(dir);
2273
+ if (!pending) {
2274
+ notify(ctx, "infinity-harness: nothing to hand off.", "warning");
2275
+ return;
2276
+ }
2277
+ // The replacement session reads this back from disk in `session_start`;
2278
+ // put it back so the claim above does not consume it.
2279
+ requestHandoff(dir, {
2280
+ reason: pending.reason,
2281
+ detail: pending.detail,
2282
+ kickoff: pending.kickoff,
2283
+ carry: pending.carry,
2284
+ runId: pending.runId,
2285
+ });
2286
+ handingOff = false;
2287
+ try {
2288
+ await ctx.waitForIdle?.();
2289
+ const parent = ctx.sessionManager?.getSessionFile?.() ?? undefined;
2290
+ const result = await ctx.newSession({ parentSession: parent ?? undefined });
2291
+ if (result?.cancelled) {
2292
+ clearHandoff(dir);
2293
+ notify(ctx, "infinity-harness: handoff cancelled — continuing here.", "warning");
2294
+ pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
2295
+ }
2296
+ }
2297
+ catch (e) {
2298
+ // A handoff that cannot happen must never end the run: fall back to
2299
+ // carrying on in this session, which is the pre-2.3 behaviour.
2300
+ //
2301
+ // Unless the session is already gone — `newSession` can fail *after*
2302
+ // replacing the runtime, and reaching for the old `pi` then is the
2303
+ // "stale ctx" error rather than a recovery.
2304
+ clearHandoff(dir);
2305
+ if (!sessionLive)
2306
+ return;
2307
+ notify(ctx, `infinity-harness: could not start a new session — ${errMsg(e)}`, "warning");
2308
+ pi.sendUserMessage(pending.kickoff, { deliverAs: "followUp" });
2309
+ }
2310
+ },
2311
+ });
2312
+ /**
2313
+ * What the background sessions are doing.
2314
+ *
2315
+ * The run's work is no longer in this transcript, so this is where a human
2316
+ * looks when they come back to the terminal. It prints rather than asking
2317
+ * the model anything: reading the log must never cost a turn.
2318
+ */
2319
+ pi.registerCommand("infinity:workers", {
2320
+ description: "Show the background pi sessions — which unit, which model, and the recent log",
2321
+ handler: async (args, ctx) => {
2322
+ const dir = projectDir(ctx);
2323
+ if (!isHarnessProject(dir)) {
2324
+ notify(ctx, NO_HARNESS, "warning");
2325
+ return;
2326
+ }
2327
+ const rows = Math.max(5, Math.min(120, Number.parseInt(args.trim(), 10) || 25));
2328
+ const st = loadSupervisorState(dir);
2329
+ const log = loadActivity(dir);
2330
+ const engine = engineFor(dir);
2331
+ const lines = [];
2332
+ lines.push(engine === "background"
2333
+ ? "Work runs in background pi sessions. This session spends nothing on it."
2334
+ : "Work runs in THIS session (execution.engine = main-session). Your model is paying for the run.");
2335
+ if (st?.unit)
2336
+ lines.push(`Unit ${describeUnit(st.unit, st.baseModel)}`);
2337
+ if (st?.worker) {
2338
+ const w = st.worker;
2339
+ lines.push(`Worker ${w.name} · ${w.state} · ${w.unitLabel} · asked ${w.model || "pi default"}` +
2340
+ (w.servedModel && w.servedModel !== w.model ? ` · served ${w.servedModel}` : "") +
2341
+ ` · ${w.turns} turn(s) · ${w.tokens.inputTokens + w.tokens.outputTokens} tokens`);
2342
+ if (w.doing)
2343
+ lines.push(` ${w.doing}`);
2344
+ }
2345
+ else {
2346
+ lines.push("Worker none running");
2347
+ }
2348
+ if (st?.history?.length) {
2349
+ lines.push("");
2350
+ lines.push("Finished sessions (newest last):");
2351
+ for (const h of st.history.slice(-6)) {
2352
+ lines.push(` ${h.name} · ${h.unitLabel} · ${h.servedModel ?? (h.model || "pi default")} · ${h.turns} turn(s)`);
2353
+ }
2354
+ }
2355
+ lines.push("");
2356
+ lines.push(log.length ? `Background log (last ${Math.min(rows, log.length)}):` : "Background log is empty.");
2357
+ for (const l of log.slice(-rows)) {
2358
+ const when = l.at.slice(11, 16);
2359
+ lines.push(` ${when} ${l.worker ? l.worker + " " : ""}${l.text}`);
2360
+ }
2361
+ pi.sendMessage({ customType: "infinity:workers", content: lines.join("\n"), display: true, details: { engine } }, { triggerTurn: false });
2362
+ },
2363
+ });
2364
+ pi.registerCommand("infinity:approve", {
2365
+ description: "Approve the phase waiting for you — or send it back with a note (via daemon when running)",
2366
+ handler: async (args, ctx) => {
2367
+ const dir = projectDir(ctx);
2368
+ if (!isHarnessProject(dir)) {
2369
+ notify(ctx, NO_HARNESS, "warning");
2370
+ return;
2371
+ }
2372
+ // Prefer daemon when alive: forward approval to localhost daemon (token-guarded).
2373
+ try {
2374
+ const d = readJsonSafe(resolvePath(dir, "harness/daemon.json"), null);
2375
+ if (d?.port) {
2376
+ const h = { "Content-Type": "application/json" };
2377
+ if (d.token)
2378
+ h["Authorization"] = `Bearer ${d.token}`;
2379
+ const r = await fetch(`http://127.0.0.1:${d.port}/approve`, { method: "POST", headers: h, body: JSON.stringify({ note: args.trim() }) });
2380
+ const j = await r.json().catch(() => ({}));
2381
+ if (r.ok && j.ok !== false) {
2382
+ notify(ctx, "infinity-harness: approved via daemon.", "info");
2383
+ refreshWidget(ctx);
2384
+ return;
2385
+ }
2386
+ }
2387
+ }
2388
+ catch { }
2389
+ const { config } = loadConfig(dir);
2390
+ if (!config.awaitingApproval) {
2391
+ const signing = approvedPhases(config);
2392
+ notify(ctx, signing.length
2393
+ ? `Nothing is waiting. You are signing: ${signing.map((p) => p.toUpperCase()).join(", ")}.`
2394
+ : "Nothing is waiting, and you are not signing any phase. /infinity:config changes that.", "info");
2395
+ return;
2396
+ }
2397
+ if (!loopArmed(dir))
2398
+ armRun(dir, sessionId);
2399
+ await applyApproval(ctx, dir, args.trim());
2400
+ },
2401
+ });
2402
+ // -- escalation, rework, replan -------------------------------------------- // -- escalation, rework, replan --------------------------------------------
2403
+ pi.registerTool({
2404
+ name: "infinity_rework",
2405
+ label: "Rework",
2406
+ description: "Send a task and everything that depends on it back to `rework`. Use when work built on a task " +
2407
+ "turns out not to hold up: the dependents were built on the broken thing, so they are suspect " +
2408
+ "until re-proved. Bounded by the rework budget.",
2409
+ parameters: {
2410
+ type: "object",
2411
+ required: ["task"],
2412
+ properties: {
2413
+ task: { type: "string", description: 'Task key, e.g. "feature-001/task-003"' },
2414
+ reason: { type: "string", description: "Why this is going backwards" },
2415
+ maxImpactDepth: { type: "integer", minimum: 1, maximum: 10 },
2416
+ },
2417
+ },
2418
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2419
+ const dir = projectDir(ctx);
2420
+ const { list } = loadFeatureList(dir);
2421
+ const target = flattenTasks(list).find((t) => t.compositeKey === params.task || t.key === params.task || t.id === params.task);
2422
+ if (!target) {
2423
+ return {
2424
+ content: [{ type: "text", text: `No task matches "${params.task}".` }],
2425
+ details: { error: "no-such-task" },
2426
+ isError: true,
2427
+ };
2428
+ }
2429
+ try {
2430
+ const result = await startRework({
2431
+ projectDir: dir,
2432
+ featureId: target.featureId,
2433
+ taskId: target.id,
2434
+ key: target.key,
2435
+ reason: params.reason ?? "rework requested",
2436
+ runId: runFor(dir),
2437
+ maxImpactDepth: params.maxImpactDepth,
2438
+ });
2439
+ refreshWidget(ctx);
2440
+ const downstream = result.impacted.length
2441
+ ? `Also flipped ${result.impacted.length} dependent task(s): ${result.impacted.join(", ")}`
2442
+ : "Nothing depends on it, so this is contained.";
2443
+ return {
2444
+ content: [
2445
+ {
2446
+ type: "text",
2447
+ text: `${target.compositeKey} is back in rework (plan revision ${result.baseRevision}).\n${downstream}\nFix the root task first, then re-prove the rest.`,
2448
+ },
2449
+ ],
2450
+ details: result,
2451
+ };
2452
+ }
2453
+ catch (e) {
2454
+ return {
2455
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
2456
+ details: { error: "rework-failed" },
2457
+ isError: true,
2458
+ };
2459
+ }
2460
+ },
2461
+ });
2462
+ pi.registerTool({
2463
+ name: "infinity_replan",
2464
+ label: "Replan",
2465
+ description: "Add sprints, features or tasks to the plan mid-run, without resubmitting the whole task list. " +
2466
+ "Use when the work turns out to need something that was never planned — the plan is the record, " +
2467
+ "and building what it does not contain leaves it lying. Bounded by the replan budget.",
2468
+ parameters: {
2469
+ type: "object",
2470
+ properties: {
2471
+ reason: { type: "string", description: "What the plan was missing" },
2472
+ addFeatures: {
2473
+ type: "array",
2474
+ maxItems: 20,
2475
+ items: {
2476
+ type: "object",
2477
+ required: ["id", "name"],
2478
+ properties: {
2479
+ id: { type: "string" },
2480
+ name: { type: "string" },
2481
+ description: { type: "string" },
2482
+ difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
2483
+ },
2484
+ },
2485
+ },
2486
+ addTasks: {
2487
+ type: "array",
2488
+ maxItems: 50,
2489
+ items: {
2490
+ type: "object",
2491
+ required: ["featureId", "task"],
2492
+ properties: {
2493
+ featureId: { type: "string" },
2494
+ task: {
2495
+ type: "object",
2496
+ required: ["id", "description"],
2497
+ properties: {
2498
+ id: { type: "string" },
2499
+ key: { type: "string" },
2500
+ description: { type: "string" },
2501
+ status: { type: "string", enum: ["pending", "in_progress", "complete", "blocked", "rework"] },
2502
+ dependsOn: { type: "array", items: { type: "string" } },
2503
+ difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
2504
+ acceptanceCriteria: { type: "array", items: { type: "string" } },
2505
+ },
2506
+ },
2507
+ },
2508
+ },
2509
+ },
2510
+ },
2511
+ },
2512
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2513
+ const dir = projectDir(ctx);
2514
+ try {
2515
+ const result = await amendPlan({
2516
+ projectDir: dir,
2517
+ reason: params.reason ?? "mid-run amendment",
2518
+ addFeatures: params.addFeatures,
2519
+ addTasks: params.addTasks,
2520
+ });
2521
+ refreshWidget(ctx);
2522
+ return {
2523
+ content: [
2524
+ {
2525
+ type: "text",
2526
+ text: `Plan amended to revision ${result.baseRevision}: ` +
2527
+ `+${result.added.features} feature(s), +${result.added.tasks} task(s), ` +
2528
+ `+${result.added.sprints} sprint(s).`,
2529
+ },
2530
+ ],
2531
+ details: result,
2532
+ };
2533
+ }
2534
+ catch (e) {
2535
+ return {
2536
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
2537
+ details: { error: "replan-failed" },
2538
+ isError: true,
2539
+ };
2540
+ }
2541
+ },
2542
+ });
2543
+ pi.registerTool({
2544
+ name: "infinity_unstuck",
2545
+ label: "Unstuck",
2546
+ description: "Ask the escalation ladder what to try next: retry, reframe, consult a stronger model, rework, " +
2547
+ "replan, or master. Read-only — it recommends, it does not act. /infinity:run consults it " +
2548
+ "automatically when a run stalls; call it yourself when you are stuck and want the next rung.",
2549
+ parameters: { type: "object", properties: {} },
2550
+ async execute(_id, _params, _signal, _onUpdate, ctx) {
2551
+ const dir = projectDir(ctx);
2552
+ const { list } = loadFeatureList(dir);
2553
+ const task = flattenTasks(list).find((t) => t.status === "in_progress" || t.status === "rework");
2554
+ const choice = chooseUnstuckStrategy({
2555
+ projectDir: dir,
2556
+ featureId: task?.featureId,
2557
+ taskId: task?.id,
2558
+ currentDifficulty: task?.difficulty ?? null,
2559
+ requireDeltaForRework: false,
2560
+ });
2561
+ const spent = escalationSummary(dir);
2562
+ const text = choice.strategy
2563
+ ? `Next rung: ${choice.strategy} — ${choice.reason}` +
2564
+ (choice.nextModel ? `\nModel: ${choice.nextModel}` : "") +
2565
+ `\nSpent so far: ${spent.reworks} rework(s), ${spent.replans} replan(s)` +
2566
+ (spent.returnTo ? `, returning to ${spent.returnTo}` : "")
2567
+ : `The ladder has nothing left: ${choice.reason}. This needs a human.`;
2568
+ return { content: [{ type: "text", text }], details: { ...choice, spent } };
2569
+ },
2570
+ });
2571
+ pi.registerTool({
2572
+ name: "infinity_spawn_worker",
2573
+ label: "Spawn Worker",
2574
+ description: "Run one task in an isolated worker: its own attempt directory, prompt, output log and " +
2575
+ "fingerprint under tmp/. Use for a task worth attempting without the current conversation's " +
2576
+ "context — a clean-room retry. Records the attempt whether or not a command is configured.",
2577
+ parameters: {
2578
+ type: "object",
2579
+ required: ["task", "prompt"],
2580
+ properties: {
2581
+ task: { type: "string", description: 'Task key, e.g. "feature-001/task-003"' },
2582
+ prompt: { type: "string", description: "The complete instruction for the isolated worker" },
2583
+ command: { type: "string", description: "Shell command to run; {promptfile} is substituted" },
2584
+ model: { type: "string", description: "Model reference for the worker" },
2585
+ timeoutMs: { type: "integer", minimum: 1000, maximum: 3_600_000 },
2586
+ },
2587
+ },
2588
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2589
+ const dir = projectDir(ctx);
2590
+ const { list } = loadFeatureList(dir);
2591
+ const target = flattenTasks(list).find((t) => t.compositeKey === params.task || t.key === params.task || t.id === params.task);
2592
+ if (!target) {
2593
+ return {
2594
+ content: [{ type: "text", text: `No task matches "${params.task}".` }],
2595
+ details: { error: "no-such-task" },
2596
+ isError: true,
2597
+ };
2598
+ }
2599
+ try {
2600
+ const result = await spawnIsolatedWorker({
2601
+ projectDir: dir,
2602
+ runId: runFor(dir),
2603
+ featureId: target.featureId,
2604
+ taskId: target.id,
2605
+ prompt: params.prompt,
2606
+ command: params.command,
2607
+ model: params.model,
2608
+ timeoutMs: params.timeoutMs,
2609
+ });
2610
+ const ran = params.command
2611
+ ? `exit ${result.exitCode}${result.timedOut ? " (timed out)" : ""}`
2612
+ : "recorded only — no command configured";
2613
+ return {
2614
+ content: [
2615
+ {
2616
+ type: "text",
2617
+ text: `Worker attempt ${result.attempt} for ${target.compositeKey}: ${ran}\n` +
2618
+ `${result.attemptDir}\n\n${result.output.slice(-4000) || "(no output)"}`,
2619
+ },
2620
+ ],
2621
+ details: result,
2622
+ };
2623
+ }
2624
+ catch (e) {
2625
+ return {
2626
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
2627
+ details: { error: "worker-failed" },
2628
+ isError: true,
2629
+ };
2630
+ }
2631
+ },
2632
+ });
2633
+ pi.registerTool({
2634
+ name: "infinity_goal",
2635
+ label: "Goal",
2636
+ description: "The outer loop. `start` states a goal and opens pass 1; `status` reports where it is; " +
2637
+ "`review` judges whether the work so far actually meets the goal and, if it does not, rewinds " +
2638
+ "the pipeline for another pass with the remaining work named; `cancel` stops pursuing it. " +
2639
+ "The phase gate decides whether the WORK is done; this decides whether the GOAL is done.",
2640
+ parameters: {
2641
+ type: "object",
2642
+ required: ["action"],
2643
+ properties: {
2644
+ action: { type: "string", enum: ["start", "status", "review", "cancel"] },
2645
+ goal: { type: "string", description: "start: what this whole run is for, in one sentence" },
2646
+ maxIterations: { type: "integer", minimum: 1, maximum: 50, description: "start: how many passes at most" },
2647
+ decision: {
2648
+ type: "string",
2649
+ enum: ["complete", "incomplete", "blocked", "failed"],
2650
+ description: "review: does the work meet the goal?",
2651
+ },
2652
+ rationale: { type: "string", description: "review: why, judged against the goal not the plan" },
2653
+ remainingWork: {
2654
+ type: "array",
2655
+ items: { type: "string" },
2656
+ description: "review: required unless complete — what is still missing. The next pass is planned from this.",
2657
+ },
2658
+ reason: { type: "string", description: "cancel: why" },
2659
+ },
2660
+ },
2661
+ async execute(_id, params, _signal, _onUpdate, ctx) {
2662
+ const dir = projectDir(ctx);
2663
+ try {
2664
+ if (params.action === "start") {
2665
+ if (!params.goal?.trim()) {
2666
+ return {
2667
+ content: [{ type: "text", text: "A goal needs to say something." }],
2668
+ details: { error: "no-goal" },
2669
+ isError: true,
2670
+ };
2671
+ }
2672
+ const { state } = await startGoal({
2673
+ targetDir: dir,
2674
+ goal: params.goal,
2675
+ runId: `goal-${runFor(dir)}`,
2676
+ maxIterations: params.maxIterations,
2677
+ });
2678
+ refreshWidget(ctx);
2679
+ const view = viewOf(state);
2680
+ return {
2681
+ content: [
2682
+ {
2683
+ type: "text",
2684
+ text: `Goal set: ${view.goal}\nPass 1 of at most ${view.maxIterations}. The pipeline is at the ` +
2685
+ `first phase — define what this needs, plan it, build it. When the pipeline completes, ` +
2686
+ `call infinity_goal with action "review" and judge it against the goal, not the plan.`,
2687
+ },
2688
+ ],
2689
+ details: view,
2690
+ };
2691
+ }
2692
+ if (params.action === "status") {
2693
+ const state = await loadGoal(dir);
2694
+ if (!state) {
2695
+ return {
2696
+ content: [{ type: "text", text: "No goal is being pursued in this project." }],
2697
+ details: { active: false },
2698
+ };
2699
+ }
2700
+ const view = viewOf(state);
2701
+ const remaining = view.remainingWork.length
2702
+ ? `\nStill missing:\n${view.remainingWork.map((w) => ` - ${w}`).join("\n")}`
2703
+ : "";
2704
+ return {
2705
+ content: [{ type: "text", text: `${describeGoal(view)}\nPhase: ${view.phase}${remaining}` }],
2706
+ details: view,
2707
+ };
2708
+ }
2709
+ if (params.action === "review") {
2710
+ if (!params.decision || !params.rationale?.trim()) {
2711
+ return {
2712
+ content: [{ type: "text", text: "A review needs a decision and a rationale." }],
2713
+ details: { error: "incomplete-review" },
2714
+ isError: true,
2715
+ };
2716
+ }
2717
+ const outcome = await reviewGoal(dir, {
2718
+ decision: params.decision,
2719
+ rationale: params.rationale,
2720
+ remainingWork: params.remainingWork,
2721
+ });
2722
+ refreshWidget(ctx);
2723
+ if (!outcome.terminal) {
2724
+ // A new goal pass is the largest context boundary there is: the
2725
+ // pipeline has rewound to its first phase and the next pass plans
2726
+ // for what is left, not for what the last one already built.
2727
+ // Carrying a whole finished pass of conversation into it is the
2728
+ // worst case of the problem session handoff exists to solve.
2729
+ const brief = await briefText(dir);
2730
+ const { config } = loadConfig(dir);
2731
+ if (config.session?.handoff !== "off" && loopArmed(dir)) {
2732
+ const detail = `goal pass ${config.goalPass ?? "next"}`;
2733
+ requestHandoff(dir, {
2734
+ reason: "goal-pass",
2735
+ detail,
2736
+ kickoff: composeKickoff(brief, "goal-pass", detail, carryNote(dir)),
2737
+ carry: carryNote(dir),
2738
+ runId: runFor(dir),
2739
+ });
2740
+ handingOff = true;
2741
+ pi.sendUserMessage("/infinity:handoff", {
2742
+ deliverAs: "followUp",
2743
+ expandPromptTemplates: true,
2744
+ });
2745
+ }
2746
+ else {
2747
+ // Rewinding the pipeline means the next brief is a different one.
2748
+ pi.sendUserMessage(brief, { deliverAs: "followUp" });
2749
+ }
2750
+ }
2751
+ return { content: [{ type: "text", text: outcome.message }], details: viewOf(outcome.state) };
2752
+ }
2753
+ if (params.action === "cancel") {
2754
+ const state = await cancelGoal(dir, params.reason ?? "cancelled by request");
2755
+ refreshWidget(ctx);
2756
+ return {
2757
+ content: [{ type: "text", text: state ? `Goal cancelled: ${state.goal}` : "No goal to cancel." }],
2758
+ details: state ? viewOf(state) : { active: false },
2759
+ };
2760
+ }
2761
+ return {
2762
+ content: [{ type: "text", text: `Unknown action "${params.action}".` }],
2763
+ details: { error: "unknown-action" },
2764
+ isError: true,
2765
+ };
2766
+ }
2767
+ catch (e) {
2768
+ return {
2769
+ content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }],
2770
+ details: { error: "goal-failed" },
2771
+ isError: true,
2772
+ };
2773
+ }
2774
+ },
2775
+ });
2776
+ // -- commands -------------------------------------------------------------
2777
+ pi.registerCommand("infinity:status", {
2778
+ description: "Show the current phase, plan and gate state",
2779
+ handler: async (_args, ctx) => {
2780
+ const dir = projectDir(ctx);
2781
+ if (!isHarnessProject(dir)) {
2782
+ notify(ctx, NO_HARNESS, "warning");
2783
+ return;
2784
+ }
2785
+ const state = widgetStateFor(dir);
2786
+ if (state) {
2787
+ notify(ctx, renderWidget(state, { width: 76, styler, glyphs, boxed: true }).join("\n"), "info");
2788
+ }
2789
+ refreshWidget(ctx);
2790
+ },
2791
+ });
2792
+ pi.registerCommand("infinity:next", {
2793
+ description: "Print the current brief",
2794
+ handler: async (_args, ctx) => {
2795
+ const dir = projectDir(ctx);
2796
+ // Without this guard it printed a brief for a project with no harness —
2797
+ // a page of pipeline instructions for a pipeline that does not exist.
2798
+ if (!isHarnessProject(dir)) {
2799
+ notify(ctx, NO_HARNESS, "warning");
2800
+ return;
2801
+ }
2802
+ notify(ctx, await briefText(dir), "info");
2803
+ },
2804
+ });
2805
+ pi.registerCommand("infinity:validate", {
2806
+ description: "Run the gate for the current phase",
2807
+ handler: async (_args, ctx) => {
2808
+ const dir = projectDir(ctx);
2809
+ if (!isHarnessProject(dir)) {
2810
+ notify(ctx, NO_HARNESS, "warning");
2811
+ return;
2812
+ }
2813
+ const { config } = loadConfig(dir);
2814
+ if (!config.currentPhase) {
2815
+ notify(ctx, "No current phase.", "warning");
2816
+ return;
2817
+ }
2818
+ const gate = await runChecks(dir, config.currentPhase, { record: true });
2819
+ const lines = gate.checks.map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`);
2820
+ notify(ctx, `Gate ${gate.overall ? "PASS" : "FAIL"}\n${lines.join("\n")}`, gate.overall ? "info" : "warning");
2821
+ refreshWidget(ctx);
2822
+ },
2823
+ });
2824
+ pi.registerCommand("infinity:run", {
2825
+ description: "Start the continuous loop — validate, advance, re-brief, until done or stuck (captures baseModel for daemon)",
2826
+ handler: async (_args, ctx) => {
2827
+ const dir = projectDir(ctx);
2828
+ if (!isHarnessProject(dir)) {
2829
+ notify(ctx, NO_HARNESS, "warning");
2830
+ return;
2831
+ }
2832
+ // Capture baseModel (X) at arm time — the detached Daemon has no ctx.model.
2833
+ // Without this, Daemon refuses to arm and silently falls back to pi default.
2834
+ try {
2835
+ const bm = baseModelOf(ctx);
2836
+ if (bm) {
2837
+ const { loadRunState: _load, saveRunState: _save, runIdFor: _runIdFor } = await import("../../src/core/runState.js");
2838
+ const runId = _runIdFor(dir, sessionId);
2839
+ const rs = _load(dir);
2840
+ const parts = bm.split("/");
2841
+ const baseModel = parts.length >= 2 ? { provider: parts[0], id: parts.slice(1).join("/") } : { provider: "anthropic", id: bm };
2842
+ if (rs) {
2843
+ rs.baseModel = baseModel;
2844
+ _save(dir, rs);
2845
+ }
2846
+ else {
2847
+ const { newRunState } = await import("../../src/core/runState.js");
2848
+ const ns = newRunState(runId);
2849
+ ns.baseModel = baseModel;
2850
+ _save(dir, ns);
2851
+ }
2852
+ }
2853
+ }
2854
+ catch { }
2855
+ // When a detached Daemon can serve this repo, prefer it: it is the only
2856
+ // path where /infinity:halt|approve|pilot|replan go to Daemon and the
2857
+ // model stays on X. Spawning it is one detached `node` process.
2858
+ const tryDaemonSpawn = async () => {
2859
+ try {
2860
+ const { spawn } = await import("node:child_process");
2861
+ const { existsSync, openSync, closeSync } = await import("node:fs");
2862
+ const { resolve: _resolve } = await import("node:path");
2863
+ // Daemon entry must exist; when not built (dev) fall back to supervisor.
2864
+ // Package dist first — Node 26 bans stripping .ts under node_modules.
2865
+ let _pkgDir = null;
2866
+ try {
2867
+ _pkgDir = dirname(fileURLToPath(import.meta.url));
2868
+ _pkgDir = _resolve(_pkgDir, "../..");
2869
+ }
2870
+ catch { }
2871
+ const _pkgJs = _pkgDir ? _resolve(_pkgDir, "dist/src/daemon/index.js") : null;
2872
+ const _altJs = _pkgDir ? _resolve(_pkgDir, "dist/daemon/index.js") : null;
2873
+ const _pkgTs = _pkgDir ? _resolve(_pkgDir, "src/daemon/index.ts") : null;
2874
+ const candidates = [
2875
+ _pkgJs,
2876
+ _altJs,
2877
+ _resolve(dir, "dist/src/daemon/index.js"),
2878
+ _resolve(dir, "dist/daemon/index.js"),
2879
+ _pkgTs,
2880
+ _resolve(dir, "src/daemon/index.ts"),
2881
+ ].filter(Boolean);
2882
+ let entry = null;
2883
+ for (const c of candidates)
2884
+ if (existsSync(c)) {
2885
+ entry = c;
2886
+ break;
2887
+ }
2888
+ if (!entry)
2889
+ return { spawned: false };
2890
+ // Guard: single owner — if a daemon is already alive, do not spawn rival.
2891
+ try {
2892
+ const { loadDaemon: _ld, isDaemonAlive: _alive } = await import("../../src/daemon/guard.js");
2893
+ const live = _ld(dir);
2894
+ if (live && _alive(live))
2895
+ return { spawned: true, url: `http://127.0.0.1:${live.port}/dashboard` };
2896
+ }
2897
+ catch { }
2898
+ const logFd = openSync(_resolve(dir, "harness/daemon.log"), "a");
2899
+ const args = [];
2900
+ if (entry.endsWith(".ts"))
2901
+ args.push("--experimental-strip-types", "--no-warnings=ExperimentalWarning");
2902
+ args.push(entry, dir);
2903
+ const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true, env: { ...process.env, INFINITY_HARNESS_WORKER: "1" } });
2904
+ try {
2905
+ child.unref();
2906
+ }
2907
+ catch { }
2908
+ try {
2909
+ closeSync(logFd);
2910
+ }
2911
+ catch { }
2912
+ // Brief poll for daemon.json liveness (port picked as 0).
2913
+ for (let i = 0; i < 40; i++) {
2914
+ await new Promise(r => setTimeout(r, 250));
2915
+ try {
2916
+ const { loadDaemon: ld2, isDaemonAlive: alive2 } = await import("../../src/daemon/guard.js");
2917
+ const live2 = ld2(dir);
2918
+ if (live2 && alive2(live2))
2919
+ return { spawned: true, url: `http://127.0.0.1:${live2.port}/dashboard` };
2920
+ }
2921
+ catch { }
2922
+ }
2923
+ return { spawned: true };
2924
+ }
2925
+ catch {
2926
+ return { spawned: false };
2927
+ }
2928
+ };
2929
+ armRun(dir, sessionId);
2930
+ const engine = engineFor(dir);
2931
+ notify(ctx, `infinity-harness: continuous run armed. It stops on completion, on an exhausted retry budget, ` +
2932
+ `when no progress is detected, or when you create ${stopFilePath(dir)}. Use /infinity:halt to stop now.`, "info");
2933
+ if (engine === "main-session") {
2934
+ const text = await briefText(dir);
2935
+ pi.sendUserMessage(text, { deliverAs: "followUp" });
2936
+ return;
2937
+ }
2938
+ const daemonResult = await tryDaemonSpawn();
2939
+ if (daemonResult.spawned) {
2940
+ notify(ctx, daemonResult.url ? `infinity-harness: daemon running at ${daemonResult.url} — this session stays free; /infinity:halt stops it.` : "infinity-harness: daemon started — this session stays free; /infinity:halt stops it.", "info");
2941
+ refreshWidget(ctx);
2942
+ return;
2943
+ }
2944
+ // Fallback: legacy background supervisor (spawns pi child sessions in this pi process).
2945
+ const unit = currentUnit(dir, baseModelOf(ctx));
2946
+ await startEngine(ctx, dir);
2947
+ notify(ctx, unit
2948
+ ? `infinity-harness: working in background sessions — ${describeUnit(unit, baseModelOf(ctx))}. ` +
2949
+ `This session stays free; /infinity:workers shows what they are doing.`
2950
+ : "infinity-harness: background engine started.", "info");
2951
+ refreshWidget(ctx);
2952
+ },
2953
+ });
2954
+ pi.registerCommand("infinity:goal", {
2955
+ description: "State a goal and pursue it across passes — or review, cancel, or check one",
2956
+ handler: async (args, ctx) => {
2957
+ const dir = projectDir(ctx);
2958
+ if (!isHarnessProject(dir)) {
2959
+ notify(ctx, NO_HARNESS, "warning");
2960
+ return;
2961
+ }
2962
+ const text = args.trim();
2963
+ if (text === "" || text === "status") {
2964
+ const state = await loadGoal(dir);
2965
+ if (!state) {
2966
+ notify(ctx, 'No goal set. `/infinity:goal <what you want built>` starts one.', "info");
2967
+ return;
2968
+ }
2969
+ const view = viewOf(state);
2970
+ const remaining = view.remainingWork.length
2971
+ ? `\nStill missing:\n${view.remainingWork.map((w) => ` - ${w}`).join("\n")}`
2972
+ : "";
2973
+ notify(ctx, `${describeGoal(view)}\nPhase: ${view.phase}${remaining}`, "info");
2974
+ return;
2975
+ }
2976
+ if (text === "cancel") {
2977
+ const state = await cancelGoal(dir, "cancelled from /infinity:goal");
2978
+ notify(ctx, state ? `Goal cancelled: ${state.goal}` : "No goal to cancel.", "info");
2979
+ refreshWidget(ctx);
2980
+ return;
2981
+ }
2982
+ try {
2983
+ const { state } = await startGoal({ targetDir: dir, goal: text, runId: `goal-${runFor(dir)}` });
2984
+ refreshWidget(ctx);
2985
+ notify(ctx, `Goal set: ${state.goal}\nPass 1 of at most ${state.limits.maxIterations}. ` +
2986
+ `The pipeline is back at its first phase.`, "info");
2987
+ pi.sendUserMessage(await briefText(dir), { deliverAs: "followUp" });
2988
+ }
2989
+ catch (e) {
2990
+ notify(ctx, e instanceof Error ? e.message : String(e), "error");
2991
+ }
2992
+ },
2993
+ });
2994
+ pi.registerCommand("infinity:unstuck", {
2995
+ description: "What the escalation ladder would try next, and what it has spent",
2996
+ handler: async (_args, ctx) => {
2997
+ const dir = projectDir(ctx);
2998
+ if (!isHarnessProject(dir)) {
2999
+ notify(ctx, NO_HARNESS, "warning");
3000
+ return;
3001
+ }
3002
+ const { list } = loadFeatureList(dir);
3003
+ const task = flattenTasks(list).find((t) => t.status === "in_progress" || t.status === "rework");
3004
+ const choice = chooseUnstuckStrategy({
3005
+ projectDir: dir,
3006
+ featureId: task?.featureId,
3007
+ taskId: task?.id,
3008
+ currentDifficulty: task?.difficulty ?? null,
3009
+ requireDeltaForRework: false,
3010
+ });
3011
+ const spent = escalationSummary(dir);
3012
+ const rework = loadRework(dir);
3013
+ const lines = [
3014
+ choice.strategy
3015
+ ? `Next rung: ${choice.strategy} — ${choice.reason}`
3016
+ : `The ladder has nothing left: ${choice.reason}`,
3017
+ choice.nextModel ? `Model: ${choice.nextModel}` : null,
3018
+ `Spent: ${spent.reworks} rework(s), ${spent.replans} replan(s)`,
3019
+ rework ? `Returning to ${rework.returnFeature}/${rework.returnTask} — ${rework.reason}` : null,
3020
+ `Ladder: ${DEFAULT_LADDER.join(" → ")}`,
3021
+ ].filter((l) => l !== null);
3022
+ notify(ctx, lines.join("\n"), "info");
3023
+ },
3024
+ });
3025
+ pi.registerCommand("infinity:rework", {
3026
+ description: "Send a task and its dependents back to rework (via daemon when running)",
3027
+ handler: async (args, ctx) => {
3028
+ const dir = projectDir(ctx);
3029
+ if (!isHarnessProject(dir)) {
3030
+ notify(ctx, NO_HARNESS, "warning");
3031
+ return;
3032
+ }
3033
+ const key = args.trim();
3034
+ // Daemon owns rework.json mutation — forward when alive.
3035
+ if (key && key !== "clear") {
3036
+ try {
3037
+ const d = readJsonSafe(resolvePath(dir, "harness/daemon.json"), null);
3038
+ if (d?.port) {
3039
+ const h = { "Content-Type": "application/json" };
3040
+ if (d.token)
3041
+ h["Authorization"] = `Bearer ${d.token}`;
3042
+ const r = await fetch(`http://127.0.0.1:${d.port}/rework`, { method: "POST", headers: h, body: JSON.stringify({ task: key }) });
3043
+ const j = await r.json().catch(() => ({}));
3044
+ if (r.ok && j.ok !== false) {
3045
+ notify(ctx, `infinity-harness: rework ${key} via daemon.`, "info");
3046
+ refreshWidget(ctx);
3047
+ return;
3048
+ }
3049
+ }
3050
+ }
3051
+ catch { }
3052
+ }
3053
+ const { list } = loadFeatureList(dir);
3054
+ const tasks = flattenTasks(list);
3055
+ if (key === "clear") {
3056
+ await clearRework(dir);
3057
+ notify(ctx, "Rework record cleared.", "info");
3058
+ refreshWidget(ctx);
3059
+ return;
3060
+ }
3061
+ let target = tasks.find((t) => t.compositeKey === key || t.key === key || t.id === key);
3062
+ if (!target && ctx.hasUI) {
3063
+ const rows = tasks.map((t) => `${t.compositeKey} [${t.status}] ${t.description}`);
3064
+ const picked = await ctx.ui.select("Send which task back to rework?", rows);
3065
+ if (picked === undefined)
3066
+ return;
3067
+ target = tasks[rows.indexOf(picked)];
3068
+ }
3069
+ if (!target) {
3070
+ notify(ctx, key ? `No task matches "${key}".` : "Name a task: /infinity:rework <task-key>", "warning");
3071
+ return;
3072
+ }
3073
+ try {
3074
+ const result = await startRework({
3075
+ projectDir: dir,
3076
+ featureId: target.featureId,
3077
+ taskId: target.id,
3078
+ key: target.key,
3079
+ reason: "rework from /infinity:rework",
3080
+ runId: runFor(dir),
3081
+ });
3082
+ refreshWidget(ctx);
3083
+ notify(ctx, `${target.compositeKey} → rework (revision ${result.baseRevision}). ` +
3084
+ (result.impacted.length
3085
+ ? `${result.impacted.length} dependent task(s) went with it: ${result.impacted.join(", ")}`
3086
+ : "Nothing depends on it."), "info");
3087
+ }
3088
+ catch (e) {
3089
+ notify(ctx, e instanceof Error ? e.message : String(e), "error");
3090
+ }
3091
+ },
3092
+ });
3093
+ pi.registerCommand("infinity:halt", {
3094
+ description: "Stop the continuous loop after the current turn",
3095
+ handler: async (_args, ctx) => {
3096
+ const dir = projectDir(ctx);
3097
+ if (!isHarnessProject(dir)) {
3098
+ notify(ctx, NO_HARNESS, "warning");
3099
+ return;
3100
+ }
3101
+ // Prefer daemon path: POST /halt to daemon (token-guarded), fall back to local.
3102
+ try {
3103
+ const d = readJsonSafe(resolvePath(dir, "harness/daemon.json"), null);
3104
+ if (d?.port) {
3105
+ const headers = { "Content-Type": "application/json" };
3106
+ if (d.token)
3107
+ headers["Authorization"] = `Bearer ${d.token}`;
3108
+ const r = await fetch(`http://127.0.0.1:${d.port}/halt`, { method: "POST", headers, body: JSON.stringify({}) });
3109
+ const j = await r.json().catch(() => ({}));
3110
+ if (r.ok && j.ok !== false) {
3111
+ notify(ctx, "infinity-harness: halted via daemon.", "info");
3112
+ refreshWidget(ctx);
3113
+ return;
3114
+ }
3115
+ }
3116
+ }
3117
+ catch { }
3118
+ disarmRun(dir, "halted from /infinity:halt");
3119
+ clearHandoff(dir);
3120
+ await stopEngine("halted from /infinity:halt");
3121
+ notify(ctx, "infinity-harness: continuous run stopped, background sessions closed.", "info");
3122
+ refreshWidget(ctx);
3123
+ },
3124
+ });
3125
+ pi.registerCommand("infinity:pause", {
3126
+ description: "Pause the pipeline (persisted in harness/config.json)",
3127
+ handler: async (_args, ctx) => {
3128
+ const dir = projectDir(ctx);
3129
+ if (!isHarnessProject(dir)) {
3130
+ notify(ctx, NO_HARNESS, "warning");
3131
+ return;
3132
+ }
3133
+ const { value } = await withLock(configPath(dir), () => {
3134
+ const { config, ok } = loadConfig(dir);
3135
+ if (!ok)
3136
+ return false;
3137
+ config.paused = true;
3138
+ return saveConfig(dir, config).ok;
3139
+ });
3140
+ disarmRun(dir, "paused from /infinity:pause");
3141
+ await stopEngine("paused from /infinity:pause");
3142
+ notify(ctx, value ? "infinity-harness: paused, background sessions closed." : "Could not pause — config unreadable.", value ? "info" : "error");
3143
+ refreshWidget(ctx);
3144
+ },
3145
+ });
3146
+ pi.registerCommand("infinity:resume", {
3147
+ description: "Unpause the pipeline",
3148
+ handler: async (_args, ctx) => {
3149
+ const dir = projectDir(ctx);
3150
+ if (!isHarnessProject(dir)) {
3151
+ notify(ctx, NO_HARNESS, "warning");
3152
+ return;
3153
+ }
3154
+ const { value } = await withLock(configPath(dir), () => {
3155
+ const { config, ok } = loadConfig(dir);
3156
+ if (!ok)
3157
+ return false;
3158
+ config.paused = false;
3159
+ return saveConfig(dir, config).ok;
3160
+ });
3161
+ if (value && loadRunState(dir)?.armed === true && engineFor(dir) !== "main-session") {
3162
+ await startEngine(ctx, dir);
3163
+ }
3164
+ notify(ctx, value ? "infinity-harness: resumed." : "Could not resume — config unreadable.", value ? "info" : "error");
3165
+ refreshWidget(ctx);
3166
+ },
3167
+ });
3168
+ pi.registerCommand("infinity:config", {
3169
+ description: "Configure the harness — models per difficulty tier, gates, commands, loop budgets",
3170
+ handler: async (args, ctx) => {
3171
+ const dir = projectDir(ctx);
3172
+ if (!isHarnessProject(dir)) {
3173
+ notify(ctx, NO_HARNESS, "warning");
3174
+ return;
3175
+ }
3176
+ // `\/infinity:config show` prints everything without prompting, which is
3177
+ // what you want over SSH, in a log, or when the UI has no dialogs.
3178
+ if (args.trim() === "show" || !ctx.hasUI) {
3179
+ notify(ctx, renderSettings(dir), "info");
3180
+ if (!ctx.hasUI && args.trim() !== "show") {
3181
+ notify(ctx, "This mode has no dialogs — edit harness/config.json directly.", "warning");
3182
+ }
3183
+ return;
3184
+ }
3185
+ const changed = await runConfigMenu({
3186
+ targetDir: dir,
3187
+ prompt: prompterFor(ctx),
3188
+ models: () => availableModels(ctx),
3189
+ });
3190
+ if (changed.length === 0) {
3191
+ notify(ctx, "infinity-harness: no changes.", "info");
3192
+ }
3193
+ else {
3194
+ notify(ctx, `infinity-harness: updated ${changed.join(", ")}`, "info");
3195
+ }
3196
+ refreshWidget(ctx);
3197
+ },
3198
+ });
3199
+ pi.registerCommand("infinity:models", {
3200
+ description: "Show which models pi has available, and how the harness is routing them",
3201
+ handler: async (_args, ctx) => {
3202
+ const dir = projectDir(ctx);
3203
+ const models = availableModels(ctx);
3204
+ const lines = [];
3205
+ lines.push(`Models pi can use (${models.length})`);
3206
+ if (models.length === 0) {
3207
+ lines.push(" none — check provider auth, or run `pi models`");
3208
+ }
3209
+ else {
3210
+ for (const m of models)
3211
+ lines.push(` ${m.label}`);
3212
+ }
3213
+ if (isHarnessProject(dir)) {
3214
+ lines.push("", "Routing");
3215
+ const io = readAll(dir);
3216
+ const group = SETTINGS.find((g) => g.id === "models");
3217
+ for (const s of group?.settings ?? []) {
3218
+ lines.push(` ${s.label.padEnd(28)} ${formatValue(s, readSetting(io, s))}`);
3219
+ }
3220
+ lines.push("", "Change these with /infinity:config.");
3221
+ }
3222
+ notify(ctx, lines.join("\n"), "info");
3223
+ },
3224
+ });
3225
+ // -- keys -----------------------------------------------------------------
3226
+ //
3227
+ // The widget is nine rows of a plan that is routinely sixty. Without these
3228
+ // the other fifty-one are unreachable without opening the dashboard, which
3229
+ // is not a thing anyone does mid-glance.
3230
+ //
3231
+ // `alt+` and not `ctrl+`: pi already binds ctrl+j (newline), ctrl+k (delete
3232
+ // to line end) and ctrl+o (expand tool output). Shadowing an editor key to
3233
+ // scroll a widget would be a worse bug than the one being fixed.
3234
+ //
3235
+ // Shortcuts are editor-focused via registerShortcut, but also handled as a
3236
+ // raw terminal fallback so they work when an overlay or selector has focus
3237
+ // or when the terminal sends the legacy ESC+j sequence that the editor
3238
+ // otherwise swallows as text.
3239
+ const scrollDown = async (ctx) => moveView(ctx, SCROLL_STEP);
3240
+ const scrollUp = async (ctx) => moveView(ctx, -SCROLL_STEP);
3241
+ const toggleExpand = async (ctx) => {
3242
+ view = { ...view, expanded: !view.expanded };
3243
+ refreshWidget(ctx);
3244
+ };
3245
+ // Widget scroll/expand shortcuts — registered via pi's KeyId ("alt+j") so
3246
+ // they participate in keybinding resolution, plus a raw terminal fallback so
3247
+ // they also work while a ctx.ui.select dialog owns focus (where the editor
3248
+ // shortcut path is shadowed) and across Kitty/legacy ESC+j encodings.
3249
+ pi.registerShortcut("alt+j", { description: "infinity-harness: scroll the plan down", handler: scrollDown });
3250
+ pi.registerShortcut("alt+k", { description: "infinity-harness: scroll the plan up", handler: scrollUp });
3251
+ pi.registerShortcut("alt+o", { description: "infinity-harness: expand or collapse the plan widget", handler: toggleExpand });
3252
+ // Also bind the same actions without alt for terminals that swallow alt+j/k
3253
+ // (notably Windows Terminal + WSLg). User can remap in ~/.pi/agent/keybindings.json.
3254
+ pi.registerShortcut("ctrl+alt+j", { description: "infinity-harness: scroll the plan down", handler: scrollDown });
3255
+ pi.registerShortcut("ctrl+alt+k", { description: "infinity-harness: scroll the plan up", handler: scrollUp });
3256
+ pi.registerShortcut("ctrl+alt+o", { description: "infinity-harness: expand or collapse the plan widget", handler: toggleExpand });
3257
+ // Fallback raw input handler — covers selectors/overlays and both legacy
3258
+ // ESC+j and Kitty keyboard protocol encodings. Installed per-session because
3259
+ // onTerminalInput is a UI session affordance, not a global.
3260
+ let removeTerminalShortcut = null;
3261
+ const isAltWith = (data, letter) => {
3262
+ // Legacy: ESC + letter, case-insensitive
3263
+ if (data === "\x1b" + letter.toLowerCase() || data === "\x1b" + letter.toUpperCase())
3264
+ return true;
3265
+ // Some terminals already expose the Kittywrapped form via pattern tests above
3266
+ const lower = data.toLowerCase();
3267
+ if (lower === "\x1b" + letter.toLowerCase())
3268
+ return true;
3269
+ // Kitty CSI-u: ESC[106;3u (alt+j), ESC[107;3u (alt+k), ESC[111;3u (alt+o)
3270
+ // modifier 3 == alt (see pi-tui keys.ts MODIFIERS.alt = 2, value 3 == alt press)
3271
+ const code = letter.toLowerCase().charCodeAt(0);
3272
+ if (data.includes(`\x1b[${code};3u`) || data.includes(`\x1b[${code};3:1u`))
3273
+ return true;
3274
+ return false;
3275
+ };
3276
+ const installTerminalShortcuts = (ctx) => {
3277
+ try {
3278
+ removeTerminalShortcut?.();
3279
+ }
3280
+ catch { }
3281
+ try {
3282
+ removeTerminalShortcut = ctx.ui.onTerminalInput((data) => {
3283
+ if (isAltWith(data, "j")) {
3284
+ void scrollDown(ctx);
3285
+ return { consume: true };
3286
+ }
3287
+ if (isAltWith(data, "k")) {
3288
+ void scrollUp(ctx);
3289
+ return { consume: true };
3290
+ }
3291
+ if (isAltWith(data, "o")) {
3292
+ void toggleExpand(ctx);
3293
+ return { consume: true };
3294
+ }
3295
+ return undefined;
3296
+ });
3297
+ }
3298
+ catch { }
3299
+ };
3300
+ pi.registerCommand("infinity:scroll", {
3301
+ description: "Move the plan widget — up, down, top, bottom, expand, follow",
3302
+ handler: async (args, ctx) => {
3303
+ const dir = projectDir(ctx);
3304
+ if (!isHarnessProject(dir)) {
3305
+ notify(ctx, NO_HARNESS, "warning");
3306
+ return;
3307
+ }
3308
+ const what = args.trim().toLowerCase() || "down";
3309
+ const rows = planRowCount(dir);
3310
+ switch (what) {
3311
+ case "up":
3312
+ moveView(ctx, -SCROLL_STEP);
3313
+ return;
3314
+ case "down":
3315
+ moveView(ctx, SCROLL_STEP);
3316
+ return;
3317
+ case "top":
3318
+ view = { ...view, scroll: 0 };
3319
+ break;
3320
+ case "bottom":
3321
+ view = { ...view, scroll: rows };
3322
+ break;
3323
+ case "expand":
3324
+ view = { ...view, expanded: true };
3325
+ break;
3326
+ case "collapse":
3327
+ view = { ...view, expanded: false };
3328
+ break;
3329
+ case "follow":
3330
+ // Back to tracking the active task, which is where it starts.
3331
+ view = defaultView();
3332
+ break;
3333
+ default:
3334
+ notify(ctx, "Use: up · down · top · bottom · expand · collapse · follow", "warning");
3335
+ return;
3336
+ }
3337
+ refreshWidget(ctx);
3338
+ },
3339
+ });
3340
+ pi.registerCommand("infinity:dashboard", {
3341
+ description: "Open the read-only web dashboard for this run (served by daemon when running, fallback remote)",
3342
+ handler: async (_args, ctx) => {
3343
+ const dir = projectDir(ctx);
3344
+ if (!isHarnessProject(dir)) {
3345
+ notify(ctx, NO_HARNESS, "warning");
3346
+ return;
3347
+ }
3348
+ try {
3349
+ const d = readJsonSafe(resolvePath(dir, "harness/daemon.json"), null);
3350
+ if (d?.port) {
3351
+ const url = `http://127.0.0.1:${d.port}/dashboard`;
3352
+ notify(ctx, `infinity-harness dashboard (daemon): ${url}`, "info");
3353
+ return;
3354
+ }
3355
+ }
3356
+ catch { }
3357
+ const remote = await import("../../src/remote.js");
3358
+ if (remoteServer) {
3359
+ notify(ctx, `Dashboard already live at ${remoteServer.url} (fallback remote)`, "info");
3360
+ return;
3361
+ }
3362
+ const srv = await remote.createRemoteServer({ projectDir: dir, host: "127.0.0.1", port: 0 });
3363
+ remoteServer = srv;
3364
+ remoteDir = dir;
3365
+ notify(ctx, `infinity-harness dashboard: ${srv.url}`, "info");
3366
+ },
3367
+ });
3368
+ pi.registerCommand("infinity:pilot", {
3369
+ description: "Set pilot mode — copilot | autopilot | full (takes effect at next phase boundary)",
3370
+ handler: async (args, ctx) => {
3371
+ const dir = projectDir(ctx);
3372
+ if (!isHarnessProject(dir)) {
3373
+ notify(ctx, NO_HARNESS, "warning");
3374
+ return;
3375
+ }
3376
+ // Prefer daemon when alive.
3377
+ const raw = String(args ?? "").trim().toLowerCase();
3378
+ if (raw && ["copilot", "autopilot", "full"].includes(raw)) {
3379
+ try {
3380
+ const d = readJsonSafe(resolvePath(dir, "harness/daemon.json"), null);
3381
+ if (d?.port) {
3382
+ const h = { "Content-Type": "application/json" };
3383
+ if (d.token)
3384
+ h["Authorization"] = `Bearer ${d.token}`;
3385
+ const r = await fetch(`http://127.0.0.1:${d.port}/pilot`, { method: "POST", headers: h, body: JSON.stringify({ pilot: raw }) });
3386
+ const j = await r.json().catch(() => ({}));
3387
+ if (r.ok && j.ok !== false) {
3388
+ notify(ctx, `infinity-harness: pilot → ${raw} via daemon (next boundary).`, "info");
3389
+ refreshWidget(ctx);
3390
+ return;
3391
+ }
3392
+ }
3393
+ }
3394
+ catch { }
3395
+ }
3396
+ // Fallback: write config locally. Import core helper for pilot preset.
3397
+ try {
3398
+ const { applyPilotPreset } = await import("../../src/core/config.js");
3399
+ const { withLock: _wl } = await import("../../src/core/lock.js");
3400
+ if (!raw) {
3401
+ const { config: c } = loadConfig(dir);
3402
+ const cur = c.pilot ?? "autopilot";
3403
+ notify(ctx, `infinity-harness pilot is ${cur}. Usage: /infinity:pilot copilot|autopilot|full`, "info");
3404
+ return;
3405
+ }
3406
+ if (!["copilot", "autopilot", "full"].includes(raw)) {
3407
+ notify(ctx, `"${raw}" is not a pilot mode — copilot | autopilot | full`, "warning");
3408
+ return;
3409
+ }
3410
+ const wl = _wl;
3411
+ await wl(configPath(dir), () => {
3412
+ const l = loadConfig(dir);
3413
+ if (!l.ok)
3414
+ return false;
3415
+ l.config.pilot = raw;
3416
+ applyPilotPreset(l.config, raw);
3417
+ return saveConfig(dir, l.config).ok;
3418
+ });
3419
+ notify(ctx, `infinity-harness: pilot → ${raw} (next phase boundary).`, "info");
3420
+ refreshWidget(ctx);
3421
+ }
3422
+ catch (e) {
3423
+ notify(ctx, e instanceof Error ? e.message : String(e), "error");
3424
+ }
3425
+ },
3426
+ });
3427
+ pi.registerCommand("infinity:replan", {
3428
+ description: "Propose a plan amendment (mid-run addFeatures/addTasks) — via daemon when running",
3429
+ handler: async (args, ctx) => {
3430
+ const dir = projectDir(ctx);
3431
+ if (!isHarnessProject(dir)) {
3432
+ notify(ctx, NO_HARNESS, "warning");
3433
+ return;
3434
+ }
3435
+ const raw = String(args ?? "").trim();
3436
+ // Try daemon first so replan invariants (cancel-not-delete, maxReplansPerPhase) are honoured under daemon lock.
3437
+ try {
3438
+ const d = readJsonSafe(resolvePath(dir, "harness/daemon.json"), null);
3439
+ if (d?.port) {
3440
+ const h = { "Content-Type": "application/json" };
3441
+ if (d.token)
3442
+ h["Authorization"] = `Bearer ${d.token}`;
3443
+ let body = {};
3444
+ if (raw) {
3445
+ try {
3446
+ body = JSON.parse(raw);
3447
+ }
3448
+ catch {
3449
+ body = { reason: raw };
3450
+ }
3451
+ }
3452
+ const r = await fetch(`http://127.0.0.1:${d.port}/replan`, { method: "POST", headers: h, body: JSON.stringify(body) });
3453
+ const j = await r.json().catch(() => ({}));
3454
+ if (r.ok && j.ok !== false) {
3455
+ notify(ctx, `infinity-harness: replan via daemon — ${JSON.stringify(j)}`, "info");
3456
+ refreshWidget(ctx);
3457
+ return;
3458
+ }
3459
+ }
3460
+ }
3461
+ catch { }
3462
+ notify(ctx, `Replan: ${raw || "use infinity_replan tool with addFeatures/addTasks, or POST JSON to /replan"}`, "info");
3463
+ },
3464
+ });
3465
+ }
3466
+ function errMsg(e) {
3467
+ return e instanceof Error ? e.message : String(e);
3468
+ }
3469
+ /** The workflow this project is on, and whether it still matches a named one. */
3470
+ function describeCurrentWorkflow(dir) {
3471
+ const { config } = loadConfig(dir);
3472
+ const named = matchWorkflow(config);
3473
+ const head = `Workflow: ${summarizeWorkflow(config)}`;
3474
+ const rail = (config.phases?.enabled ?? [])
3475
+ .map((p) => (config.phaseModes?.[p] === "copilot" ? `[${p}]` : p))
3476
+ .join(" → ");
3477
+ const drift = named
3478
+ ? ""
3479
+ : "\n\nThese settings do not match any saved workflow. `/infinity:workflow` can save them as one.";
3480
+ return `${head}\n ${rail}\n (a phase in [brackets] stops for you)${drift}`;
3481
+ }
3482
+ /**
3483
+ * What this session is, when the work is happening somewhere else.
3484
+ *
3485
+ * Deliberately a few lines rather than the brief. The brief is several
3486
+ * kilobytes, it is re-read on every turn once it is in the transcript, and it
3487
+ * is written to make a model start building — none of which belongs in a
3488
+ * window whose job is to show a human what is going on.
3489
+ */
3490
+ function controlPanelNote(dir, armed) {
3491
+ const { config } = loadConfig(dir);
3492
+ const { list } = loadFeatureList(dir);
3493
+ const p = computeProgress(list);
3494
+ const L = [];
3495
+ L.push("[infinity-harness] control panel");
3496
+ L.push(`${(config.currentPhase ?? "not started").toUpperCase()} · ${p.tasksDone}/${p.tasksTotal} tasks · ` +
3497
+ `${p.featuresDone}/${p.featuresTotal} features · plan rev ${list.baseRevision}` +
3498
+ (armed ? " · run armed" : ""));
3499
+ L.push("");
3500
+ L.push("The run works in separate background pi sessions, each on the model its difficulty tier " +
3501
+ "names. This session does not do that work and should not start it.");
3502
+ L.push("");
3503
+ L.push(" /infinity:workers what the background sessions are doing right now");
3504
+ L.push(" /infinity:status where the run is");
3505
+ L.push(armed ? " /infinity:halt stop the run" : " /infinity:run start the run");
3506
+ L.push(" /infinity:approve sign a phase that is waiting for you");
3507
+ return L.join("\n");
3508
+ }
3509
+ /**
3510
+ * The contract for a control panel.
3511
+ *
3512
+ * The pipeline contract tells a model to work the plan. Told that in a
3513
+ * session whose whole point is *not* to work the plan, a model helpfully
3514
+ * starts building — on the human's own model, which is the bug this engine
3515
+ * exists to fix. This says the opposite, in as few words.
3516
+ */
3517
+ function controlPanelContract(dir) {
3518
+ const { config, ok } = loadConfig(dir);
3519
+ if (!ok || !config.currentPhase)
3520
+ return null;
3521
+ const { list } = loadFeatureList(dir);
3522
+ const p = computeProgress(list);
3523
+ let armed = false;
3524
+ try {
3525
+ const r = readJsonSafe(runStatePathSync(dir), null);
3526
+ armed = r?.armed === true;
3527
+ }
3528
+ catch { }
3529
+ // When parked, the harness does NO work anywhere — not in this session and
3530
+ // not in background sessions. When armed, background sessions do the work
3531
+ // and this session is idle by design (it never spends the human's model).
3532
+ const workLine = armed
3533
+ ? "The work is being done by separate background pi sessions on their own models, not by you."
3534
+ : "The harness is NOT running — nothing is happening in background. Only `/infinity:run` starts it.";
3535
+ const rule1 = armed
3536
+ ? "1. Do not implement plan tasks, advance phases, or edit `harness/` by hand. Answer the"
3537
+ : "1. Do NOT start building, researching, or validating. The harness is parked. If the human";
3538
+ const rule1b = armed
3539
+ ? " human's questions about the run, and use `/infinity:workers` and `infinity_status`"
3540
+ : " asks you to do harness work anyway, tell them it is parked and needs `/infinity:run`,";
3541
+ const rule1c = armed
3542
+ ? " to see what the background sessions are doing."
3543
+ : " then stop. Do not touch `harness/` files.";
3544
+ const rule2 = armed
3545
+ ? "2. If the human asks you to build something, say that the harness is driving it and offer"
3546
+ : "2. While parked, you may ONLY answer questions about the project — its stack, commands,";
3547
+ const rule2b = armed
3548
+ ? " `/infinity:run`, `/infinity:halt`, or `/infinity:replan` instead."
3549
+ : " files, and how the harness would run. Do not write code, docs, plans, or validation.";
3550
+ const rule3 = "3. The plan of record is `harness/features/feature-list.json`; your memory of it is not.";
3551
+ const extra = armed ? [] : ["", "Parked means parked. No tool calls that mutate the project while parked."];
3552
+ return [
3553
+ "## infinity-harness — you are the control panel" + (armed ? "" : " (PARKED)"),
3554
+ "",
3555
+ `This project runs an infinity-harness pipeline at **${config.currentPhase.toUpperCase()}**, ` +
3556
+ `${p.tasksDone}/${p.tasksTotal} tasks done. ` + workLine,
3557
+ "",
3558
+ rule1,
3559
+ rule1b,
3560
+ rule1c,
3561
+ rule2,
3562
+ rule2b,
3563
+ rule3,
3564
+ ...extra,
3565
+ ].join("\n");
3566
+ }
3567
+ /**
3568
+ * The few sentences the run cannot afford to have summarised away.
3569
+ *
3570
+ * Short on purpose: this is paid for on every single request, and a long
3571
+ * system prompt crowds out the work. It carries only what stops the agent
3572
+ * going freelance after a compaction — where the pipeline is, what it is
3573
+ * working on, and the three rules that make the harness a harness.
3574
+ */
3575
+ function harnessContract(dir) {
3576
+ const { config, ok } = loadConfig(dir);
3577
+ if (!ok || !config.currentPhase)
3578
+ return null;
3579
+ const { list } = loadFeatureList(dir);
3580
+ const progress = computeProgress(list);
3581
+ const phase = config.currentPhase.toUpperCase();
3582
+ const role = config.currentRole ?? "";
3583
+ const L = [];
3584
+ L.push("## infinity-harness");
3585
+ L.push("");
3586
+ L.push(`This project is driven by the infinity-harness pipeline. You are at **${phase}**` +
3587
+ (role ? ` wearing the ${role} hat` : "") +
3588
+ `, with ${progress.tasksDone}/${progress.tasksTotal} tasks done across ` +
3589
+ `${progress.featuresTotal} feature(s). Plan revision ${list.baseRevision}.`);
3590
+ L.push("");
3591
+ L.push("Rules that do not change, whatever the conversation above says:");
3592
+ L.push("");
3593
+ L.push("1. The plan of record is `harness/features/feature-list.json`, reached through the");
3594
+ L.push(" `infinity_plan` tool. It is the truth; your memory of it is not.");
3595
+ L.push("2. You never advance a phase and never mark your own work complete. Call");
3596
+ L.push(" `infinity_validate`; the gate is the only referee. Do not edit");
3597
+ L.push(" `harness/config.json` by hand.");
3598
+ L.push("3. If you do not know what to do next, call `infinity_brief` rather than guessing.");
3599
+ if (config.awaitingApproval) {
3600
+ L.push(`4. ${String(config.awaitingApproval).toUpperCase()} is waiting for a human signature. Do not start the next phase.`);
3601
+ }
3602
+ if (config.paused) {
3603
+ L.push("4. The pipeline is PAUSED. Do not continue autonomously — report and stop.");
3604
+ }
3605
+ return L.join("\n");
3606
+ }
3607
+ /** Our injected reminders, so they can be pruned before the next call. */
3608
+ function isOurReminder(m) {
3609
+ const msg = m;
3610
+ if (msg?.role !== "user" || !Array.isArray(msg.content))
3611
+ return false;
3612
+ return msg.content.some((c) => c?.type === "text" && c.text?.startsWith("[infinity-harness]"));
3613
+ }
3614
+ //# sourceMappingURL=index.js.map