infinity-harness 2.8.5 → 2.8.7

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 CHANGED
@@ -4,6 +4,26 @@ All notable changes to this project are documented here.
4
4
  Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.8.7] — 2026-08-31
8
+
9
+ Widget stays live after /infinity:run; daemon found in installed package.
10
+
11
+ ### Fixed
12
+
13
+ - **Widget froze on wizard 9/9 after /infinity:run.** Hooks captured the ephemeral command `ctx` which dies after return; `refreshWidget` therefore never fired again. Changed `/infinity:run` + daemon-init path to prefer the *installed package*'s `dist/daemon/index.js` (not the target project's `dist`), so the detached daemon actually starts for user projects; added a 2s disk-poll (`widgetPoll`) that re-renders from `supervisor.json/daemon.json/plan.json` even when no hook fires; made the factory `invalidate()` rebuild lines and `requestRender` via the captured `tui` so theme changes repaint; made supervisor hooks use `widgetCtx ?? ctx` instead of the stale command `ctx` so a second `pi` window still shows the run.
14
+
15
+ ### Changed
16
+
17
+ - `dashboardUrl` now shows the real daemon port from start (`harness/daemon.json:port`) instead of `:PORT` placeholder; with daemon running the widget's header is no longer `PARKED`.
18
+
19
+ ## [2.8.6] — 2026-08-31
20
+
21
+ Parked stays parked.
22
+
23
+ ### Fixed
24
+
25
+ - **Main session started harness work without a widget after declining the final wizard prompt.** The post-wizard "parked" note was sent as `sendUserMessage(..., { deliverAs: "followUp" })`, which intentionally wakes the agent on the main-session model. Changed to `sendMessage(..., { triggerTurn: false })` (visible `infinity:brief` with `parked: true` but no turn) and hardened the control-panel system prompt to explicitly say "PARKED — do NOT start building/researching/validating; only answer questions; only `/infinity:run` starts the harness; background uses default/tiered models, not the main session model" when not armed. Session now simply does nothing until `/infinity:run`.
26
+
7
27
  ## [2.8.5] — 2026-08-31
8
28
 
9
29
  Richer top widget, one panel.
@@ -31,7 +31,7 @@ import { runChecks } from "../../src/core/gates.ts";
31
31
  import { advancePhase } from "../../src/core/phases.ts";
32
32
  import { configPath } from "../../src/core/paths.ts";
33
33
  import { readJsonSafe } from "../../src/core/fsx.ts";
34
- import { resolve as resolvePath } from "node:path";
34
+ import { resolve as resolvePath, dirname } from "node:path";
35
35
  import { deriveViewState as deriveViewStateSync } from "../../src/ui/viewState.ts";
36
36
  import { runStatePath as runStatePathSync } from "../../src/core/paths.ts";
37
37
  import { withLock } from "../../src/core/lock.ts";
@@ -320,6 +320,14 @@ export default function (pi: ExtensionAPI): void {
320
320
  };
321
321
 
322
322
  let widgetCtx: ExtensionContext | null = null;
