@aeondave/pi-persona 1.17.1 → 1.17.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/AGENTS.md +59 -28
  2. package/CHANGELOG.md +234 -0
  3. package/README.md +16 -12
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +183 -55
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +82 -32
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +15 -10
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +1 -1
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +165 -146
  48. package/src/engine/child.ts +2 -2
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +12 -7
  51. package/src/engine/stream.ts +4 -20
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/engine.ts +43 -16
  54. package/src/extension/hooks.ts +106 -36
  55. package/src/extension/persona-model-settings.ts +31 -13
  56. package/src/extension/shared.ts +15 -7
  57. package/src/extension.ts +505 -241
  58. package/src/monitor/archive.ts +235 -0
  59. package/src/monitor/session.ts +21 -1
  60. package/src/orchestration/flow-journal.ts +26 -5
  61. package/src/orchestration/flow-run.ts +38 -13
  62. package/src/orchestration/flow.ts +9 -2
  63. package/src/orchestration/reducers.ts +9 -5
  64. package/src/orchestration/render.ts +1 -1
  65. package/src/orchestration/sdk.ts +17 -5
  66. package/src/orchestration/strategies/compete.ts +5 -1
  67. package/src/orchestration/strategies/council-rounds.ts +2 -0
  68. package/src/orchestration/strategies/critic-loop.ts +3 -0
  69. package/src/orchestration/strategies/debate.ts +2 -0
  70. package/src/orchestration/strategies/fanout.ts +3 -2
  71. package/src/orchestration/strategies/judge.ts +5 -1
  72. package/src/orchestration/strategies/magi.ts +40 -23
  73. package/src/orchestration/strategies/map.ts +13 -3
  74. package/src/orchestration/strategies/pair.ts +2 -0
  75. package/src/orchestration/strategies/pipeline.ts +2 -0
  76. package/src/orchestration/strategies/synthesize.ts +27 -7
  77. package/src/orchestration/strategy.ts +31 -0
  78. package/src/persona/persona.ts +7 -3
  79. package/src/telemetry/contract.ts +1 -1
  80. package/src/tools/background-run.ts +151 -0
  81. package/src/tools/council.ts +77 -52
  82. package/src/tools/delegate-tool.ts +310 -280
  83. package/src/tools/delegate.ts +22 -210
  84. package/src/tools/exocom-work.ts +18 -53
  85. package/src/tools/exocom.ts +15 -19
  86. package/src/tools/flow.ts +85 -28
  87. package/src/tools/intercom-tool.ts +108 -118
  88. package/src/tools/intercom.ts +1 -1
  89. package/src/tools/models.ts +19 -9
  90. package/src/tools/monitor.ts +39 -6
  91. package/src/tools/timer.ts +11 -13
  92. package/src/ui/agent-overlay.ts +55 -14
  93. package/src/ui/agent-tree.ts +84 -18
  94. package/src/ui/compact-widget.ts +25 -0
  95. package/src/ui/failure-summary.ts +55 -0
  96. package/src/ui/literal-tool-result.ts +24 -0
  97. package/src/ui/model-picker.ts +158 -81
  98. package/src/ui/persona-settings.ts +9 -3
  99. package/src/ui/presentation.ts +24 -0
  100. package/src/ui/report-body.ts +63 -0
  101. package/src/ui/session-cards.ts +189 -0
  102. package/src/ui/wake-rows.ts +262 -0
  103. package/teams.yaml +5 -1
