@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 +15 -34
- package/package.json +2 -1
- package/src/binaries/catalog.ts +24 -1
- package/src/exec.ts +7 -3
- package/src/extension.ts +0 -3
- package/src/icon-catalog.ts +6 -0
- package/src/index.ts +0 -1
- package/src/os.ts +15 -6
- package/src/pix-command.ts +81 -19
- 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/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
|
|
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
|
-
|
|
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
|
});
|
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/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
|
-
|
|
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(
|
|
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
|
-
|
|
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;
|
package/src/pix-command.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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);
|
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
|
-
}
|