infinity-harness 2.6.5 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -991,10 +1057,14 @@ table.tasks tr:last-child td{border-bottom:0}
991
1057
  /* While-developed: the whole active branch pulses — every active box, not just one feature. */
992
1058
  .dash-url{margin:8px 0 0;font-size:13px} .dash-url a{color:var(--t-accent);text-decoration:none;border-bottom:1px dashed rgba(var(--rgb-accent),.45)} .dash-url a:hover{border-bottom-style:solid}
993
1059
  .handoff-note{margin:4px 0 0;color:var(--muted);font-size:12px}
994
- .tier.is-current,.feature.is-current{animation:cardPulse 1.2s ease-in-out infinite; border-color:var(--c-accent)!important; box-shadow:0 0 0 3px rgba(var(--rgb-accent),.45), 0 0 12px rgba(var(--rgb-accent),.25), var(--shadow)}
995
- .tier.is-current .tier-name,.feature.is-current .feature-name{color:var(--t-accent);font-weight:650}
996
- .row.is-active{animation:taskBlink 0.9s ease-in-out infinite; outline:2px solid var(--c-active); outline-offset:-2px; box-shadow:0 0 8px rgba(var(--rgb-active),.35)}
997
- @keyframes cardPulse{0%,100%{box-shadow:0 0 0 3px rgba(var(--rgb-accent),.45),0 0 12px rgba(var(--rgb-accent),.25),var(--shadow); border-color:var(--c-accent)}50%{box-shadow:0 0 0 7px rgba(var(--rgb-accent),.22),0 0 16px rgba(var(--rgb-accent),.32),var(--shadow); border-color:rgba(var(--rgb-accent),.70)}}
1060
+ /* Active branching: make it scream, not whisper. Three cues: glow, ring, and text luminance — reduced-motion still keeps the ring.
1061
+ Pulse is strong at the crest, not near-reduced-motion. */
1062
+ .tier.is-current,.feature.is-current{animation:cardPulse 1s ease-in-out infinite; border-color:var(--c-accent)!important; box-shadow:0 0 0 4px rgba(var(--rgb-accent),.60), 0 0 16px rgba(var(--rgb-accent),.45), var(--shadow)}
1063
+ .tier.is-current .tier-name,.feature.is-current .feature-name{color:var(--t-accent);font-weight:800; animation:textPulse 1s ease-in-out infinite}
1064
+ .row.is-active{animation:taskBlink 0.85s ease-in-out infinite; outline:2.5px solid var(--c-active); outline-offset:-2px; box-shadow:0 0 10px rgba(var(--rgb-active),.45)}
1065
+ .step.is-current .dot{box-shadow:0 0 0 5px rgba(var(--rgb-accent),.50), 0 0 14px rgba(var(--rgb-accent),.40); animation:phasePulse 1s ease-in-out infinite}
1066
+ @keyframes cardPulse{0%,100%{box-shadow:0 0 0 4px rgba(var(--rgb-accent),.60),0 0 16px rgba(var(--rgb-accent),.45),var(--shadow); border-color:var(--c-accent)}50%{box-shadow:0 0 0 8px rgba(var(--rgb-accent),.15),0 0 20px rgba(var(--rgb-accent),.55),var(--shadow); border-color:rgba(var(--rgb-accent),.40)}}
1067
+ @keyframes phasePulse{0%,100%{box-shadow:0 0 0 5px rgba(var(--rgb-accent),.50),0 0 14px rgba(var(--rgb-accent),.40)}50%{box-shadow:0 0 0 9px rgba(var(--rgb-accent),.10),0 0 18px rgba(var(--rgb-accent),.55)}}
998
1068
  @keyframes textPulse{0%,100%{opacity:1}50%{opacity:.78}}
999
1069
  .row-blocked{background:rgba(var(--rgb-blocked),.07)}
1000
1070
  .row-blocked .cell-n{box-shadow:inset 2px 0 0 var(--c-blocked)}
