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.
- package/CHANGELOG.md +80 -0
- package/README.md +68 -15
- package/extensions/infinity-harness/index.ts +600 -26
- package/harness/docs/ARCHITECTURE.md +13 -7
- package/harness/docs/CONSTRAINTS.md +13 -5
- package/harness/docs/DECISIONS.md +44 -0
- package/harness/docs/DOMAIN.md +44 -8
- package/package.json +1 -1
- package/src/core/config.ts +88 -1
- package/src/core/featureList.ts +85 -17
- package/src/core/gates.ts +8 -6
- package/src/core/init.ts +33 -3
- package/src/core/modelRouter.ts +149 -0
- package/src/core/paths.ts +29 -0
- package/src/core/plan.ts +39 -0
- package/src/core/runState.ts +151 -0
- package/src/core/settings.ts +138 -4
- package/src/core/types.ts +49 -0
- package/src/daemon/budget.ts +94 -0
- package/src/daemon/guard.ts +113 -0
- package/src/daemon/index.ts +421 -0
- package/src/daemon/isolation.ts +95 -0
- package/src/daemon/preflight.ts +132 -0
- package/src/daemon/server.ts +153 -0
- package/src/daemon/supervisorState.ts +83 -0
- package/src/daemon/worker.ts +239 -0
- package/src/daemon/worktree.ts +95 -0
- package/src/exec/piWorker.ts +706 -0
- package/src/goalState.ts +2 -22
- package/src/intake.ts +4 -1
- package/src/loop.ts +35 -34
- package/src/modelRouter.ts +0 -0
- package/src/remote.ts +28 -7
- package/src/replan.ts +7 -3
- package/src/rework.ts +9 -3
- package/src/runState.ts +15 -121
- package/src/scheduler.ts +115 -135
- package/src/supervisor.ts +955 -0
- package/src/taskList.ts +41 -3
- package/src/ui/dashboard.ts +127 -0
- package/src/ui/viewState.ts +77 -0
- package/src/ui/widget.ts +189 -0
- package/src/ui/wizard.ts +43 -7
- package/src/unstuck.ts +0 -0
- 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
|
-
|
|
674
|
-
//
|
|
675
|
-
|
|
676
|
-
|
|
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
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
|
|
755
|
-
|
|
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
|
|
758
|
-
|
|
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
|
-
|
|
2316
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
*
|