@bermudi/pi-delegate 0.1.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/format.ts ADDED
@@ -0,0 +1,506 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { renderOutputForLLM } from "./spill.ts";
5
+ import type {
6
+ ResolvedTask,
7
+ TaskProgress,
8
+ TaskResult,
9
+ ToolActivity,
10
+ } from "./types.ts";
11
+
12
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
13
+
14
+ /** Return the current spinner glyph for live progress rendering. */
15
+ export function spinnerFrame(): string {
16
+ return SPINNER[Math.floor(Date.now() / 80) % SPINNER.length]!;
17
+ }
18
+
19
+ /** Get terminal width, clamped to a reasonable range. */
20
+ export function getTermWidth(): number {
21
+ return Math.max(40, Math.min(process.stdout.columns || 120, 200));
22
+ }
23
+
24
+ // Re-used segmenter for grapheme-aware truncation
25
+ const _segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
26
+ const _wideCharRe = /[\u{1100}-\u{10FFFF}]/u;
27
+ // Combining marks (NFD decomposition) — force slow path since length != display width
28
+ const _combiningRe =
29
+ /[\u0300-\u036F\u1AB0-\u1AFF\u1DC0-\u1DFF\u20D0-\u20FF\uFE20-\uFE2F]/u;
30
+
31
+ /** Return the display width of a single grapheme cluster. */
32
+ function charWidth(seg: string): number {
33
+ const cp = seg.codePointAt(0)!;
34
+ if (cp < 0x20) return 0; // control chars
35
+ if (cp === 0x7f) return 0; // DEL
36
+ if (cp >= 0x1100 && cp <= 0x115f) return 2; // Hangul Jamo
37
+ if (cp >= 0x2e80 && cp <= 0xa4cf) return 2; // CJK, Yi, etc.
38
+ if (cp >= 0xac00 && cp <= 0xd7a3) return 2; // Hangul Syllables
39
+ if (cp >= 0xf900 && cp <= 0xfaff) return 2; // CJK Compatibility Ideographs
40
+ if (cp >= 0xfe10 && cp <= 0xfe19) return 2; // Vertical forms
41
+ if (cp >= 0xfe30 && cp <= 0xfe6f) return 2; // CJK Compatibility Forms
42
+ if (cp >= 0xff00 && cp <= 0xff60) return 2; // Fullwidth ASCII variants
43
+ if (cp >= 0xffe0 && cp <= 0xffe6) return 2; // Fullwidth symbol variants
44
+ if (cp >= 0x20000 && cp <= 0x3fffd) return 2; // CJK Extensions B-I
45
+ if (cp >= 0x1f000 && cp <= 0x1fffd) return 2; // Symbols, emoticons, transport, etc.
46
+ if (cp >= 0xe0000 && cp <= 0xe007f) return 0; // Tags (invisible formatting)
47
+ // Note: ZWJ sequences (👨‍👩‍👧‍👦) and skin-tone modifiers (👍🏻) are handled
48
+ // by Intl.Segmenter as single graphemes. The base emoji code point
49
+ // determines width (typically 2), which is correct for display.
50
+ return 1;
51
+ }
52
+
53
+ /**
54
+ * Truncate a line to maxWidth, preserving ANSI styling through the ellipsis.
55
+ * Uses Intl.Segmenter for proper Unicode/emoji handling.
56
+ */
57
+ export function truncLine(text: string, maxWidth: number): string {
58
+ if (maxWidth <= 0) return "";
59
+
60
+ // Fast path: plain ASCII (no ANSI, no wide chars, no combining marks)
61
+ if (
62
+ !/\x1b\[[0-9;]*m/.test(text) &&
63
+ !_wideCharRe.test(text) &&
64
+ !_combiningRe.test(text)
65
+ ) {
66
+ if (text.length <= maxWidth) return text;
67
+ return text.slice(0, maxWidth - 1) + "…";
68
+ }
69
+
70
+ // Split on ANSI sequences so they remain atomic
71
+ const parts = text.split(/(\x1b\[[0-9;]*m)/);
72
+
73
+ // Pre-check: does the text fit without truncation?
74
+ let totalVis = 0;
75
+ for (const part of parts) {
76
+ if (/^\x1b\[[0-9;]*m$/.test(part)) continue;
77
+ for (const segment of _segmenter.segment(part)) {
78
+ totalVis += charWidth(segment.segment);
79
+ if (totalVis > maxWidth) break;
80
+ }
81
+ if (totalVis > maxWidth) break;
82
+ }
83
+ if (totalVis <= maxWidth) return text;
84
+
85
+ const target = maxWidth - 1; // reserve space for "…"
86
+ let result = "";
87
+ let vis = 0;
88
+ let activeStyles: string[] = [];
89
+
90
+ for (const part of parts) {
91
+ if (/^\x1b\[[0-9;]*m$/.test(part)) {
92
+ result += part;
93
+ if (part === "\x1b[0m" || part === "\x1b[m") activeStyles = [];
94
+ else activeStyles.push(part);
95
+ continue;
96
+ }
97
+
98
+ // Fast path: ASCII-only part that fits entirely (no combining marks)
99
+ if (
100
+ !_wideCharRe.test(part) &&
101
+ !_combiningRe.test(part) &&
102
+ vis + part.length <= target
103
+ ) {
104
+ result += part;
105
+ vis += part.length;
106
+ continue;
107
+ }
108
+
109
+ for (const segment of _segmenter.segment(part)) {
110
+ const seg = segment.segment;
111
+ const w = charWidth(seg);
112
+ if (vis + w > target)
113
+ return (
114
+ result +
115
+ activeStyles.join("") +
116
+ "…" +
117
+ (activeStyles.length ? "\x1b[0m" : "")
118
+ );
119
+ result += seg;
120
+ vis += w;
121
+ }
122
+ }
123
+
124
+ return result;
125
+ }
126
+
127
+ /**
128
+ * Apply a line budget so the TUI doesn't overflow the terminal.
129
+ * Returns lines trimmed to fit within `budget` visible rows.
130
+ */
131
+ export function applyLineBudget(lines: string[], expanded: boolean): string[] {
132
+ if (expanded) return [...lines]; // expanded shows everything
133
+ const rows = process.stdout.rows || 30;
134
+ const budget = Math.max(10, Math.min(18, Math.floor(rows * 0.4)));
135
+ if (lines.length <= budget) return [...lines];
136
+ const hidden = lines.length - budget + 1;
137
+ return [
138
+ ...lines.slice(0, budget - 1),
139
+ truncLine(`… ${hidden} lines hidden · Ctrl+O expands`, getTermWidth()),
140
+ ];
141
+ }
142
+
143
+ /** Replace the current home-directory prefix with `~` for display. */
144
+ export function shortenPath(p: string): string {
145
+ const home = process.env.HOME;
146
+ if (!home || home === "/") return p;
147
+ // Exact home match
148
+ if (p === home) return "~";
149
+ // Prefix check with separator to avoid /home/alice matching /home/alice2
150
+ const prefix = home.endsWith(path.sep) ? home : home + path.sep;
151
+ if (p.startsWith(prefix)) return "~" + path.sep + p.slice(prefix.length);
152
+ return p;
153
+ }
154
+
155
+ /** Human-readable activity age ("active now", "active 5s ago", etc.) */
156
+ export function getActivityAge(lastActivityAt: number | undefined): string {
157
+ if (lastActivityAt === undefined) return "";
158
+ const ago = Math.max(0, Date.now() - lastActivityAt);
159
+ if (ago < 1000) return "active now";
160
+ if (ago < 60000) return `active ${Math.floor(ago / 1000)}s ago`;
161
+ return `active ${Math.floor(ago / 60000)}m ago`;
162
+ }
163
+
164
+ /** Format milliseconds as a compact human-readable duration. */
165
+ export function fmtDuration(ms: number): string {
166
+ if (ms < 1000) return `${ms}ms`;
167
+ const s = ms / 1000;
168
+ if (s < 60) return `${s.toFixed(1)}s`;
169
+ const mins = Math.floor(s / 60);
170
+ const secs = Math.floor(s % 60);
171
+ return `${mins}m${secs}s`;
172
+ }
173
+
174
+ /** Format a token count using compact k notation above 1,000. */
175
+ export function fmtTokens(n: number): string {
176
+ return n < 1000
177
+ ? `${n}`
178
+ : n < 10000
179
+ ? `${(n / 1000).toFixed(1)}k`
180
+ : `${Math.round(n / 1000)}k`;
181
+ }
182
+
183
+ /** Truncate a string to at most `n` characters with an ellipsis. */
184
+ export function trunc(s: string, n: number): string {
185
+ return s.length <= n ? s : s.slice(0, n - 1) + "…";
186
+ }
187
+
188
+ /**
189
+ * Extract a single-line preview of agent output for collapsed final display.
190
+ *
191
+ * Collapsed mode can't afford the full markdown render (and the render cache is
192
+ * keyed to expanded width), so we pull a cheap plain-text first line instead.
193
+ * Skips leading blanks and strips common markdown markers (headings, bullets,
194
+ * code fences) so the preview is the first *meaningful* line of content.
195
+ * Returns "" for empty / "(no output)" — callers should omit the line entirely.
196
+ */
197
+ export function previewOutputLine(output: string, maxWidth: number): string {
198
+ if (maxWidth <= 0) return "";
199
+ const clean = output.trim();
200
+ if (!clean || clean === "(no output)") return "";
201
+ for (const raw of clean.split("\n")) {
202
+ const line = raw.trim();
203
+ if (!line) continue;
204
+ // Strip leading markdown noise so the preview reads as content, not markup.
205
+ const stripped = line
206
+ .replace(/^#{1,6}\s+/, "")
207
+ .replace(/^[-*+]\s+/, "")
208
+ .replace(/^\d+\.\s+/, "")
209
+ .replace(/^>\s*/, "")
210
+ .replace(/^```+.*$/, "")
211
+ .trim();
212
+ if (stripped) return truncLine(stripped, maxWidth);
213
+ }
214
+ return "";
215
+ }
216
+
217
+ /** Return the tree branch glyph for item `i` of `n`. */
218
+ export const tree = (i: number, n: number) => (i === n - 1 ? "└─" : "├─");
219
+ /** Return the continuation indentation for item `i` of `n`. */
220
+ export const indent = (i: number, n: number) => (i === n - 1 ? " " : "│ ");
221
+
222
+ // ── Tool Activity Formatting ─────────────────────────────────────────────
223
+
224
+ /** Pick the first non-empty arg value for display, preferring the named key then common fallbacks. */
225
+ function firstArg(
226
+ args: Record<string, unknown>,
227
+ primary: string,
228
+ fallbacks: string[] = [],
229
+ ): string | undefined {
230
+ for (const key of [primary, ...fallbacks]) {
231
+ const val = args[key];
232
+ if (typeof val === "string" && val.trim()) return val;
233
+ }
234
+ return undefined;
235
+ }
236
+
237
+ /** Render a compact, human-readable summary of a tool call. */
238
+ export function formatToolCallShort(
239
+ name: string,
240
+ args: Record<string, unknown>,
241
+ ): string {
242
+ if (!args || typeof args !== "object") return name;
243
+ switch (name) {
244
+ case "bash": {
245
+ const cmd = firstArg(args, "command") ?? "...";
246
+ const maxLen = 80;
247
+ return `$ ${cmd.length > maxLen ? cmd.slice(0, maxLen) + "…" : cmd}`;
248
+ }
249
+ case "read": {
250
+ const p = shortenPath(firstArg(args, "path", ["file_path"]) ?? "...");
251
+ const offset = typeof args.offset === "number" ? args.offset : undefined;
252
+ const limit = typeof args.limit === "number" ? args.limit : undefined;
253
+ let line = `read ${p}`;
254
+ if (offset !== undefined || limit !== undefined) {
255
+ const start = offset ?? 1;
256
+ const end = limit !== undefined ? start + limit - 1 : "";
257
+ line += `:${start}${end ? `-${end}` : ""}`;
258
+ }
259
+ return line;
260
+ }
261
+ case "write": {
262
+ const p = shortenPath(firstArg(args, "path", ["file_path"]) ?? "...");
263
+ const lines = String(args.content ?? "").split("\n").length;
264
+ return `write ${p}${lines > 1 ? ` (${lines} lines)` : ""}`;
265
+ }
266
+ case "edit": {
267
+ const p = shortenPath(firstArg(args, "path", ["file_path"]) ?? "...");
268
+ return `edit ${p}`;
269
+ }
270
+ default: {
271
+ // Try to pick a meaningful first arg before falling back to JSON
272
+ for (const key of [
273
+ "command",
274
+ "path",
275
+ "file_path",
276
+ "pattern",
277
+ "query",
278
+ "url",
279
+ "task",
280
+ "prompt",
281
+ ]) {
282
+ const val = args[key];
283
+ if (typeof val === "string" && val.trim()) {
284
+ const preview = val.length > 50 ? val.slice(0, 50) + "…" : val;
285
+ return `${name} ${preview}`;
286
+ }
287
+ }
288
+ try {
289
+ const preview = JSON.stringify(args).slice(0, 50);
290
+ return `${name} ${preview}${preview.length >= 50 ? "…" : ""}`;
291
+ } catch {
292
+ return name;
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * A session file is *resumable* iff `resumeFrom` would accept it: the file
300
+ * exists **and** contains at least one restorable message/custom_message entry
301
+ * on the leaf path (i.e. `buildSessionContext().messages.length > 0`). A
302
+ * header-only `.jsonl` — produced when a subagent's first model call dies
303
+ * before emitting any assistant message and the failure path force-flushed the
304
+ * header — is real on disk but rejected by `resumeFrom` with "empty session".
305
+ * Advertising those as resumable sends the parent to a dead path.
306
+ *
307
+ * This reads the file but short-circuits at the first restorable entry; on the
308
+ * failure path these files are typically tiny (header-only or near-empty).
309
+ */
310
+ function isResumableSessionFile(sessionFile: string): boolean {
311
+ if (!fs.existsSync(sessionFile)) return false;
312
+ try {
313
+ // Read line-by-line; a header-only file is one line. We only need to know
314
+ // whether any message-bearing entry exists — matches the gate resumeFrom
315
+ // applies (buildSessionContext().messages.length > 0) for the common case.
316
+ const content = fs.readFileSync(sessionFile, "utf8");
317
+ for (const line of content.split("\n")) {
318
+ if (!line) continue;
319
+ try {
320
+ const entry = JSON.parse(line) as { type?: string };
321
+ if (entry.type === "message" || entry.type === "custom_message") {
322
+ return true;
323
+ }
324
+ } catch {
325
+ /* skip malformed/trailing line */
326
+ }
327
+ }
328
+ } catch {
329
+ /* unreadable — treat as not resumable */
330
+ }
331
+ return false;
332
+ }
333
+
334
+ /**
335
+ * Render a failed or aborted task's result lines for LLM consumption.
336
+ *
337
+ * Single source of truth used by both the sync (execute) and async
338
+ * (formatCompletedTicket) render paths — previously this logic was duplicated
339
+ * and the two copies had drifted into the same bug: reporting a sessionFile
340
+ * path that didn't exist on disk.
341
+ *
342
+ * Emits:
343
+ * [FAILED|ABORTED: <error> · session: <shortpath> · touched: <files>]
344
+ * <partial output, when available>
345
+ * → To retry: delegate({ tasks: [{ resumeFrom: "<path>", prompt: "continue" }] })
346
+ *
347
+ * When a sessionFile is present but not actually resumable (file absent, or a
348
+ * header-only file that `resumeFrom` would reject as empty), emit an explicit
349
+ * notice instead of a retry hint — so the parent model is told to re-dispatch
350
+ * fresh rather than left to fabricate a path or chase a dead resume.
351
+ */
352
+ export function formatFailedTask(r: TaskResult, cwd?: string): string[] {
353
+ const parts: string[] = [];
354
+ const isAbort = /abort/i.test(r.error ?? "");
355
+ // Empty string is falsy but not nullish — `||` covers both undefined and "".
356
+ const failParts = [r.error || "unknown error"];
357
+ if (r.sessionFile) failParts.push(`session: ${shortenPath(r.sessionFile)}`);
358
+ const touched = cwd ? relativeTouchedSummary(r.touchedFiles, cwd) : null;
359
+ if (touched) failParts.push(`touched: ${touched}`);
360
+ parts.push(`[${isAbort ? "ABORTED" : "FAILED"}: ${failParts.join(" · ")}]`);
361
+
362
+ // Surface partial assistant output even when the task did not complete.
363
+ if (r.output && r.output !== "(no output)") {
364
+ parts.push(renderOutputForLLM(r.output, r.agent));
365
+ }
366
+
367
+ if (r.sessionFile && isResumableSessionFile(r.sessionFile)) {
368
+ const safePath = JSON.stringify(r.sessionFile);
369
+ // A model-attributable failure (usage limit, auth, quota) is not transient
370
+ // for the resolved model — point the parent at the `model` field so it
371
+ // resumes the same conversation on a different model instead of retrying
372
+ // the same wall or re-dispatching fresh (which loses the accumulated
373
+ // context). createAgentSession honors an explicit `model` over the
374
+ // session's stored model, so this works without any extra plumbing.
375
+ if (r.failureKind === "model_error") {
376
+ parts.push(
377
+ `→ To retry with a different model: delegate({ tasks: [{ resumeFrom: ${safePath}, model: "<alt-model>", prompt: "continue" }] })`,
378
+ );
379
+ } else {
380
+ parts.push(
381
+ `→ To retry: delegate({ tasks: [{ resumeFrom: ${safePath}, prompt: "continue" }] })`,
382
+ );
383
+ }
384
+ } else if (r.sessionFile) {
385
+ parts.push(`[no resumable session — re-dispatch as a fresh task]`);
386
+ }
387
+ return parts;
388
+ }
389
+
390
+ /**
391
+ * Render a single completed task's result lines for LLM consumption.
392
+ *
393
+ * Single source of truth for the per-task text block used by both the sync
394
+ * (execute) and async (formatCompletedTicket) render paths. Encapsulates the
395
+ * header line, task warnings, and the success/failure body — delegating
396
+ * failure rendering to {@link formatFailedTask}.
397
+ *
398
+ * Emits:
399
+ * === <agent>: <truncated prompt> ===
400
+ * [WARNING: <w>] (per warning, if any)
401
+ * [FAILED: ...] / [OK | <duration> | <tokens> tokens · <sessionFile> · touched: <files>]
402
+ *
403
+ * <output> (success body only)
404
+ *
405
+ * The caller owns the ticket-level header ("X/Y tasks completed...") and any
406
+ * PENDING handling — those differ between the sync and async paths.
407
+ */
408
+ export function formatCompletedTask(
409
+ task: ResolvedTask,
410
+ result: TaskResult,
411
+ ): string[] {
412
+ const parts: string[] = [];
413
+ // `|| task.action` covers action-only tasks (close/list/...) where prompt is
414
+ // empty. Async prompt tasks always set prompt, so this is a no-op there.
415
+ parts.push(
416
+ `=== ${result.agent}: ${trunc(task.prompt || task.action || "", 80)} ===`,
417
+ );
418
+ if (task.warnings?.length) {
419
+ for (const w of task.warnings) parts.push(`[WARNING: ${w}]`);
420
+ }
421
+ if (result.error) {
422
+ parts.push(...formatFailedTask(result, task.cwd));
423
+ } else {
424
+ const meta = [
425
+ `OK | ${fmtDuration(result.durationMs)} | ${fmtTokens(result.tokens)} tokens`,
426
+ ];
427
+ if (result.sessionFile) meta.push(shortenPath(result.sessionFile));
428
+ const touched = relativeTouchedSummary(result.touchedFiles, task.cwd);
429
+ if (touched) meta.push(`touched: ${touched}`);
430
+ parts.push(
431
+ `[${meta.join(" · ")}]\n\n${renderOutputForLLM(result.output, result.agent)}`,
432
+ );
433
+ }
434
+ return parts;
435
+ }
436
+
437
+ // ── Shared live-progress row helpers ───────────────────────────────────────
438
+ // These dedupe the per-task computations the LLM-facing poll view
439
+ // (tickets.handlePoll) and the TUI branches (render-branches) both need. Each is
440
+ // pure over TaskProgress/TaskResult, so it tests without a renderer.
441
+
442
+ /** The in-flight tool activity (no result yet), or null — the "current thing
443
+ * this task is doing". Shared by the poll view's running line and the TUI's
444
+ * compact activity line. */
445
+ export function inFlightActivity(p: TaskProgress): ToolActivity | null {
446
+ return p.activities.findLast((a) => !a.result) ?? null;
447
+ }
448
+
449
+ /** The latest activity for a task, whether in-flight or completed. */
450
+ export function latestActivity(p: TaskProgress): ToolActivity | null {
451
+ return p.activities.at(-1) ?? null;
452
+ }
453
+
454
+ /** Human-readable label for the current or latest activity.
455
+ * - "write src/foo.ts" when a tool is in-flight (even if a later-started tool
456
+ * already finished under parallel execution)
457
+ * - "last: read src/bar.ts" after a tool completes and the model is thinking
458
+ * - "thinking" when no activity has been recorded yet */
459
+ export function formatActivityLabel(p: TaskProgress): string {
460
+ const activity = inFlightActivity(p) ?? latestActivity(p);
461
+ if (!activity) return "thinking";
462
+ const call = formatToolCallShort(activity.name, activity.args);
463
+ if (!activity.result) return call;
464
+ return `last: ${call}`;
465
+ }
466
+
467
+ /** Compact TUI activity label. Uses the same current/latest-activity selection
468
+ * as {@link formatActivityLabel} but adds elapsed time for in-flight tools and
469
+ * a completion/error icon for finished ones. */
470
+ export function compactActivity(p: TaskProgress): string {
471
+ const activity = inFlightActivity(p) ?? latestActivity(p);
472
+ if (!activity) return "thinking…";
473
+ const call = formatToolCallShort(activity.name, activity.args);
474
+ if (!activity.result) {
475
+ const toolAge = fmtDuration(Date.now() - activity.startTime);
476
+ return `${call} | ${toolAge}`;
477
+ }
478
+ const icon = activity.result.isError ? "✗" : "✓";
479
+ return `${call} ${icon}`;
480
+ }
481
+
482
+ /** Per-task stats core: [duration, tokens]. Callers append medium-specific
483
+ * extras (touched files; themed join). */
484
+ export function taskMetaBase(r: TaskResult): string[] {
485
+ return [fmtDuration(r.durationMs), `${fmtTokens(r.tokens)} tokens`];
486
+ }
487
+
488
+ /** Pending-task waiting label: "queued (N running)" at the concurrency cap,
489
+ * else "waiting…". Shared by the two TUI branches. */
490
+ export function waitingLabel(runningCount: number, cap: number): string {
491
+ return runningCount >= cap ? `queued (${runningCount} running)` : "waiting…";
492
+ }
493
+
494
+ /** Touched-files summary relative to cwd ("src/a.ts, src/b.ts"), or null when
495
+ * none resolve under cwd. Was byte-for-byte duplicated in formatCompletedTask
496
+ * and tickets.handlePoll. */
497
+ export function relativeTouchedSummary(
498
+ files: string[],
499
+ cwd: string,
500
+ ): string | null {
501
+ if (!files.length) return null;
502
+ const rel = files
503
+ .map((f) => path.relative(cwd, f))
504
+ .filter((f) => f && !f.startsWith(".."));
505
+ return rel.length ? rel.join(", ") : null;
506
+ }
package/host-compat.ts ADDED
@@ -0,0 +1,73 @@
1
+ import * as piCodingAgent from "@earendil-works/pi-coding-agent";
2
+ import type { DelegateToolResult } from "./types.ts";
3
+
4
+ /**
5
+ * Symbols the dispatch / host-deps path dereferences. A `new`, `.create()`, or
6
+ * bare call on any of these crashes with a cryptic
7
+ * `Cannot read properties of undefined (reading '…')` if pi drops or renames it.
8
+ * Keep this list in sync with the actual import sites (host.ts, sessions.ts,
9
+ * lifecycle.ts, agents.ts).
10
+ */
11
+ const REQUIRED_SYMBOLS = [
12
+ "ModelRuntime",
13
+ "SettingsManager",
14
+ "SessionManager",
15
+ "DefaultResourceLoader",
16
+ "DefaultPackageManager",
17
+ "createAgentSession",
18
+ "getAgentDir",
19
+ "parseFrontmatter",
20
+ ] as const;
21
+
22
+ /**
23
+ * Build a tool result describing any required symbols missing from a pi
24
+ * namespace, or `null` if all are present. Pure (no I/O, no cache) so it can be
25
+ * unit-tested with a stub namespace.
26
+ */
27
+ export function hostCompatResult(
28
+ ns: Record<string, unknown>,
29
+ ): DelegateToolResult | null {
30
+ const missing = REQUIRED_SYMBOLS.filter((name) => ns[name] === undefined);
31
+ if (missing.length === 0) return null;
32
+ const listed = missing.map((m) => `'${m}'`).join(", ");
33
+ return {
34
+ content: [
35
+ {
36
+ type: "text",
37
+ text: [
38
+ "delegate extension: host compatibility check failed.",
39
+ "",
40
+ `pi no longer exports ${listed} from @earendil-works/pi-coding-agent.`,
41
+ "This is a version mismatch — the delegate bundle was built against a",
42
+ "pi version that has since removed or renamed these symbols (the same",
43
+ "class of regression that broke delegation across pi 0.80.3→0.80.8,",
44
+ "when `authStorage` + `modelRegistry` were folded into `modelRuntime`).",
45
+ "",
46
+ "Fix: from the pi-delegate repo run `bun install && bun run build`, then",
47
+ "`/reload` in Pi — or pin pi to a version compatible with this build.",
48
+ ].join("\n"),
49
+ },
50
+ ],
51
+ details: { tasks: [], results: [], progress: [] },
52
+ };
53
+ }
54
+
55
+ let cached: DelegateToolResult | null | undefined;
56
+
57
+ /**
58
+ * One-time compatibility check against the *installed* pi. The bundle marks the
59
+ * pi-* packages external, so this namespace resolves — via pi's jiti alias — to
60
+ * whatever pi is actually installed, which may differ from the build/typecheck
61
+ * target. Cached because module exports are immutable for the process lifetime.
62
+ */
63
+ export function hostCompatError(): DelegateToolResult | null {
64
+ if (cached === undefined) {
65
+ cached = hostCompatResult(piCodingAgent as Record<string, unknown>);
66
+ }
67
+ return cached;
68
+ }
69
+
70
+ /** Test-only: reset the cache (for exercising the detection path afresh). */
71
+ export function _resetHostCompatCacheForTesting(): void {
72
+ cached = undefined;
73
+ }