infinity-harness 2.6.6 → 2.8.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +68 -15
  3. package/extensions/infinity-harness/index.ts +600 -26
  4. package/harness/docs/ARCHITECTURE.md +13 -7
  5. package/harness/docs/CONSTRAINTS.md +13 -5
  6. package/harness/docs/DECISIONS.md +44 -0
  7. package/harness/docs/DOMAIN.md +44 -8
  8. package/package.json +1 -1
  9. package/src/core/config.ts +88 -1
  10. package/src/core/featureList.ts +85 -17
  11. package/src/core/gates.ts +8 -6
  12. package/src/core/init.ts +33 -3
  13. package/src/core/modelRouter.ts +149 -0
  14. package/src/core/paths.ts +29 -0
  15. package/src/core/plan.ts +39 -0
  16. package/src/core/runState.ts +151 -0
  17. package/src/core/settings.ts +138 -4
  18. package/src/core/types.ts +49 -0
  19. package/src/daemon/budget.ts +94 -0
  20. package/src/daemon/guard.ts +113 -0
  21. package/src/daemon/index.ts +421 -0
  22. package/src/daemon/isolation.ts +95 -0
  23. package/src/daemon/preflight.ts +132 -0
  24. package/src/daemon/server.ts +153 -0
  25. package/src/daemon/supervisorState.ts +83 -0
  26. package/src/daemon/worker.ts +239 -0
  27. package/src/daemon/worktree.ts +95 -0
  28. package/src/exec/piWorker.ts +706 -0
  29. package/src/goalState.ts +2 -22
  30. package/src/intake.ts +4 -1
  31. package/src/loop.ts +35 -34
  32. package/src/modelRouter.ts +0 -0
  33. package/src/remote.ts +28 -7
  34. package/src/replan.ts +7 -3
  35. package/src/rework.ts +9 -3
  36. package/src/runState.ts +15 -121
  37. package/src/scheduler.ts +115 -135
  38. package/src/supervisor.ts +955 -0
  39. package/src/taskList.ts +41 -3
  40. package/src/ui/dashboard.ts +127 -0
  41. package/src/ui/viewState.ts +77 -0
  42. package/src/ui/widget.ts +189 -0
  43. package/src/ui/wizard.ts +43 -7
  44. package/src/unstuck.ts +0 -0
  45. package/src/worker.ts +12 -8
@@ -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";
@@ -30,6 +31,9 @@ import { runChecks } from "../../src/core/gates.ts";
30
31
  import { advancePhase } from "../../src/core/phases.ts";
31
32
  import { configPath } from "../../src/core/paths.ts";
32
33
  import { readJsonSafe } from "../../src/core/fsx.ts";
34
+ import { resolve as resolvePath } from "node:path";
35
+ import { deriveViewState as deriveViewStateSync } from "../../src/ui/viewState.ts";
36
+ import { runStatePath as runStatePathSync } from "../../src/core/paths.ts";
33
37
  import { withLock } from "../../src/core/lock.ts";
