@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
@@ -1,7 +1,7 @@
1
1
  /** Session identity registration and Pi lifecycle adapter. */
2
2
 
3
3
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
5
5
  import { Text } from "@earendil-works/pi-tui";
6
6
  import { type Static, Type } from "typebox";
7
7
 
@@ -15,6 +15,7 @@ import {
15
15
  sanitizeIdentityName,
16
16
  } from "../core/session-identity.ts";
17
17
  import { compactInlineText } from "../ui/presentation.ts";
18
+ import { renderLiteralToolResult } from "../ui/literal-tool-result.ts";
18
19
 
19
20
  export interface IdentityHost {
20
21
  capabilities(): EffectiveCapabilities | undefined;
@@ -34,50 +35,14 @@ const AgentNameParams = Type.Object({
34
35
  name: Type.String({ minLength: 1, maxLength: 32, description: "A short personal handle, separate from persona and role." }),
35
36
  });
36
37
 
37
- type SessionManagerCompat = {
38
- getSessionId?: () => string;
39
- getHeader?: () => { id?: string } | null;
40
- getBranch?: () => readonly unknown[];
41
- getEntries?: () => readonly unknown[];
42
- };
43
-
44
- function sessionManager(ctx: ExtensionContext): SessionManagerCompat {
45
- return (ctx as ExtensionContext & { sessionManager?: SessionManagerCompat }).sessionManager ?? {};
46
- }
47
-
48
- /** Read the real session id when available. Process fallback is intentionally start-only. */
49
- function readSessionId(ctx: ExtensionContext, allowProcessFallback: boolean): string | undefined {
50
- const manager = sessionManager(ctx);
38
+ /** Pi 1.4 supplies both methods. A failed host read is unavailable, never a different owner or branch. */
39
+ function readSession(ctx: ExtensionContext): { id: string; entries: readonly SessionEntry[] } | undefined {
51
40
  try {
52
- const id = manager.getSessionId?.();
53
- if (typeof id === "string" && id.trim()) return id;
41
+ const id = ctx.sessionManager.getSessionId();
42
+ return id.trim() ? { id, entries: ctx.sessionManager.getBranch() } : undefined;
54
43
  } catch {
55
- /* Compatibility with light-weight session-manager test doubles. */
44
+ return undefined;
56
45
  }
57
- try {
58
- const id = manager.getHeader?.()?.id;
59
- if (typeof id === "string" && id.trim()) return id;
60
- } catch {
61
- /* Compatibility with light-weight session-manager test doubles. */
62
- }
63
- return allowProcessFallback ? `process-${process.pid}` : undefined;
64
- }
65
-
66
- function readEntries(ctx: ExtensionContext): readonly unknown[] {
67
- const manager = sessionManager(ctx);
68
- try {
69
- const branch = manager.getBranch?.();
70
- if (Array.isArray(branch)) return branch;
71
- } catch {
72
- /* Fall through to getEntries when a mock or older host has no branch reader. */
73
- }
74
- try {
75
- const entries = manager.getEntries?.();
76
- if (Array.isArray(entries)) return entries;
77
- } catch {
78
- /* Missing persistence only means there is no stored chosen identity to restore. */
79
- }
80
- return [];
81
46
  }
82
47
 
83
48
  function setIdentityStatus(ctx: ExtensionContext, name: string): void {
@@ -90,9 +55,7 @@ function setIdentityStatus(ctx: ExtensionContext, name: string): void {
90
55
 
91
56
  function activeTools(pi: ExtensionAPI): readonly string[] {
92
57
  try {
93
- const getActiveTools = (pi as unknown as { getActiveTools?: () => string[] }).getActiveTools;
94
- const names = getActiveTools?.call(pi);
95
- return Array.isArray(names) ? names : [];
58
+ return pi.getActiveTools();
96
59
  } catch {
97
60
  return [];
98
61
  }
@@ -112,7 +75,7 @@ function messageCustomType(message: AgentMessage): string | undefined {
112
75
  function hasInboundPeerRequest(messages: readonly AgentMessage[]): boolean {
113
76
  return messages.some((message) => {
114
77
  const customType = messageCustomType(message);
115
- return customType === "exocom_received" || customType === "exocom-inbound" || customType === "pi-persona:exocom-inbound";
78
+ return customType === "exocom_received";
116
79
  });
117
80
  }
118
81
 
@@ -137,39 +100,41 @@ function identityReceipt(name: string, changed: boolean): string {
137
100
 
138
101
  export function installIdentity(pi: ExtensionAPI, host: IdentityHost): SessionIdentity {
139
102
  let currentSessionId: string | undefined;
140
- let currentName = provisionalIdentityName(`process-${process.pid}`);
103
+ let currentName = "unavailable";
141
104
  let currentChosen = false;
142
- let currentSessionIsFallback = false;
143
- let started = false;
105
+ const unavailable = (ctx: ExtensionContext): void => {
106
+ currentSessionId = undefined;
107
+ currentName = "unavailable";
108
+ currentChosen = false;
109
+ setIdentityStatus(ctx, currentName);
110
+ };
111
+ const selectSession = (session: NonNullable<ReturnType<typeof readSession>>): void => {
112
+ if (currentSessionId === session.id) return;
113
+ const stored = findStoredIdentity(session.entries, session.id, host.reservedNames());
114
+ currentSessionId = session.id;
115
+ currentName = stored ?? provisionalIdentityName(session.id);
116
+ currentChosen = stored !== undefined;
117
+ };
144
118
 
145
119
  const identity: SessionIdentity = {
146
120
  start(ctx) {
147
- const detectedSessionId = readSessionId(ctx, false);
148
- const sessionId = detectedSessionId ?? readSessionId(ctx, true)!;
149
- if (started && currentSessionId === sessionId) {
150
- setIdentityStatus(ctx, currentName);
151
- return;
152
- }
153
- currentSessionId = sessionId;
154
- currentSessionIsFallback = detectedSessionId === undefined;
155
- const stored = findStoredIdentity(readEntries(ctx), sessionId, host.reservedNames());
156
- currentName = stored ?? provisionalIdentityName(sessionId);
157
- currentChosen = stored !== undefined;
158
- started = true;
121
+ const session = readSession(ctx);
122
+ if (!session) { unavailable(ctx); return; }
123
+ selectSession(session);
159
124
  setIdentityStatus(ctx, currentName);
160
125
  },
161
126
 
162
127
  rename(raw, ctx) {
163
128
  const name = sanitizeIdentityName(raw, host.reservedNames());
164
129
  if (!name) throw new Error("identity name is empty, unsafe, generic, or reserved by a persona/agent");
165
- const sessionId = readSessionId(ctx, false) ?? (currentSessionIsFallback ? undefined : currentSessionId);
166
- if (!sessionId) throw new Error("identity session is unavailable");
167
- if (currentChosen && currentSessionId === sessionId && currentName === name) return currentName;
130
+ const session = readSession(ctx);
131
+ if (!session) { unavailable(ctx); throw new Error("identity session is unavailable"); }
132
+ selectSession(session);
133
+ if (currentChosen && currentName === name) return currentName;
168
134
 
169
135
  // Persistence comes first. A failed append therefore cannot leave memory or telemetry
170
136
  // claiming a name that is absent from the session history.
171
- pi.appendEntry(IDENTITY_ENTRY_CUSTOM_TYPE, makeIdentityEntry(sessionId, name));
172
- currentSessionId = sessionId;
137
+ pi.appendEntry(IDENTITY_ENTRY_CUSTOM_TYPE, makeIdentityEntry(session.id, name));
173
138
  currentName = name;
174
139
  currentChosen = true;
175
140
  setIdentityStatus(ctx, currentName);
@@ -202,17 +167,18 @@ export function installIdentity(pi: ExtensionAPI, host: IdentityHost): SessionId
202
167
  return { content: [{ type: "text", text }], details: { name, changed } };
203
168
  },
204
169
  renderCall(args, theme) {
205
- const name = compactInlineText(args.name, { maxChars: 32 }) || "?";
170
+ const name = compactInlineText(args.name ?? "", { maxChars: 32 }) || "?";
206
171
  return new Text(`${theme.fg("toolTitle", theme.bold("Agent Name "))}${theme.fg("accent", name)}`, 0, 0);
207
172
  },
208
- renderResult(result, { expanded }, theme) {
173
+ renderResult(result, { expanded }, theme, context) {
174
+ const failed = context?.isError ?? result.isError ?? false;
209
175
  const details = result.details as unknown as { name?: unknown; changed?: unknown } | undefined;
210
- const name = typeof details?.name === "string" ? compactInlineText(details.name, { maxChars: 32 }) : "";
211
- const first = result.content.find((item) => item.type === "text");
212
- const rendered = name
213
- ? identityReceipt(name, details?.changed !== false)
214
- : first?.type === "text" ? compactInlineText(first.text, { maxChars: 96 }) : "Agent name failed";
215
- return new Text(theme.fg(name ? (expanded ? "toolOutput" : "accent") : "error", rendered), 0, 0);
176
+ const name = !failed && typeof details?.name === "string" ? details.name : "";
177
+ // Failures share the one literal renderer (error heading, bounded preview, full expanded
178
+ // text); a chosen handle is a one-line purpose receipt collapsed and the complete literal
179
+ // receipt expanded.
180
+ if (!name || expanded) return renderLiteralToolResult(result, { expanded }, theme, context);
181
+ return new Text(theme.fg("accent", compactInlineText(identityReceipt(name, details?.changed !== false), { maxChars: 80 })), 0, 0);
216
182
  },
217
183
  });
218
184
 
@@ -228,7 +194,7 @@ export function installIdentity(pi: ExtensionAPI, host: IdentityHost): SessionId
228
194
  inboundPeer: hasInboundPeerRequest(event.messages),
229
195
  ...(namingTool === undefined ? {} : { namingTool }),
230
196
  };
231
- const content = buildIdentityContext(currentChosen, contextOptions);
197
+ const content = currentSessionId === undefined ? undefined : buildIdentityContext(currentChosen, contextOptions);
232
198
  if (content === undefined) return { messages };
233
199
  const contextMessage = {
234
200
  role: "custom" as const,
@@ -4,10 +4,11 @@
4
4
  */
5
5
 
6
6
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
7
+ import type { ModelsRefreshResult } from "@earendil-works/pi-ai";
7
8
 
8
9
  import type { ModelParticipant } from "../persona/model-participants.ts";
9
10
  import { compactInlineText } from "../ui/presentation.ts";
10
- import { ModelPicker, orderModelRefs } from "../ui/model-picker.ts";
11
+ import { ModelPicker, orderModelRefs, type ModelPickerMetadata } from "../ui/model-picker.ts";
11
12
 
12
13
  export interface PersonaModelOwner {
13
14
  name: string;
@@ -34,6 +35,8 @@ export interface PersonaModelSettingsDeps {
34
35
  write: (owner: string, patch: Record<string, string | null>) => void;
35
36
  /** Session/persona liveness guard supplied by the extension. */
36
37
  isCurrent?: (ctx: ExtensionContext) => boolean;
38
+ /** Refresh adapter supplied by the catalog owner to keep derived discovery caches consistent. */
39
+ refreshCatalog?: (signal: AbortSignal) => Promise<ModelsRefreshResult | undefined>;
37
40
  }
38
41
 
39
42
  const DONE = "Done";
@@ -124,7 +127,7 @@ export async function openPersonaModelSettings(ctx: ExtensionContext, deps: Pers
124
127
  return;
125
128
  }
126
129
  if (choice === REFRESH) {
127
- if (!(await refreshCatalog(ctx, isCurrent))) return;
130
+ if (!(await refreshCatalog(ctx, isCurrent, deps.refreshCatalog))) return;
128
131
  continue;
129
132
  }
130
133
  if (otherAssignmentsOption && choice === otherAssignmentsOption) {
@@ -167,20 +170,26 @@ export async function openPersonaModelSettings(ctx: ExtensionContext, deps: Pers
167
170
  }
168
171
  if (action !== actions[0]) continue;
169
172
 
170
- const modelRefs = availableModelRefs(ctx);
173
+ const models = availableModels(ctx);
174
+ const modelRefs = orderModelRefs(models.map((model) => `${model.provider}/${model.id}`), ctx.model?.provider);
171
175
  if (modelRefs.length === 0) {
172
176
  ctx.ui.notify("No authenticated models are available. Use /login, then choose Set or change again; saved assignments were kept.", "warning");
173
177
  continue;
174
178
  }
179
+ const current = rows[rowIndex]!;
175
180
  const modelTitle = compactInlineText(
176
- `Model for ${participant.label} · current: ${participant.model ?? "none"} (${participant.source})`,
181
+ `Model for ${participant.label} · current: ${current.model} (${current.source})`,
177
182
  { maxChars: 180 },
178
183
  );
179
184
  let modelChoice: string | undefined;
180
185
  try {
181
186
  modelChoice = ctx.mode === "tui"
182
187
  ? await ctx.ui.custom<string | undefined>(
183
- (tui, theme, _keybindings, done) => new ModelPicker(tui, theme, modelTitle, modelRefs, ctx.model?.provider, done),
188
+ (tui, theme, keybindings, done) => new ModelPicker(tui, theme, modelTitle, modelRefs, ctx.model?.provider, done, {
189
+ models,
190
+ ...(current.currentRef !== undefined ? { currentRef: current.currentRef } : {}),
191
+ ...(keybindings ? { keybindings } : {}),
192
+ }),
184
193
  { overlay: false },
185
194
  )
186
195
  : await select(ctx, modelTitle, modelRefs);
@@ -199,7 +208,7 @@ function participantRow(
199
208
  saved: Readonly<Record<string, string>>,
200
209
  staged: Readonly<Record<string, string | null>>,
201
210
  index: number,
202
- ): { option: string } {
211
+ ): { option: string; model: string; source: string; currentRef?: string } {
203
212
  const hasPendingDirect = Object.prototype.hasOwnProperty.call(staged, participant.key);
204
213
  const pendingDirect = hasPendingDirect ? staged[participant.key] : undefined;
205
214
  const hasPendingShared = participant.key !== participant.agent && Object.prototype.hasOwnProperty.call(staged, participant.agent);
@@ -208,22 +217,30 @@ function participantRow(
208
217
  const sharedSaved = participant.key !== participant.agent ? effectiveSavedValue(participant.agent, saved, staged) : undefined;
209
218
  let model: string;
210
219
  let source: string;
220
+ let currentRef: string | undefined;
211
221
  if (typeof pendingDirect === "string") {
212
222
  model = pendingDirect;
223
+ currentRef = pendingDirect;
213
224
  source = "assignment (pending)";
214
225
  } else if (pendingDirect === null) {
215
226
  if (sharedSaved) {
216
227
  model = sharedSaved;
228
+ currentRef = sharedSaved;
217
229
  source = "shared assignment (pending fallback)";
218
230
  } else {
219
231
  model = "resolve lower precedence next run";
220
232
  source = "pending reset";
221
233
  }
234
+ } else if (typeof pendingShared === "string" && !directSaved && participant.source !== "inline") {
235
+ model = pendingShared;
236
+ currentRef = pendingShared;
237
+ source = "shared assignment (pending)";
222
238
  } else if (hasPendingShared && pendingShared === null && !directSaved) {
223
239
  model = "resolve lower precedence next run";
224
240
  source = "shared reset pending";
225
241
  } else {
226
242
  model = participant.model ?? "none";
243
+ currentRef = participant.model;
227
244
  source = participant.source;
228
245
  }
229
246
  const sharedNote = !directSaved && sharedSaved
@@ -235,7 +252,8 @@ function participantRow(
235
252
  : hasPendingShared && pendingShared === null ? " · shared reset pending"
236
253
  : "";
237
254
  const option = `${index + 1}. ${safeInline(participant.label, 80)} · ${safeInline(model, 90)} (${safeInline(source, 24)})${sharedNote}${pendingNote}`;
238
- return { option: compactInlineText(option, { maxChars: 200 }) };
255
+ return { option: compactInlineText(option, { maxChars: 200 }), model, source,
256
+ ...(currentRef !== undefined ? { currentRef } : {}) };
239
257
  }
240
258
 
241
259
  function effectiveSavedValue(
@@ -247,20 +265,20 @@ function effectiveSavedValue(
247
265
  return saved[key];
248
266
  }
249
267
 
250
- function availableModelRefs(ctx: ExtensionContext): string[] {
268
+ function availableModels(ctx: ExtensionContext): ModelPickerMetadata[] {
251
269
  try {
252
- const unique = new Set<string>();
270
+ const unique = new Map<string, ModelPickerMetadata>();
253
271
  for (const model of ctx.modelRegistry.getAvailable()) {
254
272
  if (typeof model.provider !== "string" || !model.provider.trim() || typeof model.id !== "string" || !model.id.trim()) continue;
255
- unique.add(`${model.provider}/${model.id}`);
273
+ unique.set(`${model.provider}/${model.id}`, model);
256
274
  }
257
- return orderModelRefs([...unique], ctx.model?.provider);
275
+ return [...unique.values()];
258
276
  } catch {
259
277
  return [];
260
278
  }
261
279
  }
262
280
 
263
- async function refreshCatalog(ctx: ExtensionContext, isCurrent: () => boolean): Promise<boolean> {
281
+ async function refreshCatalog(ctx: ExtensionContext, isCurrent: () => boolean, refresh?: PersonaModelSettingsDeps["refreshCatalog"]): Promise<boolean> {
264
282
  if (!ctx.modelRegistry.refresh) {
265
283
  ctx.ui.notify("This Pi host cannot explicitly refresh the model catalog; existing assignments were kept.", "warning");
266
284
  return true;
@@ -268,7 +286,7 @@ async function refreshCatalog(ctx: ExtensionContext, isCurrent: () => boolean):
268
286
  const deadline = AbortSignal.timeout(20_000);
269
287
  const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
270
288
  try {
271
- const result = await ctx.modelRegistry.refresh({ signal });
289
+ const result = await (refresh ? refresh(signal) : ctx.modelRegistry.refresh({ signal }));
272
290
  if (ctx.signal?.aborted) {
273
291
  ctx.ui.notify("Model catalog refresh was cancelled; saved assignments were kept.", "info");
274
292
  return false;
@@ -0,0 +1,136 @@
1
+ /** Session-native terminal archive ownership. Owners are frozen before asynchronous work starts. */
2
+ import { randomUUID } from "node:crypto";
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { TerminalResultArchive } from "../results/archive.ts";
5
+ import { resultReceipt, type ResultReceipt } from "../results/retrieval.ts";
6
+ import type { TerminalResultInput, TerminalStatus } from "../results/types.ts";
7
+ import type { AgentResult } from "../orchestration/types.ts";
8
+ import { emptyUsage } from "../engine/stream.ts";
9
+ import { type AsyncRun, type AsyncRunTracker } from "../engine/async.ts";
10
+
11
+ export interface CaptureMetadata {
12
+ originRunId?: string;
13
+ label?: string;
14
+ status?: TerminalStatus;
15
+ partial?: string;
16
+ }
17
+ export type CaptureResult = (origin: string, result: AgentResult, meta?: CaptureMetadata) => ResultReceipt;
18
+ interface Owner {
19
+ id: string;
20
+ file: string | undefined;
21
+ manager: ExtensionContext["sessionManager"];
22
+ generation: string;
23
+ archive: TerminalResultArchive;
24
+ }
25
+
26
+ export class SessionResults {
27
+ private owner: Owner | undefined;
28
+ private disposed = false;
29
+ private readonly captures = new Map<AsyncRun, CaptureResult>();
30
+ private readonly detach: Array<() => void> = [];
31
+
32
+ constructor(privateApi: Pick<ExtensionAPI, "appendEntry">) {
33
+ this.append = (type, data) => privateApi.appendEntry(type, data);
34
+ }
35
+ private readonly append: (type: string, data: unknown) => void;
36
+
37
+ activate(ctx: ExtensionContext): TerminalResultArchive {
38
+ // A session_shutdown revokes the previous owner; a later session_start in the same process
39
+ // (the documented multi-activation lifecycle) starts a FRESH owner, never a zombie.
40
+ if (this.disposed) {
41
+ this.disposed = false;
42
+ for (const off of this.detach.splice(0)) {
43
+ try { off(); } catch { /* teardown must not block a new activation */ }
44
+ }
45
+ }
46
+ const manager = ctx.sessionManager;
47
+ // A context without a session manager (e.g. --no-session / UI-less runs) still gets an
48
+ // honest MEMORY-ONLY owner: bounded runtime retention, no persistence claims.
49
+ const id = manager ? manager.getSessionId() : `memory:${randomUUID()}`;
50
+ const file = manager ? manager.getSessionFile() : undefined;
51
+ const previous = this.owner;
52
+ // File identity owns persisted history. A memory owner additionally needs manager identity.
53
+ if (previous && previous.id === id && previous.file === file && (file !== undefined || previous.manager === manager)) return previous.archive;
54
+ const generation = randomUUID();
55
+ // Freeze all-branch history now. A storage read failure belongs to archive accounting,
56
+ // not to the ordinary work being launched; never retry through a mutable future context.
57
+ let entries: readonly unknown[] = [];
58
+ let historyError: unknown;
59
+ let historyFailed = false;
60
+ try { entries = manager ? manager.getEntries() : []; } catch (error) { historyError = error; historyFailed = true; }
61
+ const owner: Owner = {
62
+ id, file, manager, generation,
63
+ archive: new TerminalResultArchive({
64
+ entries: () => { if (historyFailed) throw historyError; return entries; },
65
+ persistent: file !== undefined,
66
+ append: (type, data) => {
67
+ if (this.owner !== owner || (manager && (manager.getSessionId() !== id || manager.getSessionFile() !== file))) {
68
+ throw new Error("Terminal archive owner was revoked");
69
+ }
70
+ this.append(type, data);
71
+ },
72
+ }),
73
+ };
74
+ this.owner = owner;
75
+ return owner.archive;
76
+ }
77
+
78
+ get archive(): TerminalResultArchive | undefined { return this.owner?.archive; }
79
+
80
+ /** Bind at logical creation, not at settlement through lastCtx. Each scope has its own origin namespace. */
81
+ bind(scope: string): CaptureResult {
82
+ const owner = this.owner;
83
+ const prefix = `${owner?.generation ?? randomUUID()}:${scope}`;
84
+ return (origin, result, meta = {}) => {
85
+ if (!owner || this.owner !== owner || (owner.manager && (owner.manager.getSessionId() !== owner.id || owner.manager.getSessionFile() !== owner.file))) {
86
+ return { warning: "Terminal report not archived: owning session activation is no longer current." };
87
+ }
88
+ const status = meta.status ?? (result.ok ? "done" : result.failureKind === "abort" ? "stopped" : "failed");
89
+ const output = result.output.trim() ? result.output : meta.partial?.trim() ? meta.partial : result.error ?? "(no output)";
90
+ const input: TerminalResultInput = {
91
+ originKey: `${prefix}:${origin}`, status, agent: result.agent.slice(0, 128), body: output,
92
+ provenance: result.output.trim() ? "result-output" : meta.partial?.trim() ? "partial-output" : result.error ? "error" : "result-output",
93
+ ...(meta.originRunId ? { originRunId: meta.originRunId.slice(0, 128) } : {}),
94
+ ...(meta.label ? { label: meta.label.slice(0, 256) } : {}),
95
+ ...(result.modelUsed ? { model: result.modelUsed.slice(0, 512) } : {}),
96
+ ...(result.thinkingRequested ? { thinkingRequested: result.thinkingRequested } : {}),
97
+ ...(result.thinkingEffective ? { thinkingEffective: result.thinkingEffective } : {}),
98
+ ...(result.error ? { error: result.error.slice(0, 2048) } : {}),
99
+ usage: { input: result.usage.input, output: result.usage.output,
100
+ ...(result.usage.cacheRead !== undefined ? { cacheRead: result.usage.cacheRead } : {}),
101
+ ...(result.usage.cacheWrite !== undefined ? { cacheWrite: result.usage.cacheWrite } : {}),
102
+ ...(result.usage.cost !== undefined ? { cost: result.usage.cost } : {}),
103
+ ...(result.usage.turns !== undefined ? { turns: result.usage.turns } : {}),
104
+ },
105
+ };
106
+ try { return resultReceipt(owner.archive.save(input)); }
107
+ catch { return { warning: "Terminal report not archived: storage callback failed." }; }
108
+ };
109
+ }
110
+
111
+ /** Register before the ordinary completion listener. onCreate precedes even a synchronous thunk failure. */
112
+ track(tracker: AsyncRunTracker): void {
113
+ this.detach.push(tracker.onCreate((run) => this.captures.set(run, this.bind("tracker"))));
114
+ this.detach.push(tracker.onComplete((run) => {
115
+ const capture = this.captures.get(run);
116
+ this.captures.delete(run);
117
+ if (!capture) return;
118
+ const result = run.result ?? { agent: run.agent, output: "", usage: emptyUsage(), ok: false, ...(run.error ? { error: run.error } : {}) };
119
+ run.resultReceipt = capture(run.id, result, {
120
+ originRunId: run.id, ...(run.label ? { label: run.label } : {}),
121
+ status: run.status === "running" ? "failed" : run.status,
122
+ partial: run.progress.output,
123
+ });
124
+ if (run.result) run.result.resultReceipt = { ...run.resultReceipt };
125
+ }));
126
+ }
127
+
128
+ dispose(): void {
129
+ this.disposed = true;
130
+ this.owner = undefined;
131
+ this.captures.clear();
132
+ for (const off of this.detach.splice(0)) {
133
+ try { off(); } catch { /* best-effort teardown */ }
134
+ }
135
+ }
136
+ }
@@ -9,8 +9,6 @@ import { boundCompletionSurface, MAX_COMPLETION_REPORT_CHARS } from "../engine/a
9
9
  import type { InboundDecision } from "../exocom/inbound.ts";
10
10
  import { EXOCOM } from "../exocom/limits.ts";
11
11
  import type { DisplayPeer, ExocomInboundResult } from "../exocom/plane.ts";
12
- import type { DelegateView } from "../tools/delegate.ts";
13
- import type { AgentNodeStatus } from "../ui/agent-tree.ts";
14
12
  import { compactInlineText } from "../ui/presentation.ts";
15
13
 
16
14
  const PI_PERSONA_TOOL_ERROR = "__piPersonaToolError";
@@ -113,10 +111,20 @@ export function exocomInboundBatchSize(items: readonly string[]): number {
113
111
 
114
112
  export const EXOCOM_INBOX_MAX = 3 * EXOCOM.SENDER_MAX_MSGS;
115
113
 
116
- export function agentNodeStatusForDelegate(view: Pick<DelegateView, "running" | "ok"> & { failureKind?: DelegateView["failureKind"] }): AgentNodeStatus {
117
- if (view.running) return "running";
118
- if (view.failureKind === "abort") return "stopped";
119
- return view.ok ? "done" : "failed";
114
+ /** A headless session (`pi -p`): a launching tool joins its run before returning, because nothing would
115
+ * deliver a follow-up once the process exits. Interactive (TUI/RPC) sessions launch and return a receipt. */
116
+ export function isInline(ctx: { hasUI?: boolean }): boolean {
117
+ return ctx.hasUI !== true;
118
+ }
119
+
120
+ /**
121
+ * The closing advice of a `timer`/`monitor` arm result. Headless `pi -p` exits when the turn settles,
122
+ * so a wake lands only if it fires first: ending the turn to wait for one would drop it.
123
+ */
124
+ export function armWakeAdvice(ctx: { hasUI?: boolean } | undefined, noun: string, pending: string, interactive: string): string {
125
+ return ctx?.hasUI === false
126
+ ? `Headless pi -p: the process exits when this turn settles, so this ${noun} wakes you only if it fires before then. Do not end your turn expecting it; finish inline, or report the ${pending} as pending.`
127
+ : interactive;
120
128
  }
121
129
 
122
130
  export function sendPersonaFollowUp(
@@ -164,6 +172,23 @@ export function shouldReportHeartbeatFailure(consecutiveFailures: number): boole
164
172
  return consecutiveFailures === 1 || consecutiveFailures % 10 === 0;
165
173
  }
166
174
 
175
+ /** One bounded, terminal-safe cause line for an operator toast. Error `message`s interpolate raw
176
+ * provider/filesystem text (multi-line, ANSI-capable) — every toast that embeds one goes through
177
+ * this so the notification stays a single sanitized line. The untruncated text keeps its other
178
+ * surface (stderr, /doctor, the tool result). */
179
+ export function toastCause(message: string, maxChars = 160): string {
180
+ return compactInlineText(message, { maxChars });
181
+ }
182
+
183
+ /** A fair prefix of a name list, bounded for one toast line: `"a, b, c, +N more"`. Discovery
184
+ * reports (collisions, seeded files, installed personas) grow with the user's own directory, so
185
+ * an unbounded `join(", ")` would let the toast wrap over many terminal rows. */
186
+ export function compactNameList(names: readonly string[], max = 8): string {
187
+ const clean = names.map((name) => compactInlineText(name, { maxChars: 80 })).filter((name) => name.length > 0);
188
+ if (clean.length <= max) return clean.join(", ");
189
+ return `${clean.slice(0, max).join(", ")}, +${clean.length - max} more`;
190
+ }
191
+
167
192
  export function fenceIntercomOutcome(out: { text: string; details: { action: string; messages?: unknown[] } }, fence: (t: string) => string): string {
168
193
  const untrusted = out.details.action === "inbox" && (out.details.messages?.length ?? 0) > 0;
169
194
  return untrusted ? fence(out.text) : out.text;
@@ -171,5 +196,5 @@ export function fenceIntercomOutcome(out: { text: string; details: { action: str
171
196
 
172
197
  export function coachingDisabledHint(personaName: string | undefined): string {
173
198
  const who = sanitizeDisplayLabel(personaName ?? "default", "default");
174
- return `(coaching is OFF for persona "${who}" — sub-agents get no contact_supervisor tool, so the message bus is empty. To just watch or redirect them use action "peek"/"steer"; to exchange messages, add \`coaching: true\` or switch to a coaching persona.)`;
199
+ return `(coaching is OFF for persona "${who}" — sub-agents get no contact_supervisor tool, so the message bus is empty. To redirect one use action "steer"; results return on their own. To exchange messages, add \`coaching: true\` or switch to a coaching persona.)`;
175
200
  }