@aeondave/pi-persona 1.17.3 → 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 (84) hide show
  1. package/AGENTS.md +44 -36
  2. package/CHANGELOG.md +76 -1
  3. package/README.md +23 -13
  4. package/docs/ARCHITECTURE.md +97 -37
  5. package/docs/COLLABORATION.md +110 -0
  6. package/docs/EXPERIENCE.md +33 -8
  7. package/docs/README.md +12 -0
  8. package/docs/REFERENCE.md +65 -14
  9. package/package.json +12 -6
  10. package/src/agents/agent.ts +6 -0
  11. package/src/bridge.ts +25 -8
  12. package/src/bus/broker/client.ts +119 -21
  13. package/src/bus/broker/host.ts +123 -8
  14. package/src/bus/broker/messages.ts +31 -4
  15. package/src/bus/contact.ts +113 -38
  16. package/src/bus/peers.ts +15 -0
  17. package/src/bus/result-reader.ts +88 -0
  18. package/src/core/brief.ts +14 -4
  19. package/src/core/capabilities.ts +1 -1
  20. package/src/core/leg-thinking.ts +57 -0
  21. package/src/core/ownership.ts +1 -2
  22. package/src/core/pi-compat.ts +5 -12
  23. package/src/core/session-runs.ts +1 -1
  24. package/src/core/types.ts +1 -1
  25. package/src/engine/adapter.ts +83 -19
  26. package/src/engine/async.ts +49 -10
  27. package/src/engine/child.ts +10 -2
  28. package/src/engine/inproc.ts +87 -46
  29. package/src/engine/result-grant.ts +62 -0
  30. package/src/engine/session-tools.ts +6 -0
  31. package/src/engine/stream.ts +6 -0
  32. package/src/engine/worktree.ts +17 -7
  33. package/src/exocom/collaboration-delivery.ts +29 -0
  34. package/src/exocom/collaboration.ts +148 -0
  35. package/src/exocom/envelope.ts +43 -4
  36. package/src/exocom/gate.ts +7 -2
  37. package/src/exocom/install.ts +45 -17
  38. package/src/exocom/ledger.ts +58 -8
  39. package/src/exocom/plane.ts +32 -1
  40. package/src/exocom/profile.ts +40 -0
  41. package/src/exocom/registry.ts +5 -0
  42. package/src/exocom/status.ts +11 -1
  43. package/src/exocom/write-guard.ts +7 -0
  44. package/src/extension/broker-host.ts +61 -9
  45. package/src/extension/engine.ts +20 -21
  46. package/src/extension/hooks.ts +952 -938
  47. package/src/extension/identity.ts +41 -75
  48. package/src/extension/results.ts +136 -0
  49. package/src/extension/shared.ts +17 -0
  50. package/src/extension.ts +2963 -2873
  51. package/src/loader.ts +11 -3
  52. package/src/orchestration/flow-journal.ts +7 -1
  53. package/src/orchestration/flow-run.ts +122 -19
  54. package/src/orchestration/flow.ts +11 -0
  55. package/src/orchestration/roster.ts +71 -23
  56. package/src/orchestration/sdk.ts +44 -6
  57. package/src/orchestration/types.ts +10 -0
  58. package/src/persona/model-participants.ts +20 -7
  59. package/src/persona/orchestrate.ts +2 -0
  60. package/src/persona/persona.ts +1 -1
  61. package/src/results/archive.ts +281 -0
  62. package/src/results/record.ts +166 -0
  63. package/src/results/retrieval.ts +142 -0
  64. package/src/results/types.ts +128 -0
  65. package/src/telemetry/contract.ts +1 -1
  66. package/src/tools/council.ts +40 -23
  67. package/src/tools/delegate-tool.ts +50 -14
  68. package/src/tools/delegate.ts +13 -6
  69. package/src/tools/exocom-collaboration-input.ts +168 -0
  70. package/src/tools/exocom-collaboration.ts +315 -0
  71. package/src/tools/exocom-profile.ts +102 -0
  72. package/src/tools/exocom.ts +20 -3
  73. package/src/tools/flow.ts +24 -8
  74. package/src/tools/intercom-tool.ts +98 -14
  75. package/src/tools/models.ts +15 -0
  76. package/src/tools/monitor.ts +20 -1
  77. package/src/tools/read-result.ts +52 -0
  78. package/src/tools/timer.ts +23 -2
  79. package/src/ui/agent-overlay.ts +11 -3
  80. package/src/ui/agent-tree.ts +43 -1
  81. package/src/ui/literal-tool-result.ts +1 -1
  82. package/src/ui/model-picker.ts +1 -1
  83. package/src/ui/orchestration-launch.ts +49 -0
  84. package/src/ui/session-cards.ts +191 -14
