infinity-harness 2.0.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 +114 -0
- package/LICENSE +21 -0
- package/README.md +266 -0
- package/extensions/infinity-harness/index.ts +870 -0
- package/harness/docs/ARCHITECTURE.md +159 -0
- package/harness/docs/CONSTRAINTS.md +19 -0
- package/harness/docs/DECISIONS.md +107 -0
- package/harness/docs/DOMAIN.md +13 -0
- package/harness/docs/agents/evaluator.md +14 -0
- package/harness/docs/agents/generator.md +13 -0
- package/harness/docs/agents/planner.md +13 -0
- package/harness/docs/agents/simplifier.md +13 -0
- package/harness/docs/api-patterns.md +23 -0
- package/harness/docs/phases/build.md +47 -0
- package/harness/docs/phases/define.md +58 -0
- package/harness/docs/phases/plan.md +50 -0
- package/harness/docs/phases/review.md +47 -0
- package/harness/docs/phases/ship.md +43 -0
- package/harness/docs/phases/simplify.md +45 -0
- package/harness/docs/phases/verify.md +46 -0
- package/harness/model-router.json +28 -0
- package/harness/skills/README.md +60 -0
- package/harness/skills/auth-security.md +56 -0
- package/harness/skills/building-mcp-servers.md +70 -0
- package/harness/skills/building-tools.md +60 -0
- package/harness/skills/capability-acquisition.md +72 -0
- package/harness/skills/cli-design.md +55 -0
- package/harness/skills/code-review.md +57 -0
- package/harness/skills/codebase-design.md +70 -0
- package/harness/skills/concurrency-async.md +61 -0
- package/harness/skills/config-and-secrets.md +52 -0
- package/harness/skills/context-hygiene.md +51 -0
- package/harness/skills/databases.md +63 -0
- package/harness/skills/diagnosing-bugs.md +84 -0
- package/harness/skills/domain-modeling.md +65 -0
- package/harness/skills/error-handling-logging.md +56 -0
- package/harness/skills/frontend-ui.md +56 -0
- package/harness/skills/grilling.md +48 -0
- package/harness/skills/http-apis.md +60 -0
- package/harness/skills/performance.md +53 -0
- package/harness/skills/pi-todo-adapted.md +41 -0
- package/harness/skills/planning-tasks.md +86 -0
- package/harness/skills/prototype.md +39 -0
- package/harness/skills/research.md +32 -0
- package/harness/skills/resolving-merge-conflicts.md +30 -0
- package/harness/skills/scope-discipline.md +49 -0
- package/harness/skills/self-review.md +45 -0
- package/harness/skills/stuck-protocol.md +51 -0
- package/harness/skills/tdd.md +80 -0
- package/harness/skills/testing-infra.md +57 -0
- package/harness/skills/writing-skills.md +60 -0
- package/package.json +61 -0
- package/src/core/brief.ts +242 -0
- package/src/core/config.ts +265 -0
- package/src/core/exec.ts +130 -0
- package/src/core/featureList.ts +286 -0
- package/src/core/fsx.ts +119 -0
- package/src/core/gates.ts +444 -0
- package/src/core/lock.ts +192 -0
- package/src/core/paths.ts +95 -0
- package/src/core/phases.ts +143 -0
- package/src/core/settings.ts +445 -0
- package/src/core/types.ts +245 -0
- package/src/goalLoop.ts +628 -0
- package/src/goalSpec.ts +679 -0
- package/src/goalState.ts +338 -0
- package/src/loop.ts +355 -0
- package/src/modelRouter.ts +184 -0
- package/src/remote.ts +244 -0
- package/src/replan.ts +300 -0
- package/src/review.ts +53 -0
- package/src/rework.ts +274 -0
- package/src/taskList.ts +355 -0
- package/src/ui/config.ts +286 -0
- package/src/ui/dashboard.ts +1066 -0
- package/src/ui/theme.ts +317 -0
- package/src/ui/widget.ts +370 -0
- package/src/unstuck.ts +214 -0
- package/src/worker.ts +351 -0
- package/types/proper-lockfile.d.ts +19 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infinity-harness — the pi extension.
|
|
3
|
+
*
|
|
4
|
+
* This file is deliberately thin. It owns pi's lifecycle and nothing else:
|
|
5
|
+
* every decision about phases, gates, plans and looping lives in `src/`, where
|
|
6
|
+
* it is typed and unit-tested. An earlier version inlined copies of the plan
|
|
7
|
+
* engine and the widget here, which meant the tested code and the shipped code
|
|
8
|
+
* were two different implementations that drifted apart. There is one
|
|
9
|
+
* implementation now, and this adapter calls it.
|
|
10
|
+
*
|
|
11
|
+
* What the adapter is responsible for:
|
|
12
|
+
* - injecting the brief when a session starts
|
|
13
|
+
* - running the gate when the agent goes quiet, and advancing or re-briefing
|
|
14
|
+
* - keeping the plan widget truthful
|
|
15
|
+
* - surviving compaction without losing the plan
|
|
16
|
+
* - refusing tool calls that would skip a phase
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { randomUUID } from "node:crypto";
|
|
21
|
+
|
|
22
|
+
import { isHarnessProject, loadConfig, saveConfig } from "../../src/core/config.ts";
|
|
23
|
+
import { loadFeatureList, computeProgress } from "../../src/core/featureList.ts";
|
|
24
|
+
import { buildBrief, renderBrief } from "../../src/core/brief.ts";
|
|
25
|
+
import { runChecks } from "../../src/core/gates.ts";
|
|
26
|
+
import { advancePhase } from "../../src/core/phases.ts";
|
|
27
|
+
import { configPath } from "../../src/core/paths.ts";
|
|
28
|
+
import { withLock } from "../../src/core/lock.ts";
|
|
29
|
+
import { ValidationError, type FeatureList, type Phase } from "../../src/core/types.ts";
|
|
30
|
+
import { writeTaskList, summarizeApply, type TaskInput } from "../../src/taskList.ts";
|
|
31
|
+
import { renderWidget, renderStatusLine, type WidgetState } from "../../src/ui/widget.ts";
|
|
32
|
+
import { createStyler, detectGlyphs } from "../../src/ui/theme.ts";
|
|
33
|
+
import { decideNext, stopFilePath } from "../../src/loop.ts";
|
|
34
|
+
import { runConfigMenu, renderSettings, type ModelChoice, type Prompter } from "../../src/ui/config.ts";
|
|
35
|
+
import { SETTINGS, readAll, readSetting, formatValue } from "../../src/core/settings.ts";
|
|
36
|
+
|
|
37
|
+
const CHECKPOINT = "infinity:checkpoint";
|
|
38
|
+
const WIDGET_KEY = "infinity-harness";
|
|
39
|
+
const STATUS_KEY = "infinity";
|
|
40
|
+
|
|
41
|
+
/** Reminder cadence, in LLM calls, when the plan still has open tasks. */
|
|
42
|
+
const REMINDER_INTERVAL = 4;
|
|
43
|
+
|
|
44
|
+
function projectDir(ctx: unknown): string {
|
|
45
|
+
const c = ctx as { cwd?: string; projectDir?: string } | undefined;
|
|
46
|
+
return c?.cwd ?? c?.projectDir ?? process.cwd();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function notify(ctx: unknown, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
50
|
+
try {
|
|
51
|
+
(ctx as { ui?: { notify?: (m: string, t?: string) => void } }).ui?.notify?.(message, level);
|
|
52
|
+
} catch {
|
|
53
|
+
/* headless mode has no UI */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export default function (pi: ExtensionAPI): void {
|
|
58
|
+
// -- session-scoped state -------------------------------------------------
|
|
59
|
+
const runId = randomUUID();
|
|
60
|
+
let llmCalls = 0;
|
|
61
|
+
let loopEnabled = false;
|
|
62
|
+
let loopBusy = false;
|
|
63
|
+
let lastBriefPhase: string | null = null;
|
|
64
|
+
let remoteServer: { url: string; close: () => Promise<void> } | null = null;
|
|
65
|
+
let remoteDir: string | null = null;
|
|
66
|
+
|
|
67
|
+
const styler = createStyler();
|
|
68
|
+
const glyphs = detectGlyphs();
|
|
69
|
+
|
|
70
|
+
// -- widget ---------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
const widgetStateFor = (dir: string): WidgetState | null => {
|
|
73
|
+
try {
|
|
74
|
+
const { list } = loadFeatureList(dir);
|
|
75
|
+
const { config } = loadConfig(dir);
|
|
76
|
+
return {
|
|
77
|
+
list,
|
|
78
|
+
phase: config.currentPhase,
|
|
79
|
+
enabledPhases: config.phases?.enabled,
|
|
80
|
+
paused: Boolean(config.paused),
|
|
81
|
+
revision: list.baseRevision,
|
|
82
|
+
retries: { task: config.taskRetryCount ?? 0, max: config.maxRetries ?? 10 },
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const refreshWidget = (ctx: ExtensionContext): void => {
|
|
90
|
+
try {
|
|
91
|
+
const dir = projectDir(ctx);
|
|
92
|
+
const state = widgetStateFor(dir);
|
|
93
|
+
if (!state) return;
|
|
94
|
+
const lines = renderWidget(state, { width: 76, styler, glyphs });
|
|
95
|
+
ctx.ui.setWidget(WIDGET_KEY, lines);
|
|
96
|
+
ctx.ui.setStatus(STATUS_KEY, renderStatusLine(state, glyphs));
|
|
97
|
+
} catch {
|
|
98
|
+
/* the widget is never worth breaking a turn over */
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
// -- brief ----------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
const briefText = async (dir: string, includeGate = false): Promise<string> => {
|
|
105
|
+
const { config } = loadConfig(dir);
|
|
106
|
+
const brief = await buildBrief(dir, { includeGate });
|
|
107
|
+
return renderBrief(brief, config);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// -- configuration --------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The models this session can actually use.
|
|
114
|
+
*
|
|
115
|
+
* Prefers `scopedModels` when the user has scoped the session, because those
|
|
116
|
+
* are the models they deliberately chose; otherwise every model pi holds
|
|
117
|
+
* working credentials for. Models without auth are excluded — offering one
|
|
118
|
+
* would produce a tier that fails at the first task rather than at setup.
|
|
119
|
+
*/
|
|
120
|
+
const availableModels = (ctx: ExtensionContext): ModelChoice[] => {
|
|
121
|
+
try {
|
|
122
|
+
const scoped = ctx.scopedModels ?? [];
|
|
123
|
+
const models =
|
|
124
|
+
scoped.length > 0
|
|
125
|
+
? scoped.map((s) => s.model)
|
|
126
|
+
: (ctx.modelRegistry?.getAvailable?.() ?? []);
|
|
127
|
+
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
const out: ModelChoice[] = [];
|
|
130
|
+
for (const m of models) {
|
|
131
|
+
if (!m?.id || !m?.provider) continue;
|
|
132
|
+
const ref = `${m.provider}/${m.id}`;
|
|
133
|
+
if (seen.has(ref)) continue;
|
|
134
|
+
seen.add(ref);
|
|
135
|
+
const bits: string[] = [ref];
|
|
136
|
+
if (m.name && m.name !== m.id) bits.push(`· ${m.name}`);
|
|
137
|
+
if (m.contextWindow) bits.push(`· ${Math.round(m.contextWindow / 1000)}k ctx`);
|
|
138
|
+
if (m.reasoning) bits.push("· reasoning");
|
|
139
|
+
out.push({ ref, label: bits.join(" ") });
|
|
140
|
+
}
|
|
141
|
+
out.sort((a, b) => a.ref.localeCompare(b.ref));
|
|
142
|
+
return out;
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** Adapt pi's UI to the prompter the config flow expects. */
|
|
149
|
+
const prompterFor = (ctx: ExtensionContext): Prompter => ({
|
|
150
|
+
select: (title, opts) => ctx.ui.select(title, opts),
|
|
151
|
+
input: (title, placeholder) => ctx.ui.input(title, placeholder),
|
|
152
|
+
notify: (message, level) => notify(ctx, message, level ?? "info"),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// -- lifecycle ------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
158
|
+
const dir = projectDir(ctx);
|
|
159
|
+
if (!isHarnessProject(dir)) return;
|
|
160
|
+
|
|
161
|
+
refreshWidget(ctx);
|
|
162
|
+
const { config } = loadConfig(dir);
|
|
163
|
+
lastBriefPhase = config.currentPhase;
|
|
164
|
+
|
|
165
|
+
notify(ctx, `infinity-harness active · ${config.currentPhase ?? "not started"}`, "info");
|
|
166
|
+
try {
|
|
167
|
+
pi.appendEntry("infinity:session", { runId, dir, phase: config.currentPhase });
|
|
168
|
+
} catch {
|
|
169
|
+
/* entry log is best-effort */
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// The brief is delivered as a message rather than a notification so the
|
|
173
|
+
// model actually reads it. Without this the agent starts from whatever
|
|
174
|
+
// the user typed and ignores the pipeline entirely.
|
|
175
|
+
try {
|
|
176
|
+
const text = await briefText(dir);
|
|
177
|
+
pi.sendMessage(
|
|
178
|
+
{ customType: "infinity:brief", content: text, display: true, details: { phase: config.currentPhase } },
|
|
179
|
+
{ triggerTurn: false, deliverAs: "nextTurn" },
|
|
180
|
+
);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
notify(ctx, `infinity-harness: could not build brief — ${errMsg(e)}`, "warning");
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
pi.on("session_tree", async (_event, ctx) => {
|
|
187
|
+
refreshWidget(ctx);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Periodic nudge. Long runs drift: the model finishes work and forgets to
|
|
192
|
+
* record it, so the plan on disk and reality diverge. A short reminder every
|
|
193
|
+
* few calls costs little and keeps the plan honest.
|
|
194
|
+
*/
|
|
195
|
+
pi.on("context", async (event, ctx) => {
|
|
196
|
+
const dir = projectDir(ctx);
|
|
197
|
+
if (!isHarnessProject(dir)) return;
|
|
198
|
+
|
|
199
|
+
const messages = event.messages ?? [];
|
|
200
|
+
// Drop any reminder we injected on a previous call; they are transient
|
|
201
|
+
// scaffolding, not conversation, and accumulate into real token cost.
|
|
202
|
+
const filtered = messages.filter((m) => !isOurReminder(m));
|
|
203
|
+
const pruned = filtered.length !== messages.length ? { messages: filtered } : undefined;
|
|
204
|
+
|
|
205
|
+
let list: FeatureList;
|
|
206
|
+
try {
|
|
207
|
+
list = loadFeatureList(dir).list;
|
|
208
|
+
} catch {
|
|
209
|
+
return pruned;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const progress = computeProgress(list);
|
|
213
|
+
if (progress.tasksTotal === 0 || progress.tasksDone === progress.tasksTotal) {
|
|
214
|
+
llmCalls = 0;
|
|
215
|
+
return pruned;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
llmCalls += 1;
|
|
219
|
+
if (llmCalls < REMINDER_INTERVAL) return pruned;
|
|
220
|
+
llmCalls = 0;
|
|
221
|
+
|
|
222
|
+
const open = (list.features ?? [])
|
|
223
|
+
.flatMap((f) => f.tasks ?? [])
|
|
224
|
+
.filter((t) => t.status !== "complete")
|
|
225
|
+
.slice(0, 12)
|
|
226
|
+
.map((t) => `${t.key ?? t.id}=${t.status}`)
|
|
227
|
+
.join(", ");
|
|
228
|
+
|
|
229
|
+
const reminder = {
|
|
230
|
+
role: "user",
|
|
231
|
+
content: [
|
|
232
|
+
{
|
|
233
|
+
type: "text",
|
|
234
|
+
text:
|
|
235
|
+
`[infinity-harness] Plan revision ${list.baseRevision}. Open: ${open}. ` +
|
|
236
|
+
`If the real state differs from this, call infinity_plan with baseRevision ${list.baseRevision} ` +
|
|
237
|
+
`and the complete task list (omitted keys are deleted).`,
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
timestamp: Date.now(),
|
|
241
|
+
} as (typeof messages)[number];
|
|
242
|
+
|
|
243
|
+
return { messages: [...filtered, reminder] };
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Compaction drops the transcript. The plan lives on disk so it survives,
|
|
248
|
+
* but the model's *awareness* of it does not — so we re-state it afterwards.
|
|
249
|
+
*/
|
|
250
|
+
pi.on("session_before_compact", async (_event, ctx) => {
|
|
251
|
+
const dir = projectDir(ctx);
|
|
252
|
+
if (!isHarnessProject(dir)) return;
|
|
253
|
+
try {
|
|
254
|
+
const { list } = loadFeatureList(dir);
|
|
255
|
+
pi.appendEntry(CHECKPOINT, { revision: list.baseRevision, at: new Date().toISOString() });
|
|
256
|
+
} catch {
|
|
257
|
+
/* checkpoint is advisory */
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
pi.on("session_compact", async (_event, ctx) => {
|
|
262
|
+
const dir = projectDir(ctx);
|
|
263
|
+
if (!isHarnessProject(dir)) return;
|
|
264
|
+
try {
|
|
265
|
+
const text = await briefText(dir);
|
|
266
|
+
pi.sendMessage(
|
|
267
|
+
{ customType: "infinity:brief", content: text, display: false, details: { after: "compaction" } },
|
|
268
|
+
{ triggerTurn: false, deliverAs: "nextTurn" },
|
|
269
|
+
);
|
|
270
|
+
refreshWidget(ctx);
|
|
271
|
+
} catch {
|
|
272
|
+
/* the next brief will catch it up */
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
277
|
+
refreshWidget(ctx);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The loop. `agent_settled` fires when the agent has stopped working, which
|
|
282
|
+
* is the only safe moment to run the gate and decide what happens next.
|
|
283
|
+
*/
|
|
284
|
+
pi.on("agent_settled", async (_event, ctx) => {
|
|
285
|
+
const dir = projectDir(ctx);
|
|
286
|
+
if (!isHarnessProject(dir)) return;
|
|
287
|
+
if (!loopEnabled || loopBusy) return;
|
|
288
|
+
|
|
289
|
+
loopBusy = true;
|
|
290
|
+
try {
|
|
291
|
+
const { decision } = await decideNext({ targetDir: dir, runId });
|
|
292
|
+
refreshWidget(ctx);
|
|
293
|
+
|
|
294
|
+
switch (decision.action) {
|
|
295
|
+
case "advanced":
|
|
296
|
+
notify(ctx, `infinity-harness: gate passed → ${decision.toPhase}`, "info");
|
|
297
|
+
lastBriefPhase = decision.toPhase;
|
|
298
|
+
pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
|
|
299
|
+
break;
|
|
300
|
+
case "continue":
|
|
301
|
+
notify(ctx, `infinity-harness: gate failed — re-briefing`, "warning");
|
|
302
|
+
pi.sendUserMessage(decision.message, { deliverAs: "followUp" });
|
|
303
|
+
break;
|
|
304
|
+
case "wait":
|
|
305
|
+
loopEnabled = false;
|
|
306
|
+
notify(ctx, `infinity-harness: ${decision.detail}`, "warning");
|
|
307
|
+
break;
|
|
308
|
+
case "stop":
|
|
309
|
+
loopEnabled = false;
|
|
310
|
+
notify(
|
|
311
|
+
ctx,
|
|
312
|
+
`infinity-harness: run finished — ${decision.detail}`,
|
|
313
|
+
decision.reason === "complete" ? "info" : "warning",
|
|
314
|
+
);
|
|
315
|
+
try {
|
|
316
|
+
pi.appendEntry("infinity:run-end", { reason: decision.reason, detail: decision.detail });
|
|
317
|
+
} catch {
|
|
318
|
+
/* best-effort */
|
|
319
|
+
}
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
} catch (e) {
|
|
323
|
+
loopEnabled = false;
|
|
324
|
+
notify(ctx, `infinity-harness: loop error, stopping — ${errMsg(e)}`, "error");
|
|
325
|
+
} finally {
|
|
326
|
+
loopBusy = false;
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The enforcement bit: refuse edits that would skip a phase.
|
|
332
|
+
*
|
|
333
|
+
* We only block writes that actually change `currentPhase`. Blocking every
|
|
334
|
+
* touch of the config would stop the harness configuring itself.
|
|
335
|
+
*/
|
|
336
|
+
pi.on("tool_call", async (event, ctx) => {
|
|
337
|
+
const dir = projectDir(ctx);
|
|
338
|
+
if (!isHarnessProject(dir)) return;
|
|
339
|
+
|
|
340
|
+
const e = event as { toolName?: string; name?: string; input?: Record<string, unknown> };
|
|
341
|
+
const tool = String(e.toolName ?? e.name ?? "");
|
|
342
|
+
const input = e.input ?? {};
|
|
343
|
+
|
|
344
|
+
const path = String(input.path ?? input.file ?? input.filePath ?? "");
|
|
345
|
+
const content = String(input.content ?? input.data ?? input.new_string ?? "");
|
|
346
|
+
const command = String(input.command ?? input.cmd ?? "");
|
|
347
|
+
|
|
348
|
+
const editsPhase =
|
|
349
|
+
path.replace(/\\/g, "/").includes("harness/config.json") &&
|
|
350
|
+
/write|edit|replace|patch/i.test(tool) &&
|
|
351
|
+
/"currentPhase"/.test(content);
|
|
352
|
+
|
|
353
|
+
const shellAdvance =
|
|
354
|
+
/harness/.test(command) && /\bphase\b/.test(command) && /\bnext\b|\badvance\b/.test(command);
|
|
355
|
+
|
|
356
|
+
if (!editsPhase && !shellAdvance) return;
|
|
357
|
+
|
|
358
|
+
const { config } = loadConfig(dir);
|
|
359
|
+
if (!config.currentPhase) return;
|
|
360
|
+
|
|
361
|
+
const gate = await runChecks(dir, config.currentPhase, { record: false });
|
|
362
|
+
if (gate.overall) return;
|
|
363
|
+
|
|
364
|
+
const failing = gate.checks
|
|
365
|
+
.filter((c) => !c.pass)
|
|
366
|
+
.map((c) => `${c.name} (${c.detail})`)
|
|
367
|
+
.join("; ");
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
block: true,
|
|
371
|
+
reason:
|
|
372
|
+
`infinity-harness: the ${config.currentPhase.toUpperCase()} gate has not passed, so the phase ` +
|
|
373
|
+
`cannot advance. Failing: ${failing}. Fix these, then let the harness advance the phase — ` +
|
|
374
|
+
`do not edit harness/config.json by hand.`,
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
pi.on("session_shutdown", async () => {
|
|
379
|
+
if (remoteServer) {
|
|
380
|
+
try {
|
|
381
|
+
await remoteServer.close();
|
|
382
|
+
} catch {
|
|
383
|
+
/* closing a dead server is fine */
|
|
384
|
+
}
|
|
385
|
+
remoteServer = null;
|
|
386
|
+
remoteDir = null;
|
|
387
|
+
}
|
|
388
|
+
loopEnabled = false;
|
|
389
|
+
loopBusy = false;
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
// -- tools ----------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
pi.registerTool({
|
|
395
|
+
name: "infinity_plan",
|
|
396
|
+
label: "Plan",
|
|
397
|
+
description:
|
|
398
|
+
"Read or rewrite the harness plan. Submit the COMPLETE task list — any key you omit is deleted. " +
|
|
399
|
+
"Pass baseRevision (from the brief or a previous call) so a concurrent write cannot be clobbered; " +
|
|
400
|
+
"a stale revision is rejected and you should re-read and resubmit. Omit `tasks` to read the plan.",
|
|
401
|
+
parameters: {
|
|
402
|
+
type: "object",
|
|
403
|
+
properties: {
|
|
404
|
+
baseRevision: {
|
|
405
|
+
type: "integer",
|
|
406
|
+
minimum: 0,
|
|
407
|
+
description: "Revision you read. Rejected if the plan has moved on.",
|
|
408
|
+
},
|
|
409
|
+
tasks: {
|
|
410
|
+
type: "array",
|
|
411
|
+
maxItems: 200,
|
|
412
|
+
description: "Complete authoritative task list. Omit to read without writing.",
|
|
413
|
+
items: {
|
|
414
|
+
type: "object",
|
|
415
|
+
required: ["key"],
|
|
416
|
+
properties: {
|
|
417
|
+
key: { type: "string", description: 'Stable key, e.g. "task-004" or "feature-002/task-004"' },
|
|
418
|
+
subject: { type: "string", description: "What the task is" },
|
|
419
|
+
status: {
|
|
420
|
+
type: "string",
|
|
421
|
+
enum: ["pending", "in_progress", "complete", "blocked", "rework"],
|
|
422
|
+
},
|
|
423
|
+
dependsOn: { type: "array", items: { type: "string" }, maxItems: 20 },
|
|
424
|
+
subtasks: {
|
|
425
|
+
type: "array",
|
|
426
|
+
items: {
|
|
427
|
+
type: "object",
|
|
428
|
+
required: ["title"],
|
|
429
|
+
properties: {
|
|
430
|
+
title: { type: "string" },
|
|
431
|
+
status: { type: "string", enum: ["pending", "in_progress", "complete"] },
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
difficulty: { type: "string", enum: ["easy", "moderate", "difficult"] },
|
|
436
|
+
modelHint: { type: "string" },
|
|
437
|
+
criteria: { type: "array", items: { type: "string" } },
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
} as never,
|
|
443
|
+
async execute(_id: string, params: { baseRevision?: number; tasks?: TaskInput[] }, _signal, _onUpdate, ctx) {
|
|
444
|
+
const dir = projectDir(ctx);
|
|
445
|
+
|
|
446
|
+
if (!Array.isArray(params?.tasks)) {
|
|
447
|
+
const { list } = loadFeatureList(dir);
|
|
448
|
+
const p = computeProgress(list);
|
|
449
|
+
const rows = (list.features ?? [])
|
|
450
|
+
.flatMap((f) => (f.tasks ?? []).map((t) => `[${t.status}] ${t.key ?? t.id}: ${t.description}`))
|
|
451
|
+
.join("\n");
|
|
452
|
+
return {
|
|
453
|
+
content: [
|
|
454
|
+
{
|
|
455
|
+
type: "text",
|
|
456
|
+
text: `Plan revision ${list.baseRevision} — ${p.tasksDone}/${p.tasksTotal} tasks\n${rows || "(empty)"}`,
|
|
457
|
+
},
|
|
458
|
+
],
|
|
459
|
+
details: { revision: list.baseRevision, progress: p },
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
// writeTaskList takes the plan lock itself, around the whole
|
|
465
|
+
// read-apply-write. Wrapping it again here would only add a second
|
|
466
|
+
// lock with weaker semantics.
|
|
467
|
+
const result = writeTaskList(dir, { baseRevision: params.baseRevision, tasks: params.tasks! });
|
|
468
|
+
refreshWidget(ctx as ExtensionContext);
|
|
469
|
+
return {
|
|
470
|
+
content: [{ type: "text", text: summarizeApply(result) }],
|
|
471
|
+
details: {
|
|
472
|
+
revision: result.revision,
|
|
473
|
+
change: result.change,
|
|
474
|
+
tasks: result.tasks.map((t) => ({
|
|
475
|
+
key: t.compositeKey,
|
|
476
|
+
status: t.status,
|
|
477
|
+
description: t.description,
|
|
478
|
+
})),
|
|
479
|
+
},
|
|
480
|
+
};
|
|
481
|
+
} catch (e) {
|
|
482
|
+
const isValidation = e instanceof ValidationError || (e as Error)?.name === "ValidationError";
|
|
483
|
+
const { list } = loadFeatureList(dir);
|
|
484
|
+
return {
|
|
485
|
+
content: [
|
|
486
|
+
{
|
|
487
|
+
type: "text",
|
|
488
|
+
text: `${isValidation ? "Rejected" : "Error"}: ${errMsg(e)}\nCurrent revision is ${list.baseRevision}.`,
|
|
489
|
+
},
|
|
490
|
+
],
|
|
491
|
+
details: { error: errMsg(e), revision: list.baseRevision },
|
|
492
|
+
isError: true,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
pi.registerTool({
|
|
499
|
+
name: "infinity_validate",
|
|
500
|
+
label: "Validate",
|
|
501
|
+
description:
|
|
502
|
+
"Run the deterministic gate for the current phase and report each check. This is the only way work is " +
|
|
503
|
+
"judged complete — do not assert completion yourself. Optionally scope to one feature+task.",
|
|
504
|
+
parameters: {
|
|
505
|
+
type: "object",
|
|
506
|
+
properties: {
|
|
507
|
+
feature: { type: "string", description: "Scope to this feature id" },
|
|
508
|
+
task: { type: "string", description: "Scope to this task id (requires feature)" },
|
|
509
|
+
},
|
|
510
|
+
} as never,
|
|
511
|
+
async execute(_id: string, params: { feature?: string; task?: string }, _signal, _onUpdate, ctx) {
|
|
512
|
+
const dir = projectDir(ctx);
|
|
513
|
+
const { config } = loadConfig(dir);
|
|
514
|
+
if (!config.currentPhase) {
|
|
515
|
+
return {
|
|
516
|
+
content: [{ type: "text", text: "No current phase — the harness is not initialised." }],
|
|
517
|
+
details: { error: "no-phase" },
|
|
518
|
+
isError: true,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
const gate = await runChecks(dir, config.currentPhase, {
|
|
522
|
+
feature: params?.feature,
|
|
523
|
+
task: params?.task,
|
|
524
|
+
record: true,
|
|
525
|
+
});
|
|
526
|
+
refreshWidget(ctx as ExtensionContext);
|
|
527
|
+
const lines = gate.checks
|
|
528
|
+
.map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`)
|
|
529
|
+
.join("\n");
|
|
530
|
+
return {
|
|
531
|
+
content: [
|
|
532
|
+
{
|
|
533
|
+
type: "text",
|
|
534
|
+
text: `Gate ${gate.overall ? "PASS" : "FAIL"} on ${gate.phase}\n${lines}`,
|
|
535
|
+
},
|
|
536
|
+
],
|
|
537
|
+
details: gate,
|
|
538
|
+
};
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
pi.registerTool({
|
|
543
|
+
name: "infinity_advance",
|
|
544
|
+
label: "Advance Phase",
|
|
545
|
+
description:
|
|
546
|
+
"Advance one phase. Refuses unless the current gate passes. Normally the harness does this for you " +
|
|
547
|
+
"after a passing validate; call it only when you need to advance explicitly.",
|
|
548
|
+
parameters: { type: "object", properties: {} } as never,
|
|
549
|
+
async execute(_id: string, _params: unknown, _signal, _onUpdate, ctx) {
|
|
550
|
+
const dir = projectDir(ctx);
|
|
551
|
+
const { config } = loadConfig(dir);
|
|
552
|
+
if (!config.currentPhase) {
|
|
553
|
+
return {
|
|
554
|
+
content: [{ type: "text", text: "No current phase." }],
|
|
555
|
+
details: { error: "no-phase" },
|
|
556
|
+
isError: true,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const gate = await runChecks(dir, config.currentPhase, { record: true });
|
|
560
|
+
if (!gate.overall) {
|
|
561
|
+
return {
|
|
562
|
+
content: [
|
|
563
|
+
{
|
|
564
|
+
type: "text",
|
|
565
|
+
text:
|
|
566
|
+
`Blocked: the ${config.currentPhase} gate failed — ${gate.failures.join(", ")}. ` +
|
|
567
|
+
`Fix these and validate again.`,
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
details: gate,
|
|
571
|
+
isError: true,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
const moved = await advancePhase(dir);
|
|
575
|
+
refreshWidget(ctx as ExtensionContext);
|
|
576
|
+
if (!moved.ok) {
|
|
577
|
+
return {
|
|
578
|
+
content: [{ type: "text", text: `Could not advance: ${moved.error}` }],
|
|
579
|
+
details: { error: moved.error },
|
|
580
|
+
isError: true,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
const text = await briefText(dir);
|
|
584
|
+
return {
|
|
585
|
+
content: [{ type: "text", text: `Advanced ${moved.from} → ${moved.to}\n\n${text}` }],
|
|
586
|
+
details: { from: moved.from, to: moved.to },
|
|
587
|
+
};
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
pi.registerTool({
|
|
592
|
+
name: "infinity_brief",
|
|
593
|
+
label: "Next Step",
|
|
594
|
+
description:
|
|
595
|
+
"Get the current brief: phase, role, feature, task, acceptance criteria and the gate verdict. " +
|
|
596
|
+
"Call this when you are unsure what to work on.",
|
|
597
|
+
parameters: {
|
|
598
|
+
type: "object",
|
|
599
|
+
properties: {
|
|
600
|
+
includeGate: { type: "boolean", description: "Run the gate to include a live verdict (slower)" },
|
|
601
|
+
},
|
|
602
|
+
} as never,
|
|
603
|
+
async execute(_id: string, params: { includeGate?: boolean }, _signal, _onUpdate, ctx) {
|
|
604
|
+
const dir = projectDir(ctx);
|
|
605
|
+
const text = await briefText(dir, Boolean(params?.includeGate));
|
|
606
|
+
const brief = await buildBrief(dir);
|
|
607
|
+
return { content: [{ type: "text", text }], details: brief };
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
pi.registerTool({
|
|
612
|
+
name: "infinity_dashboard",
|
|
613
|
+
label: "Dashboard",
|
|
614
|
+
description:
|
|
615
|
+
"Start, stop, or query the read-only web dashboard for this run. It binds to localhost and never " +
|
|
616
|
+
"mutates harness state — it is for the human watching the run.",
|
|
617
|
+
parameters: {
|
|
618
|
+
type: "object",
|
|
619
|
+
required: ["action"],
|
|
620
|
+
properties: {
|
|
621
|
+
action: { type: "string", enum: ["start", "stop", "status"] },
|
|
622
|
+
port: { type: "integer", minimum: 0, maximum: 65535, description: "0 picks a free port" },
|
|
623
|
+
host: { type: "string", description: "Bind address, default 127.0.0.1" },
|
|
624
|
+
},
|
|
625
|
+
} as never,
|
|
626
|
+
async execute(_id: string, params: { action: string; port?: number; host?: string }, _signal, _onUpdate, ctx) {
|
|
627
|
+
const dir = projectDir(ctx);
|
|
628
|
+
const remote = await import("../../src/remote.ts");
|
|
629
|
+
|
|
630
|
+
if (params.action === "stop") {
|
|
631
|
+
if (!remoteServer) {
|
|
632
|
+
return { content: [{ type: "text", text: "Dashboard is not running." }], details: { running: false } };
|
|
633
|
+
}
|
|
634
|
+
const was = remoteServer.url;
|
|
635
|
+
await remoteServer.close();
|
|
636
|
+
remoteServer = null;
|
|
637
|
+
remoteDir = null;
|
|
638
|
+
return { content: [{ type: "text", text: `Dashboard stopped (${was}).` }], details: { running: false } };
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (params.action === "status") {
|
|
642
|
+
const state = remote.buildRemoteState(dir);
|
|
643
|
+
return {
|
|
644
|
+
content: [
|
|
645
|
+
{
|
|
646
|
+
type: "text",
|
|
647
|
+
text: remoteServer
|
|
648
|
+
? `Dashboard live at ${remoteServer.url} · plan revision ${state.baseRevision}`
|
|
649
|
+
: `Dashboard not running · plan revision ${state.baseRevision}`,
|
|
650
|
+
},
|
|
651
|
+
],
|
|
652
|
+
details: { running: Boolean(remoteServer), url: remoteServer?.url ?? null, baseRevision: state.baseRevision },
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (remoteServer && remoteDir === dir) {
|
|
657
|
+
return {
|
|
658
|
+
content: [{ type: "text", text: `Dashboard already live at ${remoteServer.url}` }],
|
|
659
|
+
details: { running: true, url: remoteServer.url },
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
if (remoteServer) {
|
|
663
|
+
await remoteServer.close();
|
|
664
|
+
remoteServer = null;
|
|
665
|
+
}
|
|
666
|
+
const srv = await remote.createRemoteServer({
|
|
667
|
+
projectDir: dir,
|
|
668
|
+
host: params.host ?? "127.0.0.1",
|
|
669
|
+
port: typeof params.port === "number" ? params.port : 0,
|
|
670
|
+
});
|
|
671
|
+
remoteServer = srv;
|
|
672
|
+
remoteDir = dir;
|
|
673
|
+
notify(ctx, `infinity-harness dashboard: ${srv.url}`, "info");
|
|
674
|
+
return {
|
|
675
|
+
content: [{ type: "text", text: `Dashboard live at ${srv.url}` }],
|
|
676
|
+
details: { running: true, url: srv.url, port: srv.port },
|
|
677
|
+
};
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// -- commands -------------------------------------------------------------
|
|
682
|
+
|
|
683
|
+
pi.registerCommand("infinity:status", {
|
|
684
|
+
description: "Show the current phase, plan and gate state",
|
|
685
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
686
|
+
const dir = projectDir(ctx);
|
|
687
|
+
if (!isHarnessProject(dir)) {
|
|
688
|
+
notify(ctx, "No harness in this project (harness/config.json not found).", "warning");
|
|
689
|
+
return;
|
|
690
|
+
}
|
|
691
|
+
const state = widgetStateFor(dir);
|
|
692
|
+
if (state) {
|
|
693
|
+
notify(ctx, renderWidget(state, { width: 76, styler, glyphs, boxed: true }).join("\n"), "info");
|
|
694
|
+
}
|
|
695
|
+
refreshWidget(ctx);
|
|
696
|
+
},
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
pi.registerCommand("infinity:next", {
|
|
700
|
+
description: "Print the current brief",
|
|
701
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
702
|
+
const dir = projectDir(ctx);
|
|
703
|
+
notify(ctx, await briefText(dir), "info");
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
pi.registerCommand("infinity:validate", {
|
|
708
|
+
description: "Run the gate for the current phase",
|
|
709
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
710
|
+
const dir = projectDir(ctx);
|
|
711
|
+
const { config } = loadConfig(dir);
|
|
712
|
+
if (!config.currentPhase) {
|
|
713
|
+
notify(ctx, "No current phase.", "warning");
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const gate = await runChecks(dir, config.currentPhase, { record: true });
|
|
717
|
+
const lines = gate.checks.map((c) => `${c.advisory ? "·" : c.pass ? "+" : "x"} ${c.name}: ${c.detail}`);
|
|
718
|
+
notify(ctx, `Gate ${gate.overall ? "PASS" : "FAIL"}\n${lines.join("\n")}`, gate.overall ? "info" : "warning");
|
|
719
|
+
refreshWidget(ctx);
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
pi.registerCommand("infinity:run", {
|
|
724
|
+
description: "Start the continuous loop — validate, advance, re-brief, until done or stuck",
|
|
725
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
726
|
+
const dir = projectDir(ctx);
|
|
727
|
+
if (!isHarnessProject(dir)) {
|
|
728
|
+
notify(ctx, "No harness in this project.", "warning");
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
loopEnabled = true;
|
|
732
|
+
notify(
|
|
733
|
+
ctx,
|
|
734
|
+
`infinity-harness: continuous run armed. It stops on completion, on an exhausted retry budget, ` +
|
|
735
|
+
`when no progress is detected, or when you create ${stopFilePath(dir)}. Use /infinity:halt to stop now.`,
|
|
736
|
+
"info",
|
|
737
|
+
);
|
|
738
|
+
const text = await briefText(dir);
|
|
739
|
+
pi.sendUserMessage(text, { deliverAs: "followUp" });
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
pi.registerCommand("infinity:halt", {
|
|
744
|
+
description: "Stop the continuous loop after the current turn",
|
|
745
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
746
|
+
loopEnabled = false;
|
|
747
|
+
notify(ctx, "infinity-harness: continuous run stopped.", "info");
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
pi.registerCommand("infinity:pause", {
|
|
752
|
+
description: "Pause the pipeline (persisted in harness/config.json)",
|
|
753
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
754
|
+
const dir = projectDir(ctx);
|
|
755
|
+
const { value } = await withLock(configPath(dir), () => {
|
|
756
|
+
const { config, ok } = loadConfig(dir);
|
|
757
|
+
if (!ok) return false;
|
|
758
|
+
config.paused = true;
|
|
759
|
+
return saveConfig(dir, config).ok;
|
|
760
|
+
});
|
|
761
|
+
loopEnabled = false;
|
|
762
|
+
notify(ctx, value ? "infinity-harness: paused." : "Could not pause — config unreadable.", value ? "info" : "error");
|
|
763
|
+
refreshWidget(ctx);
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
pi.registerCommand("infinity:resume", {
|
|
768
|
+
description: "Unpause the pipeline",
|
|
769
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
770
|
+
const dir = projectDir(ctx);
|
|
771
|
+
const { value } = await withLock(configPath(dir), () => {
|
|
772
|
+
const { config, ok } = loadConfig(dir);
|
|
773
|
+
if (!ok) return false;
|
|
774
|
+
config.paused = false;
|
|
775
|
+
return saveConfig(dir, config).ok;
|
|
776
|
+
});
|
|
777
|
+
notify(ctx, value ? "infinity-harness: resumed." : "Could not resume — config unreadable.", value ? "info" : "error");
|
|
778
|
+
refreshWidget(ctx);
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
pi.registerCommand("infinity:config", {
|
|
783
|
+
description: "Configure the harness — models per difficulty tier, gates, commands, loop budgets",
|
|
784
|
+
handler: async (args: string, ctx: ExtensionContext) => {
|
|
785
|
+
const dir = projectDir(ctx);
|
|
786
|
+
if (!isHarnessProject(dir)) {
|
|
787
|
+
notify(ctx, "No harness in this project (harness/config.json not found).", "warning");
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// `\/infinity:config show` prints everything without prompting, which is
|
|
792
|
+
// what you want over SSH, in a log, or when the UI has no dialogs.
|
|
793
|
+
if (args.trim() === "show" || !ctx.hasUI) {
|
|
794
|
+
notify(ctx, renderSettings(dir), "info");
|
|
795
|
+
if (!ctx.hasUI && args.trim() !== "show") {
|
|
796
|
+
notify(ctx, "This mode has no dialogs — edit harness/config.json directly.", "warning");
|
|
797
|
+
}
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const changed = await runConfigMenu({
|
|
802
|
+
targetDir: dir,
|
|
803
|
+
prompt: prompterFor(ctx),
|
|
804
|
+
models: () => availableModels(ctx),
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
if (changed.length === 0) {
|
|
808
|
+
notify(ctx, "infinity-harness: no changes.", "info");
|
|
809
|
+
} else {
|
|
810
|
+
notify(ctx, `infinity-harness: updated ${changed.join(", ")}`, "info");
|
|
811
|
+
}
|
|
812
|
+
refreshWidget(ctx);
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
pi.registerCommand("infinity:models", {
|
|
817
|
+
description: "Show which models pi has available, and how the harness is routing them",
|
|
818
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
819
|
+
const dir = projectDir(ctx);
|
|
820
|
+
const models = availableModels(ctx);
|
|
821
|
+
const lines: string[] = [];
|
|
822
|
+
|
|
823
|
+
lines.push(`Models pi can use (${models.length})`);
|
|
824
|
+
if (models.length === 0) {
|
|
825
|
+
lines.push(" none — check provider auth, or run `pi models`");
|
|
826
|
+
} else {
|
|
827
|
+
for (const m of models) lines.push(` ${m.label}`);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (isHarnessProject(dir)) {
|
|
831
|
+
lines.push("", "Routing");
|
|
832
|
+
const io = readAll(dir);
|
|
833
|
+
const group = SETTINGS.find((g) => g.id === "models");
|
|
834
|
+
for (const s of group?.settings ?? []) {
|
|
835
|
+
lines.push(` ${s.label.padEnd(28)} ${formatValue(s, readSetting(io, s))}`);
|
|
836
|
+
}
|
|
837
|
+
lines.push("", "Change these with /infinity:config.");
|
|
838
|
+
}
|
|
839
|
+
notify(ctx, lines.join("\n"), "info");
|
|
840
|
+
},
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
pi.registerCommand("infinity:dashboard", {
|
|
844
|
+
description: "Open the read-only web dashboard for this run",
|
|
845
|
+
handler: async (_args: string, ctx: ExtensionContext) => {
|
|
846
|
+
const dir = projectDir(ctx);
|
|
847
|
+
const remote = await import("../../src/remote.ts");
|
|
848
|
+
if (remoteServer) {
|
|
849
|
+
notify(ctx, `Dashboard already live at ${remoteServer.url}`, "info");
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
const srv = await remote.createRemoteServer({ projectDir: dir, host: "127.0.0.1", port: 0 });
|
|
853
|
+
remoteServer = srv;
|
|
854
|
+
remoteDir = dir;
|
|
855
|
+
notify(ctx, `infinity-harness dashboard: ${srv.url}`, "info");
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function errMsg(e: unknown): string {
|
|
861
|
+
return e instanceof Error ? e.message : String(e);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** Our injected reminders, so they can be pruned before the next call. */
|
|
865
|
+
function isOurReminder(m: unknown): boolean {
|
|
866
|
+
const msg = m as { role?: string; content?: Array<{ type?: string; text?: string }> };
|
|
867
|
+
if (msg?.role !== "user" || !Array.isArray(msg.content)) return false;
|
|
868
|
+
return msg.content.some((c) => c?.type === "text" && c.text?.startsWith("[infinity-harness]"));
|
|
869
|
+
}
|
|
870
|
+
|