@xynogen/pix-runtime 0.5.3 → 0.7.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
@@ -44,6 +44,12 @@ const timeoutMs = ioTimeoutMs(); // shared network timeout
44
44
  const signal = ioTimeoutSignal(toolSignal); // timeout + cancellation
45
45
  ```
46
46
 
47
+ Set `pretty.maxRenderWidth` and `pretty.maxRenderHeight` in `~/.pi/agent/pix.json`,
48
+ or change **Pretty → max modal width/height** with `/pix`. Values accept terminal
49
+ percentages such as `"65%"`/`"80%"` or fixed columns/rows such as `96`/`20`.
50
+ Percentage choices in `/pix` move in 5% steps. Width is the rendered frame width;
51
+ height is the threshold where modal content starts paging.
52
+
47
53
  Set `io.timeoutSec` in `~/.pi/agent/pix.json`, or change **Network → timeout (sec)**
48
54
  with `/pix`. The default is 30 seconds. It applies to Pix network operations,
49
55
  including remote skills, web fetch/search/transcription, MCP requests and
@@ -68,6 +74,83 @@ Collapse policy helpers:
68
74
  import { shouldCollapse, collapseDelayMs } from "@xynogen/pix-runtime/collapse";
69
75
  ```
70
76
 
77
+ ## Agent state and herdr notifications
78
+
79
+ ### Agent-state coordinator
80
+
81
+ `src/herdr-state.ts` (exported from the package index) is a process-wide
82
+ coordinator that tracks whether the agent is `working`, `blocked`, or `idle`,
83
+ keyed per Pi `EventBus`. On every transition it emits a `pix:agent-state` event:
84
+
85
+ ```ts
86
+ { state: "working" | "blocked" | "idle", message?: string, activities: number, blocks: number }
87
+ ```
88
+
89
+ Two lease primitives drive it. Both return an idempotent release function:
90
+
91
+ - `beginAgentActivity(events, source, message?)` marks asynchronous work in
92
+ progress (for example a running subagent). State reports `working` while any
93
+ activity lease is open.
94
+ - `withAgentBlock(events, source, message, prompt)` holds `blocked` state for the
95
+ duration of an awaited `prompt()` and always releases it, even on throw. Blocks
96
+ take priority over activities, so state is `blocked` whenever any block lease is
97
+ open.
98
+
99
+ `bindAgentStateEvents(events)` replays the current state and answers
100
+ `pix:agent-state:request`. `resetAgentState(events)` clears all leases on session
101
+ shutdown.
102
+
103
+ Nested leases collapse to a single state: two open blocks still report `blocked`
104
+ until both release.
105
+
106
+ Consumers that open a block today: `pix-ask` (`ask_user`, "Waiting for user
107
+ answer"), `pix-gate` (approval prompts), and `pix-sudo` (root approval).
108
+ `pix-subagent` opens activity leases for running background agents.
109
+
110
+ ```ts
111
+ import { withAgentBlock, beginAgentActivity } from "@xynogen/pix-runtime";
112
+
113
+ // Hold blocked state while waiting on the user:
114
+ await withAgentBlock(pi.events, "ask_user", "Waiting for user answer", () => promptUser());
115
+
116
+ // Mark background work:
117
+ const done = beginAgentActivity(pi.events, "subagent", "Agent running");
118
+ // ... later ...
119
+ done();
120
+ ```
121
+
122
+ ### herdr notification bridge
123
+
124
+ `src/herdr-notify.ts` (exported as `bindHerdrNotify`) is a leaf subscriber on
125
+ `pix:agent-state`. When the agent transitions INTO `blocked` it spawns:
126
+
127
+ ```bash
128
+ herdr notification show <message> --sound request
129
+ ```
130
+
131
+ so a user away from their terminal gets a popup and sound. `request` is herdr's
132
+ built-in "needs attention" cue. The trigger is edge-triggered: it fires once per
133
+ entry into `blocked`, not repeatedly, and nested blocks stay a single
134
+ notification.
135
+
136
+ The bridge is fire-and-forget. The child is `detached`, `unref`'d, and
137
+ `stdio: "ignore"`, and a missing `herdr` binary is swallowed, so it never blocks
138
+ the prompt path or throws. herdr owns the toast's color, position, and sound via
139
+ its own server config (`[toast]` / `[notification]`); pix only reports the moment
140
+ and the message. Compaction and other autonomous work never enter `blocked`, so
141
+ they never notify.
142
+
143
+ It is wired automatically by the runtime extension: bound at session start,
144
+ unbound at shutdown. No manual setup beyond running inside a herdr pane.
145
+
146
+ Two environment variables control it:
147
+
148
+ - `HERDR_ENV` — herdr sets this to `1` inside its own pane. The bridge only runs
149
+ when `HERDR_ENV === "1"`; outside a herdr pane it is a no-op and spawns
150
+ nothing.
151
+ - `PIX_HERDR_NOTIFY` — set to `0` to silence notifications even inside a herdr
152
+ pane.
153
+
71
154
  ## Testing
72
155
 
73
156
  ```ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.5.3",
3
+ "version": "0.7.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",
package/src/extension.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { bindHerdrNotify } from "./herdr-notify.ts";
3
+ import { bindAgentStateEvents, resetAgentState } from "./herdr-state.ts";
2
4
  import { once } from "./once.ts";
3
5
  import { registerPixCommand } from "./pix-command.ts";
4
6
  import { pixRuntime } from "./runtime.ts";
@@ -16,6 +18,8 @@ export default function registerRuntime(pi: ExtensionAPI): void {
16
18
  const runtime = pixRuntime();
17
19
 
18
20
  registerPixCommand(pi, runtime);
21
+ const unbindAgentState = bindAgentStateEvents(pi.events);
22
+ const unbindHerdrNotify = bindHerdrNotify(pi.events);
19
23
 
20
24
  let initialized = false;
21
25
  pi.on("session_start", async () => {
@@ -29,6 +33,9 @@ export default function registerRuntime(pi: ExtensionAPI): void {
29
33
  });
30
34
 
31
35
  pi.on("session_shutdown", async () => {
36
+ resetAgentState(pi.events);
37
+ unbindHerdrNotify();
38
+ unbindAgentState();
32
39
  await runtime.flush();
33
40
  });
34
41
  });
@@ -0,0 +1,39 @@
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
+ }
@@ -0,0 +1,116 @@
1
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type PixAgentState = "working" | "blocked" | "idle";
4
+ export type PixAgentStateEvent = {
5
+ state: PixAgentState;
6
+ message?: string;
7
+ activities: number;
8
+ blocks: number;
9
+ };
10
+
11
+ type Entry = { source: string; message?: string };
12
+ type Coordinator = {
13
+ activities: Map<symbol, Entry>;
14
+ blocks: Map<symbol, Entry>;
15
+ };
16
+
17
+ function coordinators(): WeakMap<EventBus, Coordinator> {
18
+ const global = globalThis as { __pixAgentState?: WeakMap<EventBus, Coordinator> };
19
+ global.__pixAgentState ??= new WeakMap<EventBus, Coordinator>();
20
+ return global.__pixAgentState;
21
+ }
22
+
23
+ function coordinator(events: EventBus): Coordinator {
24
+ const registry = coordinators();
25
+ let state = registry.get(events);
26
+ if (!state) {
27
+ state = { activities: new Map(), blocks: new Map() };
28
+ registry.set(events, state);
29
+ }
30
+ return state;
31
+ }
32
+
33
+ function snapshot(state: Coordinator): PixAgentStateEvent {
34
+ const blocked = [...state.blocks.values()].at(-1);
35
+ if (blocked) {
36
+ return {
37
+ state: "blocked",
38
+ ...(blocked.message ? { message: blocked.message } : {}),
39
+ activities: state.activities.size,
40
+ blocks: state.blocks.size,
41
+ };
42
+ }
43
+ const active = [...state.activities.values()].at(-1);
44
+ if (active) {
45
+ return {
46
+ state: "working",
47
+ ...(active.message ? { message: active.message } : {}),
48
+ activities: state.activities.size,
49
+ blocks: 0,
50
+ };
51
+ }
52
+ return { state: "idle", activities: 0, blocks: 0 };
53
+ }
54
+
55
+ function publish(events: EventBus): void {
56
+ events.emit("pix:agent-state", snapshot(coordinator(events)));
57
+ }
58
+
59
+ function begin(
60
+ events: EventBus,
61
+ kind: "activities" | "blocks",
62
+ source: string,
63
+ message?: string,
64
+ ): () => void {
65
+ const state = coordinator(events);
66
+ const token = Symbol(source);
67
+ state[kind].set(token, { source, message });
68
+ publish(events);
69
+ let active = true;
70
+ return () => {
71
+ if (!active) return;
72
+ active = false;
73
+ state[kind].delete(token);
74
+ publish(events);
75
+ };
76
+ }
77
+
78
+ /** Keep external status integrations working while asynchronous Pix work remains. */
79
+ export function beginAgentActivity(events: EventBus, source: string, message?: string): () => void {
80
+ return begin(events, "activities", source, message);
81
+ }
82
+
83
+ /** Internal primitive behind {@link withAgentBlock}; exported only for same-package tests. Blocks take priority over activity. */
84
+ export function beginAgentBlock(events: EventBus, source: string, message?: string): () => void {
85
+ return begin(events, "blocks", source, message);
86
+ }
87
+
88
+ /** Hold blocked state for one prompt and always release it. */
89
+ export async function withAgentBlock<T>(
90
+ events: EventBus,
91
+ source: string,
92
+ message: string | undefined,
93
+ prompt: () => Promise<T>,
94
+ ): Promise<T> {
95
+ const release = beginAgentBlock(events, source, message);
96
+ try {
97
+ return await prompt();
98
+ } finally {
99
+ release();
100
+ }
101
+ }
102
+
103
+ /** Bind state replay/reset to one Pi session lifecycle. */
104
+ export function bindAgentStateEvents(events: EventBus): () => void {
105
+ const off = events.on("pix:agent-state:request", () => publish(events));
106
+ publish(events);
107
+ return off;
108
+ }
109
+
110
+ /** Clear stale leases when a session shuts down or an extension reloads. */
111
+ export function resetAgentState(events: EventBus): void {
112
+ const state = coordinator(events);
113
+ state.activities.clear();
114
+ state.blocks.clear();
115
+ publish(events);
116
+ }
package/src/index.ts CHANGED
@@ -7,6 +7,15 @@ export type {
7
7
  SubscribeOptions,
8
8
  } from "./events.ts";
9
9
  export { default } from "./extension.ts";
10
+ export { bindHerdrNotify } from "./herdr-notify.ts";
11
+ export {
12
+ beginAgentActivity,
13
+ bindAgentStateEvents,
14
+ type PixAgentState,
15
+ type PixAgentStateEvent,
16
+ resetAgentState,
17
+ withAgentBlock,
18
+ } from "./herdr-state.ts";
10
19
  export { ioTimeoutMs, ioTimeoutSignal } from "./io.ts";
11
20
  export {
12
21
  config,
@@ -21,7 +21,7 @@ import { collapseSection } from "./sections/collapse.ts";
21
21
  import { compactionSection } from "./sections/compaction.ts";
22
22
  import { gateSection } from "./sections/gate.ts";
23
23
  import { ioSection } from "./sections/io.ts";
24
- import { prettySection } from "./sections/pretty.ts";
24
+ import { prettySection, type RenderSize } from "./sections/pretty.ts";
25
25
 
26
26
  interface SettingRow<T> {
27
27
  section: string;
@@ -42,6 +42,15 @@ function formatTokens(tokens: number): string {
42
42
  return tokens % 1_000_000 === 0 ? `${tokens / 1_000_000}M` : `${tokens / 1000}k`;
43
43
  }
44
44
 
45
+ function parseRenderSize(value: string): RenderSize {
46
+ return value.endsWith("%") ? (value as `${number}%`) : Number.parseFloat(value);
47
+ }
48
+
49
+ function resolveRenderSize(limit: RenderSize, available: number): number {
50
+ if (typeof limit === "number") return Math.floor(limit);
51
+ return Math.floor((available * Number.parseFloat(limit)) / 100);
52
+ }
53
+
45
54
  /** Parse a compact token label ("1M", "150k") back to an absolute count. */
46
55
  function parseTokens(label: string): number {
47
56
  const raw = label.trim();
@@ -71,6 +80,58 @@ const SETTINGS: SettingRow<unknown>[] = [
71
80
  read: (v) => v.lsStyle,
72
81
  patch: (value) => ({ lsStyle: value as "grid" | "tree" }),
73
82
  }),
83
+ row({
84
+ section: "Pretty",
85
+ label: "max modal width",
86
+ handle: prettySection,
87
+ values: [
88
+ "50%",
89
+ "55%",
90
+ "60%",
91
+ "65%",
92
+ "70%",
93
+ "75%",
94
+ "80%",
95
+ "85%",
96
+ "90%",
97
+ "95%",
98
+ "100%",
99
+ "72 cols",
100
+ "80 cols",
101
+ "88 cols",
102
+ "96 cols",
103
+ "104 cols",
104
+ "120 cols",
105
+ ],
106
+ read: (v) =>
107
+ typeof v.maxRenderWidth === "number" ? `${v.maxRenderWidth} cols` : v.maxRenderWidth,
108
+ patch: (value) => ({ maxRenderWidth: parseRenderSize(value) }),
109
+ }),
110
+ row({
111
+ section: "Pretty",
112
+ label: "max modal height",
113
+ handle: prettySection,
114
+ values: [
115
+ "50%",
116
+ "55%",
117
+ "60%",
118
+ "65%",
119
+ "70%",
120
+ "75%",
121
+ "80%",
122
+ "85%",
123
+ "90%",
124
+ "95%",
125
+ "100%",
126
+ "12 rows",
127
+ "16 rows",
128
+ "20 rows",
129
+ "24 rows",
130
+ ],
131
+ read: (v) =>
132
+ typeof v.maxRenderHeight === "number" ? `${v.maxRenderHeight} rows` : v.maxRenderHeight,
133
+ patch: (value) => ({ maxRenderHeight: parseRenderSize(value) }),
134
+ }),
74
135
  row({
75
136
  section: "Collapse",
76
137
  label: "enabled",
@@ -154,7 +215,6 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
154
215
  return;
155
216
  }
156
217
 
157
- const boxW = 52;
158
218
  await ui.custom(
159
219
  (
160
220
  tui: { requestRender(): void; terminal?: { rows?: number } },
@@ -208,7 +268,7 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
208
268
  };
209
269
 
210
270
  return {
211
- render: () => {
271
+ render: (width: number) => {
212
272
  const labelW = Math.max(...SETTINGS.map((r) => r.label.length));
213
273
  const body: string[] = [];
214
274
  const settingBodyLines: number[] = [];
@@ -230,8 +290,11 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
230
290
  body.push(`${cursor} ${label} ${theme.fg(isDefault ? "dim" : "success", value)}`);
231
291
  }
232
292
  const result = frameModal({
233
- width: boxW,
234
- maxHeight: modalHeight(tui.terminal?.rows),
293
+ width,
294
+ maxHeight: modalHeight(
295
+ tui.terminal?.rows,
296
+ runtime.get(prettySection).maxRenderHeight,
297
+ ),
235
298
  header: [theme.fg("accent", theme.bold(" pix settings")), ""],
236
299
  body,
237
300
  footer: [
@@ -282,7 +345,18 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
282
345
  },
283
346
  };
284
347
  },
285
- { overlay: true, overlayOptions: { anchor: "center", width: boxW, maxHeight: "80%" } },
348
+ {
349
+ overlay: true,
350
+ overlayOptions: () => {
351
+ const pretty = runtime.get(prettySection);
352
+ return {
353
+ anchor: "center",
354
+ width: pretty.maxRenderWidth,
355
+ maxHeight: pretty.maxRenderHeight,
356
+ margin: 2,
357
+ };
358
+ },
359
+ },
286
360
  );
287
361
  },
288
362
  });
@@ -309,9 +383,9 @@ interface RuntimeModalResult {
309
383
  maxBodyOffset: number;
310
384
  }
311
385
 
312
- function modalHeight(rows = 24): number {
386
+ function modalHeight(rows = 24, limit: RenderSize = "80%"): number {
313
387
  const safe = Number.isFinite(rows) ? Math.max(1, Math.floor(rows)) : 24;
314
- return Math.max(1, Math.min(safe, Math.floor(safe * 0.8)));
388
+ return Math.max(1, Math.min(safe, resolveRenderSize(limit, safe)));
315
389
  }
316
390
 
317
391
  function pageBodyOffset(offset: number, size: number, max: number, direction: -1 | 1): number {
@@ -20,6 +20,7 @@ export {
20
20
  type LsStyle,
21
21
  type PrettyConfig,
22
22
  prettySection,
23
+ type RenderSize,
23
24
  } from "./pretty.ts";
24
25
 
25
26
  import { collapseSection } from "./collapse.ts";
@@ -2,6 +2,7 @@ import { defineSection, enumOr, isObj, posNumOr } from "../schema.ts";
2
2
 
3
3
  export type IconMode = "nerd" | "unicode" | "ascii";
4
4
  export type LsStyle = "grid" | "tree";
5
+ export type RenderSize = number | `${number}%`;
5
6
 
6
7
  export interface DiffConfig {
7
8
  splitMinWidth: number;
@@ -11,6 +12,8 @@ export interface DiffConfig {
11
12
  export interface PrettyConfig {
12
13
  icons: IconMode;
13
14
  lsStyle: LsStyle;
15
+ maxRenderWidth: RenderSize;
16
+ maxRenderHeight: RenderSize;
14
17
  maxPreviewLines: number;
15
18
  maxRenderLines: number;
16
19
  maxHighlightChars: number;
@@ -21,9 +24,20 @@ export interface PrettyConfig {
21
24
  const ICON_MODES: readonly IconMode[] = ["nerd", "unicode", "ascii"];
22
25
  const LS_STYLES: readonly LsStyle[] = ["grid", "tree"];
23
26
 
27
+ function renderSizeOr(value: unknown, fallback: RenderSize): RenderSize {
28
+ if (typeof value === "number") return Number.isFinite(value) && value > 0 ? value : fallback;
29
+ if (typeof value !== "string") return fallback;
30
+ const match = value.match(/^(\d+(?:\.\d+)?)%$/);
31
+ if (!match) return fallback;
32
+ const percent = Number(match[1]);
33
+ return percent > 0 && percent <= 100 ? (value as `${number}%`) : fallback;
34
+ }
35
+
24
36
  const DEFAULTS: Readonly<PrettyConfig> = {
25
37
  icons: "nerd",
26
38
  lsStyle: "grid",
39
+ maxRenderWidth: "65%",
40
+ maxRenderHeight: "80%",
27
41
  maxPreviewLines: 80,
28
42
  maxRenderLines: 150,
29
43
  maxHighlightChars: 80_000,
@@ -40,6 +54,8 @@ export const prettySection = defineSection<"pretty", PrettyConfig>({
40
54
  return {
41
55
  icons: enumOr(raw.icons, ICON_MODES, DEFAULTS.icons),
42
56
  lsStyle: enumOr(raw.lsStyle, LS_STYLES, DEFAULTS.lsStyle),
57
+ maxRenderWidth: renderSizeOr(raw.maxRenderWidth, DEFAULTS.maxRenderWidth),
58
+ maxRenderHeight: renderSizeOr(raw.maxRenderHeight, DEFAULTS.maxRenderHeight),
43
59
  maxPreviewLines: posNumOr(raw.maxPreviewLines, DEFAULTS.maxPreviewLines),
44
60
  maxRenderLines: posNumOr(raw.maxRenderLines, DEFAULTS.maxRenderLines),
45
61
  maxHighlightChars: posNumOr(raw.maxHighlightChars, DEFAULTS.maxHighlightChars),