@aeondave/pi-persona 1.17.1 → 1.17.4

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 (146) hide show
  1. package/AGENTS.md +103 -64
  2. package/CHANGELOG.md +310 -1
  3. package/README.md +37 -23
  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 +280 -92
  15. package/docs/COLLABORATION.md +110 -0
  16. package/docs/EXPERIENCE.md +52 -17
  17. package/docs/MONITORS.md +62 -3
  18. package/docs/README.md +12 -0
  19. package/docs/REFERENCE.md +139 -38
  20. package/docs/SPINE.md +2 -2
  21. package/docs/STRATEGIES.md +23 -8
  22. package/docs/TELEMETRY.md +1 -0
  23. package/flows/deep-review.flow.json +1 -1
  24. package/package.json +15 -8
  25. package/personas/audit.md +28 -17
  26. package/personas/dev.md +30 -32
  27. package/personas/elite.md +223 -221
  28. package/personas/judge.md +25 -19
  29. package/personas/magi.md +26 -29
  30. package/personas/planner.md +31 -24
  31. package/personas/researcher.md +25 -26
  32. package/personas/swarm.md +35 -25
  33. package/personas/verify.md +30 -19
  34. package/src/agents/agent.ts +26 -0
  35. package/src/bridge.ts +28 -10
  36. package/src/bus/broker/client.ts +119 -21
  37. package/src/bus/broker/host.ts +123 -8
  38. package/src/bus/broker/messages.ts +31 -4
  39. package/src/bus/contact.ts +113 -38
  40. package/src/bus/peers.ts +15 -0
  41. package/src/bus/result-reader.ts +88 -0
  42. package/src/core/brief.ts +436 -353
  43. package/src/core/capabilities.ts +1 -1
  44. package/src/core/config.ts +15 -10
  45. package/src/core/leg-thinking.ts +57 -0
  46. package/src/core/model-catalog.ts +171 -0
  47. package/src/core/model-tier.ts +89 -0
  48. package/src/core/models.ts +13 -4
  49. package/src/core/monitor-output.ts +85 -0
  50. package/src/core/monitor.ts +34 -2
  51. package/src/core/nudge.ts +176 -23
  52. package/src/core/ownership.ts +1 -2
  53. package/src/core/pi-compat.ts +5 -12
  54. package/src/core/seed.ts +99 -6
  55. package/src/core/session-runs.ts +87 -0
  56. package/src/core/settings.ts +1 -1
  57. package/src/core/timer.ts +34 -9
  58. package/src/core/types.ts +1 -1
  59. package/src/engine/adapter.ts +95 -27
  60. package/src/engine/async.ts +213 -155
  61. package/src/engine/child.ts +12 -4
  62. package/src/engine/host-module.ts +12 -0
  63. package/src/engine/inproc.ts +99 -53
  64. package/src/engine/result-grant.ts +62 -0
  65. package/src/engine/session-tools.ts +6 -0
  66. package/src/engine/stream.ts +10 -20
  67. package/src/engine/worktree.ts +17 -7
  68. package/src/exocom/collaboration-delivery.ts +29 -0
  69. package/src/exocom/collaboration.ts +148 -0
  70. package/src/exocom/envelope.ts +43 -4
  71. package/src/exocom/gate.ts +7 -2
  72. package/src/exocom/install.ts +47 -18
  73. package/src/exocom/ledger.ts +58 -8
  74. package/src/exocom/plane.ts +32 -1
  75. package/src/exocom/profile.ts +40 -0
  76. package/src/exocom/registry.ts +5 -0
  77. package/src/exocom/status.ts +11 -1
  78. package/src/exocom/write-guard.ts +7 -0
  79. package/src/extension/broker-host.ts +61 -9
  80. package/src/extension/engine.ts +54 -28
  81. package/src/extension/hooks.ts +952 -868
  82. package/src/extension/identity.ts +41 -75
  83. package/src/extension/persona-model-settings.ts +31 -13
  84. package/src/extension/results.ts +136 -0
  85. package/src/extension/shared.ts +32 -7
  86. package/src/extension.ts +2965 -2611
  87. package/src/loader.ts +11 -3
  88. package/src/monitor/archive.ts +235 -0
  89. package/src/monitor/session.ts +21 -1
  90. package/src/orchestration/flow-journal.ts +33 -6
  91. package/src/orchestration/flow-run.ts +160 -32
  92. package/src/orchestration/flow.ts +20 -2
  93. package/src/orchestration/reducers.ts +9 -5
  94. package/src/orchestration/render.ts +1 -1
  95. package/src/orchestration/roster.ts +71 -23
  96. package/src/orchestration/sdk.ts +61 -11
  97. package/src/orchestration/strategies/compete.ts +5 -1
  98. package/src/orchestration/strategies/council-rounds.ts +2 -0
  99. package/src/orchestration/strategies/critic-loop.ts +3 -0
  100. package/src/orchestration/strategies/debate.ts +2 -0
  101. package/src/orchestration/strategies/fanout.ts +3 -2
  102. package/src/orchestration/strategies/judge.ts +5 -1
  103. package/src/orchestration/strategies/magi.ts +40 -23
  104. package/src/orchestration/strategies/map.ts +13 -3
  105. package/src/orchestration/strategies/pair.ts +2 -0
  106. package/src/orchestration/strategies/pipeline.ts +2 -0
  107. package/src/orchestration/strategies/synthesize.ts +27 -7
  108. package/src/orchestration/strategy.ts +31 -0
  109. package/src/orchestration/types.ts +10 -0
  110. package/src/persona/model-participants.ts +20 -7
  111. package/src/persona/orchestrate.ts +2 -0
  112. package/src/persona/persona.ts +8 -4
  113. package/src/results/archive.ts +281 -0
  114. package/src/results/record.ts +166 -0
  115. package/src/results/retrieval.ts +142 -0
  116. package/src/results/types.ts +128 -0
  117. package/src/telemetry/contract.ts +1 -1
  118. package/src/tools/background-run.ts +151 -0
  119. package/src/tools/council.ts +108 -66
  120. package/src/tools/delegate-tool.ts +349 -283
  121. package/src/tools/delegate.ts +35 -216
  122. package/src/tools/exocom-collaboration-input.ts +168 -0
  123. package/src/tools/exocom-collaboration.ts +315 -0
  124. package/src/tools/exocom-profile.ts +102 -0
  125. package/src/tools/exocom-work.ts +18 -53
  126. package/src/tools/exocom.ts +35 -22
  127. package/src/tools/flow.ts +103 -30
  128. package/src/tools/intercom-tool.ts +202 -128
  129. package/src/tools/intercom.ts +1 -1
  130. package/src/tools/models.ts +34 -9
  131. package/src/tools/monitor.ts +58 -6
  132. package/src/tools/read-result.ts +52 -0
  133. package/src/tools/timer.ts +34 -15
  134. package/src/ui/agent-overlay.ts +65 -16
  135. package/src/ui/agent-tree.ts +127 -19
  136. package/src/ui/compact-widget.ts +25 -0
  137. package/src/ui/failure-summary.ts +55 -0
  138. package/src/ui/literal-tool-result.ts +24 -0
  139. package/src/ui/model-picker.ts +158 -81
  140. package/src/ui/orchestration-launch.ts +49 -0
  141. package/src/ui/persona-settings.ts +9 -3
  142. package/src/ui/presentation.ts +24 -0
  143. package/src/ui/report-body.ts +63 -0
  144. package/src/ui/session-cards.ts +366 -0
  145. package/src/ui/wake-rows.ts +262 -0
  146. package/teams.yaml +5 -1
