infinity-harness 2.6.5 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,6 +22,7 @@ import type {
22
22
  ExtensionContext,
23
23
  } from "@earendil-works/pi-coding-agent";
24
24
  import { randomUUID } from "node:crypto";
25
+ import { fileURLToPath } from "node:url";
25
26
 
26
27
  import { isHarnessProject, loadConfig, saveConfig } from "../../src/core/config.ts";
27
28
  import { loadFeatureList, computeProgress, nextActionableTask } from "../../src/core/featureList.ts";
@@ -49,6 +50,20 @@ import { amendPlan, loadReplanHistory, type ReplanTaskInput } from "../../src/re
49
50
  import { chooseUnstuckStrategy } from "../../src/unstuck.ts";
50
51
  import { escalationSummary } from "../../src/escalate.ts";
51
52
  import { spawnIsolatedWorker } from "../../src/worker.ts";
53
+ import { executionPolicyOf } from "../../src/scheduler.ts";
54
+ import { isWorkerProcess } from "../../src/exec/piWorker.ts";
55
+ import {
56
+ appendActivity,
57
+ currentUnit,
58
+ describeUnit,
59
+ loadActivity,
60
+ loadSupervisorState,
61
+ isRefusal,
62
+ startSupervisor,
63
+ type ActivityLine,
64
+ type RunningSupervisor,
65
+ type SupervisorState,
66
+ } from "../../src/supervisor.ts";
52
67
  import {
53
68
  startGoal,
54
69
  loadGoal,
@@ -104,6 +119,23 @@ import type { DisplayPolicy } from "../../src/core/types.ts";
104
119
  import { defaultView, scrollView, SCROLL_STEP, TASK_WINDOW, EXPANDED_WINDOW, type WidgetView } from "../../src/ui/widget.ts";
105
120
  import { buildPlanRows } from "../../src/ui/planTree.ts";
106
121
 
122
+ /**
123
+ * This file's own path.
124
+ *
125
+ * A background worker normally discovers the harness the same way this
126
+ * session did, because the harness is an installed pi package. When it was
127
+ * loaded from an explicit `-e` instead — a dev checkout, and every e2e run —
128
+ * discovery finds nothing, and the supervisor restarts the worker with this
129
+ * path so it still has the plan tools.
130
+ */
131
+ const SELF_PATH: string | null = (() => {
132
+ try {
133
+ return fileURLToPath(import.meta.url);
134
+ } catch {
135
+ return null;
136
+ }
137
+ })();
138
+
107
139
  const CHECKPOINT = "infinity:checkpoint";
108
140
  const WIDGET_KEY = "infinity-harness";
109
141
  const STATUS_KEY = "infinity";
@@ -139,6 +171,21 @@ export default function (pi: ExtensionAPI): void {
139
171
  let remoteDir: string | null = null;
140
172
  let view: WidgetView = defaultView();
141
173
 
174
+ /**
175
+ * The background orchestrator, when one is running in this session.
176
+ *
177
+ * It is a plain async loop in this process — no LLM, no tokens — that keeps
178
+ * one `pi` child working on the current unit. Only one session drives a run
179
+ * at a time; a second pi window on the same project shows the widget and
180
+ * the log, and leaves the driving alone.
181
+ */
182
+ let supervisor: RunningSupervisor | null = null;
183
+ /** This session is a background worker, not the human's control panel. */
184
+ const workerProcess = isWorkerProcess();
185
+ /** Ring of activity lines mirrored into the widget. */
186
+ let activity: ActivityLine[] = [];
187
+ let supState: SupervisorState | null = null;
188
+
142
189
  /**
143
190
  * Is this instance's session still the live one?
144
191
  *
@@ -195,9 +242,33 @@ export default function (pi: ExtensionAPI): void {
195
242
  const pass = typeof config.goalPass === "number" ? config.goalPass : null;
196
243
  const maxPasses = typeof config.goalMaxPasses === "number" ? config.goalMaxPasses : null;
197
244
  const run = loadRunState(dir);
245
+ const sup = supState ?? loadSupervisorState(dir);
246
+ const worker = sup?.worker ?? null;
198
247
  return {
199
248
  list,
200
249
  view,
250
+ engine: executionPolicyOf(config).engine,
251
+ workers: worker
252
+ ? [
253
+ {
254
+ name: worker.name,
255
+ unit: worker.unitLabel,
256
+ level: worker.level,
257
+ model: worker.servedModel ?? worker.model,
258
+ difficulty: worker.difficulty,
259
+ state: worker.state,
260
+ doing: worker.doing,
261
+ tokens: worker.tokens.inputTokens + worker.tokens.outputTokens,
262
+ contextRatio: worker.contextRatio,
263
+ },
264
+ ]
265
+ : [],
266
+ activity: (activity.length ? activity : loadActivity(dir)).slice(-40).map((l) => ({
267
+ at: l.at,
268
+ level: l.level,
269
+ worker: l.worker,
270
+ text: l.text,
271
+ })),
201
272
  dashboardUrl: remoteServer?.url ?? null,
202
273
  handoffModelNote,
203
274
  sessions: run?.sessions ?? null,
@@ -417,6 +488,106 @@ export default function (pi: ExtensionAPI): void {
417
488
  } catch { return null; }
418
489
  };
419
490
 
491
+
492
+ // -- the background engine -------------------------------------------------
493
+
494
+ /** Where work runs for this project, honouring the legacy escape hatch. */
495
+ const engineFor = (dir: string): "background" | "main-session" => {
496
+ try {
497
+ return executionPolicyOf(loadConfig(dir).config).engine;
498
+ } catch {
499
+ return "background";
500
+ }
501
+ };
502
+
503
+ /**
504
+ * The model this session is on, as `provider/id`.
505
+ *
506
+ * A router slot left empty means "whatever pi is already configured with",
507
+ * and a *child* process does not inherit that: it would fall back to pi's
508
+ * default provider, which is not necessarily what the human is looking at.
509
+ * So we read it here and hand it to the worker explicitly.
510
+ */
511
+ const baseModelOf = (ctx: unknown): string | null => {
512
+ try {
513
+ const m = (ctx as { model?: { id?: string; provider?: string } }).model;
514
+ if (m?.id) return m.provider ? `${m.provider}/${m.id}` : m.id;
515
+ } catch {
516
+ /* pi without a model is a pi that cannot run anything anyway */
517
+ }
518
+ return null;
519
+ };
520
+
521
+ /**
522
+ * Start the background orchestrator for this project.
523
+ *
524
+ * Everything it does happens in this process, in plain JavaScript. It costs
525
+ * no tokens in this session: the only LLM calls a run makes are made by the
526
+ * `pi` children it spawns, on the models the router chose for them.
527
+ */
528
+ const startEngine = async (ctx: ExtensionContext, dir: string): Promise<boolean> => {
529
+ if (supervisor?.isRunning()) return true;
530
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc") {
531
+ // `pi -p` has no future in which to watch anything. Arming the run is
532
+ // still right — the next interactive session picks it up.
533
+ notify(ctx, "infinity-harness: run armed. Open pi interactively to drive it.", "info");
534
+ return false;
535
+ }
536
+ const runId = runFor(dir);
537
+ const started = startSupervisor({
538
+ targetDir: dir,
539
+ runId,
540
+ sessionId,
541
+ baseModel: baseModelOf(ctx),
542
+ harnessExtension: SELF_PATH,
543
+ hooks: {
544
+ onState: (st) => {
545
+ supState = st;
546
+ if (sessionLive) refreshWidget(ctx);
547
+ },
548
+ onActivity: (line) => {
549
+ activity = [...activity, line].slice(-120);
550
+ if (!sessionLive) return;
551
+ // Only the things a human would want interrupted for. Tool-by-tool
552
+ // narration belongs in the widget's log, not in notifications.
553
+ if (line.level === "error" || line.level === "warn" || line.level === "good") {
554
+ notify(ctx, `infinity-harness: ${line.text}`, line.level === "error" ? "error" : line.level === "warn" ? "warning" : "info");
555
+ }
556
+ refreshWidget(ctx);
557
+ },
558
+ onApproval: (phase) => {
559
+ if (sessionLive) void askForApproval(ctx, dir, phase);
560
+ },
561
+ onStop: (reason, detail) => {
562
+ if (!sessionLive) return;
563
+ notify(ctx, `infinity-harness: run finished — ${detail}`, reason === "complete" ? "info" : "warning");
564
+ refreshWidget(ctx);
565
+ },
566
+ },
567
+ });
568
+ if (isRefusal(started)) {
569
+ // A second pi window on the same project is a *viewer*. Saying so is
570
+ // better than quietly putting two workers in one working tree.
571
+ notify(ctx, `infinity-harness: ${started.reason} This window still shows the run.`, "warning");
572
+ supervisor = null;
573
+ refreshWidget(ctx);
574
+ return false;
575
+ }
576
+ supervisor = started;
577
+ return true;
578
+ };
579
+
580
+ const stopEngine = async (reason: string): Promise<void> => {
581
+ const running = supervisor;
582
+ supervisor = null;
583
+ if (!running) return;
584
+ try {
585
+ await running.stop(reason);
586
+ } catch {
587
+ /* a supervisor that will not stop cleanly must not block the command */
588
+ }
589
+ };
590
+
420
591
  // -- session handoff ------------------------------------------------------
421
592
 
422
593
  /** The task/feature/sprint/goal/subtask the pipeline is on right now, or null. */
@@ -664,16 +835,35 @@ export default function (pi: ExtensionAPI): void {
664
835
  const dir = projectDir(ctx);
665
836
  if (!isHarnessProject(dir)) return;
666
837
 
838
+ // A background worker loads this extension too — it needs the plan tools
839
+ // and the phase guard. What it must not do is drive: no widget, no brief
840
+ // injection, no loop, and above all no supervisor of its own, or one run
841
+ // would fork into a tree of pi processes spawning pi processes.
842
+ if (workerProcess) {
843
+ try {
844
+ pi.appendEntry("infinity:worker-session", {
845
+ unit: process.env.INFINITY_HARNESS_UNIT ?? null,
846
+ runId: process.env.INFINITY_HARNESS_RUN ?? null,
847
+ });
848
+ } catch {
849
+ /* best effort */
850
+ }
851
+ return;
852
+ }
853
+
667
854
  view = defaultView();
668
855
  refreshWidget(ctx);
669
856
  installTerminalShortcuts(ctx);
670
857
  const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
671
858
  const { config } = loadConfig(dir);
672
859
  lastBriefPhase = config.currentPhase;
673
- // Route on session start (including after handoff) so the fresh session
674
- // actually runs on the tier model, not whatever the harness was started with.
675
- try { await applyRouting(ctx, dir, `session_start:${reason}`); } catch {}
676
- ;(async () => { try { await applyRouting(ctx, dir, "session_start"); } catch {} })();
860
+ const engine = engineFor(dir);
861
+ // Under the background engine this session is a control panel: its model
862
+ // is the human's and stays the human's. Routing only ever applied to the
863
+ // legacy engine, where the human's session *is* the worker.
864
+ if (engine === "main-session") {
865
+ try { await applyRouting(ctx, dir, `session_start:${reason}`); } catch {}
866
+ }
677
867
 
678
868
 
679
869
  const run = reason === "startup" ? loadRunState(dir) : countSession(dir);
@@ -700,6 +890,36 @@ export default function (pi: ExtensionAPI): void {
700
890
  // A handoff written by the session this one replaces. It carries the brief
701
891
  // plus the reason the previous session ended, so the agent does not spend
702
892
  // its first turn working out why it woke up mid-run.
893
+ // The background engine picks a run back up by restarting the supervisor:
894
+ // the plan, the phase and every budget are on disk, so a pi that was
895
+ // closed overnight resumes where it stopped rather than starting over.
896
+ if (engine !== "main-session") {
897
+ clearHandoff(dir);
898
+ if (armed) {
899
+ const ok = await startEngine(ctx, dir);
900
+ if (ok) notify(ctx, "infinity-harness: continuing the run in background sessions.", "info");
901
+ }
902
+ // A short orientation note, not the brief. The brief is several
903
+ // kilobytes and it is what makes a model start *working* the pipeline;
904
+ // in a control panel it would be paid for on every turn the human takes
905
+ // and would invite this session to do the work itself.
906
+ try {
907
+ pi.sendMessage(
908
+ {
909
+ customType: "infinity:brief",
910
+ content: controlPanelNote(dir, armed),
911
+ display: true,
912
+ details: { phase: config.currentPhase, engine },
913
+ },
914
+ { triggerTurn: false },
915
+ );
916
+ } catch (e) {
917
+ notify(ctx, `infinity-harness: ${errMsg(e)}`, "warning");
918
+ }
919
+ refreshWidget(ctx);
920
+ return;
921
+ }
922
+
703
923
  const pending = takeHandoff(dir);
704
924
  if (pending && pending.runId === runFor(dir)) {
705
925
  try {
@@ -748,13 +968,15 @@ export default function (pi: ExtensionAPI): void {
748
968
  if (!sessionLive) return;
749
969
  const dir = projectDir(ctx);
750
970
  if (!isHarnessProject(dir)) return;
751
- // Live-model routing: switch the pi session model/thinking for the next
752
- // actionable task. This is what makes harness/model-router.json do anything
753
- // in the main session; without it the GUI pointing at the same model was
754
- // the whole behavior.
755
- try { await applyRouting(ctx, dir, "before_agent_start"); } catch {}
971
+ // Live-model routing rewrites *this* session's model, which is only ever
972
+ // right when this session is the worker. Under the background engine the
973
+ // human's model is theirs, and the routed model belongs to the child.
974
+ if (engineFor(dir) === "main-session") {
975
+ try { await applyRouting(ctx, dir, "before_agent_start"); } catch {}
976
+ }
756
977
  try {
757
- const contract = harnessContract(dir);
978
+ const contract =
979
+ engineFor(dir) === "main-session" ? harnessContract(dir) : controlPanelContract(dir);
758
980
  if (!contract) return;
759
981
  const base = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
760
982
  const routed = await routingSummaryForBrief(dir);
@@ -766,7 +988,7 @@ export default function (pi: ExtensionAPI): void {
766
988
  });
767
989
 
768
990
  pi.on("session_tree", async (_event, ctx) => {
769
- if (!sessionLive) return;
991
+ if (!sessionLive || workerProcess) return;
770
992
  refreshWidget(ctx);
771
993
  });
772
994
 
@@ -776,7 +998,7 @@ export default function (pi: ExtensionAPI): void {
776
998
  * few calls costs little and keeps the plan honest.
777
999
  */
778
1000
  pi.on("context", async (event, ctx) => {
779
- if (!sessionLive) return;
1001
+ if (!sessionLive || workerProcess) return;
780
1002
  const dir = projectDir(ctx);
781
1003
  if (!isHarnessProject(dir)) return;
782
1004
 
@@ -855,6 +1077,9 @@ export default function (pi: ExtensionAPI): void {
855
1077
 
856
1078
  pi.on("session_compact", async (event, ctx) => {
857
1079
  if (!sessionLive) return;
1080
+ // A worker that compacts re-reads the brief from its own prompt; the
1081
+ // control-panel re-brief would put a second brief in front of it.
1082
+ if (workerProcess) return;
858
1083
  const dir = projectDir(ctx);
859
1084
  if (!isHarnessProject(dir)) return;
860
1085
  try {
@@ -883,7 +1108,7 @@ export default function (pi: ExtensionAPI): void {
883
1108
  });
884
1109
 
885
1110
  pi.on("turn_end", async (_event, ctx) => {
886
- if (!sessionLive) return;
1111
+ if (!sessionLive || workerProcess) return;
887
1112
  refreshWidget(ctx);
888
1113
  });
889
1114
 
@@ -896,11 +1121,15 @@ export default function (pi: ExtensionAPI): void {
896
1121
  * `/reload`, `/resume`, and pi being restarted.
897
1122
  */
898
1123
  pi.on("agent_settled", async (_event, ctx) => {
899
- if (!sessionLive) return;
1124
+ if (!sessionLive || workerProcess) return;
900
1125
  const dir = projectDir(ctx);
901
1126
  if (!isHarnessProject(dir)) return;
902
1127
  if (loopBusy || handingOff) return;
903
1128
  if (!loopArmed(dir)) return;
1129
+ // The background engine drives the run from the supervisor, in this
1130
+ // process, with no LLM turn in this session at all. Driving it from here
1131
+ // too is what made the human's model pay for the whole run.
1132
+ if (engineFor(dir) !== "main-session") return;
904
1133
 
905
1134
  loopBusy = true;
906
1135
  try {
@@ -1020,6 +1249,8 @@ export default function (pi: ExtensionAPI): void {
1020
1249
 
1021
1250
  pi.on("session_shutdown", async () => {
1022
1251
  sessionLive = false;
1252
+ // A pi that closes must not leave a worker running against the project.
1253
+ await stopEngine("this pi session closed");
1023
1254
  if (remoteServer) {
1024
1255
  try {
1025
1256
  await remoteServer.close();
@@ -1225,40 +1456,33 @@ export default function (pi: ExtensionAPI): void {
1225
1456
  const lines = gate.checks
1226
1457
  .map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
1227
1458
  .join("\n");
1228
- // Research (and any other phase whose mode is autopilot) used to stall
1229
- // forever until someone typed "continue" because the brief said
1230
- // PASS→advance but no component actually advanced without the continuous
1231
- // loop armed. Fix that, but do NOT auto-advance BUILD verify-style
1232
- // phases that require real work (tests, coverage, clean tree) to have
1233
- // genuinely passed on the *next* phase's gate as well — otherwise a
1234
- // single infinity_validate hops build→verify→review.
1235
- // Only auto-advance doc/process phases whose gate is purely content (
1236
- // research, define, plan). BUILD and later require explicit validation.
1459
+ // Autopilot means auto-pilot: when the gate passes and the current
1460
+ // phase's phaseMode is autopilot, advance immediately (any phase). The
1461
+ // old allowlist stalled real autopilot builds after RESEARCH → DEFINE.
1462
+ // Copilot still parks via needsApproval check below.
1237
1463
  if (gate.overall && !params?.feature && !params?.task) {
1238
- const autoPhases: ReadonlySet<string> = new Set(["research", "define", "plan"]);
1239
- if (autoPhases.has(String(gate.phase))) {
1240
- try {
1241
- const { needsApproval } = await import("../../src/approval.ts");
1242
- const fresh = loadConfig(dir).config;
1243
- if (!needsApproval(fresh, fresh.currentPhase)) {
1244
- const { advancePhase } = await import("../../src/core/phases.ts");
1245
- const moved = await advancePhase(dir);
1246
- if (moved.ok && moved.to) {
1247
- refreshWidget(ctx as ExtensionContext);
1248
- const brief = await briefText(dir);
1249
- return {
1250
- content: [
1251
- {
1252
- type: "text",
1253
- text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
1254
- },
1255
- ],
1256
- details: { ...gate, advanced: moved } as unknown as typeof gate,
1257
- };
1258
- }
1464
+ try {
1465
+ const { needsApproval } = await import("../../src/approval.ts");
1466
+ const fresh = loadConfig(dir).config;
1467
+ if (!needsApproval(fresh, fresh.currentPhase)) {
1468
+ const { advancePhase, ensurePhaseSeeded } = await import("../../src/core/phases.ts");
1469
+ const moved = await advancePhase(dir);
1470
+ if (moved.ok && moved.to) {
1471
+ try { ensurePhaseSeeded(dir, moved.to); } catch {}
1472
+ refreshWidget(ctx as ExtensionContext);
1473
+ const brief = await briefText(dir);
1474
+ return {
1475
+ content: [
1476
+ {
1477
+ type: "text",
1478
+ text: `Gate PASS on ${gate.phase} → advanced ${moved.from} → ${moved.to}\n${lines}\n\n${brief}`,
1479
+ },
1480
+ ],
1481
+ details: { ...gate, advanced: moved } as unknown as typeof gate,
1482
+ };
1259
1483
  }
1260
- } catch {}
1261
- }
1484
+ }
1485
+ } catch {}
1262
1486
  }
1263
1487
  return {
1264
1488
  content: [
@@ -1799,6 +2023,64 @@ export default function (pi: ExtensionAPI): void {
1799
2023
  },
1800
2024
  });
1801
2025
 
2026
+
2027
+ /**
2028
+ * What the background sessions are doing.
2029
+ *
2030
+ * The run's work is no longer in this transcript, so this is where a human
2031
+ * looks when they come back to the terminal. It prints rather than asking
2032
+ * the model anything: reading the log must never cost a turn.
2033
+ */
2034
+ pi.registerCommand("infinity:workers", {
2035
+ description: "Show the background pi sessions — which unit, which model, and the recent log",
2036
+ handler: async (args: string, ctx: ExtensionContext) => {
2037
+ const dir = projectDir(ctx);
2038
+ if (!isHarnessProject(dir)) {
2039
+ notify(ctx, NO_HARNESS, "warning");
2040
+ return;
2041
+ }
2042
+ const rows = Math.max(5, Math.min(120, Number.parseInt(args.trim(), 10) || 25));
2043
+ const st = loadSupervisorState(dir);
2044
+ const log = loadActivity(dir);
2045
+ const engine = engineFor(dir);
2046
+ const lines: string[] = [];
2047
+ lines.push(
2048
+ engine === "background"
2049
+ ? "Work runs in background pi sessions. This session spends nothing on it."
2050
+ : "Work runs in THIS session (execution.engine = main-session). Your model is paying for the run.",
2051
+ );
2052
+ if (st?.unit) lines.push(`Unit ${describeUnit(st.unit, st.baseModel)}`);
2053
+ if (st?.worker) {
2054
+ const w = st.worker;
2055
+ lines.push(
2056
+ `Worker ${w.name} · ${w.state} · ${w.unitLabel} · asked ${w.model || "pi default"}` +
2057
+ (w.servedModel && w.servedModel !== w.model ? ` · served ${w.servedModel}` : "") +
2058
+ ` · ${w.turns} turn(s) · ${w.tokens.inputTokens + w.tokens.outputTokens} tokens`,
2059
+ );
2060
+ if (w.doing) lines.push(` ${w.doing}`);
2061
+ } else {
2062
+ lines.push("Worker none running");
2063
+ }
2064
+ if (st?.history?.length) {
2065
+ lines.push("");
2066
+ lines.push("Finished sessions (newest last):");
2067
+ for (const h of st.history.slice(-6)) {
2068
+ lines.push(` ${h.name} · ${h.unitLabel} · ${h.servedModel ?? (h.model || "pi default")} · ${h.turns} turn(s)`);
2069
+ }
2070
+ }
2071
+ lines.push("");
2072
+ lines.push(log.length ? `Background log (last ${Math.min(rows, log.length)}):` : "Background log is empty.");
2073
+ for (const l of log.slice(-rows)) {
2074
+ const when = l.at.slice(11, 16);
2075
+ lines.push(` ${when} ${l.worker ? l.worker + " " : ""}${l.text}`);
2076
+ }
2077
+ pi.sendMessage(
2078
+ { customType: "infinity:workers", content: lines.join("\n"), display: true, details: { engine } },
2079
+ { triggerTurn: false },
2080
+ );
2081
+ },
2082
+ });
2083
+
1802
2084
  pi.registerCommand("infinity:approve", {
1803
2085
  description: "Approve the phase waiting for you — or send it back with a note",
1804
2086
  handler: async (args: string, ctx: ExtensionContext) => {
@@ -2313,14 +2595,32 @@ export default function (pi: ExtensionAPI): void {
2313
2595
  return;
2314
2596
  }
2315
2597
  armRun(dir, sessionId);
2598
+ const engine = engineFor(dir);
2316
2599
  notify(
2317
2600
  ctx,
2318
2601
  `infinity-harness: continuous run armed. It stops on completion, on an exhausted retry budget, ` +
2319
2602
  `when no progress is detected, or when you create ${stopFilePath(dir)}. Use /infinity:halt to stop now.`,
2320
2603
  "info",
2321
2604
  );
2322
- const text = await briefText(dir);
2323
- pi.sendUserMessage(text, { deliverAs: "followUp" });
2605
+ if (engine === "main-session") {
2606
+ const text = await briefText(dir);
2607
+ pi.sendUserMessage(text, { deliverAs: "followUp" });
2608
+ return;
2609
+ }
2610
+ // The background engine: this session says nothing to its own model. It
2611
+ // starts an orchestrator that spawns pi children on the routed models,
2612
+ // and from here on it is a control panel.
2613
+ const unit = currentUnit(dir, baseModelOf(ctx));
2614
+ await startEngine(ctx, dir);
2615
+ notify(
2616
+ ctx,
2617
+ unit
2618
+ ? `infinity-harness: working in background sessions — ${describeUnit(unit, baseModelOf(ctx))}. ` +
2619
+ `This session stays free; /infinity:workers shows what they are doing.`
2620
+ : "infinity-harness: background engine started.",
2621
+ "info",
2622
+ );
2623
+ refreshWidget(ctx);
2324
2624
  },
2325
2625
  });
2326
2626
 
@@ -2468,7 +2768,8 @@ export default function (pi: ExtensionAPI): void {
2468
2768
  }
2469
2769
  disarmRun(dir, "halted from /infinity:halt");
2470
2770
  clearHandoff(dir);
2471
- notify(ctx, "infinity-harness: continuous run stopped.", "info");
2771
+ await stopEngine("halted from /infinity:halt");
2772
+ notify(ctx, "infinity-harness: continuous run stopped, background sessions closed.", "info");
2472
2773
  refreshWidget(ctx);
2473
2774
  },
2474
2775
  });
@@ -2488,7 +2789,8 @@ export default function (pi: ExtensionAPI): void {
2488
2789
  return saveConfig(dir, config).ok;
2489
2790
  });
2490
2791
  disarmRun(dir, "paused from /infinity:pause");
2491
- notify(ctx, value ? "infinity-harness: paused." : "Could not pause — config unreadable.", value ? "info" : "error");
2792
+ await stopEngine("paused from /infinity:pause");
2793
+ notify(ctx, value ? "infinity-harness: paused, background sessions closed." : "Could not pause — config unreadable.", value ? "info" : "error");
2492
2794
  refreshWidget(ctx);
2493
2795
  },
2494
2796
  });
@@ -2507,6 +2809,9 @@ export default function (pi: ExtensionAPI): void {
2507
2809
  config.paused = false;
2508
2810
  return saveConfig(dir, config).ok;
2509
2811
  });
2812
+ if (value && loadRunState(dir)?.armed === true && engineFor(dir) !== "main-session") {
2813
+ await startEngine(ctx, dir);
2814
+ }
2510
2815
  notify(ctx, value ? "infinity-harness: resumed." : "Could not resume — config unreadable.", value ? "info" : "error");
2511
2816
  refreshWidget(ctx);
2512
2817
  },
@@ -2715,6 +3020,67 @@ function describeCurrentWorkflow(dir: string): string {
2715
3020
  return `${head}\n ${rail}\n (a phase in [brackets] stops for you)${drift}`;
2716
3021
  }
2717
3022
 
3023
+ /**
3024
+ * What this session is, when the work is happening somewhere else.
3025
+ *
3026
+ * Deliberately a few lines rather than the brief. The brief is several
3027
+ * kilobytes, it is re-read on every turn once it is in the transcript, and it
3028
+ * is written to make a model start building — none of which belongs in a
3029
+ * window whose job is to show a human what is going on.
3030
+ */
3031
+ function controlPanelNote(dir: string, armed: boolean): string {
3032
+ const { config } = loadConfig(dir);
3033
+ const { list } = loadFeatureList(dir);
3034
+ const p = computeProgress(list);
3035
+ const L: string[] = [];
3036
+ L.push("[infinity-harness] control panel");
3037
+ L.push(
3038
+ `${(config.currentPhase ?? "not started").toUpperCase()} · ${p.tasksDone}/${p.tasksTotal} tasks · ` +
3039
+ `${p.featuresDone}/${p.featuresTotal} features · plan rev ${list.baseRevision}` +
3040
+ (armed ? " · run armed" : ""),
3041
+ );
3042
+ L.push("");
3043
+ L.push(
3044
+ "The run works in separate background pi sessions, each on the model its difficulty tier " +
3045
+ "names. This session does not do that work and should not start it.",
3046
+ );
3047
+ L.push("");
3048
+ L.push(" /infinity:workers what the background sessions are doing right now");
3049
+ L.push(" /infinity:status where the run is");
3050
+ L.push(armed ? " /infinity:halt stop the run" : " /infinity:run start the run");
3051
+ L.push(" /infinity:approve sign a phase that is waiting for you");
3052
+ return L.join("\n");
3053
+ }
3054
+
3055
+ /**
3056
+ * The contract for a control panel.
3057
+ *
3058
+ * The pipeline contract tells a model to work the plan. Told that in a
3059
+ * session whose whole point is *not* to work the plan, a model helpfully
3060
+ * starts building — on the human's own model, which is the bug this engine
3061
+ * exists to fix. This says the opposite, in as few words.
3062
+ */
3063
+ function controlPanelContract(dir: string): string | null {
3064
+ const { config, ok } = loadConfig(dir);
3065
+ if (!ok || !config.currentPhase) return null;
3066
+ const { list } = loadFeatureList(dir);
3067
+ const p = computeProgress(list);
3068
+ return [
3069
+ "## infinity-harness — you are the control panel",
3070
+ "",
3071
+ `This project runs an infinity-harness pipeline at **${config.currentPhase.toUpperCase()}**, ` +
3072
+ `${p.tasksDone}/${p.tasksTotal} tasks done. The work is being done by separate background ` +
3073
+ `pi sessions on their own models, not by you.`,
3074
+ "",
3075
+ "1. Do not implement plan tasks, advance phases, or edit `harness/` by hand. Answer the",
3076
+ " human's questions about the run, and use `/infinity:workers` and `infinity_status`",
3077
+ " to see what the background sessions are doing.",
3078
+ "2. If the human asks you to build something, say that the harness is driving it and offer",
3079
+ " `/infinity:run`, `/infinity:halt`, or `/infinity:replan` instead.",
3080
+ "3. The plan of record is `harness/features/feature-list.json`; your memory of it is not.",
3081
+ ].join("\n");
3082
+ }
3083
+
2718
3084
  /**
2719
3085
  * The few sentences the run cannot afford to have summarised away.
2720
3086
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.6.5",
3
+ "version": "2.7.0",
4
4
  "description": "A pi agent extension that runs a gated build pipeline unattended \u2014 enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -52,7 +52,7 @@ export function defaultConfig(): HarnessConfig {
52
52
  roles: { strict: false },
53
53
  researchDepth: "deep" as import("./types.ts").ResearchDepth,
54
54
  session: { handoff: "task", contextThreshold: 0.6, carryNotes: true },
55
- execution: { parallelAt: "task", maxWorkers: 3 },
55
+ execution: { engine: "background", parallelAt: "task", maxWorkers: 3 },
56
56
  approvals: { research: false, define: false, plan: false },
57
57
  phaseModes: Object.fromEntries(DEFAULT_ENABLED_PHASES.map((p) => [p, "autopilot"])),
58
58
  workflow: { id: "autopilot", name: "autopilot" },