@aeondave/pi-persona 1.17.0 → 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 (109) hide show
  1. package/AGENTS.md +79 -29
  2. package/CHANGELOG.md +264 -0
  3. package/README.md +27 -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 +223 -57
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +189 -35
  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 +16 -11
  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 +251 -0
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +195 -136
  48. package/src/engine/child.ts +1 -1
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +11 -6
  51. package/src/engine/stream.ts +3 -0
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/companion-settings.ts +29 -0
  54. package/src/extension/engine.ts +43 -16
  55. package/src/extension/hooks.ts +106 -36
  56. package/src/extension/persona-model-settings.ts +336 -0
  57. package/src/extension/persona-settings.ts +459 -0
  58. package/src/extension/shared.ts +15 -7
  59. package/src/extension.ts +642 -267
  60. package/src/monitor/archive.ts +235 -0
  61. package/src/monitor/session.ts +21 -1
  62. package/src/orchestration/flow-journal.ts +26 -5
  63. package/src/orchestration/flow-run.ts +38 -13
  64. package/src/orchestration/flow.ts +9 -2
  65. package/src/orchestration/reducers.ts +9 -5
  66. package/src/orchestration/render.ts +1 -1
  67. package/src/orchestration/sdk.ts +17 -3
  68. package/src/orchestration/strategies/compete.ts +5 -1
  69. package/src/orchestration/strategies/council-rounds.ts +2 -0
  70. package/src/orchestration/strategies/critic-loop.ts +3 -0
  71. package/src/orchestration/strategies/debate.ts +2 -0
  72. package/src/orchestration/strategies/fanout.ts +3 -2
  73. package/src/orchestration/strategies/judge.ts +5 -1
  74. package/src/orchestration/strategies/magi.ts +40 -23
  75. package/src/orchestration/strategies/map.ts +13 -3
  76. package/src/orchestration/strategies/pair.ts +2 -0
  77. package/src/orchestration/strategies/pipeline.ts +2 -0
  78. package/src/orchestration/strategies/synthesize.ts +27 -7
  79. package/src/orchestration/strategy.ts +31 -0
  80. package/src/persona/config-store.ts +6 -195
  81. package/src/persona/model-participants.ts +1 -1
  82. package/src/persona/persona.ts +7 -3
  83. package/src/persona/settings-store.ts +636 -0
  84. package/src/telemetry/contract.ts +1 -1
  85. package/src/tools/background-run.ts +151 -0
  86. package/src/tools/council.ts +77 -52
  87. package/src/tools/delegate-tool.ts +315 -285
  88. package/src/tools/delegate.ts +22 -210
  89. package/src/tools/exocom-work.ts +18 -53
  90. package/src/tools/exocom.ts +15 -19
  91. package/src/tools/flow.ts +85 -28
  92. package/src/tools/intercom-tool.ts +108 -118
  93. package/src/tools/intercom.ts +1 -1
  94. package/src/tools/models.ts +19 -9
  95. package/src/tools/monitor.ts +39 -6
  96. package/src/tools/timer.ts +11 -13
  97. package/src/ui/agent-overlay.ts +55 -14
  98. package/src/ui/agent-tree.ts +84 -18
  99. package/src/ui/compact-widget.ts +25 -0
  100. package/src/ui/failure-summary.ts +55 -0
  101. package/src/ui/literal-tool-result.ts +24 -0
  102. package/src/ui/model-picker.ts +158 -81
  103. package/src/ui/persona-settings.ts +328 -0
  104. package/src/ui/presentation.ts +24 -0
  105. package/src/ui/report-body.ts +63 -0
  106. package/src/ui/session-cards.ts +189 -0
  107. package/src/ui/wake-rows.ts +262 -0
  108. package/teams.yaml +5 -1
  109. package/src/persona/state.ts +0 -28
@@ -33,6 +33,7 @@ import { roleHint } from "../orchestration/roster.ts";
33
33
  import { type AgentRunSpec, isPositiveFiniteMs, type SteerFn, type StrategyEngine } from "../orchestration/sdk.ts";
34
34
  import type { AgentResult } from "../orchestration/types.ts";
35
35
  import { looksLikeProviderError } from "./errors.ts";
36
+ import { piHostModuleHint } from "./host-module.ts";
36
37
  import { nextChildHandle } from "./handles.ts";
37
38
  import { combineSignals } from "./signals.ts";
38
39
  import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
@@ -666,7 +667,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
666
667
  // One progress tick, routed exactly as the subscription routes them: the per-call sink
667
668
  // takes the AgentProgress shape (no `turns`), else the construction-time sink.
668
669
  const emitProgress = (snap: ProgressSnapshot): void => {
669
- if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
670
+ if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.usage ? { usage: snap.usage } : {}), ...(snap.activity ? { activity: snap.activity } : {}), ...(snap.toolEvent ? { toolEvent: snap.toolEvent } : {}) });
670
671
  else if (deps.onProgress) deps.onProgress(snap);
671
672
  };
672
673
  let contractDef: ContractDef | undefined = requestedDef;
@@ -735,7 +736,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
735
736
  // launch path (extension.ts) only has the latter.
736
737
  const snap = snapshot(state);
737
738
  const activity = `✉ from ${env.from}`;
738
- if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, activity });
739
+ if (onProgress) onProgress({ output: snap.output, tokens: snap.tokens, ...(snap.usage ? { usage: snap.usage } : {}), activity });
739
740
  else if (deps.onProgress) deps.onProgress({ ...snap, activity });
740
741
  }
741
742
  };