@@ -0,0 +1,366 @@
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, visibleWidth, 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
+ /** The delegate-card-family frame every surface in this file shares: a bold semantic
29
+ * `<Event> · <datum>` title, two-space-indented compact content lines, and the dim
30
+ * expand hint only when compact actually hides content. Payloads are never modified —
31
+ * this is projection-only (expanded still shows the complete sanitized payload). */
32
+ const INDENT = " ";
33
+ /** Compact budget: title + 5 body rows + optional hint keeps every collapsed card ≤ 7 rows. */
34
+ const COMPACT_BODY_ROWS = 4;
35
+
36
+ /** All seven surfaces share semantic chrome. Literal paths style at paint time; report bodies
37
+ * use native Markdown's invalidatable layout cache. Pi rebuilds callbacks on transcript invalidation. */
38
+ function createSessionCard(card: Card, expanded: boolean, theme: Theme, detailHint: () => string, outputPad = 0): Component {
39
+ if (expanded && card.markdown) {
40
+ return createReportBody(card.full || "(no output)", theme, {
41
+ padding: outputPad,
42
+ background: COLORS[card.state].bg,
43
+ header: (width) => [
44
+ theme.fg(COLORS[card.state].fg, theme.bold(truncateToWidth(sanitizeTerminalText(card.title), width))),
45
+ ...(card.metadata ? [theme.fg("dim", truncateToWidth(card.metadata, width))] : []),
46
+ ].join("\n"),
47
+ });
48
+ }
49
+ return {
50
+ invalidate() { /* Literal Text path has no themed/layout cache. */ },
51
+ render(width) {
52
+ if (width <= 0) return [];
53
+ const padding = Math.min(Math.max(0, Math.floor(outputPad)), Math.max(0, Math.floor((width - 1) / 2)));
54
+ const innerWidth = Math.max(1, width - padding * 2);
55
+ const colors = COLORS[card.state];
56
+ // The indent only applies where a clipped content column still fits; every compact line
57
+ // is clipped to the remaining columns so indent + text never wrap or spill mid-word.
58
+ const bodyPad = innerWidth > INDENT.length ? INDENT : "";
59
+ const contentWidth = Math.max(1, innerWidth - bodyPad.length);
60
+ const preview = expanded ? undefined : card.preview(contentWidth);
61
+ const body = expanded ? card.full : preview?.text ?? "(no output)";
62
+ const bodyLines = sanitizeTerminalText(body || "(no output)").split("\n").map((line) => expanded
63
+ ? theme.fg("toolOutput", line)
64
+ : theme.fg("toolOutput", `${bodyPad}${truncateToWidth(line, contentWidth)}`));
65
+ const lines = [
66
+ theme.fg(colors.fg, theme.bold(truncateToWidth(sanitizeTerminalText(card.title), innerWidth))),
67
+ ...(card.metadata ? [theme.fg("dim", truncateToWidth(`${bodyPad}${sanitizeTerminalText(card.metadata)}`, innerWidth))] : []),
68
+ ...bodyLines,
69
+ ...(preview?.truncated ? [theme.fg("dim", truncateToWidth(detailHint(), innerWidth))] : []),
70
+ ];
71
+ // Text owns wrapping and full-row padding/background, including embedded foreground resets.
72
+ // A terminal narrower than a single wide glyph cannot display that glyph; clip only there.
73
+ return new Text(lines.join("\n"), padding, 0, (text) => theme.bg(colors.bg, text))
74
+ .render(width).map((line) => truncateToWidth(line, width));
75
+ },
76
+ };
77
+ }
78
+
79
+ function preview(full: string, maxLines: number, maxColumns: number): Card["preview"] {
80
+ return (width) => compactVisibleText(full, { maxLines, maxLineChars: Math.min(width, maxColumns) });
81
+ }
82
+
83
+ /** Single source of truth for the settlement head grammar, shared by state selection and the
84
+ * compact projection below. The generator is `buildCompletionReport` in src/engine/async.ts. */
85
+ const SETTLEMENT_HEAD = /^(?:\[pi-persona\] )?(\d+) async runs? settled — (\d+) done, (\d+) failed(?:, (\d+) stopped)?$/;
86
+
87
+ /** Only this exact runtime-owned aggregate is evidence of settlement. No keyword scan of output,
88
+ * asks, timers, monitor event bodies, or peers. Legacy/unknown follow-ups remain informational. */
89
+ function followUpState(full: string): { state: State; status: string } {
90
+ const first = full.split("\n", 1)[0] ?? "";
91
+ const summary = SETTLEMENT_HEAD.exec(first);
92
+ if (summary) {
93
+ const total = Number(summary[1]);
94
+ const done = Number(summary[2]);
95
+ const failed = Number(summary[3]);
96
+ const stopped = Number(summary[4] ?? 0);
97
+ if ([total, done, failed, stopped].every(Number.isSafeInteger) && total > 0 && total === done + failed + stopped) {
98
+ if (failed > 0) return { state: "error", status: "failed" };
99
+ if (stopped > 0) return { state: "attention", status: "stopped" };
100
+ return { state: "success", status: "complete" };
101
+ }
102
+ }
103
+ return { state: "neutral", status: "info" };
104
+ }
105
+
106
+ function monitorState(record: MonitorLogRecord): { state: State; status: string } {
107
+ if (record.outcome === "error") return { state: "error", status: "failed" };
108
+ if (record.outcome === "timeout" || record.outcome === "cancelled") return { state: "attention", status: "stopped" };
109
+ if (record.outcome === "exit") {
110
+ // MonitorManager prepends this line before any untrusted stdout/stderr diagnostics.
111
+ const first = record.text.split("\n", 1)[0] ?? "";
112
+ const exit = /^process exited with exit code (-?\d+)$/.exec(first);
113
+ if (exit) return exit[1] === "0" ? { state: "success", status: "complete" } : { state: "error", status: "failed" };
114
+ if (/^process exited (?:by .+|with exit code -?\d+ \(signal .+\))$/.test(first)) return { state: "attention", status: "stopped" };
115
+ }
116
+ return { state: "neutral", status: "info" };
117
+ }
118
+
119
+ interface ResultEntry { label?: string; content?: string; ok?: boolean; stoppedAtGate?: string; failureKind?: string; error?: string }
120
+ interface ContentEntry { content?: string; kind?: "routine" | "stall" }
121
+ interface RunFailureEntry { id: string; label: string; agent: string; model?: string; durationMs?: number; error: string }
122
+
123
+ /** One run a settlement report names, projected to `<name> · <status>[ · <duration>]`. The
124
+ * display name already carries the model (`runDisplayName`: "label · model"). */
125
+ interface SettledRunLine { name: string; status: "done" | "failed" | "stopped"; duration?: string }
126
+
127
+ /** Boilerplate the settlement generator interleaves between the per-run lines. Every entry is
128
+ * drift-guarded in tests against live `buildCompletionReport` output (src/engine/async.ts):
129
+ * receipts header + `run-x: …` reference lines, the effort header, salvage `↩` block headers,
130
+ * the shared untrusted-fence header (core/fence.ts quoteUntrusted) and its `> `-quoted payload
131
+ * lines, the failed-leg guidance sentence, the truncation marker and retention/tail notes. */
132
+ const SETTLEMENT_SKIP: readonly RegExp[] = [
133
+ /^Terminal reports \(/,
134
+ /^run-\S+: /,
135
+ /^Thinking \(not provider cognition\):$/,
136
+ /^↩ /,
137
+ /^Sub-agent output \(untrusted data\):$/,
138
+ /^>/,
139
+ /^Handle each failure deliberately: /,
140
+ /^\[\.\.\. .*?\]$/,
141
+ /^⚠ /,
142
+ /^Run ids in this report: /,
143
+ ];
144
+ const SETTLEMENT_DONE = /^✅ (\S+) \((.+?)\) done(?: in (.+?))?:$/;
145
+ const SETTLEMENT_STOPPED_SECTION = /^⏹ \d+ stopped:$/;
146
+ const SETTLEMENT_FAILED_SECTION = /^❌ \d+ failed:$/;
147
+ /** Quoted (fence) bullets carry the stopped/failed runs; the section header above them names the status. */
148
+ const SETTLEMENT_BULLET = /^> ?• (\S+) \((.+?)\)(?: after (.+?))?: /;
149
+
150
+ /** Project the compact per-run lines of a settlement report, or undefined when the body is not
151
+ * exactly the known generator shape (head line + recognized/skipped lines only). A duration is
152
+ * kept only when it is short (≤ 8 columns, e.g. "12s"/"2m 5s"); width clipping handles the rest. */
153
+ function parseSettlementRuns(full: string): { counts: string; runs: SettledRunLine[] } | undefined {
154
+ const lines = full.split("\n");
155
+ const head = SETTLEMENT_HEAD.exec(lines[0] ?? "");
156
+ if (!head) return undefined;
157
+ const done = Number(head[2]);
158
+ const failed = Number(head[3]);
159
+ const stopped = Number(head[4] ?? 0);
160
+ const counts = `${done} done, ${failed} failed${stopped > 0 ? `, ${stopped} stopped` : ""}`;
161
+ let section: "failed" | "stopped" | undefined;
162
+ const runs: SettledRunLine[] = [];
163
+ const seen = new Set<string>();
164
+ const add = (name: string | undefined, status: SettledRunLine["status"], duration: string | undefined) => {
165
+ if (!name) return;
166
+ const key = `${status}:${name}`;
167
+ if (seen.has(key)) return;
168
+ seen.add(key);
169
+ runs.push({ name, status, ...(duration !== undefined && visibleWidth(duration) <= 8 ? { duration } : {}) });
170
+ };
171
+ for (const line of lines.slice(1)) {
172
+ const bullet = SETTLEMENT_BULLET.exec(line);
173
+ if (bullet) {
174
+ if (section === undefined) return undefined;
175
+ add(bullet[2], section, bullet[3]);
176
+ continue;
177
+ }
178
+ if (line.trim().length === 0 || SETTLEMENT_SKIP.some((skip) => skip.test(line))) continue;
179
+ const doneLine = SETTLEMENT_DONE.exec(line);
180
+ if (doneLine) {
181
+ section = undefined;
182
+ add(doneLine[2], "done", doneLine[3]);
183
+ continue;
184
+ }
185
+ if (SETTLEMENT_STOPPED_SECTION.test(line)) { section = "stopped"; continue; }
186
+ if (SETTLEMENT_FAILED_SECTION.test(line)) { section = "failed"; continue; }
187
+ return undefined; // unknown shape → the caller falls back to the generic bounded preview
188
+ }
189
+ return { counts, runs };
190
+ }
191
+
192
+ /** Compact projection of a settlement report: counts line + one line per named run, capped at
193
+ * COMPACT_BODY_ROWS (a `… +N more` tail row replaces the overflow). Undefined run list = fallback. */
194
+ function settlementPreview(full: string): Card["preview"] | undefined {
195
+ const parsed = parseSettlementRuns(full);
196
+ if (!parsed || parsed.runs.length === 0) return undefined;
197
+ return () => {
198
+ const lines = [parsed.counts, ...parsed.runs.map((run) => `${run.name} · ${run.status}${run.duration ? ` · ${run.duration}` : ""}`)];
199
+ const visible = lines.length <= COMPACT_BODY_ROWS ? lines : [...lines.slice(0, COMPACT_BODY_ROWS - 1), `… +${lines.length - (COMPACT_BODY_ROWS - 1)} more`];
200
+ return { text: visible.join("\n"), truncated: true };
201
+ };
202
+ }
203
+
204
+ interface StatusRunLine { name: string; detail: string; queued: boolean }
205
+
206
+ /** Skip-list for the peek-digest boilerplate the status card's title/frame replaces. Each entry
207
+ * is drift-guarded in tests against live `buildPeekDigest`/`buildCheckIn` output (src/engine/async.ts):
208
+ * the check-in header, the cumulative-token footnote and the omitted-runs tail. The
209
+ * `Async runs: N (M running)` header is recognized below (the title carries its counts). */
210
+ const STATUS_SKIP: readonly RegExp[] = [
211
+ /^Async activity · (?:routine check-in|possible stall)$/,
212
+ /^Cumulative tokens show /,
213
+ /^… \d+ additional async runs omitted/,
214
+ ];
215
+ const STATUS_RUNNING = /^\[([^\]]+)\] (.+?) — running \((?:(\d+) turns, )?(.+?) cumulative input \+ output tokens\)(.*)$/;
216
+ const STATUS_SETTLED = /^\[([^\]]+)\] (.+?) — (done|failed|stopped)(?: (?:in|after) (.+?))?(?::.*)?$/;
217
+ const STATUS_HEADER_COUNTS = /^Async runs: \d+ \((\d+) running\)$/;
218
+
219
+ /** Parse one peek digest into per-run compact lines (`<name> · 53t · 216k` for running legs,
220
+ * `<name> · done · 12s` for settled ones), or undefined when the shape is not the generator's. */
221
+ function parseStatusRuns(full: string): { running: number; queued: number; runs: StatusRunLine[] } | undefined {
222
+ let running: number | undefined;
223
+ let queued = 0;
224
+ const runs: StatusRunLine[] = [];
225
+ for (const line of full.split("\n")) {
226
+ if (line.trim().length === 0 || STATUS_SKIP.some((skip) => skip.test(line))) continue;
227
+ const head = STATUS_HEADER_COUNTS.exec(line);
228
+ if (head) {
229
+ if (running !== undefined) return undefined;
230
+ running = Number(head[1]);
231
+ continue;
232
+ }
233
+ const runLine = STATUS_RUNNING.exec(line);
234
+ if (runLine) {
235
+ const [, , name, turns, tokens, suffix] = runLine;
236
+ const isQueued = /· queued \(/.test(suffix ?? "");
237
+ if (isQueued) queued++;
238
+ const usage = `${tokens ?? "0"} cumulative input + output tokens`;
239
+ const detail = isQueued ? `queued · ${usage}` : `${turns !== undefined ? `${turns} turns · ` : ""}${usage}`;
240
+ runs.push({ name: name ?? "", detail, queued: isQueued });
241
+ continue;
242
+ }
243
+ const settledLine = STATUS_SETTLED.exec(line);
244
+ if (settledLine) {
245
+ const [, , name, status, duration] = settledLine;
246
+ runs.push({ name: name ?? "", detail: duration !== undefined ? `${status} · ${duration}` : status ?? "", queued: false });
247
+ continue;
248
+ }
249
+ return undefined; // unknown shape → the caller falls back to the generic bounded preview
250
+ }
251
+ if (running === undefined || runs.length === 0) return undefined;
252
+ return { running, queued, runs };
253
+ }
254
+
255
+ /** Compact projection of the periodic status digest, capped like the settlement preview. */
256
+ function statusPreview(full: string): Card["preview"] | undefined {
257
+ const parsed = parseStatusRuns(full);
258
+ if (!parsed) return undefined;
259
+ return () => {
260
+ const lines = parsed.runs.map((run) => `${run.name} · ${run.detail}`);
261
+ const visible = lines.length <= COMPACT_BODY_ROWS ? lines : [...lines.slice(0, COMPACT_BODY_ROWS - 1), `… +${lines.length - (COMPACT_BODY_ROWS - 1)} more`];
262
+ return { text: visible.join("\n"), truncated: true };
263
+ };
264
+ }
265
+
266
+ export interface SessionCardOptions {
267
+ detailHint: () => string;
268
+ /** Existing bounded/fenced runtime formatter, injected to keep UI independent of tool wiring. */
269
+ monitorLog: (record: MonitorLogRecord) => string;
270
+ }
271
+
272
+ export function registerSessionCards(pi: Pick<ExtensionAPI, "registerMessageRenderer" | "registerEntryRenderer">, options: SessionCardOptions): void {
273
+ const sessionCard = (card: Card, expanded: boolean, theme: Theme, outputPad = 0): Component =>
274
+ createSessionCard(card, expanded, theme, options.detailHint, outputPad);
275
+ for (const customType of ["pi-persona", "exocom_received"] as const) {
276
+ pi.registerMessageRenderer(customType, (message, { expanded, outputPad }, theme) => {
277
+ const raw = typeof message.content === "string" ? message.content
278
+ : message.content.map((part) => part.type === "text" ? part.text : "[image]").join("\n");
279
+ const full = sanitizeTerminalText(raw);
280
+ const { state, status } = customType === "pi-persona" ? followUpState(full) : { state: "neutral" as const, status: "inbound" };
281
+ // Only the validated settlement envelope is a pi-persona report. Unknown is NOT
282
+ // Markdown: monitor stdout/stderr, timers and operational notices share this type.
283
+ const markdown = customType === "exocom_received" || status !== "info";
284
+ const projected = customType === "pi-persona" ? settlementPreview(full) : undefined;
285
+ return sessionCard({
286
+ title: customType === "pi-persona" ? `Update · ${status}` : "Exocom · inbound",
287
+ state, full, markdown,
288
+ preview: projected ?? preview(full, 4, 100),
289
+ }, expanded, theme, outputPad);
290
+ });
291
+ }
292
+ pi.registerEntryRenderer(MONITOR_LOG_ENTRY, (entry, { expanded }, theme) => {
293
+ const record = parseMonitorLogRecord(entry.data);
294
+ if (!record) return undefined;
295
+ const { state, status } = monitorState(record);
296
+ return sessionCard({
297
+ title: `Monitor · ${compactInlineText(record.entry.label)}`,
298
+ state, metadata: `${record.outcome} · ${status} · ${compactInlineText(record.entry.id)}`,
299
+ full: sanitizeTerminalText(options.monitorLog(record)),
300
+ preview: (width) => ({ text: `${compactInlineText(neutralizeMonitorUnicode(record.text), { maxChars: Math.min(width, 160) })}\n${compactInlineText(record.entry.id)} · monitor result`, truncated: true }),
301
+ }, expanded, theme);
302
+ });
303
+ pi.registerEntryRenderer<RunFailureEntry>("pi-persona-run-failure", (entry, { expanded }, theme) => {
304
+ const data = entry.data;
305
+ if (!data) return undefined;
306
+ const metadata = [compactInlineText(data.agent, { maxChars: 48 }), ...(data.model ? [compactInlineText(data.model, { maxChars: 96 })] : []), ...(data.durationMs !== undefined ? [formatDuration(data.durationMs)] : [])].join(" · ");
307
+ return sessionCard({
308
+ title: `Failed · ${compactInlineText(data.label || data.agent, { maxChars: 80 })}`, state: "error", metadata,
309
+ full: `Run ${compactInlineText(data.id)}\n${sanitizeTerminalText(data.error)}`,
310
+ preview: (width) => ({ text: compactVisibleText(summarizeFailure(data.error), { maxLines: 2, maxLineChars: Math.min(width, 100) }).text, truncated: true }),
311
+ }, expanded, theme);
312
+ });
313
+ pi.registerEntryRenderer<ResultEntry>("pi-persona-result", (entry, { expanded }, theme) => {
314
+ const data = entry.data;
315
+ if (!data) return undefined;
316
+ const full = sanitizeTerminalText(data.content || "(no output)");
317
+ const stopped = data.ok !== true && typeof data.stoppedAtGate === "string";
318
+ const state: State = stopped ? "attention" : data.ok === true ? "success" : data.ok === false ? "error" : "neutral";
319
+ const status = stopped ? `stopped · checkpoint ${compactInlineText(data.stoppedAtGate ?? "", { maxChars: 32 })}` : data.ok === true ? "complete" : data.ok === false ? "failed" : "unknown";
320
+ const cause = compactVisibleText([data.failureKind, data.error].filter(Boolean).join(" · "), { maxLines: 1, maxLineChars: 80 }).text;
321
+ const label = compactInlineText(data.label ?? "", { maxChars: 96 });
322
+ return sessionCard({
323
+ title: `${label || "Result"} · ${status}${state === "error" && cause !== "(no output)" ? ` · ${cause}` : ""}`,
324
+ state, full, markdown: true, preview: preview(full, 3, 100),
325
+ }, expanded, theme);
326
+ });
327
+ pi.registerEntryRenderer<ContentEntry>("pi-persona-nudge", (entry, { expanded }, theme) => {
328
+ if (!entry.data) return undefined;
329
+ const full = sanitizeTerminalText(entry.data.content || "⟢ pi-persona · checkpoint");
330
+ const lines = full.split("\n").map((line) => line.trim()).filter(Boolean);
331
+ const header = compactInlineText(lines[0]?.replace(/^⟢\s*/, "") ?? "", { maxChars: 96 }) || "checkpoint";
332
+ // Drop the generator's own "pi-persona · checkpoint" boilerplate from the datum; a bare
333
+ // checkpoint header leaves the title as just the event word.
334
+ const datum = header === "checkpoint" ? "" : header.replace(/^pi-persona · checkpoint\b\??/, "").replace(/^pi-persona · /, "").trim();
335
+ const details = lines.slice(1);
336
+ const trigger = details.find((line) => line.startsWith("Trigger:"));
337
+ const action = details.find((line) => /^(?:Action|Verify|Otherwise):/.test(line));
338
+ const selected = [trigger, action].filter((line): line is string => line !== undefined);
339
+ const chosen = selected.length > 0 ? selected : details.slice(0, 2);
340
+ return sessionCard({
341
+ title: datum ? `Checkpoint · ${datum}` : "Checkpoint", state: "attention", full,
342
+ preview: (width) => {
343
+ const result = preview(chosen.join("\n"), 2, 100)(width);
344
+ return { text: result.text, truncated: result.truncated || details.some((line) => !chosen.includes(line)) };
345
+ },
346
+ }, expanded, theme);
347
+ });
348
+ pi.registerEntryRenderer<ContentEntry>("pi-persona-status", (entry, { expanded }, theme) => {
349
+ if (!entry.data) return undefined;
350
+ const full = sanitizeTerminalText(entry.data.content || "Async activity · routine check-in");
351
+ const stall = entry.data.kind === "stall";
352
+ const event = stall ? "Stall" : "Status";
353
+ const digest = full.split("\n").slice(1).filter((line) => line.trim().length > 0);
354
+ const parsed = parseStatusRuns(full);
355
+ return sessionCard({
356
+ title: parsed
357
+ ? `${event} · ${parsed.running} running${parsed.queued > 0 ? ` · ${parsed.queued} queued` : ""}`
358
+ : `${event} · check-in`,
359
+ state: stall ? "attention" : "neutral", full,
360
+ preview: parsed ? statusPreview(full)! : (width) => {
361
+ const result = preview(digest.slice(0, 2).join("\n") || full, 2, 140)(width);
362
+ return { text: result.text, truncated: result.truncated || digest.length > 2 };
363
+ },
364
+ }, expanded, theme);
365
+ });
366
+ }
@@ -0,0 +1,262 @@
1
+ import { stripTerminalControls } from "../core/fence.ts";
2
+ import type { MonitorEntry, MonitorEvent, MonitorOutput } from "../core/monitor.ts";
3
+ import { MonitorOutputTail } from "../core/monitor-output.ts";
4
+ import type { TimerEntry } from "../core/timer.ts";
5
+ import type { AddNodeInput, AgentNodePatch, AgentNodeStatus } from "./agent-tree.ts";
6
+
7
+ /**
8
+ * Wake-source rows — the pure mapping from monitor/timer lifecycle callbacks to agent-tree rows.
9
+ *
10
+ * Monitors and timers are background wake sources, not agents, but the operator should see them
11
+ * beside the sub-agents: what is armed, when it is due, what it has produced, why it ended. This
12
+ * module turns each lifecycle callback into an `AddNodeInput` / `AgentNodePatch` and nothing more:
13
+ * no tree, no clock, no Pi imports. Every timestamp comes from the entry or event itself, so the
14
+ * output is deterministic. Monitor labels, details and metadata are sanitized and bounded;
15
+ * live output adds a single-line progress preview without becoming a semantic event. Anything
16
+ * long — command, timer message, event history and bounded stdout/stderr tails — lives in
17
+ * `output`, which only the F9 drill-in shows.
18
+ */
19
+
20
+ /** Longest `command: …` description line (args may total 24k chars). */
21
+ const COMMAND_LINE_MAX = 2_000;
22
+ /** Longest first-line excerpt of one monitor event kept in the drill-in. */
23
+ const EVENT_TEXT_MAX = 1_000;
24
+ /** Longest first-line summary of a non-output terminal event, used as the row detail. */
25
+ const SUMMARY_MAX = 120;
26
+ /** Longest timer label shown on a row (the scheduler bounds it too; this projection does not trust that). */
27
+ const TIMER_LABEL_MAX = 120;
28
+ /** Event lines the drill-in remembers per monitor; older ones fall off the front. */
29
+ const MAX_EVENT_LINES = 40;
30
+ /** The manager's status line for a clean exit; diagnostics (stdout/stderr tails) follow on later lines. */
31
+ const CLEAN_EXIT = /\bexit code 0\b/;
32
+
33
+ /** Tree id of a wake-source row: `monitor:<id>` / `timer:<id>` (never collides with `async:`/strategy ids). */
34
+ export function wakeNodeId(kind: "monitor" | "timer", id: string): string {
35
+ return `${kind}:${id}`;
36
+ }
37
+
38
+ export interface WakeRowChange {
39
+ id: string;
40
+ patch: AgentNodePatch;
41
+ }
42
+
43
+ export interface WakeRowEvent extends WakeRowChange {
44
+ /** true when this event ended the watch (the row must settle and leave). */
45
+ settled: boolean;
46
+ }
47
+
48
+ /** ISO-8601 for a display line; "?" for a non-finite or out-of-range instant instead of throwing. */
49
+ function iso(ms: number): string {
50
+ if (!Number.isFinite(ms)) return "?";
51
+ const date = new Date(ms);
52
+ return Number.isFinite(date.getTime()) ? date.toISOString() : "?";
53
+ }
54
+
55
+ /** At most `max` code units, ending in "…" when clipped; never cuts an astral character in half. */
56
+ function clamp(text: string, max: number): string {
57
+ if (text.length <= max) return text;
58
+ let end = max - 1;
59
+ const last = text.charCodeAt(end - 1);
60
+ if (last >= 0xd800 && last <= 0xdbff) end -= 1;
61
+ return `${text.slice(0, end)}…`;
62
+ }
63
+
64
+ function firstLine(text: string): string {
65
+ const newline = text.indexOf("\n");
66
+ const line = newline < 0 ? text : text.slice(0, newline);
67
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
68
+ }
69
+
70
+ /**
71
+ * One self-contained display line: terminal controls stripped, any line break that survives
72
+ * (`stripTerminalControls` maps a bare `\r` to `\n`) collapsed to a single visible marker.
73
+ *
74
+ * Done per stored line, NOT at render time, on purpose. `renderMonitorOutput` joins every stored line
75
+ * into one buffer and the F9 overlay sanitises only that joined buffer, after the join. A child
76
+ * line carrying a bare `\r` would then render as two lines, the second able to pose as a genuine
77
+ * `[<iso>] …` event line (a forged event), and an unterminated escape in one line could swallow the
78
+ * genuine lines after it. Each line must already be inert before it is joined with its neighbours.
79
+ */
80
+ function oneLine(text: string): string {
81
+ return stripTerminalControls(text).replace(/[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, "").replace(/[\n\u2028\u2029]+/g, " ⏎ ").replace(/\t/g, " ").trim();
82
+ }
83
+
84
+ /** What a monitor remembers between its arming and its end. */
85
+ interface MonitorState {
86
+ displayId: string;
87
+ mode: MonitorEntry["mode"];
88
+ stdout: MonitorOutputTail;
89
+ stderr: MonitorOutputTail;
90
+ preview: string;
91
+ latestStream: MonitorOutput["stream"];
92
+ /** One dirty bit per admitted monitor, never a queue of raw chunks. */
93
+ outputPending: boolean;
94
+ /** Description lines above the events counter (mode, command, cwd, timing). */
95
+ base: string[];
96
+ maxEvents: number;
97
+ /** Whether the description carries an `events:` counter line (output mode). */
98
+ counted: boolean;
99
+ /** Output events seen so far. */
100
+ events: number;
101
+ /** The last `MAX_EVENT_LINES` event lines, oldest first. */
102
+ lines: string[];
103
+ }
104
+
105
+ function renderMonitorOutput(state: MonitorState): string {
106
+ const counter = state.counted || state.events > 0 ? [`events: ${state.events}/${state.maxEvents}`] : [];
107
+ const tails: string[] = [];
108
+ for (const stream of ["stdout", "stderr"] as const) {
109
+ const text = state[stream].text.trimEnd();
110
+ // Quote every untrusted line: output must not impersonate the metadata or event history.
111
+ if (text) tails.push(`${stream} tail:\n> ${text.replace(/\n/g, "\n> ")}`);
112
+ }
113
+ return [...state.base, ...counter, ...state.lines, ...tails].join("\n");
114
+ }
115
+
116
+ /** Preview construction, like the drill-in, belongs to publication rather than raw ingestion. */
117
+ function refreshMonitorPreview(state: MonitorState): void {
118
+ if (!state.outputPending) return;
119
+ const other = state.latestStream === "stdout" ? "stderr" : "stdout";
120
+ for (const stream of [state.latestStream, other] as const) {
121
+ const preview = clamp(state[stream].preview, SUMMARY_MAX);
122
+ if (preview) {
123
+ state.preview = `${stream}: ${preview}`;
124
+ break;
125
+ }
126
+ }
127
+ state.outputPending = false;
128
+ }
129
+
130
+ function monitorDetail(state: MonitorState): string {
131
+ const counter = state.counted || state.events > 0 ? ` · ${state.events}/${state.maxEvents} events` : "";
132
+ return `${state.displayId} · ${state.mode}${counter}${state.preview ? ` · ${state.preview}` : ""}`;
133
+ }
134
+
135
+ function terminalStatus(event: MonitorEvent): AgentNodeStatus {
136
+ if (event.kind === "output") return "done";
137
+ // Only the manager's status line decides: a failed child's stdout/stderr tail can say anything.
138
+ if (event.kind === "exit") return CLEAN_EXIT.test(firstLine(event.text)) ? "done" : "failed";
139
+ return "failed";
140
+ }
141
+
142
+ export class WakeRows {
143
+ private readonly monitors = new Map<string, MonitorState>();
144
+
145
+ monitorArmed(entry: MonitorEntry): AddNodeInput {
146
+ const counted = entry.mode === "output";
147
+ const displayId = clamp(oneLine(entry.id), SUMMARY_MAX);
148
+ const state: MonitorState = {
149
+ displayId,
150
+ mode: entry.mode,
151
+ stdout: new MonitorOutputTail(),
152
+ stderr: new MonitorOutputTail(),
153
+ preview: "",
154
+ latestStream: "stdout",
155
+ outputPending: false,
156
+ base: [
157
+ `${displayId} · ${entry.mode} mode`,
158
+ clamp(oneLine(`command: ${[entry.command, ...entry.args].join(" ")}`), COMMAND_LINE_MAX),
159
+ clamp(oneLine(`cwd: ${entry.cwd}`), COMMAND_LINE_MAX),
160
+ `armed ${iso(entry.startedAtEpochMs)} · deadline ${iso(entry.expiresAtEpochMs)}`,
161
+ ],
162
+ maxEvents: entry.maxEvents,
163
+ counted,
164
+ events: 0,
165
+ lines: [],
166
+ };
167
+ this.monitors.set(entry.id, state);
168
+ return {
169
+ id: wakeNodeId("monitor", entry.id),
170
+ label: clamp(oneLine(entry.label), SUMMARY_MAX),
171
+ kind: "monitor",
172
+ status: "running",
173
+ detail: `${displayId} · ${entry.mode}`,
174
+ startedAt: entry.startedAtEpochMs,
175
+ lastAdvanceAt: entry.startedAtEpochMs,
176
+ dueAt: entry.expiresAtEpochMs,
177
+ output: renderMonitorOutput(state),
178
+ };
179
+ }
180
+
181
+ /** Feed bounded tails immediately; no full-tail reads, tree patches or semantic credit per chunk. */
182
+ monitorOutput(output: MonitorOutput): boolean {
183
+ const state = this.monitors.get(output.id);
184
+ if (!state) return false;
185
+ state[output.stream].append(output.text);
186
+ state.latestStream = output.stream;
187
+ state.outputPending = true;
188
+ return true;
189
+ }
190
+
191
+ /** Called by the existing UI frame BEFORE composing the widget, once per dirty monitor. */
192
+ flushMonitorOutput(): WakeRowChange[] {
193
+ const changes: WakeRowChange[] = [];
194
+ for (const [id, state] of this.monitors) {
195
+ if (!state.outputPending) continue;
196
+ refreshMonitorPreview(state);
197
+ changes.push({ id: wakeNodeId("monitor", id), patch: { detail: monitorDetail(state), output: renderMonitorOutput(state) } });
198
+ }
199
+ return changes;
200
+ }
201
+
202
+ /** Shutdown owns the final archive capture, not another UI frame. Late output is ignored. */
203
+ clearMonitors(): void {
204
+ this.monitors.clear();
205
+ }
206
+
207
+ monitorEvent(event: MonitorEvent): WakeRowEvent {
208
+ const state = this.monitors.get(event.id);
209
+ const head = clamp(oneLine(firstLine(event.text)), EVENT_TEXT_MAX);
210
+ let observed = 1;
211
+ if (state) {
212
+ refreshMonitorPreview(state);
213
+ if (event.kind === "output") state.events += 1;
214
+ observed = state.events;
215
+ state.lines.push(`[${iso(event.atEpochMs)}] ${head}`);
216
+ if (state.lines.length > MAX_EVENT_LINES) state.lines.splice(0, state.lines.length - MAX_EVENT_LINES);
217
+ }
218
+ const max = state ? String(state.maxEvents) : "?";
219
+ const displayId = state?.displayId ?? clamp(oneLine(event.id), SUMMARY_MAX);
220
+
221
+ const patch: AgentNodePatch = { lastAdvanceAt: event.atEpochMs };
222
+ if (event.terminal) {
223
+ patch.status = terminalStatus(event);
224
+ patch.detail = `${displayId} · ${event.kind === "output" ? `${observed}/${max} events · watch ended` : clamp(head, SUMMARY_MAX)}`;
225
+ } else if (event.kind === "output") {
226
+ patch.detail = state?.preview ? monitorDetail(state) : `${displayId} · output · ${observed}/${max} events`;
227
+ }
228
+ if (state) patch.output = renderMonitorOutput(state);
229
+ if (event.terminal) this.monitors.delete(event.id);
230
+ return { id: wakeNodeId("monitor", event.id), patch, settled: event.terminal };
231
+ }
232
+
233
+ monitorRemoved(entry: MonitorEntry): WakeRowChange {
234
+ const state = this.monitors.get(entry.id);
235
+ const output = state?.outputPending ? renderMonitorOutput(state) : undefined;
236
+ this.monitors.delete(entry.id);
237
+ return { id: wakeNodeId("monitor", entry.id), patch: { status: "stopped", detail: `${clamp(oneLine(entry.id), SUMMARY_MAX)} · cancelled`, ...(output !== undefined ? { output } : {}) } };
238
+ }
239
+
240
+ timerArmed(entry: TimerEntry): AddNodeInput {
241
+ return {
242
+ id: wakeNodeId("timer", entry.id),
243
+ label: clamp(entry.label, TIMER_LABEL_MAX),
244
+ kind: "timer",
245
+ status: "running",
246
+ // A timer armed without a label is already labelled by its id; do not repeat it.
247
+ ...(entry.label !== entry.id ? { detail: entry.id } : {}),
248
+ startedAt: entry.armedAtEpochMs,
249
+ lastAdvanceAt: entry.armedAtEpochMs,
250
+ dueAt: entry.fireAtEpochMs,
251
+ output: `${entry.id} · fires ${iso(entry.fireAtEpochMs)} (armed ${iso(entry.armedAtEpochMs)})\nmessage: ${entry.message}`,
252
+ };
253
+ }
254
+
255
+ timerFired(entry: TimerEntry): WakeRowChange {
256
+ return { id: wakeNodeId("timer", entry.id), patch: { status: "done", detail: `${entry.id} · fired` } };
257
+ }
258
+
259
+ timerCancelled(entry: TimerEntry): WakeRowChange {
260
+ return { id: wakeNodeId("timer", entry.id), patch: { status: "stopped", detail: `${entry.id} · cancelled` } };
261
+ }
262
+ }
package/teams.yaml CHANGED
@@ -4,15 +4,19 @@
4
4
  # not one .md per perspective). Referenced by a persona's `orchestration.roster` / `council.roster`.
5
5
  # Project/user copies override.
6
6
 
7
- # One `reviewer` agent, three complementary lenses — the role focuses each parallel pass
7
+ # One `reviewer` agent, four complementary lenses — the role focuses each parallel pass
8
8
  # (skills load the rest). Add `model: <provider/id>` to a member for cross-model diversity.
9
9
  # Pin cheap models on WORKER members here; leave the SUPERVISOR on the session model (a weak
10
10
  # supervisor is the least able to act on its delegation reflex).
11
11
  review:
12
+ - { agent: reviewer, role: "Focus ONLY on the CORRECTNESS lens: wrong logic, off-by-one, races, null/undefined, unhandled errors, broken invariants, wrong API or contract use." }
12
13
  - { agent: reviewer, role: "Focus ONLY on the SECURITY lens: unsafe input flows, access control, sensitive data handling, unsafe sinks, input validation." }
13
14
  - { agent: reviewer, role: "Focus ONLY on the PERFORMANCE lens: algorithmic cost on hot paths, needless allocations/copies, repeated or N+1 I/O, unbounded work." }
14
15
  - { agent: reviewer, role: "Focus ONLY on the TESTS lens: untested branches and edge/error cases, brittle or mock-asserting tests, missing regression pins." }
15
16
 
17
+ # design (critic-loop): `architect` drafts ONE plan, `reviewer` critiques it, revised until nothing must-fix remains.
18
+ design: [architect, reviewer]
19
+
16
20
  repair: [operator, verifier]
17
21
  # Evidence assurance: execute one bounded leg, then independently replay its proof. Used by Elite's
18
22
  # on-demand critic-loop council; unlike the software verifier, this checks objective/scope/artifacts.