@@ -61,8 +61,6 @@ export interface StreamState {
61
61
  /** Calls whose start was surfaced but whose end has not arrived. This is not a retention
62
62
  * cache: active ids are never evicted merely because completed calls churn. */
63
63
  activeToolCalls?: Set<string>;
64
- /** Timestamp of the latest textual tool_execution_update, used only as a liveness signal. */
65
- toolOutputAt?: number;
66
64
  /** Recently completed call ids whose start/end phase was surfaced. Both caches are bounded;
67
65
  * they suppress duplicate wire events without growing for the lifetime of a long run. */
68
66
  seenToolStarts?: Set<string>;
@@ -238,7 +236,7 @@ function appendTranscript(state: StreamState, chunk: string, sep: string): void
238
236
 
239
237
  /** Fold one parsed stream event into the accumulating state (in place), returning a
240
238
  * deduplicated structured lifecycle transition when the runtime supplied a stable call id. */
241
- export function applyEvent(state: StreamState, event: unknown, observedAt?: number): ToolEvent | undefined {
239
+ export function applyEvent(state: StreamState, event: unknown): ToolEvent | undefined {
242
240
  if (!isObject(event)) return undefined;
243
241
 
244
242
  // Track the current tool so the UI can show "running: grep src/…" while a
@@ -273,20 +271,6 @@ export function applyEvent(state: StreamState, event: unknown, observedAt?: numb
273
271
  return toolEvent;
274
272
  }
275
273
 
276
- if (event.type === "tool_execution_update") {
277
- const callId = event.toolCallId;
278
- const toolName = event.toolName;
279
- const partialResult = event.partialResult;
280
- const hasText = isObject(partialResult) && Array.isArray(partialResult.content)
281
- && partialResult.content.some((part) => isObject(part) && part.type === "text" && typeof part.text === "string" && part.text.length > 0);
282
- if (typeof callId === "string" && callId.length > 0
283
- && typeof toolName === "string" && toolName.length > 0
284
- && state.activeToolCalls?.has(callId) === true && hasText && observedAt !== undefined) {
285
- state.toolOutputAt = observedAt;
286
- }
287
- return undefined;
288
- }
289
-
290
274
  // Live partial: pi streams the growing message as `message_update` events. Surface
291
275
  // its text (or reasoning) so a long-thinking agent shows progress, not just tokens.
292
276
  // ONLY the assistant's own message counts: pi also emits message_start/message_end for
@@ -355,10 +339,10 @@ export interface ProgressSnapshot {
355
339
  output: string;
356
340
  turns: number;
357
341
  tokens: number;
342
+ /** Copied cumulative counters for display; optional for legacy/custom progress sources. */
343
+ usage?: ChildUsage;
358
344
  /** The tool currently running (e.g. "grep src/…"), if any. */
359
345
  activity?: string;
360
- /** Timestamp of the latest textual tool output update, when available. */
361
- toolOutputAt?: number;
362
346
  /** One authoritative runtime tool lifecycle transition, when this snapshot is event-driven. */
363
347
  toolEvent?: ToolEvent;
364
348
  }
@@ -370,9 +354,9 @@ export function snapshot(state: StreamState, toolEvent?: ToolEvent): ProgressSna
370
354
  output: [state.transcript, state.partial].filter((s) => s).join("\n\n") || state.output,
371
355
  turns: state.usage.turns,
372
356
  tokens: state.usage.input + state.usage.output,
357
+ usage: { ...state.usage },
373
358
  };
374
359
  if (state.activity !== undefined) snap.activity = state.activity;
375
- if (state.toolOutputAt !== undefined) snap.toolOutputAt = state.toolOutputAt;
376
360
  if (toolEvent !== undefined) snap.toolEvent = toolEvent;
377
361
  return snap;
378
362
  }
@@ -13,6 +13,7 @@ import { canCallTool, canParticipateInExocom, EXOCOM_TOOL_NAMES } from "../core/
13
13
  import type { DelegationNudge } from "../core/nudge.ts";
14
14
  import { shortModel } from "../tools/delegate.ts";
15
15
  import { boundDisplayRows } from "../ui/presentation.ts";
16
+ import { compactWidget } from "../ui/compact-widget.ts";
16
17
  import type { PersonaController } from "../persona/controller.ts";
17
18
  import { type InstanceDescriptor, type PeerDescriptor } from "../telemetry/contract.ts";
18
19
  import type { TelemetryProducer } from "../telemetry/producer.ts";
@@ -327,7 +328,7 @@ export function installExocom(pi: ExtensionAPI, host: ExocomHost): ExocomInstall
327
328
  return `${quiet ? "💤" : "📡"} ${name}${persona ? ` (${persona})` : ""} · ${shortModel(model) || "?"} · ctx ${contextPct}% · recv ${exocomPlane?.receivedFromPeer(p.session_id) ?? 0} · sent ${exocomPlane?.sentToPeer(p.session_id) ?? 0}${workspaceBit}`;
328
329
  });
329
330
  const lines = boundDisplayRows(local, peerRows, 7, "exocom_list for the full pool");
