@aeondave/pi-persona 1.17.0 → 1.17.3

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.
Files changed (109) hide show
  1. package/AGENTS.md +79 -29
  2. package/CHANGELOG.md +264 -0
  3. package/README.md +27 -12
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +223 -57
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +189 -35
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +16 -11
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +251 -0
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +195 -136
  48. package/src/engine/child.ts +1 -1
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +11 -6
  51. package/src/engine/stream.ts +3 -0
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/companion-settings.ts +29 -0
  54. package/src/extension/engine.ts +43 -16
  55. package/src/extension/hooks.ts +106 -36
  56. package/src/extension/persona-model-settings.ts +336 -0
  57. package/src/extension/persona-settings.ts +459 -0
  58. package/src/extension/shared.ts +15 -7
  59. package/src/extension.ts +642 -267
  60. package/src/monitor/archive.ts +235 -0
  61. package/src/monitor/session.ts +21 -1
  62. package/src/orchestration/flow-journal.ts +26 -5
  63. package/src/orchestration/flow-run.ts +38 -13
  64. package/src/orchestration/flow.ts +9 -2
  65. package/src/orchestration/reducers.ts +9 -5
  66. package/src/orchestration/render.ts +1 -1
  67. package/src/orchestration/sdk.ts +17 -3
  68. package/src/orchestration/strategies/compete.ts +5 -1
  69. package/src/orchestration/strategies/council-rounds.ts +2 -0
  70. package/src/orchestration/strategies/critic-loop.ts +3 -0
  71. package/src/orchestration/strategies/debate.ts +2 -0
  72. package/src/orchestration/strategies/fanout.ts +3 -2
  73. package/src/orchestration/strategies/judge.ts +5 -1
  74. package/src/orchestration/strategies/magi.ts +40 -23
  75. package/src/orchestration/strategies/map.ts +13 -3
  76. package/src/orchestration/strategies/pair.ts +2 -0
  77. package/src/orchestration/strategies/pipeline.ts +2 -0
  78. package/src/orchestration/strategies/synthesize.ts +27 -7
  79. package/src/orchestration/strategy.ts +31 -0
  80. package/src/persona/config-store.ts +6 -195
  81. package/src/persona/model-participants.ts +1 -1
  82. package/src/persona/persona.ts +7 -3
  83. package/src/persona/settings-store.ts +636 -0
  84. package/src/telemetry/contract.ts +1 -1
  85. package/src/tools/background-run.ts +151 -0
  86. package/src/tools/council.ts +77 -52
  87. package/src/tools/delegate-tool.ts +315 -285
  88. package/src/tools/delegate.ts +22 -210
  89. package/src/tools/exocom-work.ts +18 -53
  90. package/src/tools/exocom.ts +15 -19
  91. package/src/tools/flow.ts +85 -28
  92. package/src/tools/intercom-tool.ts +108 -118
  93. package/src/tools/intercom.ts +1 -1
  94. package/src/tools/models.ts +19 -9
  95. package/src/tools/monitor.ts +39 -6
  96. package/src/tools/timer.ts +11 -13
  97. package/src/ui/agent-overlay.ts +55 -14
  98. package/src/ui/agent-tree.ts +84 -18
  99. package/src/ui/compact-widget.ts +25 -0
  100. package/src/ui/failure-summary.ts +55 -0
  101. package/src/ui/literal-tool-result.ts +24 -0
  102. package/src/ui/model-picker.ts +158 -81
  103. package/src/ui/persona-settings.ts +328 -0
  104. package/src/ui/presentation.ts +24 -0
  105. package/src/ui/report-body.ts +63 -0
  106. package/src/ui/session-cards.ts +189 -0
  107. package/src/ui/wake-rows.ts +262 -0
  108. package/teams.yaml +5 -1
  109. package/src/persona/state.ts +0 -28
