@aeondave/pi-persona 1.17.3 → 1.17.5

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 (93) hide show
  1. package/AGENTS.md +57 -36
  2. package/CHANGELOG.md +106 -1
  3. package/README.md +32 -16
  4. package/docs/ARCHITECTURE.md +199 -48
  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/docs/STRATEGIES.md +6 -0
  10. package/package.json +12 -6
  11. package/src/agents/agent.ts +6 -0
  12. package/src/bridge.ts +33 -23
  13. package/src/bus/broker/client.ts +151 -30
  14. package/src/bus/broker/host.ts +130 -11
  15. package/src/bus/broker/messages.ts +35 -5
  16. package/src/bus/contact-transport.ts +8 -0
  17. package/src/bus/contact.ts +118 -40
  18. package/src/bus/peers.ts +23 -6
  19. package/src/bus/result-reader.ts +88 -0
  20. package/src/core/brief.ts +14 -4
  21. package/src/core/capabilities.ts +1 -1
  22. package/src/core/companion.ts +64 -0
  23. package/src/core/leg-thinking.ts +57 -0
  24. package/src/core/ownership.ts +1 -2
  25. package/src/core/pi-compat.ts +5 -12
  26. package/src/core/session-runs.ts +1 -1
  27. package/src/core/types.ts +1 -1
  28. package/src/engine/adapter.ts +100 -20
  29. package/src/engine/async.ts +53 -11
  30. package/src/engine/child.ts +11 -2
  31. package/src/engine/inproc.ts +98 -47
  32. package/src/engine/result-grant.ts +62 -0
  33. package/src/engine/session-tools.ts +6 -0
  34. package/src/engine/stream.ts +6 -0
  35. package/src/engine/worktree.ts +17 -7
  36. package/src/exocom/collaboration-delivery.ts +29 -0
  37. package/src/exocom/collaboration.ts +148 -0
  38. package/src/exocom/envelope.ts +43 -4
  39. package/src/exocom/gate.ts +7 -2
  40. package/src/exocom/install.ts +45 -17
  41. package/src/exocom/ledger.ts +58 -8
  42. package/src/exocom/plane.ts +32 -1
  43. package/src/exocom/profile.ts +40 -0
  44. package/src/exocom/registry.ts +5 -0
  45. package/src/exocom/status.ts +11 -1
  46. package/src/exocom/write-guard.ts +7 -0
  47. package/src/extension/broker-host.ts +61 -9
  48. package/src/extension/companion.ts +38 -0
  49. package/src/extension/engine.ts +40 -29
  50. package/src/extension/hooks.ts +954 -931
  51. package/src/extension/identity.ts +41 -75
  52. package/src/extension/memory-owner.ts +54 -0
  53. package/src/extension/mind-worker.ts +129 -0
  54. package/src/extension/results.ts +138 -0
  55. package/src/extension/shared.ts +17 -0
  56. package/src/extension.ts +2922 -2849
  57. package/src/loader.ts +11 -3
  58. package/src/orchestration/agent-attempt.ts +15 -0
  59. package/src/orchestration/flow-journal.ts +7 -1
  60. package/src/orchestration/flow-run.ts +125 -19
  61. package/src/orchestration/flow.ts +11 -0
  62. package/src/orchestration/roster.ts +71 -23
  63. package/src/orchestration/sdk.ts +61 -19
  64. package/src/orchestration/types.ts +10 -0
  65. package/src/persona/model-participants.ts +20 -7
  66. package/src/persona/orchestrate.ts +6 -7
  67. package/src/persona/persona.ts +1 -1
  68. package/src/results/archive.ts +281 -0
  69. package/src/results/record.ts +166 -0
  70. package/src/results/retrieval.ts +142 -0
  71. package/src/results/types.ts +128 -0
  72. package/src/telemetry/contract.ts +1 -1
  73. package/src/tools/council.ts +40 -23
  74. package/src/tools/delegate-tool.ts +50 -14
  75. package/src/tools/delegate.ts +13 -6
  76. package/src/tools/exocom-collaboration-input.ts +168 -0
  77. package/src/tools/exocom-collaboration.ts +315 -0
  78. package/src/tools/exocom-profile.ts +102 -0
  79. package/src/tools/exocom.ts +20 -3
  80. package/src/tools/flow.ts +24 -8
  81. package/src/tools/intercom-tool.ts +98 -14
  82. package/src/tools/models.ts +15 -0
  83. package/src/tools/monitor.ts +20 -1
  84. package/src/tools/read-result.ts +52 -0
  85. package/src/tools/timer.ts +23 -2
  86. package/src/ui/activity-rows.ts +34 -0
  87. package/src/ui/agent-overlay.ts +23 -13
  88. package/src/ui/agent-tree.ts +67 -6
  89. package/src/ui/literal-tool-result.ts +1 -1
  90. package/src/ui/model-picker.ts +1 -1
  91. package/src/ui/orchestration-launch.ts +49 -0
  92. package/src/ui/session-cards.ts +191 -14
  93. package/src/ui/strategy-leg.ts +40 -0