330
- host.lastCtx.ui.setWidget("persona-exocom", lines, { placement: "aboveEditor" });
331
+ host.lastCtx.ui.setWidget("persona-exocom", compactWidget(lines), { placement: "aboveEditor" });
331
332
  } catch {
332
333
  /* cosmetic — the widget is best-effort */
333
334
  }
@@ -8,6 +8,7 @@ import { type ContractDef, DEFAULT_CONTRACT, installedContractNames } from "../c
8
8
  import type { RunLimits } from "../core/capabilities.ts";
9
9
  import type { PiPersonaConfig } from "../core/config.ts";
10
10
  import { isThinkingLevel } from "../core/types.ts";
11
+ import type { ModelCostMetadata } from "../core/model-tier.ts";
11
12
  import { personaModels, type PersonaConfigStore } from "../persona/config-store.ts";
12
13
  import { participantKey } from "../persona/model-participants.ts";
13
14
  import type { PersonaController, PersonaHost } from "../persona/controller.ts";
@@ -310,33 +311,59 @@ export function createBuildEngine(d: () => BuildEngineDeps): BuildEngine {
310
311
  }
311
312
  }
312
313
 
313
- // The models the user can intentionally route to — NOT every built-in catalog entry.
314
- // `getAvailable()` is normally the authenticated set, but extension-native providers
315
- // (for example a local subscription/CLI bridge) can be runnable without appearing in
316
- // that auth snapshot. Union their registered catalogs, plus the active session provider,
317
- // while keeping unrelated unauthenticated built-ins hidden. If an older Pi lacks the
318
- // availability API, retain its historical getAll() compatibility behavior.
319
- export function configuredModels(ctx: ExtensionContext): Array<{ provider: string; id: string }> {
314
+ // The host's model-level availability snapshot, plus the exact active model. A provider's
315
+ // credentials do not grant every model in its catalog. Extension-native bridges can be
316
+ // absent from an auth snapshot: restore only their explicit registered model lists in that
317
+ // case, never every inherited model under their provider id. Older facades without an
318
+ // availability API retain their historical getAll() fallback.
319
+ export function configuredModels(ctx: ExtensionContext): ModelCostMetadata[] {
320
320
  const reg = ctx.modelRegistry;
321
321
  const all = reg.getAll();
322
322
  let available: typeof all;
323
323
  try {
324
324
  available = reg.getAvailable();
325
325
  } catch {
326
- return all.map((m) => ({ provider: m.provider, id: m.id }));
326
+ return all;
327
327
  }
328
- const trustedProviders = new Set<string>();
329
- if (ctx.model?.provider) trustedProviders.add(ctx.model.provider);
328
+ const byRef = new Map(available.map((m) => [`${m.provider}\0${m.id}`, m]));
329
+ if (ctx.model) byRef.set(`${ctx.model.provider}\0${ctx.model.id}`, ctx.model);
330
+ const availableProviders = new Set(available.map((m) => m.provider));
331
+ const declaredRefs = new Set<string>();
332
+ let registered: readonly string[] = [];
330
333
  try {
331
- const registered = (reg as typeof reg & { getRegisteredProviderIds?: () => string[] }).getRegisteredProviderIds?.() ?? [];
332
- for (const provider of registered) trustedProviders.add(provider);
334
+ registered = reg.getRegisteredProviderIds?.() ?? [];
333
335
  } catch {
334
336
  /* a third-party/older registry facade may not expose extension provider ids */
335
337
  }
336
- const byRef = new Map(available.map((m) => [`${m.provider}\0${m.id}`, m]));
338
+ for (const provider of registered) {
339
+ // A populated snapshot can intentionally exclude individual models for this credential.
340
+ if (availableProviders.has(provider)) continue;
341
+ let native: ReturnType<typeof reg.getRegisteredNativeProvider>;
342
+ try {
343
+ native = reg.getRegisteredNativeProvider?.(provider);
344
+ // An empty credential-filtered list is authoritative too; it need not be a stale snapshot.
345
+ if (reg.getProvider?.(provider)?.filterModels || native?.filterModels) continue;
346
+ } catch { continue; }
347
+ try {
348
+ const models = reg.getRegisteredProviderConfig?.(provider)?.models;
349
+ if (Array.isArray(models)) {
350
+ for (const model of models) {
351
+ if ((model.type === undefined || model.type === "chat") && typeof model.id === "string") declaredRefs.add(`${provider}\0${model.id}`);
352
+ }
353
+ }
354
+ } catch { /* one broken extension's metadata must not hide other providers */ }
355
+ try {
356
+ const models = native?.getModels();
357
+ if (Array.isArray(models)) {
358
+ for (const model of models) {
359
+ if (model.provider === provider && typeof model.id === "string") declaredRefs.add(`${provider}\0${model.id}`);
360
+ }
361
+ }
362
+ } catch { /* a native catalog may be temporarily unavailable */ }
363
+ }
337
364
  for (const model of all) {
338
- if (trustedProviders.has(model.provider)) byRef.set(`${model.provider}\0${model.id}`, model);
365
+ const ref = `${model.provider}\0${model.id}`;
366
+ if (declaredRefs.has(ref)) byRef.set(ref, model);
339
367
  }
340
- const list = [...byRef.values()];
341
- return list.map((m) => ({ provider: m.provider, id: m.id }));
368
+ return [...byRef.values()];
342
369
  }
@@ -10,10 +10,11 @@ import { buildExocomBrief, RUNTIME_CONTEXT_GUIDANCE } from "../core/brief.ts";
10
10
  import { canCallTool, canDelegateTo, canFanOut } from "../core/capabilities.ts";
11
11
  import type { PiPersonaConfig } from "../core/config.ts";
12
12
  import { fenceUntrusted } from "../core/fence.ts";
13
- import { HANDOFF_TOOL_NAMES, type DelegationNudge, type PersistenceNudge } from "../core/nudge.ts";
13
+ import { HANDOFF_TOOL_NAMES, longWaitSeconds, type DelegationNudge, type PersistenceNudge, type PollGuard } from "../core/nudge.ts";
14
+ import { findStoredRuns, makeRunsEntry, restoreNotice, RUNS_ENTRY_CUSTOM_TYPE } from "../core/session-runs.ts";
14
15
  import { buildSessionAnchor } from "../core/time.ts";
15
16
  import { readClockSnapshot } from "./clock.ts";
16
- import { type AsyncRun, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
17
+ import { type AsyncRun, type AsyncRunTracker, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
17
18
  import { constrainedTurnAllows } from "../exocom/gate.ts";
18
19
  import type { LedgerClaim } from "../exocom/ledger.ts";
19
20
  import type { DisplayPeer } from "../exocom/plane.ts";
@@ -33,12 +34,15 @@ import type { OrchestrationGrammar, Persona } from "../persona/persona.ts";
33
34
  import type { PersonaConfigStore } from "../persona/config-store.ts";
34
35
  import { TELEMETRY_EVENT_NAME, type TelemetryEvent } from "../telemetry/contract.ts";
35
36
  import { TelemetryProducer, type TelemetryAgentInput } from "../telemetry/producer.ts";
36
- import type { AgentTree, AgentNode } from "../ui/agent-tree.ts";
37
+ import { isWakeSource, type AgentTree, type AgentNode } from "../ui/agent-tree.ts";
37
38
  import { compactInlineText } from "../ui/presentation.ts";
38
39
  import type { FailureKind } from "../orchestration/types.ts";
39
40
  import type { ExocomInstall } from "../exocom/install.ts";
40
41
  import type { SessionIdentity } from "./identity.ts";
41
42
 
43
+ type SessionManagerLike = { getSessionId?: () => string; getEntries?: () => readonly unknown[] };
44
+ const sessionManagerOf = (ctx: ExtensionContext): SessionManagerLike => (ctx as ExtensionContext & { sessionManager?: SessionManagerLike }).sessionManager ?? {};
45
+
42
46
  type MandatoryOutcome = { label: string; output: string; ok: boolean; error?: string; failureKind?: FailureKind };
43
47
 
44
48
  export interface HookHost {
@@ -69,11 +73,15 @@ export interface HookHost {
69
73
  telemetryAgent(node: AgentNode): TelemetryAgentInput;
70
74
  delegationNudge: DelegationNudge;
71
75
  persistenceNudge: PersistenceNudge;
76
+ /** Refuses an unchanged repeat of an intercom peek/result; absent when the nudge kill switch is off. */
77
+ pollGuard: PollGuard | undefined;
78
+ /** The async-run tracker: what is still running, and the run-id counter a restore continues. */
79
+ tracker: Pick<AsyncRunTracker, "running" | "issued" | "continueFrom">;
72
80
  appendNudgeEntry(content: string): void;
73
81
  completionNotifier: IdleCoalescingNotifier<AsyncRun>;
74
82
  intercomNotifier: IdleCoalescingNotifier<PendingAsk>;
75
83
  timerNotifier: IdleCoalescingNotifier<TimerEntry>;
76
- timerScheduler: { cancelAll(): void };
84
+ timerScheduler: { cancelAll(): void; readonly size: number };
77
85
  monitors: MonitorSession;
78
86
  peekWatcher: PeekWatcher;
79
87
  stopPeek(): void;
@@ -124,13 +132,59 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
124
132
  }
125
133
  pendingSurrenderRelays.add(id);
126
134
  };
135
+ // `delegate`/`council`/`flow` calls (all background launches) whose tool_call passed but whose result has
136
+ // not landed. Pi prepares every call of a batch before any executes, so a same-batch launch + `sleep`
137
+ // would otherwise see no running child yet. A launch that later fails validation counts until its result lands: accepted.
138
+ const launching = new Set<string>();
139
+ const clearInFlight = (): void => { pendingSurrenderRelays.clear(); launching.clear(); };
140
+ /** A tool the model can call right now: active in the host and allowed by the persona. */
141
+ const toolAllowed = (name: string): boolean => {
142
+ const caps = h.controller.capabilities;
143
+ return pi.getActiveTools().includes(name) && (!caps || canCallTool(caps, name));
144
+ };
145
+ // ── restore honesty ───────────────────────────────────────────────────────
146
+ // A reload/resume/`pi -c` ends this instance and with it every background run, armed timer and
147
+ // monitor (memory only). Shutdown records what dies and the run-id counter in a session entry
148
+ // (core/session-runs.ts); the next start tells the restored supervisor once, passively, and
149
+ // continues the ids so they never repeat within one history. Best effort: a failure on either
150
+ // side degrades to the old behaviour, never to a broken shutdown or start.
151
+ let restoredIssued = 0; // the counter this instance started from: unchanged ⇒ nothing new to record
152
+ function recordLostWork(ctx: ExtensionContext): void {
153
+ try {
154
+ const sessionId = sessionManagerOf(ctx).getSessionId?.();
155
+ if (!sessionId) return;
156
+ const lost = {
157
+ runs: [...new Set([...h.tracker.running(), ...h.completionNotifier.peekPending()].map((run) => run.id))],
158
+ timers: h.timerScheduler.size,
159
+ monitors: h.monitors.list().length,
160
+ };
161
+ if (lost.runs.length === 0 && lost.timers === 0 && lost.monitors === 0 && h.tracker.issued <= restoredIssued) return;
162
+ pi.appendEntry(RUNS_ENTRY_CUSTOM_TYPE, makeRunsEntry(sessionId, h.tracker.issued, lost));
163
+ } catch { /* shutdown must complete */ }
164
+ }
165
+ function announceLostWork(ctx: ExtensionContext): void {
166
+ try {
167
+ const manager = sessionManagerOf(ctx);
168
+ const stored = findStoredRuns(manager.getEntries?.() ?? [], manager.getSessionId?.() ?? "");
169
+ if (!stored) return;
170
+ h.tracker.continueFrom(stored.issued);
171
+ restoredIssued = h.tracker.issued;
172
+ const notice = restoreNotice(stored.lost);
173
+ if (!notice) return;
174
+ pi.sendMessage({ customType: "pi-persona", content: notice, display: true }, { deliverAs: "nextTurn" }); // passive: never starts a turn
175
+ // Acknowledge so the newest entry is an empty loss: the same loss can never be announced twice.
176
+ pi.appendEntry(RUNS_ENTRY_CUSTOM_TYPE, makeRunsEntry(stored.sessionId, stored.issued, { runs: [], timers: 0, monitors: 0 }));
177
+ } catch { /* the session start must complete */ }
178
+ }
179
+
127
180
  // ── lifecycle ─────────────────────────────────────────────────────────────
128
181
  pi.on("session_start", async (_event, ctx) => {
129
- pendingSurrenderRelays.clear();
182
+ clearInFlight();
130
183
  h.lastCtx = ctx;
131
184
  h.delegationNudge.reset(); // a fresh session starts with a clean by-hand run
132
185
  h.childUsage.reset();
133
186
  h.publishPersonaCost();
187
+ announceLostWork(ctx);
134
188
  // Opt-in only (PI_PERSONA_SEED=on): auto-install the bundled defaults once. Default is off —
135
189
  // a fresh install shows no h.personas until `/persona seed` or `/persona restore`.
136
190
  if (h.config.seed && !existsSync(h.seedMarker())) {
@@ -182,8 +236,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
182
236
  await exocom.reconcile(ctx); // after persona activation, so plane + active tools share one gate
183
237
  await h.telemetry?.stop("session-replaced");
184
238
  h.telemetry = undefined;
185
- const sessionManager = (ctx as ExtensionContext & { sessionManager?: { getSessionId?: () => string } }).sessionManager;
186
- const sessionId = sessionManager?.getSessionId?.();
239
+ const sessionId = sessionManagerOf(ctx).getSessionId?.();
187
240
  if (sessionId) {
188
241
  const eventBus = (pi as unknown as { events?: { emit?: (name: string, event: TelemetryEvent) => void } }).events;
189
242
  try {
@@ -218,6 +271,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
218
271
  // published — a session start that follows no teardown carries no stale projection.
219
272
  h.lastAgentProjection.clear();
220
273
  for (const node of h.agentTree.snapshot()) {
274
+ if (isWakeSource(node)) continue; // timer/monitor rows are not agents: no replay, no projection
221
275
  h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
222
276
  h.telemetry.publishAgentAdded(h.telemetryAgent(node));
223
277
  }
@@ -227,8 +281,9 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
227
281
  });
228
282
 
229
283
  pi.on("session_shutdown", async (event, ctx) => {
230
- pendingSurrenderRelays.clear();
284
+ clearInFlight();
231
285
  h.lastCtx = ctx;
286
+ recordLostWork(ctx); // before the notifiers/timers/runs below are torn down
232
287
  h.disposed = true; // gate any late async-run onComplete from touching the next session's instance
233
288
  h.deferredOrchestrations.length = 0;
234
289
  h.pendingOrchestration = undefined;
@@ -277,7 +332,8 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
277
332
  // idle window first; its replay starts the next run and the remaining FIFO entries follow one per
278
333
  // settlement. With no deferred input, flush ordinary wakes immediately.
279
334
  pi.on("agent_settled", async () => {
280
- pendingSurrenderRelays.clear();
335
+ clearInFlight();
336
+ h.pollGuard?.reset(); // a settled turn ends any poll streak: the next peek is a fresh look
281
337
  if (h.lastCtx) h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(h.lastCtx), status: "idle" });
282
338
  // Pi's `prepareToolCall` returns {kind:"immediate"} when the abort signal is already set
283
339
  // after beforeToolCall — skipping afterToolCall, and so our tool_result hook. Those calls
@@ -356,13 +412,16 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
356
412
  const now = Date.now();
357
413
  const anchor = buildSessionAnchor(ctx.sessionManager?.getHeader?.() ?? null, now);
358
414
  if (anchor) prompt = `${prompt}\n\n${anchor}`;
359
- const wakeCaps = h.controller.capabilities;
360
- const activeWakeTools = new Set(pi.getActiveTools());
361
- const wakeAllows = (name: string) => activeWakeTools.has(name) && (!wakeCaps || canCallTool(wakeCaps, name));
362
415
  const wakeGuidance: string[] = [];
363
- if (wakeAllows("timer")) wakeGuidance.push("timer now reads the current clock; timer arm schedules a fixed-time wake (absolute times require a timezone).");
364
- if (wakeAllows("monitor") && wakeAllows("bash")) wakeGuidance.push("monitor watches a job or event-producing program: mode=exit for job completion, mode=output for file/log/system changes reported as stdout lines. Filter changes in the producer; set a useful deadline and event count, and cancel the watch when its purpose ends.");
365
- if (wakeGuidance.length) prompt = `${prompt}\n\n[Session wakes] ${wakeGuidance.join(" ")} Continue independent work while waiting; end your turn when only the event remains. These wakes require this Pi session to stay open. Use existing automatic sub-agent/peer notifications for their results; avoid duplicate watches or polling loops.`;
416
+ if (toolAllowed("timer")) wakeGuidance.push("timer now reads the current clock; timer arm schedules a fixed-time wake (absolute times require a timezone).");
417
+ if (toolAllowed("monitor") && toolAllowed("bash")) wakeGuidance.push("monitor watches a job or event-producing program: mode=exit for job completion, mode=output for file/log/system changes reported as stdout lines. Filter changes in the producer; set a useful deadline and event count, and cancel the watch when its purpose ends.");
418
+ if (wakeGuidance.length) {
419
+ // Headless `pi -p` exits when the turn settles, so "end your turn" would drop the wake.
420
+ const wakeTail = ctx.hasUI
421
+ ? "Continue independent work while waiting; end your turn when only the event remains. These wakes require this Pi session to stay open."
422
+ : "Headless: a wake is delivered only if it fires before your turn settles; do not end the turn to wait for one.";
423
+ prompt = `${prompt}\n\n[Session wakes] ${wakeGuidance.join(" ")} ${wakeTail} Avoid duplicate watches.`;
424
+ }
366
425
  const brief = h.delegationBrief(ctx);
367
426
  if (brief) prompt = `${prompt}\n\n${brief}`;
368
427
  // Per-turn exocom peer AWARENESS (mirrors the delegation brief above): regenerated from the
@@ -482,32 +541,28 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
482
541
  status: "running",
483
542
  });
484
543
  const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
485
- const gate = h.controller.gate(event.toolName, event.input);
486
- if (gate) {
544
+ // A blocked call never reaches tool_result, so close its telemetry here.
545
+ const reject = <R>(result: R): R => {
487
546
  h.telemetryToolStartedAt.delete(event.toolCallId);
488
547
  h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
489
- return gate;
490
- }
548
+ return result;
549
+ };
550
+ const gate = h.controller.gate(event.toolName, event.input);
551
+ if (gate) return reject(gate);
491
552
  if (exocom.ledgerFile) {
492
553
  let pending: ReturnType<ExocomInstall["pendingAsks"]>;
493
554
  try {
494
555
  pending = exocom.pendingAsks(ctx);
495
556
  } catch (error) {
496
557
  if (constrainedTurnAllows(event.toolName)) pending = [];
497
- else {
498
- h.telemetryToolStartedAt.delete(event.toolCallId);
499
- h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
500
- return { block: true, reason: `exocom: ledger unavailable; mutation/delegation is fail-closed until storage recovers (${error instanceof Error ? error.message : String(error)})` };
501
- }
558
+ else return reject({ block: true, reason: `exocom: ledger unavailable; mutation/delegation is fail-closed until storage recovers (${error instanceof Error ? error.message : String(error)})` });
502
559
  }
503
560
  if (pending.length > 0 && !constrainedTurnAllows(event.toolName)) {
504
- h.telemetryToolStartedAt.delete(event.toolCallId);
505
- h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
506
561
  const omitted = pending.length > 1 ? `; ${pending.length - 1} more pending` : "";
507
- return {
562
+ return reject({
508
563
  block: true,
509
564
  reason: `exocom: pending ask(s) to this session — only exocom_name (identity metadata), exocom_answer, exocom_decline, read, grep, find, ls until you answer (ask_id=${pending[0]!.ask_id}${omitted})`,
510
- };
565
+ });
511
566
  }
512
567
  if (WRITE_TOOLS.has(event.toolName) && typeof input.path === "string" && input.path.trim()) {
513
568
  // Advisory: a ledger read failure here must not block an otherwise-allowed write.
@@ -524,13 +579,25 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
524
579
  const label = exocom.peerLabelFor(claim.from_session) ?? claim.from_name;
525
580
  const reason = writeWarningReason(input.path, claim, label);
526
581
  try { if (ctx.hasUI) ctx.ui.notify(reason, "warning"); } catch { /* cosmetic */ }
527
- h.telemetryToolStartedAt.delete(event.toolCallId);
528
- h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
529
- return { block: true, reason };
582
+ return reject({ block: true, reason });
530
583
  }
531
584
  }
532
585
  }
533
586
  }
587
+ // A long leading sleep holds the turn that the idle-gated completion wake needs idle (and queues
588
+ // the operator's steering). Interactive only: a headless leg runs inline, so the model is parked
589
+ // in the delegate call and no wake is waiting on the turn.
590
+ if (h.config.nudge && ctx.hasUI === true && !event.parentToolCallId && (event.toolName === "bash" || event.toolName === "powershell") && typeof input.command === "string") {
591
+ const waitSeconds = longWaitSeconds(input.command);
592
+ const running = h.tracker.running().map((run) => run.id);
593
+ if (waitSeconds !== undefined && running.length + launching.size > 0) {
594
+ const instead = [toolAllowed("timer") && "a fixed clock time use timer arm", toolAllowed("monitor") && toolAllowed("bash") && "an external event use monitor"].filter(Boolean).join("; for ");
595
+ return reject({
596
+ block: true,
597
+ reason: `A ${Math.round(waitSeconds)}s wait blocks the completion wake and operator steering of ${running.length > 0 ? `the running sub-agents (${running.join(", ")})` : "the sub-agents you are launching"}. A finished run or batch wakes you by itself - end your turn.${instead ? ` For ${instead} - never to wait for these children.` : ""}`,
598
+ });
599
+ }
600
+ }
534
601
  if (event.toolName === "intercom") {
535
602
  const action = typeof input.action === "string" ? input.action : "message";
536
603
  if (action === "send" || action === "reply") {
@@ -583,6 +650,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
583
650
  ...(message.replyTo ? { replyTo: message.replyTo } : {}),
584
651
  });
585
652
  }
653
+ if (event.toolName === "delegate" || event.toolName === "council" || event.toolName === "flow") launching.add(event.toolCallId);
586
654
  return undefined;
587
655
  });
588
656
 
@@ -590,12 +658,13 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
590
658
  // hands-on commands by hand — a by-hand sweep with no hand-off — append a reminder to the
591
659
  // offending tool's result. It lands in RECENT context, on the very command that tripped it, where
592
660
  // a top-of-prompt persona directive has already lost its pull. Sub-h.agents run in their own
593
- // sessions, so this hook only ever sees the SUPERVISOR's own tools. A `delegate`/`council` result
594
- // resets the run.
661
+ // sessions, so this hook only ever sees the SUPERVISOR's own tools. A `delegate`/`council`/`flow`
662
+ // result resets the run (a failed `flow` is self-describing and neither resets nor nudges).
595
663
  pi.on("tool_result", (event, ctx) => {
596
664
  h.lastCtx = ctx;
597
665
  const open = h.telemetryToolStartedAt.get(event.toolCallId);
598
666
  h.telemetryToolStartedAt.delete(event.toolCallId);
667
+ launching.delete(event.toolCallId);
599
668
  if (open !== undefined) {
600
669
  h.telemetry?.publish("tool.finished", {
601
670
  callId: event.toolCallId,
@@ -711,12 +780,12 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
711
780
  }
712
781
  const notes: string[] = [];
713
782
  // Grinding-by-hand reminder: a RUN of substantive hands-on commands on the supervisor's own
714
- // tools (delegate/council reset the run). `size` classifies substantive vs glue + fat dump.
783
+ // tools (delegate/council/flow reset the run). `size` classifies substantive vs glue + fat dump.
715
784
  const size = text.length;
716
785
  const sweepNote = h.delegationNudge.observe(event.toolName, size, !errorPatch && event.isError !== true, text);
717
786
  if (sweepNote) notes.push(sweepNote);
718
- // Premature-surrender reminder: a delegated leg that came back BLOCKED/UNKNOWN (delegate/council
719
- // results only; because delegate/council reset the run the two never fire on one event).
787
+ // Premature-surrender reminder: a delegated leg that came back BLOCKED/UNKNOWN (delegate/council/flow
788
+ // results only; because those reset the run the two never fire on one event).
720
789
  if (surrender) notes.push(surrender);
721
790
  if (notes.length === 0) return errorPatch;
722
791
  const joined = notes.join("\n\n");
@@ -843,6 +912,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
843
912
  pi.on("input", async (event, ctx) => {
844
913
  h.lastCtx = ctx;
845
914
  if (event.source === "extension") return undefined;
915
+ h.pollGuard?.reset(); // a real operator message earns a fresh look
846
916
  const orch = h.controller.activePersona?.orchestration;
847
917
  const task = event.text?.trim();
848
918
  if (!orch || !task) return undefined;
@@ -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;
@@ -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(
@@ -171,5 +179,5 @@ export function fenceIntercomOutcome(out: { text: string; details: { action: str
171
179
 
172
180
  export function coachingDisabledHint(personaName: string | undefined): string {
173
181
  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.)`;
182
+ 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
183
  }