@xynogen/pix-runtime 0.16.1 → 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 +15 -34
- package/package.json +2 -1
- package/src/binaries/catalog.ts +24 -1
- package/src/extension.ts +0 -3
- package/src/icon-catalog.ts +6 -0
- package/src/index.ts +0 -1
- package/src/pix-command.ts +22 -12
- package/src/registry.ts +11 -0
- package/src/runtime.ts +18 -0
- package/src/sections/index.ts +10 -1
- package/src/sections/pretty.ts +4 -0
- package/src/sections/services.ts +12 -0
- package/src/sysstat.ts +118 -0
- package/src/herdr-notify.ts +0 -39
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
|
|
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
|
-
###
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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.
|
|
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",
|
package/src/binaries/catalog.ts
CHANGED
|
@@ -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
|
});
|
package/src/icon-catalog.ts
CHANGED
|
@@ -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
package/src/pix-command.ts
CHANGED
|
@@ -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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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);
|
package/src/sections/index.ts
CHANGED
|
@@ -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 {
|
|
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;
|
package/src/sections/pretty.ts
CHANGED
|
@@ -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
|
};
|
package/src/sections/services.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/herdr-notify.ts
DELETED
|
@@ -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
|
-
}
|