@xynogen/pix-runtime 0.16.0 → 0.17.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.
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.0",
3
+ "version": "0.17.0",
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",
@@ -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/exec.ts CHANGED
@@ -97,7 +97,7 @@ export interface ToolExecOptions {
97
97
  onStatus?: (status: ToolStatus) => void;
98
98
  /** Test seam for the host OS. */
99
99
  host?: HostPlatform;
100
- /** Cap on captured stdout/stderr bytes each (default 50 MB). */
100
+ /** Cap on captured stdout/stderr bytes each (default 50 MB). Past it the child is stopped. */
101
101
  maxBuffer?: number;
102
102
  /** Args are already quoted for the target (cmd.exe `/c` lines); Node must not re-quote. */
103
103
  verbatim?: boolean;
@@ -111,7 +111,7 @@ export interface ToolRunResult {
111
111
  /** Raw stdout for binary output (clipboard images, archives). */
112
112
  stdoutBytes: Buffer;
113
113
  timedOut: boolean;
114
- /** Output passed `maxBuffer` and was cut. A zero exit code is then not a complete result. */
114
+ /** Output passed `maxBuffer`; the child was stopped and output cut. Never a complete result. */
115
115
  truncated: boolean;
116
116
  /** The binary that ran and where it was found. */
117
117
  tool: ResolvedTool;
@@ -220,7 +220,11 @@ function collect(
220
220
  let truncated = false;
221
221
  const push = (list: Buffer[], chunk: Buffer, len: number): number => {
222
222
  const room = cap - len;
223
- if (chunk.length > room) truncated = true;
223
+ // Like pi.exec: stop a runaway child instead of draining output nobody keeps.
224
+ if (chunk.length > room) {
225
+ truncated = true;
226
+ stop();
227
+ }
224
228
  if (room <= 0) return len;
225
229
  list.push(chunk.length > room ? chunk.subarray(0, room) : chunk);
226
230
  return len + Math.min(chunk.length, room);
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,
package/src/os.ts CHANGED
@@ -46,7 +46,7 @@ export function openCommand(
46
46
  target: string,
47
47
  host: HostPlatform,
48
48
  app?: string,
49
- ): { name: string; args: string[]; verbatim?: boolean } {
49
+ ): { name: string; args: string[]; verbatim?: boolean; cwd?: string } {
50
50
  if (host.os === "darwin") return { name: "open", args: app ? ["-a", app, target] : [target] };
51
51
  if (host.os === "win32") {
52
52
  // `start` is a cmd.exe builtin; "" is its window-title argument.
@@ -54,8 +54,9 @@ export function openCommand(
54
54
  return { name: "cmd", args: ["/d", "/s", "/c", `"${parts.join(" ")}"`], verbatim: true };
55
55
  }
56
56
  if (host.wsl && !app) {
57
- // WSL: hand the target to Windows so the user's default browser opens.
58
- return { name: "wslview", args: [target] };
57
+ // WSL: hand the target to Windows so the user's default browser opens. Windows programs
58
+ // cannot start from a long `\\wsl.localhost\...` cwd, so start from `/`.
59
+ return { name: "wslview", args: [target], cwd: "/" };
59
60
  }
60
61
  return { name: app ?? "xdg-open", args: [target] };
61
62
  }
@@ -71,6 +72,7 @@ export async function openTarget(target: string, opts: OpenOptions = {}): Promis
71
72
  if (cmd.name === "wslview" && !resolveTool("wslview", opts))
72
73
  cmd = openCommand(target, { ...host, wsl: false });
73
74
  const result = await runTool(cmd.name, cmd.args, {
75
+ cwd: cmd.cwd,
74
76
  env: opts.env,
75
77
  host,
76
78
  timeoutMs: opts.timeoutMs ?? 15_000,
@@ -140,10 +142,16 @@ export function pickPreferredMime(types: readonly string[]): string | null {
140
142
  }
141
143
 
142
144
  /** Output bytes when the tool exists and exits 0; null otherwise (probing, never throws). */
143
- function probe(name: string, args: string[], timeoutMs: number, opts: OsOptions): Buffer | null {
145
+ function probe(
146
+ name: string,
147
+ args: string[],
148
+ timeoutMs: number,
149
+ opts: OsOptions,
150
+ cwd?: string,
151
+ ): Buffer | null {
144
152
  if (!resolveTool(name, opts)) return null;
145
153
  try {
146
- const r = runToolSync(name, args, { env: opts.env, host: opts.host, timeoutMs });
154
+ const r = runToolSync(name, args, { env: opts.env, host: opts.host, timeoutMs, cwd });
147
155
  return r.code === 0 ? r.stdoutBytes : null;
148
156
  } catch {
149
157
  return null;
@@ -203,7 +211,8 @@ function viaPowerShell(host: HostPlatform, opts: OsOptions): ClipboardImage | nu
203
211
  "if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
204
212
  ].join("; ");
205
213
  const args = ["-NoProfile", "-STA", "-Command", script];
206
- const out = probe("powershell", args, POWERSHELL_TIMEOUT_MS, opts);
214
+ // From WSL, Windows programs cannot start from a long `\\wsl.localhost\...` cwd.
215
+ const out = probe("powershell", args, POWERSHELL_TIMEOUT_MS, opts, host.wsl ? "/" : undefined);
207
216
  if (out?.toString("utf-8").trim() !== "ok") return null;
208
217
  const bytes = readFileSync(tmpFile);
209
218
  return bytes.length ? { bytes, mimeType: "image/png" } : null;
@@ -42,6 +42,21 @@ function row<T>(r: SettingRow<T>): SettingRow<unknown> {
42
42
  return r as SettingRow<unknown>;
43
43
  }
44
44
 
45
+ /** Pi-owned theme row: not stored in pix.json, applied through `ctx.ui.setTheme`. */
46
+ interface ThemeRow {
47
+ kind: "theme";
48
+ section: string;
49
+ label: string;
50
+ values: readonly string[];
51
+ current: () => string;
52
+ }
53
+
54
+ type Row = SettingRow<unknown> | ThemeRow;
55
+
56
+ function isThemeRow(row: Row): row is ThemeRow {
57
+ return "kind" in row;
58
+ }
59
+
45
60
  /** Render a token count as a compact label: 1_000_000 → "1M", 150_000 → "150k". */
46
61
  function formatTokens(tokens: number): string {
47
62
  return tokens % 1_000_000 === 0 ? `${tokens / 1_000_000}M` : `${tokens / 1000}k`;
@@ -213,6 +228,20 @@ function binariesEnv(runtime: PixRuntime): NodeJS.ProcessEnv {
213
228
  return { ...process.env, PI_CODING_AGENT_DIR: dirname(runtime.path) };
214
229
  }
215
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
+
216
245
  const TABS = ["Settings", "Binaries", "Footer"] as const;
217
246
  /** Most frequently changed tabs sit leftmost; unknown package tabs go before rare ones. */
218
247
  const TAB_ORDER = ["Settings", "Tools", "MCP", "Optimizer", "Web", "Voice"];
@@ -291,12 +320,32 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
291
320
  if (data && typeof data === "object" && "registered" in data) data.registered = true;
292
321
  });
293
322
  pi.registerCommand("pix", {
294
- description: "pix: open shared settings (edit pix.json)",
295
- handler: async (_args: string, ctx: ExtensionCommandContext) => {
323
+ description: "pix: open shared settings (edit pix.json); /pix <tab> opens that tab",
324
+ getArgumentCompletions: (prefix: string) => {
325
+ const p = prefix.trim().toLowerCase();
326
+ const items = [...TABS, ...packageSettings(pi).map((entry) => entry.label)]
327
+ .map((label) => label.toLowerCase())
328
+ .filter((name) => name.startsWith(p))
329
+ .map((name) => ({ value: name, label: name }));
330
+ return items.length ? items : null;
331
+ },
332
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
296
333
  // Re-parse pix.json so section parsers remain the single validation source.
297
334
  await runtime.reload({ origin: "command", source: "pix" });
298
335
  const panels = packageSettings(pi);
299
336
  const settings = visibleSettings(pi);
337
+ // Pi owns the active theme (settings.json); `/pix` only cycles it through `ctx.ui.setTheme`.
338
+ const themeNames = (ctx.ui.getAllThemes?.() ?? []).map((t) => t.name).sort();
339
+ const themeRow: ThemeRow | undefined =
340
+ themeNames.length > 1
341
+ ? {
342
+ kind: "theme",
343
+ section: "Pretty",
344
+ label: "theme",
345
+ values: themeNames,
346
+ current: () => ctx.ui.theme.name ?? "",
347
+ }
348
+ : undefined;
300
349
  // SAFETY: Runtime UI supports custom overlays beyond the published command context type.
301
350
  const ui = ctx.ui as unknown as {
302
351
  custom?: (f: unknown, opts?: unknown) => Promise<unknown>;
@@ -338,7 +387,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
338
387
  ),
339
388
  ...panels.map((entry) => entry.label),
340
389
  ].sort((a, b) => tabRank(a) - tabRank(b));
341
- let tab = "Settings";
390
+ const wanted = (args ?? "").trim().toLowerCase();
391
+ let tab = tabs.find((name) => name.toLowerCase() === wanted) ?? "Settings";
342
392
  let embedded:
343
393
  | { render(width: number): string[]; handleInput(data: string): void; dispose?(): void }
344
394
  | undefined;
@@ -369,18 +419,14 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
369
419
  return new Promise<unknown>((resolve) => {
370
420
  leavePanel = () => resolve(null);
371
421
  // The /pix tab row takes one line; render() shrinks the panel's terminal until both fit.
372
- const panelTui = {
373
- ...tui,
374
- terminal: {
375
- ...tui.terminal,
376
- get rows() {
377
- return Math.max(
378
- 1,
379
- (tui.terminal?.rows ?? process.stdout.rows ?? 24) - panelShrink,
380
- );
381
- },
382
- },
383
- };
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 });
384
430
  embedded = factory(panelTui, theme, kb, (value: unknown) => {
385
431
  if (generation !== panelGeneration) return resolve(null);
386
432
  if (value == null) {
@@ -429,11 +475,27 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
429
475
  mountPanel();
430
476
  };
431
477
 
432
- const rows = () =>
433
- settings.filter((r) => (r.section === "Footer") === (tab === "Footer"));
478
+ // `/pix <tab>` opens straight into that tab.
479
+ if (tab === "Binaries") binariesTab().refresh();
480
+ mountPanel();
481
+
482
+ const rows = (): Row[] => {
483
+ const list = settings.filter((r) => (r.section === "Footer") === (tab === "Footer"));
484
+ return tab === "Settings" && themeRow ? [themeRow, ...list] : list;
485
+ };
434
486
  const cycle = (direction: -1 | 1) => {
435
487
  const row = rows()[selected];
436
488
  if (!row) return;
489
+ if (isThemeRow(row)) {
490
+ const cur = row.values.indexOf(row.current());
491
+ const start = cur === -1 && direction === -1 ? 0 : cur;
492
+ const next = row.values[(start + direction + row.values.length) % row.values.length];
493
+ const result = next ? ctx.ui.setTheme(next) : undefined;
494
+ if (result && !result.success)
495
+ ui.notify(`pix: theme ${next}: ${result.error ?? "failed"}`, "error");
496
+ tui.requestRender();
497
+ return;
498
+ }
437
499
  // Functional updater: the runtime queue serializes writes, and the
438
500
  // callback sees the latest committed value — so rapid presses each
439
501
  // advance exactly one step instead of collapsing on a stale snapshot.
@@ -538,8 +600,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
538
600
  const sel = i === selected;
539
601
  const cursor = sel ? theme.fg("accent", "→") : " ";
540
602
  const label = theme.fg(sel ? "accent" : "text", row.label.padEnd(labelW));
541
- const value = row.read(runtime.get(row.handle));
542
- const isDefault = value === row.read(row.handle.defaults);
603
+ const value = isThemeRow(row) ? row.current() : row.read(runtime.get(row.handle));
604
+ const isDefault = isThemeRow(row) || value === row.read(row.handle.defaults);
543
605
  settingBodyLines[i] = body.length;
544
606
  body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
545
607
  }
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,118 @@
1
+ /**
2
+ * sysstat.ts — cheap host load sample for status bars: CPU %, RAM %, GPU %, CPU temperature.
3
+ *
4
+ * CPU and RAM come from `node:os`, so they work on every OS. GPU busy % and temperature
5
+ * are read from Linux sysfs (`/sys/class/drm`, `/sys/class/hwmon`, `/sys/class/thermal`);
6
+ * elsewhere, or when the driver exposes nothing, those fields are undefined.
7
+ * ponytail: no NVIDIA support (its driver has no sysfs busy file). Add an `nvidia-smi`
8
+ * catalog binary and a slow poll here if that is needed.
9
+ */
10
+
11
+ import { readdirSync, readFileSync } from "node:fs";
12
+ import { cpus, freemem, totalmem } from "node:os";
13
+ import { join } from "node:path";
14
+
15
+ export interface SystemStats {
16
+ /** Busy CPU since the previous sample, 0–100. Undefined on the first sample. */
17
+ cpu?: number;
18
+ /** Used memory (total − available), 0–100. */
19
+ ram?: number;
20
+ /** GPU busy, 0–100 (Linux amdgpu/i915/xe sysfs only). */
21
+ gpu?: number;
22
+ /** CPU package temperature in °C (Linux hwmon/thermal only). */
23
+ temp?: number;
24
+ }
25
+
26
+ /** Opaque CPU counter snapshot; pass the previous one to get a CPU percent. */
27
+ export type CpuTimes = { busy: number; total: number };
28
+
29
+ export function cpuTimes(): CpuTimes {
30
+ let busy = 0;
31
+ let total = 0;
32
+ for (const { times } of cpus()) {
33
+ const sum = times.user + times.nice + times.sys + times.idle + times.irq;
34
+ total += sum;
35
+ busy += sum - times.idle;
36
+ }
37
+ return { busy, total };
38
+ }
39
+
40
+ /** Busy share between two snapshots, 0–100, or undefined when no time passed. */
41
+ export function cpuPercent(prev: CpuTimes, next: CpuTimes): number | undefined {
42
+ const total = next.total - prev.total;
43
+ if (total <= 0) return undefined;
44
+ return clampPct(((next.busy - prev.busy) / total) * 100);
45
+ }
46
+
47
+ const clampPct = (n: number) => Math.min(100, Math.max(0, Math.round(n)));
48
+
49
+ function readNumber(path: string): number | undefined {
50
+ try {
51
+ const n = Number(readFileSync(path, "utf8").trim());
52
+ return Number.isFinite(n) ? n : undefined;
53
+ } catch {
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+ function list(dir: string): string[] {
59
+ try {
60
+ return readdirSync(dir);
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ /** Highest `gpu_busy_percent` across DRM cards (a laptop may report an idle iGPU too). */
67
+ export function readGpuBusy(sys = "/sys"): number | undefined {
68
+ let best: number | undefined;
69
+ for (const card of list(join(sys, "class/drm"))) {
70
+ if (!/^card\d+$/.test(card)) continue;
71
+ const n = readNumber(join(sys, "class/drm", card, "device/gpu_busy_percent"));
72
+ if (n !== undefined) best = Math.max(best ?? 0, clampPct(n));
73
+ }
74
+ return best;
75
+ }
76
+
77
+ // CPU sensor drivers, most specific first. coretemp = Intel, k10temp/zenpower = AMD.
78
+ const CPU_HWMON = ["k10temp", "zenpower", "coretemp", "cpu_thermal", "acpitz"];
79
+
80
+ /** CPU temperature in °C from hwmon (known CPU drivers), then thermal_zone0. */
81
+ export function readCpuTemp(sys = "/sys"): number | undefined {
82
+ const hwmon = join(sys, "class/hwmon");
83
+ const byName = new Map<string, string>();
84
+ for (const dir of list(hwmon)) {
85
+ try {
86
+ byName.set(readFileSync(join(hwmon, dir, "name"), "utf8").trim(), dir);
87
+ } catch {}
88
+ }
89
+ for (const name of CPU_HWMON) {
90
+ const dir = byName.get(name);
91
+ const milli = dir && readNumber(join(hwmon, dir, "temp1_input"));
92
+ if (milli) return Math.round(milli / 1000);
93
+ }
94
+ const zone = readNumber(join(sys, "class/thermal/thermal_zone0/temp"));
95
+ return zone ? Math.round(zone / 1000) : undefined;
96
+ }
97
+
98
+ /**
99
+ * Take one sample. Keep the returned `times` and pass it back next call so CPU % covers
100
+ * the interval between calls. Synchronous and cheap (a few small sysfs reads).
101
+ */
102
+ export function readSystemStats(
103
+ prev?: CpuTimes,
104
+ sys = "/sys",
105
+ ): { stats: SystemStats; times: CpuTimes } {
106
+ const times = cpuTimes();
107
+ const total = totalmem();
108
+ const linux = process.platform === "linux";
109
+ return {
110
+ times,
111
+ stats: {
112
+ cpu: prev ? cpuPercent(prev, times) : undefined,
113
+ ram: total > 0 ? clampPct(((total - freemem()) / total) * 100) : undefined,
114
+ gpu: linux ? readGpuBusy(sys) : undefined,
115
+ temp: linux ? readCpuTemp(sys) : undefined,
116
+ },
117
+ };
118
+ }
@@ -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
- }