@aeondave/pi-persona 1.15.2
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.
- package/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host lifecycle hooks: session, settlement, prompt composition, tool gate, mandatory orchestration.
|
|
3
|
+
*/
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
import type { AgentConfig } from "../agents/agent.ts";
|
|
9
|
+
import { buildExocomBrief, RUNTIME_CONTEXT_GUIDANCE } from "../core/brief.ts";
|
|
10
|
+
import { canCallTool, canDelegateTo, canFanOut } from "../core/capabilities.ts";
|
|
11
|
+
import type { PiPersonaConfig } from "../core/config.ts";
|
|
12
|
+
import { fenceUntrusted } from "../core/fence.ts";
|
|
13
|
+
import type { DelegationNudge, PersistenceNudge } from "../core/nudge.ts";
|
|
14
|
+
import { buildSessionAnchor } from "../core/time.ts";
|
|
15
|
+
import { readClockSnapshot } from "./clock.ts";
|
|
16
|
+
import { type AsyncRun, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
|
|
17
|
+
import { constrainedTurnAllows } from "../exocom/gate.ts";
|
|
18
|
+
import type { LedgerClaim } from "../exocom/ledger.ts";
|
|
19
|
+
import type { DisplayPeer } from "../exocom/plane.ts";
|
|
20
|
+
import { peerClaimFor, WRITE_TOOLS, writeWarningKey, writeWarningReason } from "../exocom/write-guard.ts";
|
|
21
|
+
import {
|
|
22
|
+
canonicalExocomTelemetryTargets,
|
|
23
|
+
piPersonaToolErrorPatch,
|
|
24
|
+
sendPersonaFollowUp,
|
|
25
|
+
type PendingAsk,
|
|
26
|
+
} from "./shared.ts";
|
|
27
|
+
import type { TimerEntry } from "../core/timer.ts";
|
|
28
|
+
import type { MonitorSession } from "../monitor/session.ts";
|
|
29
|
+
import type { FlowSpec } from "../orchestration/flow.ts";
|
|
30
|
+
import { resolveStrategyName } from "../persona/orchestrate.ts";
|
|
31
|
+
import type { PersonaController, PersonaHost } from "../persona/controller.ts";
|
|
32
|
+
import type { OrchestrationGrammar, Persona } from "../persona/persona.ts";
|
|
33
|
+
import type { PersonaConfigStore } from "../persona/config-store.ts";
|
|
34
|
+
import { TELEMETRY_EVENT_NAME, type TelemetryEvent } from "../telemetry/contract.ts";
|
|
35
|
+
import { TelemetryProducer, type TelemetryAgentInput } from "../telemetry/producer.ts";
|
|
36
|
+
import type { AgentTree, AgentNode } from "../ui/agent-tree.ts";
|
|
37
|
+
import type { FailureKind } from "../orchestration/types.ts";
|
|
38
|
+
import type { ExocomInstall } from "../exocom/install.ts";
|
|
39
|
+
import type { SessionIdentity } from "./identity.ts";
|
|
40
|
+
|
|
41
|
+
type MandatoryOutcome = { label: string; output: string; ok: boolean; error?: string; failureKind?: FailureKind };
|
|
42
|
+
|
|
43
|
+
export interface HookHost {
|
|
44
|
+
readonly identity: SessionIdentity;
|
|
45
|
+
config: PiPersonaConfig;
|
|
46
|
+
controller: PersonaController;
|
|
47
|
+
personaHost: PersonaHost;
|
|
48
|
+
get lastCtx(): ExtensionContext | undefined;
|
|
49
|
+
set lastCtx(value: ExtensionContext | undefined);
|
|
50
|
+
get disposed(): boolean;
|
|
51
|
+
set disposed(value: boolean);
|
|
52
|
+
get orchestrating(): boolean;
|
|
53
|
+
set orchestrating(value: boolean);
|
|
54
|
+
get processingDeferredOrchestration(): boolean;
|
|
55
|
+
set processingDeferredOrchestration(value: boolean);
|
|
56
|
+
get pendingOrchestration(): MandatoryOutcome | undefined;
|
|
57
|
+
set pendingOrchestration(value: MandatoryOutcome | undefined);
|
|
58
|
+
readonly MAX_DEFERRED_ORCHESTRATIONS: number;
|
|
59
|
+
readonly deferredOrchestrations: Array<{ task: string; orchestration: OrchestrationGrammar }>;
|
|
60
|
+
get telemetry(): TelemetryProducer | undefined;
|
|
61
|
+
set telemetry(value: TelemetryProducer | undefined);
|
|
62
|
+
readonly telemetryToolStartedAt: Map<string, { startedAt: number; name: string }>;
|
|
63
|
+
readonly telemetryExocomPending: Map<string, Array<{ id: string; sessionId: string; target: string; size: number }>>;
|
|
64
|
+
readonly telemetryIntercomPending: Map<string, { kind: string; to: string; size: number; replyTo?: string }>;
|
|
65
|
+
readonly lastAgentProjection: Map<string, string>;
|
|
66
|
+
readonly telemetryAskSenders: Map<string, string>;
|
|
67
|
+
readonly agentTree: AgentTree;
|
|
68
|
+
telemetryAgent(node: AgentNode): TelemetryAgentInput;
|
|
69
|
+
delegationNudge: DelegationNudge;
|
|
70
|
+
persistenceNudge: PersistenceNudge;
|
|
71
|
+
appendNudgeEntry(content: string): void;
|
|
72
|
+
completionNotifier: IdleCoalescingNotifier<AsyncRun>;
|
|
73
|
+
intercomNotifier: IdleCoalescingNotifier<PendingAsk>;
|
|
74
|
+
timerNotifier: IdleCoalescingNotifier<TimerEntry>;
|
|
75
|
+
timerScheduler: { cancelAll(): void };
|
|
76
|
+
monitors: MonitorSession;
|
|
77
|
+
peekWatcher: PeekWatcher;
|
|
78
|
+
stopPeek(): void;
|
|
79
|
+
stopRegistry: Map<string, () => void>;
|
|
80
|
+
stopRequested: Set<string>;
|
|
81
|
+
steerRegistry: Map<string, unknown>;
|
|
82
|
+
broker: { close(): Promise<void> };
|
|
83
|
+
get spineText(): string;
|
|
84
|
+
delegationBrief(ctx: ExtensionContext): string | undefined;
|
|
85
|
+
get agents(): AgentConfig[];
|
|
86
|
+
get personas(): Persona[];
|
|
87
|
+
reload(cwd: string): void;
|
|
88
|
+
readConfigStore(): PersonaConfigStore;
|
|
89
|
+
get personaConfigs(): PersonaConfigStore;
|
|
90
|
+
set personaConfigs(value: PersonaConfigStore);
|
|
91
|
+
readRememberedPersona(): string | undefined;
|
|
92
|
+
runSeed(force: boolean): { copied: unknown[] };
|
|
93
|
+
seedMarker(): string;
|
|
94
|
+
personaDataDir(): string;
|
|
95
|
+
reportSeedSourceCollisions(ctx: ExtensionContext, result: { copied: unknown[] }): void;
|
|
96
|
+
refreshSpineAfterSeed(): void;
|
|
97
|
+
inspectEnabledLegacySpines(): void;
|
|
98
|
+
reportSeedMigration(ctx: ExtensionContext, result: unknown): void;
|
|
99
|
+
seedMigration: unknown;
|
|
100
|
+
reportDataRootMigration(ctx: ExtensionContext): void;
|
|
101
|
+
reportSpineWarning(ctx: ExtensionContext): void;
|
|
102
|
+
reportDefinitionCollisions(ctx: ExtensionContext): void;
|
|
103
|
+
userAgentDir(): string;
|
|
104
|
+
loadFlow(cwd: string, name: string): { ok: true; flow: FlowSpec } | { ok: false; error: string } | undefined;
|
|
105
|
+
runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, task: string): Promise<{ output: string; ok: boolean; cancelled?: boolean; error?: string; failureKind?: FailureKind }>;
|
|
106
|
+
runStrategyVisible(ctx: ExtensionContext, orch: OrchestrationGrammar, task: string, label: string): Promise<{ output: string; ok: boolean; error?: string; failureKind?: FailureKind } | undefined>;
|
|
107
|
+
personaGrammarError(err: unknown): string;
|
|
108
|
+
intercomRecipient(input: Record<string, unknown>, askId: string | undefined): string;
|
|
109
|
+
idleDelivery: unknown;
|
|
110
|
+
drainBusBlock: () => string;
|
|
111
|
+
childUsage: { reset(): void };
|
|
112
|
+
publishPersonaCost: () => void;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstall): void {
|
|
116
|
+
// ── lifecycle ─────────────────────────────────────────────────────────────
|
|
117
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
118
|
+
h.lastCtx = ctx;
|
|
119
|
+
h.delegationNudge.reset(); // a fresh session starts with a clean by-hand run
|
|
120
|
+
h.childUsage.reset();
|
|
121
|
+
h.publishPersonaCost();
|
|
122
|
+
// Opt-in only (PI_PERSONA_SEED=on): auto-install the bundled defaults once. Default is off —
|
|
123
|
+
// a fresh install shows no h.personas until `/persona seed` or `/persona restore`.
|
|
124
|
+
if (h.config.seed && !existsSync(h.seedMarker())) {
|
|
125
|
+
try {
|
|
126
|
+
const r = h.runSeed(false);
|
|
127
|
+
h.reportSeedSourceCollisions(ctx, r);
|
|
128
|
+
// Resolution happened before session_start, when a fresh install had no user copy. Refresh
|
|
129
|
+
// now so this very process uses what auto-seed just created and reports only post-seed state.
|
|
130
|
+
h.refreshSpineAfterSeed();
|
|
131
|
+
if (ctx.hasUI && r.copied.length > 0) {
|
|
132
|
+
ctx.ui.notify(`pi-persona: seeded ${r.copied.length} default(s) to ${h.personaDataDir()} — edit them freely; /persona restore brings back the originals.`, "info");
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
// Seeding is multi-file: an error can arrive after the prompt itself was copied. Re-resolve
|
|
136
|
+
// and retry only enabled legacy roles so partial progress is visible in this same process.
|
|
137
|
+
h.inspectEnabledLegacySpines();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
h.reportSeedMigration(ctx, h.seedMigration);
|
|
141
|
+
h.reportDataRootMigration(ctx); // the one-time storage-root move ran at activation; this is its voice
|
|
142
|
+
h.reportSpineWarning(ctx);
|
|
143
|
+
h.reload(ctx.cwd);
|
|
144
|
+
h.identity.start(ctx); // restore session identity before persona activation can join Exocom
|
|
145
|
+
h.reportDefinitionCollisions(ctx);
|
|
146
|
+
h.personaConfigs = h.readConfigStore();
|
|
147
|
+
// Restore order: --persona flag > env pin (PI_PERSONA_DEFAULT) > remembered-on-disk. Read-only.
|
|
148
|
+
const flagPersona = ((pi.getFlag("persona") as string) || "").trim();
|
|
149
|
+
const remembered = flagPersona || h.config.defaultPersona || h.readRememberedPersona();
|
|
150
|
+
const target = remembered ? h.personas.find((p) => p.name === remembered) : undefined;
|
|
151
|
+
if (target) await h.controller.activate(target);
|
|
152
|
+
else {
|
|
153
|
+
// A name that doesn't resolve leaves the session with NO persona at all, whichever source it
|
|
154
|
+
// came from — and silence there reads as "it's active" while nothing is (downstream tooling
|
|
155
|
+
// keyed on the remembered name, e.g. per-persona memory, then serves a persona that was never
|
|
156
|
+
// activated). An EXPLICIT `--persona` is a direct instruction, so it stays an error; an env
|
|
157
|
+
// pin or a stale remembered name is a warning. The marker itself is deliberately NOT cleared:
|
|
158
|
+
// h.personas are discovered per-cwd, so a name that is missing here may be present in the
|
|
159
|
+
// project the user came from. The model (`--model`) and effort (`--thinking`) are pi's own
|
|
160
|
+
// flags — pi validates those.
|
|
161
|
+
if (remembered) {
|
|
162
|
+
const names = h.personas.map((p) => p.name).sort().join(", ") || "(none installed — run /persona seed)";
|
|
163
|
+
const source = flagPersona ? "--persona" : h.config.defaultPersona ? "PI_PERSONA_DEFAULT" : "remembered persona";
|
|
164
|
+
const msg = `pi-persona: ${source} "${remembered}" is not an installed persona. Available: ${names}`;
|
|
165
|
+
if (ctx.hasUI) ctx.ui.notify(msg, flagPersona ? "error" : "warning");
|
|
166
|
+
else process.stderr.write(`${msg}\n`);
|
|
167
|
+
}
|
|
168
|
+
h.personaHost.setStatus(h.controller.activePersona?.label);
|
|
169
|
+
}
|
|
170
|
+
await exocom.reconcile(ctx); // after persona activation, so plane + active tools share one gate
|
|
171
|
+
await h.telemetry?.stop("session-replaced");
|
|
172
|
+
h.telemetry = undefined;
|
|
173
|
+
const sessionManager = (ctx as ExtensionContext & { sessionManager?: { getSessionId?: () => string } }).sessionManager;
|
|
174
|
+
const sessionId = sessionManager?.getSessionId?.();
|
|
175
|
+
if (sessionId) {
|
|
176
|
+
const eventBus = (pi as unknown as { events?: { emit?: (name: string, event: TelemetryEvent) => void } }).events;
|
|
177
|
+
h.telemetry = new TelemetryProducer({
|
|
178
|
+
agentDir: h.userAgentDir(),
|
|
179
|
+
cwd: ctx.cwd,
|
|
180
|
+
sessionId,
|
|
181
|
+
emit: (event) => eventBus?.emit?.(TELEMETRY_EVENT_NAME, event),
|
|
182
|
+
heartbeat: () => exocom.currentTelemetryInstance(h.lastCtx ?? ctx),
|
|
183
|
+
...(process.env.PI_PERSONA_DEBUG
|
|
184
|
+
? { onError: (error: unknown) => { process.stderr.write(`[pi-persona] telemetry: ${error instanceof Error ? error.message : String(error)}\n`); } }
|
|
185
|
+
: {}),
|
|
186
|
+
});
|
|
187
|
+
h.telemetry.start(exocom.currentTelemetryInstance(ctx));
|
|
188
|
+
// Re-seeded with the replay, so the dedupe below always mirrors what THIS producer last
|
|
189
|
+
// published — a session start that follows no teardown carries no stale projection.
|
|
190
|
+
h.lastAgentProjection.clear();
|
|
191
|
+
for (const node of h.agentTree.snapshot()) {
|
|
192
|
+
h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
|
|
193
|
+
h.telemetry.publishAgentAdded(h.telemetryAgent(node));
|
|
194
|
+
}
|
|
195
|
+
exocom.publishTelemetryPeers();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
200
|
+
h.lastCtx = ctx;
|
|
201
|
+
h.disposed = true; // gate any late async-run onComplete from touching the next session's instance
|
|
202
|
+
h.deferredOrchestrations.length = 0;
|
|
203
|
+
h.pendingOrchestration = undefined;
|
|
204
|
+
h.stopPeek(); // h.reload-hygiene: never leak the idle-peek timer across sessions
|
|
205
|
+
h.peekWatcher.reset(); // …nor a stale "already surfaced this leg as stuck" set into the next session
|
|
206
|
+
h.completionNotifier.cancel(); // …nor the coalesced-delivery flush timers
|
|
207
|
+
h.intercomNotifier.cancel();
|
|
208
|
+
exocom.notifier?.cancel(); // …nor a late exocom follow-up (M1) — cancelled BEFORE exocom.stop tears down the plane below
|
|
209
|
+
exocom.waitNotifier?.cancel();
|
|
210
|
+
h.timerScheduler.cancelAll(); // …nor any armed alarms (never wake the next session)
|
|
211
|
+
h.timerNotifier.cancel();
|
|
212
|
+
h.monitors.cancelAll();
|
|
213
|
+
// This instance is being torn down (a h.reload/new/resume rebinds a fresh one); abort in-flight
|
|
214
|
+
// sub-h.agents and reset control state so nothing is left orphaned or rendered stale.
|
|
215
|
+
for (const abort of [...h.stopRegistry.values()]) {
|
|
216
|
+
try {
|
|
217
|
+
abort();
|
|
218
|
+
} catch {
|
|
219
|
+
/* ignore */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
h.stopRegistry.clear();
|
|
223
|
+
h.stopRequested.clear();
|
|
224
|
+
h.steerRegistry.clear();
|
|
225
|
+
h.agentTree.clear();
|
|
226
|
+
h.personaHost.setStatus(undefined);
|
|
227
|
+
// Queued, not called bare: a shutdown that lands while session_start's own start is still
|
|
228
|
+
// pending would otherwise see `exocom.plane` unset, no-op, and let that start bind a socket
|
|
229
|
+
// and register an entry for a session that no longer exists. Idempotent either way — off
|
|
230
|
+
// (or never started) ⇒ a no-op.
|
|
231
|
+
await exocom.queue(() => exocom.stop());
|
|
232
|
+
// Broker teardown (spec B1/B5): idempotent — a session that never built a broker-backed
|
|
233
|
+
// child engine (flag off, or on but unused) never started a host, so this is a no-op.
|
|
234
|
+
// `SupervisorBroker.close()` is itself best-effort (never blocks shutdown on a teardown error).
|
|
235
|
+
await h.broker.close();
|
|
236
|
+
await h.telemetry?.stop(event?.reason);
|
|
237
|
+
h.telemetry = undefined;
|
|
238
|
+
h.telemetryToolStartedAt.clear();
|
|
239
|
+
h.telemetryExocomPending.clear();
|
|
240
|
+
h.telemetryIntercomPending.clear();
|
|
241
|
+
h.telemetryAskSenders.clear(); // …and no ask attribution leaks into the next session
|
|
242
|
+
h.lastAgentProjection.clear();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// Pi marks the session idle before emitting agent_settled. A deferred mandatory input owns this
|
|
246
|
+
// idle window first; its replay starts the next run and the remaining FIFO entries follow one per
|
|
247
|
+
// settlement. With no deferred input, flush ordinary wakes immediately.
|
|
248
|
+
pi.on("agent_settled", async () => {
|
|
249
|
+
if (h.lastCtx) h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(h.lastCtx), status: "idle" });
|
|
250
|
+
// Pi's `prepareToolCall` returns {kind:"immediate"} when the abort signal is already set
|
|
251
|
+
// after beforeToolCall — skipping afterToolCall, and so our tool_result hook. Those calls
|
|
252
|
+
// would otherwise stay "running" until session_shutdown (press Esc mid-fan-out and every
|
|
253
|
+
// call that had not yet started is orphaned). The turn boundary is where they end.
|
|
254
|
+
for (const [callId, open] of h.telemetryToolStartedAt) {
|
|
255
|
+
h.telemetry?.publish("tool.finished", {
|
|
256
|
+
callId,
|
|
257
|
+
agentId: "supervisor",
|
|
258
|
+
name: open.name,
|
|
259
|
+
status: "failed",
|
|
260
|
+
durationMs: Math.max(0, Date.now() - open.startedAt),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
h.telemetryToolStartedAt.clear(); // a late result for a drained call must not close it twice
|
|
264
|
+
// An exocom send orphaned on that same path left its recipients at "queued" forever. They are
|
|
265
|
+
// reported failed, never delivered: the call never reached a result, so nothing observed a send.
|
|
266
|
+
for (const pending of h.telemetryExocomPending.values()) {
|
|
267
|
+
for (const message of pending) h.telemetry?.publish("message.sent", {
|
|
268
|
+
id: message.id,
|
|
269
|
+
channel: "exocom",
|
|
270
|
+
// A session id, like every other exocom publish site — never an agentId literal in this slot.
|
|
271
|
+
from: h.lastCtx ? exocom.telemetrySessionId(h.lastCtx) : `legacy-${process.pid}`,
|
|
272
|
+
to: message.sessionId,
|
|
273
|
+
kind: "message",
|
|
274
|
+
status: "failed",
|
|
275
|
+
expectsReply: false,
|
|
276
|
+
size: message.size,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
h.telemetryExocomPending.clear();
|
|
280
|
+
for (const [callId, message] of h.telemetryIntercomPending) {
|
|
281
|
+
h.telemetry?.publish(message.kind === "reply" ? "message.replied" : "message.sent", {
|
|
282
|
+
id: callId,
|
|
283
|
+
channel: "intercom",
|
|
284
|
+
from: "supervisor",
|
|
285
|
+
to: message.to,
|
|
286
|
+
kind: message.kind,
|
|
287
|
+
status: "failed",
|
|
288
|
+
expectsReply: false,
|
|
289
|
+
size: message.size,
|
|
290
|
+
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
h.telemetryIntercomPending.clear();
|
|
294
|
+
if (await processNextDeferredOrchestration()) return;
|
|
295
|
+
h.completionNotifier.kick();
|
|
296
|
+
h.intercomNotifier.kick();
|
|
297
|
+
h.timerNotifier.kick();
|
|
298
|
+
h.monitors.kick();
|
|
299
|
+
exocom.notifier?.kick();
|
|
300
|
+
exocom.waitNotifier?.kick();
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
304
|
+
h.lastCtx = ctx;
|
|
305
|
+
h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(ctx), status: "active" });
|
|
306
|
+
// The spine lifts persona-less turns too: with nothing active the composition is Pi's base
|
|
307
|
+
// prompt + the layer. Off ⇒ `h.spineText` is empty and this is `event.systemPrompt` itself.
|
|
308
|
+
const noPersona = h.spineText ? `${event.systemPrompt}\n\n${h.spineText}` : event.systemPrompt;
|
|
309
|
+
let prompt = h.controller.composePrompt(event.systemPrompt, h.spineText) ?? noPersona;
|
|
310
|
+
// Stable system-level interpretation of runtime metadata. Never append an acknowledgement
|
|
311
|
+
// request to the conversation: that would recreate the old identity pseudo-turn loop.
|
|
312
|
+
prompt = `${prompt}\n\n${RUNTIME_CONTEXT_GUIDANCE}`;
|
|
313
|
+
// The session time anchor (core/time.ts). Unconditional, unlike both briefs below: a supervisor
|
|
314
|
+
// that has been working for hours needs to know it whether or not h.agents or peers exist. It goes
|
|
315
|
+
// in the SYSTEM prompt because the prompt is re-composed and re-sent every turn rather than
|
|
316
|
+
// summarized, so an anchor placed here cannot be compacted away — the same fact in the
|
|
317
|
+
// conversation tail would be. Its start comes from the session HEADER, the first entry of the
|
|
318
|
+
// append-only session file, so after /resume it is still the ORIGINAL start: that is what makes it
|
|
319
|
+
// answer "how long have I been on this problem" across restarts and not merely across turns.
|
|
320
|
+
// Placed BEFORE the briefs so the standing hand-off default keeps the last word at the tail; the
|
|
321
|
+
// elapsed reading remains coarse to preserve caching; the precise clock goes in a new
|
|
322
|
+
// conversation message below, and timer now can refresh it within a long tool loop.
|
|
323
|
+
const now = Date.now();
|
|
324
|
+
const anchor = buildSessionAnchor(ctx.sessionManager?.getHeader?.() ?? null, now);
|
|
325
|
+
if (anchor) prompt = `${prompt}\n\n${anchor}`;
|
|
326
|
+
const wakeCaps = h.controller.capabilities;
|
|
327
|
+
const activeWakeTools = new Set(pi.getActiveTools());
|
|
328
|
+
const wakeAllows = (name: string) => activeWakeTools.has(name) && (!wakeCaps || canCallTool(wakeCaps, name));
|
|
329
|
+
const wakeGuidance: string[] = [];
|
|
330
|
+
if (wakeAllows("timer")) wakeGuidance.push("timer now reads the current clock; timer arm schedules a fixed-time wake (absolute times require a timezone).");
|
|
331
|
+
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.");
|
|
332
|
+
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.`;
|
|
333
|
+
const brief = h.delegationBrief(ctx);
|
|
334
|
+
if (brief) prompt = `${prompt}\n\n${brief}`;
|
|
335
|
+
// Per-turn exocom peer AWARENESS (mirrors the delegation brief above): regenerated from the
|
|
336
|
+
// live registry every turn so it cannot desync, and gated implicitly — exocom.plane is only
|
|
337
|
+
// set when exocom is on AND canUseBus, so this is a no-op (undefined ⇒ no prompt change)
|
|
338
|
+
// whenever exocom is off, matching every other opt-in surface in this file.
|
|
339
|
+
if (exocom.plane) {
|
|
340
|
+
let peers: DisplayPeer[] | undefined;
|
|
341
|
+
let pendingBlock: string | undefined;
|
|
342
|
+
let ledgerFailure: string | undefined;
|
|
343
|
+
try {
|
|
344
|
+
peers = exocom.plane.listPeers();
|
|
345
|
+
} catch {
|
|
346
|
+
// Peer awareness is opportunistic. A transient registry I/O failure must not abort
|
|
347
|
+
// Pi's turn or prevent the rest of the system prompt from reaching the model.
|
|
348
|
+
}
|
|
349
|
+
if (exocom.ledgerFile) {
|
|
350
|
+
try {
|
|
351
|
+
pendingBlock = exocom.pendingAskPrompt(ctx);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
ledgerFailure = `[pi-persona] Exocom ledger unavailable. Treat work ownership and pending asks as unresolved; do not mutate or delegate until storage recovers. (${error instanceof Error ? error.message : String(error)})`;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const xcaps = h.controller.capabilities;
|
|
357
|
+
// Holding the bus says nothing about `delegate` (canUseBus keys off `intercom` alone), so
|
|
358
|
+
// read the persona the way h.delegationBrief does — absent capabilities ⇒ unrestricted — but
|
|
359
|
+
// gate on a REACHABLE target, which is stricter than h.delegationBrief: that one still renders
|
|
360
|
+
// (with `installedCount`, to say "your allowlist filtered everything away") when fan-out is
|
|
361
|
+
// allowed and no target survives, whereas urging a peer-vs-sub-agent split with nothing to
|
|
362
|
+
// delegate to would point the model at a call the gate refuses.
|
|
363
|
+
// `canFanOut(xcaps)` is defence in depth, not a live branch: `resolveCapabilities` empties
|
|
364
|
+
// `delegateTargets` whenever the delegate tool is absent (core/capabilities.ts), so the
|
|
365
|
+
// second conjunct already implies the first and no test can kill the first alone. Kept
|
|
366
|
+
// because it states the structural rule the second conjunct only happens to encode.
|
|
367
|
+
const canDelegate = xcaps ? canFanOut(xcaps) && h.agents.some((a) => canDelegateTo(xcaps, a.name)) : h.agents.length > 0;
|
|
368
|
+
const scope = exocom.scope;
|
|
369
|
+
const xbrief = buildExocomBrief((peers ?? []).map((p) => {
|
|
370
|
+
const workspaceId = p.workspace_id ?? scope?.scopeWorkspaceId;
|
|
371
|
+
const workspaceCode = p.workspace_code ?? (scope && workspaceId === scope.scopeWorkspaceId ? scope.scopeCode : undefined);
|
|
372
|
+
const workspaceLabel = p.workspace_label
|
|
373
|
+
?? (scope && workspaceId === scope.homeWorkspaceId ? scope.homeWorkspaceLabel : undefined);
|
|
374
|
+
return {
|
|
375
|
+
name: p.displayName,
|
|
376
|
+
persona: p.persona,
|
|
377
|
+
...(workspaceCode && workspaceLabel ? {
|
|
378
|
+
workspaceCode,
|
|
379
|
+
workspaceLabel,
|
|
380
|
+
sameWorkspace: workspaceId === scope?.homeWorkspaceId,
|
|
381
|
+
} : {}),
|
|
382
|
+
};
|
|
383
|
+
}), {
|
|
384
|
+
canDelegate,
|
|
385
|
+
tools: {
|
|
386
|
+
name: !xcaps || canCallTool(xcaps, "exocom_name"),
|
|
387
|
+
list: !xcaps || canCallTool(xcaps, "exocom_list"),
|
|
388
|
+
send: !xcaps || canCallTool(xcaps, "exocom_send"),
|
|
389
|
+
claim: scope?.joined !== true && (!xcaps || canCallTool(xcaps, "exocom_claim")),
|
|
390
|
+
ask: !xcaps || canCallTool(xcaps, "exocom_ask"),
|
|
391
|
+
answer: !xcaps || canCallTool(xcaps, "exocom_answer"),
|
|
392
|
+
decline: !xcaps || canCallTool(xcaps, "exocom_decline"),
|
|
393
|
+
wait: !xcaps || canCallTool(xcaps, "exocom_wait"),
|
|
394
|
+
release: !xcaps || canCallTool(xcaps, "exocom_release"),
|
|
395
|
+
progress: !xcaps || canCallTool(xcaps, "exocom_progress"),
|
|
396
|
+
status: !xcaps || canCallTool(xcaps, "exocom_status"),
|
|
397
|
+
},
|
|
398
|
+
// Exocom has no UI gate, so a headless (`pi -p`) run has live peers and no way to ask
|
|
399
|
+
// anyone anything. `hasUI` is pi's dialog capability, not a headcount (see the field's
|
|
400
|
+
// doc) — but the clause it gates is an ask, and an ask needs a channel, not a person.
|
|
401
|
+
canAskHuman: ctx.hasUI === true,
|
|
402
|
+
namedByModel: exocom.namedByModel,
|
|
403
|
+
// Naming changes display metadata only. Pending work still gates every work action.
|
|
404
|
+
canNameNow: pi.getActiveTools().includes("exocom_name") && (!xcaps || canCallTool(xcaps, "exocom_name")),
|
|
405
|
+
...(scope ? {
|
|
406
|
+
joined: scope.joined,
|
|
407
|
+
scopeCode: scope.scopeCode,
|
|
408
|
+
homeWorkspaceLabel: scope.homeWorkspaceLabel,
|
|
409
|
+
homeWorkspaceCode: scope.homeWorkspaceCode,
|
|
410
|
+
} : {}),
|
|
411
|
+
});
|
|
412
|
+
if (xbrief) prompt = `${prompt}\n\n${xbrief}`;
|
|
413
|
+
if (pendingBlock) prompt = `${prompt}\n\n${pendingBlock}`;
|
|
414
|
+
else if (ledgerFailure) prompt = `${prompt}\n\n${ledgerFailure}`;
|
|
415
|
+
}
|
|
416
|
+
if (h.pendingOrchestration) {
|
|
417
|
+
// The result is sub-agent text entering the SYSTEM prompt — fence it (I-guardrail:
|
|
418
|
+
// untrusted output must never reach the supervisor unfenced, least of all here).
|
|
419
|
+
if (h.pendingOrchestration.ok) {
|
|
420
|
+
// Keep the established success hand-off byte-for-byte: successful mandatory runs are
|
|
421
|
+
// still rulings the supervisor should present without spending the same council twice.
|
|
422
|
+
prompt = `${prompt}\n\n[orchestration: ${h.pendingOrchestration.label}] The mandated multi-agent orchestration was run on the user's request and produced the result below. Present and build on it as your answer — do not re-run it:\n\n${fenceUntrusted(h.pendingOrchestration.output)}`;
|
|
423
|
+
} else {
|
|
424
|
+
const evidence = h.pendingOrchestration.error && h.pendingOrchestration.error !== h.pendingOrchestration.output
|
|
425
|
+
? `${h.pendingOrchestration.output}\n\n--- RUNTIME ERROR ---\n${h.pendingOrchestration.error}`
|
|
426
|
+
: h.pendingOrchestration.output;
|
|
427
|
+
prompt = h.pendingOrchestration.failureKind === "abort"
|
|
428
|
+
? `${prompt}\n\n[orchestration: ${h.pendingOrchestration.label}] The mandated multi-agent orchestration was CANCELLED. Report the cancellation; do not present its partial output as approved, and do not resume or re-run it unless the user explicitly asks:\n\n${fenceUntrusted(evidence)}`
|
|
429
|
+
: `${prompt}\n\n[orchestration: ${h.pendingOrchestration.label}] The mandated multi-agent orchestration FAILED or remained unresolved. Do not present its output as approved or claim completion. Report the blocker, then repair and verify before declaring success:\n\n${fenceUntrusted(evidence)}`;
|
|
430
|
+
reportMandatoryFailure(ctx, h.pendingOrchestration);
|
|
431
|
+
}
|
|
432
|
+
h.pendingOrchestration = undefined;
|
|
433
|
+
}
|
|
434
|
+
const clock = readClockSnapshot(now);
|
|
435
|
+
return {
|
|
436
|
+
...(prompt !== event.systemPrompt ? { systemPrompt: prompt } : {}),
|
|
437
|
+
...(clock ? { message: { customType: "pi-persona-clock", content: clock, display: false } } : {}),
|
|
438
|
+
};
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
pi.on("tool_call", (event, ctx) => {
|
|
442
|
+
h.lastCtx = ctx;
|
|
443
|
+
const startedAt = Date.now();
|
|
444
|
+
h.telemetryToolStartedAt.set(event.toolCallId, { startedAt, name: event.toolName });
|
|
445
|
+
h.telemetry?.publish("tool.started", {
|
|
446
|
+
callId: event.toolCallId,
|
|
447
|
+
agentId: "supervisor",
|
|
448
|
+
name: event.toolName,
|
|
449
|
+
status: "running",
|
|
450
|
+
});
|
|
451
|
+
const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
|
|
452
|
+
const gate = h.controller.gate(event.toolName, event.input);
|
|
453
|
+
if (gate) {
|
|
454
|
+
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
455
|
+
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
456
|
+
return gate;
|
|
457
|
+
}
|
|
458
|
+
if (exocom.ledgerFile) {
|
|
459
|
+
let pending: ReturnType<ExocomInstall["pendingAsks"]>;
|
|
460
|
+
try {
|
|
461
|
+
pending = exocom.pendingAsks(ctx);
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (constrainedTurnAllows(event.toolName)) pending = [];
|
|
464
|
+
else {
|
|
465
|
+
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
466
|
+
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
467
|
+
return { block: true, reason: `exocom: ledger unavailable; mutation/delegation is fail-closed until storage recovers (${error instanceof Error ? error.message : String(error)})` };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (pending.length > 0 && !constrainedTurnAllows(event.toolName)) {
|
|
471
|
+
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
472
|
+
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
473
|
+
const omitted = pending.length > 1 ? `; ${pending.length - 1} more pending` : "";
|
|
474
|
+
return {
|
|
475
|
+
block: true,
|
|
476
|
+
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})`,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
if (WRITE_TOOLS.has(event.toolName) && typeof input.path === "string" && input.path.trim()) {
|
|
480
|
+
// Advisory: a ledger read failure here must not block an otherwise-allowed write.
|
|
481
|
+
let claim: LedgerClaim | undefined;
|
|
482
|
+
try {
|
|
483
|
+
claim = peerClaimFor(input.path, exocom.ledgerState().claims, exocom.sessionId, ctx.cwd);
|
|
484
|
+
} catch {
|
|
485
|
+
claim = undefined;
|
|
486
|
+
}
|
|
487
|
+
if (claim) {
|
|
488
|
+
const key = writeWarningKey(claim, input.path);
|
|
489
|
+
if (!exocom.writeWarnings.seen(key)) {
|
|
490
|
+
exocom.writeWarnings.remember(key);
|
|
491
|
+
const label = exocom.peerLabelFor(claim.from_session) ?? claim.from_name;
|
|
492
|
+
const reason = writeWarningReason(input.path, claim, label);
|
|
493
|
+
try { if (ctx.hasUI) ctx.ui.notify(reason, "warning"); } catch { /* cosmetic */ }
|
|
494
|
+
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
495
|
+
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
496
|
+
return { block: true, reason };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (event.toolName === "intercom") {
|
|
502
|
+
const action = typeof input.action === "string" ? input.action : "message";
|
|
503
|
+
if (action === "send" || action === "reply") {
|
|
504
|
+
const text = typeof input.message === "string" ? input.message : "";
|
|
505
|
+
const askId = typeof input.askId === "string" ? input.askId : undefined;
|
|
506
|
+
h.telemetryIntercomPending.set(event.toolCallId, {
|
|
507
|
+
kind: action,
|
|
508
|
+
to: h.intercomRecipient(input, askId),
|
|
509
|
+
size: Buffer.byteLength(text, "utf8"),
|
|
510
|
+
...(askId ? { replyTo: askId } : {}),
|
|
511
|
+
});
|
|
512
|
+
h.telemetry?.publish(action === "reply" ? "message.replied" : "message.sent", {
|
|
513
|
+
id: event.toolCallId,
|
|
514
|
+
channel: "intercom",
|
|
515
|
+
from: "supervisor",
|
|
516
|
+
to: h.intercomRecipient(input, askId),
|
|
517
|
+
kind: action,
|
|
518
|
+
status: "queued",
|
|
519
|
+
expectsReply: false,
|
|
520
|
+
size: Buffer.byteLength(text, "utf8"),
|
|
521
|
+
...(askId ? { replyTo: askId } : {}),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
if (event.toolName === "exocom_send") {
|
|
526
|
+
const text = typeof input.message === "string" ? input.message : "";
|
|
527
|
+
const target = typeof input.target === "string" ? input.target : "";
|
|
528
|
+
let peers: DisplayPeer[] = [];
|
|
529
|
+
try { peers = exocom.plane?.listPeers() ?? []; } catch { /* h.telemetry cannot break the tool */ }
|
|
530
|
+
const sessions = canonicalExocomTelemetryTargets(peers, target);
|
|
531
|
+
const size = Buffer.byteLength(text, "utf8");
|
|
532
|
+
const pending = sessions.map((sessionId) => ({
|
|
533
|
+
id: sessions.length === 1 ? event.toolCallId : `${event.toolCallId}:${sessionId}`,
|
|
534
|
+
sessionId,
|
|
535
|
+
target: target === "*" ? (peers.find((peer) => peer.session_id === sessionId)?.target ?? sessionId) : target,
|
|
536
|
+
size,
|
|
537
|
+
}));
|
|
538
|
+
if (pending.length > 0) h.telemetryExocomPending.set(event.toolCallId, pending);
|
|
539
|
+
for (const message of pending) h.telemetry?.publish("message.sent", {
|
|
540
|
+
id: message.id,
|
|
541
|
+
channel: "exocom",
|
|
542
|
+
from: exocom.telemetrySessionId(ctx),
|
|
543
|
+
to: message.sessionId,
|
|
544
|
+
kind: "message",
|
|
545
|
+
status: "queued",
|
|
546
|
+
expectsReply: false,
|
|
547
|
+
size: message.size,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return undefined;
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// Delegation nudge (h.config.nudge; delegating h.personas only): when the supervisor grinds a RUN of
|
|
554
|
+
// hands-on commands by hand — a by-hand sweep with no hand-off — append a reminder to the
|
|
555
|
+
// offending tool's result. It lands in RECENT context, on the very command that tripped it, where
|
|
556
|
+
// a top-of-prompt persona directive has already lost its pull. Sub-h.agents run in their own
|
|
557
|
+
// sessions, so this hook only ever sees the SUPERVISOR's own tools. A `delegate`/`council` result
|
|
558
|
+
// resets the run.
|
|
559
|
+
pi.on("tool_result", (event, ctx) => {
|
|
560
|
+
h.lastCtx = ctx;
|
|
561
|
+
const open = h.telemetryToolStartedAt.get(event.toolCallId);
|
|
562
|
+
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
563
|
+
if (open !== undefined) {
|
|
564
|
+
h.telemetry?.publish("tool.finished", {
|
|
565
|
+
callId: event.toolCallId,
|
|
566
|
+
agentId: "supervisor",
|
|
567
|
+
name: event.toolName,
|
|
568
|
+
status: event.isError ? "failed" : "done",
|
|
569
|
+
durationMs: Math.max(0, Date.now() - open.startedAt),
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
const telemetryInput = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
|
|
573
|
+
if (event.toolName === "exocom_send") {
|
|
574
|
+
const pending = h.telemetryExocomPending.get(event.toolCallId) ?? [];
|
|
575
|
+
h.telemetryExocomPending.delete(event.toolCallId);
|
|
576
|
+
const details = event.details && typeof event.details === "object" && !Array.isArray(event.details)
|
|
577
|
+
? event.details as { failed?: Array<{ target?: unknown }>; failedCount?: unknown; omittedFailures?: unknown }
|
|
578
|
+
: undefined;
|
|
579
|
+
const sampledFailures = details?.failed ?? [];
|
|
580
|
+
const failedTargets = new Set(sampledFailures.flatMap((failure) => typeof failure.target === "string" ? [failure.target] : []));
|
|
581
|
+
// A broadcast samples its failures (`MAX_BROADCAST_DETAIL_ITEMS` in tools/exocom.ts) and
|
|
582
|
+
// reports the rest as a count, so the sample alone CLEARS nobody: with more failures than
|
|
583
|
+
// the sample holds, a recipient we cannot find in it is unknown, never delivered.
|
|
584
|
+
const declaredFailures = typeof details?.failedCount === "number" ? details.failedCount : sampledFailures.length;
|
|
585
|
+
const omittedFailures = Math.max(
|
|
586
|
+
typeof details?.omittedFailures === "number" ? details.omittedFailures : 0,
|
|
587
|
+
declaredFailures - sampledFailures.length,
|
|
588
|
+
);
|
|
589
|
+
for (const message of pending) h.telemetry?.publish("message.sent", {
|
|
590
|
+
id: message.id,
|
|
591
|
+
channel: "exocom",
|
|
592
|
+
from: exocom.telemetrySessionId(ctx),
|
|
593
|
+
to: message.sessionId,
|
|
594
|
+
kind: "message",
|
|
595
|
+
status: event.isError || failedTargets.has(message.target) ? "failed" : omittedFailures > 0 ? "unknown" : "delivered",
|
|
596
|
+
expectsReply: false,
|
|
597
|
+
size: message.size,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
if (event.toolName === "intercom") {
|
|
601
|
+
const action = typeof telemetryInput.action === "string" ? telemetryInput.action : "";
|
|
602
|
+
// The open entry is the right to publish a terminal: once the turn-boundary drain has closed
|
|
603
|
+
// this call, a late result must not report it a second time.
|
|
604
|
+
const pending = h.telemetryIntercomPending.get(event.toolCallId);
|
|
605
|
+
h.telemetryIntercomPending.delete(event.toolCallId);
|
|
606
|
+
if (pending && (action === "send" || action === "reply")) {
|
|
607
|
+
const text = typeof telemetryInput.message === "string" ? telemetryInput.message : "";
|
|
608
|
+
const askId = typeof telemetryInput.askId === "string" ? telemetryInput.askId : undefined;
|
|
609
|
+
h.telemetry?.publish(action === "reply" ? "message.replied" : "message.sent", {
|
|
610
|
+
id: event.toolCallId,
|
|
611
|
+
channel: "intercom",
|
|
612
|
+
from: "supervisor",
|
|
613
|
+
// Ask settlement has already retired its sender lookup. The tool-start
|
|
614
|
+
// snapshot owns the recipient for both queued and terminal telemetry.
|
|
615
|
+
to: pending.to,
|
|
616
|
+
kind: action,
|
|
617
|
+
status: event.isError ? "failed" : action === "reply" ? "replied" : "delivered",
|
|
618
|
+
expectsReply: false,
|
|
619
|
+
size: Buffer.byteLength(text, "utf8"),
|
|
620
|
+
...(askId ? { replyTo: askId } : {}),
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const errorPatch = piPersonaToolErrorPatch(event.details);
|
|
625
|
+
if (!h.config.nudge) return errorPatch;
|
|
626
|
+
// Only a supervisor that CAN delegate is nudged to — a persona without the tool can't act on it.
|
|
627
|
+
if (!h.controller.capabilities?.tools.has("delegate")) return errorPatch;
|
|
628
|
+
const notes: string[] = [];
|
|
629
|
+
// Grinding-by-hand reminder: a RUN of substantive hands-on commands on the supervisor's own
|
|
630
|
+
// tools (delegate/council reset the run). `size` classifies substantive vs glue + fat dump.
|
|
631
|
+
const text = event.content.reduce((s, c) => (c.type === "text" ? s + c.text : s), "");
|
|
632
|
+
const size = text.length;
|
|
633
|
+
const sweepNote = h.delegationNudge.observe(event.toolName, size, !errorPatch && event.isError !== true, text);
|
|
634
|
+
if (sweepNote) notes.push(sweepNote);
|
|
635
|
+
// Premature-surrender reminder: a delegated leg that came back BLOCKED/UNKNOWN (delegate/council
|
|
636
|
+
// results only; because delegate/council reset the run the two never fire on one event).
|
|
637
|
+
const surrender = h.persistenceNudge.observe(event.toolName, text);
|
|
638
|
+
if (surrender) notes.push(surrender);
|
|
639
|
+
if (notes.length === 0) return errorPatch;
|
|
640
|
+
const joined = notes.join("\n\n");
|
|
641
|
+
// Keep the semantic copy in the tool result for the model, and add a TUI-only durable card for
|
|
642
|
+
// the operator. Built-in/custom tool renderers are free to hide `content` when collapsed (some
|
|
643
|
+
// ignore it even when expanded), so the entry is the only cross-tool visibility guarantee.
|
|
644
|
+
h.appendNudgeEntry(joined);
|
|
645
|
+
return { ...errorPatch, content: [...event.content, { type: "text", text: joined }] };
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// Mandatory orchestration: when the active persona declares a strategy/parallel/
|
|
649
|
+
// pipeline mode (or a flow), run it on the user's turn (the LLM cannot skip it) and
|
|
650
|
+
// fold the result into the prompt. Opportunistic h.personas (no orchestration) take the
|
|
651
|
+
// normal turn.
|
|
652
|
+
function mandatoryFailure(label: string, output: string, error: string, failureKind: FailureKind): MandatoryOutcome {
|
|
653
|
+
return { label, output, ok: false, error, failureKind };
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Make a mandatory failure visible outside the hidden system prompt too. The cause may contain
|
|
657
|
+
* provider/model text, so compact it to one bounded operator-facing line. */
|
|
658
|
+
function reportMandatoryFailure(ctx: ExtensionContext, outcome: MandatoryOutcome): void {
|
|
659
|
+
const label = outcome.label.replace(/\s+/g, " ").trim().slice(0, 120);
|
|
660
|
+
const cause = (outcome.error ?? "unresolved result").replace(/\s+/g, " ").trim().slice(0, 500);
|
|
661
|
+
const kind = outcome.failureKind ? ` [${outcome.failureKind}]` : "";
|
|
662
|
+
const message = `pi-persona: mandatory orchestration ${label} failed${kind}: ${cause}`;
|
|
663
|
+
try {
|
|
664
|
+
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
665
|
+
else process.stderr.write(`${message}\n`);
|
|
666
|
+
} catch {
|
|
667
|
+
// Diagnostics are best-effort; the fail-closed system-prompt hand-off remains authoritative.
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
async function executeMandatoryOrchestration(
|
|
672
|
+
ctx: ExtensionContext,
|
|
673
|
+
orch: OrchestrationGrammar,
|
|
674
|
+
task: string,
|
|
675
|
+
): Promise<MandatoryOutcome> {
|
|
676
|
+
const flowName = orch.mode === "flow" ? orch.flow : undefined;
|
|
677
|
+
const label = flowName ? `flow ${flowName}` : (resolveStrategyName(orch) ?? "strategy");
|
|
678
|
+
try {
|
|
679
|
+
if (flowName) {
|
|
680
|
+
const parsed = h.loadFlow(ctx.cwd, flowName);
|
|
681
|
+
if (!parsed) {
|
|
682
|
+
const error = `no flow named "${flowName}"`;
|
|
683
|
+
return mandatoryFailure(label, error, error, "contract");
|
|
684
|
+
}
|
|
685
|
+
if (!parsed.ok) {
|
|
686
|
+
const error = `flow "${flowName}" is invalid: ${parsed.error}`;
|
|
687
|
+
return mandatoryFailure(label, error, error, "contract");
|
|
688
|
+
}
|
|
689
|
+
const outcome = await h.runFlowVisible(ctx, parsed.flow, task);
|
|
690
|
+
const output = outcome.output || "(the flow returned no output)";
|
|
691
|
+
if (outcome.ok) return { label, output, ok: true };
|
|
692
|
+
const error = outcome.cancelled
|
|
693
|
+
? "the mandatory flow was aborted"
|
|
694
|
+
: (outcome.error ?? "the mandatory flow did not complete successfully");
|
|
695
|
+
const failureKind = outcome.cancelled ? "abort" : (outcome.failureKind ?? "agent");
|
|
696
|
+
return mandatoryFailure(label, output, error, failureKind);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const result = await h.runStrategyVisible(ctx, orch, task, "strategy");
|
|
700
|
+
if (!result) {
|
|
701
|
+
const error = "the mandatory orchestration returned no result";
|
|
702
|
+
return mandatoryFailure(label, `(${error})`, error, "contract");
|
|
703
|
+
}
|
|
704
|
+
if (!result.ok) {
|
|
705
|
+
return mandatoryFailure(
|
|
706
|
+
label,
|
|
707
|
+
result.output || "(the orchestration returned no output)",
|
|
708
|
+
result.error ?? "the mandatory orchestration did not complete successfully",
|
|
709
|
+
result.failureKind ?? "agent",
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
return { label, output: result.output, ok: true };
|
|
713
|
+
} catch (err) {
|
|
714
|
+
const error = `orchestration failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
715
|
+
return mandatoryFailure(label, error, error, "agent");
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function enqueueDeferredOrchestration(
|
|
720
|
+
task: string,
|
|
721
|
+
orchestration: OrchestrationGrammar,
|
|
722
|
+
ctx: ExtensionContext,
|
|
723
|
+
): { action: "handled" } {
|
|
724
|
+
if (h.deferredOrchestrations.length >= h.MAX_DEFERRED_ORCHESTRATIONS) {
|
|
725
|
+
ctx.ui.notify(
|
|
726
|
+
`persona orchestration queue full (${h.MAX_DEFERRED_ORCHESTRATIONS}); input was not accepted — retry after the current run settles.`,
|
|
727
|
+
"error",
|
|
728
|
+
);
|
|
729
|
+
return { action: "handled" };
|
|
730
|
+
}
|
|
731
|
+
h.deferredOrchestrations.push({ task, orchestration });
|
|
732
|
+
ctx.ui.notify(`persona orchestration queued (${h.deferredOrchestrations.length}) — it will run after the current turn settles.`, "info");
|
|
733
|
+
return { action: "handled" };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
async function processNextDeferredOrchestration(): Promise<boolean> {
|
|
737
|
+
if (h.disposed || h.processingDeferredOrchestration || h.deferredOrchestrations.length === 0 || !h.lastCtx) return false;
|
|
738
|
+
const item = h.deferredOrchestrations.shift()!;
|
|
739
|
+
h.processingDeferredOrchestration = true;
|
|
740
|
+
h.orchestrating = true;
|
|
741
|
+
try {
|
|
742
|
+
const result = await executeMandatoryOrchestration(h.lastCtx, item.orchestration, item.task);
|
|
743
|
+
if (h.disposed) return false;
|
|
744
|
+
h.pendingOrchestration = result;
|
|
745
|
+
// Source is "extension", so the input hook will not orchestrate this replay again.
|
|
746
|
+
sendPersonaFollowUp(pi, item.task, "pi-persona-deferred-input");
|
|
747
|
+
return true;
|
|
748
|
+
} catch (err) {
|
|
749
|
+
h.pendingOrchestration = undefined;
|
|
750
|
+
if (!h.disposed) {
|
|
751
|
+
h.deferredOrchestrations.unshift(item);
|
|
752
|
+
h.lastCtx.ui.notify(`could not replay deferred persona input: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
753
|
+
}
|
|
754
|
+
return false;
|
|
755
|
+
} finally {
|
|
756
|
+
h.orchestrating = false;
|
|
757
|
+
h.processingDeferredOrchestration = false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
pi.on("input", async (event, ctx) => {
|
|
762
|
+
h.lastCtx = ctx;
|
|
763
|
+
if (event.source === "extension") return undefined;
|
|
764
|
+
const orch = h.controller.activePersona?.orchestration;
|
|
765
|
+
const task = event.text?.trim();
|
|
766
|
+
if (!orch || !task) return undefined;
|
|
767
|
+
const flowName = orch.mode === "flow" ? orch.flow : undefined;
|
|
768
|
+
let strategyName: string | undefined;
|
|
769
|
+
try {
|
|
770
|
+
strategyName = resolveStrategyName(orch);
|
|
771
|
+
} catch (err) {
|
|
772
|
+
// Only the persona FILE can fix this; take the turn normally rather than tearing the
|
|
773
|
+
// input hook down on every keystroke, but say so loudly — the mandatory orchestration
|
|
774
|
+
// the user configured is silently not happening.
|
|
775
|
+
ctx.ui.notify(h.personaGrammarError(err), "error");
|
|
776
|
+
return undefined;
|
|
777
|
+
}
|
|
778
|
+
if (!flowName && !strategyName) return undefined;
|
|
779
|
+
const mainBusy = ctx.isIdle() !== true;
|
|
780
|
+
// Steering belongs to the turn already in progress. Never start a competing mandatory
|
|
781
|
+
// orchestration for it; Pi remains responsible for applying the steering message.
|
|
782
|
+
if (mainBusy && event.streamingBehavior !== "followUp") return { action: "continue" };
|
|
783
|
+
// Follow-up input must retain mandatory semantics without racing the live run. An input that
|
|
784
|
+
// arrives while another orchestration is executing joins the same FIFO even if Pi is idle.
|
|
785
|
+
if (mainBusy || h.orchestrating) return enqueueDeferredOrchestration(task, orch, ctx);
|
|
786
|
+
h.orchestrating = true;
|
|
787
|
+
try {
|
|
788
|
+
h.pendingOrchestration = await executeMandatoryOrchestration(ctx, orch, task);
|
|
789
|
+
} finally {
|
|
790
|
+
h.orchestrating = false;
|
|
791
|
+
}
|
|
792
|
+
// Let the user's original prompt proceed; the ruling is injected (hidden) into the
|
|
793
|
+
// turn's system prompt via before_agent_start — no internal plumbing in the chat.
|
|
794
|
+
return undefined;
|
|
795
|
+
});
|
|
796
|
+
}
|