@danypops/pi-packed 0.27.13 → 0.28.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.
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Idempotent start/stop wrapper over setInterval -- a second start() is a no-op rather than a
3
+ * competing timer, and stop() is safe to call even if never started. Same shape as pi-papyrus's
4
+ * and pi-pipes' own BoundedPoll.
5
+ */
6
+ export class BoundedPoll {
7
+ private timer: ReturnType<typeof setInterval> | undefined;
8
+
9
+ start(intervalMs: number, tick: () => void): void {
10
+ if (this.timer) return;
11
+ this.timer = setInterval(tick, intervalMs);
12
+ }
13
+
14
+ stop(): void {
15
+ if (!this.timer) return;
16
+ clearInterval(this.timer);
17
+ this.timer = undefined;
18
+ }
19
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Persistent above-editor widget for real doctor.run issues -- mirrors pi-papyrus's own
3
+ * TaskOverlay/NoteOverlay and pi-pipes' own JobsOverlay: factory-form ctx.ui.setWidget
4
+ * registration, requestRender on refresh, hides the widget entirely (setWidget(key, undefined))
5
+ * rather than an empty box once the report is clean.
6
+ *
7
+ * doctor.run lives exclusively on the daemon's Vehicle protocol surface (/vehicle/*), not the
8
+ * legacy PackedExtensionClient RPC surface packed.ts's Natives wraps (see ExtensionOperationName
9
+ * in protocol.ts -- "doctor.run" is not one of them) -- this overlay builds its own reconnecting
10
+ * VehicleClient the same way vehicle-tools.ts's registerPackedVehicle does.
11
+ *
12
+ * Polled at a conservative interval (see DOCTOR_WIDGET_POLL_INTERVAL_MS): unlike Tasks/Notes/Jobs,
13
+ * doctor.run is a real daemon-wide scan (extension smoke tests, npm-tree walks, systemd
14
+ * shell-outs), not a cheap row read.
15
+ */
16
+ import type { DoctorReport } from "@danypops/pi-packed/doctor-format";
17
+ import { createReconnectingVehicleClient } from "@danypops/vehicle-client/daemon-client";
18
+ import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
19
+ import type { VehicleClient } from "@danypops/vehicle-core";
20
+ import type { ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
21
+ import { AutoRotatingWindow } from "malevich-tui-components";
22
+ import { BoundedPoll } from "./bounded-poll.js";
23
+ import {
24
+ buildDoctorWidgetProjection,
25
+ type DoctorWidgetProjection,
26
+ PACKED_DOCTOR_WIDGET_VISIBLE_ROWS,
27
+ renderDoctorWidgetLines,
28
+ } from "./doctor-widget.js";
29
+ import { currentVehicleClientTarget } from "./vehicle-target.js";
30
+
31
+ const WIDGET_KEY = "pi-packed-doctor";
32
+
33
+ /** doctor.run is a real daemon-wide scan, not a cheap row read -- a much longer interval than
34
+ * Tasks/Notes/Jobs' own 15-20s cadence. */
35
+ export const DOCTOR_WIDGET_POLL_INTERVAL_MS = 90_000;
36
+
37
+ /** How often the widget's own auto-rotating overflow page advances. */
38
+ export const DOCTOR_WIDGET_ROTATION_INTERVAL_MS = 6_000;
39
+
40
+ const EMPTY_PROJECTION: DoctorWidgetProjection = { issues: [], total: 0 };
41
+
42
+ function defaultClient(): VehicleClient {
43
+ // connectRetry:true (vehicle-client's own bounded background retry budget) covers a daemon
44
+ // that crashed and is mid systemd-restart -- matches registerPackedVehicle's own construction.
45
+ return createReconnectingVehicleClient(
46
+ async () => {
47
+ const resolved = currentVehicleClientTarget();
48
+ if (!resolved) throw new Error("Packed daemon is not running");
49
+ return new RemoteVehicleClient({ baseUrl: resolved.baseUrl, token: resolved.token });
50
+ },
51
+ { connectRetry: true },
52
+ );
53
+ }
54
+
55
+ export class DoctorOverlay {
56
+ private uiCtx: ExtensionUIContext | undefined;
57
+ private registered = false;
58
+ // biome-ignore lint/suspicious/noExplicitAny: same TUI-handle shape every other overlay in this ecosystem keeps untyped (requestRender is all that's used).
59
+ private tui: any | undefined;
60
+ private projection: DoctorWidgetProjection = EMPTY_PROJECTION;
61
+ private readonly poll = new BoundedPoll();
62
+ /** Repaint-only ticker (no data refetch) so the widget's own auto-rotating page visibly
63
+ * advances even when nothing else has changed. */
64
+ private readonly rotationPoll = new BoundedPoll();
65
+ private readonly rotation = new AutoRotatingWindow({
66
+ totalRows: 0,
67
+ pageSize: PACKED_DOCTOR_WIDGET_VISIBLE_ROWS,
68
+ intervalMs: DOCTOR_WIDGET_ROTATION_INTERVAL_MS,
69
+ });
70
+ private readonly client: VehicleClient;
71
+
72
+ constructor(client: VehicleClient = defaultClient()) {
73
+ this.client = client;
74
+ }
75
+
76
+ setUI(ctx: ExtensionUIContext): void {
77
+ if (ctx !== this.uiCtx) {
78
+ this.uiCtx = ctx;
79
+ this.registered = false;
80
+ this.tui = undefined;
81
+ }
82
+ }
83
+
84
+ /** Never throws: called from a poll timer and from session_start, neither of which should turn
85
+ * a best-effort status widget into a crashed extension host over a daemon that isn't running
86
+ * yet or a rendering bug. */
87
+ async refresh(): Promise<void> {
88
+ try {
89
+ const report = await this.client.invoke<DoctorReport>("doctor.run", 1, {}, { permissions: ["packed:read"] });
90
+ this.projection = buildDoctorWidgetProjection(report);
91
+ } catch {
92
+ this.projection = EMPTY_PROJECTION;
93
+ }
94
+ try {
95
+ this.render();
96
+ } catch {
97
+ // A rendering bug must not crash the extension host over a best-effort status widget.
98
+ }
99
+ }
100
+
101
+ private render(): void {
102
+ if (!this.uiCtx) return;
103
+
104
+ if (this.projection.total === 0) {
105
+ if (this.registered) {
106
+ this.uiCtx.setWidget(WIDGET_KEY, undefined);
107
+ this.registered = false;
108
+ this.tui = undefined;
109
+ this.rotationPoll.stop();
110
+ }
111
+ return;
112
+ }
113
+
114
+ if (!this.registered) {
115
+ this.uiCtx.setWidget(
116
+ WIDGET_KEY,
117
+ // biome-ignore lint/suspicious/noExplicitAny: tui is only ever used for requestRender(), matching every other overlay in this ecosystem.
118
+ (tui: any, theme: Theme) => {
119
+ this.tui = tui;
120
+ return {
121
+ render: (width: number) => renderDoctorWidgetLines(theme, this.projection, width, this.rotation),
122
+ invalidate: () => {
123
+ // Theme changed -- force re-registration, matching every other overlay in this ecosystem.
124
+ this.registered = false;
125
+ this.tui = undefined;
126
+ },
127
+ };
128
+ },
129
+ { placement: "aboveEditor" },
130
+ );
131
+ this.registered = true;
132
+ this.rotationPoll.start(DOCTOR_WIDGET_ROTATION_INTERVAL_MS, () => this.tui?.requestRender?.());
133
+ } else {
134
+ this.tui?.requestRender?.();
135
+ }
136
+ }
137
+
138
+ startPolling(intervalMs: number = DOCTOR_WIDGET_POLL_INTERVAL_MS): void {
139
+ this.poll.start(intervalMs, () => {
140
+ void this.refresh();
141
+ });
142
+ }
143
+
144
+ stopPolling(): void {
145
+ this.poll.stop();
146
+ }
147
+
148
+ dispose(): void {
149
+ this.stopPolling();
150
+ this.rotationPoll.stop();
151
+ this.uiCtx?.setWidget(WIDGET_KEY, undefined);
152
+ this.registered = false;
153
+ this.tui = undefined;
154
+ this.uiCtx = undefined;
155
+ }
156
+ }
@@ -1,14 +1,30 @@
1
1
  /**
2
- * doctor.run's own curated presenter -- its DoctorReport output shape (five array fields, several
3
- * with nested arrays/objects per row: duplicateDependencies' own locations array, extensions'
4
- * own registrations object) defeats every heuristic in vehicle-render's generic shape-probing
5
- * chain, so without this it fell all the way through to a raw JSON.stringify dump -- a real, live
6
- * incident, not a hypothetical. Reuses formatDoctorReport, the exact same human-readable text the
7
- * CLI's own `packed doctor` (non-JSON) path already renders, so the tool and the CLI never drift
8
- * into two different descriptions of the same report.
2
+ * doctor.run's own curated renderResult -- its DoctorReport output shape (five array fields,
3
+ * several with nested arrays/objects per row: duplicateDependencies' own locations array,
4
+ * extensions' own registrations object) defeats every heuristic in vehicle-render's generic
5
+ * shape-probing chain, so without this it fell all the way through to a raw JSON.stringify dump
6
+ * -- a real, live incident, not a hypothetical.
7
+ *
8
+ * A FULL renderResult override via registerVehicleTools' `renderers` option, deliberately NOT
9
+ * `renderPresenters`: without any presentations/renderers override at all, EVERY real doctor.run
10
+ * invocation gets a generic vehicle.tool-details/v1 "json"-view presentation projected and
11
+ * PERSISTED at invocation time (createTool's own default presentationProjector), and that
12
+ * projected presentation always wins ahead of a renderPresenters entry -- renderPresenters only
13
+ * ever customizes the output-to-Component step for the non-projected branch, never overriding an
14
+ * already-successful projection (see vehicle-render/result-rendering.ts's own renderVehicleResult:
15
+ * `if (projected) return renderProjectedPresentation(...)` runs before presenters are ever
16
+ * consulted). A real, live incident: wiring this as renderPresenters first left the raw JSON view
17
+ * rendering completely unchanged after release. `renderers.renderResult` sets
18
+ * presentationProjector to undefined for this one operation (see tool-creation.ts's own
19
+ * presentationProjector selection), so this function is now responsible for the operation's every
20
+ * result shape -- it delegates to the shared generic renderer (renderVehicleResult) for anything
21
+ * that isn't a real, complete DoctorReport (a partial/error state, a historical session row, or a
22
+ * future field addition), rather than re-implementing that handling here.
9
23
  */
10
24
  import { type DoctorReport, formatDoctorReport } from "@danypops/pi-packed/doctor-format";
11
- import type { VehiclePresenter } from "@danypops/vehicle-client-pi/vehicle-render";
25
+ import { renderVehicleResult } from "@danypops/vehicle-client-pi/vehicle-render";
26
+ import type { VehicleOperationDescriptor } from "@danypops/vehicle-core";
27
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
12
28
  import { asciiTextMeasure, CollapsibleText } from "malevich-tui-components";
13
29
 
14
30
  const DOCTOR_REPORT_COLLAPSED_LINES = 12;
@@ -19,14 +35,22 @@ function isDoctorReport(value: unknown): value is DoctorReport {
19
35
  return typeof candidate.ok === "boolean" && Array.isArray(candidate.conflicts) && Array.isArray(candidate.extensions);
20
36
  }
21
37
 
22
- export const renderDoctorRunResult: VehiclePresenter = (output, options, theme) => {
23
- if (!isDoctorReport(output)) return undefined;
24
- const collapsible = new CollapsibleText({
25
- text: theme.fg("text", formatDoctorReport(output, false)),
26
- collapsedLines: DOCTOR_REPORT_COLLAPSED_LINES,
27
- headerStyle: (s) => theme.fg("dim", s),
28
- measure: asciiTextMeasure,
29
- });
30
- if (options.expanded) collapsible.expand();
31
- return collapsible;
32
- };
38
+ export function createDoctorRunRenderResult(descriptor: VehicleOperationDescriptor): NonNullable<ToolDefinition["renderResult"]> {
39
+ return (result, options, theme, context) => {
40
+ if (!context.isError && !options.isPartial) {
41
+ const details = result.details as { output?: unknown } | undefined;
42
+ const output = details && Object.hasOwn(details, "output") ? details.output : undefined;
43
+ if (isDoctorReport(output)) {
44
+ const collapsible = new CollapsibleText({
45
+ text: theme.fg("text", formatDoctorReport(output, false)),
46
+ collapsedLines: DOCTOR_REPORT_COLLAPSED_LINES,
47
+ headerStyle: (s) => theme.fg("dim", s),
48
+ measure: asciiTextMeasure,
49
+ });
50
+ if (options.expanded) collapsible.expand();
51
+ return collapsible;
52
+ }
53
+ }
54
+ return renderVehicleResult(descriptor, result, options, theme, context);
55
+ };
56
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Pure projection/render pair for the Doctor status widget -- mirrors pi-papyrus's own
3
+ * task-widget.ts / pi-pipes' own jobs-widget.ts split: a DoctorReport in, a bounded intermediate
4
+ * shape out, no I/O, no TUI, fully unit-testable without a real daemon or terminal. See
5
+ * doctor-overlay.ts for the stateful ctx.ui.setWidget-registered class that drives these from a
6
+ * live doctor.run poll.
7
+ */
8
+ import type { DoctorReport } from "@danypops/pi-packed/doctor-format";
9
+ import { vehicleWidgetTitle } from "@danypops/vehicle-client-pi/widget-header";
10
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
11
+ import { type AutoRotatingWindow, renderCardRow, type TextMeasure } from "malevich-tui-components";
12
+
13
+ const measure: TextMeasure = { visibleWidth, truncateToWidth, wrapTextWithAnsi };
14
+
15
+ /** The daemon's own manifest name (see service/src/daemon/service.ts's `new VehicleRegistry({ name: "packed" })`) -- vehicleWidgetTitle capitalizes it for display. */
16
+ const VEHICLE_NAME = "packed";
17
+
18
+ /** Visible issue rows per page before the auto-rotating overflow hint pages to the next. */
19
+ export const PACKED_DOCTOR_WIDGET_VISIBLE_ROWS = 5;
20
+
21
+ export interface DoctorWidgetIssue {
22
+ label: string;
23
+ }
24
+
25
+ export interface DoctorWidgetProjection {
26
+ issues: DoctorWidgetIssue[];
27
+ total: number;
28
+ }
29
+
30
+ /**
31
+ * Mirrors doctor.run's own `ok` computation (conflicts + non-ok extensions + serviceUnits), plus
32
+ * stale module-cache entries (directly actionable: "restart the daemon"). Deliberately excludes
33
+ * duplicateDependencies -- doctor.run's own `ok` boolean excludes them too (see doctor-format.ts's
34
+ * own doc comment on why), and they're common enough in this ecosystem's own staggered version
35
+ * rollout to be low-signal for a persistent glance-at-a-widget summary.
36
+ */
37
+ export function buildDoctorWidgetProjection(report: DoctorReport): DoctorWidgetProjection {
38
+ const issues: DoctorWidgetIssue[] = [];
39
+ for (const conflict of report.conflicts) {
40
+ issues.push({ label: `${conflict.kind} "${conflict.name}" claimed by ${conflict.claimants.length} extensions` });
41
+ }
42
+ for (const extension of report.extensions) {
43
+ if (extension.status === "ok") continue;
44
+ issues.push({ label: `${extension.status} ${extension.name}${extension.message ? `: ${extension.message}` : ""}` });
45
+ }
46
+ for (const unit of report.serviceUnits) {
47
+ issues.push({ label: `${unit.package} (${unit.unitName}): ${unit.message}` });
48
+ }
49
+ for (const module of report.moduleFreshness ?? []) {
50
+ if (!module.stale) continue;
51
+ issues.push({ label: `${module.name} loaded ${module.loadedVersion ?? "?"}, disk has ${module.currentVersion ?? "?"}` });
52
+ }
53
+ return { issues, total: issues.length };
54
+ }
55
+
56
+ /** "Packed · Doctor · <N> issue(s)", plus a "page/total ⟳" suffix once genuinely paging. */
57
+ function doctorCardLabel(projection: DoctorWidgetProjection, rotation?: AutoRotatingWindow): string {
58
+ const base = vehicleWidgetTitle(VEHICLE_NAME, "Doctor", `${projection.total} issue${projection.total === 1 ? "" : "s"}`);
59
+ return rotation?.isPaging ? `${base} · ${rotation.pageIndex + 1}/${rotation.pageCount} ⟳` : base;
60
+ }
61
+
62
+ /** Renders the widget as a single bordered card -- `[]` (hide the whole widget) when there are no
63
+ * real issues, matching every other overlay's own "hide when nothing to show" convention. */
64
+ export function renderDoctorWidgetLines(
65
+ theme: { fg(color: string, text: string): string },
66
+ projection: DoctorWidgetProjection,
67
+ width: number,
68
+ rotation?: AutoRotatingWindow,
69
+ ): string[] {
70
+ if (projection.total === 0) return [];
71
+ rotation?.setTotalRows(projection.issues.length);
72
+ const { start, end } = rotation?.currentPageBounds() ?? { start: 0, end: projection.issues.length };
73
+ const visibleIssues = projection.issues.slice(start, end);
74
+
75
+ return renderCardRow(
76
+ [
77
+ {
78
+ label: doctorCardLabel(projection, rotation),
79
+ render: (innerWidth: number) =>
80
+ visibleIssues.map((issue) => truncateToWidth(`${theme.fg("warning", "!")} ${issue.label}`, innerWidth, "…")),
81
+ },
82
+ ],
83
+ width,
84
+ { measure, frameStyle: (s) => theme.fg("borderMuted", s) },
85
+ );
86
+ }
@@ -9,6 +9,7 @@
9
9
  * Install: pi install git:github.com/DanyPops/pi-packed
10
10
  */
11
11
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
+ import { DoctorOverlay } from "./doctor-overlay.js";
12
13
  import { formatUpdateNotice } from "./model.js";
13
14
  import { createNatives } from "./packed.js";
14
15
  import { registerProfiles } from "./profile.js";
@@ -42,6 +43,7 @@ export default async function (pi: ExtensionAPI) {
42
43
  // the identical pi-papyrus/pi-tickets bug). session_start fires only after that initialization
43
44
  // completes, and Pi awaits every session_start handler before the model's first turn, so
44
45
  // registering here is both safe and still visible on turn one.
46
+ let doctorOverlay: DoctorOverlay | undefined;
45
47
  pi.on("session_start", async (_event, ctx) => {
46
48
  await registerPackedVehicle(pi);
47
49
  if (!ctx.hasUI) return;
@@ -53,5 +55,12 @@ export default async function (pi: ExtensionAPI) {
53
55
  } catch {
54
56
  // mirror missing or unreadable — stay silent, never block startup.
55
57
  }
58
+ doctorOverlay ??= new DoctorOverlay();
59
+ doctorOverlay.setUI(ctx.ui);
60
+ await doctorOverlay.refresh();
61
+ doctorOverlay.startPolling();
62
+ });
63
+ pi.on("session_shutdown", async () => {
64
+ doctorOverlay?.dispose();
56
65
  });
57
66
  }
@@ -35,7 +35,7 @@ import { RemoteVehicleClient } from "@danypops/vehicle-client/http";
35
35
  import { registerVehicleTools } from "@danypops/vehicle-client-pi";
36
36
  import type { VehicleClient, VehicleManifest } from "@danypops/vehicle-core";
37
37
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
38
- import { renderDoctorRunResult } from "./doctor-render.js";
38
+ import { createDoctorRunRenderResult } from "./doctor-render.js";
39
39
  import { currentVehicleClientTarget } from "./vehicle-target.js";
40
40
 
41
41
  /** Already covered by their own dedicated, approval-aware tools in tools.ts -- see this file's own doc comment. */
@@ -98,7 +98,7 @@ export async function registerPackedVehicle(pi: ExtensionAPI): Promise<void> {
98
98
  // ensureVehicleShellHandle) -- no ownVehicleName/broker option needed here anymore; every
99
99
  // vehicle in the process (including packed's own) is discovered and namespaced uniformly.
100
100
  shell: { coreOperations: CORE_OPERATIONS },
101
- renderPresenters: { "doctor.run": renderDoctorRunResult },
101
+ renderers: (descriptor) => (descriptor.name === "doctor.run" ? { renderResult: createDoctorRunRenderResult(descriptor) } : undefined),
102
102
  renderCoverage: { operations: REVIEWED_OPERATIONS },
103
103
  });
104
104
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-packed",
3
- "version": "0.27.13",
3
+ "version": "0.28.0",
4
4
  "description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -34,7 +34,7 @@
34
34
  "@danypops/vehicle-core": "^0.17.1",
35
35
  "@danypops/vehicle-server": "^0.25.2",
36
36
  "jiti": "^2.7.0",
37
- "malevich-tui-components": "^0.21.1",
37
+ "malevich-tui-components": "^0.32.1",
38
38
  "publint": "0.3.22",
39
39
  "semver": "^7.8.5"
40
40
  },