@@ -1,6 +1,6 @@
1
1
  /** Operator-only presentation of existing session payloads. No delivery, storage or tool shells. */
2
2
  import type { ExtensionAPI, Theme, ThemeBg, ThemeColor } from "@earendil-works/pi-coding-agent";
3
- import { Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
3
+ import { Text, truncateToWidth, visibleWidth, type Component } from "@earendil-works/pi-tui";
4
4
  import { formatDuration } from "../core/time.ts";
5
5
  import { neutralizeMonitorUnicode } from "../core/monitor-output.ts";
6
6
  import { createReportBody } from "./report-body.ts";
@@ -25,6 +25,14 @@ interface Card {
25
25
  markdown?: boolean;
26
26
  }
27
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
+
28
36
  /** All seven surfaces share semantic chrome. Literal paths style at paint time; report bodies
29
37
  * use native Markdown's invalidatable layout cache. Pi rebuilds callbacks on transcript invalidation. */
30
38
  function createSessionCard(card: Card, expanded: boolean, theme: Theme, detailHint: () => string, outputPad = 0): Component {
@@ -45,12 +53,19 @@ function createSessionCard(card: Card, expanded: boolean, theme: Theme, detailHi
45
53
  const padding = Math.min(Math.max(0, Math.floor(outputPad)), Math.max(0, Math.floor((width - 1) / 2)));
46
54
  const innerWidth = Math.max(1, width - padding * 2);
47
55
  const colors = COLORS[card.state];
48
- const preview = expanded ? undefined : card.preview(innerWidth);
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);
49
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)}`));
50
65
  const lines = [
51
66
  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)),
67
+ ...(card.metadata ? [theme.fg("dim", truncateToWidth(`${bodyPad}${sanitizeTerminalText(card.metadata)}`, innerWidth))] : []),
68
+ ...bodyLines,
54
69
  ...(preview?.truncated ? [theme.fg("dim", truncateToWidth(detailHint(), innerWidth))] : []),
55
70
  ];
56
71
  // Text owns wrapping and full-row padding/background, including embedded foreground resets.
@@ -65,11 +80,15 @@ function preview(full: string, maxLines: number, maxColumns: number): Card["prev
65
80
  return (width) => compactVisibleText(full, { maxLines, maxLineChars: Math.min(width, maxColumns) });
66
81
  }
67
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
+
68
87
  /** Only this exact runtime-owned aggregate is evidence of settlement. No keyword scan of output,
69
88
  * asks, timers, monitor event bodies, or peers. Legacy/unknown follow-ups remain informational. */
70
89
  function followUpState(full: string): { state: State; status: string } {
71
90
  const first = full.split("\n", 1)[0] ?? "";
72
- const summary = /^(?:\[pi-persona\] )?(\d+) async runs? settled — (\d+) done, (\d+) failed(?:, (\d+) stopped)?$/.exec(first);
91
+ const summary = SETTLEMENT_HEAD.exec(first);
73
92
  if (summary) {
74
93
  const total = Number(summary[1]);
75
94
  const done = Number(summary[2]);
@@ -101,6 +120,149 @@ interface ResultEntry { label?: string; content?: string; ok?: boolean; stoppedA
101
120
  interface ContentEntry { content?: string; kind?: "routine" | "stall" }
102
121
  interface RunFailureEntry { id: string; label: string; agent: string; model?: string; durationMs?: number; error: string }
103
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
+
104
266
  export interface SessionCardOptions {
105
267
  detailHint: () => string;
106
268
  /** Existing bounded/fenced runtime formatter, injected to keep UI independent of tool wiring. */
@@ -119,7 +281,12 @@ export function registerSessionCards(pi: Pick<ExtensionAPI, "registerMessageRend
119
281
  // Only the validated settlement envelope is a pi-persona report. Unknown is NOT
120
282
  // Markdown: monitor stdout/stderr, timers and operational notices share this type.
121
283
  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);
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);
123
290
  });
124
291
  }
125
292
  pi.registerEntryRenderer(MONITOR_LOG_ENTRY, (entry, { expanded }, theme) => {
@@ -127,8 +294,9 @@ export function registerSessionCards(pi: Pick<ExtensionAPI, "registerMessageRend
127
294
  if (!record) return undefined;
128
295
  const { state, status } = monitorState(record);
129
296
  return sessionCard({
130
- title: `Monitor log ${compactInlineText(record.entry.label)} · ${record.outcome} · ${status}`,
131
- state, full: sanitizeTerminalText(options.monitorLog(record)),
297
+ title: `Monitor · ${compactInlineText(record.entry.label)}`,
298
+ state, metadata: `${record.outcome} · ${status} · ${compactInlineText(record.entry.id)}`,
299
+ full: sanitizeTerminalText(options.monitorLog(record)),
132
300
  preview: (width) => ({ text: `${compactInlineText(neutralizeMonitorUnicode(record.text), { maxChars: Math.min(width, 160) })}\n${compactInlineText(record.entry.id)} · monitor result`, truncated: true }),
133
301
  }, expanded, theme);
134
302
  });
@@ -137,7 +305,7 @@ export function registerSessionCards(pi: Pick<ExtensionAPI, "registerMessageRend
137
305
  if (!data) return undefined;
138
306
  const metadata = [compactInlineText(data.agent, { maxChars: 48 }), ...(data.model ? [compactInlineText(data.model, { maxChars: 96 })] : []), ...(data.durationMs !== undefined ? [formatDuration(data.durationMs)] : [])].join(" · ");
139
307
  return sessionCard({
140
- title: `${compactInlineText(data.label || data.agent, { maxChars: 80 })} · failed`, state: "error", metadata,
308
+ title: `Failed · ${compactInlineText(data.label || data.agent, { maxChars: 80 })}`, state: "error", metadata,
141
309
  full: `Run ${compactInlineText(data.id)}\n${sanitizeTerminalText(data.error)}`,
142
310
  preview: (width) => ({ text: compactVisibleText(summarizeFailure(data.error), { maxLines: 2, maxLineChars: Math.min(width, 100) }).text, truncated: true }),
143
311
  }, expanded, theme);
@@ -150,8 +318,9 @@ export function registerSessionCards(pi: Pick<ExtensionAPI, "registerMessageRend
150
318
  const state: State = stopped ? "attention" : data.ok === true ? "success" : data.ok === false ? "error" : "neutral";
151
319
  const status = stopped ? `stopped · checkpoint ${compactInlineText(data.stoppedAtGate ?? "", { maxChars: 32 })}` : data.ok === true ? "complete" : data.ok === false ? "failed" : "unknown";
152
320
  const cause = compactVisibleText([data.failureKind, data.error].filter(Boolean).join(" · "), { maxLines: 1, maxLineChars: 80 }).text;
321
+ const label = compactInlineText(data.label ?? "", { maxChars: 96 });
153
322
  return sessionCard({
154
- title: `${compactInlineText(data.label ?? "", { maxChars: 96 }) || "result"} · ${status}${state === "error" && cause !== "(no output)" ? ` · ${cause}` : ""}`,
323
+ title: `${label || "Result"} · ${status}${state === "error" && cause !== "(no output)" ? ` · ${cause}` : ""}`,
155
324
  state, full, markdown: true, preview: preview(full, 3, 100),
156
325
  }, expanded, theme);
157
326
  });
@@ -159,14 +328,17 @@ export function registerSessionCards(pi: Pick<ExtensionAPI, "registerMessageRend
159
328
  if (!entry.data) return undefined;
160
329
  const full = sanitizeTerminalText(entry.data.content || "⟢ pi-persona · checkpoint");
161
330
  const lines = full.split("\n").map((line) => line.trim()).filter(Boolean);
162
- const header = compactInlineText(lines[0]?.replace(/^⟢\s*/, "") ?? "", { maxChars: 96 }) || "pi-persona · checkpoint";
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();
163
335
  const details = lines.slice(1);
164
336
  const trigger = details.find((line) => line.startsWith("Trigger:"));
165
337
  const action = details.find((line) => /^(?:Action|Verify|Otherwise):/.test(line));
166
338
  const selected = [trigger, action].filter((line): line is string => line !== undefined);
167
339
  const chosen = selected.length > 0 ? selected : details.slice(0, 2);
168
340
  return sessionCard({
169
- title: header, state: "attention", full,
341
+ title: datum ? `Checkpoint · ${datum}` : "Checkpoint", state: "attention", full,
170
342
  preview: (width) => {
171
343
  const result = preview(chosen.join("\n"), 2, 100)(width);
172
344
  return { text: result.text, truncated: result.truncated || details.some((line) => !chosen.includes(line)) };
@@ -177,10 +349,15 @@ export function registerSessionCards(pi: Pick<ExtensionAPI, "registerMessageRend
177
349
  if (!entry.data) return undefined;
178
350
  const full = sanitizeTerminalText(entry.data.content || "Async activity · routine check-in");
179
351
  const stall = entry.data.kind === "stall";
352
+ const event = stall ? "Stall" : "Status";
180
353
  const digest = full.split("\n").slice(1).filter((line) => line.trim().length > 0);
354
+ const parsed = parseStatusRuns(full);
181
355
  return sessionCard({
182
- title: stall ? "pi-persona · possible stall" : "pi-persona · async status", state: stall ? "attention" : "neutral", full,
183
- preview: (width) => {
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) => {
184
361
  const result = preview(digest.slice(0, 2).join("\n") || full, 2, 140)(width);
185
362
  return { text: result.text, truncated: result.truncated || digest.length > 2 };
186
363
  },