@xynogen/pix-runtime 0.5.2 → 0.6.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 +77 -0
- package/package.json +1 -1
- package/src/extension.ts +7 -0
- package/src/herdr-notify.ts +39 -0
- package/src/herdr-state.ts +116 -0
- package/src/index.ts +9 -0
- package/src/pix-command.ts +51 -27
- package/src/runtime.ts +14 -2
- package/src/sections/compaction.ts +19 -1
package/README.md
CHANGED
|
@@ -68,6 +68,83 @@ Collapse policy helpers:
|
|
|
68
68
|
import { shouldCollapse, collapseDelayMs } from "@xynogen/pix-runtime/collapse";
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
## Agent state and herdr notifications
|
|
72
|
+
|
|
73
|
+
### Agent-state coordinator
|
|
74
|
+
|
|
75
|
+
`src/herdr-state.ts` (exported from the package index) is a process-wide
|
|
76
|
+
coordinator that tracks whether the agent is `working`, `blocked`, or `idle`,
|
|
77
|
+
keyed per Pi `EventBus`. On every transition it emits a `pix:agent-state` event:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
{ state: "working" | "blocked" | "idle", message?: string, activities: number, blocks: number }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Two lease primitives drive it. Both return an idempotent release function:
|
|
84
|
+
|
|
85
|
+
- `beginAgentActivity(events, source, message?)` marks asynchronous work in
|
|
86
|
+
progress (for example a running subagent). State reports `working` while any
|
|
87
|
+
activity lease is open.
|
|
88
|
+
- `withAgentBlock(events, source, message, prompt)` holds `blocked` state for the
|
|
89
|
+
duration of an awaited `prompt()` and always releases it, even on throw. Blocks
|
|
90
|
+
take priority over activities, so state is `blocked` whenever any block lease is
|
|
91
|
+
open.
|
|
92
|
+
|
|
93
|
+
`bindAgentStateEvents(events)` replays the current state and answers
|
|
94
|
+
`pix:agent-state:request`. `resetAgentState(events)` clears all leases on session
|
|
95
|
+
shutdown.
|
|
96
|
+
|
|
97
|
+
Nested leases collapse to a single state: two open blocks still report `blocked`
|
|
98
|
+
until both release.
|
|
99
|
+
|
|
100
|
+
Consumers that open a block today: `pix-ask` (`ask_user`, "Waiting for user
|
|
101
|
+
answer"), `pix-gate` (approval prompts), and `pix-sudo` (root approval).
|
|
102
|
+
`pix-subagent` opens activity leases for running background agents.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { withAgentBlock, beginAgentActivity } from "@xynogen/pix-runtime";
|
|
106
|
+
|
|
107
|
+
// Hold blocked state while waiting on the user:
|
|
108
|
+
await withAgentBlock(pi.events, "ask_user", "Waiting for user answer", () => promptUser());
|
|
109
|
+
|
|
110
|
+
// Mark background work:
|
|
111
|
+
const done = beginAgentActivity(pi.events, "subagent", "Agent running");
|
|
112
|
+
// ... later ...
|
|
113
|
+
done();
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### herdr notification bridge
|
|
117
|
+
|
|
118
|
+
`src/herdr-notify.ts` (exported as `bindHerdrNotify`) is a leaf subscriber on
|
|
119
|
+
`pix:agent-state`. When the agent transitions INTO `blocked` it spawns:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
herdr notification show <message> --sound request
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
so a user away from their terminal gets a popup and sound. `request` is herdr's
|
|
126
|
+
built-in "needs attention" cue. The trigger is edge-triggered: it fires once per
|
|
127
|
+
entry into `blocked`, not repeatedly, and nested blocks stay a single
|
|
128
|
+
notification.
|
|
129
|
+
|
|
130
|
+
The bridge is fire-and-forget. The child is `detached`, `unref`'d, and
|
|
131
|
+
`stdio: "ignore"`, and a missing `herdr` binary is swallowed, so it never blocks
|
|
132
|
+
the prompt path or throws. herdr owns the toast's color, position, and sound via
|
|
133
|
+
its own server config (`[toast]` / `[notification]`); pix only reports the moment
|
|
134
|
+
and the message. Compaction and other autonomous work never enter `blocked`, so
|
|
135
|
+
they never notify.
|
|
136
|
+
|
|
137
|
+
It is wired automatically by the runtime extension: bound at session start,
|
|
138
|
+
unbound at shutdown. No manual setup beyond running inside a herdr pane.
|
|
139
|
+
|
|
140
|
+
Two environment variables control it:
|
|
141
|
+
|
|
142
|
+
- `HERDR_ENV` — herdr sets this to `1` inside its own pane. The bridge only runs
|
|
143
|
+
when `HERDR_ENV === "1"`; outside a herdr pane it is a no-op and spawns
|
|
144
|
+
nothing.
|
|
145
|
+
- `PIX_HERDR_NOTIFY` — set to `0` to silence notifications even inside a herdr
|
|
146
|
+
pane.
|
|
147
|
+
|
|
71
148
|
## Testing
|
|
72
149
|
|
|
73
150
|
```ts
|
package/package.json
CHANGED
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,
|
package/src/pix-command.ts
CHANGED
|
@@ -44,8 +44,14 @@ function formatTokens(tokens: number): string {
|
|
|
44
44
|
|
|
45
45
|
/** Parse a compact token label ("1M", "150k") back to an absolute count. */
|
|
46
46
|
function parseTokens(label: string): number {
|
|
47
|
-
const
|
|
48
|
-
|
|
47
|
+
const raw = label.trim();
|
|
48
|
+
const m = raw.match(/^([0-9]*\.?[0-9]+)\s*([km])?$/i);
|
|
49
|
+
if (!m) return Number.NaN;
|
|
50
|
+
const numStr = m[1] ?? "";
|
|
51
|
+
const n = Number.parseFloat(numStr);
|
|
52
|
+
if (!Number.isFinite(n)) return Number.NaN;
|
|
53
|
+
const suf = (m[2] ?? "").toLowerCase();
|
|
54
|
+
return suf === "m" ? Math.round(n * 1_000_000) : Math.round(n * 1000);
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
const SETTINGS: SettingRow<unknown>[] = [
|
|
@@ -93,7 +99,8 @@ const SETTINGS: SettingRow<unknown>[] = [
|
|
|
93
99
|
section: "Compaction",
|
|
94
100
|
label: "Trigger (% ctx)",
|
|
95
101
|
handle: compactionSection,
|
|
96
|
-
|
|
102
|
+
// 0–100 in 5% steps.
|
|
103
|
+
values: Array.from({ length: 21 }, (_, i) => String(i * 5)),
|
|
97
104
|
read: (v) => String(v.triggerPercent),
|
|
98
105
|
patch: (value) => ({ triggerPercent: Number(value) }),
|
|
99
106
|
}),
|
|
@@ -135,6 +142,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
135
142
|
pi.registerCommand("pix", {
|
|
136
143
|
description: "pix: open shared settings (edit pix.json)",
|
|
137
144
|
handler: async (_args: string, ctx: ExtensionCommandContext) => {
|
|
145
|
+
// Re-parse pix.json so section parsers remain the single validation source.
|
|
146
|
+
await runtime.reload({ origin: "command", source: "pix" });
|
|
138
147
|
const ui = ctx.ui as unknown as {
|
|
139
148
|
custom?: (f: unknown, opts?: unknown) => Promise<unknown>;
|
|
140
149
|
notify(m: string, t?: "info" | "warning" | "error"): void;
|
|
@@ -169,11 +178,29 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
169
178
|
const cycle = (direction: -1 | 1) => {
|
|
170
179
|
const row = SETTINGS[selected];
|
|
171
180
|
if (!row) return;
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
181
|
+
// Functional updater: the runtime queue serializes writes, and the
|
|
182
|
+
// callback sees the latest committed value — so rapid presses each
|
|
183
|
+
// advance exactly one step instead of collapsing on a stale snapshot.
|
|
184
|
+
const step = (current: unknown): unknown => {
|
|
185
|
+
const curVal = row.read(current);
|
|
186
|
+
let cur = row.values.indexOf(curVal);
|
|
187
|
+
// ponytail: unsupported custom values snap to section default on first change.
|
|
188
|
+
if (cur === -1) {
|
|
189
|
+
const def = row.read(row.handle.defaults);
|
|
190
|
+
const defIdx = row.values.indexOf(def);
|
|
191
|
+
cur = defIdx !== -1 ? defIdx - direction : -1;
|
|
192
|
+
}
|
|
193
|
+
const next = (cur + direction + row.values.length) % row.values.length;
|
|
194
|
+
const val = row.values[next];
|
|
195
|
+
if (val === undefined) return current;
|
|
196
|
+
return { ...(current as object), ...(row.patch(val) as object) };
|
|
197
|
+
};
|
|
198
|
+
// Re-render AFTER the commit lands; the synchronous render in
|
|
199
|
+
// handleInput fires before the queued write and shows a stale value.
|
|
200
|
+
void runtime
|
|
201
|
+
.update(row.handle, step, { origin: "command", source: "pix" })
|
|
202
|
+
.then(() => tui.requestRender())
|
|
203
|
+
.catch(() => ui.notify(`pix: failed to update ${row.label}`, "error"));
|
|
177
204
|
};
|
|
178
205
|
const move = (direction: -1 | 1) => {
|
|
179
206
|
selected = (selected + direction + SETTINGS.length) % SETTINGS.length;
|
|
@@ -187,7 +214,8 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
187
214
|
const settingBodyLines: number[] = [];
|
|
188
215
|
let lastSection = "";
|
|
189
216
|
for (let i = 0; i < SETTINGS.length; i++) {
|
|
190
|
-
const row = SETTINGS[i]
|
|
217
|
+
const row = SETTINGS[i];
|
|
218
|
+
if (!row) continue;
|
|
191
219
|
if (row.section !== lastSection) {
|
|
192
220
|
if (lastSection) body.push("");
|
|
193
221
|
body.push(theme.fg("dim", ` ${row.section}`));
|
|
@@ -241,20 +269,15 @@ export function registerPixCommand(pi: ExtensionAPI, runtime: PixRuntime): void
|
|
|
241
269
|
// matchesKey handles both legacy bytes and Kitty CSI-u encodings
|
|
242
270
|
// for letters and special keys alike — raw string compares like
|
|
243
271
|
// `data === "k"` silently fail under the Kitty keyboard protocol.
|
|
244
|
-
if (
|
|
245
|
-
else if (matchesKey(data, "j") || matchesKey(data, "down")) move(1);
|
|
246
|
-
else if (matchesKey(data, "h") || matchesKey(data, "left")) cycle(-1);
|
|
247
|
-
else if (
|
|
248
|
-
matchesKey(data, "l") ||
|
|
249
|
-
matchesKey(data, "right") ||
|
|
250
|
-
matchesKey(data, "space") ||
|
|
251
|
-
matchesKey(data, "enter")
|
|
252
|
-
)
|
|
253
|
-
cycle(1);
|
|
254
|
-
else if (matchesKey(data, "escape") || matchesKey(data, "q")) {
|
|
272
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
255
273
|
done(null);
|
|
256
274
|
return;
|
|
257
|
-
}
|
|
275
|
+
}
|
|
276
|
+
if (kb.matches(data, "tui.select.up")) move(-1);
|
|
277
|
+
else if (kb.matches(data, "tui.select.down")) move(1);
|
|
278
|
+
else if (matchesKey(data, Key.left)) cycle(-1);
|
|
279
|
+
else if (matchesKey(data, Key.right)) cycle(1);
|
|
280
|
+
else return;
|
|
258
281
|
tui.requestRender();
|
|
259
282
|
},
|
|
260
283
|
};
|
|
@@ -357,12 +380,13 @@ function frameRows(
|
|
|
357
380
|
const dashes = "─".repeat(width - 2);
|
|
358
381
|
const SENTINEL = "\x00";
|
|
359
382
|
const bgOpen = bg(SENTINEL).split(SENTINEL)[0] ?? "";
|
|
360
|
-
const reassert = (s: string): string =>
|
|
361
|
-
bgOpen
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
383
|
+
const reassert = (s: string): string => {
|
|
384
|
+
if (!bgOpen) return s;
|
|
385
|
+
return s.replace(/\x1b\[([0-9;]*)m/g, (seq, p: string) => {
|
|
386
|
+
if (p === "0" || p.split(";").includes("49")) return `${seq}${bgOpen}`;
|
|
387
|
+
return seq;
|
|
388
|
+
});
|
|
389
|
+
};
|
|
366
390
|
const row = (content: string): string => {
|
|
367
391
|
const fitted = truncateToWidth(content, inner, "…");
|
|
368
392
|
const padded = fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted)));
|
package/src/runtime.ts
CHANGED
|
@@ -347,12 +347,24 @@ class RuntimeImpl implements PixRuntime {
|
|
|
347
347
|
const values = new Map<string, unknown>();
|
|
348
348
|
for (const s of this.registry.all()) values.set(s.key, s.parse(base[s.key], ctx));
|
|
349
349
|
|
|
350
|
-
|
|
351
|
-
|
|
350
|
+
// Guard against a version-skewed second runtime copy whose registry
|
|
351
|
+
// lacks this section (e.g. an old npm copy handling a new handle). The
|
|
352
|
+
// functional updater would otherwise crash the whole process on
|
|
353
|
+
// `undefined`. Fall back to the handle's own parse of the raw doc.
|
|
354
|
+
const currentValue = (
|
|
355
|
+
values.has(section.key)
|
|
356
|
+
? values.get(section.key)
|
|
357
|
+
: section.__section.parse(base[section.key], ctx)
|
|
358
|
+
) as Readonly<T>;
|
|
359
|
+
const mergedValue =
|
|
352
360
|
typeof updater === "function"
|
|
353
361
|
? (updater as (c: Readonly<T>) => T)(currentValue)
|
|
354
362
|
: deepMerge(currentValue as T, updater);
|
|
355
363
|
|
|
364
|
+
// Re-validate through the section's own parse so a bad patch (NaN,
|
|
365
|
+
// Infinity, wrong type) can never enter the live snapshot.
|
|
366
|
+
const nextValue = section.__section.parse(mergedValue, ctx) as T;
|
|
367
|
+
|
|
356
368
|
if (JSON.stringify(currentValue) === JSON.stringify(nextValue)) return undefined;
|
|
357
369
|
values.set(section.key, nextValue);
|
|
358
370
|
|
|
@@ -31,17 +31,35 @@ function pctOr(v: unknown, fallback: number): number {
|
|
|
31
31
|
/** Require a finite token floor of at least 25k. */
|
|
32
32
|
function minimumTokensOr(v: unknown, fallback: number): number {
|
|
33
33
|
if (typeof v !== "number" || !Number.isFinite(v)) return fallback;
|
|
34
|
+
// Strings from older/hand-edited pix.json (e.g. "150k") → parse before clamping.
|
|
34
35
|
return Math.max(MINIMUM_TOKEN_FLOOR, Math.round(v));
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
function parseTokenString(label: string): number | undefined {
|
|
39
|
+
const raw = label.trim();
|
|
40
|
+
if (!raw) return undefined;
|
|
41
|
+
const m = raw.match(/^([0-9]*\.?[0-9]+)\s*([km])?$/i);
|
|
42
|
+
if (!m) return undefined;
|
|
43
|
+
const numStr = m[1] ?? "";
|
|
44
|
+
const n = Number.parseFloat(numStr);
|
|
45
|
+
if (!Number.isFinite(n)) return undefined;
|
|
46
|
+
const suf = (m[2] ?? "").toLowerCase();
|
|
47
|
+
if (suf === "m") return Math.round(n * 1_000_000);
|
|
48
|
+
if (suf === "k") return Math.round(n * 1000);
|
|
49
|
+
return Math.round(n);
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
export const compactionSection = defineSection<"compaction", CompactionConfig>({
|
|
38
53
|
key: "compaction",
|
|
39
54
|
defaults: DEFAULTS,
|
|
40
55
|
parse(raw) {
|
|
41
56
|
if (!isObj(raw)) return { ...DEFAULTS };
|
|
57
|
+
const rawMin = raw.minimumTokens;
|
|
58
|
+
const coercedMin =
|
|
59
|
+
typeof rawMin === "string" ? (parseTokenString(rawMin) ?? Number.NaN) : rawMin;
|
|
42
60
|
return {
|
|
43
61
|
triggerPercent: pctOr(raw.triggerPercent, DEFAULTS.triggerPercent),
|
|
44
|
-
minimumTokens: minimumTokensOr(
|
|
62
|
+
minimumTokens: minimumTokensOr(coercedMin, DEFAULTS.minimumTokens),
|
|
45
63
|
};
|
|
46
64
|
},
|
|
47
65
|
});
|