@adeildo/pi-harness 4.0.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.
@@ -0,0 +1,188 @@
1
+ // The clock behind the speeds. A run is everything between the prompt and the agent settling; a
2
+ // request is one call to the model inside it. Both are measured from the message events, because pi
3
+ // records usage without a duration.
4
+ //
5
+ // Two speeds per request, one for each direction:
6
+ // ↑ prefill: prompt tokens over the wait for the first token, how fast what we sent was taken in
7
+ // ↓ decode: output tokens over the time spent writing, how fast the answer comes back
8
+ import {
9
+ emptyTotals,
10
+ perSecond,
11
+ promptOf,
12
+ type RequestRecord,
13
+ type Totals,
14
+ usageTotals,
15
+ } from "./totals.ts";
16
+
17
+ /** A decode speed over less than this is noise, so it waits. */
18
+ const MIN_SAMPLE_MS = 250;
19
+ /** Close enough for a live number: the provider's real count replaces it at the end. */
20
+ const CHARS_PER_TOKEN = 4;
21
+
22
+ export interface RequestView {
23
+ /** The answer is still arriving. */
24
+ streaming: boolean;
25
+ /** Waiting for the first token right now. */
26
+ waiting: boolean;
27
+ /** Since the request left, frozen when it ends. */
28
+ elapsedMs: number;
29
+ firstTokenMs: number | undefined;
30
+ /** Output tokens per second, live while streaming. */
31
+ decode: number | undefined;
32
+ /** Prompt tokens per second of waiting, once the prompt size is known. */
33
+ prefill: number | undefined;
34
+ /** What this request used so far. */
35
+ usage: Totals;
36
+ /** The output count is an estimate until the provider reports it. */
37
+ estimated: boolean;
38
+ }
39
+
40
+ export interface RunView {
41
+ running: boolean;
42
+ elapsedMs: number;
43
+ /** Requests made in this run. */
44
+ requests: number;
45
+ }
46
+
47
+ interface Request {
48
+ startedAt: number;
49
+ firstTokenAt: number | undefined;
50
+ endedAt: number | undefined;
51
+ usage: Totals;
52
+ /** Characters seen, for a live rate before the provider says how many tokens they were. */
53
+ characters: number;
54
+ }
55
+
56
+ interface Run {
57
+ startedAt: number;
58
+ endedAt: number | undefined;
59
+ requests: number;
60
+ }
61
+
62
+ interface UsageLike {
63
+ input: number;
64
+ output: number;
65
+ cacheRead: number;
66
+ cacheWrite: number;
67
+ cost: { total: number };
68
+ }
69
+
70
+ export class Telemetry {
71
+ readonly #now: () => number;
72
+ #run: Run | undefined;
73
+ #request: Request | undefined;
74
+ /** The last request that finished, frozen, so a line can show numbers that no longer move. */
75
+ #last: RequestView | undefined;
76
+
77
+ constructor(now: () => number = Date.now) {
78
+ this.#now = now;
79
+ }
80
+
81
+ runStarted(): void {
82
+ this.#run = { startedAt: this.#now(), endedAt: undefined, requests: 0 };
83
+ }
84
+
85
+ runEnded(): void {
86
+ if (this.#run !== undefined && this.#run.endedAt === undefined) this.#run.endedAt = this.#now();
87
+ }
88
+
89
+ /** A turn starting is the request leaving. */
90
+ requestStarted(): void {
91
+ this.#request = {
92
+ startedAt: this.#now(),
93
+ firstTokenAt: undefined,
94
+ endedAt: undefined,
95
+ usage: emptyTotals(),
96
+ characters: 0,
97
+ };
98
+ if (this.#run === undefined || this.#run.endedAt !== undefined) this.runStarted();
99
+ if (this.#run !== undefined) this.#run.requests++;
100
+ }
101
+
102
+ /** The assistant message opening. Starts a request when no turn announced one. */
103
+ answerStarted(): void {
104
+ if (this.#request === undefined || this.#request.endedAt !== undefined) this.requestStarted();
105
+ }
106
+
107
+ /** A piece of the answer: text, thinking or a tool call being written. */
108
+ answerGrew(delta: string, usage: UsageLike): void {
109
+ const request = this.#request;
110
+ if (request === undefined || request.endedAt !== undefined) return;
111
+ request.usage = usageTotals(usage);
112
+ if (delta.length === 0) return;
113
+ request.characters += delta.length;
114
+ request.firstTokenAt ??= this.#now();
115
+ }
116
+
117
+ /** The answer is complete. Returns what the session keeps, or nothing when there was no answer. */
118
+ answerEnded(usage: UsageLike): RequestRecord | undefined {
119
+ const request = this.#request;
120
+ if (request === undefined || request.endedAt !== undefined) return undefined;
121
+
122
+ const now = this.#now();
123
+ request.endedAt = now;
124
+ request.usage = usageTotals(usage);
125
+ if (request.usage.output === 0 && request.firstTokenAt === undefined) return undefined;
126
+
127
+ const firstTokenAt = request.firstTokenAt ?? now;
128
+ this.#last = this.request();
129
+ return {
130
+ output: request.usage.output,
131
+ prompt: promptOf(request.usage),
132
+ firstTokenMs: firstTokenAt - request.startedAt,
133
+ generationMs: now - firstTokenAt,
134
+ };
135
+ }
136
+
137
+ /** The request running now, or the last one. */
138
+ request(): RequestView | undefined {
139
+ const request = this.#request;
140
+ if (request === undefined) return undefined;
141
+
142
+ const now = this.#now();
143
+ const streaming = request.endedAt === undefined;
144
+ const firstTokenMs =
145
+ request.firstTokenAt === undefined ? undefined : request.firstTokenAt - request.startedAt;
146
+ const estimated = streaming && request.usage.output === 0;
147
+ const output = estimated
148
+ ? Math.round(request.characters / CHARS_PER_TOKEN)
149
+ : request.usage.output;
150
+ const writingMs =
151
+ request.firstTokenAt === undefined ? 0 : (request.endedAt ?? now) - request.firstTokenAt;
152
+
153
+ return {
154
+ streaming,
155
+ waiting: streaming && request.firstTokenAt === undefined,
156
+ elapsedMs: (request.endedAt ?? now) - request.startedAt,
157
+ firstTokenMs,
158
+ decode: writingMs < MIN_SAMPLE_MS && streaming ? undefined : perSecond(output, writingMs),
159
+ prefill:
160
+ firstTokenMs === undefined ? undefined : perSecond(promptOf(request.usage), firstTokenMs),
161
+ usage: { ...request.usage, output },
162
+ estimated,
163
+ };
164
+ }
165
+
166
+ /** The last request that finished. Undefined until one has. */
167
+ last(): RequestView | undefined {
168
+ return this.#last;
169
+ }
170
+
171
+ /** The run going now, or the last one. */
172
+ run(): RunView | undefined {
173
+ const run = this.#run;
174
+ if (run === undefined) return undefined;
175
+ const running = run.endedAt === undefined;
176
+ return {
177
+ running,
178
+ elapsedMs: (run.endedAt ?? this.#now()) - run.startedAt,
179
+ requests: run.requests,
180
+ };
181
+ }
182
+
183
+ reset(): void {
184
+ this.#run = undefined;
185
+ this.#request = undefined;
186
+ this.#last = undefined;
187
+ }
188
+ }
@@ -0,0 +1,149 @@
1
+ // What a session spent and how fast it went, added up from its own entries. Pi keeps the same totals
2
+ // for its footer but does not export the helper, so this is the one place that sums them.
3
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
4
+
5
+ export interface Totals {
6
+ input: number;
7
+ output: number;
8
+ cacheRead: number;
9
+ cacheWrite: number;
10
+ cost: number;
11
+ }
12
+
13
+ interface UsageLike {
14
+ input: number;
15
+ output: number;
16
+ cacheRead: number;
17
+ cacheWrite: number;
18
+ cost: { total: number };
19
+ }
20
+
21
+ /**
22
+ * What one request to the model left behind. Pi records usage without a duration, so this entry is
23
+ * where the session speeds come from after a resume.
24
+ */
25
+ export interface RequestRecord {
26
+ /** Tokens the model wrote. */
27
+ output: number;
28
+ /** Tokens sent: fresh input plus what was read from and written to the cache. */
29
+ prompt: number;
30
+ /** From the request leaving to the first piece of the answer. */
31
+ firstTokenMs: number;
32
+ /** From the first piece of the answer to the last. */
33
+ generationMs: number;
34
+ }
35
+
36
+ export const REQUEST_ENTRY = "pi-look:request";
37
+ /** Written by this package before it was renamed. It has no `prompt`, so it only counts for decode. */
38
+ const LEGACY_ENTRY = "pi-statusline:turn";
39
+
40
+ export interface Averages {
41
+ /** Output tokens per second of writing, across the branch. */
42
+ decode: number | undefined;
43
+ /** Prompt tokens per second of waiting for the first token, across the branch. */
44
+ prefill: number | undefined;
45
+ }
46
+
47
+ export function emptyTotals(): Totals {
48
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
49
+ }
50
+
51
+ export function promptOf(totals: Totals): number {
52
+ return totals.input + totals.cacheRead + totals.cacheWrite;
53
+ }
54
+
55
+ export function tokensOf(totals: Totals): number {
56
+ return promptOf(totals) + totals.output;
57
+ }
58
+
59
+ export function usageTotals(usage: UsageLike): Totals {
60
+ const totals = emptyTotals();
61
+ add(totals, usage);
62
+ return totals;
63
+ }
64
+
65
+ /** The whole branch, not just the messages after the last compaction. */
66
+ export function totalsOf(entries: readonly SessionEntry[]): Totals {
67
+ const totals = emptyTotals();
68
+
69
+ for (const entry of entries) {
70
+ if (entry.type === "usage") add(totals, entry.usage);
71
+ else if (entry.type === "message" && entry.message.role === "assistant")
72
+ add(totals, entry.message.usage);
73
+ else if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.usage)
74
+ add(totals, entry.message.usage);
75
+ else if ((entry.type === "branch_summary" || entry.type === "compaction") && entry.usage)
76
+ add(totals, entry.usage);
77
+ }
78
+
79
+ return totals;
80
+ }
81
+
82
+ /** How much of the last prompt came from the cache, the way pi's own footer reports it. */
83
+ export function cacheHitPercent(entries: readonly SessionEntry[]): number | undefined {
84
+ for (let index = entries.length - 1; index >= 0; index--) {
85
+ const entry = entries[index];
86
+ if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
87
+
88
+ const usage = entry.message.usage;
89
+ const prompt = usage.input + usage.cacheRead + usage.cacheWrite;
90
+ return prompt === 0 ? undefined : (usage.cacheRead / prompt) * 100;
91
+ }
92
+ return undefined;
93
+ }
94
+
95
+ export function cacheWarming(entries: readonly SessionEntry[]): boolean {
96
+ for (let index = entries.length - 1; index >= 0; index--) {
97
+ const entry = entries[index];
98
+ if (entry?.type !== "message" || entry.message.role !== "assistant") continue;
99
+ return entry.message.usage.cacheRead === 0 && entry.message.usage.cacheWrite > 0;
100
+ }
101
+ return false;
102
+ }
103
+
104
+ export function averagesOf(entries: readonly SessionEntry[]): Averages {
105
+ let output = 0;
106
+ let generationMs = 0;
107
+ let prompt = 0;
108
+ let waitingMs = 0;
109
+
110
+ for (const entry of entries) {
111
+ if (entry.type !== "custom") continue;
112
+ if (entry.customType !== REQUEST_ENTRY && entry.customType !== LEGACY_ENTRY) continue;
113
+ const record = entry.data;
114
+ if (!isRecord(record)) continue;
115
+
116
+ output += record.output;
117
+ generationMs += record.generationMs;
118
+ if (typeof record.prompt === "number" && record.prompt > 0) {
119
+ prompt += record.prompt;
120
+ waitingMs += record.firstTokenMs;
121
+ }
122
+ }
123
+
124
+ return { decode: perSecond(output, generationMs), prefill: perSecond(prompt, waitingMs) };
125
+ }
126
+
127
+ export function perSecond(tokens: number, milliseconds: number): number | undefined {
128
+ return tokens > 0 && milliseconds > 0 ? tokens / (milliseconds / 1000) : undefined;
129
+ }
130
+
131
+ function isRecord(
132
+ data: unknown,
133
+ ): data is Omit<RequestRecord, "prompt"> & { prompt?: number | undefined } {
134
+ if (typeof data !== "object" || data === null) return false;
135
+ const record = data as Partial<RequestRecord>;
136
+ return (
137
+ typeof record.output === "number" &&
138
+ typeof record.firstTokenMs === "number" &&
139
+ typeof record.generationMs === "number"
140
+ );
141
+ }
142
+
143
+ function add(totals: Totals, usage: UsageLike): void {
144
+ totals.input += usage.input;
145
+ totals.output += usage.output;
146
+ totals.cacheRead += usage.cacheRead;
147
+ totals.cacheWrite += usage.cacheWrite;
148
+ totals.cost += usage.cost.total;
149
+ }
@@ -0,0 +1,207 @@
1
+ // The desktop palette as a pi theme. DankMaterialShell runs matugen over the wallpaper and keeps the
2
+ // result in one file: the Material roles for dark and light, and sixteen terminal colours. Pi wants
3
+ // 51 tokens. This maps one onto the other, so the terminal, the shell and pi share one palette.
4
+ //
5
+ // Every value comes from the file. Where Material has no role, like success or a diff line, the
6
+ // terminal colour of that hue stands in, and tinted backgrounds are a surface mixed with that hue.
7
+ import type { ThemeColor } from "@earendil-works/pi-coding-agent";
8
+
9
+ export const DESKTOP_THEME = "desktop";
10
+
11
+ export type Mode = "dark" | "light";
12
+
13
+ /** The parts of `dms-colors.json` the theme reads. */
14
+ export interface DesktopColors {
15
+ mode: Mode;
16
+ /** Material roles, like `primary` and `surface_container_high`. */
17
+ roles: Readonly<Record<string, string>>;
18
+ /** The sixteen terminal colours, `color0` to `color15`, for this mode. */
19
+ terminal: Readonly<Record<string, string>>;
20
+ }
21
+
22
+ export interface ThemeJson {
23
+ $schema: string;
24
+ name: string;
25
+ vars: Record<string, string>;
26
+ colors: Record<string, string>;
27
+ export: { pageBg: string; cardBg: string; infoBg: string };
28
+ }
29
+
30
+ const HEX = /^#[0-9a-f]{6}$/i;
31
+
32
+ /** Reads the file's JSON. Undefined when it is not the shape DankMaterialShell writes. */
33
+ export function parseDesktopColors(json: unknown, mode?: Mode): DesktopColors | undefined {
34
+ if (!isRecord(json) || !isRecord(json.colors) || !isRecord(json.dank16)) return undefined;
35
+ const chosen: Mode = mode ?? (json.mode === "light" ? "light" : "dark");
36
+ const roles = json.colors[chosen];
37
+ if (!isRecord(roles)) return undefined;
38
+
39
+ const terminal: Record<string, string> = {};
40
+ for (const [name, value] of Object.entries(json.dank16)) {
41
+ const hex = isRecord(value) ? (value[chosen] ?? value.default) : value;
42
+ if (typeof hex === "string" && HEX.test(hex)) terminal[name] = hex;
43
+ }
44
+
45
+ const material: Record<string, string> = {};
46
+ for (const [name, value] of Object.entries(roles)) {
47
+ if (typeof value === "string" && HEX.test(value)) material[name] = value;
48
+ }
49
+ return { mode: chosen, roles: material, terminal };
50
+ }
51
+
52
+ /** The roles and colours the mapping needs. A file missing any of them does not become a theme. */
53
+ const REQUIRED_ROLES = [
54
+ "primary",
55
+ "secondary",
56
+ "tertiary",
57
+ "error",
58
+ "error_container",
59
+ "outline",
60
+ "outline_variant",
61
+ "on_surface",
62
+ "on_surface_variant",
63
+ "surface",
64
+ "surface_container_low",
65
+ "surface_container",
66
+ "surface_container_high",
67
+ "surface_container_highest",
68
+ ];
69
+ const REQUIRED_TERMINAL = ["color1", "color2", "color3", "color6", "color9"];
70
+
71
+ export function missingColors(colors: DesktopColors): string[] {
72
+ return [
73
+ ...REQUIRED_ROLES.filter((role) => colors.roles[role] === undefined),
74
+ ...REQUIRED_TERMINAL.filter((name) => colors.terminal[name] === undefined),
75
+ ];
76
+ }
77
+
78
+ export function desktopTheme(colors: DesktopColors): ThemeJson {
79
+ const role = (name: string): string => colors.roles[name] ?? "#808080";
80
+ const term = (name: string): string => colors.terminal[name] ?? "#808080";
81
+
82
+ const vars: Record<string, string> = {
83
+ primary: role("primary"),
84
+ secondary: role("secondary"),
85
+ tertiary: role("tertiary"),
86
+ error: role("error"),
87
+ outline: role("outline"),
88
+ outlineVariant: role("outline_variant"),
89
+ text: role("on_surface"),
90
+ subtext: role("on_surface_variant"),
91
+ surface: role("surface"),
92
+ containerLow: role("surface_container_low"),
93
+ container: role("surface_container"),
94
+ containerHigh: role("surface_container_high"),
95
+ containerHighest: role("surface_container_highest"),
96
+ red: term("color1"),
97
+ green: term("color2"),
98
+ yellow: term("color3"),
99
+ cyan: term("color6"),
100
+ brightRed: term("color9"),
101
+ successBg: mix(role("surface_container_low"), term("color2"), 0.12),
102
+ errorBg: mix(role("surface_container_low"), role("error"), 0.14),
103
+ infoBg: mix(role("surface_container"), term("color3"), 0.1),
104
+ };
105
+
106
+ const colorsByToken: Record<ThemeColor | BackgroundToken, string> = {
107
+ accent: "primary",
108
+ border: "outline",
109
+ borderAccent: "primary",
110
+ borderMuted: "outlineVariant",
111
+ success: "green",
112
+ error: "error",
113
+ warning: "yellow",
114
+ muted: "subtext",
115
+ dim: "outline",
116
+ text: "text",
117
+ thinkingText: "subtext",
118
+
119
+ selectedBg: "containerHigh",
120
+ scrollbarTrack: "outlineVariant",
121
+ scrollbarThumb: "subtext",
122
+ searchMatchBg: "containerHighest",
123
+ searchMatchText: "text",
124
+ userMessageBg: "container",
125
+ userMessageText: "text",
126
+ customMessageBg: "containerLow",
127
+ customMessageText: "text",
128
+ customMessageLabel: "tertiary",
129
+ toolPendingBg: "containerLow",
130
+ toolSuccessBg: "successBg",
131
+ toolErrorBg: "errorBg",
132
+ toolTitle: "text",
133
+ toolOutput: "subtext",
134
+
135
+ mdHeading: "tertiary",
136
+ mdLink: "primary",
137
+ mdLinkUrl: "outline",
138
+ mdCode: "secondary",
139
+ mdCodeBlock: "text",
140
+ mdCodeBlockBorder: "outlineVariant",
141
+ mdQuote: "subtext",
142
+ mdQuoteBorder: "outlineVariant",
143
+ mdHr: "outlineVariant",
144
+ mdListBullet: "primary",
145
+
146
+ toolDiffAdded: "green",
147
+ toolDiffRemoved: "red",
148
+ toolDiffContext: "subtext",
149
+
150
+ syntaxComment: "outline",
151
+ syntaxKeyword: "primary",
152
+ syntaxFunction: "tertiary",
153
+ syntaxVariable: "text",
154
+ syntaxString: "green",
155
+ syntaxNumber: "yellow",
156
+ syntaxType: "cyan",
157
+ syntaxOperator: "subtext",
158
+ syntaxPunctuation: "subtext",
159
+
160
+ // The effort ramp runs from the quiet outline through the palette's own hues to the terminal
161
+ // reds, which keep their meaning whatever the wallpaper, so the frame warms up with the effort.
162
+ thinkingOff: "outlineVariant",
163
+ thinkingMinimal: "outline",
164
+ thinkingLow: "secondary",
165
+ thinkingMedium: "primary",
166
+ thinkingHigh: "tertiary",
167
+ thinkingXhigh: "brightRed",
168
+ thinkingMax: "red",
169
+
170
+ bashMode: "green",
171
+ };
172
+
173
+ return {
174
+ $schema:
175
+ "https://raw.githubusercontent.com/earendil-works/pi/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json",
176
+ name: DESKTOP_THEME,
177
+ vars,
178
+ colors: colorsByToken,
179
+ export: { pageBg: vars.surface ?? "", cardBg: vars.container ?? "", infoBg: vars.infoBg ?? "" },
180
+ };
181
+ }
182
+
183
+ type BackgroundToken =
184
+ | "selectedBg"
185
+ | "searchMatchBg"
186
+ | "userMessageBg"
187
+ | "customMessageBg"
188
+ | "toolPendingBg"
189
+ | "toolSuccessBg"
190
+ | "toolErrorBg";
191
+
192
+ /** `base` moved `amount` of the way towards `tint`, in sRGB, which is enough for a faint wash. */
193
+ export function mix(base: string, tint: string, amount: number): string {
194
+ const a = channels(base);
195
+ const b = channels(tint);
196
+ const mixed = a.map((value, index) => Math.round(value + ((b[index] ?? value) - value) * amount));
197
+ return `#${mixed.map((value) => value.toString(16).padStart(2, "0")).join("")}`;
198
+ }
199
+
200
+ function channels(hex: string): number[] {
201
+ const value = HEX.test(hex) ? hex.slice(1) : "808080";
202
+ return [0, 2, 4].map((offset) => Number.parseInt(value.slice(offset, offset + 2), 16));
203
+ }
204
+
205
+ function isRecord(value: unknown): value is Record<string, unknown> {
206
+ return typeof value === "object" && value !== null && !Array.isArray(value);
207
+ }
@@ -0,0 +1,86 @@
1
+ // Keeps `~/.pi/agent/themes/desktop.json` equal to the desktop palette. Pi reloads the active theme
2
+ // from that folder by itself, so changing the wallpaper recolours a running pi with no restart.
3
+ import { type FSWatcher, watch } from "node:fs";
4
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
5
+ import { homedir } from "node:os";
6
+ import { basename, dirname, join } from "node:path";
7
+
8
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
9
+
10
+ import { DESKTOP_THEME, desktopTheme, missingColors, parseDesktopColors } from "./palette.ts";
11
+
12
+ /** matugen writes a few files in a burst; one rewrite per burst is enough. */
13
+ const SETTLE_MS = 250;
14
+
15
+ export type SyncResult =
16
+ | { kind: "written"; path: string }
17
+ | { kind: "unchanged"; path: string }
18
+ | { kind: "missing" }
19
+ | { kind: "invalid"; reason: string };
20
+
21
+ export function themePath(): string {
22
+ return join(getAgentDir(), "themes", `${DESKTOP_THEME}.json`);
23
+ }
24
+
25
+ export function expandHome(path: string): string {
26
+ return path === "~" || path.startsWith("~/") ? join(homedir(), path.slice(1)) : path;
27
+ }
28
+
29
+ /** Writes the theme when the palette differs from what is on disk. */
30
+ export async function syncDesktopTheme(source: string): Promise<SyncResult> {
31
+ let raw: string;
32
+ try {
33
+ raw = await readFile(expandHome(source), "utf8");
34
+ } catch {
35
+ return { kind: "missing" };
36
+ }
37
+
38
+ let json: unknown;
39
+ try {
40
+ json = JSON.parse(raw);
41
+ } catch {
42
+ return { kind: "invalid", reason: "the palette file is not JSON" };
43
+ }
44
+
45
+ const colors = parseDesktopColors(json);
46
+ if (colors === undefined) return { kind: "invalid", reason: "not a DankMaterialShell palette" };
47
+ const missing = missingColors(colors);
48
+ if (missing.length > 0) return { kind: "invalid", reason: `missing ${missing.join(", ")}` };
49
+
50
+ const path = themePath();
51
+ const next = `${JSON.stringify(desktopTheme(colors), null, "\t")}\n`;
52
+ const current = await readFile(path, "utf8").catch(() => undefined);
53
+ if (current === next) return { kind: "unchanged", path };
54
+
55
+ await mkdir(dirname(path), { recursive: true });
56
+ await writeFile(path, next);
57
+ return { kind: "written", path };
58
+ }
59
+
60
+ /**
61
+ * Watches the folder rather than the file: tools that write atomically replace the file, and a
62
+ * watch on the old one goes quiet.
63
+ */
64
+ export function watchDesktopPalette(source: string, changed: () => void): () => void {
65
+ const path = expandHome(source);
66
+ let timer: ReturnType<typeof setTimeout> | undefined;
67
+ let watcher: FSWatcher | undefined;
68
+
69
+ try {
70
+ watcher = watch(dirname(path), (_event, file) => {
71
+ if (file !== null && file !== basename(path)) return;
72
+ if (timer !== undefined) clearTimeout(timer);
73
+ timer = setTimeout(changed, SETTLE_MS);
74
+ timer.unref?.();
75
+ });
76
+ watcher.unref?.();
77
+ watcher.on("error", () => watcher?.close());
78
+ } catch {
79
+ return () => {};
80
+ }
81
+
82
+ return () => {
83
+ if (timer !== undefined) clearTimeout(timer);
84
+ watcher?.close();
85
+ };
86
+ }