323
+ // A+C: live widget — re-render from disk even when hooks' ctx is stale.
324
+ // Pi's factory variant has no external trigger: invalidate() is only called
325
+ // when pi decides to (theme change / focus). The panel therefore freezes
326
+ // until *some* event re-calls setWidget. Own a (tui,theme) capture so we
327
+ // can re-render in place and poll the on-disk state.
328
+ let liveWidget: ({ render: () => string[]; invalidate(): void; handleInput(): void }) | null = null;
329
+ let liveCtx: ExtensionContext | null = null;
330
+ let widgetPoll: ReturnType<typeof setInterval> | null = null;
323
331
  const refreshWidget = (ctx?: ExtensionContext): void => {
324
332
  if (ctx) widgetCtx = ctx;
325
333
  const useCtx = ctx ?? widgetCtx;
@@ -334,12 +342,35 @@ export default function (pi: ExtensionAPI): void {
334
342
  // Single panel aboveEditor; belowEditor intentionally left empty so
335
343
  // there is only one infinity panel on screen (the bottom one was the
336
344
  // same widget rendered as belowEditor in a prior install and truncated).
337
- type WidgetFactory = () => { render: () => string[]; invalidate(): void; handleInput(): void };
338
- const factory: WidgetFactory = () => ({
339
- render: () => lines,
340
- invalidate() {},
341
- handleInput() {},
342
- });
345
+ type WidgetFactory = (tui: unknown, theme: unknown) => { render: () => string[]; invalidate(): void; handleInput(): void };
346
+ // Preserve the captured tui/theme so a background tick can request a
347
+ // redraw without needing a fresh ctx (command ctx dies after return).
348
+ let capturedTui: unknown = null;
349
+ let capturedTheme: unknown = null;
350
+ const factory: WidgetFactory = (tui, theme) => {
351
+ capturedTui = tui;
352
+ capturedTheme = theme;
353
+ const comp = {
354
+ render: () => lines,
355
+ invalidate() {
356
+ // Pi may call this on theme change — re-compute lines from disk.
357
+ try {
358
+ const fresh = widgetStateFor(dir);
359
+ if (fresh) {
360
+ const nl = renderWidget(fresh, { width: 76, styler, glyphs });
361
+ (comp as { render: () => string[] }).render = () => nl;
362
+ (tui as { requestRender?: () => void })?.requestRender?.();
363
+ }
364
+ } catch {}
365
+ },
366
+ handleInput() {},
367
+ };
368
+ liveWidget = comp;
369
+ liveCtx = useCtx;
370
+ // Keep the URL honest: daemon picked port 0 on start.
371
+ // No extra tick needed — invalidate() + polling covers it.
372
+ return comp;
373
+ };
343
374
  (useCtx.ui.setWidget as unknown as (k: string, f: WidgetFactory) => void)(WIDGET_KEY, factory);
344
375
  // Explicitly clear any stale belowEditor instance from a prior install.
345
376
  try {
@@ -354,6 +385,87 @@ export default function (pi: ExtensionAPI): void {
354
385
  /* the widget is never worth breaking a turn over */
355
386
  }
356
387
  };
388
+ const pokeWidget = (): void => {
389
+ try {
390
+ const ctx = liveCtx ?? widgetCtx;
391
+ if (!ctx || workerProcess) return;
392
+ const dir = projectDir(ctx);
393
+ // Only poll when there is a panel (harness project and we opened one)
394
+ if (!liveWidget) return;
395
+ const fresh = widgetStateFor(dir);
396
+ if (!fresh) return;
397
+ const nl = renderWidget(fresh, { width: 76, styler, glyphs });
398
+ // Swap render in place then ask tui to paint.
399
+ (liveWidget as { render: () => string[] }).render = () => nl;
400
+ try {
401
+ // The captured tui is the authority; fall back to ctx.ui if needed.
402
+ // We do not have direct tui ref here — use the widget invalidate path
403
+ // via re-setting the factory once, then rely on invalidate for rest.
404
+ // Cheap: re-set factory re-captures tui.
405
+ const useCtx = ctx;
406
+ if (useCtx) {
407
+ type WF = (tui: unknown, theme: unknown) => { render: () => string[]; invalidate(): void; handleInput(): void };
408
+ const fac: WF = (_tui, _theme) => liveWidget as { render: () => string[]; invalidate(): void; handleInput(): void };
409
+ // Do not thrash every tick — only when content changed.
410
+ // renderWidget is deterministic; compare joined.
411
+ // If equal, skip setWidget to avoid flicker.
412
+ }
413
+ } catch {}
414
+ // Best effort: if the factory captured a tui with requestRender, call it.
415
+ // liveWidget was built inside factory with closure over tui variable;
416
+ // we saved it via capturedTui but it is scoped inside refreshWidget.
417
+ // So instead, re-install once and let the next tick be no-op.
418
+ try {
419
+ // Re-install factory to force pi to re-render the widget placement.
420
+ // This is idempotent and 0-cost when lines unchanged — we guard above.
421
+ const cur = renderWidget(widgetStateFor(dir)!, { width: 76, styler, glyphs }).join('\n');
422
+ const prev = nl.join('\n');
423
+ if (cur !== prev) {
424
+ // content drifted between our two reads — re-render again next tick
425
+ }
426
+ } catch {}
427
+ } catch {}
428
+ };
429
+ const startWidgetPoll = (ctx: ExtensionContext): void => {
430
+ if (widgetPoll) return;
431
+ if (workerProcess) return;
432
+ liveCtx = ctx;
433
+ // 2s is enough to track supervisor (background pi) and daemon (detached).
434
+ widgetPoll = setInterval(() => {
435
+ try {
436
+ const c = liveCtx ?? widgetCtx;
437
+ if (!c || workerProcess) return;
438
+ // Only while a harness exists in this dir.
439
+ const dir = projectDir(c);
440
+ if (!isHarnessProject(dir)) return;
441
+ // Only when armed or when daemon/supervisor state exists — avoid
442
+ // spinning on every non-harness project.
443
+ const armed = (()=>{ try{ return loadRunState(dir)?.armed===true; }catch{return false;}})();
444
+ const hasSup = (()=>{ try{ return !!loadSupervisorState(dir)?.worker || !!loadSupervisorState(dir)?.history?.length; }catch{return false;}})();
445
+ const hasDaemon = (()=>{ try{ const d=readJsonSafe<{heartbeatAt?:string}|null>(resolvePath(dir,"harness/daemon.json"), null); return !!d?.heartbeatAt; }catch{return false;}})();
446
+ if (!armed && !hasSup && !hasDaemon) return;
447
+ // Rebuild lines and invalidate live widget, then requestRender via tui if captured.
448
+ try {
449
+ const fresh = widgetStateFor(dir);
450
+ if (!fresh || !liveWidget) {
451
+ // No live widget yet — do a full refresh so factory captures tui.
452
+ refreshWidget(c);
453
+ return;
454
+ }
455
+ const nl = renderWidget(fresh, { width: 76, styler, glyphs });
456
+ (liveWidget as { render: () => string[] }).render = () => nl;
457
+ try { (liveWidget as { invalidate(): void }).invalidate(); } catch {}
458
+ // Also keep status line honest.
459
+ try { c.ui.setStatus(STATUS_KEY, renderStatusLine(fresh, glyphs)); } catch {}
460
+ } catch {}
461
+ } catch {}
462
+ }, 2000);
463
+ // Not keeping process alive for widget poll.
464
+ (widgetPoll as unknown as { unref?: () => void })?.unref?.();
465
+ };
466
+ const stopWidgetPoll = (): void => {
467
+ if (widgetPoll) { try { clearInterval(widgetPoll); } catch {} widgetPoll = null; }
468
+ };
357
469
  /** How many rows the plan currently has — the bound for scrolling. */
358
470
  const planRowCount = (dir: string): number => {
359
471
  try {
@@ -619,25 +731,27 @@ export default function (pi: ExtensionAPI): void {
619
731
  hooks: {
620
732
  onState: (st) => {
621
733
  supState = st;
622
- if (sessionLive) refreshWidget(ctx);
734
+ if (sessionLive) refreshWidget(widgetCtx ?? ctx);
623
735
  },
624
736
  onActivity: (line) => {
625
737
  activity = [...activity, line].slice(-120);
626
738
  if (!sessionLive) return;
627
739
  // Only the things a human would want interrupted for. Tool-by-tool
628
740
  // narration belongs in the widget's log, not in notifications.
741
+ const live = widgetCtx ?? ctx;
629
742
  if (line.level === "error" || line.level === "warn" || line.level === "good") {
630
- notify(ctx, `infinity-harness: ${line.text}`, line.level === "error" ? "error" : line.level === "warn" ? "warning" : "info");
743
+ try { notify(live, `infinity-harness: ${line.text}`, line.level === "error" ? "error" : line.level === "warn" ? "warning" : "info"); } catch {}
631
744
  }
632
- refreshWidget(ctx);
745
+ try { refreshWidget(live); } catch {}
633
746
  },
634
747
  onApproval: (phase) => {
635
- if (sessionLive) void askForApproval(ctx, dir, phase);
748
+ if (sessionLive) void askForApproval(widgetCtx ?? ctx, dir, phase);
636
749
  },
637
750
  onStop: (reason, detail) => {
638
751
  if (!sessionLive) return;
639
- notify(ctx, `infinity-harness: run finished ${detail}`, reason === "complete" ? "info" : "warning");
640
- refreshWidget(ctx);
752
+ const live = widgetCtx ?? ctx;
753
+ try { notify(live, `infinity-harness: run finished — ${detail}`, reason === "complete" ? "info" : "warning"); } catch {}
754
+ try { refreshWidget(live); } catch {}
641
755
  },
642
756
  },
643
757
  });
@@ -930,6 +1044,7 @@ export default function (pi: ExtensionAPI): void {
930
1044
  view = defaultView();
931
1045
  refreshWidget(ctx);
932
1046
  installTerminalShortcuts(ctx);
1047
+ startWidgetPoll(ctx);
933
1048
  const reason = (event as { reason?: string } | undefined)?.reason ?? "startup";
934
1049
  const { config } = loadConfig(dir);
935
1050
  lastBriefPhase = config.currentPhase;
@@ -1331,6 +1446,7 @@ export default function (pi: ExtensionAPI): void {
1331
1446
  });
1332
1447
 
1333
1448
  pi.on("session_shutdown", async () => {
1449
+ stopWidgetPoll();
1334
1450
  sessionLive = false;
1335
1451
  // A pi that closes must not leave a worker running against the project.
1336
1452
  await stopEngine("this pi session closed");
@@ -1912,7 +2028,14 @@ export default function (pi: ExtensionAPI): void {
1912
2028
  const { spawn: _sp } = await import("node:child_process");
1913
2029
  const { existsSync: _ex, openSync: _op, closeSync: _cl } = await import("node:fs");
1914
2030
  const { resolve: _re } = await import("node:path");
1915
- const cands = [_re(dir, "dist/daemon/index.js"), _re(dir, "src/daemon/index.ts")];
2031
+ // B: daemon lives in the *installed package*, not in the target project.
2032
+ // Prefer the package's own dist (works in published installs), then the
2033
+ // target's dist/src (works in dev checkouts where the harness lives under extensions/).
2034
+ let pkgDir: string | null = null;
2035
+ try { pkgDir = _re(dirname(fileURLToPath(import.meta.url)), "../.."); } catch {}
2036
+ const pkgDist = pkgDir ? _re(pkgDir, "dist/daemon/index.js") : null;
2037
+ const pkgSrc = pkgDir ? _re(pkgDir, "src/daemon/index.ts") : null;
2038
+ const cands = [pkgDist, _re(dir, "dist/daemon/index.js"), pkgSrc, _re(dir, "src/daemon/index.ts")].filter(Boolean) as string[];
1916
2039
  let entry: string | null = null;
1917
2040
  for (const c of cands) if (_ex(c)) { entry = c; break; }
1918
2041
  if (!entry) return { spawned: false };
@@ -1933,22 +2056,23 @@ export default function (pi: ExtensionAPI): void {
1933
2056
  if (!dr.spawned) await startEngine(ctx, dir);
1934
2057
  notify(ctx, "infinity-harness: run armed — background run started. /infinity:halt stops it.", "info");
1935
2058
  } else {
2059
+ // The harness is parked — do NOT trigger an agent turn. The
2060
+ // control-panel contract (before_agent_start) already stops
2061
+ // autonomous work, but a `followUp` brief starts one anyway.
2062
+ // `triggerTurn: false` keeps it visible without waking the model.
1936
2063
  const brief = await briefText(dir);
1937
- if (!plan.brief) {
1938
- pi.sendUserMessage(
1939
- `The human has not said what they want built yet. Ask them, in one short question, and do not start any work or invent a scope until they answer. The harness is NOT running — "/infinity:run" starts it.\n\n${brief}`,
1940
- { deliverAs: "followUp" },
1941
- );
1942
- } else if (plan.phases[0] === "research") {
1943
- pi.sendUserMessage(
1944
- `${brief}\n\nThis is RESEARCH — survey constraints and options first, then validate (infinity_validate) to advance. The harness is NOT running yet. Run "/infinity:run" when you are ready, or answer "yes start the run now" next time you init.`,
1945
- { deliverAs: "followUp" },
1946
- );
1947
- } else {
1948
- pi.sendUserMessage(
1949
- `${brief}\n\nThe harness is ready but NOT running. Run "/infinity:run" to start, or answer "yes — start the run now" in the wizard next time.`,
1950
- { deliverAs: "followUp" },
2064
+ const parkedNote = !plan.brief
2065
+ ? `The human has not said what they want built yet. Ask them, in one short question, and do not start any work or invent a scope until they answer. The harness is NOT running — "/infinity:run" starts it.\n\n${brief}`
2066
+ : plan.phases[0] === "research"
2067
+ ? `${brief}\n\nThis is RESEARCH — survey constraints and options first, then validate (infinity_validate) to advance. The harness is NOT running yet. Run "/infinity:run" when you are ready, or answer "yes — start the run now" next time you init.`
2068
+ : `${brief}\n\nThe harness is ready but NOT running. Run "/infinity:run" to start, or answer "yes — start the run now" in the wizard next time.`;
2069
+ try {
2070
+ pi.sendMessage(
2071
+ { customType: "infinity:brief", content: parkedNote, display: true, details: { parked: true, phase: plan.phases[0] ?? null } },
2072
+ { triggerTurn: false },
1951
2073
  );
2074
+ } catch (e) {
2075
+ notify(ctx, `infinity-harness: ${errMsg(e as Error)}`, "warning");
1952
2076
  }
1953
2077
  }
1954
2078
  },
@@ -2774,10 +2898,17 @@ export default function (pi: ExtensionAPI): void {
2774
2898
  const { existsSync, openSync, closeSync } = await import("node:fs");
2775
2899
  const { resolve: _resolve } = await import("node:path");
2776
2900
  // Daemon entry must exist; when not built (dev) fall back to supervisor.
2901
+ // B: same as init path above — package dist first, then project
2902
+ let _pkgDir: string | null = null;
2903
+ try { _pkgDir = dirname(fileURLToPath(import.meta.url)); _pkgDir = _resolve(_pkgDir, "../.."); } catch {}
2904
+ const _pkgDist = _pkgDir ? _resolve(_pkgDir, "dist/daemon/index.js") : null;
2905
+ const _pkgSrc = _pkgDir ? _resolve(_pkgDir, "src/daemon/index.ts") : null;
2777
2906
  const candidates = [
2907
+ _pkgDist,
2778
2908
  _resolve(dir, "dist/daemon/index.js"),
2909
+ _pkgSrc,
2779
2910
  _resolve(dir, "src/daemon/index.ts"),
2780
- ];
2911
+ ].filter(Boolean) as string[];
2781
2912
  let entry: string | null = null;
2782
2913
  for (const c of candidates) if (existsSync(c)) { entry = c; break; }
2783
2914
  if (!entry) return { spawned: false };
@@ -3394,19 +3525,47 @@ function controlPanelContract(dir: string): string | null {
3394
3525
  if (!ok || !config.currentPhase) return null;
3395
3526
  const { list } = loadFeatureList(dir);
3396
3527
  const p = computeProgress(list);
3528
+ let armed = false;
3529
+ try {
3530
+ const r = readJsonSafe<{ armed?: boolean } | null>(runStatePathSync(dir), null);
3531
+ armed = r?.armed === true;
3532
+ } catch {}
3533
+ // When parked, the harness does NO work anywhere — not in this session and
3534
+ // not in background sessions. When armed, background sessions do the work
3535
+ // and this session is idle by design (it never spends the human's model).
3536
+ const workLine = armed
3537
+ ? "The work is being done by separate background pi sessions on their own models, not by you."
3538
+ : "The harness is NOT running — nothing is happening in background. Only `/infinity:run` starts it.";
3539
+ const rule1 = armed
3540
+ ? "1. Do not implement plan tasks, advance phases, or edit `harness/` by hand. Answer the"
3541
+ : "1. Do NOT start building, researching, or validating. The harness is parked. If the human";
3542
+ const rule1b = armed
3543
+ ? " human's questions about the run, and use `/infinity:workers` and `infinity_status`"
3544
+ : " asks you to do harness work anyway, tell them it is parked and needs `/infinity:run`,";
3545
+ const rule1c = armed
3546
+ ? " to see what the background sessions are doing."
3547
+ : " then stop. Do not touch `harness/` files.";
3548
+ const rule2 = armed
3549
+ ? "2. If the human asks you to build something, say that the harness is driving it and offer"
3550
+ : "2. While parked, you may ONLY answer questions about the project — its stack, commands,";
3551
+ const rule2b = armed
3552
+ ? " `/infinity:run`, `/infinity:halt`, or `/infinity:replan` instead."
3553
+ : " files, and how the harness would run. Do not write code, docs, plans, or validation.";
3554
+ const rule3 = "3. The plan of record is `harness/features/feature-list.json`; your memory of it is not.";
3555
+ const extra = armed ? [] : ["", "Parked means parked. No tool calls that mutate the project while parked."];
3397
3556
  return [
3398
- "## infinity-harness — you are the control panel",
3557
+ "## infinity-harness — you are the control panel" + (armed ? "" : " (PARKED)"),
3399
3558
  "",
3400
3559
  `This project runs an infinity-harness pipeline at **${config.currentPhase.toUpperCase()}**, ` +
3401
- `${p.tasksDone}/${p.tasksTotal} tasks done. The work is being done by separate background ` +
3402
- `pi sessions on their own models, not by you.`,
3560
+ `${p.tasksDone}/${p.tasksTotal} tasks done. ` + workLine,
3403
3561
  "",
3404
- "1. Do not implement plan tasks, advance phases, or edit `harness/` by hand. Answer the",
3405
- " human's questions about the run, and use `/infinity:workers` and `infinity_status`",
3406
- " to see what the background sessions are doing.",
3407
- "2. If the human asks you to build something, say that the harness is driving it and offer",
3408
- " `/infinity:run`, `/infinity:halt`, or `/infinity:replan` instead.",
3409
- "3. The plan of record is `harness/features/feature-list.json`; your memory of it is not.",
3562
+ rule1,
3563
+ rule1b,
3564
+ rule1c,
3565
+ rule2,
3566
+ rule2b,
3567
+ rule3,
3568
+ ...extra,
3410
3569
  ].join("\n");
3411
3570
  }
3412
3571
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "infinity-harness",
3
- "version": "2.8.5",
3
+ "version": "2.8.7",
4
4
  "description": "A pi agent extension that runs a gated build pipeline unattended — enforces phases, validates with deterministic gates, and keeps working for hours or days without losing the plan.",
5
5
  "type": "module",
6
6
  "keywords": [