@@ -0,0 +1,328 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Text,
4
+ truncateToWidth,
5
+ visibleWidth,
6
+ type Component,
7
+ type KeybindingsManager,
8
+ type TUI,
9
+ } from "@earendil-works/pi-tui";
10
+
11
+ import { compactInlineText, sanitizeTerminalText } from "./presentation.ts";
12
+ import type { SettingId, SettingKind } from "../core/settings.ts";
13
+
14
+ export interface SettingsPanelRow {
15
+ id: SettingId;
16
+ group: string;
17
+ label: string;
18
+ description: string;
19
+ kind: SettingKind;
20
+ choices?: readonly string[];
21
+ activeValue: unknown;
22
+ savedValue: unknown;
23
+ hasSavedValue: boolean;
24
+ source: "environment" | "saved" | "default";
25
+ locked: boolean;
26
+ pendingReload?: boolean;
27
+ applyWhen: string;
28
+ min?: number;
29
+ max?: number;
30
+ }
31
+
32
+ export interface SettingsPanelData {
33
+ file: string;
34
+ lastPersona: string | null;
35
+ rows: readonly SettingsPanelRow[];
36
+ notice?: string;
37
+ initialQuery?: string;
38
+ initialSelectedId?: SettingId;
39
+ }
40
+
41
+ export type SettingsPanelAction =
42
+ | { type: "edit"; id: SettingId }
43
+ | { type: "reset"; id: SettingId }
44
+ | { type: "models" }
45
+ | { type: "diagnostics" }
46
+ | { type: "close" };
47
+
48
+ /** Case-insensitive search across the registry's user-facing metadata; source order is retained. */
49
+ export function filterSettingsRows(rows: readonly SettingsPanelRow[], query: string): SettingsPanelRow[] {
50
+ const needle = sanitizeTerminalText(query).trim().toLocaleLowerCase();
51
+ if (!needle) return [...rows];
52
+ return rows.filter((row) => {
53
+ const haystack = [row.id, row.group, row.label, row.description, row.applyWhen]
54
+ .concat(row.choices ?? [])
55
+ .join(" ")
56
+ .toLocaleLowerCase();
57
+ return haystack.includes(needle);
58
+ });
59
+ }
60
+
61
+ /** Scroll offset that keeps the selected setting inside a bounded list viewport. */
62
+ export function visibleSettingsWindow(selected: number, size: number, total: number, previous: number): number {
63
+ const count = Math.max(0, Math.floor(total));
64
+ const page = Math.max(1, Math.floor(size));
65
+ if (count <= page) return 0;
66
+ const index = Math.max(0, Math.min(Math.floor(selected), count - 1));
67
+ let scroll = Math.max(0, Math.floor(previous));
68
+ if (index < scroll) scroll = index;
69
+ else if (index >= scroll + page) scroll = index - page + 1;
70
+ return Math.max(0, Math.min(scroll, count - page));
71
+ }
72
+
73
+ /** Human-readable, one-line values for settings rows and details. */
74
+ export function formatSettingValue(value: unknown): string {
75
+ if (value === undefined) return "(unset)";
76
+ if (value === null) return "(unset)";
77
+ if (typeof value === "boolean") return value ? "On" : "Off";
78
+ if (typeof value === "number") return Number.isFinite(value) ? String(value) : "(invalid number)";
79
+ if (Array.isArray(value)) {
80
+ if (value.length === 0) return "(empty list)";
81
+ return value.map((item) => compactInlineText(String(item), { maxChars: 160 })).join(", ");
82
+ }
83
+ if (typeof value === "string") return compactInlineText(value, { maxChars: 160 }) || "(empty)";
84
+ return compactInlineText(String(value), { maxChars: 160 }) || "(empty)";
85
+ }
86
+
87
+ /** One fully composed list entry, clipped by terminal columns rather than JS string length. */
88
+ export function composeSettingsRow(row: SettingsPanelRow, selected: boolean, maxColumns: number, theme: Theme): string {
89
+ const width = Math.max(1, Math.floor(maxColumns));
90
+ const marker = selected ? theme.fg("accent", "▸ ") : " ";
91
+ const group = theme.fg("dim", `[${compactInlineText(row.group, { maxChars: 32 })}] `);
92
+ const label = compactInlineText(row.label, { maxChars: Math.max(16, Math.floor(width * 0.55)) });
93
+ const value = compactInlineText(formatSettingValue(row.activeValue), { maxChars: Math.max(16, Math.floor(width * 0.44)) });
94
+ const source = row.locked ? theme.fg("warning", " env locked") : row.hasSavedValue ? theme.fg("dim", " saved") : "";
95
+ const valueText = theme.fg("dim", ` ${value}`);
96
+ return fitLine(`${marker}${group}${label}${valueText}${source}`, width);
97
+ }
98
+
99
+ function fitLine(line: string, width: number): string {
100
+ // Callers sanitize all registry, path, and stored-value text before applying theme roles. Keep
101
+ // those trusted theme escapes intact so the host palette survives truncation and layout.
102
+ // Do not normalize ordinary spaces: columnLines uses them to keep the detail column aligned.
103
+ const safe = line.replace(/[\r\n]+/g, " ");
104
+ if (visibleWidth(safe) <= width) return safe;
105
+ return truncateToWidth(safe, width, "…");
106
+ }
107
+
108
+ function wrappedLines(text: string, width: number): string[] {
109
+ const safe = sanitizeTerminalText(text).replace(/\r\n?/g, "\n");
110
+ const rendered = new Text(safe, 0, 0).render(Math.max(1, width));
111
+ return rendered.map((line) => fitLine(line, Math.max(1, width)));
112
+ }
113
+
114
+ function detailLines(row: SettingsPanelRow, width: number, limit: number, theme: Theme): string[] {
115
+ const safeWidth = Math.max(1, width);
116
+ const lines: string[] = [theme.fg("accent", theme.bold(compactInlineText(row.label, { maxChars: 160 })))];
117
+ lines.push(theme.fg("dim", `Group: ${compactInlineText(row.group, { maxChars: 100 })}`));
118
+ const fixedLines = row.pendingReload ? 7 : 6;
119
+ const description = wrappedLines(row.description, safeWidth).slice(0, Math.max(1, Math.min(3, limit - fixedLines)));
120
+ lines.push(...description);
121
+ lines.push(`Active now: ${formatSettingValue(row.activeValue)}`);
122
+ lines.push(`Saved override: ${row.hasSavedValue ? formatSettingValue(row.savedValue) : "none"}`);
123
+ lines.push(`Source: ${row.locked ? "environment · locked" : row.source}`);
124
+ if (row.pendingReload) lines.push("Status: saved value is pending /reload");
125
+ lines.push(`Applies: ${compactInlineText(row.applyWhen, { maxChars: 120 })}`);
126
+ if (row.locked && row.choices?.length) lines.push("Environment value must be unset to edit.");
127
+ return lines.slice(0, Math.max(1, limit)).map((line) => fitLine(line, safeWidth));
128
+ }
129
+
130
+ function listLines(
131
+ rows: readonly SettingsPanelRow[],
132
+ selectedIndex: number,
133
+ height: number,
134
+ width: number,
135
+ previousScroll: number,
136
+ theme: Theme,
137
+ ): { lines: string[]; scroll: number } {
138
+ const limit = Math.max(1, height);
139
+ if (rows.length === 0) return { lines: [theme.fg("dim", "No settings match this filter.")], scroll: 0 };
140
+ const needsMarkers = rows.length > limit;
141
+ const itemCount = Math.max(1, limit - (needsMarkers ? 2 : 0));
142
+ const scroll = visibleSettingsWindow(selectedIndex, itemCount, rows.length, previousScroll);
143
+ const end = Math.min(rows.length, scroll + itemCount);
144
+ const lines: string[] = [];
145
+ if (scroll > 0) lines.push(theme.fg("dim", `↑ ${scroll} settings above`));
146
+ for (let index = scroll; index < end; index++) {
147
+ const row = rows[index];
148
+ if (row) lines.push(composeSettingsRow(row, index === selectedIndex, width, theme));
149
+ }
150
+ if (end < rows.length) lines.push(theme.fg("dim", `↓ ${rows.length - end} settings below`));
151
+ return { lines: lines.slice(0, limit).map((line) => fitLine(line, width)), scroll };
152
+ }
153
+
154
+ function columnLines(left: readonly string[], right: readonly string[], leftWidth: number, rightWidth: number, width: number): string[] {
155
+ const count = Math.max(left.length, right.length);
156
+ const gap = " ";
157
+ const lines: string[] = [];
158
+ for (let index = 0; index < count; index++) {
159
+ const leftLine = fitLine(left[index] ?? "", leftWidth);
160
+ const pad = Math.max(0, leftWidth - visibleWidth(leftLine));
161
+ const rightLine = fitLine(right[index] ?? "", rightWidth);
162
+ lines.push(fitLine(`${leftLine}${" ".repeat(pad)}${gap}${rightLine}`, width));
163
+ }
164
+ return lines;
165
+ }
166
+
167
+ function isPrintable(key: string): boolean {
168
+ if (key.length !== 1) return false;
169
+ const code = key.charCodeAt(0);
170
+ return code >= 0x20 && code !== 0x7f;
171
+ }
172
+
173
+ /** Full-page settings workbench. Dialogs are returned as actions so command handlers can use Pi's native prompts. */
174
+ export class PersonaSettingsPanel implements Component {
175
+ private query: string;
176
+ private searchMode = false;
177
+ private selectedIndex = 0;
178
+ private scroll = 0;
179
+ private lastWidth = 80;
180
+ private readonly tui: TUI;
181
+ private readonly theme: Theme;
182
+ private readonly keybindings: KeybindingsManager;
183
+ private readonly data: SettingsPanelData;
184
+ private readonly onAction: (action: SettingsPanelAction) => void;
185
+
186
+ constructor(
187
+ tui: TUI,
188
+ theme: Theme,
189
+ keybindings: KeybindingsManager,
190
+ data: SettingsPanelData,
191
+ onAction: (action: SettingsPanelAction) => void,
192
+ ) {
193
+ this.tui = tui;
194
+ this.theme = theme;
195
+ this.keybindings = keybindings;
196
+ this.data = data;
197
+ this.onAction = onAction;
198
+ this.query = data.initialQuery ?? "";
199
+ const initialRows = filterSettingsRows(data.rows, this.query);
200
+ const initialIndex = data.initialSelectedId
201
+ ? initialRows.findIndex((row) => row.id === data.initialSelectedId)
202
+ : -1;
203
+ this.selectedIndex = initialIndex >= 0 ? initialIndex : 0;
204
+ }
205
+
206
+ get searchQuery(): string {
207
+ return this.query;
208
+ }
209
+
210
+ get selectedId(): SettingId | undefined {
211
+ return filterSettingsRows(this.data.rows, this.query)[this.selectedIndex]?.id;
212
+ }
213
+
214
+ invalidate(): void {
215
+ // Rendering is derived entirely from the small immutable snapshot passed by the command.
216
+ }
217
+
218
+ render(width: number): string[] {
219
+ const pageWidth = Math.max(1, Math.floor(width));
220
+ this.lastWidth = pageWidth;
221
+ const rows = filterSettingsRows(this.data.rows, this.query);
222
+ if (this.selectedIndex >= rows.length) this.selectedIndex = Math.max(0, rows.length - 1);
223
+ const selected = rows[this.selectedIndex];
224
+ const terminalRows = Math.max(8, Math.floor(this.tui.terminal.rows || 24));
225
+ const title = this.theme.fg("accent", this.theme.bold("Persona settings"));
226
+ const file = fitLine(this.theme.fg("dim", `Configuration file: ${compactInlineText(this.data.file, { maxChars: 240 })}`), pageWidth);
227
+ const remembered = fitLine(this.theme.fg("dim", `Remembered persona: ${this.data.lastPersona ? compactInlineText(this.data.lastPersona, { maxChars: 120 }) : "none"}`), pageWidth);
228
+ // Show the configured primary binding; listing every alias would hide close/edit on narrow screens.
229
+ const keys = (action: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel"): string =>
230
+ this.keybindings.getKeys(action).slice(0, 1).map((key) => key === "escape" ? "esc" : key).join("/") || "unbound";
231
+ const move = `${keys("tui.select.up")}/${keys("tui.select.down")}`;
232
+ const confirm = keys("tui.select.confirm");
233
+ const cancel = keys("tui.select.cancel");
234
+ const filter = this.searchMode
235
+ ? this.theme.fg("accent", `Filter (type, backspace, ${confirm}): ${compactInlineText(this.query, { maxChars: 120 })}▌`)
236
+ : this.query
237
+ ? this.theme.fg("dim", `Filter: ${compactInlineText(this.query, { maxChars: 120 })}`)
238
+ : this.theme.fg("dim", "Filter: press / to search");
239
+ const header = [fitLine(title, pageWidth), file, remembered, fitLine(filter, pageWidth), ""];
240
+ const footer = [
241
+ ...(this.data.notice ? [fitLine(this.theme.fg("warning", compactInlineText(this.data.notice, { maxChars: 200 })), pageWidth)] : []),
242
+ ...(pageWidth < 76
243
+ ? [`${move} move ${confirm} edit r reset`, `/ filter m models d info ${cancel} close`]
244
+ : [`${move} move ${confirm} edit r reset / filter m models d diagnostics ${cancel} close`])
245
+ .map((hint) => fitLine(this.theme.fg("dim", hint), pageWidth)),
246
+ ];
247
+ const bodyHeight = Math.max(1, terminalRows - header.length - footer.length);
248
+ let body: string[];
249
+ if (!selected) {
250
+ body = [this.theme.fg("dim", "No settings match this filter.")];
251
+ } else if (pageWidth >= 80) {
252
+ const leftWidth = Math.max(24, Math.floor((pageWidth - 2) * 0.42));
253
+ const rightWidth = Math.max(1, pageWidth - leftWidth - 2);
254
+ const listing = listLines(rows, this.selectedIndex, bodyHeight, leftWidth, this.scroll, this.theme);
255
+ this.scroll = listing.scroll;
256
+ const details = detailLines(selected, rightWidth, bodyHeight, this.theme);
257
+ body = columnLines(listing.lines, details, leftWidth, rightWidth, pageWidth);
258
+ } else {
259
+ const detailsBudget = Math.min(Math.max(3, Math.floor(bodyHeight * 0.55)), Math.max(1, bodyHeight - 1));
260
+ const listBudget = Math.max(1, bodyHeight - detailsBudget - 1);
261
+ const listing = listLines(rows, this.selectedIndex, listBudget, pageWidth, this.scroll, this.theme);
262
+ this.scroll = listing.scroll;
263
+ const details = detailLines(selected, pageWidth, Math.max(1, bodyHeight - listing.lines.length - 1), this.theme);
264
+ body = [...listing.lines, "", ...details].slice(0, bodyHeight);
265
+ }
266
+ const output = [...header, ...body, ...footer].slice(0, terminalRows);
267
+ return output.map((line) => fitLine(line, pageWidth));
268
+ }
269
+
270
+ handleInput(key: string): void {
271
+ const matches = (binding: "tui.select.up" | "tui.select.down" | "tui.select.confirm" | "tui.select.cancel"): boolean =>
272
+ this.keybindings.matches(key, binding);
273
+ const rows = filterSettingsRows(this.data.rows, this.query);
274
+ if (this.searchMode) {
275
+ if (matches("tui.select.cancel")) {
276
+ this.query = "";
277
+ this.searchMode = false;
278
+ this.selectedIndex = 0;
279
+ this.scroll = 0;
280
+ } else if (matches("tui.select.confirm")) {
281
+ this.searchMode = false;
282
+ } else if (key === "\x7f" || key === "\b") {
283
+ this.query = this.query.slice(0, -1);
284
+ this.selectedIndex = 0;
285
+ this.scroll = 0;
286
+ } else if (isPrintable(key) && key !== "/") {
287
+ this.query += key;
288
+ this.selectedIndex = 0;
289
+ this.scroll = 0;
290
+ }
291
+ this.tui.requestRender();
292
+ return;
293
+ }
294
+ if (matches("tui.select.cancel")) {
295
+ if (this.query) {
296
+ this.query = "";
297
+ this.selectedIndex = 0;
298
+ this.scroll = 0;
299
+ } else this.onAction({ type: "close" });
300
+ } else if (key === "/") {
301
+ this.searchMode = true;
302
+ } else if (matches("tui.select.up")) {
303
+ this.selectedIndex = Math.max(0, this.selectedIndex - 1);
304
+ } else if (matches("tui.select.down")) {
305
+ this.selectedIndex = Math.min(Math.max(0, rows.length - 1), this.selectedIndex + 1);
306
+ } else if (matches("tui.select.confirm")) {
307
+ const row = rows[this.selectedIndex];
308
+ if (row) this.onAction({ type: "edit", id: row.id });
309
+ } else if (key.toLocaleLowerCase() === "r") {
310
+ const row = rows[this.selectedIndex];
311
+ if (row) this.onAction({ type: "reset", id: row.id });
312
+ } else if (key.toLocaleLowerCase() === "m") {
313
+ this.onAction({ type: "models" });
314
+ } else if (key.toLocaleLowerCase() === "d") {
315
+ this.onAction({ type: "diagnostics" });
316
+ }
317
+ this.tui.requestRender();
318
+ }
319
+
320
+ /** Exposed to command handlers so reopening after a native dialog preserves navigation. */
321
+ get viewState(): { query: string; selectedId: SettingId | undefined } {
322
+ return { query: this.query, selectedId: this.selectedId };
323
+ }
324
+
325
+ get pageWidth(): number {
326
+ return this.lastWidth;
327
+ }
328
+ }
@@ -168,3 +168,27 @@ export function compactVisibleText(
168
168
  originalChars: input.length,
169
169
  };
170
170
  }
