@xynogen/pix-runtime 0.16.1 → 0.17.1

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/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/xynogen/pix-mono/main/.github/assets/pix-logo.svg" alt="Pix" width="152" height="80">
3
+ </p>
4
+
1
5
  # pix-runtime
2
6
 
3
7
  Pix's small shared runtime layer. It owns the process-wide config contract:
@@ -10,7 +14,7 @@ See `DESIGN.md` for the full contract.
10
14
  ## What it does
11
15
 
12
16
  - Versioned, sparse `pix.json` (`$version: 1`) — defaults resolve in code.
13
- - Typed sections: `collapse`, `pretty`, `io`, `compaction`, `edit`, `optimizer`, `gate`, `fetch`, `search`, `voice`, `toolbox`.
17
+ - Typed sections: `collapse`, `pretty`, `io`, `compaction`, `edit`, `optimizer`, `gate`, `fetch`, `search`, `voice`, `notify`, `toolbox`.
14
18
  - `/pix` has a Footer tab for `pretty.footer` visibility.
15
19
  - Startup imports legacy service config files and archives them only after a successful save.
16
20
  - Atomic writes behind a serialized in-process queue and a short-lived
@@ -19,7 +23,9 @@ See `DESIGN.md` for the full contract.
19
23
  - Typed, path-filtered change events.
20
24
  - One-time migration of legacy unversioned config and the `optimizer.json`
21
25
  sidecar.
22
- - The `/pix` shared-settings command, with **Settings** and **Binaries** tabs.
26
+ - The `/pix` shared-settings command, with **Settings** and **Binaries** tabs plus
27
+ tabs that packages register (for example **Notify** from pix-notify).
28
+ `/pix <tab>` opens a tab directly.
23
29
  - One catalog, resolver and downloader for every external command pix runs
24
30
  (`binaries`), plus shared `paths` and `platform` helpers.