@@ -1120,6 +1190,43 @@ const SCRIPT = `
1120
1190
  return o;
1121
1191
  }
1122
1192
 
1193
+ // Tabs: remember selection in localStorage; Goal hidden when only one goal; filter by phase/goal.
1194
+ function tabBind() {
1195
+ try {
1196
+ var GK='ih-goal', PK='ih-phase';
1197
+ function read(k){ try{ return localStorage.getItem(k); }catch(e){ return null; } }
1198
+ function write(k,v){ try{ localStorage.setItem(k,v); }catch(e){} }
1199
+ var selGoal = read(GK);
1200
+ var selPhase = read(PK);
1201
+ var goalEls = document.querySelectorAll('[data-goal]');
1202
+ var phaseEls = document.querySelectorAll('[data-phase]');
1203
+ // Seed from is-current when no stored choice.
1204
+ if(!selGoal){ var cur=document.querySelector('[data-goal].is-current'); if(cur) selGoal=cur.getAttribute('data-goal'); }
1205
+ if(!selPhase){ var curP=document.querySelector('[data-phase].is-current'); if(curP) selPhase=curP.getAttribute('data-phase'); }
1206
+ function apply(){
1207
+ var gTabs=document.querySelectorAll('.dash-tabs [data-goal]');
1208
+ var pTabs=document.querySelectorAll('.dash-tabs [data-phase]');
1209
+ gTabs.forEach(function(el){ el.classList.toggle('is-current', el.getAttribute('data-goal')===selGoal); });
1210
+ pTabs.forEach(function(el){ el.classList.toggle('is-current', el.getAttribute('data-phase')===selPhase); });
1211
+ // If user picked a goal that doesn't exist anymore, fall back to first.
1212
+ var tiers=document.querySelectorAll('.tier.tier-goal');
1213
+ if(tiers.length===0) tiers=document.querySelectorAll('.tier:not(.tier-goal)');
1214
+ // Hide tiers that don't match selected goal (when goal tabs visible, else show all).
1215
+ if(goalEls.length>0 && selGoal){
1216
+ document.querySelectorAll('.tier.tier-goal').forEach(function(t){ var id=(t.querySelector('[data-goal]')||t).getAttribute('data-goal'); var match = !id || id===selGoal; t.classList.toggle('tier-hidden', !match); });
1217
+ }
1218
+ // Phase filter: hide features/tasks not in selected phase. Server could filter but client keeps auto-refresh.
1219
+ if(selPhase){
1220
+ document.querySelectorAll('.feature').forEach(function(f){ var ph=f.getAttribute('data-phase')||''; if(ph && ph!==selPhase) f.classList.add('tier-hidden'); else f.classList.remove('tier-hidden'); });
1221
+ }
1222
+ // If only one goal, goal strip already hidden server-side — no tier-hidden by goal.
1223
+ }
1224
+ goalEls.forEach(function(el){ el.addEventListener('click', function(){ selGoal=el.getAttribute('data-goal'); write(GK, selGoal); apply(); }); });
1225
+ phaseEls.forEach(function(el){ el.addEventListener('click', function(){ selPhase=el.getAttribute('data-phase'); write(PK, selPhase); apply(); }); });
1226
+ apply();
1227
+ } catch(e){}
1228
+ }
1229
+ tabBind();
1123
1230
  function refresh() {
1124
1231
  // A hidden tab is not being read; skip the work but keep the loop alive.
1125
1232
  if (document.hidden) { schedule(BASE); return; }
@@ -1169,6 +1276,66 @@ function formatTimestamp(iso: string): string {
1169
1276
  return d.toISOString().replace("T", " ").replace(/\.\d+Z$/, "Z");
1170
1277
  }
1171
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
+
1172
1339
  export function renderDashboard(state: DashboardState): string {
1173
1340
  const list = normalizeForRender(state.list ?? { version: "0", baseRevision: 0, features: [] });
1174
1341
  const features = list.features ?? [];
@@ -1217,7 +1384,33 @@ export function renderDashboard(state: DashboardState): string {
1217
1384
  // Which feature/sprint/goal is currently being worked (for blinking).
1218
1385
  const activeTask = tasks.find((t) => t.status === "in_progress" || t.status === "rework") ?? tasks.find((t) => t.status === "pending") ?? null;
1219
1386
  const activeFeatureId = activeTask?.featureId ?? null;
1220
- const body = features.length
1387
+ // tabs: Goal(s) top row, Phase(s) second row. Default = active task's goal/phase, else current phase.
1388
+ // On a project that has exactly one goal, the goal tab strip hides (requirement §3). The phase tab strip
1389
+ // always shows so RESEARCH progress is isolated from BUILD progress — same plan-list, filtered view.
1390
+ const activeGoalId = (() => {
1391
+ if (!activeTask) return goals[0]?.id ?? null;
1392
+ const f = features.find((ff) => ff.id === activeTask.featureId) ?? null;
1393
+ const s = f?.sprintId ? sprints.find((ss) => ss.id === f!.sprintId) ?? null : null;
1394
+ return (f?.goalId ?? s?.goalId ?? goals[0]?.id ?? null) as string | null;
1395
+ })();
1396
+ const currentPhase = state.phase ?? null;
1397
+ const showGoalTabs = display.levels.goal && goals.length > 1;
1398
+ const phaseTabs = getPhaseOrder(state.enabledPhases);
1399
+ const goalTabsHtml = showGoalTabs
1400
+ ? `<nav class="dash-tabs" aria-label="Goals">` +
1401
+ goals.map((g) => {
1402
+ const cur = g.id === activeGoalId ? ' is-current' : '';
1403
+ return `<button type="button" class="dash-tab${cur}" data-goal="${esc(g.id)}">${esc(g.title ?? g.id)}</button>`;
1404
+ }).join("") + `</nav>`
1405
+ : "";
1406
+ const phaseTabsHtml = `<nav class="dash-tabs" aria-label="Phases">` +
1407
+ phaseTabs.map((p) => {
1408
+ const cur = p === currentPhase ? ' is-current' : '';
1409
+ return `<button type="button" class="dash-tab${cur}" data-phase="${esc(p)}">${esc(p.toUpperCase())}</button>`;
1410
+ }).join("") + `</nav>`;
1411
+ const tabStyle = `<style>.dash-tabs{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 6px}.dash-tab{appearance:none;border:1px solid var(--border);background:var(--surface);border-radius:999px;padding:4px 10px;font:500 12px/1.2 inherit;color:var(--muted);cursor:pointer}.dash-tab.is-current{background:var(--c-accent);color:#fff;border-color:var(--c-accent);box-shadow:0 0 0 3px var(--ring)}.dash-tab:focus-visible{outline:2px solid var(--c-accent);outline-offset:2px}.tier-hidden{display:none!important}</style>`;
1412
+ // Tag every feature row with its effective phase so tabs can filter client-side without a round-trip.
1413
+ const origBody = features.length
1221
1414
  ? groups
1222
1415
  .map((group) =>
1223
1416
  renderGoalGroup(
@@ -1234,6 +1427,16 @@ export function renderDashboard(state: DashboardState): string {
1234
1427
  )
1235
1428
  .join("")
1236
1429
  : renderEmptyPlan(state.phase);
1430
+ const body = (() => {
1431
+ if (!origBody) return origBody;
1432
+ // Inject data-phase on each feature card using featurePhase list.
1433
+ let idx = 0;
1434
+ return origBody.replace(/<section class="card feature/g, () => {
1435
+ const f = features[idx++] ?? null;
1436
+ const ph = (f as { phase?: string } | null)?.phase ?? (flattenTasks(list).find((t) => t.featureId === f?.id)?.effectivePhase as string | undefined) ?? "build";
1437
+ return `<section data-phase="${esc(ph)}" class="card feature`;
1438
+ });
1439
+ })();
1237
1440
 
1238
1441
  const titleBits: string[] = [];
1239
1442
  if (paused) titleBits.push("PAUSED");
@@ -1253,7 +1456,7 @@ export function renderDashboard(state: DashboardState): string {
1253
1456
  <!-- Empty data URI: suppresses the /favicon.ico request the harness server would 404. -->
1254
1457
  <link rel="icon" href="data:,">
1255
1458
  <title>${esc(title)}</title>
1256
- <style>${STYLES}</style>
1459
+ <style>${STYLES}</style>${tabStyle}
1257
1460
  </head>
1258
1461
  <body>
1259
1462
  <div id="app">
@@ -1272,7 +1475,9 @@ ${
1272
1475
  : ""
1273
1476
  }
1274
1477
  ${display.progress ? renderProgress(counts, progress.tasksTotal, progress.featuresDone, progress.featuresTotal) : ""}
1478
+ ${renderBackground(state.engine ?? null, state.workers ?? [], state.activity ?? [])}
1275
1479
  ${renderGate(gate)}
1480
+ ${goalTabsHtml}${phaseTabsHtml}
1276
1481
  ${body}
1277
1482
  <footer class="foot">
1278
1483
  <span>state as of <span class="mono">${esc(formatTimestamp(state.timestamp))}</span></span>
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();
@@ -410,13 +523,16 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
410
523
  const headRight = phaseTag + revTag;
411
524
  const gapW = inner - width(headLeft) - width(headRight);
412
525
  push(headLeft + (gapW > 1 ? s.fg("rule", " " + g.rail.repeat(gapW - 2) + " ") : " ") + headRight);
413
- // Dashboard URL near top, clickable (OSC 8) with meaningful host:port, not just numbers.
414
- if (state.dashboardUrl) {
415
- const url = state.dashboardUrl;
416
- const label = s.fg("accent", url);
417
- // OSC 8 hyperlink: terminals that support it make it clickable; others show the URL.
418
- const link = `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007`;
419
- push(truncate(s.fg("muted", "Dashboard: ") + link, inner));
526
+ // Dashboard URL: always visible (user never has to type /infinity:dashboard to discover it).
527
+ {
528
+ const url = state.dashboardUrl as string | null | undefined;
529
+ if (url) {
530
+ const label = s.fg("accent", url);
531
+ const link = `\u001b]8;;${url}\u0007${label}\u001b]8;;\u0007`;
532
+ push(truncate(s.fg("muted", "Dashboard: ") + link, inner));
533
+ } else {
534
+ push(truncate(s.fg("muted", "Dashboard: ") + s.fg("muted", "/infinity:dashboard → http://127.0.0.1:PORT"), inner));
535
+ }
420
536
  }
421
537
  if (state.handoffModelNote) {
422
538
  push(truncate(s.fg("muted", state.handoffModelNote), inner));
@@ -532,6 +648,27 @@ export function renderWidget(state: WidgetState, options: WidgetOptions = {}): s
532
648
  }
533
649
  }
534
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
+
535
672
  // -- phase rail -----------------------------------------------------------
536
673
  if (display.rail) {
537
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) -----------------------------------------