@@ -1,5 +1,7 @@
1
1
  import { compactTokens } from "../core/format.ts";
2
+ import { effortFullName, effortSigla } from "../core/leg-thinking.ts";
2
3
  import { formatDuration } from "../core/time.ts";
4
+ import type { ThinkingLevel } from "../core/types.ts";
3
5
  import type { ChildUsage } from "../engine/stream.ts";
4
6
  import { compactInlineText } from "./presentation.ts";
5
7
  import { formatUsage } from "./usage.ts";
@@ -13,7 +15,7 @@ import { formatUsage } from "./usage.ts";
13
15
  */
14
16
 
15
17
  export type AgentNodeStatus = "running" | "done" | "failed" | "stopped";
16
- export type AgentNodeKind = "subagent" | "delegate" | "council" | "flow" | "phase" | "monitor" | "timer";
18
+ export type AgentNodeKind = "subagent" | "delegate" | "council" | "flow" | "phase" | "monitor" | "timer" | "activity";
17
19
 
18
20
  export interface AgentNode {
19
21
  id: string;
@@ -25,6 +27,13 @@ export interface AgentNode {
25
27
  /** Configured agent definition (operator/reviewer/…), if known. */
26
28
  agent?: string;
27
29
  model?: string;
30
+ /** Requested per-leg thinking effort, known at launch (the spec-level value the launch row
31
+ * carried). Rendered as the compact sigla right after the model; absent/off render nothing. */
32
+ thinking?: ThinkingLevel;
33
+ /** Observed effective Pi effort from the terminal result. Set ONLY from a real
34
+ * AgentResult.thinkingEffective (never invented); when it diverges from `thinking`, the row
35
+ * renders the honest `xh→h` form. */
36
+ thinkingEffective?: ThinkingLevel;
28
37
  /** A short trailing annotation (usage, current activity), shown after the label. */
29
38
  detail: string | undefined;
30
39
  /** The agent's output buffer, shown when the user drills into the node. */
@@ -69,10 +78,16 @@ export function isWakeSource(node: Pick<AgentNode, "kind">): boolean {
69
78
  return node.kind === "monitor" || node.kind === "timer";
70
79
  }
71
80
 
81
+ /** Local companion work shares the view but is neither an agent nor a wake source. */
82
+ export function isNonAgentNode(node: Pick<AgentNode, "kind">): boolean {
83
+ return isWakeSource(node) || node.kind === "activity";
84
+ }
85
+
72
86
  /** The kind-aware glyph shared by every surface (tree, overlay): a running timer is ⏰, a running
73
87
  * monitor ◉; everything else, settled wake rows included, uses the ordinary status glyph. */
74
88
  export function nodeGlyph(node: Pick<AgentNode, "status" | "kind">): string {
75
89
  if (node.status === "running") {
90
+ if (node.kind === "activity") return "◈";
76
91
  if (node.kind === "timer") return "⏰";
77
92
  if (node.kind === "monitor") return "◉";
78
93
  }
@@ -88,6 +103,28 @@ export interface RenderOptions {
88
103
  waitingForSupervisor?: ReadonlySet<string>;
89
104
  }
90
105
 
106
+ /** The frozen compact effort tag for a tree/overlay row: ` · xh` after the model, or ` · xh→h`
107
+ * when the terminal result reported a DIFFERENT observed effective level. `off`/absent render
108
+ * nothing — absence means off, and an observation is never invented: the divergence form shows
109
+ * only when both sides are known and genuinely differ (an observed `off` never leaves a dangling
110
+ * `→`). Pure. */
111
+ export function effortTag(requested: ThinkingLevel | undefined, effective?: ThinkingLevel): string {
112
+ const req = requested !== undefined ? effortSigla(requested) : "";
113
+ if (!req) return "";
114
+ const obs = effective !== undefined ? effortSigla(effective) : "";
115
+ return obs && obs !== req ? ` · ${req}→${obs}` : ` · ${req}`;
116
+ }
117
+
118
+ /** The F9 drill-in line for a leg's effort — FULL names, the drill-in has the room (the compact
119
+ * siglas live only on the rows): `thinking: requested xhigh → observed high`. Same honesty rules
120
+ * as {@link effortTag}; the line is omitted entirely when nothing was requested. Pure. */
121
+ export function thinkingDetailLine(requested: ThinkingLevel | undefined, effective?: ThinkingLevel): string {
122
+ const req = requested !== undefined ? effortFullName(requested) : "";
123
+ if (!req) return "";
124
+ const obs = effective !== undefined && effective !== requested ? effortFullName(effective) : "";
125
+ return obs ? `thinking: requested ${req} → observed ${obs}` : `thinking: requested ${req}`;
126
+ }
127
+
91
128
  /** Whether `detail` is the literal "queued" marker a seeded-but-not-yet-live leg carries — shared
92
129
  * by `runningAnnotation` (suppresses the clock) and `telemetryStatus` (`extension.ts`, reports the
93
130
  * `queued` state). One predicate so the two readings of the marker cannot drift apart. */
@@ -105,6 +142,7 @@ export function isQueuedMarker(detail: string | undefined): boolean {
105
142
  export function runningAnnotation(node: Pick<AgentNode, "status" | "startedAt" | "lastAdvanceAt" | "detail" | "kind" | "dueAt">, now: number, stallMs: number, waitingForSupervisor = false): string | undefined {
106
143
  if (node.status !== "running" || node.startedAt === undefined) return undefined;
107
144
  if (isQueuedMarker(node.detail)) return undefined;
145
+ if (node.kind === "activity") return formatDuration(now - node.startedAt);
108
146
  if (waitingForSupervisor) return "waiting for supervisor";
109
147
  if (node.dueAt !== undefined) {
110
148
  const left = node.dueAt - now;
@@ -179,7 +217,10 @@ export function renderAgentTree(nodes: AgentNode[], opts: RenderOptions = {}): s
179
217
  const isRoot = parentId === undefined;
180
218
  const isLast = i === kids.length - 1;
181
219
  const branch = isRoot ? "" : isLast ? "└─ " : "├─ ";
182
- const label = safeInline(node.label) || safeInline(node.id) || "agent";
220
+ const base = safeInline(node.label) || safeInline(node.id) || "agent";
221
+ // The compact effort sigla rides AFTER the label clamp — the existing per-part clipping is
222
+ // untouched; the appended field is at most ` · xh→h` wide.
223
+ const label = `${base}${effortTag(node.thinking, node.thinkingEffective)}`;
183
224
  const detailText = node.detail ? safeInline(node.detail) : "";
184
225
  const clock = opts.now !== undefined ? runningAnnotation(node, opts.now, opts.stallMs ?? 0, opts.waitingForSupervisor?.has(node.id) ?? false) : undefined;
185
226
  const tail = [detailText, clock].filter((s): s is string => Boolean(s)).join(" · ");
@@ -202,6 +243,8 @@ function wakeDigestLine(hidden: readonly AgentNode[], now: number | undefined):
202
243
  const monitors = hidden.filter((node) => node.kind === "monitor").length;
203
244
  const timers = hidden.filter((node) => node.kind === "timer").length;
204
245
  const parts: string[] = [];
246
+ const activities = hidden.filter((node) => node.kind === "activity");
247
+ if (activities.length > 0) parts.push(`◈ Mind · ${safeInline(activities[activities.length - 1]!.detail ?? "local memory work")}`);
205
248
  if (monitors > 0) parts.push(`${nodeGlyph({ status: "running", kind: "monitor" })} ${monitors} monitor${monitors === 1 ? "" : "s"}`);
206
249
  if (timers > 0) parts.push(`${nodeGlyph({ status: "running", kind: "timer" })} ${timers} timer${timers === 1 ? "" : "s"}`);
207
250
  if (now !== undefined) {
@@ -243,7 +286,7 @@ export function renderAgentTreeSummary(nodes: AgentNode[], maxRows = 8, opts: Re
243
286
  // same parent→children order, so a row's flat index doubles as its position in the rendered list.
244
287
  const flat = flattenTree(nodes);
245
288
  const hiddenWake = (from: number): AgentNode[] =>
246
- flat.slice(from).map((row) => row.node).filter((node) => isWakeSource(node) && node.status === "running");
289
+ flat.slice(from).map((row) => row.node).filter((node) => node.kind === "activity" || (isWakeSource(node) && node.status === "running"));
247
290
  let visibleCount = Math.max(0, limit - (failureLine !== undefined ? 2 : 1));
248
291
  let hidden = hiddenWake(visibleCount);
249
292
  if (hidden.length > 0) {
@@ -266,7 +309,9 @@ export interface AddNodeInput {
266
309
  status?: AgentNodeStatus;
267
310
  kind?: AgentNodeKind;
268
311
  agent?: string;
269
- model?: string;
312
+ model?: string | undefined;
313
+ thinking?: ThinkingLevel | undefined;
314
+ thinkingEffective?: ThinkingLevel | undefined;
270
315
  detail?: string;
271
316
  output?: string;
272
317
  startedAt?: number;
@@ -278,7 +323,9 @@ export interface AgentNodePatch {
278
323
  status?: AgentNodeStatus;
279
324
  kind?: AgentNodeKind;
280
325
  agent?: string;
281
- model?: string;
326
+ model?: string | undefined;
327
+ thinking?: ThinkingLevel | undefined;
328
+ thinkingEffective?: ThinkingLevel | undefined;
282
329
  detail?: string;
283
330
  output?: string;
284
331
  startedAt?: number;
@@ -292,7 +339,19 @@ function applyPatch(node: AgentNode, patch: AgentNodePatch): void {
292
339
  if (patch.status) node.status = patch.status;
293
340
  if (patch.kind !== undefined) node.kind = patch.kind;
294
341
  if (patch.agent !== undefined) node.agent = patch.agent;
295
- if (patch.model !== undefined) node.model = patch.model;
342
+ // Omission preserves metadata; explicit undefined clears a previous attempt's intent/observation.
343
+ if (Object.hasOwn(patch, "model")) {
344
+ if (patch.model === undefined) delete node.model;
345
+ else node.model = patch.model;
346
+ }
347
+ if (Object.hasOwn(patch, "thinking")) {
348
+ if (patch.thinking === undefined) delete node.thinking;
349
+ else node.thinking = patch.thinking;
350
+ }
351
+ if (Object.hasOwn(patch, "thinkingEffective")) {
352
+ if (patch.thinkingEffective === undefined) delete node.thinkingEffective;
353
+ else node.thinkingEffective = patch.thinkingEffective;
354
+ }
296
355
  if (patch.detail !== undefined) node.detail = patch.detail;
297
356
  if (patch.output !== undefined) node.output = patch.output;
298
357
  if (patch.startedAt !== undefined) node.startedAt = patch.startedAt;
@@ -333,6 +392,8 @@ export class AgentTree {
333
392
  ...(input.kind !== undefined ? { kind: input.kind } : {}),
334
393
  ...(input.agent !== undefined ? { agent: input.agent } : {}),
335
394
  ...(input.model !== undefined ? { model: input.model } : {}),
395
+ ...(input.thinking !== undefined ? { thinking: input.thinking } : {}),
396
+ ...(input.thinkingEffective !== undefined ? { thinkingEffective: input.thinkingEffective } : {}),
336
397
  ...(input.output !== undefined ? { output: input.output } : {}),
337
398
  ...(input.dueAt !== undefined ? { dueAt: input.dueAt } : {}),
338
399
  };
@@ -5,7 +5,7 @@ import { compactVisibleText, sanitizeTerminalText } from "./presentation.ts";
5
5
 
6
6
  interface LiteralResult {
7
7
  content: readonly { type: string; text?: string }[];
8
- /** Legacy direct renderer calls may carry this; the native host passes it in context. */
8
+ /** Direct internal renderer calls carry this; Pi passes it in the render context. */
9
9
  isError?: boolean;
10
10
  }
11
11
 
@@ -217,7 +217,7 @@ export class ModelPicker extends Container implements Focusable {
217
217
  return;
218
218
  } else {
219
219
  const before = this.input.getValue();
220
- // Input has no per-instance keybindings API (Pi 1.3/1.4). Scope its global
220
+ // Input has no per-instance keybindings API (Pi 1.4). Scope its global
221
221
  // lookup to this synchronous call and restore it even if handling throws.
222
222
  const global = getKeybindings();
223
223
  try {
@@ -0,0 +1,49 @@
1
+ /** Persisted orchestration launch requests. Rendering never consults live persona or model settings. */
2
+ import { resolveLegThinking } from "../core/leg-thinking.ts";
3
+ import type { ThinkingLevel } from "../core/types.ts";
4
+ import type { ModelParticipant, ModelSource } from "../persona/model-participants.ts";
5
+ import { compactInlineText, sanitizeTerminalText } from "./presentation.ts";
6
+
7
+ export interface OrchestrationLaunchParticipant {
8
+ key: string;
9
+ label: string;
10
+ agent: string;
11
+ model?: string;
12
+ modelSource: ModelSource;
13
+ thinkingRequested?: ThinkingLevel;
14
+ setupPending: boolean;
15
+ param?: string;
16
+ phase?: string;
17
+ }
18
+
19
+ export interface OrchestrationLaunchSnapshot {
20
+ participants: OrchestrationLaunchParticipant[];
21
+ unavailable?: true;
22
+ }
23
+
24
+ export function snapshotOrchestrationParticipants(
25
+ participants: readonly ModelParticipant[],
26
+ opts: { agentThinking(agent: string): ThinkingLevel | undefined; childThinking: unknown; pendingKeys: ReadonlySet<string> },
27
+ ): OrchestrationLaunchSnapshot {
28
+ return { participants: participants.map((p) => {
29
+ const agentThinking = opts.agentThinking(p.agent);
30
+ const thinkingRequested = resolveLegThinking(p, agentThinking ? { thinking: agentThinking } : {}, opts.childThinking);
31
+ return { key: p.key, label: p.label, agent: p.agent, modelSource: p.source, setupPending: !p.assigned && opts.pendingKeys.has(p.key),
32
+ ...(p.model ? { model: p.model } : {}), ...(thinkingRequested ? { thinkingRequested } : {}), ...(p.param ? { param: p.param } : {}) };
33
+ }) };
34
+ }
35
+
36
+ /** Three individually bounded rows collapsed; expansion retains every participant and full model ref. */
37
+ export function orchestrationLaunchLines(snapshot: OrchestrationLaunchSnapshot | undefined, expanded: boolean): string[] {
38
+ if (!snapshot || snapshot.unavailable) return ["Launch metadata unavailable"];
39
+ const rows = expanded ? snapshot.participants : snapshot.participants.slice(0, 3);
40
+ const lines = rows.map((p) => {
41
+ const clean = (value: string, maxChars: number): string => expanded ? sanitizeTerminalText(value) : compactInlineText(value, { maxChars });
42
+ const label = clean(`${p.phase ? `${p.phase} / ` : ""}${p.label}${p.param ? ` [${p.param}]` : ""}`, 48);
43
+ const model = p.model ? clean(p.model, 64) : "unknown";
44
+ const modelText = p.setupPending ? `model pending (current default ${model}; setup may change)` : `requested model ${model}`;
45
+ return ` ${label} · ${modelText} · requested effort ${p.thinkingRequested ?? "unknown"}`;
46
+ });
47
+ if (!expanded && snapshot.participants.length > rows.length) lines.push(` +${snapshot.participants.length - rows.length} more · expand for launch details`);
48
+ return lines;
49
+ }
@@ -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
  },
@@ -0,0 +1,40 @@
1
+ /** Model intent for live strategy rows, resolved from exact attempt identity and frozen run settings. */
2
+ import { isThinkingLevel, type ThinkingLevel } from "../core/types.ts";
3
+ import type { AgentAttemptIdentity } from "../orchestration/agent-attempt.ts";
4
+ import type { AgentResult } from "../orchestration/types.ts";
5
+ import { participantKey } from "../persona/model-participants.ts";
6
+
7
+ export interface StrategyModels {
8
+ readonly assigned: Readonly<Record<string, string>>;
9
+ readonly agents: Readonly<Record<string, string>>;
10
+ readonly sessionModel?: string;
11
+ }
12
+
13
+ export function snapshotStrategyModels(input: { assigned?: Record<string, string>; agents: readonly { name: string; model?: string }[]; sessionModel?: string }): StrategyModels {
14
+ return Object.freeze({ assigned: Object.freeze({ ...input.assigned }),
15
+ agents: Object.freeze(Object.fromEntries(input.agents.flatMap((a) => a.model ? [[a.name, a.model]] : []))),
16
+ ...(input.sessionModel ? { sessionModel: input.sessionModel } : {}) });
17
+ }
18
+
19
+ export interface StrategyLegMetadata {
20
+ agent: string;
21
+ model: string | undefined;
22
+ thinking: ThinkingLevel | undefined;
23
+ thinkingEffective: ThinkingLevel | undefined;
24
+ }
25
+
26
+ /** A bare observed id preserves a known provider only when it exactly names the same requested id. */
27
+ function observedModel(observed: string | undefined, requested: string | undefined): string | undefined {
28
+ const value = observed?.trim();
29
+ if (!value) return requested;
30
+ if (!value.includes("/") && requested?.includes("/") && requested.slice(requested.indexOf("/") + 1) === value) return requested;
31
+ return value;
32
+ }
33
+
34
+ export function strategyLegMetadata(identity: AgentAttemptIdentity, models: StrategyModels, result?: AgentResult): StrategyLegMetadata {
35
+ const requested = identity.model ?? models.assigned[participantKey(identity.agent, identity.role)]
36
+ ?? models.assigned[identity.agent] ?? models.agents[identity.agent] ?? models.sessionModel;
37
+ return { agent: identity.agent, model: observedModel(result?.modelUsed, requested),
38
+ thinking: isThinkingLevel(result?.thinkingRequested) ? result.thinkingRequested : identity.thinking,
39
+ thinkingEffective: isThinkingLevel(result?.thinkingEffective) ? result.thinkingEffective : undefined };
40
+ }