34
38
  import {
35
39
  DEFAULT_ENABLED_PHASES,
@@ -49,6 +53,20 @@ import { amendPlan, loadReplanHistory, type ReplanTaskInput } from "../../src/re
49
53
  import { chooseUnstuckStrategy } from "../../src/unstuck.ts";
50
54
  import { escalationSummary } from "../../src/escalate.ts";
51
55
  import { spawnIsolatedWorker } from "../../src/worker.ts";
56
+ import { executionPolicyOf } from "../../src/scheduler.ts";
57
+ import { isWorkerProcess } from "../../src/exec/piWorker.ts";
58
+ import {
59
+ appendActivity,
60
+ currentUnit,
61
+ describeUnit,
62
+ loadActivity,
63
+ loadSupervisorState,
64
+ isRefusal,
65
+ startSupervisor,
66
+ type ActivityLine,
67
+ type RunningSupervisor,
68
+ type SupervisorState,
69
+ } from "../../src/supervisor.ts";
52
70
  import {
53
71
  startGoal,
54
72
  loadGoal,
@@ -104,6 +122,23 @@ import type { DisplayPolicy } from "../../src/core/types.ts";
104
122
  import { defaultView, scrollView, SCROLL_STEP, TASK_WINDOW, EXPANDED_WINDOW, type WidgetView } from "../../src/ui/widget.ts";
105
123
  import { buildPlanRows } from "../../src/ui/planTree.ts";
106
124
 
125
+ /**
126
+ * This file's own path.
127
+ *
128
+ * A background worker normally discovers the harness the same way this
129
+ * session did, because the harness is an installed pi package. When it was
130
+ * loaded from an explicit `-e` instead — a dev checkout, and every e2e run —
131
+ * discovery finds nothing, and the supervisor restarts the worker with this
132
+ * path so it still has the plan tools.
133
+ */
134
+ const SELF_PATH: string | null = (() => {
135
+ try {
136
+ return fileURLToPath(import.meta.url);
137
+ } catch {
138
+ return null;
139
+ }
140
+ })();
141
+
107
142
  const CHECKPOINT = "infinity:checkpoint";
108
143
  const WIDGET_KEY = "infinity-harness";
109
144
  const STATUS_KEY = "infinity";
@@ -139,6 +174,21 @@ export default function (pi: ExtensionAPI): void {
139
174
  let remoteDir: string | null = null;
140
175
  let view: WidgetView = defaultView();
141
176
 
177
+ /**
178
+ * The background orchestrator, when one is running in this session.
179
+ *
180
+ * It is a plain async loop in this process — no LLM, no tokens — that keeps
181
+ * one `pi` child working on the current unit. Only one session drives a run
182
+ * at a time; a second pi window on the same project shows the widget and
183
+ * the log, and leaves the driving alone.
184
+ */
185
+ let supervisor: RunningSupervisor | null = null;
186
+ /** This session is a background worker, not the human's control panel. */
187
+ const workerProcess = isWorkerProcess();
188
+ /** Ring of activity lines mirrored into the widget. */
189
+ let activity: ActivityLine[] = [];
190
+ let supState: SupervisorState | null = null;
191
+
142
192
  /**
143
193
  * Is this instance's session still the live one?
144
194
  *
@@ -187,6 +237,16 @@ export default function (pi: ExtensionAPI): void {
187
237
  const { config } = loadConfig(dir);
188
238
  const handoffModelNote: string | null = handoffNoteFor((config.session?.handoff as import("../../src/core/types.ts").HandoffGranularity) ?? "task");
189
239
  const spent = escalationSummary(dir);
240
+ // v3 viewState: widget must read daemon.json before rendering — deriveViewState is that check.
241
+ let viewState: WidgetState["viewState"] = null;
242
+ try { viewState = deriveViewStateSync(dir) as unknown as WidgetState["viewState"]; } catch {}
243
+ let pilotTag2: string | null = (config as unknown as { pilot?: string }).pilot ?? null;
244
+ let phaseModes2: Record<string,string> | null = (config as unknown as { phaseModes?: Record<string,string> }).phaseModes ?? null;
245
+ if (!pilotTag2) pilotTag2 = (config as unknown as { mode?: string }).mode === "copilot" ? "copilot" : (config as unknown as { mode?: string }).mode === "autopilot" ? "autopilot" : null;
246
+ let tierSpend2: WidgetState["tierSpend"] = null;
247
+ let reworkDepth2: number | null = null;
248
+ try { const rs = readJsonSafe<{ budget?: { byTier?: Record<string,{input:number;output:number;cost:number;calls:number}> } } | null>(runStatePathSync(dir), null); tierSpend2 = (rs?.budget?.byTier as WidgetState["tierSpend"]) ?? null; } catch {}
249
+ try { const rw = readJsonSafe<{ impactedCount?:number, queue?: unknown[] } | null>(resolvePath(dir,"harness/rework.json"), null); if (Array.isArray((rw as { queue?:unknown[]})?.queue)) reworkDepth2 = (rw as { queue: unknown[]}).queue.length; else if (typeof (rw as { impactedCount?:number})?.impactedCount === "number") reworkDepth2 = Number((rw as { impactedCount:number}).impactedCount); } catch {}
190
250
  const loop = readJsonSafe<{ escalations?: { strategy: string }[] } | null>(
191
251
  loopStatePath(dir),
192
252
  null,
@@ -195,9 +255,38 @@ export default function (pi: ExtensionAPI): void {
195
255
  const pass = typeof config.goalPass === "number" ? config.goalPass : null;
196
256
  const maxPasses = typeof config.goalMaxPasses === "number" ? config.goalMaxPasses : null;
197
257
  const run = loadRunState(dir);
258
+ const sup = supState ?? loadSupervisorState(dir);
259
+ const worker = sup?.worker ?? null;
198
260
  return {
199
261
  list,
262
+ viewState,
263
+ pilot: pilotTag2,
264
+ phaseModes: phaseModes2,
265
+ tierSpend: tierSpend2,
266
+ reworkDepth: reworkDepth2,
200
267
  view,
268
+ engine: executionPolicyOf(config).engine,
269
+ workers: worker
270
+ ? [
271
+ {
272
+ name: worker.name,
273
+ unit: worker.unitLabel,
274
+ level: worker.level,
275
+ model: worker.servedModel ?? worker.model,
276
+ difficulty: worker.difficulty,
277
+ state: worker.state,
278
+ doing: worker.doing,
279
+ tokens: worker.tokens.inputTokens + worker.tokens.outputTokens,
280
+ contextRatio: worker.contextRatio,
281
+ },
282
+ ]
283
+ : [],
284
+ activity: (activity.length ? activity : loadActivity(dir)).slice(-40).map((l) => ({
285
+ at: l.at,
286
+ level: l.level,
287
+ worker: l.worker,
288
+ text: l.text,
289
+ })),
201
290
  dashboardUrl: remoteServer?.url ?? null,
202
291
  handoffModelNote,
203
292
  sessions: run?.sessions ?? null,
@@ -417,6 +506,106 @@ export default function (pi: ExtensionAPI): void {
417
506
  } catch { return null; }
418
507
  };
419
508
 
509
+
510
+ // -- the background engine -------------------------------------------------
511
+
512
+ /** Where work runs for this project, honouring the legacy escape hatch. */
513
+ const engineFor = (dir: string): "background" | "main-session" => {
514
+ try {
515
+ return executionPolicyOf(loadConfig(dir).config).engine;
516
+ } catch {
517
+ return "background";
518
+ }
519
+ };
520
+
521
+ /**
522
+ * The model this session is on, as `provider/id`.
523
+ *
524
+ * A router slot left empty means "whatever pi is already configured with",
525
+ * and a *child* process does not inherit that: it would fall back to pi's
526
+ * default provider, which is not necessarily what the human is looking at.
527
+ * So we read it here and hand it to the worker explicitly.
528
+ */
529
+ const baseModelOf = (ctx: unknown): string | null => {
530
+ try {
531
+ const m = (ctx as { model?: { id?: string; provider?: string } }).model;
532
+ if (m?.id) return m.provider ? `${m.provider}/${m.id}` : m.id;
533
+ } catch {
534
+ /* pi without a model is a pi that cannot run anything anyway */
535
+ }
536
+ return null;
537
+ };
538
+
539
+ /**
540
+ * Start the background orchestrator for this project.
541
+ *
542
+ * Everything it does happens in this process, in plain JavaScript. It costs
543
+ * no tokens in this session: the only LLM calls a run makes are made by the
544
+ * `pi` children it spawns, on the models the router chose for them.
545
+ */
546
+ const startEngine = async (ctx: ExtensionContext, dir: string): Promise<boolean> => {
547
+ if (supervisor?.isRunning()) return true;
548
+ if (ctx.mode !== "tui" && ctx.mode !== "rpc") {
549
+ // `pi -p` has no future in which to watch anything. Arming the run is
550
+ // still right — the next interactive session picks it up.
551
+ notify(ctx, "infinity-harness: run armed. Open pi interactively to drive it.", "info");
552
+ return false;
553
+ }
554
+ const runId = runFor(dir);
555
+ const started = startSupervisor({
556
+ targetDir: dir,
557
+ runId,
558
+ sessionId,
559
+ baseModel: baseModelOf(ctx),
560
+ harnessExtension: SELF_PATH,
561
+ hooks: {
562
+ onState: (st) => {
563
+ supState = st;
564
+ if (sessionLive) refreshWidget(ctx);
565
+ },
566
+ onActivity: (line) => {
567
+ activity = [...activity, line].slice(-120);
568
+ if (!sessionLive) return;
569
+ // Only the things a human would want interrupted for. Tool-by-tool
570
+ // narration belongs in the widget's log, not in notifications.
571
+ if (line.level === "error" || line.level === "warn" || line.level === "good") {
572
+ notify(ctx, `infinity-harness: ${line.text}`, line.level === "error" ? "error" : line.level === "warn" ? "warning" : "info");
573
+ }
574
+ refreshWidget(ctx);
575
+ },
576
+ onApproval: (phase) => {
577
+ if (sessionLive) void askForApproval(ctx, dir, phase);
578
+ },
579
+ onStop: (reason, detail) => {
580
+ if (!sessionLive) return;
581
+ notify(ctx, `infinity-harness: run finished — ${detail}`, reason === "complete" ? "info" : "warning");
582
+ refreshWidget(ctx);
583
+ },
584
+ },
585
+ });
586
+ if (isRefusal(started)) {
587
+ // A second pi window on the same project is a *viewer*. Saying so is
588
+ // better than quietly putting two workers in one working tree.
589
+ notify(ctx, `infinity-harness: ${started.reason} This window still shows the run.`, "warning");
590
+ supervisor = null;
591
+ refreshWidget(ctx);
592
+ return false;
593
+ }
594
+ supervisor = started;
595
+ return true;
596
+ };
597
+
598
+ const stopEngine = async (reason: string): Promise<void> => {
599
+ const running = supervisor;
600
+ supervisor = null;
601
+ if (!running) return;
602
+ try {
603
+ await running.stop(reason);
604
+ } catch {
605
+ /* a supervisor that will not stop cleanly must not block the command */
606
+ }
607
+ };
608
+
420
609
  // -- session handoff ------------------------------------------------------
421
610
 
422
611
  /** The task/feature/sprint/goal/subtask the pipeline is on right now, or null. */
@@ -664,16 +853,35 @@ export default function (pi: ExtensionAPI): void {
664
853
  const dir = projectDir(ctx);
665
854
  if (!isHarnessProject(dir)) return;
666
855
 
856
+ // A background worker loads this extension too — it needs the plan tools
857
+ // and the phase guard. What it must not do is drive: no widget, no brief
858
+ // injection, no loop, and above all no supervisor of its own, or one run
859
+ // would fork into a tree of pi processes spawning pi processes.
860
+ if (workerProcess) {
861
+ try {
862
+ pi.appendEntry("infinity:worker-session", {
863
+ unit: process.env.INFINITY_HARNESS_UNIT ?? null,
864
+ runId: process.env.INFINITY_HARNESS_RUN ?? null,
865
+ });
866
+ } catch {
867
+ /* best effort */
868
+ }
869
+ return;
870
+ }
871
+
667
872
  view = defaultView();
668
873
  refreshWidget(ctx);
669
874
  installTerminalShortcuts(ctx);
670
875
  const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
671
876
  const { config } = loadConfig(dir);
672
877
  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 {} })();
878
+ const engine = engineFor(dir);
879
+ // Under the background engine this session is a control panel: its model
880
+ // is the human's and stays the human's. Routing only ever applied to the
881
+ // legacy engine, where the human's session *is* the worker.
882
+ if (engine === "main-session") {
883
+ try { await applyRouting(ctx, dir, `session_start:${reason}`); } catch {}
884
+ }
677
885
 
678
886
 
679
887
  const run = reason === "startup" ? loadRunState(dir) : countSession(dir);
@@ -700,6 +908,36 @@ export default function (pi: ExtensionAPI): void {
700
908
  // A handoff written by the session this one replaces. It carries the brief
701
909
  // plus the reason the previous session ended, so the agent does not spend
702
910
  // its first turn working out why it woke up mid-run.
911
+ // The background engine picks a run back up by restarting the supervisor:
912
+ // the plan, the phase and every budget are on disk, so a pi that was
913
+ // closed overnight resumes where it stopped rather than starting over.
914
+ if (engine !== "main-session") {
915
+ clearHandoff(dir);
916
+ if (armed) {
917
+ const ok = await startEngine(ctx, dir);
918
+ if (ok) notify(ctx, "infinity-harness: continuing the run in background sessions.", "info");
919
+ }
920
+ // A short orientation note, not the brief. The brief is several
921
+ // kilobytes and it is what makes a model start *working* the pipeline;
922
+ // in a control panel it would be paid for on every turn the human takes
923
+ // and would invite this session to do the work itself.
924
+ try {
925
+ pi.sendMessage(
926
+ {
927
+ customType: "infinity:brief",
928
+ content: controlPanelNote(dir, armed),
929
+ display: true,
930
+ details: { phase: config.currentPhase, engine },
931
+ },
932
+ { triggerTurn: false },
933
+ );
934
+ } catch (e) {
935
+ notify(ctx, `infinity-harness: ${errMsg(e)}`, "warning");
936
+ }
937
+ refreshWidget(ctx);
938
+ return;
939
+ }
940
+
703
941
  const pending = takeHandoff(dir);
704
942
  if (pending && pending.runId === runFor(dir)) {
705
943
  try {
@@ -748,25 +986,34 @@ export default function (pi: ExtensionAPI): void {
748
986
  if (!sessionLive) return;
749
987
  const dir = projectDir(ctx);
750
988
  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 {}
989
+ // Live-model routing rewrites *this* session's model, which is only ever
990
+ // right when this session is the worker. Under the background engine the
991
+ // human's model is theirs, and the routed model belongs to the child.
992
+ if (engineFor(dir) === "main-session") {
993
+ try { await applyRouting(ctx, dir, "before_agent_start"); } catch {}
994
+ }
756
995
  try {
757
- const contract = harnessContract(dir);
758
- if (!contract) return;
996
+ const viewState = (()=>{ try{ return deriveViewStateSync(dir); }catch{return null; }})();
997
+ const daemonAlive = (()=>{ try { const d=readJsonSafe<{heartbeatAt?:string}|null>(resolvePath(dir,"harness/daemon.json"), null); if(!d?.heartbeatAt) return false; return Date.now()-new Date(d.heartbeatAt).getTime()<90_000; } catch { return false; }})() ? "daemon alive" : viewState ? `daemon ${viewState.state}` : "daemon unknown";
998
+ const armed = (()=>{ try{ const r=readJsonSafe<{armed?:boolean}|null>(runStatePathSync(dir), null); return r?.armed===true ? "armed" : "not armed"; }catch{return "not armed";}})();
999
+ const prefix = `[infinity-harness] ${daemonAlive}, ${armed}, phase=${loadConfig(dir).config.currentPhase ?? "?"}${viewState?.reason ? ` — ${viewState.reason}` : ""}\n`;
1000
+ const contract =
1001
+ engineFor(dir) === "main-session" ? harnessContract(dir) : controlPanelContract(dir);
1002
+ if (!contract) {
1003
+ const base0 = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
1004
+ return { systemPrompt: `${prefix}\n${base0}` };
1005
+ }
759
1006
  const base = (event as { systemPrompt?: string }).systemPrompt ?? ctx.getSystemPrompt();
760
1007
  const routed = await routingSummaryForBrief(dir);
761
1008
  const suffix = routed ? `\n\n${routed}` : "";
762
- return { systemPrompt: `${base}${suffix}\n\n${contract}` };
1009
+ return { systemPrompt: `${prefix}${base}${suffix}\n\n${contract}` };
763
1010
  } catch {
764
1011
  return;
765
1012
  }
766
1013
  });
767
1014
 
768
1015
  pi.on("session_tree", async (_event, ctx) => {
769
- if (!sessionLive) return;
1016
+ if (!sessionLive || workerProcess) return;
770
1017
  refreshWidget(ctx);
771
1018
  });
772
1019
 
@@ -776,7 +1023,7 @@ export default function (pi: ExtensionAPI): void {
776
1023
  * few calls costs little and keeps the plan honest.
777
1024
  */
778
1025
  pi.on("context", async (event, ctx) => {
779
- if (!sessionLive) return;
1026
+ if (!sessionLive || workerProcess) return;
780
1027
  const dir = projectDir(ctx);
781
1028
  if (!isHarnessProject(dir)) return;
782
1029
 
@@ -855,6 +1102,9 @@ export default function (pi: ExtensionAPI): void {
855
1102
 
856
1103
  pi.on("session_compact", async (event, ctx) => {
857
1104
  if (!sessionLive) return;
1105
+ // A worker that compacts re-reads the brief from its own prompt; the
1106
+ // control-panel re-brief would put a second brief in front of it.
1107
+ if (workerProcess) return;
858
1108
  const dir = projectDir(ctx);
859
1109
  if (!isHarnessProject(dir)) return;
860
1110
  try {
@@ -883,7 +1133,7 @@ export default function (pi: ExtensionAPI): void {
883
1133
  });
884
1134
 
885
1135
  pi.on("turn_end", async (_event, ctx) => {
886
- if (!sessionLive) return;
1136
+ if (!sessionLive || workerProcess) return;
887
1137
  refreshWidget(ctx);
888
1138
  });
889
1139
 
@@ -896,11 +1146,15 @@ export default function (pi: ExtensionAPI): void {
896
1146
  * `/reload`, `/resume`, and pi being restarted.
897
1147
  */
898
1148
  pi.on("agent_settled", async (_event, ctx) => {
899
- if (!sessionLive) return;
1149
+ if (!sessionLive || workerProcess) return;
900
1150
  const dir = projectDir(ctx);
901
1151
  if (!isHarnessProject(dir)) return;
902
1152
  if (loopBusy || handingOff) return;
903
1153
  if (!loopArmed(dir)) return;
1154
+ // The background engine drives the run from the supervisor, in this
1155
+ // process, with no LLM turn in this session at all. Driving it from here
1156
+ // too is what made the human's model pay for the whole run.
1157
+ if (engineFor(dir) !== "main-session") return;
904
1158
 
905
1159
  loopBusy = true;
906
1160
  try {
@@ -1020,6 +1274,8 @@ export default function (pi: ExtensionAPI): void {
1020
1274
 
1021
1275
  pi.on("session_shutdown", async () => {
1022
1276
  sessionLive = false;
1277
+ // A pi that closes must not leave a worker running against the project.
1278
+ await stopEngine("this pi session closed");
1023
1279
  if (remoteServer) {
1024
1280
  try {
1025
1281
  await remoteServer.close();
@@ -1078,6 +1334,7 @@ export default function (pi: ExtensionAPI): void {
1078
1334
  difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
1079
1335
  modelHint: { type: "string" },
1080
1336
  criteria: { type: "array", items: { type: "string" } },
1337
+ phase: { type: "string", enum: ["research","define","plan","build","verify","simplify","review","ship"], description: "Which phase owns this task — task.phase, absent means build" },
1081
1338
  },
1082
1339
  },
1083
1340
  },
@@ -1792,14 +2049,83 @@ export default function (pi: ExtensionAPI): void {
1792
2049
  },
1793
2050
  });
1794
2051
 
2052
+
2053
+ /**
2054
+ * What the background sessions are doing.
2055
+ *
2056
+ * The run's work is no longer in this transcript, so this is where a human
2057
+ * looks when they come back to the terminal. It prints rather than asking
2058
+ * the model anything: reading the log must never cost a turn.
2059
+ */
2060
+ pi.registerCommand("infinity:workers", {
2061
+ description: "Show the background pi sessions — which unit, which model, and the recent log",
2062
+ handler: async (args: string, ctx: ExtensionContext) => {
2063
+ const dir = projectDir(ctx);
2064
+ if (!isHarnessProject(dir)) {
2065
+ notify(ctx, NO_HARNESS, "warning");
2066
+ return;
2067
+ }
2068
+ const rows = Math.max(5, Math.min(120, Number.parseInt(args.trim(), 10) || 25));
2069
+ const st = loadSupervisorState(dir);
2070
+ const log = loadActivity(dir);
2071
+ const engine = engineFor(dir);
2072
+ const lines: string[] = [];
2073
+ lines.push(
2074
+ engine === "background"
2075
+ ? "Work runs in background pi sessions. This session spends nothing on it."
2076
+ : "Work runs in THIS session (execution.engine = main-session). Your model is paying for the run.",
2077
+ );
2078
+ if (st?.unit) lines.push(`Unit ${describeUnit(st.unit, st.baseModel)}`);
2079
+ if (st?.worker) {
2080
+ const w = st.worker;
2081
+ lines.push(
2082
+ `Worker ${w.name} · ${w.state} · ${w.unitLabel} · asked ${w.model || "pi default"}` +
2083
+ (w.servedModel && w.servedModel !== w.model ? ` · served ${w.servedModel}` : "") +
2084
+ ` · ${w.turns} turn(s) · ${w.tokens.inputTokens + w.tokens.outputTokens} tokens`,
2085
+ );
2086
+ if (w.doing) lines.push(` ${w.doing}`);
2087
+ } else {
2088
+ lines.push("Worker none running");
2089
+ }
2090
+ if (st?.history?.length) {
2091
+ lines.push("");
2092
+ lines.push("Finished sessions (newest last):");
2093
+ for (const h of st.history.slice(-6)) {
2094
+ lines.push(` ${h.name} · ${h.unitLabel} · ${h.servedModel ?? (h.model || "pi default")} · ${h.turns} turn(s)`);
2095
+ }
2096
+ }
2097
+ lines.push("");
2098
+ lines.push(log.length ? `Background log (last ${Math.min(rows, log.length)}):` : "Background log is empty.");
2099
+ for (const l of log.slice(-rows)) {
2100
+ const when = l.at.slice(11, 16);
2101
+ lines.push(` ${when} ${l.worker ? l.worker + " " : ""}${l.text}`);
2102
+ }
2103
+ pi.sendMessage(
2104
+ { customType: "infinity:workers", content: lines.join("\n"), display: true, details: { engine } },
2105
+ { triggerTurn: false },
2106
+ );
2107
+ },
2108
+ });
2109
+
1795
2110
  pi.registerCommand("infinity:approve", {
1796
- description: "Approve the phase waiting for you — or send it back with a note",
2111
+ description: "Approve the phase waiting for you — or send it back with a note (via daemon when running)",
1797
2112
  handler: async (args: string, ctx: ExtensionContext) => {
1798
2113
  const dir = projectDir(ctx);
1799
2114
  if (!isHarnessProject(dir)) {
1800
2115
  notify(ctx, NO_HARNESS, "warning");
1801
2116
  return;
1802
2117
  }
2118
+ // Prefer daemon when alive: forward approval to localhost daemon (token-guarded).
2119
+ try {
2120
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
2121
+ if (d?.port) {
2122
+ const h: Record<string,string> = { "Content-Type": "application/json" };
2123
+ if (d.token) h["Authorization"]=`Bearer ${d.token}`;
2124
+ const r = await fetch(`http://127.0.0.1:${d.port}/approve`, { method: "POST", headers: h, body: JSON.stringify({ note: args.trim() }) });
2125
+ const j = await r.json().catch(()=>({})) as { ok?: boolean; error?: string };
2126
+ if (r.ok && j.ok !== false) { notify(ctx, "infinity-harness: approved via daemon.", "info"); refreshWidget(ctx); return; }
2127
+ }
2128
+ } catch {}
1803
2129
  const { config } = loadConfig(dir);
1804
2130
  if (!config.awaitingApproval) {
1805
2131
  const signing = approvedPhases(config);
@@ -1812,7 +2138,6 @@ export default function (pi: ExtensionAPI): void {
1812
2138
  );
1813
2139
  return;
1814
2140
  }
1815
- // Approving re-arms the run: the human answering is them saying carry on.
1816
2141
  if (!loopArmed(dir)) armRun(dir, sessionId);
1817
2142
  await applyApproval(ctx, dir, args.trim());
1818
2143
  },
@@ -2298,22 +2623,104 @@ export default function (pi: ExtensionAPI): void {
2298
2623
  });
2299
2624
 
2300
2625
  pi.registerCommand("infinity:run", {
2301
- description: "Start the continuous loop — validate, advance, re-brief, until done or stuck",
2626
+ description: "Start the continuous loop — validate, advance, re-brief, until done or stuck (captures baseModel for daemon)",
2302
2627
  handler: async (_args: string, ctx: ExtensionContext) => {
2303
2628
  const dir = projectDir(ctx);
2304
2629
  if (!isHarnessProject(dir)) {
2305
2630
  notify(ctx, NO_HARNESS, "warning");
2306
2631
  return;
2307
2632
  }
2633
+ // Capture baseModel (X) at arm time — the detached Daemon has no ctx.model.
2634
+ // Without this, Daemon refuses to arm and silently falls back to pi default.
2635
+ try {
2636
+ const bm = baseModelOf(ctx);
2637
+ if (bm) {
2638
+ const { loadRunState: _load, saveRunState: _save, runIdFor: _runIdFor } = await import("../../src/core/runState.ts");
2639
+ const runId = _runIdFor(dir, sessionId);
2640
+ const rs = _load(dir);
2641
+ const parts = bm.split("/");
2642
+ const baseModel = parts.length >= 2 ? { provider: parts[0]!, id: parts.slice(1).join("/") } : { provider: "anthropic", id: bm };
2643
+ if (rs) { rs.baseModel = baseModel as never; _save(dir, rs); }
2644
+ else {
2645
+ const { newRunState } = await import("../../src/core/runState.ts");
2646
+ const ns = newRunState(runId);
2647
+ ns.baseModel = baseModel as never;
2648
+ _save(dir, ns);
2649
+ }
2650
+ }
2651
+ } catch {}
2652
+ // When a detached Daemon can serve this repo, prefer it: it is the only
2653
+ // path where /infinity:halt|approve|pilot|replan go to Daemon and the
2654
+ // model stays on X. Spawning it is one detached `node` process.
2655
+ const tryDaemonSpawn = async (): Promise<{ spawned: boolean; url?: string }> => {
2656
+ try {
2657
+ const { spawn } = await import("node:child_process");
2658
+ const { existsSync, openSync, closeSync } = await import("node:fs");
2659
+ const { resolve: _resolve } = await import("node:path");
2660
+ // Daemon entry must exist; when not built (dev) fall back to supervisor.
2661
+ const candidates = [
2662
+ _resolve(dir, "dist/daemon/index.js"),
2663
+ _resolve(dir, "src/daemon/index.ts"),
2664
+ ];
2665
+ let entry: string | null = null;
2666
+ for (const c of candidates) if (existsSync(c)) { entry = c; break; }
2667
+ if (!entry) return { spawned: false };
2668
+ // Guard: single owner — if a daemon is already alive, do not spawn rival.
2669
+ try {
2670
+ const { loadDaemon: _ld, isDaemonAlive: _alive } = await import("../../src/daemon/guard.ts");
2671
+ const live = _ld(dir);
2672
+ if (live && _alive(live)) return { spawned: true, url: `http://127.0.0.1:${live.port}/dashboard` };
2673
+ } catch {}
2674
+ const logFd = openSync(_resolve(dir, "harness/daemon.log"), "a");
2675
+ const args: string[] = [];
2676
+ if (entry.endsWith(".ts")) args.push("--experimental-strip-types", "--no-warnings=ExperimentalWarning");
2677
+ args.push(entry, dir);
2678
+ const child = spawn(process.execPath, args, { detached: true, stdio: ["ignore", logFd, logFd], windowsHide: true, env: { ...process.env, INFINITY_HARNESS_WORKER: "1" } } as unknown as Parameters<typeof spawn>[2]);
2679
+ try { child.unref(); } catch {}
2680
+ try { closeSync(logFd); } catch {}
2681
+ // Brief poll for daemon.json liveness (port picked as 0).
2682
+ for (let i = 0; i < 40; i++) {
2683
+ await new Promise(r=>setTimeout(r, 250));
2684
+ try {
2685
+ const { loadDaemon: ld2, isDaemonAlive: alive2 } = await import("../../src/daemon/guard.ts");
2686
+ const live2 = ld2(dir);
2687
+ if (live2 && alive2(live2)) return { spawned: true, url: `http://127.0.0.1:${live2.port}/dashboard` };
2688
+ } catch {}
2689
+ }
2690
+ return { spawned: true };
2691
+ } catch { return { spawned: false }; }
2692
+ };
2308
2693
  armRun(dir, sessionId);
2694
+ const engine = engineFor(dir);
2309
2695
  notify(
2310
2696
  ctx,
2311
2697
  `infinity-harness: continuous run armed. It stops on completion, on an exhausted retry budget, ` +
2312
2698
  `when no progress is detected, or when you create ${stopFilePath(dir)}. Use /infinity:halt to stop now.`,
2313
2699
  "info",
2314
2700
  );
2315
- const text = await briefText(dir);
2316
- pi.sendUserMessage(text, { deliverAs: "followUp" });
2701
+ if (engine === "main-session") {
2702
+ const text = await briefText(dir);
2703
+ pi.sendUserMessage(text, { deliverAs: "followUp" });
2704
+ return;
2705
+ }
2706
+ const daemonResult = await tryDaemonSpawn();
2707
+ if (daemonResult.spawned) {
2708
+ 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");
2709
+ refreshWidget(ctx);
2710
+ return;
2711
+ }
2712
+ // Fallback: legacy background supervisor (spawns pi child sessions in this pi process).
2713
+ const unit = currentUnit(dir, baseModelOf(ctx));
2714
+ await startEngine(ctx, dir);
2715
+ notify(
2716
+ ctx,
2717
+ unit
2718
+ ? `infinity-harness: working in background sessions — ${describeUnit(unit, baseModelOf(ctx))}. ` +
2719
+ `This session stays free; /infinity:workers shows what they are doing.`
2720
+ : "infinity-harness: background engine started.",
2721
+ "info",
2722
+ );
2723
+ refreshWidget(ctx);
2317
2724
  },
2318
2725
  });
2319
2726
 
@@ -2397,7 +2804,7 @@ export default function (pi: ExtensionAPI): void {
2397
2804
  });
2398
2805
 
2399
2806
  pi.registerCommand("infinity:rework", {
2400
- description: "Send a task and its dependents back to rework",
2807
+ description: "Send a task and its dependents back to rework (via daemon when running)",
2401
2808
  handler: async (args: string, ctx: ExtensionContext) => {
2402
2809
  const dir = projectDir(ctx);
2403
2810
  if (!isHarnessProject(dir)) {
@@ -2405,6 +2812,19 @@ export default function (pi: ExtensionAPI): void {
2405
2812
  return;
2406
2813
  }
2407
2814
  const key = args.trim();
2815
+ // Daemon owns rework.json mutation — forward when alive.
2816
+ if (key && key !== "clear") {
2817
+ try {
2818
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
2819
+ if (d?.port) {
2820
+ const h: Record<string,string> = { "Content-Type": "application/json" };
2821
+ if (d.token) h["Authorization"] = `Bearer ${d.token}`;
2822
+ const r = await fetch(`http://127.0.0.1:${d.port}/rework`, { method: "POST", headers: h, body: JSON.stringify({ task: key }) });
2823
+ const j = await r.json().catch(()=>({})) as { ok?: boolean; error?: string };
2824
+ if (r.ok && j.ok !== false) { notify(ctx, `infinity-harness: rework ${key} via daemon.`, "info"); refreshWidget(ctx); return; }
2825
+ }
2826
+ } catch {}
2827
+ }
2408
2828
  const { list } = loadFeatureList(dir);
2409
2829
  const tasks = flattenTasks(list);
2410
2830
 
@@ -2459,9 +2879,21 @@ export default function (pi: ExtensionAPI): void {
2459
2879
  notify(ctx, NO_HARNESS, "warning");
2460
2880
  return;
2461
2881
  }
2882
+ // Prefer daemon path: POST /halt to daemon (token-guarded), fall back to local.
2883
+ try {
2884
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
2885
+ if (d?.port) {
2886
+ const headers: Record<string,string> = { "Content-Type": "application/json" };
2887
+ if (d.token) headers["Authorization"] = `Bearer ${d.token}`;
2888
+ const r = await fetch(`http://127.0.0.1:${d.port}/halt`, { method: "POST", headers, body: JSON.stringify({}) });
2889
+ const j = await r.json().catch(()=> ({})) as { ok?: boolean; error?: string };
2890
+ if (r.ok && j.ok !== false) { notify(ctx, "infinity-harness: halted via daemon.", "info"); refreshWidget(ctx); return; }
2891
+ }
2892
+ } catch {}
2462
2893
  disarmRun(dir, "halted from /infinity:halt");
2463
2894
  clearHandoff(dir);
2464
- notify(ctx, "infinity-harness: continuous run stopped.", "info");
2895
+ await stopEngine("halted from /infinity:halt");
2896
+ notify(ctx, "infinity-harness: continuous run stopped, background sessions closed.", "info");
2465
2897
  refreshWidget(ctx);
2466
2898
  },
2467
2899
  });
@@ -2481,7 +2913,8 @@ export default function (pi: ExtensionAPI): void {
2481
2913
  return saveConfig(dir, config).ok;
2482
2914
  });
2483
2915
  disarmRun(dir, "paused from /infinity:pause");
2484
- notify(ctx, value ? "infinity-harness: paused." : "Could not pause — config unreadable.", value ? "info" : "error");
2916
+ await stopEngine("paused from /infinity:pause");
2917
+ notify(ctx, value ? "infinity-harness: paused, background sessions closed." : "Could not pause — config unreadable.", value ? "info" : "error");
2485
2918
  refreshWidget(ctx);
2486
2919
  },
2487
2920
  });
@@ -2500,6 +2933,9 @@ export default function (pi: ExtensionAPI): void {
2500
2933
  config.paused = false;
2501
2934
  return saveConfig(dir, config).ok;
2502
2935
  });
2936
+ if (value && loadRunState(dir)?.armed === true && engineFor(dir) !== "main-session") {
2937
+ await startEngine(ctx, dir);
2938
+ }
2503
2939
  notify(ctx, value ? "infinity-harness: resumed." : "Could not resume — config unreadable.", value ? "info" : "error");
2504
2940
  refreshWidget(ctx);
2505
2941
  },
@@ -2670,16 +3106,24 @@ export default function (pi: ExtensionAPI): void {
2670
3106
  });
2671
3107
 
2672
3108
  pi.registerCommand("infinity:dashboard", {
2673
- description: "Open the read-only web dashboard for this run",
3109
+ description: "Open the read-only web dashboard for this run (served by daemon when running, fallback remote)",
2674
3110
  handler: async (_args: string, ctx: ExtensionContext) => {
2675
3111
  const dir = projectDir(ctx);
2676
3112
  if (!isHarnessProject(dir)) {
2677
3113
  notify(ctx, NO_HARNESS, "warning");
2678
3114
  return;
2679
3115
  }
3116
+ try {
3117
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
3118
+ if (d?.port) {
3119
+ const url = `http://127.0.0.1:${d.port}/dashboard`;
3120
+ notify(ctx, `infinity-harness dashboard (daemon): ${url}`, "info");
3121
+ return;
3122
+ }
3123
+ } catch {}
2680
3124
  const remote = await import("../../src/remote.ts");
2681
3125
  if (remoteServer) {
2682
- notify(ctx, `Dashboard already live at ${remoteServer.url}`, "info");
3126
+ notify(ctx, `Dashboard already live at ${remoteServer.url} (fallback remote)`, "info");
2683
3127
  return;
2684
3128
  }
2685
3129
  const srv = await remote.createRemoteServer({ projectDir: dir, host: "127.0.0.1", port: 0 });
@@ -2688,6 +3132,75 @@ export default function (pi: ExtensionAPI): void {
2688
3132
  notify(ctx, `infinity-harness dashboard: ${srv.url}`, "info");
2689
3133
  },
2690
3134
  });
3135
+
3136
+ pi.registerCommand("infinity:pilot", {
3137
+ description: "Set pilot mode — copilot | autopilot | full (takes effect at next phase boundary)",
3138
+ handler: async (args: string, ctx: ExtensionContext) => {
3139
+ const dir = projectDir(ctx);
3140
+ if (!isHarnessProject(dir)) { notify(ctx, NO_HARNESS, "warning"); return; }
3141
+ // Prefer daemon when alive.
3142
+ const raw = String(args ?? "").trim().toLowerCase();
3143
+ if (raw && ["copilot","autopilot","full"].includes(raw)) {
3144
+ try {
3145
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
3146
+ if (d?.port) {
3147
+ const h: Record<string,string> = { "Content-Type": "application/json" };
3148
+ if (d.token) h["Authorization"]=`Bearer ${d.token}`;
3149
+ const r = await fetch(`http://127.0.0.1:${d.port}/pilot`, { method:"POST", headers:h, body: JSON.stringify({ pilot: raw }) });
3150
+ const j = await r.json().catch(()=>({})) as { ok?:boolean; error?:string };
3151
+ if (r.ok && j.ok!==false) { notify(ctx, `infinity-harness: pilot → ${raw} via daemon (next boundary).`, "info"); refreshWidget(ctx); return; }
3152
+ }
3153
+ } catch {}
3154
+ }
3155
+ // Fallback: write config locally. Import core helper for pilot preset.
3156
+ try {
3157
+ const { applyPilotPreset } = await import("../../src/core/config.ts");
3158
+ const { withLock: _wl } = await import("../../src/core/lock.ts");
3159
+ if (!raw) {
3160
+ const { config: c } = loadConfig(dir);
3161
+ const cur = (c as unknown as { pilot?: string }).pilot ?? "autopilot";
3162
+ notify(ctx, `infinity-harness pilot is ${cur}. Usage: /infinity:pilot copilot|autopilot|full`, "info");
3163
+ return;
3164
+ }
3165
+ if (!["copilot","autopilot","full"].includes(raw)) { notify(ctx, `"${raw}" is not a pilot mode — copilot | autopilot | full`, "warning"); return; }
3166
+ const wl = _wl as unknown as (path: string, fn: ()=>unknown)=>Promise<{ ok:boolean; value: unknown }>;
3167
+ await wl(configPath(dir), () => {
3168
+ const l = loadConfig(dir);
3169
+ if (!l.ok) return false;
3170
+ (l.config as unknown as { pilot: string }).pilot = raw;
3171
+ applyPilotPreset(l.config as unknown as Parameters<typeof applyPilotPreset>[0], raw as "copilot"|"autopilot"|"full");
3172
+ return saveConfig(dir, l.config).ok;
3173
+ });
3174
+ notify(ctx, `infinity-harness: pilot → ${raw} (next phase boundary).`, "info");
3175
+ refreshWidget(ctx);
3176
+ } catch (e) { notify(ctx, e instanceof Error ? e.message : String(e), "error"); }
3177
+ },
3178
+ });
3179
+
3180
+ pi.registerCommand("infinity:replan", {
3181
+ description: "Propose a plan amendment (mid-run addFeatures/addTasks) — via daemon when running",
3182
+ handler: async (args: string, ctx: ExtensionContext) => {
3183
+ const dir = projectDir(ctx);
3184
+ if (!isHarnessProject(dir)) { notify(ctx, NO_HARNESS, "warning"); return; }
3185
+ const raw = String(args ?? "").trim();
3186
+ // Try daemon first so replan invariants (cancel-not-delete, maxReplansPerPhase) are honoured under daemon lock.
3187
+ try {
3188
+ const d = readJsonSafe<{ port?: number; token?: string } | null>(resolvePath(dir, "harness/daemon.json"), null);
3189
+ if (d?.port) {
3190
+ const h: Record<string,string> = { "Content-Type": "application/json" };
3191
+ if (d.token) h["Authorization"]=`Bearer ${d.token}`;
3192
+ let body: Record<string, unknown> = {};
3193
+ if (raw) { try { body = JSON.parse(raw) as Record<string, unknown>; } catch { body = { reason: raw }; } }
3194
+ const r = await fetch(`http://127.0.0.1:${d.port}/replan`, { method:"POST", headers:h, body: JSON.stringify(body) });
3195
+ const j = await r.json().catch(()=>({})) as { ok?:boolean; error?:string };
3196
+ if (r.ok && j.ok!==false) { notify(ctx, `infinity-harness: replan via daemon — ${JSON.stringify(j)}`, "info"); refreshWidget(ctx); return; }
3197
+ }
3198
+ } catch {}
3199
+ notify(ctx, `Replan: ${raw || "use infinity_replan tool with addFeatures/addTasks, or POST JSON to /replan"}`, "info");
3200
+ },
3201
+ });
3202
+
3203
+
2691
3204
  }
2692
3205
 
2693
3206
  function errMsg(e: unknown): string {
@@ -2708,6 +3221,67 @@ function describeCurrentWorkflow(dir: string): string {
2708
3221
  return `${head}\n ${rail}\n (a phase in [brackets] stops for you)${drift}`;
2709
3222
  }
2710
3223
 
3224
+ /**
3225
+ * What this session is, when the work is happening somewhere else.
3226
+ *
3227
+ * Deliberately a few lines rather than the brief. The brief is several
3228
+ * kilobytes, it is re-read on every turn once it is in the transcript, and it
3229
+ * is written to make a model start building — none of which belongs in a
3230
+ * window whose job is to show a human what is going on.
3231
+ */
3232
+ function controlPanelNote(dir: string, armed: boolean): string {
3233
+ const { config } = loadConfig(dir);
3234
+ const { list } = loadFeatureList(dir);
3235
+ const p = computeProgress(list);
3236
+ const L: string[] = [];
3237
+ L.push("[infinity-harness] control panel");
3238
+ L.push(
3239
+ `${(config.currentPhase ?? "not started").toUpperCase()} · ${p.tasksDone}/${p.tasksTotal} tasks · ` +
3240
+ `${p.featuresDone}/${p.featuresTotal} features · plan rev ${list.baseRevision}` +
3241
+ (armed ? " · run armed" : ""),
3242
+ );
3243
+ L.push("");
3244
+ L.push(
3245
+ "The run works in separate background pi sessions, each on the model its difficulty tier " +
3246
+ "names. This session does not do that work and should not start it.",
3247
+ );
3248
+ L.push("");
3249
+ L.push(" /infinity:workers what the background sessions are doing right now");
3250
+ L.push(" /infinity:status where the run is");
3251
+ L.push(armed ? " /infinity:halt stop the run" : " /infinity:run start the run");
3252
+ L.push(" /infinity:approve sign a phase that is waiting for you");
3253
+ return L.join("\n");
3254
+ }
3255
+
3256
+ /**
3257
+ * The contract for a control panel.
3258
+ *
3259
+ * The pipeline contract tells a model to work the plan. Told that in a
3260
+ * session whose whole point is *not* to work the plan, a model helpfully
3261
+ * starts building — on the human's own model, which is the bug this engine
3262
+ * exists to fix. This says the opposite, in as few words.
3263
+ */
3264
+ function controlPanelContract(dir: string): string | null {
3265
+ const { config, ok } = loadConfig(dir);
3266
+ if (!ok || !config.currentPhase) return null;
3267
+ const { list } = loadFeatureList(dir);
3268
+ const p = computeProgress(list);
3269
+ return [
3270
+ "## infinity-harness — you are the control panel",
3271
+ "",
3272
+ `This project runs an infinity-harness pipeline at **${config.currentPhase.toUpperCase()}**, ` +
3273
+ `${p.tasksDone}/${p.tasksTotal} tasks done. The work is being done by separate background ` +
3274
+ `pi sessions on their own models, not by you.`,
3275
+ "",
3276
+ "1. Do not implement plan tasks, advance phases, or edit `harness/` by hand. Answer the",
3277
+ " human's questions about the run, and use `/infinity:workers` and `infinity_status`",
3278
+ " to see what the background sessions are doing.",
3279
+ "2. If the human asks you to build something, say that the harness is driving it and offer",
3280
+ " `/infinity:run`, `/infinity:halt`, or `/infinity:replan` instead.",
3281
+ "3. The plan of record is `harness/features/feature-list.json`; your memory of it is not.",
3282
+ ].join("\n");
3283
+ }
3284
+
2711
3285
  /**
2712
3286
  * The few sentences the run cannot afford to have summarised away.
2713
3287
  *