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.
- package/CHANGELOG.md +18 -0
- package/dist/extensions/infinity-harness/index.js +3614 -0
- package/dist/extensions/infinity-harness/index.js.map +1 -0
- package/dist/src/approval.js +154 -0
- package/dist/src/approval.js.map +1 -0
- package/dist/src/core/brief.js +309 -0
- package/dist/src/core/brief.js.map +1 -0
- package/dist/src/core/config.js +539 -0
- package/dist/src/core/config.js.map +1 -0
- package/dist/src/core/exec.js +106 -0
- package/dist/src/core/exec.js.map +1 -0
- package/dist/src/core/featureList.js +365 -0
- package/dist/src/core/featureList.js.map +1 -0
- package/dist/src/core/fsx.js +123 -0
- package/dist/src/core/fsx.js.map +1 -0
- package/dist/src/core/gates.js +490 -0
- package/dist/src/core/gates.js.map +1 -0
- package/dist/src/core/init.js +480 -0
- package/dist/src/core/init.js.map +1 -0
- package/dist/src/core/lock.js +183 -0
- package/dist/src/core/lock.js.map +1 -0
- package/dist/src/core/modelRouter.js +185 -0
- package/dist/src/core/modelRouter.js.map +1 -0
- package/dist/src/core/paths.js +129 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/phases.js +254 -0
- package/dist/src/core/phases.js.map +1 -0
- package/dist/src/core/plan.js +15 -0
- package/dist/src/core/plan.js.map +1 -0
- package/dist/src/core/runState.js +126 -0
- package/dist/src/core/runState.js.map +1 -0
- package/dist/src/core/settings.js +877 -0
- package/dist/src/core/settings.js.map +1 -0
- package/dist/src/core/skills.js +319 -0
- package/dist/src/core/skills.js.map +1 -0
- package/dist/src/core/skillsAudit.js +194 -0
- package/dist/src/core/skillsAudit.js.map +1 -0
- package/dist/src/core/types.js +77 -0
- package/dist/src/core/types.js.map +1 -0
- package/dist/src/daemon/budget.js +85 -0
- package/dist/src/daemon/budget.js.map +1 -0
- package/dist/src/daemon/guard.js +122 -0
- package/dist/src/daemon/guard.js.map +1 -0
- package/dist/src/daemon/index.js +461 -0
- package/dist/src/daemon/index.js.map +1 -0
- package/dist/src/daemon/isolation.js +84 -0
- package/dist/src/daemon/isolation.js.map +1 -0
- package/dist/src/daemon/preflight.js +131 -0
- package/dist/src/daemon/preflight.js.map +1 -0
- package/dist/src/daemon/server.js +174 -0
- package/dist/src/daemon/server.js.map +1 -0
- package/dist/src/daemon/supervisorState.js +42 -0
- package/dist/src/daemon/supervisorState.js.map +1 -0
- package/dist/src/daemon/worker.js +239 -0
- package/dist/src/daemon/worker.js.map +1 -0
- package/dist/src/daemon/worktree.js +102 -0
- package/dist/src/daemon/worktree.js.map +1 -0
- package/dist/src/escalate.js +312 -0
- package/dist/src/escalate.js.map +1 -0
- package/dist/src/exec/piWorker.js +630 -0
- package/dist/src/exec/piWorker.js.map +1 -0
- package/dist/src/goal.js +317 -0
- package/dist/src/goal.js.map +1 -0
- package/dist/src/goalLoop.js +374 -0
- package/dist/src/goalLoop.js.map +1 -0
- package/dist/src/goalSpec.js +397 -0
- package/dist/src/goalSpec.js.map +1 -0
- package/dist/src/goalState.js +255 -0
- package/dist/src/goalState.js.map +1 -0
- package/dist/src/handoff.js +205 -0
- package/dist/src/handoff.js.map +1 -0
- package/dist/src/intake.js +203 -0
- package/dist/src/intake.js.map +1 -0
- package/dist/src/loop.js +592 -0
- package/dist/src/loop.js.map +1 -0
- package/dist/src/modelRouter.js +216 -0
- package/dist/src/modelRouter.js.map +1 -0
- package/dist/src/remote.js +246 -0
- package/dist/src/remote.js.map +1 -0
- package/dist/src/replan.js +262 -0
- package/dist/src/replan.js.map +1 -0
- package/dist/src/review.js +54 -0
- package/dist/src/review.js.map +1 -0
- package/dist/src/rework.js +241 -0
- package/dist/src/rework.js.map +1 -0
- package/dist/src/runState.js +4 -0
- package/dist/src/runState.js.map +1 -0
- package/dist/src/scheduler.js +270 -0
- package/dist/src/scheduler.js.map +1 -0
- package/dist/src/supervisor.js +787 -0
- package/dist/src/supervisor.js.map +1 -0
- package/dist/src/taskList.js +430 -0
- package/dist/src/taskList.js.map +1 -0
- package/dist/src/ui/config.js +268 -0
- package/dist/src/ui/config.js.map +1 -0
- package/dist/src/ui/dashboard.js +1288 -0
- package/dist/src/ui/dashboard.js.map +1 -0
- package/dist/src/ui/display.js +229 -0
- package/dist/src/ui/display.js.map +1 -0
- package/dist/src/ui/planTree.js +219 -0
- package/dist/src/ui/planTree.js.map +1 -0
- package/dist/src/ui/theme.js +298 -0
- package/dist/src/ui/theme.js.map +1 -0
- package/dist/src/ui/viewState.js +65 -0
- package/dist/src/ui/viewState.js.map +1 -0
- package/dist/src/ui/widget.js +721 -0
- package/dist/src/ui/widget.js.map +1 -0
- package/dist/src/ui/wizard.js +540 -0
- package/dist/src/ui/wizard.js.map +1 -0
- package/dist/src/unstuck.js +210 -0
- package/dist/src/unstuck.js.map +1 -0
- package/dist/src/worker.js +267 -0
- package/dist/src/worker.js.map +1 -0
- package/dist/src/workflow.js +250 -0
- package/dist/src/workflow.js.map +1 -0
- package/dist/tests/approval.test.js +144 -0
- package/dist/tests/approval.test.js.map +1 -0
- package/dist/tests/brief.test.js +271 -0
- package/dist/tests/brief.test.js.map +1 -0
- package/dist/tests/config.test.js +320 -0
- package/dist/tests/config.test.js.map +1 -0
- package/dist/tests/dashboard.test.js +277 -0
- package/dist/tests/dashboard.test.js.map +1 -0
- package/dist/tests/display.test.js +229 -0
- package/dist/tests/display.test.js.map +1 -0
- package/dist/tests/escalate.test.js +272 -0
- package/dist/tests/escalate.test.js.map +1 -0
- package/dist/tests/featureList.test.js +429 -0
- package/dist/tests/featureList.test.js.map +1 -0
- package/dist/tests/gates.test.js +333 -0
- package/dist/tests/gates.test.js.map +1 -0
- package/dist/tests/goal.test.js +229 -0
- package/dist/tests/goal.test.js.map +1 -0
- package/dist/tests/goalLoop.test.js +170 -0
- package/dist/tests/goalLoop.test.js.map +1 -0
- package/dist/tests/goalSpec.test.js +146 -0
- package/dist/tests/goalSpec.test.js.map +1 -0
- package/dist/tests/goalState.test.js +202 -0
- package/dist/tests/goalState.test.js.map +1 -0
- package/dist/tests/handoff.test.js +127 -0
- package/dist/tests/handoff.test.js.map +1 -0
- package/dist/tests/init.test.js +264 -0
- package/dist/tests/init.test.js.map +1 -0
- package/dist/tests/intake.test.js +288 -0
- package/dist/tests/intake.test.js.map +1 -0
- package/dist/tests/loop.test.js +453 -0
- package/dist/tests/loop.test.js.map +1 -0
- package/dist/tests/modelRouter.test.js +131 -0
- package/dist/tests/modelRouter.test.js.map +1 -0
- package/dist/tests/phases.test.js +244 -0
- package/dist/tests/phases.test.js.map +1 -0
- package/dist/tests/planTree.test.js +190 -0
- package/dist/tests/planTree.test.js.map +1 -0
- package/dist/tests/remote.test.js +380 -0
- package/dist/tests/remote.test.js.map +1 -0
- package/dist/tests/replan.test.js +113 -0
- package/dist/tests/replan.test.js.map +1 -0
- package/dist/tests/review.test.js +77 -0
- package/dist/tests/review.test.js.map +1 -0
- package/dist/tests/rework.test.js +109 -0
- package/dist/tests/rework.test.js.map +1 -0
- package/dist/tests/routing-live.test.js +121 -0
- package/dist/tests/routing-live.test.js.map +1 -0
- package/dist/tests/runState.test.js +73 -0
- package/dist/tests/runState.test.js.map +1 -0
- package/dist/tests/settings.test.js +311 -0
- package/dist/tests/settings.test.js.map +1 -0
- package/dist/tests/skills.test.js +387 -0
- package/dist/tests/skills.test.js.map +1 -0
- package/dist/tests/supervisor.test.js +437 -0
- package/dist/tests/supervisor.test.js.map +1 -0
- package/dist/tests/surface.test.js +203 -0
- package/dist/tests/surface.test.js.map +1 -0
- package/dist/tests/taskList.test.js +376 -0
- package/dist/tests/taskList.test.js.map +1 -0
- package/dist/tests/theme.test.js +149 -0
- package/dist/tests/theme.test.js.map +1 -0
- package/dist/tests/unstuck.test.js +268 -0
- package/dist/tests/unstuck.test.js.map +1 -0
- package/dist/tests/widget.test.js +424 -0
- package/dist/tests/widget.test.js.map +1 -0
- package/dist/tests/worker.test.js +306 -0
- package/dist/tests/worker.test.js.map +1 -0
- package/dist/tests/workflow.test.js +187 -0
- package/dist/tests/workflow.test.js.map +1 -0
- package/extensions/infinity-harness/index.ts +39 -12
- package/package.json +5 -2
- package/src/core/modelRouter.ts +23 -1
- package/src/daemon/index.ts +3 -3
- package/src/daemon/worker.ts +28 -5
- package/src/ui/config.ts +25 -1
- 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
|