@aeondave/pi-persona 1.17.3 → 1.17.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +44 -36
- package/CHANGELOG.md +76 -1
- package/README.md +23 -13
- package/docs/ARCHITECTURE.md +97 -37
- package/docs/COLLABORATION.md +110 -0
- package/docs/EXPERIENCE.md +33 -8
- package/docs/README.md +12 -0
- package/docs/REFERENCE.md +65 -14
- package/package.json +12 -6
- package/src/agents/agent.ts +6 -0
- package/src/bridge.ts +25 -8
- package/src/bus/broker/client.ts +119 -21
- package/src/bus/broker/host.ts +123 -8
- package/src/bus/broker/messages.ts +31 -4
- package/src/bus/contact.ts +113 -38
- package/src/bus/peers.ts +15 -0
- package/src/bus/result-reader.ts +88 -0
- package/src/core/brief.ts +14 -4
- package/src/core/capabilities.ts +1 -1
- package/src/core/leg-thinking.ts +57 -0
- package/src/core/ownership.ts +1 -2
- package/src/core/pi-compat.ts +5 -12
- package/src/core/session-runs.ts +1 -1
- package/src/core/types.ts +1 -1
- package/src/engine/adapter.ts +83 -19
- package/src/engine/async.ts +49 -10
- package/src/engine/child.ts +10 -2
- package/src/engine/inproc.ts +87 -46
- package/src/engine/result-grant.ts +62 -0
- package/src/engine/session-tools.ts +6 -0
- package/src/engine/stream.ts +6 -0
- package/src/engine/worktree.ts +17 -7
- package/src/exocom/collaboration-delivery.ts +29 -0
- package/src/exocom/collaboration.ts +148 -0
- package/src/exocom/envelope.ts +43 -4
- package/src/exocom/gate.ts +7 -2
- package/src/exocom/install.ts +45 -17
- package/src/exocom/ledger.ts +58 -8
- package/src/exocom/plane.ts +32 -1
- package/src/exocom/profile.ts +40 -0
- package/src/exocom/registry.ts +5 -0
- package/src/exocom/status.ts +11 -1
- package/src/exocom/write-guard.ts +7 -0
- package/src/extension/broker-host.ts +61 -9
- package/src/extension/engine.ts +20 -21
- package/src/extension/hooks.ts +952 -938
- package/src/extension/identity.ts +41 -75
- package/src/extension/results.ts +136 -0
- package/src/extension/shared.ts +17 -0
- package/src/extension.ts +2963 -2873
- package/src/loader.ts +11 -3
- package/src/orchestration/flow-journal.ts +7 -1
- package/src/orchestration/flow-run.ts +122 -19
- package/src/orchestration/flow.ts +11 -0
- package/src/orchestration/roster.ts +71 -23
- package/src/orchestration/sdk.ts +44 -6
- package/src/orchestration/types.ts +10 -0
- package/src/persona/model-participants.ts +20 -7
- package/src/persona/orchestrate.ts +2 -0
- package/src/persona/persona.ts +1 -1
- package/src/results/archive.ts +281 -0
- package/src/results/record.ts +166 -0
- package/src/results/retrieval.ts +142 -0
- package/src/results/types.ts +128 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/council.ts +40 -23
- package/src/tools/delegate-tool.ts +50 -14
- package/src/tools/delegate.ts +13 -6
- package/src/tools/exocom-collaboration-input.ts +168 -0
- package/src/tools/exocom-collaboration.ts +315 -0
- package/src/tools/exocom-profile.ts +102 -0
- package/src/tools/exocom.ts +20 -3
- package/src/tools/flow.ts +24 -8
- package/src/tools/intercom-tool.ts +98 -14
- package/src/tools/models.ts +15 -0
- package/src/tools/monitor.ts +20 -1
- package/src/tools/read-result.ts +52 -0
- package/src/tools/timer.ts +23 -2
- package/src/ui/agent-overlay.ts +11 -3
- package/src/ui/agent-tree.ts +43 -1
- package/src/ui/literal-tool-result.ts +1 -1
- package/src/ui/model-picker.ts +1 -1
- package/src/ui/orchestration-launch.ts +49 -0
- package/src/ui/session-cards.ts +191 -14
package/src/extension/hooks.ts
CHANGED
|
@@ -1,948 +1,962 @@
|
|
|
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 { 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";
|
|
15
|
-
import { buildSessionAnchor } from "../core/time.ts";
|
|
16
|
-
import { readClockSnapshot } from "./clock.ts";
|
|
17
|
-
import { type AsyncRun, type AsyncRunTracker, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
|
|
18
|
-
import { constrainedTurnAllows } from "../exocom/gate.ts";
|
|
19
|
-
import type { LedgerClaim } from "../exocom/ledger.ts";
|
|
20
|
-
import type { DisplayPeer } from "../exocom/plane.ts";
|
|
21
|
-
import { peerClaimFor, WRITE_TOOLS, writeWarningKey, writeWarningReason } from "../exocom/write-guard.ts";
|
|
22
|
-
import {
|
|
23
|
-
canonicalExocomTelemetryTargets,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
import type {
|
|
31
|
-
import {
|
|
32
|
-
import type {
|
|
33
|
-
import
|
|
34
|
-
import type {
|
|
35
|
-
import {
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
38
|
-
import {
|
|
39
|
-
import type
|
|
40
|
-
import
|
|
41
|
-
import type {
|
|
42
|
-
|
|
43
|
-
type
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
get
|
|
56
|
-
set
|
|
57
|
-
get
|
|
58
|
-
set
|
|
59
|
-
get
|
|
60
|
-
set
|
|
61
|
-
get
|
|
62
|
-
set
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
readonly
|
|
70
|
-
readonly
|
|
71
|
-
readonly
|
|
72
|
-
readonly
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
get
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
pi.
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
h.
|
|
186
|
-
h.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
h.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
h.
|
|
210
|
-
h.
|
|
211
|
-
h.
|
|
212
|
-
h.
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 { 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";
|
|
15
|
+
import { buildSessionAnchor } from "../core/time.ts";
|
|
16
|
+
import { readClockSnapshot } from "./clock.ts";
|
|
17
|
+
import { type AsyncRun, type AsyncRunTracker, type IdleCoalescingNotifier, type PeekWatcher } from "../engine/async.ts";
|
|
18
|
+
import { constrainedTurnAllows } from "../exocom/gate.ts";
|
|
19
|
+
import type { LedgerClaim } from "../exocom/ledger.ts";
|
|
20
|
+
import type { DisplayPeer } from "../exocom/plane.ts";
|
|
21
|
+
import { peerClaimFor, WRITE_TOOLS, writeWarningKey, writeWarningReason, writeWarningToastReason } from "../exocom/write-guard.ts";
|
|
22
|
+
import {
|
|
23
|
+
canonicalExocomTelemetryTargets,
|
|
24
|
+
compactNameList,
|
|
25
|
+
piPersonaToolErrorPatch,
|
|
26
|
+
sendPersonaFollowUp,
|
|
27
|
+
toastCause,
|
|
28
|
+
type PendingAsk,
|
|
29
|
+
} from "./shared.ts";
|
|
30
|
+
import type { TimerEntry } from "../core/timer.ts";
|
|
31
|
+
import type { MonitorSession } from "../monitor/session.ts";
|
|
32
|
+
import type { FlowSpec } from "../orchestration/flow.ts";
|
|
33
|
+
import { resolveStrategyName } from "../persona/orchestrate.ts";
|
|
34
|
+
import type { PersonaController, PersonaHost } from "../persona/controller.ts";
|
|
35
|
+
import type { OrchestrationGrammar, Persona } from "../persona/persona.ts";
|
|
36
|
+
import type { PersonaConfigStore } from "../persona/config-store.ts";
|
|
37
|
+
import { TELEMETRY_EVENT_NAME } from "../telemetry/contract.ts";
|
|
38
|
+
import { TelemetryProducer, type TelemetryAgentInput } from "../telemetry/producer.ts";
|
|
39
|
+
import { isWakeSource, type AgentTree, type AgentNode } from "../ui/agent-tree.ts";
|
|
40
|
+
import { compactInlineText } from "../ui/presentation.ts";
|
|
41
|
+
import type { FailureKind } from "../orchestration/types.ts";
|
|
42
|
+
import type { ExocomInstall } from "../exocom/install.ts";
|
|
43
|
+
import type { SessionIdentity } from "./identity.ts";
|
|
44
|
+
import type { SessionResults } from "./results.ts";
|
|
45
|
+
import { resultReceiptHint, type ResultReceipt } from "../results/retrieval.ts";
|
|
46
|
+
|
|
47
|
+
type MandatoryOutcome = { label: string; output: string; ok: boolean; error?: string; failureKind?: FailureKind; resultReceipt?: ResultReceipt };
|
|
48
|
+
|
|
49
|
+
export interface HookHost {
|
|
50
|
+
readonly identity: SessionIdentity;
|
|
51
|
+
readonly results: SessionResults;
|
|
52
|
+
config: PiPersonaConfig;
|
|
53
|
+
controller: PersonaController;
|
|
54
|
+
personaHost: PersonaHost;
|
|
55
|
+
get lastCtx(): ExtensionContext | undefined;
|
|
56
|
+
set lastCtx(value: ExtensionContext | undefined);
|
|
57
|
+
get disposed(): boolean;
|
|
58
|
+
set disposed(value: boolean);
|
|
59
|
+
get orchestrating(): boolean;
|
|
60
|
+
set orchestrating(value: boolean);
|
|
61
|
+
get processingDeferredOrchestration(): boolean;
|
|
62
|
+
set processingDeferredOrchestration(value: boolean);
|
|
63
|
+
get pendingOrchestration(): MandatoryOutcome | undefined;
|
|
64
|
+
set pendingOrchestration(value: MandatoryOutcome | undefined);
|
|
65
|
+
readonly MAX_DEFERRED_ORCHESTRATIONS: number;
|
|
66
|
+
readonly deferredOrchestrations: Array<{ task: string; orchestration: OrchestrationGrammar }>;
|
|
67
|
+
get telemetry(): TelemetryProducer | undefined;
|
|
68
|
+
set telemetry(value: TelemetryProducer | undefined);
|
|
69
|
+
readonly telemetryToolStartedAt: Map<string, { startedAt: number; name: string }>;
|
|
70
|
+
readonly telemetryExocomPending: Map<string, Array<{ id: string; sessionId: string; target: string; size: number; replyTo?: string }>>;
|
|
71
|
+
readonly telemetryIntercomPending: Map<string, { kind: string; to: string; size: number; replyTo?: string }>;
|
|
72
|
+
readonly lastAgentProjection: Map<string, string>;
|
|
73
|
+
readonly telemetryAskSenders: Map<string, string>;
|
|
74
|
+
readonly agentTree: AgentTree;
|
|
75
|
+
telemetryAgent(node: AgentNode): TelemetryAgentInput;
|
|
76
|
+
delegationNudge: DelegationNudge;
|
|
77
|
+
persistenceNudge: PersistenceNudge;
|
|
78
|
+
/** Refuses an unchanged repeat of an intercom peek/result; absent when the nudge kill switch is off. */
|
|
79
|
+
pollGuard: PollGuard | undefined;
|
|
80
|
+
/** The async-run tracker: what is still running, and the run-id counter a restore continues. */
|
|
81
|
+
tracker: Pick<AsyncRunTracker, "running" | "issued" | "continueFrom">;
|
|
82
|
+
appendNudgeEntry(content: string): void;
|
|
83
|
+
completionNotifier: IdleCoalescingNotifier<AsyncRun>;
|
|
84
|
+
intercomNotifier: IdleCoalescingNotifier<PendingAsk>;
|
|
85
|
+
timerNotifier: IdleCoalescingNotifier<TimerEntry>;
|
|
86
|
+
timerScheduler: { cancelAll(): void; readonly size: number };
|
|
87
|
+
monitors: MonitorSession;
|
|
88
|
+
peekWatcher: PeekWatcher;
|
|
89
|
+
stopPeek(): void;
|
|
90
|
+
stopRegistry: Map<string, () => void>;
|
|
91
|
+
stopRequested: Set<string>;
|
|
92
|
+
steerRegistry: Map<string, unknown>;
|
|
93
|
+
broker: { close(): Promise<void> };
|
|
94
|
+
get spineText(): string;
|
|
95
|
+
delegationBrief(ctx: ExtensionContext): string | undefined;
|
|
96
|
+
get agents(): AgentConfig[];
|
|
97
|
+
get personas(): Persona[];
|
|
98
|
+
reload(cwd: string): void;
|
|
99
|
+
readConfigStore(): PersonaConfigStore;
|
|
100
|
+
get personaConfigs(): PersonaConfigStore;
|
|
101
|
+
set personaConfigs(value: PersonaConfigStore);
|
|
102
|
+
readRememberedPersona(): string | undefined;
|
|
103
|
+
runSeed(force: boolean): { copied: unknown[] };
|
|
104
|
+
seedMarker(): string;
|
|
105
|
+
personaDataDir(): string;
|
|
106
|
+
reportSeedSourceCollisions(ctx: ExtensionContext, result: { copied: unknown[] }): void;
|
|
107
|
+
refreshSpineAfterSeed(): void;
|
|
108
|
+
inspectEnabledLegacySpines(): void;
|
|
109
|
+
reportSeedMigration(ctx: ExtensionContext, result: unknown): void;
|
|
110
|
+
seedMigration: unknown;
|
|
111
|
+
reportDataRootMigration(ctx: ExtensionContext): void;
|
|
112
|
+
reportSpineWarning(ctx: ExtensionContext): void;
|
|
113
|
+
reportDefinitionCollisions(ctx: ExtensionContext): void;
|
|
114
|
+
userAgentDir(): string;
|
|
115
|
+
loadFlow(cwd: string, name: string): { ok: true; flow: FlowSpec } | { ok: false; error: string } | undefined;
|
|
116
|
+
runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, task: string): Promise<{ output: string; ok: boolean; cancelled?: boolean; error?: string; failureKind?: FailureKind; resultReceipt?: ResultReceipt }>;
|
|
117
|
+
runStrategyVisible(ctx: ExtensionContext, orch: OrchestrationGrammar, task: string, label: string): Promise<{ output: string; ok: boolean; error?: string; failureKind?: FailureKind; resultReceipt?: ResultReceipt } | undefined>;
|
|
118
|
+
personaGrammarError(err: unknown): string;
|
|
119
|
+
intercomRecipient(input: Record<string, unknown>, askId: string | undefined): string;
|
|
120
|
+
idleDelivery: unknown;
|
|
121
|
+
drainBusBlock: () => string;
|
|
122
|
+
childUsage: { reset(): void };
|
|
123
|
+
publishPersonaCost: () => void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstall): void {
|
|
127
|
+
// Only parent ids are retained, never child output. Propagate through wrappers only when they
|
|
128
|
+
// actually relay a surrender marker; the outer model-visible result owns the note and card.
|
|
129
|
+
const pendingSurrenderRelays = new Set<string>();
|
|
130
|
+
const rememberSurrenderRelay = (id: string): void => {
|
|
131
|
+
if (pendingSurrenderRelays.size >= 256) {
|
|
132
|
+
const oldest = pendingSurrenderRelays.values().next().value;
|
|
133
|
+
if (oldest !== undefined) pendingSurrenderRelays.delete(oldest);
|
|
134
|
+
}
|
|
135
|
+
pendingSurrenderRelays.add(id);
|
|
136
|
+
};
|
|
137
|
+
// `delegate`/`council`/`flow` calls (all background launches) whose tool_call passed but whose result has
|
|
138
|
+
// not landed. Pi prepares every call of a batch before any executes, so a same-batch launch + `sleep`
|
|
139
|
+
// would otherwise see no running child yet. A launch that later fails validation counts until its result lands: accepted.
|
|
140
|
+
const launching = new Set<string>();
|
|
141
|
+
const clearInFlight = (): void => { pendingSurrenderRelays.clear(); launching.clear(); };
|
|
142
|
+
/** A tool the model can call right now: active in the host and allowed by the persona. */
|
|
143
|
+
const toolAllowed = (name: string): boolean => {
|
|
144
|
+
const caps = h.controller.capabilities;
|
|
145
|
+
return pi.getActiveTools().includes(name) && (!caps || canCallTool(caps, name));
|
|
146
|
+
};
|
|
147
|
+
// ── restore honesty ───────────────────────────────────────────────────────
|
|
148
|
+
// A reload/resume/`pi -c` ends this instance and with it every background run, armed timer and
|
|
149
|
+
// monitor (memory only). Shutdown records what dies and the run-id counter in a session entry
|
|
150
|
+
// (core/session-runs.ts); the next start tells the restored supervisor once, passively, and
|
|
151
|
+
// continues the ids so they never repeat within one history. Best effort: a failure on either
|
|
152
|
+
// side degrades to the old behaviour, never to a broken shutdown or start.
|
|
153
|
+
let restoredIssued = 0; // the counter this instance started from: unchanged ⇒ nothing new to record
|
|
154
|
+
function recordLostWork(ctx: ExtensionContext): void {
|
|
155
|
+
try {
|
|
156
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
157
|
+
if (!sessionId) return;
|
|
158
|
+
const lost = {
|
|
159
|
+
runs: [...new Set([...h.tracker.running(), ...h.completionNotifier.peekPending()].map((run) => run.id))],
|
|
160
|
+
timers: h.timerScheduler.size,
|
|
161
|
+
monitors: h.monitors.list().length,
|
|
162
|
+
};
|
|
163
|
+
if (lost.runs.length === 0 && lost.timers === 0 && lost.monitors === 0 && h.tracker.issued <= restoredIssued) return;
|
|
164
|
+
pi.appendEntry(RUNS_ENTRY_CUSTOM_TYPE, makeRunsEntry(sessionId, h.tracker.issued, lost));
|
|
165
|
+
} catch { /* shutdown must complete */ }
|
|
166
|
+
}
|
|
167
|
+
function announceLostWork(ctx: ExtensionContext): void {
|
|
168
|
+
try {
|
|
169
|
+
const manager = ctx.sessionManager;
|
|
170
|
+
const stored = findStoredRuns(manager.getEntries(), manager.getSessionId());
|
|
171
|
+
if (!stored) return;
|
|
172
|
+
h.tracker.continueFrom(stored.issued);
|
|
173
|
+
restoredIssued = h.tracker.issued;
|
|
174
|
+
const notice = restoreNotice(stored.lost);
|
|
175
|
+
if (!notice) return;
|
|
176
|
+
pi.sendMessage({ customType: "pi-persona", content: notice, display: true }, { deliverAs: "nextTurn" }); // passive: never starts a turn
|
|
177
|
+
// Acknowledge so the newest entry is an empty loss: the same loss can never be announced twice.
|
|
178
|
+
pi.appendEntry(RUNS_ENTRY_CUSTOM_TYPE, makeRunsEntry(stored.sessionId, stored.issued, { runs: [], timers: 0, monitors: 0 }));
|
|
179
|
+
} catch { /* the session start must complete */ }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── lifecycle ─────────────────────────────────────────────────────────────
|
|
183
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
184
|
+
clearInFlight();
|
|
185
|
+
h.lastCtx = ctx;
|
|
186
|
+
h.results.activate(ctx);
|
|
187
|
+
h.delegationNudge.reset(); // a fresh session starts with a clean by-hand run
|
|
188
|
+
h.childUsage.reset();
|
|
189
|
+
h.publishPersonaCost();
|
|
190
|
+
announceLostWork(ctx);
|
|
191
|
+
// Opt-in only (PI_PERSONA_SEED=on): auto-install the bundled defaults once. Default is off —
|
|
192
|
+
// a fresh install shows no h.personas until `/persona seed` or `/persona restore`.
|
|
193
|
+
if (h.config.seed && !existsSync(h.seedMarker())) {
|
|
194
|
+
try {
|
|
195
|
+
const r = h.runSeed(false);
|
|
196
|
+
h.reportSeedSourceCollisions(ctx, r);
|
|
197
|
+
// Resolution happened before session_start, when a fresh install had no user copy. Refresh
|
|
198
|
+
// now so this very process uses what auto-seed just created and reports only post-seed state.
|
|
199
|
+
h.refreshSpineAfterSeed();
|
|
200
|
+
if (ctx.hasUI && r.copied.length > 0) {
|
|
201
|
+
ctx.ui.notify(`pi-persona: seeded ${r.copied.length} default(s) to ${h.personaDataDir()} — edit them freely; /persona restore brings back the originals.`, "info");
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// Seeding is multi-file: an error can arrive after the prompt itself was copied. Re-resolve
|
|
205
|
+
// and retry only enabled legacy roles so partial progress is visible in this same process.
|
|
206
|
+
h.inspectEnabledLegacySpines();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
h.reportSeedMigration(ctx, h.seedMigration);
|
|
210
|
+
h.reportDataRootMigration(ctx); // the one-time storage-root move ran at activation; this is its voice
|
|
211
|
+
h.reportSpineWarning(ctx);
|
|
212
|
+
h.reload(ctx.cwd);
|
|
213
|
+
h.identity.start(ctx); // restore session identity before persona activation can join Exocom
|
|
214
|
+
h.reportDefinitionCollisions(ctx);
|
|
215
|
+
h.personaConfigs = h.readConfigStore();
|
|
216
|
+
// Restore order: --persona flag > env pin (PI_PERSONA_DEFAULT) > remembered-on-disk. Read-only.
|
|
217
|
+
const flagPersona = ((pi.getFlag("persona") as string) || "").trim();
|
|
218
|
+
const remembered = flagPersona || h.config.defaultPersona || h.readRememberedPersona();
|
|
219
|
+
const target = remembered ? h.personas.find((p) => p.name === remembered) : undefined;
|
|
220
|
+
if (target) await h.controller.activate(target);
|
|
221
|
+
else {
|
|
222
|
+
// A name that doesn't resolve leaves the session with NO persona at all, whichever source it
|
|
223
|
+
// came from — and silence there reads as "it's active" while nothing is (downstream tooling
|
|
224
|
+
// keyed on the remembered name, e.g. per-persona memory, then serves a persona that was never
|
|
225
|
+
// activated). An EXPLICIT `--persona` is a direct instruction, so it stays an error; an env
|
|
226
|
+
// pin or a stale remembered name is a warning. The marker itself is deliberately NOT cleared:
|
|
227
|
+
// h.personas are discovered per-cwd, so a name that is missing here may be present in the
|
|
228
|
+
// project the user came from. The model (`--model`) and effort (`--thinking`) are pi's own
|
|
229
|
+
// flags — pi validates those.
|
|
230
|
+
if (remembered) {
|
|
231
|
+
const names = compactNameList(h.personas.map((p) => p.name), 12) || "(none installed — run /persona seed)";
|
|
232
|
+
const source = flagPersona ? "--persona" : h.config.defaultPersona ? "PI_PERSONA_DEFAULT" : "remembered persona";
|
|
233
|
+
const msg = `pi-persona: ${source} "${remembered}" is not an installed persona. Available: ${names}`;
|
|
234
|
+
if (ctx.hasUI) ctx.ui.notify(msg, flagPersona ? "error" : "warning");
|
|
235
|
+
else process.stderr.write(`${msg}\n`);
|
|
236
|
+
}
|
|
237
|
+
h.personaHost.setStatus(h.controller.activePersona?.label);
|
|
238
|
+
}
|
|
239
|
+
await exocom.reconcile(ctx); // after persona activation, so plane + active tools share one gate
|
|
240
|
+
await h.telemetry?.stop("session-replaced");
|
|
241
|
+
h.telemetry = undefined;
|
|
242
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
240
243
|
if (sessionId) {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
//
|
|
271
|
-
|
|
272
|
-
h.
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
h.
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
h.
|
|
288
|
-
h.deferredOrchestrations.length = 0;
|
|
289
|
-
h.pendingOrchestration = undefined;
|
|
290
|
-
h.stopPeek(); // h.reload-hygiene: never leak the idle-peek timer across sessions
|
|
291
|
-
h.peekWatcher.reset(); // …nor a stale "already surfaced this leg as stuck" set into the next session
|
|
292
|
-
h.completionNotifier.cancel(); // …nor the coalesced-delivery flush timers
|
|
293
|
-
h.intercomNotifier.cancel();
|
|
294
|
-
exocom.notifier?.cancel(); // …nor a late exocom follow-up (M1) — cancelled BEFORE exocom.stop tears down the plane below
|
|
295
|
-
exocom.waitNotifier?.cancel();
|
|
296
|
-
h.timerScheduler.cancelAll(); // …nor any armed alarms (never wake the next session)
|
|
297
|
-
h.timerNotifier.cancel();
|
|
298
|
-
h.monitors.cancelAll();
|
|
299
|
-
// This instance is being torn down (a h.reload/new/resume rebinds a fresh one); abort in-flight
|
|
300
|
-
// sub-h.agents and reset control state so nothing is left orphaned or rendered stale.
|
|
301
|
-
for (const abort of [...h.stopRegistry.values()]) {
|
|
302
|
-
try {
|
|
303
|
-
abort();
|
|
304
|
-
} catch {
|
|
305
|
-
/* ignore */
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
h.stopRegistry.clear();
|
|
309
|
-
h.stopRequested.clear();
|
|
310
|
-
h.steerRegistry.clear();
|
|
311
|
-
h.agentTree.clear();
|
|
312
|
-
h.personaHost.setStatus(undefined);
|
|
313
|
-
// Queued, not called bare: a shutdown that lands while session_start's own start is still
|
|
314
|
-
// pending would otherwise see `exocom.plane` unset, no-op, and let that start bind a socket
|
|
315
|
-
// and register an entry for a session that no longer exists. Idempotent either way — off
|
|
316
|
-
// (or never started) ⇒ a no-op.
|
|
317
|
-
await exocom.queue(() => exocom.stop());
|
|
318
|
-
// Broker teardown (spec B1/B5): idempotent — a session that never built a broker-backed
|
|
319
|
-
// child engine (flag off, or on but unused) never started a host, so this is a no-op.
|
|
320
|
-
// `SupervisorBroker.close()` is itself best-effort (never blocks shutdown on a teardown error).
|
|
321
|
-
await h.broker.close();
|
|
322
|
-
await h.telemetry?.stop(event?.reason);
|
|
323
|
-
h.telemetry = undefined;
|
|
324
|
-
h.telemetryToolStartedAt.clear();
|
|
325
|
-
h.telemetryExocomPending.clear();
|
|
326
|
-
h.telemetryIntercomPending.clear();
|
|
327
|
-
h.telemetryAskSenders.clear(); // …and no ask attribution leaks into the next session
|
|
328
|
-
h.lastAgentProjection.clear();
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
// Pi marks the session idle before emitting agent_settled. A deferred mandatory input owns this
|
|
332
|
-
// idle window first; its replay starts the next run and the remaining FIFO entries follow one per
|
|
333
|
-
// settlement. With no deferred input, flush ordinary wakes immediately.
|
|
334
|
-
pi.on("agent_settled", async () => {
|
|
335
|
-
clearInFlight();
|
|
336
|
-
h.pollGuard?.reset(); // a settled turn ends any poll streak: the next peek is a fresh look
|
|
337
|
-
if (h.lastCtx) h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(h.lastCtx), status: "idle" });
|
|
338
|
-
// Pi's `prepareToolCall` returns {kind:"immediate"} when the abort signal is already set
|
|
339
|
-
// after beforeToolCall — skipping afterToolCall, and so our tool_result hook. Those calls
|
|
340
|
-
// would otherwise stay "running" until session_shutdown (press Esc mid-fan-out and every
|
|
341
|
-
// call that had not yet started is orphaned). The turn boundary is where they end.
|
|
342
|
-
for (const [callId, open] of h.telemetryToolStartedAt) {
|
|
343
|
-
h.telemetry?.publish("tool.finished", {
|
|
344
|
-
callId,
|
|
345
|
-
agentId: "supervisor",
|
|
346
|
-
name: open.name,
|
|
347
|
-
status: "failed",
|
|
348
|
-
durationMs: Math.max(0, Date.now() - open.startedAt),
|
|
349
|
-
});
|
|
350
|
-
}
|
|
351
|
-
h.telemetryToolStartedAt.clear(); // a late result for a drained call must not close it twice
|
|
352
|
-
// An exocom send orphaned on that same path left its recipients at "queued" forever. They are
|
|
353
|
-
// reported failed, never delivered: the call never reached a result, so nothing observed a send.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
// A session id, like every other exocom publish site — never an agentId literal in this slot.
|
|
359
|
-
from: h.lastCtx ? exocom.telemetrySessionId(h.lastCtx) : `legacy-${process.pid}`,
|
|
360
|
-
to: message.sessionId,
|
|
361
|
-
kind: "message",
|
|
362
|
-
status: "failed",
|
|
363
|
-
expectsReply: false,
|
|
364
|
-
size: message.size,
|
|
365
|
-
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
366
|
-
});
|
|
367
|
-
}
|
|
368
|
-
h.telemetryExocomPending.clear();
|
|
369
|
-
for (const [callId, message] of h.telemetryIntercomPending) {
|
|
370
|
-
h.telemetry?.publish(message.kind === "reply" ? "message.replied" : "message.sent", {
|
|
371
|
-
id: callId,
|
|
372
|
-
channel: "intercom",
|
|
373
|
-
from: "supervisor",
|
|
374
|
-
to: message.to,
|
|
375
|
-
kind: message.kind,
|
|
376
|
-
status: "failed",
|
|
377
|
-
expectsReply: false,
|
|
378
|
-
size: message.size,
|
|
379
|
-
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
380
|
-
});
|
|
381
|
-
}
|
|
382
|
-
h.telemetryIntercomPending.clear();
|
|
383
|
-
if (await processNextDeferredOrchestration()) return;
|
|
384
|
-
h.completionNotifier.kick();
|
|
385
|
-
h.intercomNotifier.kick();
|
|
386
|
-
h.timerNotifier.kick();
|
|
387
|
-
h.monitors.kick();
|
|
388
|
-
exocom.notifier?.kick();
|
|
389
|
-
exocom.waitNotifier?.kick();
|
|
390
|
-
});
|
|
391
|
-
|
|
392
|
-
pi.on("before_agent_start", (event, ctx) => {
|
|
393
|
-
h.lastCtx = ctx;
|
|
394
|
-
h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(ctx), status: "active" });
|
|
395
|
-
// The spine lifts persona-less turns too: with nothing active the composition is Pi's base
|
|
396
|
-
// prompt + the layer. Off ⇒ `h.spineText` is empty and this is `event.systemPrompt` itself.
|
|
397
|
-
const noPersona = h.spineText ? `${event.systemPrompt}\n\n${h.spineText}` : event.systemPrompt;
|
|
398
|
-
let prompt = h.controller.composePrompt(event.systemPrompt, h.spineText) ?? noPersona;
|
|
399
|
-
// Stable system-level interpretation of runtime metadata. Never append an acknowledgement
|
|
400
|
-
// request to the conversation: that would recreate the old identity pseudo-turn loop.
|
|
401
|
-
prompt = `${prompt}\n\n${RUNTIME_CONTEXT_GUIDANCE}`;
|
|
402
|
-
// The session time anchor (core/time.ts). Unconditional, unlike both briefs below: a supervisor
|
|
403
|
-
// that has been working for hours needs to know it whether or not h.agents or peers exist. It goes
|
|
404
|
-
// in the SYSTEM prompt because the prompt is re-composed and re-sent every turn rather than
|
|
405
|
-
// summarized, so an anchor placed here cannot be compacted away — the same fact in the
|
|
406
|
-
// conversation tail would be. Its start comes from the session HEADER, the first entry of the
|
|
407
|
-
// append-only session file, so after /resume it is still the ORIGINAL start: that is what makes it
|
|
408
|
-
// answer "how long have I been on this problem" across restarts and not merely across turns.
|
|
409
|
-
// Placed BEFORE the briefs so the standing hand-off default keeps the last word at the tail; the
|
|
410
|
-
// elapsed reading remains coarse to preserve caching; the precise clock goes in a new
|
|
411
|
-
// conversation message below, and timer now can refresh it within a long tool loop.
|
|
412
|
-
const now = Date.now();
|
|
413
|
-
const anchor = buildSessionAnchor(ctx.sessionManager?.getHeader?.() ?? null, now);
|
|
414
|
-
if (anchor) prompt = `${prompt}\n\n${anchor}`;
|
|
415
|
-
const wakeGuidance: string[] = [];
|
|
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
|
-
}
|
|
425
|
-
const brief = h.delegationBrief(ctx);
|
|
426
|
-
if (brief) prompt = `${prompt}\n\n${brief}`;
|
|
427
|
-
// Per-turn exocom peer AWARENESS (mirrors the delegation brief above): regenerated from the
|
|
428
|
-
// live registry every turn so it cannot desync, and gated implicitly — exocom.plane is only
|
|
429
|
-
// set when exocom is on AND canUseBus, so this is a no-op (undefined ⇒ no prompt change)
|
|
430
|
-
// whenever exocom is off, matching every other opt-in surface in this file.
|
|
431
|
-
if (exocom.plane) {
|
|
432
|
-
let peers: DisplayPeer[] | undefined;
|
|
433
|
-
let pendingBlock: string | undefined;
|
|
434
|
-
let ledgerFailure: string | undefined;
|
|
435
|
-
try {
|
|
436
|
-
peers = exocom.plane.listPeers();
|
|
437
|
-
} catch {
|
|
438
|
-
// Peer awareness is opportunistic. A transient registry I/O failure must not abort
|
|
439
|
-
// Pi's turn or prevent the rest of the system prompt from reaching the model.
|
|
440
|
-
}
|
|
441
|
-
if (exocom.ledgerFile) {
|
|
442
|
-
try {
|
|
443
|
-
pendingBlock = exocom.pendingAskPrompt(ctx);
|
|
444
|
-
} catch (error) {
|
|
445
|
-
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)})`;
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
const xcaps = h.controller.capabilities;
|
|
449
|
-
// Holding the bus says nothing about `delegate` (canUseBus keys off `intercom` alone), so
|
|
450
|
-
// read the persona the way h.delegationBrief does — absent capabilities ⇒ unrestricted — but
|
|
451
|
-
// gate on a REACHABLE target, which is stricter than h.delegationBrief: that one still renders
|
|
452
|
-
// (with `installedCount`, to say "your allowlist filtered everything away") when fan-out is
|
|
453
|
-
// allowed and no target survives, whereas urging a peer-vs-sub-agent split with nothing to
|
|
454
|
-
// delegate to would point the model at a call the gate refuses.
|
|
455
|
-
// `canFanOut(xcaps)` is defence in depth, not a live branch: `resolveCapabilities` empties
|
|
456
|
-
// `delegateTargets` whenever the delegate tool is absent (core/capabilities.ts), so the
|
|
457
|
-
// second conjunct already implies the first and no test can kill the first alone. Kept
|
|
458
|
-
// because it states the structural rule the second conjunct only happens to encode.
|
|
459
|
-
const canDelegate = xcaps ? canFanOut(xcaps) && h.agents.some((a) => canDelegateTo(xcaps, a.name)) : h.agents.length > 0;
|
|
460
|
-
const scope = exocom.scope;
|
|
461
|
-
const xbrief = buildExocomBrief((peers ?? []).map((p) => {
|
|
462
|
-
const workspaceId = p.workspace_id ?? scope?.scopeWorkspaceId;
|
|
463
|
-
const workspaceCode = p.workspace_code ?? (scope && workspaceId === scope.scopeWorkspaceId ? scope.scopeCode : undefined);
|
|
464
|
-
const workspaceLabel = p.workspace_label
|
|
465
|
-
?? (scope && workspaceId === scope.homeWorkspaceId ? scope.homeWorkspaceLabel : undefined);
|
|
466
|
-
return {
|
|
467
|
-
name: p.displayName,
|
|
468
|
-
persona: p.persona,
|
|
469
|
-
...(workspaceCode && workspaceLabel ? {
|
|
470
|
-
workspaceCode,
|
|
471
|
-
workspaceLabel,
|
|
472
|
-
sameWorkspace: workspaceId === scope?.homeWorkspaceId,
|
|
473
|
-
} : {}),
|
|
474
|
-
};
|
|
475
|
-
}), {
|
|
476
|
-
canDelegate,
|
|
477
|
-
tools: {
|
|
478
|
-
name: !xcaps || canCallTool(xcaps, "exocom_name"),
|
|
479
|
-
list: !xcaps || canCallTool(xcaps, "exocom_list"),
|
|
480
|
-
send: !xcaps || canCallTool(xcaps, "exocom_send"),
|
|
481
|
-
claim: scope?.joined !== true && (!xcaps || canCallTool(xcaps, "exocom_claim")),
|
|
482
|
-
ask: !xcaps || canCallTool(xcaps, "exocom_ask"),
|
|
483
|
-
answer: !xcaps || canCallTool(xcaps, "exocom_answer"),
|
|
484
|
-
decline: !xcaps || canCallTool(xcaps, "exocom_decline"),
|
|
485
|
-
wait: !xcaps || canCallTool(xcaps, "exocom_wait"),
|
|
486
|
-
release: !xcaps || canCallTool(xcaps, "exocom_release"),
|
|
487
|
-
progress: !xcaps || canCallTool(xcaps, "exocom_progress"),
|
|
488
|
-
status: !xcaps || canCallTool(xcaps, "exocom_status"),
|
|
489
|
-
},
|
|
490
|
-
// Exocom has no UI gate, so a headless (`pi -p`) run has live peers and no way to ask
|
|
491
|
-
// anyone anything. `hasUI` is pi's dialog capability, not a headcount (see the field's
|
|
492
|
-
// doc) — but the clause it gates is an ask, and an ask needs a channel, not a person.
|
|
493
|
-
canAskHuman: ctx.hasUI === true,
|
|
494
|
-
namedByModel: exocom.namedByModel,
|
|
495
|
-
// Naming changes display metadata only. Pending work still gates every work action.
|
|
496
|
-
canNameNow: pi.getActiveTools().includes("exocom_name") && (!xcaps || canCallTool(xcaps, "exocom_name")),
|
|
497
|
-
...(scope ? {
|
|
498
|
-
joined: scope.joined,
|
|
499
|
-
scopeCode: scope.scopeCode,
|
|
500
|
-
homeWorkspaceLabel: scope.homeWorkspaceLabel,
|
|
501
|
-
homeWorkspaceCode: scope.homeWorkspaceCode,
|
|
502
|
-
} : {}),
|
|
503
|
-
});
|
|
504
|
-
if (xbrief) prompt = `${prompt}\n\n${xbrief}`;
|
|
505
|
-
if (pendingBlock) prompt = `${prompt}\n\n${pendingBlock}`;
|
|
506
|
-
else if (ledgerFailure) prompt = `${prompt}\n\n${ledgerFailure}`;
|
|
507
|
-
}
|
|
508
|
-
if (h.pendingOrchestration) {
|
|
509
|
-
// The result is sub-agent text entering the SYSTEM prompt — fence it (I-guardrail:
|
|
510
|
-
// untrusted output must never reach the supervisor unfenced, least of all here).
|
|
511
|
-
if (h.pendingOrchestration.ok) {
|
|
512
|
-
// Keep the established success hand-off byte-for-byte: successful mandatory runs are
|
|
513
|
-
// still rulings the supervisor should present without spending the same council twice.
|
|
514
|
-
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)}`;
|
|
515
|
-
} else {
|
|
516
|
-
const evidence = h.pendingOrchestration.error && h.pendingOrchestration.error !== h.pendingOrchestration.output
|
|
517
|
-
? `${h.pendingOrchestration.output}\n\n--- RUNTIME ERROR ---\n${h.pendingOrchestration.error}`
|
|
518
|
-
: h.pendingOrchestration.output;
|
|
519
|
-
prompt = h.pendingOrchestration.failureKind === "abort"
|
|
520
|
-
? `${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)}`
|
|
521
|
-
: `${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)}`;
|
|
522
|
-
reportMandatoryFailure(ctx, h.pendingOrchestration);
|
|
523
|
-
}
|
|
524
|
-
h.pendingOrchestration = undefined;
|
|
525
|
-
}
|
|
526
|
-
const clock = readClockSnapshot(now);
|
|
527
|
-
return {
|
|
528
|
-
...(prompt !== event.systemPrompt ? { systemPrompt: prompt } : {}),
|
|
529
|
-
...(clock ? { message: { customType: "pi-persona-clock", content: clock, display: false } } : {}),
|
|
530
|
-
};
|
|
531
|
-
});
|
|
532
|
-
|
|
533
|
-
pi.on("tool_call", (event, ctx) => {
|
|
534
|
-
h.lastCtx = ctx;
|
|
535
|
-
const startedAt = Date.now();
|
|
536
|
-
h.telemetryToolStartedAt.set(event.toolCallId, { startedAt, name: event.toolName });
|
|
537
|
-
h.telemetry?.publish("tool.started", {
|
|
538
|
-
callId: event.toolCallId,
|
|
539
|
-
agentId: "supervisor",
|
|
540
|
-
name: event.toolName,
|
|
541
|
-
status: "running",
|
|
542
|
-
});
|
|
543
|
-
const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
|
|
544
|
-
// A blocked call never reaches tool_result, so close its telemetry here.
|
|
545
|
-
const reject = <R>(result: R): R => {
|
|
546
|
-
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
547
|
-
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
548
|
-
return result;
|
|
549
|
-
};
|
|
550
|
-
const gate = h.controller.gate(event.toolName, event.input);
|
|
551
|
-
if (gate) return reject(gate);
|
|
552
|
-
if (exocom.ledgerFile) {
|
|
553
|
-
let pending: ReturnType<ExocomInstall["pendingAsks"]>;
|
|
554
|
-
try {
|
|
555
|
-
pending = exocom.pendingAsks(ctx);
|
|
556
|
-
} catch (error) {
|
|
557
|
-
if (constrainedTurnAllows(event.toolName)) pending = [];
|
|
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)})` });
|
|
559
|
-
}
|
|
560
|
-
if (pending.length > 0 && !constrainedTurnAllows(event.toolName)) {
|
|
561
|
-
const omitted = pending.length > 1 ? `; ${pending.length - 1} more pending` : "";
|
|
562
|
-
return reject({
|
|
563
|
-
block: true,
|
|
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})`,
|
|
565
|
-
});
|
|
566
|
-
}
|
|
567
|
-
if (WRITE_TOOLS.has(event.toolName) && typeof input.path === "string" && input.path.trim()) {
|
|
568
|
-
// Advisory: a ledger read failure here must not block an otherwise-allowed write.
|
|
569
|
-
let claim: LedgerClaim | undefined;
|
|
570
|
-
try {
|
|
571
|
-
claim = peerClaimFor(input.path, exocom.ledgerState().claims, exocom.sessionId, ctx.cwd);
|
|
572
|
-
} catch {
|
|
573
|
-
claim = undefined;
|
|
574
|
-
}
|
|
575
|
-
if (claim) {
|
|
576
|
-
const key = writeWarningKey(claim, input.path);
|
|
577
|
-
if (!exocom.writeWarnings.seen(key)) {
|
|
578
|
-
exocom.writeWarnings.remember(key);
|
|
579
|
-
const label = exocom.peerLabelFor(claim.from_session) ?? claim.from_name;
|
|
580
|
-
const reason = writeWarningReason(input.path, claim, label);
|
|
581
|
-
try { if (ctx.hasUI) ctx.ui.notify(reason, "warning"); } catch { /* cosmetic */ }
|
|
582
|
-
return reject({ block: true, reason });
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
}
|
|
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
|
-
}
|
|
601
|
-
if (event.toolName === "intercom") {
|
|
602
|
-
const action = typeof input.action === "string" ? input.action : "message";
|
|
603
|
-
if (action === "send" || action === "reply") {
|
|
604
|
-
const text = typeof input.message === "string" ? input.message : "";
|
|
605
|
-
const askId = typeof input.askId === "string" ? input.askId : undefined;
|
|
606
|
-
h.telemetryIntercomPending.set(event.toolCallId, {
|
|
607
|
-
kind: action,
|
|
608
|
-
to: h.intercomRecipient(input, askId),
|
|
609
|
-
size: Buffer.byteLength(text, "utf8"),
|
|
610
|
-
...(askId ? { replyTo: askId } : {}),
|
|
611
|
-
});
|
|
612
|
-
h.telemetry?.publish(action === "reply" ? "message.replied" : "message.sent", {
|
|
613
|
-
id: event.toolCallId,
|
|
614
|
-
channel: "intercom",
|
|
615
|
-
from: "supervisor",
|
|
616
|
-
to: h.intercomRecipient(input, askId),
|
|
617
|
-
kind: action,
|
|
618
|
-
status: "queued",
|
|
619
|
-
expectsReply: false,
|
|
620
|
-
size: Buffer.byteLength(text, "utf8"),
|
|
621
|
-
...(askId ? { replyTo: askId } : {}),
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
if (event.toolName === "exocom_send") {
|
|
626
|
-
const text = typeof input.message === "string" ? input.message : "";
|
|
627
|
-
const target = typeof input.target === "string" ? input.target : "";
|
|
628
|
-
const replyTo = typeof input.in_reply_to === "string" && input.in_reply_to.length > 0 ? input.in_reply_to : undefined;
|
|
629
|
-
let peers: DisplayPeer[] = [];
|
|
630
|
-
try { peers = exocom.plane?.listPeers() ?? []; } catch { /* h.telemetry cannot break the tool */ }
|
|
631
|
-
const sessions = canonicalExocomTelemetryTargets(peers, target);
|
|
632
|
-
const size = Buffer.byteLength(text, "utf8");
|
|
633
|
-
const pending = sessions.map((sessionId) => ({
|
|
634
|
-
id: sessions.length === 1 ? event.toolCallId : `${event.toolCallId}:${sessionId}`,
|
|
635
|
-
sessionId,
|
|
636
|
-
target: target === "*" ? (peers.find((peer) => peer.session_id === sessionId)?.target ?? sessionId) : target,
|
|
637
|
-
size,
|
|
638
|
-
...(replyTo ? { replyTo } : {}),
|
|
639
|
-
}));
|
|
640
|
-
if (pending.length > 0) h.telemetryExocomPending.set(event.toolCallId, pending);
|
|
641
|
-
for (const message of pending) h.telemetry?.publish("message.sent", {
|
|
642
|
-
id: message.id,
|
|
643
|
-
channel: "exocom",
|
|
644
|
-
from: exocom.telemetrySessionId(ctx),
|
|
645
|
-
to: message.sessionId,
|
|
646
|
-
kind: "message",
|
|
647
|
-
status: "queued",
|
|
648
|
-
expectsReply: false,
|
|
649
|
-
size: message.size,
|
|
650
|
-
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
651
|
-
});
|
|
652
|
-
}
|
|
653
|
-
if (event.toolName === "delegate" || event.toolName === "council" || event.toolName === "flow") launching.add(event.toolCallId);
|
|
654
|
-
return undefined;
|
|
655
|
-
});
|
|
656
|
-
|
|
657
|
-
// Delegation nudge (h.config.nudge; delegating h.personas only): when the supervisor grinds a RUN of
|
|
658
|
-
// hands-on commands by hand — a by-hand sweep with no hand-off — append a reminder to the
|
|
659
|
-
// offending tool's result. It lands in RECENT context, on the very command that tripped it, where
|
|
660
|
-
// a top-of-prompt persona directive has already lost its pull. Sub-h.agents run in their own
|
|
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).
|
|
663
|
-
pi.on("tool_result", (event, ctx) => {
|
|
664
|
-
h.lastCtx = ctx;
|
|
665
|
-
const open = h.telemetryToolStartedAt.get(event.toolCallId);
|
|
666
|
-
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
667
|
-
launching.delete(event.toolCallId);
|
|
668
|
-
if (open !== undefined) {
|
|
669
|
-
h.telemetry?.publish("tool.finished", {
|
|
670
|
-
callId: event.toolCallId,
|
|
671
|
-
agentId: "supervisor",
|
|
672
|
-
name: event.toolName,
|
|
673
|
-
status: event.isError ? "failed" : "done",
|
|
674
|
-
durationMs: Math.max(0, Date.now() - open.startedAt),
|
|
675
|
-
});
|
|
676
|
-
}
|
|
677
|
-
const telemetryInput = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
|
|
678
|
-
if (event.toolName === "exocom_send") {
|
|
679
|
-
const pending = h.telemetryExocomPending.get(event.toolCallId) ?? [];
|
|
680
|
-
h.telemetryExocomPending.delete(event.toolCallId);
|
|
681
|
-
const details = event.details && typeof event.details === "object" && !Array.isArray(event.details)
|
|
682
|
-
? event.details as {
|
|
683
|
-
target?: unknown;
|
|
684
|
-
msg_id?: unknown;
|
|
685
|
-
sent?: unknown;
|
|
686
|
-
failed?: Array<{ target?: unknown }>;
|
|
687
|
-
failedCount?: unknown;
|
|
688
|
-
omittedFailures?: unknown;
|
|
689
|
-
}
|
|
690
|
-
: undefined;
|
|
691
|
-
const receipts = Array.isArray(details?.sent)
|
|
692
|
-
? details.sent.flatMap((receipt) => {
|
|
693
|
-
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return [];
|
|
694
|
-
const item = receipt as { target?: unknown; msg_id?: unknown };
|
|
695
|
-
return typeof item.target === "string" && typeof item.msg_id === "string" && item.msg_id.length > 0
|
|
696
|
-
? [{ target: item.target, msgId: item.msg_id }]
|
|
697
|
-
: [];
|
|
698
|
-
})
|
|
699
|
-
: [];
|
|
700
|
-
const sampledFailures = details?.failed ?? [];
|
|
701
|
-
const failedTargets = new Set(sampledFailures.flatMap((failure) => typeof failure.target === "string" ? [failure.target] : []));
|
|
702
|
-
// A broadcast samples its failures (`MAX_BROADCAST_DETAIL_ITEMS` in tools/exocom.ts) and
|
|
703
|
-
// reports the rest as a count, so the sample alone CLEARS nobody: with more failures than
|
|
704
|
-
// the sample holds, a recipient we cannot find in it is unknown, never delivered.
|
|
705
|
-
const declaredFailures = typeof details?.failedCount === "number" ? details.failedCount : sampledFailures.length;
|
|
706
|
-
const omittedFailures = Math.max(
|
|
707
|
-
typeof details?.omittedFailures === "number" ? details.omittedFailures : 0,
|
|
708
|
-
declaredFailures - sampledFailures.length,
|
|
709
|
-
);
|
|
710
|
-
for (const message of pending) {
|
|
711
|
-
const matchingReceipts = !event.isError && details?.target === "*"
|
|
712
|
-
? receipts.filter((receipt) => receipt.target === message.target)
|
|
713
|
-
: [];
|
|
714
|
-
const failed = event.isError || failedTargets.has(message.target);
|
|
715
|
-
const wireId = failed ? undefined
|
|
716
|
-
: details?.target === message.target && typeof details.msg_id === "string" && details.msg_id.length > 0
|
|
717
|
-
? details.msg_id
|
|
718
|
-
: matchingReceipts.length === 1 ? matchingReceipts[0]!.msgId : undefined;
|
|
719
|
-
h.telemetry?.publish("message.sent", {
|
|
244
|
+
try {
|
|
245
|
+
h.telemetry = new TelemetryProducer({
|
|
246
|
+
agentDir: h.userAgentDir(),
|
|
247
|
+
cwd: ctx.cwd,
|
|
248
|
+
sessionId,
|
|
249
|
+
emit: (event) => pi.events.emit(TELEMETRY_EVENT_NAME, event),
|
|
250
|
+
heartbeat: () => exocom.currentTelemetryInstance(h.lastCtx ?? ctx),
|
|
251
|
+
onError: (error: unknown) => {
|
|
252
|
+
// Retention and writer failures must be observable during normal operation too.
|
|
253
|
+
// A broken UI/logger must never turn best-effort telemetry into a session failure.
|
|
254
|
+
try {
|
|
255
|
+
const message = `pi-persona: telemetry: ${compactInlineText(error instanceof Error ? error.message : String(error), { maxChars: 400 })}`;
|
|
256
|
+
const current = h.lastCtx ?? ctx;
|
|
257
|
+
if (current.hasUI) current.ui.notify(message, "warning");
|
|
258
|
+
else process.stderr.write(`${message}\n`);
|
|
259
|
+
} catch { /* diagnostics are non-fatal */ }
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
h.telemetry.start(exocom.currentTelemetryInstance(ctx));
|
|
263
|
+
} catch (error) {
|
|
264
|
+
h.telemetry = undefined;
|
|
265
|
+
const message = "pi-persona: telemetry unavailable (safe session admission failed)";
|
|
266
|
+
try {
|
|
267
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
268
|
+
else process.stderr.write(`${message}\n`);
|
|
269
|
+
} catch { /* admission diagnostics are non-fatal too */ }
|
|
270
|
+
}
|
|
271
|
+
if (h.telemetry) {
|
|
272
|
+
// Re-seeded with the replay, so the dedupe below always mirrors what THIS producer last
|
|
273
|
+
// published — a session start that follows no teardown carries no stale projection.
|
|
274
|
+
h.lastAgentProjection.clear();
|
|
275
|
+
for (const node of h.agentTree.snapshot()) {
|
|
276
|
+
if (isWakeSource(node)) continue; // timer/monitor rows are not agents: no replay, no projection
|
|
277
|
+
h.lastAgentProjection.set(node.id, JSON.stringify(h.telemetryAgent(node)));
|
|
278
|
+
h.telemetry.publishAgentAdded(h.telemetryAgent(node));
|
|
279
|
+
}
|
|
280
|
+
exocom.publishTelemetryPeers();
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
pi.on("session_shutdown", async (event, ctx) => {
|
|
286
|
+
clearInFlight();
|
|
287
|
+
h.lastCtx = ctx;
|
|
288
|
+
recordLostWork(ctx); // before the notifiers/timers/runs below are torn down
|
|
289
|
+
h.disposed = true; // gate any late async-run onComplete from touching the next session's instance
|
|
290
|
+
h.results.dispose(); // revoke frozen owners before cancellation can settle old runs
|
|
291
|
+
h.deferredOrchestrations.length = 0;
|
|
292
|
+
h.pendingOrchestration = undefined;
|
|
293
|
+
h.stopPeek(); // h.reload-hygiene: never leak the idle-peek timer across sessions
|
|
294
|
+
h.peekWatcher.reset(); // …nor a stale "already surfaced this leg as stuck" set into the next session
|
|
295
|
+
h.completionNotifier.cancel(); // …nor the coalesced-delivery flush timers
|
|
296
|
+
h.intercomNotifier.cancel();
|
|
297
|
+
exocom.notifier?.cancel(); // …nor a late exocom follow-up (M1) — cancelled BEFORE exocom.stop tears down the plane below
|
|
298
|
+
exocom.waitNotifier?.cancel();
|
|
299
|
+
h.timerScheduler.cancelAll(); // …nor any armed alarms (never wake the next session)
|
|
300
|
+
h.timerNotifier.cancel();
|
|
301
|
+
h.monitors.cancelAll();
|
|
302
|
+
// This instance is being torn down (a h.reload/new/resume rebinds a fresh one); abort in-flight
|
|
303
|
+
// sub-h.agents and reset control state so nothing is left orphaned or rendered stale.
|
|
304
|
+
for (const abort of [...h.stopRegistry.values()]) {
|
|
305
|
+
try {
|
|
306
|
+
abort();
|
|
307
|
+
} catch {
|
|
308
|
+
/* ignore */
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
h.stopRegistry.clear();
|
|
312
|
+
h.stopRequested.clear();
|
|
313
|
+
h.steerRegistry.clear();
|
|
314
|
+
h.agentTree.clear();
|
|
315
|
+
h.personaHost.setStatus(undefined);
|
|
316
|
+
// Queued, not called bare: a shutdown that lands while session_start's own start is still
|
|
317
|
+
// pending would otherwise see `exocom.plane` unset, no-op, and let that start bind a socket
|
|
318
|
+
// and register an entry for a session that no longer exists. Idempotent either way — off
|
|
319
|
+
// (or never started) ⇒ a no-op.
|
|
320
|
+
await exocom.queue(() => exocom.stop());
|
|
321
|
+
// Broker teardown (spec B1/B5): idempotent — a session that never built a broker-backed
|
|
322
|
+
// child engine (flag off, or on but unused) never started a host, so this is a no-op.
|
|
323
|
+
// `SupervisorBroker.close()` is itself best-effort (never blocks shutdown on a teardown error).
|
|
324
|
+
await h.broker.close();
|
|
325
|
+
await h.telemetry?.stop(event?.reason);
|
|
326
|
+
h.telemetry = undefined;
|
|
327
|
+
h.telemetryToolStartedAt.clear();
|
|
328
|
+
h.telemetryExocomPending.clear();
|
|
329
|
+
h.telemetryIntercomPending.clear();
|
|
330
|
+
h.telemetryAskSenders.clear(); // …and no ask attribution leaks into the next session
|
|
331
|
+
h.lastAgentProjection.clear();
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Pi marks the session idle before emitting agent_settled. A deferred mandatory input owns this
|
|
335
|
+
// idle window first; its replay starts the next run and the remaining FIFO entries follow one per
|
|
336
|
+
// settlement. With no deferred input, flush ordinary wakes immediately.
|
|
337
|
+
pi.on("agent_settled", async () => {
|
|
338
|
+
clearInFlight();
|
|
339
|
+
h.pollGuard?.reset(); // a settled turn ends any poll streak: the next peek is a fresh look
|
|
340
|
+
if (h.lastCtx) h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(h.lastCtx), status: "idle" });
|
|
341
|
+
// Pi's `prepareToolCall` returns {kind:"immediate"} when the abort signal is already set
|
|
342
|
+
// after beforeToolCall — skipping afterToolCall, and so our tool_result hook. Those calls
|
|
343
|
+
// would otherwise stay "running" until session_shutdown (press Esc mid-fan-out and every
|
|
344
|
+
// call that had not yet started is orphaned). The turn boundary is where they end.
|
|
345
|
+
for (const [callId, open] of h.telemetryToolStartedAt) {
|
|
346
|
+
h.telemetry?.publish("tool.finished", {
|
|
347
|
+
callId,
|
|
348
|
+
agentId: "supervisor",
|
|
349
|
+
name: open.name,
|
|
350
|
+
status: "failed",
|
|
351
|
+
durationMs: Math.max(0, Date.now() - open.startedAt),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
h.telemetryToolStartedAt.clear(); // a late result for a drained call must not close it twice
|
|
355
|
+
// An exocom send orphaned on that same path left its recipients at "queued" forever. They are
|
|
356
|
+
// reported failed, never delivered: the call never reached a result, so nothing observed a send.
|
|
357
|
+
if (h.lastCtx) {
|
|
358
|
+
const from = exocom.telemetrySessionId(h.lastCtx);
|
|
359
|
+
for (const pending of h.telemetryExocomPending.values()) {
|
|
360
|
+
for (const message of pending) h.telemetry?.publish("message.sent", {
|
|
720
361
|
id: message.id,
|
|
721
362
|
channel: "exocom",
|
|
722
|
-
|
|
363
|
+
// A session id, like every other exocom publish site — never an agentId literal in this slot.
|
|
364
|
+
from,
|
|
723
365
|
to: message.sessionId,
|
|
724
366
|
kind: "message",
|
|
725
|
-
|
|
726
|
-
// broadcast failure sample omitted failures for other peers. Without a receipt, an
|
|
727
|
-
// incomplete failure sample cannot prove this particular row was delivered.
|
|
728
|
-
status: failed ? "failed" : omittedFailures > 0 && wireId === undefined ? "unknown" : "delivered",
|
|
367
|
+
status: "failed",
|
|
729
368
|
expectsReply: false,
|
|
730
369
|
size: message.size,
|
|
731
370
|
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
732
|
-
...(wireId ? { wireId } : {}),
|
|
733
|
-
});
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
if (event.toolName === "intercom") {
|
|
737
|
-
const action = typeof telemetryInput.action === "string" ? telemetryInput.action : "";
|
|
738
|
-
// The open entry is the right to publish a terminal: once the turn-boundary drain has closed
|
|
739
|
-
// this call, a late result must not report it a second time.
|
|
740
|
-
const pending = h.telemetryIntercomPending.get(event.toolCallId);
|
|
741
|
-
h.telemetryIntercomPending.delete(event.toolCallId);
|
|
742
|
-
if (pending && (action === "send" || action === "reply")) {
|
|
743
|
-
const text = typeof telemetryInput.message === "string" ? telemetryInput.message : "";
|
|
744
|
-
const askId = typeof telemetryInput.askId === "string" ? telemetryInput.askId : undefined;
|
|
745
|
-
h.telemetry?.publish(action === "reply" ? "message.replied" : "message.sent", {
|
|
746
|
-
id: event.toolCallId,
|
|
747
|
-
channel: "intercom",
|
|
748
|
-
from: "supervisor",
|
|
749
|
-
// Ask settlement has already retired its sender lookup. The tool-start
|
|
750
|
-
// snapshot owns the recipient for both queued and terminal telemetry.
|
|
751
|
-
to: pending.to,
|
|
752
|
-
kind: action,
|
|
753
|
-
status: event.isError ? "failed" : action === "reply" ? "replied" : "delivered",
|
|
754
|
-
expectsReply: false,
|
|
755
|
-
size: Buffer.byteLength(text, "utf8"),
|
|
756
|
-
...(askId ? { replyTo: askId } : {}),
|
|
757
371
|
});
|
|
758
372
|
}
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
//
|
|
793
|
-
|
|
794
|
-
//
|
|
795
|
-
h.
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
if (
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
h.
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
373
|
+
}
|
|
374
|
+
h.telemetryExocomPending.clear();
|
|
375
|
+
for (const [callId, message] of h.telemetryIntercomPending) {
|
|
376
|
+
h.telemetry?.publish(message.kind === "reply" ? "message.replied" : "message.sent", {
|
|
377
|
+
id: callId,
|
|
378
|
+
channel: "intercom",
|
|
379
|
+
from: "supervisor",
|
|
380
|
+
to: message.to,
|
|
381
|
+
kind: message.kind,
|
|
382
|
+
status: "failed",
|
|
383
|
+
expectsReply: false,
|
|
384
|
+
size: message.size,
|
|
385
|
+
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
h.telemetryIntercomPending.clear();
|
|
389
|
+
if (await processNextDeferredOrchestration()) return;
|
|
390
|
+
h.completionNotifier.kick();
|
|
391
|
+
h.intercomNotifier.kick();
|
|
392
|
+
h.timerNotifier.kick();
|
|
393
|
+
h.monitors.kick();
|
|
394
|
+
exocom.notifier?.kick();
|
|
395
|
+
exocom.waitNotifier?.kick();
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
pi.on("before_agent_start", (event, ctx) => {
|
|
399
|
+
h.lastCtx = ctx;
|
|
400
|
+
h.telemetry?.publish("instance.updated", { ...exocom.currentTelemetryInstance(ctx), status: "active" });
|
|
401
|
+
// The spine lifts persona-less turns too: with nothing active the composition is Pi's base
|
|
402
|
+
// prompt + the layer. Off ⇒ `h.spineText` is empty and this is `event.systemPrompt` itself.
|
|
403
|
+
const noPersona = h.spineText ? `${event.systemPrompt}\n\n${h.spineText}` : event.systemPrompt;
|
|
404
|
+
let prompt = h.controller.composePrompt(event.systemPrompt, h.spineText) ?? noPersona;
|
|
405
|
+
// Stable system-level interpretation of runtime metadata. Never append an acknowledgement
|
|
406
|
+
// request to the conversation: that would recreate the old identity pseudo-turn loop.
|
|
407
|
+
prompt = `${prompt}\n\n${RUNTIME_CONTEXT_GUIDANCE}`;
|
|
408
|
+
// The session time anchor (core/time.ts). Unconditional, unlike both briefs below: a supervisor
|
|
409
|
+
// that has been working for hours needs to know it whether or not h.agents or peers exist. It goes
|
|
410
|
+
// in the SYSTEM prompt because the prompt is re-composed and re-sent every turn rather than
|
|
411
|
+
// summarized, so an anchor placed here cannot be compacted away — the same fact in the
|
|
412
|
+
// conversation tail would be. Its start comes from the session HEADER, the first entry of the
|
|
413
|
+
// append-only session file, so after /resume it is still the ORIGINAL start: that is what makes it
|
|
414
|
+
// answer "how long have I been on this problem" across restarts and not merely across turns.
|
|
415
|
+
// Placed BEFORE the briefs so the standing hand-off default keeps the last word at the tail; the
|
|
416
|
+
// elapsed reading remains coarse to preserve caching; the precise clock goes in a new
|
|
417
|
+
// conversation message below, and timer now can refresh it within a long tool loop.
|
|
418
|
+
const now = Date.now();
|
|
419
|
+
const anchor = buildSessionAnchor(ctx.sessionManager.getHeader(), now);
|
|
420
|
+
if (anchor) prompt = `${prompt}\n\n${anchor}`;
|
|
421
|
+
const wakeGuidance: string[] = [];
|
|
422
|
+
if (toolAllowed("timer")) wakeGuidance.push("timer now reads the current clock; timer arm schedules a fixed-time wake (absolute times require a timezone).");
|
|
423
|
+
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.");
|
|
424
|
+
if (wakeGuidance.length) {
|
|
425
|
+
// Headless `pi -p` exits when the turn settles, so "end your turn" would drop the wake.
|
|
426
|
+
const wakeTail = ctx.hasUI
|
|
427
|
+
? "Continue independent work while waiting; end your turn when only the event remains. These wakes require this Pi session to stay open."
|
|
428
|
+
: "Headless: a wake is delivered only if it fires before your turn settles; do not end the turn to wait for one.";
|
|
429
|
+
prompt = `${prompt}\n\n[Session wakes] ${wakeGuidance.join(" ")} ${wakeTail} Avoid duplicate watches.`;
|
|
430
|
+
}
|
|
431
|
+
const brief = h.delegationBrief(ctx);
|
|
432
|
+
if (brief) prompt = `${prompt}\n\n${brief}`;
|
|
433
|
+
// Per-turn exocom peer AWARENESS (mirrors the delegation brief above): regenerated from the
|
|
434
|
+
// live registry every turn so it cannot desync, and gated implicitly — exocom.plane is only
|
|
435
|
+
// set when exocom is on AND canUseBus, so this is a no-op (undefined ⇒ no prompt change)
|
|
436
|
+
// whenever exocom is off, matching every other opt-in surface in this file.
|
|
437
|
+
if (exocom.plane) {
|
|
438
|
+
let peers: DisplayPeer[] | undefined;
|
|
439
|
+
let pendingBlock: string | undefined;
|
|
440
|
+
let ledgerFailure: string | undefined;
|
|
441
|
+
try {
|
|
442
|
+
peers = exocom.plane.listPeers();
|
|
443
|
+
} catch {
|
|
444
|
+
// Peer awareness is opportunistic. A transient registry I/O failure must not abort
|
|
445
|
+
// Pi's turn or prevent the rest of the system prompt from reaching the model.
|
|
446
|
+
}
|
|
447
|
+
if (exocom.ledgerFile) {
|
|
448
|
+
try {
|
|
449
|
+
pendingBlock = exocom.pendingAskPrompt(ctx);
|
|
450
|
+
} catch (error) {
|
|
451
|
+
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)})`;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const xcaps = h.controller.capabilities;
|
|
455
|
+
// Holding the bus says nothing about `delegate` (canUseBus keys off `intercom` alone), so
|
|
456
|
+
// read the persona the way h.delegationBrief does — absent capabilities ⇒ unrestricted — but
|
|
457
|
+
// gate on a REACHABLE target, which is stricter than h.delegationBrief: that one still renders
|
|
458
|
+
// (with `installedCount`, to say "your allowlist filtered everything away") when fan-out is
|
|
459
|
+
// allowed and no target survives, whereas urging a peer-vs-sub-agent split with nothing to
|
|
460
|
+
// delegate to would point the model at a call the gate refuses.
|
|
461
|
+
// `canFanOut(xcaps)` is defence in depth, not a live branch: `resolveCapabilities` empties
|
|
462
|
+
// `delegateTargets` whenever the delegate tool is absent (core/capabilities.ts), so the
|
|
463
|
+
// second conjunct already implies the first and no test can kill the first alone. Kept
|
|
464
|
+
// because it states the structural rule the second conjunct only happens to encode.
|
|
465
|
+
const canDelegate = xcaps ? canFanOut(xcaps) && h.agents.some((a) => canDelegateTo(xcaps, a.name)) : h.agents.length > 0;
|
|
466
|
+
const scope = exocom.scope;
|
|
467
|
+
const xbrief = buildExocomBrief((peers ?? []).map((p) => {
|
|
468
|
+
const workspaceId = p.workspace_id ?? scope?.scopeWorkspaceId;
|
|
469
|
+
const workspaceCode = p.workspace_code ?? (scope && workspaceId === scope.scopeWorkspaceId ? scope.scopeCode : undefined);
|
|
470
|
+
const workspaceLabel = p.workspace_label
|
|
471
|
+
?? (scope && workspaceId === scope.homeWorkspaceId ? scope.homeWorkspaceLabel : undefined);
|
|
472
|
+
return {
|
|
473
|
+
name: p.displayName,
|
|
474
|
+
persona: p.persona,
|
|
475
|
+
...(workspaceCode && workspaceLabel ? {
|
|
476
|
+
workspaceCode,
|
|
477
|
+
workspaceLabel,
|
|
478
|
+
sameWorkspace: workspaceId === scope?.homeWorkspaceId,
|
|
479
|
+
} : {}),
|
|
480
|
+
};
|
|
481
|
+
}), {
|
|
482
|
+
canDelegate,
|
|
483
|
+
tools: {
|
|
484
|
+
name: !xcaps || canCallTool(xcaps, "exocom_name"),
|
|
485
|
+
list: !xcaps || canCallTool(xcaps, "exocom_list"),
|
|
486
|
+
send: !xcaps || canCallTool(xcaps, "exocom_send"),
|
|
487
|
+
claim: scope?.joined !== true && (!xcaps || canCallTool(xcaps, "exocom_claim")),
|
|
488
|
+
ask: !xcaps || canCallTool(xcaps, "exocom_ask"),
|
|
489
|
+
answer: !xcaps || canCallTool(xcaps, "exocom_answer"),
|
|
490
|
+
decline: !xcaps || canCallTool(xcaps, "exocom_decline"),
|
|
491
|
+
wait: !xcaps || canCallTool(xcaps, "exocom_wait"),
|
|
492
|
+
release: !xcaps || canCallTool(xcaps, "exocom_release"),
|
|
493
|
+
progress: !xcaps || canCallTool(xcaps, "exocom_progress"),
|
|
494
|
+
status: !xcaps || canCallTool(xcaps, "exocom_status"),
|
|
495
|
+
task: !xcaps || canCallTool(xcaps, "exocom_task"),
|
|
496
|
+
offer: !xcaps || canCallTool(xcaps, "exocom_offer"),
|
|
497
|
+
publish: !xcaps || canCallTool(xcaps, "exocom_publish"),
|
|
498
|
+
profile: !xcaps || canCallTool(xcaps, "exocom_profile"),
|
|
499
|
+
},
|
|
500
|
+
// Exocom has no UI gate, so a headless (`pi -p`) run has live peers and no way to ask
|
|
501
|
+
// anyone anything. `hasUI` is pi's dialog capability, not a headcount (see the field's
|
|
502
|
+
// doc) — but the clause it gates is an ask, and an ask needs a channel, not a person.
|
|
503
|
+
canAskHuman: ctx.hasUI === true,
|
|
504
|
+
namedByModel: exocom.namedByModel,
|
|
505
|
+
// Naming changes display metadata only. Pending work still gates every work action.
|
|
506
|
+
canNameNow: pi.getActiveTools().includes("exocom_name") && (!xcaps || canCallTool(xcaps, "exocom_name")),
|
|
507
|
+
...(scope ? {
|
|
508
|
+
joined: scope.joined,
|
|
509
|
+
scopeCode: scope.scopeCode,
|
|
510
|
+
homeWorkspaceLabel: scope.homeWorkspaceLabel,
|
|
511
|
+
homeWorkspaceCode: scope.homeWorkspaceCode,
|
|
512
|
+
} : {}),
|
|
513
|
+
});
|
|
514
|
+
if (xbrief) prompt = `${prompt}\n\n${xbrief}`;
|
|
515
|
+
if (pendingBlock) prompt = `${prompt}\n\n${pendingBlock}`;
|
|
516
|
+
else if (ledgerFailure) prompt = `${prompt}\n\n${ledgerFailure}`;
|
|
517
|
+
}
|
|
518
|
+
if (h.pendingOrchestration) {
|
|
519
|
+
// The result is sub-agent text entering the SYSTEM prompt — fence it (I-guardrail:
|
|
520
|
+
// untrusted output must never reach the supervisor unfenced, least of all here).
|
|
521
|
+
if (h.pendingOrchestration.ok) {
|
|
522
|
+
// Keep the established success hand-off byte-for-byte: successful mandatory runs are
|
|
523
|
+
// still rulings the supervisor should present without spending the same council twice.
|
|
524
|
+
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)}`;
|
|
525
|
+
} else {
|
|
526
|
+
const evidence = h.pendingOrchestration.error && h.pendingOrchestration.error !== h.pendingOrchestration.output
|
|
527
|
+
? `${h.pendingOrchestration.output}\n\n--- RUNTIME ERROR ---\n${h.pendingOrchestration.error}`
|
|
528
|
+
: h.pendingOrchestration.output;
|
|
529
|
+
prompt = h.pendingOrchestration.failureKind === "abort"
|
|
530
|
+
? `${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)}`
|
|
531
|
+
: `${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)}`;
|
|
532
|
+
reportMandatoryFailure(ctx, h.pendingOrchestration);
|
|
533
|
+
}
|
|
534
|
+
const receipt = resultReceiptHint(h.pendingOrchestration.resultReceipt);
|
|
535
|
+
if (receipt) prompt += `\n\n${receipt}`;
|
|
536
|
+
h.pendingOrchestration = undefined;
|
|
537
|
+
}
|
|
538
|
+
const clock = readClockSnapshot(now);
|
|
539
|
+
return {
|
|
540
|
+
...(prompt !== event.systemPrompt ? { systemPrompt: prompt } : {}),
|
|
541
|
+
...(clock ? { message: { customType: "pi-persona-clock", content: clock, display: false } } : {}),
|
|
542
|
+
};
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
pi.on("tool_call", (event, ctx) => {
|
|
546
|
+
h.lastCtx = ctx;
|
|
547
|
+
const startedAt = Date.now();
|
|
548
|
+
h.telemetryToolStartedAt.set(event.toolCallId, { startedAt, name: event.toolName });
|
|
549
|
+
h.telemetry?.publish("tool.started", {
|
|
550
|
+
callId: event.toolCallId,
|
|
551
|
+
agentId: "supervisor",
|
|
552
|
+
name: event.toolName,
|
|
553
|
+
status: "running",
|
|
554
|
+
});
|
|
555
|
+
const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
|
|
556
|
+
// A blocked call never reaches tool_result, so close its telemetry here.
|
|
557
|
+
const reject = <R>(result: R): R => {
|
|
558
|
+
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
559
|
+
h.telemetry?.publish("tool.finished", { callId: event.toolCallId, agentId: "supervisor", name: event.toolName, status: "failed" });
|
|
560
|
+
return result;
|
|
561
|
+
};
|
|
562
|
+
const gate = h.controller.gate(event.toolName, event.input);
|
|
563
|
+
if (gate) return reject(gate);
|
|
564
|
+
if (exocom.ledgerFile) {
|
|
565
|
+
let pending: ReturnType<ExocomInstall["pendingAsks"]>;
|
|
566
|
+
try {
|
|
567
|
+
pending = exocom.pendingAsks(ctx);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (constrainedTurnAllows(event.toolName, input)) pending = [];
|
|
570
|
+
else return reject({ block: true, reason: `exocom: ledger unavailable; mutation/delegation is fail-closed until storage recovers (${error instanceof Error ? error.message : String(error)})` });
|
|
571
|
+
}
|
|
572
|
+
if (pending.length > 0 && !constrainedTurnAllows(event.toolName, input)) {
|
|
573
|
+
const omitted = pending.length > 1 ? `; ${pending.length - 1} more pending` : "";
|
|
574
|
+
return reject({
|
|
575
|
+
block: true,
|
|
576
|
+
reason: `exocom: pending ask(s) to this session — only exocom_name (identity metadata), exocom_answer, exocom_decline, exocom_status, explicit collaboration read actions, read, grep, find, ls until you answer (ask_id=${pending[0]!.ask_id}${omitted})`,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
if (WRITE_TOOLS.has(event.toolName) && typeof input.path === "string" && input.path.trim()) {
|
|
580
|
+
// Advisory: a ledger read failure here must not block an otherwise-allowed write.
|
|
581
|
+
let claim: LedgerClaim | undefined;
|
|
582
|
+
try {
|
|
583
|
+
claim = peerClaimFor(input.path, exocom.ledgerState().claims, exocom.sessionId, ctx.cwd);
|
|
584
|
+
} catch {
|
|
585
|
+
claim = undefined;
|
|
586
|
+
}
|
|
587
|
+
if (claim) {
|
|
588
|
+
const key = writeWarningKey(claim, input.path);
|
|
589
|
+
if (!exocom.writeWarnings.seen(key)) {
|
|
590
|
+
exocom.writeWarnings.remember(key);
|
|
591
|
+
const label = exocom.peerLabelFor(claim.from_session) ?? claim.from_name;
|
|
592
|
+
const reason = writeWarningReason(input.path, claim, label);
|
|
593
|
+
// The toast carries only the one-line head; the fenced peer-authored claim block stays
|
|
594
|
+
// in the model's tool result below — a notification is a single line, not the fence.
|
|
595
|
+
try { if (ctx.hasUI) ctx.ui.notify(writeWarningToastReason(input.path, claim, label), "warning"); } catch { /* cosmetic */ }
|
|
596
|
+
return reject({ block: true, reason });
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// A long leading sleep holds the turn that the idle-gated completion wake needs idle (and queues
|
|
602
|
+
// the operator's steering). Interactive only: a headless leg runs inline, so the model is parked
|
|
603
|
+
// in the delegate call and no wake is waiting on the turn.
|
|
604
|
+
if (h.config.nudge && ctx.hasUI === true && !event.parentToolCallId && (event.toolName === "bash" || event.toolName === "powershell") && typeof input.command === "string") {
|
|
605
|
+
const waitSeconds = longWaitSeconds(input.command);
|
|
606
|
+
const running = h.tracker.running().map((run) => run.id);
|
|
607
|
+
if (waitSeconds !== undefined && running.length + launching.size > 0) {
|
|
608
|
+
const instead = [toolAllowed("timer") && "a fixed clock time use timer arm", toolAllowed("monitor") && toolAllowed("bash") && "an external event use monitor"].filter(Boolean).join("; for ");
|
|
609
|
+
return reject({
|
|
610
|
+
block: true,
|
|
611
|
+
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.` : ""}`,
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
if (event.toolName === "intercom") {
|
|
616
|
+
const action = typeof input.action === "string" ? input.action : "message";
|
|
617
|
+
if (action === "send" || action === "reply") {
|
|
618
|
+
const text = typeof input.message === "string" ? input.message : "";
|
|
619
|
+
const askId = typeof input.askId === "string" ? input.askId : undefined;
|
|
620
|
+
h.telemetryIntercomPending.set(event.toolCallId, {
|
|
621
|
+
kind: action,
|
|
622
|
+
to: h.intercomRecipient(input, askId),
|
|
623
|
+
size: Buffer.byteLength(text, "utf8"),
|
|
624
|
+
...(askId ? { replyTo: askId } : {}),
|
|
625
|
+
});
|
|
626
|
+
h.telemetry?.publish(action === "reply" ? "message.replied" : "message.sent", {
|
|
627
|
+
id: event.toolCallId,
|
|
628
|
+
channel: "intercom",
|
|
629
|
+
from: "supervisor",
|
|
630
|
+
to: h.intercomRecipient(input, askId),
|
|
631
|
+
kind: action,
|
|
632
|
+
status: "queued",
|
|
633
|
+
expectsReply: false,
|
|
634
|
+
size: Buffer.byteLength(text, "utf8"),
|
|
635
|
+
...(askId ? { replyTo: askId } : {}),
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (event.toolName === "exocom_send") {
|
|
640
|
+
const text = typeof input.message === "string" ? input.message : "";
|
|
641
|
+
const target = typeof input.target === "string" ? input.target : "";
|
|
642
|
+
const replyTo = typeof input.in_reply_to === "string" && input.in_reply_to.length > 0 ? input.in_reply_to : undefined;
|
|
643
|
+
let peers: DisplayPeer[] = [];
|
|
644
|
+
try { peers = exocom.plane?.listPeers() ?? []; } catch { /* h.telemetry cannot break the tool */ }
|
|
645
|
+
const sessions = canonicalExocomTelemetryTargets(peers, target);
|
|
646
|
+
const size = Buffer.byteLength(text, "utf8");
|
|
647
|
+
const pending = sessions.map((sessionId) => ({
|
|
648
|
+
id: sessions.length === 1 ? event.toolCallId : `${event.toolCallId}:${sessionId}`,
|
|
649
|
+
sessionId,
|
|
650
|
+
target: target === "*" ? (peers.find((peer) => peer.session_id === sessionId)?.target ?? sessionId) : target,
|
|
651
|
+
size,
|
|
652
|
+
...(replyTo ? { replyTo } : {}),
|
|
653
|
+
}));
|
|
654
|
+
if (pending.length > 0) h.telemetryExocomPending.set(event.toolCallId, pending);
|
|
655
|
+
for (const message of pending) h.telemetry?.publish("message.sent", {
|
|
656
|
+
id: message.id,
|
|
657
|
+
channel: "exocom",
|
|
658
|
+
from: exocom.telemetrySessionId(ctx),
|
|
659
|
+
to: message.sessionId,
|
|
660
|
+
kind: "message",
|
|
661
|
+
status: "queued",
|
|
662
|
+
expectsReply: false,
|
|
663
|
+
size: message.size,
|
|
664
|
+
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
if (event.toolName === "delegate" || event.toolName === "council" || event.toolName === "flow") launching.add(event.toolCallId);
|
|
668
|
+
return undefined;
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
// Delegation nudge (h.config.nudge; delegating h.personas only): when the supervisor grinds a RUN of
|
|
672
|
+
// hands-on commands by hand — a by-hand sweep with no hand-off — append a reminder to the
|
|
673
|
+
// offending tool's result. It lands in RECENT context, on the very command that tripped it, where
|
|
674
|
+
// a top-of-prompt persona directive has already lost its pull. Sub-h.agents run in their own
|
|
675
|
+
// sessions, so this hook only ever sees the SUPERVISOR's own tools. A `delegate`/`council`/`flow`
|
|
676
|
+
// result resets the run (a failed `flow` is self-describing and neither resets nor nudges).
|
|
677
|
+
pi.on("tool_result", (event, ctx) => {
|
|
678
|
+
h.lastCtx = ctx;
|
|
679
|
+
const open = h.telemetryToolStartedAt.get(event.toolCallId);
|
|
680
|
+
h.telemetryToolStartedAt.delete(event.toolCallId);
|
|
681
|
+
launching.delete(event.toolCallId);
|
|
682
|
+
if (open !== undefined) {
|
|
683
|
+
h.telemetry?.publish("tool.finished", {
|
|
684
|
+
callId: event.toolCallId,
|
|
685
|
+
agentId: "supervisor",
|
|
686
|
+
name: event.toolName,
|
|
687
|
+
status: event.isError ? "failed" : "done",
|
|
688
|
+
durationMs: Math.max(0, Date.now() - open.startedAt),
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
const telemetryInput = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
|
|
692
|
+
if (event.toolName === "exocom_send") {
|
|
693
|
+
const pending = h.telemetryExocomPending.get(event.toolCallId) ?? [];
|
|
694
|
+
h.telemetryExocomPending.delete(event.toolCallId);
|
|
695
|
+
const details = event.details && typeof event.details === "object" && !Array.isArray(event.details)
|
|
696
|
+
? event.details as {
|
|
697
|
+
target?: unknown;
|
|
698
|
+
msg_id?: unknown;
|
|
699
|
+
sent?: unknown;
|
|
700
|
+
failed?: Array<{ target?: unknown }>;
|
|
701
|
+
failedCount?: unknown;
|
|
702
|
+
omittedFailures?: unknown;
|
|
703
|
+
}
|
|
704
|
+
: undefined;
|
|
705
|
+
const receipts = Array.isArray(details?.sent)
|
|
706
|
+
? details.sent.flatMap((receipt) => {
|
|
707
|
+
if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return [];
|
|
708
|
+
const item = receipt as { target?: unknown; msg_id?: unknown };
|
|
709
|
+
return typeof item.target === "string" && typeof item.msg_id === "string" && item.msg_id.length > 0
|
|
710
|
+
? [{ target: item.target, msgId: item.msg_id }]
|
|
711
|
+
: [];
|
|
712
|
+
})
|
|
713
|
+
: [];
|
|
714
|
+
const sampledFailures = details?.failed ?? [];
|
|
715
|
+
const failedTargets = new Set(sampledFailures.flatMap((failure) => typeof failure.target === "string" ? [failure.target] : []));
|
|
716
|
+
// A broadcast samples its failures (`MAX_BROADCAST_DETAIL_ITEMS` in tools/exocom.ts) and
|
|
717
|
+
// reports the rest as a count, so the sample alone CLEARS nobody: with more failures than
|
|
718
|
+
// the sample holds, a recipient we cannot find in it is unknown, never delivered.
|
|
719
|
+
const declaredFailures = typeof details?.failedCount === "number" ? details.failedCount : sampledFailures.length;
|
|
720
|
+
const omittedFailures = Math.max(
|
|
721
|
+
typeof details?.omittedFailures === "number" ? details.omittedFailures : 0,
|
|
722
|
+
declaredFailures - sampledFailures.length,
|
|
723
|
+
);
|
|
724
|
+
for (const message of pending) {
|
|
725
|
+
const matchingReceipts = !event.isError && details?.target === "*"
|
|
726
|
+
? receipts.filter((receipt) => receipt.target === message.target)
|
|
727
|
+
: [];
|
|
728
|
+
const failed = event.isError || failedTargets.has(message.target);
|
|
729
|
+
const wireId = failed ? undefined
|
|
730
|
+
: details?.target === message.target && typeof details.msg_id === "string" && details.msg_id.length > 0
|
|
731
|
+
? details.msg_id
|
|
732
|
+
: matchingReceipts.length === 1 ? matchingReceipts[0]!.msgId : undefined;
|
|
733
|
+
h.telemetry?.publish("message.sent", {
|
|
734
|
+
id: message.id,
|
|
735
|
+
channel: "exocom",
|
|
736
|
+
from: exocom.telemetrySessionId(ctx),
|
|
737
|
+
to: message.sessionId,
|
|
738
|
+
kind: "message",
|
|
739
|
+
// A matching successful ACK receipt settles this recipient even when the bounded
|
|
740
|
+
// broadcast failure sample omitted failures for other peers. Without a receipt, an
|
|
741
|
+
// incomplete failure sample cannot prove this particular row was delivered.
|
|
742
|
+
status: failed ? "failed" : omittedFailures > 0 && wireId === undefined ? "unknown" : "delivered",
|
|
743
|
+
expectsReply: false,
|
|
744
|
+
size: message.size,
|
|
745
|
+
...(message.replyTo ? { replyTo: message.replyTo } : {}),
|
|
746
|
+
...(wireId ? { wireId } : {}),
|
|
747
|
+
});
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (event.toolName === "intercom") {
|
|
751
|
+
const action = typeof telemetryInput.action === "string" ? telemetryInput.action : "";
|
|
752
|
+
// The open entry is the right to publish a terminal: once the turn-boundary drain has closed
|
|
753
|
+
// this call, a late result must not report it a second time.
|
|
754
|
+
const pending = h.telemetryIntercomPending.get(event.toolCallId);
|
|
755
|
+
h.telemetryIntercomPending.delete(event.toolCallId);
|
|
756
|
+
if (pending && (action === "send" || action === "reply")) {
|
|
757
|
+
const text = typeof telemetryInput.message === "string" ? telemetryInput.message : "";
|
|
758
|
+
const askId = typeof telemetryInput.askId === "string" ? telemetryInput.askId : undefined;
|
|
759
|
+
h.telemetry?.publish(action === "reply" ? "message.replied" : "message.sent", {
|
|
760
|
+
id: event.toolCallId,
|
|
761
|
+
channel: "intercom",
|
|
762
|
+
from: "supervisor",
|
|
763
|
+
// Ask settlement has already retired its sender lookup. The tool-start
|
|
764
|
+
// snapshot owns the recipient for both queued and terminal telemetry.
|
|
765
|
+
to: pending.to,
|
|
766
|
+
kind: action,
|
|
767
|
+
status: event.isError ? "failed" : action === "reply" ? "replied" : "delivered",
|
|
768
|
+
expectsReply: false,
|
|
769
|
+
size: Buffer.byteLength(text, "utf8"),
|
|
770
|
+
...(askId ? { replyTo: askId } : {}),
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const errorPatch = piPersonaToolErrorPatch(event.details);
|
|
775
|
+
if (!h.config.nudge) return errorPatch;
|
|
776
|
+
// Only a supervisor that CAN delegate is nudged to — a persona without the tool can't act on it.
|
|
777
|
+
if (!h.controller.capabilities?.tools.has("delegate")) return errorPatch;
|
|
778
|
+
const text = event.content.reduce((s, c) => (c.type === "text" ? s + c.text : s), "");
|
|
779
|
+
const relayed = pendingSurrenderRelays.delete(event.toolCallId);
|
|
780
|
+
const surrender = relayed ? h.persistenceNudge.scan(text) : h.persistenceNudge.observe(event.toolName, text);
|
|
781
|
+
// A NESTED call (a codemode script's `ctx.executeTool`) is a real execution — the gate, the
|
|
782
|
+
// telemetry above and the error patch all already ran — but its content is not a model-facing
|
|
783
|
+
// tool result. The caller chooses what to print or discard; only that outer output is charged
|
|
784
|
+
// to the context-burn run. Appending a note here would modify programmatic data and could
|
|
785
|
+
// create an invisible reminder. `parentToolCallId` is the host's explicit marker; `<parent>/<n>`
|
|
786
|
+
// id shape is never inferred from the id string.
|
|
787
|
+
if (typeof event.parentToolCallId === "string" && event.parentToolCallId.length > 0) {
|
|
788
|
+
if (surrender) rememberSurrenderRelay(event.parentToolCallId);
|
|
789
|
+
// A hand-off that actually landed ends the run even when a script issued it, so the reset is
|
|
790
|
+
// NOT dropped here. A FAILED one does not reset, and its repair note would be invisible:
|
|
791
|
+
// the next model-visible result is what reports the streak.
|
|
792
|
+
if (!errorPatch && event.isError !== true && HANDOFF_TOOL_NAMES.has(event.toolName)) h.delegationNudge.reset();
|
|
793
|
+
return errorPatch;
|
|
794
|
+
}
|
|
795
|
+
const notes: string[] = [];
|
|
796
|
+
// Grinding-by-hand reminder: a RUN of substantive hands-on commands on the supervisor's own
|
|
797
|
+
// tools (delegate/council/flow reset the run). `size` classifies substantive vs glue + fat dump.
|
|
798
|
+
const size = text.length;
|
|
799
|
+
const sweepNote = h.delegationNudge.observe(event.toolName, size, !errorPatch && event.isError !== true, text);
|
|
800
|
+
if (sweepNote) notes.push(sweepNote);
|
|
801
|
+
// Premature-surrender reminder: a delegated leg that came back BLOCKED/UNKNOWN (delegate/council/flow
|
|
802
|
+
// results only; because those reset the run the two never fire on one event).
|
|
803
|
+
if (surrender) notes.push(surrender);
|
|
804
|
+
if (notes.length === 0) return errorPatch;
|
|
805
|
+
const joined = notes.join("\n\n");
|
|
806
|
+
// Keep the semantic copy in the tool result for the model, and add a TUI-only durable card for
|
|
807
|
+
// the operator. Built-in/custom tool renderers are free to hide `content` when collapsed (some
|
|
808
|
+
// ignore it even when expanded), so the entry is the only cross-tool visibility guarantee.
|
|
809
|
+
h.appendNudgeEntry(joined);
|
|
810
|
+
return { ...errorPatch, content: [...event.content, { type: "text", text: joined }] };
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
// Mandatory orchestration: when the active persona declares a strategy/parallel/
|
|
814
|
+
// pipeline mode (or a flow), run it on the user's turn (the LLM cannot skip it) and
|
|
815
|
+
// fold the result into the prompt. Opportunistic h.personas (no orchestration) take the
|
|
816
|
+
// normal turn.
|
|
817
|
+
function mandatoryFailure(label: string, output: string, error: string, failureKind: FailureKind, resultReceipt?: ResultReceipt): MandatoryOutcome {
|
|
818
|
+
return { label, output, ok: false, error, failureKind, ...(resultReceipt ? { resultReceipt } : {}) };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** Make a mandatory failure visible outside the hidden system prompt too. The cause may contain
|
|
822
|
+
* provider/model text, so compact it to one bounded operator-facing line. */
|
|
823
|
+
function reportMandatoryFailure(ctx: ExtensionContext, outcome: MandatoryOutcome): void {
|
|
824
|
+
const label = compactInlineText(outcome.label, { maxChars: 120 });
|
|
825
|
+
const cause = toastCause(outcome.error ?? "unresolved result", 200);
|
|
826
|
+
const kind = outcome.failureKind ? ` [${outcome.failureKind}]` : "";
|
|
827
|
+
const message = `pi-persona: mandatory orchestration ${label} failed${kind}: ${cause}`;
|
|
828
|
+
try {
|
|
829
|
+
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
830
|
+
else process.stderr.write(`${message}\n`);
|
|
831
|
+
} catch {
|
|
832
|
+
// Diagnostics are best-effort; the fail-closed system-prompt hand-off remains authoritative.
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function executeMandatoryOrchestration(
|
|
837
|
+
ctx: ExtensionContext,
|
|
838
|
+
orch: OrchestrationGrammar,
|
|
839
|
+
task: string,
|
|
840
|
+
): Promise<MandatoryOutcome> {
|
|
841
|
+
const flowName = orch.mode === "flow" ? orch.flow : undefined;
|
|
842
|
+
const label = flowName ? `flow ${flowName}` : (resolveStrategyName(orch) ?? "strategy");
|
|
843
|
+
try {
|
|
844
|
+
if (flowName) {
|
|
845
|
+
const parsed = h.loadFlow(ctx.cwd, flowName);
|
|
846
|
+
if (!parsed) {
|
|
847
|
+
const error = `no flow named "${flowName}"`;
|
|
848
|
+
return mandatoryFailure(label, error, error, "contract");
|
|
849
|
+
}
|
|
850
|
+
if (!parsed.ok) {
|
|
851
|
+
const error = `flow "${flowName}" is invalid: ${parsed.error}`;
|
|
852
|
+
return mandatoryFailure(label, error, error, "contract");
|
|
853
|
+
}
|
|
854
|
+
const outcome = await h.runFlowVisible(ctx, parsed.flow, task);
|
|
855
|
+
const output = outcome.output || "(the flow returned no output)";
|
|
856
|
+
if (outcome.ok) return { label, output, ok: true, ...(outcome.resultReceipt ? { resultReceipt: outcome.resultReceipt } : {}) };
|
|
857
|
+
const error = outcome.cancelled
|
|
858
|
+
? "the mandatory flow was aborted"
|
|
859
|
+
: (outcome.error ?? "the mandatory flow did not complete successfully");
|
|
860
|
+
const failureKind = outcome.cancelled ? "abort" : (outcome.failureKind ?? "agent");
|
|
861
|
+
return mandatoryFailure(label, output, error, failureKind, outcome.resultReceipt);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
const result = await h.runStrategyVisible(ctx, orch, task, "strategy");
|
|
865
|
+
if (!result) {
|
|
866
|
+
const error = "the mandatory orchestration returned no result";
|
|
867
|
+
return mandatoryFailure(label, `(${error})`, error, "contract");
|
|
868
|
+
}
|
|
869
|
+
if (!result.ok) {
|
|
870
|
+
return mandatoryFailure(
|
|
871
|
+
label,
|
|
872
|
+
result.output || "(the orchestration returned no output)",
|
|
873
|
+
result.error ?? "the mandatory orchestration did not complete successfully",
|
|
874
|
+
result.failureKind ?? "agent", result.resultReceipt,
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
return { label, output: result.output, ok: true, ...(result.resultReceipt ? { resultReceipt: result.resultReceipt } : {}) };
|
|
878
|
+
} catch (err) {
|
|
879
|
+
const error = `orchestration failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
880
|
+
return mandatoryFailure(label, error, error, "agent");
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function enqueueDeferredOrchestration(
|
|
885
|
+
task: string,
|
|
886
|
+
orchestration: OrchestrationGrammar,
|
|
887
|
+
ctx: ExtensionContext,
|
|
888
|
+
): { action: "handled" } {
|
|
889
|
+
if (h.deferredOrchestrations.length >= h.MAX_DEFERRED_ORCHESTRATIONS) {
|
|
890
|
+
ctx.ui.notify(
|
|
891
|
+
`persona orchestration queue full (${h.MAX_DEFERRED_ORCHESTRATIONS}); input was not accepted — retry after the current run settles.`,
|
|
892
|
+
"error",
|
|
893
|
+
);
|
|
894
|
+
return { action: "handled" };
|
|
895
|
+
}
|
|
896
|
+
h.deferredOrchestrations.push({ task, orchestration });
|
|
897
|
+
ctx.ui.notify(`persona orchestration queued (${h.deferredOrchestrations.length}) — it will run after the current turn settles.`, "info");
|
|
898
|
+
return { action: "handled" };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function processNextDeferredOrchestration(): Promise<boolean> {
|
|
902
|
+
if (h.disposed || h.processingDeferredOrchestration || h.deferredOrchestrations.length === 0 || !h.lastCtx) return false;
|
|
903
|
+
const item = h.deferredOrchestrations.shift()!;
|
|
904
|
+
h.processingDeferredOrchestration = true;
|
|
905
|
+
h.orchestrating = true;
|
|
906
|
+
try {
|
|
907
|
+
const result = await executeMandatoryOrchestration(h.lastCtx, item.orchestration, item.task);
|
|
908
|
+
if (h.disposed) return false;
|
|
909
|
+
h.pendingOrchestration = result;
|
|
910
|
+
// Source is "extension", so the input hook will not orchestrate this replay again.
|
|
911
|
+
sendPersonaFollowUp(pi, item.task, "pi-persona-deferred-input");
|
|
912
|
+
return true;
|
|
913
|
+
} catch (err) {
|
|
914
|
+
h.pendingOrchestration = undefined;
|
|
915
|
+
if (!h.disposed) {
|
|
916
|
+
h.deferredOrchestrations.unshift(item);
|
|
917
|
+
h.lastCtx.ui.notify(`could not replay deferred persona input: ${toastCause(err instanceof Error ? err.message : String(err))}`, "error");
|
|
918
|
+
}
|
|
919
|
+
return false;
|
|
920
|
+
} finally {
|
|
921
|
+
h.orchestrating = false;
|
|
922
|
+
h.processingDeferredOrchestration = false;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
pi.on("input", async (event, ctx) => {
|
|
927
|
+
h.lastCtx = ctx;
|
|
928
|
+
if (event.source === "extension") return undefined;
|
|
929
|
+
h.pollGuard?.reset(); // a real operator message earns a fresh look
|
|
930
|
+
const orch = h.controller.activePersona?.orchestration;
|
|
931
|
+
const task = event.text?.trim();
|
|
932
|
+
if (!orch || !task) return undefined;
|
|
933
|
+
const flowName = orch.mode === "flow" ? orch.flow : undefined;
|
|
934
|
+
let strategyName: string | undefined;
|
|
935
|
+
try {
|
|
936
|
+
strategyName = resolveStrategyName(orch);
|
|
937
|
+
} catch (err) {
|
|
938
|
+
// Only the persona FILE can fix this; take the turn normally rather than tearing the
|
|
939
|
+
// input hook down on every keystroke, but say so loudly — the mandatory orchestration
|
|
940
|
+
// the user configured is silently not happening.
|
|
941
|
+
ctx.ui.notify(h.personaGrammarError(err), "error");
|
|
942
|
+
return undefined;
|
|
943
|
+
}
|
|
944
|
+
if (!flowName && !strategyName) return undefined;
|
|
945
|
+
const mainBusy = ctx.isIdle() !== true;
|
|
946
|
+
// Steering belongs to the turn already in progress. Never start a competing mandatory
|
|
947
|
+
// orchestration for it; Pi remains responsible for applying the steering message.
|
|
948
|
+
if (mainBusy && event.streamingBehavior !== "followUp") return { action: "continue" };
|
|
949
|
+
// Follow-up input must retain mandatory semantics without racing the live run. An input that
|
|
950
|
+
// arrives while another orchestration is executing joins the same FIFO even if Pi is idle.
|
|
951
|
+
if (mainBusy || h.orchestrating) return enqueueDeferredOrchestration(task, orch, ctx);
|
|
952
|
+
h.orchestrating = true;
|
|
953
|
+
try {
|
|
954
|
+
h.pendingOrchestration = await executeMandatoryOrchestration(ctx, orch, task);
|
|
955
|
+
} finally {
|
|
956
|
+
h.orchestrating = false;
|
|
957
|
+
}
|
|
958
|
+
// Let the user's original prompt proceed; the ruling is injected (hidden) into the
|
|
959
|
+
// turn's system prompt via before_agent_start — no internal plumbing in the chat.
|
|
960
|
+
return undefined;
|
|
961
|
+
});
|
|
962
|
+
}
|