infinity-harness 2.6.6 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -68,8 +68,30 @@ export type DashboardState = {
68
68
  * reads, so a level turned off in one is off in the other.
69
69
  */
70
70
  display?: DisplayPolicy | null;
71
+ /** Where the work is happening. */
72
+ engine?: "background" | "main-session" | null;
73
+ /** The background pi sessions doing the work, and what each is on. */
74
+ workers?: DashWorker[] | null;
75
+ /** The tail of the background log. */
76
+ activity?: DashActivity[] | null;
71
77
  };
72
78
 
79
+ export type DashWorker = {
80
+ name: string;
81
+ unitLabel: string;
82
+ level: string;
83
+ model: string;
84
+ servedModel?: string | null;
85
+ difficulty?: string | null;
86
+ state: string;
87
+ doing?: string | null;
88
+ turns?: number;
89
+ tokens?: { inputTokens: number; outputTokens: number } | null;
90
+ contextRatio?: number | null;
91
+ };
92
+
93
+ export type DashActivity = { at: string; level: string; worker: string | null; text: string };
94
+
73
95
  // ── escaping ────────────────────────────────────────────────────────────────
74
96
 
75
97
  const HTML_ESCAPES: Record<string, string> = {
@@ -799,6 +821,50 @@ body{
799
821
  color:var(--faint);font-weight:600;margin-bottom:6px;
800
822
  }
801
823
 
824
+
825
+ /* -- background sessions --------------------------------------------------- */
826
+ /*
827
+ * The run's work happens in other pi processes now, so this panel is the only
828
+ * place it is visible. It is deliberately the loudest thing under the meter:
829
+ * a live dot, the model each session is on, and a reverse-chronological log.
830
+ */
831
+ .card.background h2{margin:0 0 4px;font-size:14px;letter-spacing:.01em}
832
+ .bg-note{margin:0 0 12px;color:var(--muted);font-size:13px}
833
+ .bg-workers{list-style:none;margin:0;padding:0;display:grid;gap:8px}
834
+ .bg-worker{
835
+ display:grid;grid-template-columns:auto auto 1fr;gap:4px 10px;align-items:baseline;
836
+ padding:10px 12px;border:1px solid var(--border);border-radius:10px;background:var(--surface-2);
837
+ }
838
+ .bg-dot{
839
+ width:8px;height:8px;border-radius:50%;background:var(--muted);
840
+ align-self:center;grid-row:1;
841
+ }
842
+ .bg-worker.is-working .bg-dot,.bg-worker.is-starting .bg-dot{
843
+ background:var(--c-accent);box-shadow:0 0 0 3px var(--ring);animation:bgpulse 1.6s ease-in-out infinite;
844
+ }
845
+ .bg-worker.is-failed .bg-dot{background:var(--t-blocked,#C0392B)}
846
+ @keyframes bgpulse{0%,100%{opacity:1}50%{opacity:.35}}
847
+ @media (prefers-reduced-motion:reduce){.bg-worker .bg-dot{animation:none}}
848
+ .bg-name{font-weight:650}
849
+ .bg-model{
850
+ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px;
851
+ color:var(--c-accent);overflow-wrap:anywhere;
852
+ }
853
+ .bg-meta{grid-column:2/-1;color:var(--muted);font-size:12.5px}
854
+ .bg-doing{grid-column:2/-1;font-size:13px;overflow-wrap:anywhere}
855
+ .bg-log{
856
+ list-style:none;margin:12px 0 0;padding:10px 0 0;border-top:1px solid var(--border);
857
+ max-height:260px;overflow:auto;display:grid;gap:2px;
858
+ }
859
+ .bg-line{display:grid;grid-template-columns:44px 34px 1fr;gap:8px;font-size:12.5px;align-items:baseline}
860
+ .bg-time{color:var(--faint);font-variant-numeric:tabular-nums}
861
+ .bg-who{color:var(--c-accent);font-weight:600}
862
+ .bg-text{color:var(--muted);overflow-wrap:anywhere}
863
+ .bg-line.lvl-work .bg-text{color:var(--text)}
864
+ .bg-line.lvl-good .bg-text{color:var(--t-success,#177245)}
865
+ .bg-line.lvl-warn .bg-text{color:var(--t-rework,#8E44AD)}
866
+ .bg-line.lvl-error .bg-text{color:var(--t-blocked,#C0392B)}
867
+
802
868
  /* -- masthead ------------------------------------------------------------- */
803
869
  .masthead{
804
870
  position:sticky;top:0;z-index:20;
@@ -1210,6 +1276,66 @@ function formatTimestamp(iso: string): string {
1210
1276
  return d.toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
1211
1277
  }
1212
1278
 
1279
+ /**
1280
+ * The background sessions, and the log of what they have done.
1281
+ *
1282
+ * When the work moved out of the human's terminal this became the only place
1283
+ * a run is visible at all — so it says, per session, which unit it owns, what
1284
+ * model is answering for it, and the last thing it did. The model is on the
1285
+ * card because "routed to the difficult tier" is a claim, and a claim you
1286
+ * cannot see is one nobody believes.
1287
+ */
1288
+ export function renderBackground(
1289
+ engine: DashboardState["engine"],
1290
+ workers: readonly DashWorker[],
1291
+ activity: readonly DashActivity[],
1292
+ ): string {
1293
+ if (!engine && workers.length === 0 && activity.length === 0) return "";
1294
+ const head =
1295
+ engine === "main-session"
1296
+ ? `<p class="bg-note">Work runs in the pi session you started the harness from.</p>`
1297
+ : `<p class="bg-note">Work runs in separate background pi sessions, each on the model its difficulty tier names.</p>`;
1298
+ const cards = workers.length
1299
+ ? workers
1300
+ .map((w) => {
1301
+ const tokens = w.tokens ? w.tokens.inputTokens + w.tokens.outputTokens : 0;
1302
+ const ctx = typeof w.contextRatio === "number" ? `${Math.round(w.contextRatio * 100)}% ctx` : "";
1303
+ const bits = [
1304
+ `${w.level} · ${w.unitLabel}`,
1305
+ w.difficulty ? `${w.difficulty} tier` : "",
1306
+ `${w.turns ?? 0} turn${(w.turns ?? 0) === 1 ? "" : "s"}`,
1307
+ tokens ? `${tokens.toLocaleString("en-US")} tokens` : "",
1308
+ ctx,
1309
+ ].filter(Boolean);
1310
+ return (
1311
+ `<li class="bg-worker is-${esc(w.state)}">` +
1312
+ `<span class="bg-dot" aria-hidden="true"></span>` +
1313
+ `<span class="bg-name">${esc(w.name)}</span>` +
1314
+ `<span class="bg-model">${esc(w.servedModel || w.model || "pi default")}</span>` +
1315
+ `<span class="bg-meta">${esc(bits.join(" · "))}</span>` +
1316
+ (w.doing ? `<span class="bg-doing">${esc(w.doing)}</span>` : "") +
1317
+ `</li>`
1318
+ );
1319
+ })
1320
+ .join("")
1321
+ : `<li class="bg-worker is-idle"><span class="bg-dot" aria-hidden="true"></span><span class="bg-name">idle</span><span class="bg-meta">no background session running</span></li>`;
1322
+ const log = activity.length
1323
+ ? `<ol class="bg-log">` +
1324
+ activity
1325
+ .slice(-40)
1326
+ .reverse()
1327
+ .map(
1328
+ (l) =>
1329
+ `<li class="bg-line lvl-${esc(l.level)}"><span class="bg-time">${esc(l.at.slice(11, 16))}</span>` +
1330
+ (l.worker ? `<span class="bg-who">${esc(l.worker)}</span>` : `<span class="bg-who"></span>`) +
1331
+ `<span class="bg-text">${esc(l.text)}</span></li>`,
1332
+ )
1333
+ .join("") +
1334
+ `</ol>`
1335
+ : "";
1336
+ return `<section class="card background"><h2>Background</h2>${head}<ul class="bg-workers">${cards}</ul>${log}</section>`;
1337
+ }
1338
+
1213
1339
  export function renderDashboard(state: DashboardState): string {
1214
1340
  const list = normalizeForRender(state.list ?? { version: "0", baseRevision: 0, features: [] });
1215
1341
  const features = list.features ?? [];
@@ -1349,6 +1475,7 @@ ${
1349
1475
  : ""
1350
1476
  }
1351
1477
  ${display.progress ? renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal) : ""}
1478
+ ${renderBackground(state.engine ?? null, state.workers ?? [], state.activity ?? [])}
1352
1479
  ${renderGate(gate)}
1353
1480
  ${goalTabsHtml}${phaseTabsHtml}
1354
1481
  ${body}
package/src/ui/widget.ts CHANGED
@@ -86,6 +86,27 @@ export type WidgetState = {
86
86
  * there too — configuring how you read a plan once, rather than twice.
87
87
  */
88
88
  display?: DisplayPolicy | null;
89
+ /**
90
+ * Where the work is actually happening.
91
+ *
92
+ * `background` means the run is being driven by separate pi sessions and
93
+ * this one is a control panel. It is the single most important thing the
94
+ * widget can say, because it is the difference between "my model is being
95
+ * spent on this" and "it is not".
96
+ */
97
+ engine?: "background" | "main-session" | null;
98
+ /**
99
+ * The background sessions doing the work right now.
100
+ *
101
+ * This is the answer to "what is happening?" when the answer is no longer
102
+ * visible in the transcript — which, once the work moved out of this
103
+ * session, is always.
104
+ */
105
+ workers?: WorkerLine[] | null;
106
+ /** The tail of the background log: what the workers have been doing. */
107
+ activity?: ActivityLine[] | null;
108
+ /** Rows of background log to draw. 0 hides the section. */
109
+ activityRows?: number;
89
110
  /**
90
111
  * What the human asked for, before a plan exists to hold a goal.
91
112
  *
@@ -96,6 +117,26 @@ export type WidgetState = {
96
117
  intake?: string | null;
97
118
  };
98
119
 
120
+ /** One background pi session, as the widget draws it. */
121
+ export type WorkerLine = {
122
+ name: string;
123
+ unit: string;
124
+ level: string;
125
+ model: string;
126
+ difficulty?: string | null;
127
+ state: "starting" | "working" | "idle" | "closed" | "failed";
128
+ doing?: string | null;
129
+ tokens?: number;
130
+ contextRatio?: number | null;
131
+ };
132
+
133
+ export type ActivityLine = {
134
+ at: string;
135
+ level: "info" | "work" | "warn" | "error" | "good";
136
+ worker: string | null;
137
+ text: string;
138
+ };
139
+
99
140
  export type WidgetView = {
100
141
  /** First visible row, or null to follow the active task. */
101
142
  scroll: number | null;
@@ -378,6 +419,78 @@ export function rowWindow(
378
419
  * Returns plain strings so the host can hand them straight to
379
420
  * `ctx.ui.setWidget`. Colour is embedded as ANSI when the styler is colouring.
380
421
  */
422
+
423
+ /** Rows of background log drawn by default. Enough to see the shape of a minute. */
424
+ export const ACTIVITY_ROWS = 5;
425
+
426
+ const ACTIVITY_ROLE: Record<ActivityLine["level"], Role> = {
427
+ info: "muted",
428
+ work: "text",
429
+ warn: "rework",
430
+ error: "blocked",
431
+ good: "success",
432
+ };
433
+
434
+ const WORKER_ROLE: Record<WorkerLine["state"], Role> = {
435
+ starting: "active",
436
+ working: "active",
437
+ idle: "muted",
438
+ closed: "muted",
439
+ failed: "blocked",
440
+ };
441
+
442
+ /** `HH:MM` in local time — a full ISO stamp is six columns of nothing. */
443
+ function clock(iso: string): string {
444
+ const d = new Date(iso);
445
+ if (Number.isNaN(d.getTime())) return "--:--";
446
+ return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
447
+ }
448
+
449
+ /**
450
+ * The background sessions, one line each.
451
+ *
452
+ * The model is on the line on purpose. The whole point of routing work out of
453
+ * the human's session is that a different model does it, and a claim you
454
+ * cannot see is a claim nobody believes — least of all after the version
455
+ * where it silently did not happen.
456
+ */
457
+ export function renderWorkers(workers: readonly WorkerLine[], inner: number, g: GlyphSet, s: Styler): string[] {
458
+ const out: string[] = [];
459
+ for (const w of workers) {
460
+ const role = WORKER_ROLE[w.state] ?? "muted";
461
+ const dot = s.fg(role, w.state === "working" || w.state === "starting" ? g.inProgress : g.pending);
462
+ const head =
463
+ dot +
464
+ " " +
465
+ s.bold(s.fg(role, w.name)) +
466
+ s.fg("rule", " · ") +
467
+ s.fg("text", w.unit) +
468
+ (w.difficulty ? s.fg("muted", " (" + w.difficulty + ")") : "");
469
+ const model = s.fg("accent", w.model || "pi default");
470
+ const ctx =
471
+ typeof w.contextRatio === "number" ? s.fg(w.contextRatio > 0.75 ? "rework" : "muted", " " + Math.round(w.contextRatio * 100) + "%") : "";
472
+ const right = model + ctx;
473
+ const gap = Math.max(1, inner - width(head) - width(right));
474
+ out.push(truncate(head + " ".repeat(gap) + right, inner));
475
+ if (w.doing) out.push(truncate(" " + s.fg("muted", g.arrow + " " + w.doing), inner));
476
+ }
477
+ return out;
478
+ }
479
+
480
+ /** The background log: the only place a human sees what the workers did. */
481
+ export function renderActivity(lines: readonly ActivityLine[], rows: number, inner: number, s: Styler): string[] {
482
+ const tail = lines.slice(-Math.max(0, rows));
483
+ return tail.map((l) =>
484
+ truncate(
485
+ s.fg("rule", clock(l.at)) +
486
+ " " +
487
+ (l.worker ? s.fg("accent", l.worker) + " " : "") +
488
+ s.fg(ACTIVITY_ROLE[l.level] ?? "muted", l.text),
489
+ inner,
490
+ ),
491
+ );
492
+ }
493
+
381
494
  export function renderWidget(state: WidgetState, options: WidgetOptions = {}): string[] {
382
495
  const total = options.width ?? DEFAULT_WIDTH;
383
496
  const s = options.styler ?? createStyler();
@@ -535,6 +648,27 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
535
648
  }
536
649
  }
537
650
 
651
+ // -- background sessions --------------------------------------------------
652
+ //
653
+ // Placed above the rail because, on a run driven by workers, this is the
654
+ // part of the widget that changes second by second. A human glancing at the
655
+ // terminal is checking that something is alive.
656
+ const workers = state.workers ?? [];
657
+ if (workers.length) {
658
+ push();
659
+ push(s.fg("rule", "background") + s.fg("rule", " " + g.rail.repeat(Math.max(1, inner - 12))));
660
+ for (const line of renderWorkers(workers, inner, g, s)) push(line);
661
+ } else if (state.engine === "background" && state.list.features.length > 0) {
662
+ push();
663
+ push(truncate(s.fg("muted", "background · no worker running — /infinity:run starts one"), inner));
664
+ }
665
+
666
+ const activityRows = state.activityRows ?? (state.activity?.length ? ACTIVITY_ROWS : 0);
667
+ if (activityRows > 0 && state.activity?.length) {
668
+ push();
669
+ for (const line of renderActivity(state.activity, view.expanded ? activityRows * 3 : activityRows, inner, s)) push(line);
670
+ }
671
+
538
672
  // -- phase rail -----------------------------------------------------------
539
673
  if (display.rail) {
540
674
  push();
package/src/ui/wizard.ts CHANGED
@@ -109,14 +109,49 @@ async function pickThinkingLevel(prompt: Prompter, title: string): Promise<Think
109
109
  return picked as ThinkingLevel;
110
110
  }
111
111
 
112
- async function pickModelsStep(prompt: Prompter, modelsFn?: WizardOptions["models"]): Promise<{ router: NonNullable<IntakeAnswers["router"]> } | undefined> {
112
+ /**
113
+ * What a difficulty tier actually buys, at the level the human just chose.
114
+ *
115
+ * Difficulty is scored per task, but the *model* changes only where the
116
+ * session changes — that is what makes it a model boundary at all. So at
117
+ * feature-level handoff a feature runs on its hardest task's model and the
118
+ * easy tasks inside it get the hard model too. Saying that here, in the
119
+ * question, is the difference between a setting that behaves surprisingly and
120
+ * one the human chose with their eyes open.
121
+ */
122
+ export function tierScopeNote(handoff: string): string {
123
+ switch (handoff) {
124
+ case "off":
125
+ case "goal":
126
+ return "one session for the whole run, so the run takes the hardest tier in the plan";
127
+ case "phase":
128
+ return "one session per phase, so every task in a phase takes that phase's hardest tier";
129
+ case "sprint":
130
+ return "one session per sprint, so every task in a sprint takes that sprint's hardest tier";
131
+ case "feature":
132
+ return "one session per feature, so every task in a feature takes that feature's hardest tier";
133
+ case "subtask":
134
+ return "one session per subtask, so each subtask can take its own tier";
135
+ default:
136
+ return "one session per task, so each task takes its own tier";
137
+ }
138
+ }
139
+
140
+ async function pickModelsStep(
141
+ prompt: Prompter,
142
+ modelsFn?: WizardOptions["models"],
143
+ handoff: string = "task",
144
+ ): Promise<{ router: NonNullable<IntakeAnswers["router"]> } | undefined> {
113
145
  const models = modelsFn ? (await modelsFn()) ?? [] : [];
114
146
  // First ask whether routing is even wanted — most runs don't need it, and
115
147
  // skipping the 8 follow-up questions keeps the wizard short. Old tests that
116
148
  // don't know about this step get routing off by default so they keep passing.
117
149
  const ROUTE_ON = "yes — pick models per tier";
118
150
  const ROUTE_OFF = "no — use pi's current model for everything";
119
- const enablePick = await prompt.select("Route work by difficulty to different models?", [ROUTE_ON, ROUTE_OFF]);
151
+ const enablePick = await prompt.select(
152
+ `Route work by difficulty to different models? (${tierScopeNote(handoff)})`,
153
+ [ROUTE_ON, ROUTE_OFF],
154
+ );
120
155
  if (enablePick === undefined) {
121
156
  // No answer scripted (e.g. an older test) → treat as "off" so the
122
157
  // wizard doesn't look cancelled to callers that only scripted four steps.
@@ -178,10 +213,11 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
178
213
  const handoffLabels = handoffOptions.map((o) => line(o.label, o.help));
179
214
  const handoffPick = await prompt.select(HANDOFF_QUESTION.title, handoffLabels);
180
215
  if (handoffPick === undefined) return { cancelled: true };
181
- const handoff = (handoffOptions[handoffLabels.indexOf(handoffPick)]?.value ?? "phase") as
182
- | "off"
183
- | "phase"
184
- | "task";
216
+ // Every level the question offers, not the three this cast used to allow:
217
+ // picking "every feature" and getting the task-level narrowing is how a
218
+ // setting silently becomes a different setting.
219
+ const handoff = (handoffOptions[handoffLabels.indexOf(handoffPick)]?.value ??
220
+ "phase") as import("../core/types.ts").HandoffGranularity;
185
221
 
186
222
  // -- 3b. research depth (only when research is in the pipeline) ----------
187
223
  let researchDepth: import("../intake.ts").ResearchDepth | undefined;
@@ -201,7 +237,7 @@ export async function runIntakeWizard(options: WizardOptions): Promise<WizardRes
201
237
  }
202
238
 
203
239
  // -- 4. models ----------------------------------------------------------
204
- const modelsAnswer = await pickModelsStep(prompt, options.models);
240
+ const modelsAnswer = await pickModelsStep(prompt, options.models, handoff);
205
241
  if (modelsAnswer === undefined) return { cancelled: true };
206
242
 
207
243
  // -- 5. execution (parallelism) -----------------------------------------