@@ -825,7 +826,7 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
825
826
 
826
827
  const result: AgentResult = { agent: spec.agent, output: state.output, usage: state.usage, ok, ...(resolvedRef ? { modelUsed: resolvedRef } : {}) };
827
828
  // A timeout/abort is the *cause of death* — label it first (mirrors the child engine).
828
- if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
829
+ if (timedOut) result.error = `${tag} agent timed out — no events for ${watchdogMs}ms (raise this leg's timeoutMs if its tool is legitimately silent)${state.errorMessage ? ` (last error: ${state.errorMessage})` : ""}`;
829
830
  // The deadline cannot tell the two causes apart, so the message names both: claiming
830
831
  // "it never started" would misdirect the operator of a leg that did start and was
831
832
  // simply waiting on a slow first response (mirrors the child engine's wording).
@@ -836,6 +837,8 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
836
837
  else if (state.errorMessage) result.error = `${tag} ${state.errorMessage}`;
837
838
  else if (thrownError) result.error = `${tag} ${thrownError}`;
838
839
  else if (!ok) result.error = `${tag} agent failed (${state.stopReason ?? "unknown"})`;
840
+ const hostModuleHint = !ok ? piHostModuleHint(state.errorMessage ?? thrownError ?? "") : undefined;
841
+ if (hostModuleHint && result.error) result.error += ` — ${hostModuleHint}`;
839
842
  // Classify the failure so the fallback decorator can reroute ONLY provider errors,
840
843
  // never an abort/timeout/hard-cap/agent miss. A stream `error` stop reason is pi's
841
844
  // own report of an API failure → provider. A THROWN error is not automatically a
@@ -846,9 +849,11 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
846
849
  ? "timeout"
847
850
  : aborted
848
851
  ? "abort"
849
- : state.stopReason === "error" || (thrownError !== undefined && looksLikeProviderError(thrownError))
850
- ? "provider"
851
- : "agent";
852
+ : hostModuleHint
853
+ ? "agent"
854
+ : state.stopReason === "error" || (thrownError !== undefined && looksLikeProviderError(thrownError))
855
+ ? "provider"
856
+ : "agent";
852
857
 
853
858
  if (contractDef) {
854
859
  const v = parseAndValidate(state.output, contractDef);
@@ -339,6 +339,8 @@ export interface ProgressSnapshot {
339
339
  output: string;
340
340
  turns: number;
341
341
  tokens: number;
342
+ /** Copied cumulative counters for display; optional for legacy/custom progress sources. */
343
+ usage?: ChildUsage;
342
344
  /** The tool currently running (e.g. "grep src/…"), if any. */
343
345
  activity?: string;
344
346
  /** One authoritative runtime tool lifecycle transition, when this snapshot is event-driven. */
@@ -352,6 +354,7 @@ export function snapshot(state: StreamState, toolEvent?: ToolEvent): ProgressSna
352
354
  output: [state.transcript, state.partial].filter((s) => s).join("\n\n") || state.output,
353
355
  turns: state.usage.turns,
354
356
  tokens: state.usage.input + state.usage.output,
357
+ usage: { ...state.usage },
355
358
  };
356
359
  if (state.activity !== undefined) snap.activity = state.activity;
357
360
  if (toolEvent !== undefined) snap.toolEvent = toolEvent;
@@ -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
  }
@@ -0,0 +1,29 @@
1
+ import type { EventBus } from "@earendil-works/pi-coding-agent";
2
+ import { PERSONA_SETTINGS, validatePreferences, type CompanionPreferenceValues } from "../core/settings.ts";
3
+
4
+ export const COMPANION_SETTINGS_CHANNEL = "pi-persona:settings:collect:v1";
5
+ export interface CompanionSettingsSnapshot {
6
+ mind?: Partial<Pick<CompanionPreferenceValues, "mindCapture" | "mindNudge">>;
7
+ flow?: Partial<Pick<CompanionPreferenceValues, "flowEnabled" | "flowPort" | "flowIdleMs">>;
8
+ }
9
+
10
+ /** Synchronous, optional discovery on Pi's public bus. No plugin import, model request or writer. */
11
+ export function collectCompanionSettings(bus: Pick<EventBus, "emit"> | undefined): CompanionSettingsSnapshot {
12
+ const snapshot: CompanionSettingsSnapshot = {};
13
+ if (!bus) return snapshot;
14
+ try {
15
+ bus.emit(COMPANION_SETTINGS_CHANNEL, { add: (id: unknown, values: unknown): void => {
16
+ if (id !== "mind" && id !== "flow") return;
17
+ if (!values || typeof values !== "object" || Array.isArray(values)) return;
18
+ const keys = PERSONA_SETTINGS.filter((setting) => setting.owner === id).map((setting) => setting.id);
19
+ const reportedKeys = Object.keys(values);
20
+ if (reportedKeys.length === 0 || !reportedKeys.every((key) => keys.includes(key as keyof CompanionPreferenceValues))) return;
21
+ try {
22
+ const validated = validatePreferences(values);
23
+ if (id === "mind") snapshot.mind = validated as Partial<Pick<CompanionPreferenceValues, "mindCapture" | "mindNudge">>;
24
+ else snapshot.flow = validated as Partial<Pick<CompanionPreferenceValues, "flowEnabled" | "flowPort" | "flowIdleMs">>;
25
+ } catch { /* A malformed contribution is not an editable settings owner. */ }
26
+ } });
27
+ } catch { /* Keep valid responses if another extension listener throws. */ }
28
+ return snapshot;
29
+ }
@@ -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;