171
+
172
+ /**
173
+ * The prompt an operator reads before releasing a flow checkpoint: which gate, what runs next,
174
+ * and enough of the gated phase's output to decide. The prompt is modal, so it cannot point at an
175
+ * "expand" key; it states how much it left out instead.
176
+ */
177
+ export function gateReviewText(gate: {
178
+ flow: string;
179
+ phase: string;
180
+ /** The phases the approval releases. */
181
+ next: ReadonlyArray<{ id: string; strategy: string }>;
182
+ output: string;
183
+ }): string {
184
+ const name = (value: string): string => compactInlineText(value, { maxChars: 64 });
185
+ const body = compactVisibleText(gate.output, { maxLines: 12, maxLineChars: 100 });
186
+ return [
187
+ `Checkpoint "${name(gate.phase)}" of flow "${name(gate.flow)}" — approve and continue?`,
188
+ ...(gate.next.length > 0 ? [`then: ${gate.next.map((p) => `${name(p.id)} (${name(p.strategy)})`).join(", ")}`] : []),
189
+ // Everything below is the child's text: a fixed rule keeps it from passing as more prompt.
190
+ "--- gated output (untrusted) ---",
191
+ body.text,
192
+ ...(body.truncated ? [`(+${Math.max(0, gate.output.length - body.text.length)} characters not shown)`] : []),
193
+ ].join("\n");
194
+ }
@@ -0,0 +1,63 @@
1
+ /** UI-only prose projection. Never rewrites stored/model-facing text or unwraps trust fences. */
2
+ import { getMarkdownTheme, type Theme, type ThemeBg } from "@earendil-works/pi-coding-agent";
3
+ import { Markdown, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
4
+ import { sanitizeTerminalText } from "./presentation.ts";
5
+
6
+ interface ReportBodyOptions {
7
+ /** Trusted chrome stays literal Text, outside the Markdown body. Evaluated at paint time. */
8
+ header?: (width: number) => string;
9
+ /** Custom session cards only. Normal tools already have the host's shell. */
10
+ background?: ThemeBg;
11
+ padding?: number;
12
+ }
13
+
14
+ class ReportBody implements Component {
15
+ private readonly markdownTheme = getMarkdownTheme();
16
+ private readonly markdown: Markdown;
17
+ readonly source: string;
18
+ private theme: Theme;
19
+ private options: ReportBodyOptions;
20
+ constructor(source: string, theme: Theme, options: ReportBodyOptions) {
21
+ this.source = source;
22
+ this.theme = theme;
23
+ this.options = options;
24
+ // No defaultTextStyle: Pi caches its default style prefix independently of invalidate().
25
+ // Apply the fallback foreground/background outside Markdown so theme changes stay fresh.
26
+ this.markdown = new Markdown(source, 0, 0, this.markdownTheme);
27
+ }
28
+ update(theme: Theme, options: ReportBodyOptions): void {
29
+ this.theme = theme;
30
+ this.options = options;
31
+ this.invalidate();
32
+ }
33
+ invalidate(): void {
34
+ Object.assign(this.markdownTheme, getMarkdownTheme());
35
+ // Native Markdown retains its source-token cache while clearing themed/layout output.
36
+ this.markdown.invalidate();
37
+ }
38
+ render(width: number): string[] {
39
+ if (width <= 0) return [];
40
+ const padding = Math.min(Math.max(0, Math.floor(this.options.padding ?? 0)), Math.max(0, Math.floor((width - 1) / 2)));
41
+ const innerWidth = Math.max(1, width - 2 * padding);
42
+ const header = this.options.header?.(innerWidth);
43
+ const lines = [
44
+ ...(header ? new Text(header, 0, 0).render(innerWidth) : []),
45
+ ...this.markdown.render(innerWidth).map((line) => this.theme.fg("toolOutput", line)),
46
+ ];
47
+ const background = this.options.background;
48
+ return new Text(lines.join("\n"), padding, 0, background ? (text) => this.theme.bg(background, text) : undefined)
49
+ .render(width).map((line) => truncateToWidth(line, width));
50
+ }
51
+ }
52
+
53
+ /** Keep one native Markdown instance per report. Width changes reflow natively; invalidate() refreshes
54
+ * theme/layout, not source tokens. Tool callbacks can also reuse Pi's lastComponent across redraws.
55
+ * No global transcript/content cache: old reports are reclaimed with their host components. */
56
+ export function createReportBody(text: string, theme: Theme, options: ReportBodyOptions = {}, previous?: Component): Component {
57
+ const source = sanitizeTerminalText(text);
58
+ if (previous instanceof ReportBody && previous.source === source) {
59
+ previous.update(theme, options);
60
+ return previous;
61
+ }
62
+ return new ReportBody(source, theme, options);
63
+ }
@@ -0,0 +1,189 @@
1
+ /** Operator-only presentation of existing session payloads. No delivery, storage or tool shells. */
2
+ import type { ExtensionAPI, Theme, ThemeBg, ThemeColor } from "@earendil-works/pi-coding-agent";
3
+ import { Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
4
+ import { formatDuration } from "../core/time.ts";
5
+ import { neutralizeMonitorUnicode } from "../core/monitor-output.ts";
6
+ import { createReportBody } from "./report-body.ts";
7
+ import { MONITOR_LOG_ENTRY, parseMonitorLogRecord, type MonitorLogRecord } from "../monitor/archive.ts";
8
+ import { summarizeFailure } from "./failure-summary.ts";
9
+ import { compactInlineText, compactVisibleText, sanitizeTerminalText, type CompactVisibleTextResult } from "./presentation.ts";
10
+
11
+ type State = "neutral" | "success" | "attention" | "error";
12
+ const COLORS: Record<State, { bg: ThemeBg; fg: ThemeColor }> = {
13
+ neutral: { bg: "customMessageBg", fg: "accent" },
14
+ success: { bg: "toolSuccessBg", fg: "success" },
15
+ attention: { bg: "toolPendingBg", fg: "warning" },
16
+ error: { bg: "toolErrorBg", fg: "error" },
17
+ };
18
+ interface Card {
19
+ title: string;
20
+ state: State;
21
+ full: string;
22
+ preview: (width: number) => Pick<CompactVisibleTextResult, "text" | "truncated">;
23
+ metadata?: string;
24
+ /** Selected only by trusted surface/envelope metadata, never prose keywords. */
25
+ markdown?: boolean;
26
+ }
27
+
28
+ /** All seven surfaces share semantic chrome. Literal paths style at paint time; report bodies
29
+ * use native Markdown's invalidatable layout cache. Pi rebuilds callbacks on transcript invalidation. */
30
+ function createSessionCard(card: Card, expanded: boolean, theme: Theme, detailHint: () => string, outputPad = 0): Component {
31
+ if (expanded && card.markdown) {
32
+ return createReportBody(card.full || "(no output)", theme, {
33
+ padding: outputPad,
34
+ background: COLORS[card.state].bg,
35
+ header: (width) => [
36
+ theme.fg(COLORS[card.state].fg, theme.bold(truncateToWidth(sanitizeTerminalText(card.title), width))),
37
+ ...(card.metadata ? [theme.fg("dim", truncateToWidth(card.metadata, width))] : []),
38
+ ].join("\n"),
39
+ });
40
+ }
41
+ return {
42
+ invalidate() { /* Literal Text path has no themed/layout cache. */ },
43
+ render(width) {
44
+ if (width <= 0) return [];
45
+ const padding = Math.min(Math.max(0, Math.floor(outputPad)), Math.max(0, Math.floor((width - 1) / 2)));
46
+ const innerWidth = Math.max(1, width - padding * 2);
47
+ const colors = COLORS[card.state];
48
+ const preview = expanded ? undefined : card.preview(innerWidth);
49
+ const body = expanded ? card.full : preview?.text ?? "(no output)";
50
+ const lines = [
51
+ theme.fg(colors.fg, theme.bold(truncateToWidth(sanitizeTerminalText(card.title), innerWidth))),
52
+ ...(card.metadata ? [theme.fg("dim", truncateToWidth(card.metadata, innerWidth))] : []),
53
+ ...sanitizeTerminalText(body || "(no output)").split("\n").map((line) => theme.fg("toolOutput", line)),
54
+ ...(preview?.truncated ? [theme.fg("dim", truncateToWidth(detailHint(), innerWidth))] : []),
55
+ ];
56
+ // Text owns wrapping and full-row padding/background, including embedded foreground resets.
57
+ // A terminal narrower than a single wide glyph cannot display that glyph; clip only there.
58
+ return new Text(lines.join("\n"), padding, 0, (text) => theme.bg(colors.bg, text))
59
+ .render(width).map((line) => truncateToWidth(line, width));
60
+ },
61
+ };
62
+ }
63
+
64
+ function preview(full: string, maxLines: number, maxColumns: number): Card["preview"] {
65
+ return (width) => compactVisibleText(full, { maxLines, maxLineChars: Math.min(width, maxColumns) });
66
+ }
67
+
68
+ /** Only this exact runtime-owned aggregate is evidence of settlement. No keyword scan of output,
69
+ * asks, timers, monitor event bodies, or peers. Legacy/unknown follow-ups remain informational. */
70
+ function followUpState(full: string): { state: State; status: string } {
71
+ const first = full.split("\n", 1)[0] ?? "";
72
+ const summary = /^(?:\[pi-persona\] )?(\d+) async runs? settled — (\d+) done, (\d+) failed(?:, (\d+) stopped)?$/.exec(first);
73
+ if (summary) {
74
+ const total = Number(summary[1]);
75
+ const done = Number(summary[2]);
76
+ const failed = Number(summary[3]);
77
+ const stopped = Number(summary[4] ?? 0);
78
+ if ([total, done, failed, stopped].every(Number.isSafeInteger) && total > 0 && total === done + failed + stopped) {
79
+ if (failed > 0) return { state: "error", status: "failed" };
80
+ if (stopped > 0) return { state: "attention", status: "stopped" };
81
+ return { state: "success", status: "complete" };
82
+ }
83
+ }
84
+ return { state: "neutral", status: "info" };
85
+ }
86
+
87
+ function monitorState(record: MonitorLogRecord): { state: State; status: string } {
88
+ if (record.outcome === "error") return { state: "error", status: "failed" };
89
+ if (record.outcome === "timeout" || record.outcome === "cancelled") return { state: "attention", status: "stopped" };
90
+ if (record.outcome === "exit") {
91
+ // MonitorManager prepends this line before any untrusted stdout/stderr diagnostics.
92
+ const first = record.text.split("\n", 1)[0] ?? "";
93
+ const exit = /^process exited with exit code (-?\d+)$/.exec(first);
94
+ if (exit) return exit[1] === "0" ? { state: "success", status: "complete" } : { state: "error", status: "failed" };
95
+ if (/^process exited (?:by .+|with exit code -?\d+ \(signal .+\))$/.test(first)) return { state: "attention", status: "stopped" };
96
+ }
97
+ return { state: "neutral", status: "info" };
98
+ }
99
+
100
+ interface ResultEntry { label?: string; content?: string; ok?: boolean; stoppedAtGate?: string; failureKind?: string; error?: string }
101
+ interface ContentEntry { content?: string; kind?: "routine" | "stall" }
102
+ interface RunFailureEntry { id: string; label: string; agent: string; model?: string; durationMs?: number; error: string }
103
+
104
+ export interface SessionCardOptions {
105
+ detailHint: () => string;
106
+ /** Existing bounded/fenced runtime formatter, injected to keep UI independent of tool wiring. */
107
+ monitorLog: (record: MonitorLogRecord) => string;
108
+ }
109
+
110
+ export function registerSessionCards(pi: Pick<ExtensionAPI, "registerMessageRenderer" | "registerEntryRenderer">, options: SessionCardOptions): void {
111
+ const sessionCard = (card: Card, expanded: boolean, theme: Theme, outputPad = 0): Component =>
112
+ createSessionCard(card, expanded, theme, options.detailHint, outputPad);
113
+ for (const customType of ["pi-persona", "exocom_received"] as const) {
114
+ pi.registerMessageRenderer(customType, (message, { expanded, outputPad }, theme) => {
115
+ const raw = typeof message.content === "string" ? message.content
116
+ : message.content.map((part) => part.type === "text" ? part.text : "[image]").join("\n");
117
+ const full = sanitizeTerminalText(raw);
118
+ const { state, status } = customType === "pi-persona" ? followUpState(full) : { state: "neutral" as const, status: "inbound" };
119
+ // Only the validated settlement envelope is a pi-persona report. Unknown is NOT
120
+ // Markdown: monitor stdout/stderr, timers and operational notices share this type.
121
+ const markdown = customType === "exocom_received" || status !== "info";
122
+ return sessionCard({ title: customType === "pi-persona" ? `pi-persona · update · ${status}` : "exocom · inbound", state, full, markdown, preview: preview(full, 4, 100) }, expanded, theme, outputPad);
123
+ });
124
+ }
125
+ pi.registerEntryRenderer(MONITOR_LOG_ENTRY, (entry, { expanded }, theme) => {
126
+ const record = parseMonitorLogRecord(entry.data);
127
+ if (!record) return undefined;
128
+ const { state, status } = monitorState(record);
129
+ return sessionCard({
130
+ title: `Monitor log ${compactInlineText(record.entry.label)} · ${record.outcome} · ${status}`,
131
+ state, full: sanitizeTerminalText(options.monitorLog(record)),
132
+ preview: (width) => ({ text: `${compactInlineText(neutralizeMonitorUnicode(record.text), { maxChars: Math.min(width, 160) })}\n${compactInlineText(record.entry.id)} · monitor result`, truncated: true }),
133
+ }, expanded, theme);
134
+ });
135
+ pi.registerEntryRenderer<RunFailureEntry>("pi-persona-run-failure", (entry, { expanded }, theme) => {
136
+ const data = entry.data;
137
+ if (!data) return undefined;
138
+ const metadata = [compactInlineText(data.agent, { maxChars: 48 }), ...(data.model ? [compactInlineText(data.model, { maxChars: 96 })] : []), ...(data.durationMs !== undefined ? [formatDuration(data.durationMs)] : [])].join(" · ");
139
+ return sessionCard({
140
+ title: `${compactInlineText(data.label || data.agent, { maxChars: 80 })} · failed`, state: "error", metadata,
141
+ full: `Run ${compactInlineText(data.id)}\n${sanitizeTerminalText(data.error)}`,
142
+ preview: (width) => ({ text: compactVisibleText(summarizeFailure(data.error), { maxLines: 2, maxLineChars: Math.min(width, 100) }).text, truncated: true }),
143
+ }, expanded, theme);
144
+ });
145
+ pi.registerEntryRenderer<ResultEntry>("pi-persona-result", (entry, { expanded }, theme) => {
146
+ const data = entry.data;
147
+ if (!data) return undefined;
148
+ const full = sanitizeTerminalText(data.content || "(no output)");
149
+ const stopped = data.ok !== true && typeof data.stoppedAtGate === "string";
150
+ const state: State = stopped ? "attention" : data.ok === true ? "success" : data.ok === false ? "error" : "neutral";
151
+ const status = stopped ? `stopped · checkpoint ${compactInlineText(data.stoppedAtGate ?? "", { maxChars: 32 })}` : data.ok === true ? "complete" : data.ok === false ? "failed" : "unknown";
152
+ const cause = compactVisibleText([data.failureKind, data.error].filter(Boolean).join(" · "), { maxLines: 1, maxLineChars: 80 }).text;
153
+ return sessionCard({
154
+ title: `${compactInlineText(data.label ?? "", { maxChars: 96 }) || "result"} · ${status}${state === "error" && cause !== "(no output)" ? ` · ${cause}` : ""}`,
155
+ state, full, markdown: true, preview: preview(full, 3, 100),
156
+ }, expanded, theme);
157
+ });
158
+ pi.registerEntryRenderer<ContentEntry>("pi-persona-nudge", (entry, { expanded }, theme) => {
159
+ if (!entry.data) return undefined;
160
+ const full = sanitizeTerminalText(entry.data.content || "⟢ pi-persona · checkpoint");
161
+ const lines = full.split("\n").map((line) => line.trim()).filter(Boolean);
162
+ const header = compactInlineText(lines[0]?.replace(/^⟢\s*/, "") ?? "", { maxChars: 96 }) || "pi-persona · checkpoint";
163
+ const details = lines.slice(1);
164
+ const trigger = details.find((line) => line.startsWith("Trigger:"));
165
+ const action = details.find((line) => /^(?:Action|Verify|Otherwise):/.test(line));
166
+ const selected = [trigger, action].filter((line): line is string => line !== undefined);
167
+ const chosen = selected.length > 0 ? selected : details.slice(0, 2);
168
+ return sessionCard({
169
+ title: header, state: "attention", full,
170
+ preview: (width) => {
171
+ const result = preview(chosen.join("\n"), 2, 100)(width);
172
+ return { text: result.text, truncated: result.truncated || details.some((line) => !chosen.includes(line)) };
173
+ },
174
+ }, expanded, theme);
175
+ });
176
+ pi.registerEntryRenderer<ContentEntry>("pi-persona-status", (entry, { expanded }, theme) => {
177
+ if (!entry.data) return undefined;
178
+ const full = sanitizeTerminalText(entry.data.content || "Async activity · routine check-in");
179
+ const stall = entry.data.kind === "stall";
180
+ const digest = full.split("\n").slice(1).filter((line) => line.trim().length > 0);
181
+ return sessionCard({
182
+ title: stall ? "pi-persona · possible stall" : "pi-persona · async status", state: stall ? "attention" : "neutral", full,
183
+ preview: (width) => {
184
+ const result = preview(digest.slice(0, 2).join("\n") || full, 2, 140)(width);
185
+ return { text: result.text, truncated: result.truncated || digest.length > 2 };
186
+ },
187
+ }, expanded, theme);
188
+ });
189
+ }