@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 +15 -34
- package/package.json +3 -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 +69 -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.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": {
|
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,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
|
+
}
|
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
|
-
}
|