infinity-harness 2.8.6 → 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 +12 -0
- package/extensions/infinity-harness/index.ts +145 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,18 @@ 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
|
+
|
|
7
19
|
## [2.8.6] — 2026-08-31
|
|
8
20
|
|
|
9
21
|
Parked stays parked.
|
|
@@ -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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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(
|
|
743
|
+
try { notify(live, `infinity-harness: ${line.text}`, line.level === "error" ? "error" : line.level === "warn" ? "warning" : "info"); } catch {}
|
|
631
744
|
}
|
|
632
|
-
refreshWidget(
|
|
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
|
-
|
|
640
|
-
|
|
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
|
-
|
|
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 };
|
|
@@ -2775,10 +2898,17 @@ export default function (pi: ExtensionAPI): void {
|
|
|
2775
2898
|
const { existsSync, openSync, closeSync } = await import("node:fs");
|
|
2776
2899
|
const { resolve: _resolve } = await import("node:path");
|
|
2777
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;
|
|
2778
2906
|
const candidates = [
|
|
2907
|
+
_pkgDist,
|
|
2779
2908
|
_resolve(dir, "dist/daemon/index.js"),
|
|
2909
|
+
_pkgSrc,
|
|
2780
2910
|
_resolve(dir, "src/daemon/index.ts"),
|
|
2781
|
-
];
|
|
2911
|
+
].filter(Boolean) as string[];
|
|
2782
2912
|
let entry: string | null = null;
|
|
2783
2913
|
for (const c of candidates) if (existsSync(c)) { entry = c; break; }
|
|
2784
2914
|
if (!entry) return { spawned: false };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "infinity-harness",
|
|
3
|
-
"version": "2.8.
|
|
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": [
|