25
31
  - **User shell** — user `!` commands run through PowerShell on Windows (pwsh 7,
@@ -67,8 +73,8 @@ pix-core consumes both settings.
67
73
 
68
74
  Over that threshold, pix-core masks older tool output before it summarizes.
69
75
  `compaction.maskKeep` (default `10`) is the number of newest tool results kept as
70
- is. Older successful `bash`/`grep`/`find`/`ls`/`read` output becomes a one-line
71
- stub; errors stay. `compaction.maskMinTokens` (default `5000`) skips a pass that
76
+ is. Older successful `bash`/`grep`/`find`/`ls`/`read` output of 2,000 chars or more
77
+ becomes a one-line stub; smaller output and errors stay. `compaction.maskMinTokens` (default `5000`) skips a pass that
72
78
  would free fewer tokens, because each pass breaks the prompt cache. Both are
73
79
  `pix.json` calibration knobs with no `/pix` picker.
74
80
 
@@ -297,37 +303,12 @@ const done = beginAgentActivity(pi.events, "subagent", "Agent running");
297
303
  done();
298
304
  ```
299
305
 
300
- ### herdr notification bridge
301
-
302
- `src/herdr-notify.ts` (exported as `bindHerdrNotify`) is a leaf subscriber on
303
- `pix:agent-state`. When the agent transitions INTO `blocked` it spawns:
304
-
305
- ```bash
306
- herdr notification show <message> --sound request
307
- ```
308
-
309
- so a user away from their terminal gets a popup and sound. `request` is herdr's
310
- built-in "needs attention" cue. The trigger is edge-triggered: it fires once per
311
- entry into `blocked`, not repeatedly, and nested blocks stay a single
312
- notification.
313
-
314
- The bridge is fire-and-forget. The child is `detached`, `unref`'d, and
315
- `stdio: "ignore"`, and a missing `herdr` binary is swallowed, so it never blocks
316
- the prompt path or throws. herdr owns the toast's color, position, and sound via
317
- its own server config (`[toast]` / `[notification]`); pix only reports the moment
318
- and the message. Compaction and other autonomous work never enter `blocked`, so
319
- they never notify.
320
-
321
- It is wired automatically by the runtime extension: bound at session start,
322
- unbound at shutdown. No manual setup beyond running inside a herdr pane.
323
-
324
- Two environment variables control it:
306
+ ### Notifications
325
307
 
326
- - `HERDR_ENV` — herdr sets this to `1` inside its own pane. The bridge only runs
327
- when `HERDR_ENV === "1"`; outside a herdr pane it is a no-op and spawns
328
- nothing.
329
- - `PIX_HERDR_NOTIFY` — set to `0` to silence notifications even inside a herdr
330
- pane.
308
+ The runtime only emits `pix:agent-state`. The sound/popup on `blocked` lives in
309
+ [`@xynogen/pix-notify`](https://www.npmjs.com/package/@xynogen/pix-notify): herdr
310
+ notification inside a herdr pane, the configured system sound everywhere else.
311
+ It reads the `notify` section (`enabled`, `sound`), edited in the `/pix` **Notify** tab.
331
312
 
332
313
  ## Testing
333
314
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.16.1",
3
+ "version": "0.17.1",
4
4
  "description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -36,6 +36,7 @@
36
36
  "./testing": "./src/testing.ts",
37
37
  "./exec": "./src/exec.ts",
38
38
  "./os": "./src/os.ts",
39
+ "./sysstat": "./src/sysstat.ts",
39
40
  "./audio": "./src/audio.ts",
40
41
  "./safe-path": "./src/safe-path.ts",
41
42
  "./which": "./src/which.ts",
@@ -68,6 +69,7 @@
68
69
  "@ast-grep/napi": "^0.45.0"
69
70
  },
70
71
  "dependencies": {
72
+ "systeminformation": "5.33.19",
71
73
  "xxhash-wasm": "1.1.0"
72
74
  },
73
75
  "peerDependenciesMeta": {
@@ -113,6 +113,29 @@ export const CATALOG = {
113
113
  asset: (h, v) => (h.os === "win32" ? `aria2-${v}-win-64bit-build1.zip` : undefined),
114
114
  },
115
115
  },
116
+ herdr: {
117
+ usedBy: ["pix-notify"],
118
+ os: ["linux", "darwin"],
119
+ optional: true,
120
+ hint: { default: "see herdr.dev — only used inside a herdr pane" },
121
+ },
122
+ paplay: {
123
+ usedBy: ["pix-notify"],
124
+ os: LINUX,
125
+ optional: true,
126
+ // PulseAudio's player; PipeWire ships the same job as pw-play.
127
+ names: ["paplay", "pw-play"],
128
+ versionArgs: null,
129
+ hint: {
130
+ default: "apt install pulseaudio-utils · dnf install pulseaudio-utils · pacman -S libpulse",
131
+ },
132
+ },
133
+ afplay: {
134
+ usedBy: ["pix-notify"],
135
+ os: ["darwin"],
136
+ versionArgs: null,
137
+ hint: { default: "built into macOS" },
138
+ },
116
139
  ffmpeg: {
117
140
  usedBy: ["pix-voice"],
118
141
  os: ["linux", "win32", "darwin"],
@@ -237,7 +260,7 @@ export const CATALOG = {
237
260
  hint: { default: "npm install -g @earendil-works/pi-coding-agent" },
238
261
  },
239
262
  powershell: {
240
- usedBy: ["pix-powershell", "pix-ask", "pix-voice"],
263
+ usedBy: ["pix-powershell", "pix-ask", "pix-voice", "pix-notify"],
241
264
  os: ["win32", "linux"],
242
265
  wslOnly: true,
243
266
  // WSL reaches Windows PowerShell through interop as powershell.exe.
package/src/extension.ts CHANGED
@@ -5,7 +5,6 @@ import {
5
5
  type ExtensionAPI,
6
6
  } from "@earendil-works/pi-coding-agent";
7
7
  import { syncBinaryStore } from "./binaries/store.ts";
8
- import { bindHerdrNotify } from "./herdr-notify.ts";
9
8
  import { bindAgentStateEvents, resetAgentState, resetUnattendedState } from "./herdr-state.ts";
10
9
  import { once } from "./once.ts";
11
10
  import { registerPixCommand } from "./pix-command.ts";
@@ -26,7 +25,6 @@ export default function registerRuntime(pi: ExtensionAPI): void {
26
25
 
27
26
  registerPixCommand(pi, runtime);
28
27
  const unbindAgentState = bindAgentStateEvents(pi.events);
29
- const unbindHerdrNotify = bindHerdrNotify(pi.events);
30
28
 
31
29
  // User `!` commands: PowerShell on Windows, $SHELL on POSIX (zsh gets rc + aliases).
32
30
  const shell = userShell();
@@ -65,7 +63,6 @@ export default function registerRuntime(pi: ExtensionAPI): void {
65
63
  pi.on("session_shutdown", async () => {
66
64
  resetAgentState(pi.events);
67
65
  resetUnattendedState(pi.events);
68
- unbindHerdrNotify();
69
66
  unbindAgentState();
70
67
  await runtime.flush();
71
68
  });
@@ -58,6 +58,12 @@ const CATALOG = {
58
58
  "net.in": { nerd: "\u21E1", unicode: "\u21E1", ascii: "in" },
59
59
  "net.out": { nerd: "\u21E3", unicode: "\u21E3", ascii: "out" },
60
60
  score: { nerd: "\u26A1", unicode: "\u26A1", ascii: "S" },
61
+ // Distinct shapes: chip (CPU), card (GPU), stick (RAM). md-memory looks like the CPU chip.
62
+ "sys.cpu": { nerd: "\u{F4BC}", unicode: `\u2699${VS}`, ascii: "cpu" },
63
+ "sys.gpu": { nerd: "\u{F08AE}", unicode: `\u25A6${VS}`, ascii: "gpu" },
64
+ "sys.ram": { nerd: "\u{EFC5}", unicode: `\u2630${VS}`, ascii: "ram" },
65
+ "sys.temp": { nerd: "\u{F050F}", unicode: `\u2668${VS}`, ascii: "t" },
66
+ clock: { nerd: "\u{F0150}", unicode: `\u25F7${VS}`, ascii: "@" },
61
67
 
62
68
  // ── misc ──────────────────────────────────────────────────────────────
63
69
  ok: { nerd: "\u2713", unicode: "\u2713", ascii: "ok" },
package/src/index.ts CHANGED
@@ -8,7 +8,6 @@ export type {
8
8
  SubscribeOptions,
9
9
  } from "./events.ts";
10
10
  export { default } from "./extension.ts";
11
- export { bindHerdrNotify } from "./herdr-notify.ts";
12
11
  export {
13
12
  beginAgentActivity,
14
13
  bindAgentStateEvents,
@@ -228,6 +228,20 @@ function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
228
228
  return { ...process.env, PI_CODING_AGENT_DIR: dirname(runtime.path) };
229
229
  }
230
230
 
231
+ /**
232
+ * `target` with some properties replaced. Other reads go to `target` itself, so
233
+ * prototype methods keep working and are bound to the real instance (`this`).
234
+ */
235
+ function forward<T extends object>(target: T, overrides: Record<string, () => unknown>): T {
236
+ return new Proxy(target, {
237
+ get(obj, key) {
238
+ if (typeof key === "string" && key in overrides) return overrides[key]?.();
239
+ const value = Reflect.get(obj, key, obj);
240
+ return typeof value === "function" ? value.bind(obj) : value;
241
+ },
242
+ });
243
+ }
244
+
231
245
  const TABS = ["Settings", "Binaries", "Footer"] as const;
232
246
  /** Most frequently changed tabs sit leftmost; unknown package tabs go before rare ones. */
233
247
  const TAB_ORDER = ["Settings", "Tools", "MCP", "Optimizer", "Web", "Voice"];
@@ -405,18 +419,14 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
405
419
  return new Promise<unknown>((resolve) => {
406
420
  leavePanel = () => resolve(null);
407
421
  // The /pix tab row takes one line; render() shrinks the panel's terminal until both fit.
408
- const panelTui = {
409
- ...tui,
410
- terminal: {
411
- ...tui.terminal,
412
- get rows() {
413
- return Math.max(
414
- 1,
415
- (tui.terminal?.rows ?? process.stdout.rows ?? 24) - panelShrink,
416
- );
417
- },
418
- },
419
- };
422
+ // Proxies, not spreads: the real TUI and terminal are class instances whose
423
+ // methods (requestRender, columns) live on the prototype and a spread drops them.
424
+ const terminal = tui.terminal ?? {};
425
+ const panelTerminal = forward(terminal, {
426
+ rows: () =>
427
+ Math.max(1, (terminal.rows ?? process.stdout.rows ?? 24) - panelShrink),
428
+ });
429
+ const panelTui = forward(tui, { terminal: () => panelTerminal });
420
430
  embedded = factory(panelTui, theme, kb, (value: unknown) => {
421
431
  if (generation !== panelGeneration) return resolve(null);
422
432
  if (value == null) {
package/src/registry.ts CHANGED
@@ -28,6 +28,17 @@ export class SectionRegistry {
28
28
  return this.byKey.get(key);
29
29
  }
30
30
 
31
+ /**
32
+ * Swap in a newer definition for a known key (Pi reload re-evaluates modules, but the
33
+ * runtime singleton on globalThis survives with the old schema). True when it changed.
34
+ */
35
+ adopt(handle: SectionHandle<string, unknown>): boolean {
36
+ if (!this.byKey.has(handle.key) || this.byKey.get(handle.key) === handle.__section)
37
+ return false;
38
+ this.byKey.set(handle.key, handle.__section);
39
+ return true;
40
+ }
41
+
31
42
  all(): ConfigSection<unknown>[] {
32
43
  return [...this.byKey.values()];
33
44
  }
package/src/runtime.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  type SectionHandle,
41
41
  stripDefaults,
42
42
  } from "./schema.ts";
43
+ import { builtinSections } from "./sections/index.ts";
43
44
 
44
45
  export interface InitOptions {
45
46
  origin?: ConfigChangeOrigin;
@@ -112,6 +113,13 @@ class RuntimeImpl implements PixRuntime {
112
113
  return this.current !== null;
113
114
  }
114
115
 
116
+ /** Adopt newer section definitions; drop the snapshot so the next read re-resolves them. */
117
+ adoptSections(handles: readonly SectionHandle<string, unknown>[]): void {
118
+ let changed = false;
119
+ for (const handle of handles) changed = this.registry.adopt(handle) || changed;
120
+ if (changed) this.current = null;
121
+ }
122
+
115
123
  private parseContext(): ParseContext {
116
124
  return { diagnostic: (d) => this.sink.push(d) };
117
125
  }
@@ -492,6 +500,16 @@ export function pixRuntime(): PixRuntime {
492
500
  return g[SINGLETON_KEY];
493
501
  }
494
502
 
503
+ // A reload re-evaluates this module but keeps the singleton: hand it this copy's schemas,
504
+ // or new keys (e.g. a new footer flag) are dropped on every read and write.
505
+ // ponytail: last-evaluated copy wins. Version-skewed copies loading after a newer one
506
+ // would hand back older schemas; compare a schema version here if that ever happens.
507
+ (
508
+ (globalThis as Global)[SINGLETON_KEY] as
509
+ | { adoptSections?: (h: readonly SectionHandle<string, unknown>[]) => void }
510
+ | undefined
511
+ )?.adoptSections?.(builtinSections);
512
+
495
513
  /** Test/entry hook: create an isolated runtime with injected adapters. */
496
514
  export function createRuntime(adapters: RuntimeAdapters = {}): PixRuntime {
497
515
  return new RuntimeImpl(adapters);
@@ -34,6 +34,8 @@ import { prettySection } from "./pretty.ts";
34
34
 
35
35
  export {
36
36
  fetchSection,
37
+ type NotifyConfig,
38
+ notifySection,
37
39
  searchSection,
38
40
  type ToolboxConfig,
39
41
  toolboxSection,
@@ -42,7 +44,13 @@ export {
42
44
  type WebConfig,
43
45
  } from "./services.ts";
44
46
 
45
- import { fetchSection, searchSection, toolboxSection, voiceSection } from "./services.ts";
47
+ import {
48
+ fetchSection,
49
+ notifySection,
50
+ searchSection,
51
+ toolboxSection,
52
+ voiceSection,
53
+ } from "./services.ts";
46
54
 
47
55
  /** All built-in sections, in stable registration order. */
48
56
  export const builtinSections = [
@@ -56,5 +64,6 @@ export const builtinSections = [
56
64
  fetchSection,
57
65
  searchSection,
58
66
  voiceSection,
67
+ notifySection,
59
68
  toolboxSection,
60
69
  ] as const;
@@ -32,6 +32,8 @@ export interface PrettyConfig {
32
32
  | "tokens"
33
33
  | "cost"
34
34
  | "tps"
35
+ | "system"
36
+ | "clock"
35
37
  | "wrap",
36
38
  boolean
37
39
  >;
@@ -72,6 +74,8 @@ const DEFAULTS: Readonly<PrettyConfig> = {
72
74
  tokens: true,
73
75
  cost: true,
74
76
  tps: true,
77
+ system: true,
78
+ clock: true,
75
79
  wrap: false,
76
80
  },
77
81
  };
@@ -47,6 +47,18 @@ export const voiceSection = defineSection({
47
47
  return result;
48
48
  },
49
49
  });
50
+ const notifyDefaults = { enabled: true, sound: "auto" };
51
+ export type NotifyConfig = typeof notifyDefaults;
52
+ export const notifySection = defineSection({
53
+ key: "notify",
54
+ defaults: notifyDefaults,
55
+ parse(raw): NotifyConfig {
56
+ const item = isObj(raw) ? raw : {};
57
+ const sound = typeof item.sound === "string" && item.sound.trim() ? item.sound.trim() : "auto";
58
+ return { enabled: boolOr(item.enabled, true), sound };
59
+ },
60
+ });
61
+
50
62
  export interface ToolboxConfig {
51
63
  disabledTools?: string[];
52
64
  loadedTools?: string[];
package/src/sysstat.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { promises as fs } from "node:fs";
2
+ import si from "systeminformation";
3
+
4
+ export interface SystemStats {
5
+ cpu?: number;
6
+ cpuTemp?: number;
7
+ gpu?: number;
8
+ gpuTemp?: number;
9
+ ram?: number;
10
+ }
11
+
12
+ /** Missing sensors, sentinel values and failed probes stay unavailable, never become zero. */
13
+ const reading = (value: unknown, max: number): number | undefined =>
14
+ typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= max
15
+ ? Math.round(value)
16
+ : undefined;
17
+
18
+ async function readAmdStats(): Promise<SystemStats> {
19
+ const stats: SystemStats = {};
20
+ const read = (path: string) => fs.readFile(path, "utf8").catch(() => "");
21
+ const cards = await fs.readdir("/sys/class/drm").catch(() => []);
22
+ for (const card of cards.filter((name) => /^card\d+$/.test(name))) {
23
+ const device = `/sys/class/drm/${card}/device`;
24
+ if ((await read(`${device}/vendor`)).trim() !== "0x1002") continue;
25
+ const busy = (await read(`${device}/gpu_busy_percent`)).trim();
26
+ const gpu = busy ? reading(Number(busy), 100) : undefined;
27
+ if (gpu !== undefined) stats.gpu = Math.max(stats.gpu ?? 0, gpu);
28
+ const sensors = await fs.readdir(`${device}/hwmon`).catch(() => []);
29
+ for (const sensor of sensors) {
30
+ const raw = (await read(`${device}/hwmon/${sensor}/temp1_input`)).trim();
31
+ const temperature = raw ? reading(Number(raw) / 1000, 150) : undefined;
32
+ if (temperature !== undefined) stats.gpuTemp = Math.max(stats.gpuTemp ?? 0, temperature);
33
+ }
34
+ }
35
+ return stats;
36
+ }
37
+
38
+ /** Read each source independently so an unsupported sensor cannot discard other readings. */
39
+ export async function readSystemStats(): Promise<SystemStats> {
40
+ const [cpu, temperature, graphics, memory] = await Promise.allSettled([
41
+ si.currentLoad(),
42
+ si.cpuTemperature(),
43
+ si.graphics(),
44
+ si.mem(),
45
+ ]);
46
+ const controllers = graphics.status === "fulfilled" ? graphics.value.controllers : [];
47
+ const highest = (key: "utilizationGpu" | "temperatureGpu", max: number) => {
48
+ const values = controllers
49
+ .map((controller) => reading(controller[key], max))
50
+ .filter((value): value is number => value !== undefined);
51
+ return values.length ? Math.max(...values) : undefined;
52
+ };
53
+ const gpu = highest("utilizationGpu", 100);
54
+ const gpuTemp = highest("temperatureGpu", 150);
55
+ const amd =
56
+ process.platform === "linux" && (gpu === undefined || gpuTemp === undefined)
57
+ ? await readAmdStats()
58
+ : {};
59
+ return {
60
+ cpu: cpu.status === "fulfilled" ? reading(cpu.value.currentLoad, 100) : undefined,
61
+ cpuTemp: temperature.status === "fulfilled" ? reading(temperature.value.main, 150) : undefined,
62
+ gpu: gpu ?? amd.gpu,
63
+ gpuTemp: gpuTemp ?? amd.gpuTemp,
64
+ ram:
65
+ memory.status === "fulfilled" && memory.value.total > 0
66
+ ? reading(((memory.value.total - memory.value.available) / memory.value.total) * 100, 100)
67
+ : undefined,
68
+ };
69
+ }
@@ -1,39 +0,0 @@
1
- import { spawn } from "node:child_process";
2
- import type { EventBus } from "@earendil-works/pi-coding-agent";
3
- import type { PixAgentState, PixAgentStateEvent } from "./herdr-state.ts";
4
-
5
- /**
6
- * Leaf bridge: when the agent enters the `blocked` state (ask_user / gate /
7
- * sudo waiting on the user), fire a herdr notification so an away-from-pane
8
- * user gets a sound + popup. Fires only on the transition INTO blocked, never
9
- * repeatedly. No-op outside a herdr pane; silence with `PIX_HERDR_NOTIFY=0`.
10
- */
11
- export function bindHerdrNotify(events: EventBus, spawnFn = spawn): () => void {
12
- // ponytail: gate on HERDR_ENV — herdr's own extension uses the same signal;
13
- // outside herdr the CLI would just no-op, so skip the spawn entirely.
14
- if (process.env.HERDR_ENV !== "1" || process.env.PIX_HERDR_NOTIFY === "0") {
15
- return () => {};
16
- }
17
- let last: PixAgentState | undefined;
18
- const off = events.on("pix:agent-state", (raw) => {
19
- const event = raw as PixAgentStateEvent;
20
- if (event.state === "blocked" && last !== "blocked") {
21
- notify(event.message ?? "Pi needs your attention", spawnFn);
22
- }
23
- last = event.state;
24
- });
25
- return off;
26
- }
27
-
28
- function notify(message: string, spawnFn: typeof spawn): void {
29
- try {
30
- const child = spawnFn("herdr", ["notification", "show", message, "--sound", "request"], {
31
- stdio: "ignore",
32
- detached: true,
33
- });
34
- child.on("error", () => {}); // herdr not on PATH — ignore
35
- child.unref();
36
- } catch {
37
- // spawn threw synchronously; nothing actionable
38
- }
39
- }