@aeondave/pi-persona 1.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +249 -0
- package/CHANGELOG.md +260 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/agents/balthasar.md +32 -0
- package/agents/casper.md +33 -0
- package/agents/evidence-verifier.md +23 -0
- package/agents/melchior.md +32 -0
- package/agents/operator.md +63 -0
- package/agents/research.md +58 -0
- package/agents/reviewer.md +49 -0
- package/agents/scout.md +23 -0
- package/agents/verifier.md +21 -0
- package/assets/banner.png +0 -0
- package/assets/demo1.gif +0 -0
- package/assets/demo1.mp4 +0 -0
- package/assets/workflow.png +0 -0
- package/contracts/finding.contract.json +9 -0
- package/docs/ARCHITECTURE.md +727 -0
- package/docs/EXPERIENCE.md +96 -0
- package/docs/MONITORS.md +84 -0
- package/docs/README.md +53 -0
- package/docs/REFERENCE.md +408 -0
- package/docs/SPINE.md +152 -0
- package/docs/STRATEGIES.md +215 -0
- package/docs/TELEMETRY.md +9 -0
- package/flows/deep-review.flow.json +8 -0
- package/flows/gated-build.flow.json +9 -0
- package/package.json +106 -0
- package/personas/audit.md +29 -0
- package/personas/dev.md +52 -0
- package/personas/elite.md +256 -0
- package/personas/judge.md +32 -0
- package/personas/magi.md +41 -0
- package/personas/planner.md +37 -0
- package/personas/researcher.md +37 -0
- package/personas/swarm.md +38 -0
- package/personas/verify.md +31 -0
- package/presets/magi-rounds.preset.json +5 -0
- package/prompts/spine.md +11 -0
- package/prompts/spine.worker.md +9 -0
- package/src/agents/agent.ts +78 -0
- package/src/bridge.ts +209 -0
- package/src/bus/broker/client.ts +379 -0
- package/src/bus/broker/framing.ts +43 -0
- package/src/bus/broker/host.ts +407 -0
- package/src/bus/broker/messages.ts +87 -0
- package/src/bus/broker/paths.ts +31 -0
- package/src/bus/contact.ts +145 -0
- package/src/bus/inproc.ts +279 -0
- package/src/bus/peers.ts +114 -0
- package/src/core/brief.ts +356 -0
- package/src/core/capabilities.ts +114 -0
- package/src/core/config.ts +228 -0
- package/src/core/contract.ts +391 -0
- package/src/core/data-root.ts +260 -0
- package/src/core/discovery.ts +33 -0
- package/src/core/display-label.ts +47 -0
- package/src/core/fence.ts +151 -0
- package/src/core/format.ts +13 -0
- package/src/core/frontmatter.ts +202 -0
- package/src/core/models.ts +192 -0
- package/src/core/monitor.ts +415 -0
- package/src/core/naming.ts +42 -0
- package/src/core/nudge.ts +238 -0
- package/src/core/ownership.ts +102 -0
- package/src/core/permissions.ts +56 -0
- package/src/core/pi-compat.ts +50 -0
- package/src/core/seed.ts +770 -0
- package/src/core/session-identity.ts +89 -0
- package/src/core/time.ts +202 -0
- package/src/core/timer.ts +221 -0
- package/src/core/types.ts +28 -0
- package/src/engine/adapter.ts +257 -0
- package/src/engine/async.ts +898 -0
- package/src/engine/change-report.ts +143 -0
- package/src/engine/child.ts +532 -0
- package/src/engine/errors.ts +30 -0
- package/src/engine/fallback.ts +57 -0
- package/src/engine/handles.ts +21 -0
- package/src/engine/inproc.ts +857 -0
- package/src/engine/signals.ts +11 -0
- package/src/engine/spec-preflight.ts +33 -0
- package/src/engine/stream.ts +369 -0
- package/src/engine/worktree.ts +144 -0
- package/src/exocom/activation.ts +31 -0
- package/src/exocom/codes.ts +261 -0
- package/src/exocom/envelope.ts +180 -0
- package/src/exocom/gate.ts +6 -0
- package/src/exocom/guards.ts +45 -0
- package/src/exocom/inbound.ts +141 -0
- package/src/exocom/install.ts +970 -0
- package/src/exocom/ledger.ts +603 -0
- package/src/exocom/limits.ts +20 -0
- package/src/exocom/paths.ts +35 -0
- package/src/exocom/plane.ts +1116 -0
- package/src/exocom/registry.ts +307 -0
- package/src/exocom/scope.ts +42 -0
- package/src/exocom/status.ts +86 -0
- package/src/exocom/untrusted.ts +40 -0
- package/src/exocom/wait.ts +23 -0
- package/src/exocom/write-guard.ts +89 -0
- package/src/extension/broker-host.ts +181 -0
- package/src/extension/clock.ts +14 -0
- package/src/extension/engine.ts +321 -0
- package/src/extension/hooks.ts +796 -0
- package/src/extension/identity.ts +244 -0
- package/src/extension/shared.ts +175 -0
- package/src/extension.ts +2221 -0
- package/src/loader.ts +218 -0
- package/src/monitor/process.ts +116 -0
- package/src/monitor/session.ts +61 -0
- package/src/orchestration/flow-journal.ts +86 -0
- package/src/orchestration/flow-run.ts +301 -0
- package/src/orchestration/flow.ts +125 -0
- package/src/orchestration/judge.ts +78 -0
- package/src/orchestration/model-retry.ts +85 -0
- package/src/orchestration/parallel.ts +107 -0
- package/src/orchestration/params.ts +23 -0
- package/src/orchestration/reducers.ts +123 -0
- package/src/orchestration/render.ts +127 -0
- package/src/orchestration/roster.ts +133 -0
- package/src/orchestration/sdk.ts +326 -0
- package/src/orchestration/strategies/compete.ts +182 -0
- package/src/orchestration/strategies/council-rounds.ts +124 -0
- package/src/orchestration/strategies/critic-loop.ts +168 -0
- package/src/orchestration/strategies/debate.ts +126 -0
- package/src/orchestration/strategies/fanout.ts +20 -0
- package/src/orchestration/strategies/judge.ts +117 -0
- package/src/orchestration/strategies/magi.ts +239 -0
- package/src/orchestration/strategies/map.ts +327 -0
- package/src/orchestration/strategies/pair.ts +72 -0
- package/src/orchestration/strategies/pipeline.ts +91 -0
- package/src/orchestration/strategies/synthesize.ts +99 -0
- package/src/orchestration/strategy.ts +49 -0
- package/src/orchestration/types.ts +31 -0
- package/src/orchestration/voting.ts +126 -0
- package/src/persona/config-store.ts +215 -0
- package/src/persona/controller.ts +173 -0
- package/src/persona/gating.ts +82 -0
- package/src/persona/orchestrate.ts +83 -0
- package/src/persona/persona.ts +294 -0
- package/src/persona/spine.ts +221 -0
- package/src/persona/state.ts +28 -0
- package/src/telemetry/contract.ts +244 -0
- package/src/telemetry/paths.ts +14 -0
- package/src/telemetry/producer.ts +390 -0
- package/src/tools/council.ts +200 -0
- package/src/tools/delegate-tool.ts +637 -0
- package/src/tools/delegate.ts +661 -0
- package/src/tools/exocom-work.ts +328 -0
- package/src/tools/exocom.ts +476 -0
- package/src/tools/flow.ts +117 -0
- package/src/tools/intercom-tool.ts +292 -0
- package/src/tools/intercom.ts +181 -0
- package/src/tools/models.ts +39 -0
- package/src/tools/monitor.ts +66 -0
- package/src/tools/timer.ts +105 -0
- package/src/ui/agent-overlay.ts +526 -0
- package/src/ui/agent-tree.ts +307 -0
- package/src/ui/live-clock.ts +42 -0
- package/src/ui/model-picker.ts +160 -0
- package/src/ui/presentation.ts +170 -0
- package/src/ui/usage.ts +133 -0
- package/teams.yaml +27 -0
package/src/extension.ts
ADDED
|
@@ -0,0 +1,2221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-persona — the single ExtensionFactory wiring every layer to Pi.
|
|
3
|
+
*
|
|
4
|
+
* Loads personas/agents/teams on session_start; applies a persona (prompt +
|
|
5
|
+
* model/thinking/tools) and gates delegation via the PersonaController; registers
|
|
6
|
+
* the `delegate` and `council` tools, the unified agent tree + navigable overlay
|
|
7
|
+
* (`f9` / `/agents`), the `f8` persona cycle, and the `/persona`, `/doctor`,
|
|
8
|
+
* `/orchestrate`, `/peek` commands. Orchestration runs through the Strategy SDK on
|
|
9
|
+
* one of two engine backends: InProcessEngine (the runtime default — steerable,
|
|
10
|
+
* shares the session) or ChildProcessEngine (the correctness baseline — worktree
|
|
11
|
+
* isolation; see docs/ARCHITECTURE.md).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
|
|
18
|
+
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
20
|
+
|
|
21
|
+
import type { AgentConfig } from "./agents/agent.ts";
|
|
22
|
+
import { installBridge, isBridgeMode } from "./bridge.ts";
|
|
23
|
+
import { resolveConfig } from "./core/config.ts";
|
|
24
|
+
import { type ContractDef, installedContractNames } from "./core/contract.ts";
|
|
25
|
+
import { type DataRootMigrationResult, migrateLegacyDataRoot } from "./core/data-root.ts";
|
|
26
|
+
import {
|
|
27
|
+
inspectLegacySeededSpines,
|
|
28
|
+
migratePristineSeededDefaults,
|
|
29
|
+
seedDefaults,
|
|
30
|
+
type SeedMigrationOptions,
|
|
31
|
+
type SeedMigrationResult,
|
|
32
|
+
type SeedResult,
|
|
33
|
+
type SpineLegacyIO,
|
|
34
|
+
type SpineLegacyResult,
|
|
35
|
+
type SpineLegacySelection,
|
|
36
|
+
} from "./core/seed.ts";
|
|
37
|
+
import { buildDelegationBrief } from "./core/brief.ts";
|
|
38
|
+
import { canCallTool, canDelegateTo, canFanOut, type RunLimits } from "./core/capabilities.ts";
|
|
39
|
+
import { fenceUntrusted } from "./core/fence.ts";
|
|
40
|
+
import { sanitizeDisplayLabel } from "./core/display-label.ts";
|
|
41
|
+
import { DelegationNudge, PersistenceNudge } from "./core/nudge.ts";
|
|
42
|
+
import { installedPiVersion, MIN_PI_VERSION, satisfiesFloor } from "./core/pi-compat.ts";
|
|
43
|
+
import { type EngineAdapterBroker } from "./engine/adapter.ts";
|
|
44
|
+
import { SupervisorBroker } from "./extension/broker-host.ts";
|
|
45
|
+
import { configuredModels, createBuildEngine, DEFAULT_ENGINE_FACTORIES, type EngineFactories } from "./extension/engine.ts";
|
|
46
|
+
import { installHooks, type HookHost } from "./extension/hooks.ts";
|
|
47
|
+
import {
|
|
48
|
+
canDeliverPersonaNotification,
|
|
49
|
+
expandDetailHint,
|
|
50
|
+
sanitizePeerField,
|
|
51
|
+
sendPersonaFollowUp,
|
|
52
|
+
type PendingAsk,
|
|
53
|
+
} from "./extension/shared.ts";
|
|
54
|
+
export {
|
|
55
|
+
agentNodeStatusForDelegate,
|
|
56
|
+
boundExocomInboundBatch,
|
|
57
|
+
canDeliverPersonaNotification,
|
|
58
|
+
canonicalExocomTelemetryTargets,
|
|
59
|
+
coachingDisabledHint,
|
|
60
|
+
exocomInboundBatchSize,
|
|
61
|
+
EXOCOM_INBOX_MAX,
|
|
62
|
+
exocomInboundDisposition,
|
|
63
|
+
expandDetailHint,
|
|
64
|
+
failureDetails,
|
|
65
|
+
fenceIntercomOutcome,
|
|
66
|
+
formatCouncilCallLabel,
|
|
67
|
+
formatExocomQueuedBatchToast,
|
|
68
|
+
formatExocomQueuedToast,
|
|
69
|
+
piPersonaToolErrorPatch,
|
|
70
|
+
reconcileAnsweredAsk,
|
|
71
|
+
sanitizeLabel,
|
|
72
|
+
sanitizePeerField,
|
|
73
|
+
sendPersonaFollowUp,
|
|
74
|
+
shouldReportHeartbeatFailure,
|
|
75
|
+
type ExocomQueuedToast,
|
|
76
|
+
type PendingAsk,
|
|
77
|
+
} from "./extension/shared.ts";
|
|
78
|
+
import { installExocom } from "./exocom/install.ts";
|
|
79
|
+
import { installIdentity } from "./extension/identity.ts";
|
|
80
|
+
import { parseExocomArgv } from "./exocom/activation.ts";
|
|
81
|
+
import { formatLedgerStatus } from "./exocom/status.ts";
|
|
82
|
+
import { registerDelegateTool } from "./tools/delegate-tool.ts";
|
|
83
|
+
import { registerIntercomTool } from "./tools/intercom-tool.ts";
|
|
84
|
+
import { registerTimerTool } from "./tools/timer.ts";
|
|
85
|
+
import { registerMonitorTool } from "./tools/monitor.ts";
|
|
86
|
+
import { createMonitorSession, type MonitorSession } from "./monitor/session.ts";
|
|
87
|
+
import { registerCouncilTool } from "./tools/council.ts";
|
|
88
|
+
import { registerFlowTool } from "./tools/flow.ts";
|
|
89
|
+
import { registerModelsTool } from "./tools/models.ts";
|
|
90
|
+
import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn, buildPeekAlert, buildPeekDigest, buildRetentionOverflowNote, IdleCoalescingNotifier, PeekWatcher, renderCompletion, STALL_FLAG_MS } from "./engine/async.ts";
|
|
91
|
+
import { emptyUsage, type ToolEvent } from "./engine/stream.ts";
|
|
92
|
+
import { brokerEndpoint } from "./bus/broker/paths.ts";
|
|
93
|
+
import { InProcessBus } from "./bus/inproc.ts";
|
|
94
|
+
import { loadContracts, loadDefinitions, loadPresets, loadTeams, type LoadResult, type ScopedDir } from "./loader.ts";
|
|
95
|
+
import { type FlowSpec, flowHash, parseFlow, verifyFlowRefs } from "./orchestration/flow.ts";
|
|
96
|
+
import { journalFileName, journalWriter, readJournal } from "./orchestration/flow-journal.ts";
|
|
97
|
+
import { runFlow } from "./orchestration/flow-run.ts";
|
|
98
|
+
import type { FlowOutcome } from "./orchestration/flow-run.ts";
|
|
99
|
+
import { type RosterMember, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
|
|
100
|
+
import type { AgentProgress, AgentStatus, SteerFn } from "./orchestration/sdk.ts";
|
|
101
|
+
import { knownParams, strategyNames } from "./orchestration/strategy.ts";
|
|
102
|
+
import { compactMemberResult } from "./orchestration/render.ts";
|
|
103
|
+
import type { AgentResult, FailureKind } from "./orchestration/types.ts";
|
|
104
|
+
import { type ModelHandle, PersonaController, type PersonaHost } from "./persona/controller.ts";
|
|
105
|
+
import { resolveStrategyName, runPersonaStrategy } from "./persona/orchestrate.ts";
|
|
106
|
+
import { expandCouncilPreset, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
|
|
107
|
+
import { bundledSpinePath, bundledWorkerSpinePath, readSpineFile, resolveSpine, type SpineSources } from "./persona/spine.ts";
|
|
108
|
+
import { readLastPersona, writeLastPersona } from "./persona/state.ts";
|
|
109
|
+
import { type AgentKind as TelemetryAgentKind, type AgentStatus as TelemetryAgentStatus } from "./telemetry/contract.ts";
|
|
110
|
+
import { TelemetryProducer, type TelemetryAgentInput } from "./telemetry/producer.ts";
|
|
111
|
+
import {
|
|
112
|
+
type PersonaConfigStore,
|
|
113
|
+
personaModels,
|
|
114
|
+
readPersonaConfigs,
|
|
115
|
+
withPersonaModels,
|
|
116
|
+
writePersonaConfigs,
|
|
117
|
+
} from "./persona/config-store.ts";
|
|
118
|
+
import { shortModel } from "./tools/delegate.ts";
|
|
119
|
+
import { formatInbox } from "./tools/intercom.ts";
|
|
120
|
+
import { renderTimerFire, TimerScheduler, type TimerEntry } from "./core/timer.ts";
|
|
121
|
+
import { AgentOverlay } from "./ui/agent-overlay.ts";
|
|
122
|
+
import { type AddNodeInput, type AgentNode, type AgentTreeChange, AgentTree, type AgentNodeStatus, isQueuedMarker, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
|
|
123
|
+
import { LiveClock } from "./ui/live-clock.ts";
|
|
124
|
+
import { filterModels, ModelPicker, orderModelRefs } from "./ui/model-picker.ts";
|
|
125
|
+
import { compactInlineText, compactVisibleText, sanitizeTerminalText } from "./ui/presentation.ts";
|
|
126
|
+
import { ChildUsageLedger, formatPersonaCostStatus, formatUsage, PERSONA_COST_STATUS_KEY } from "./ui/usage.ts";
|
|
127
|
+
|
|
128
|
+
const RUN_LIMITS: RunLimits = {
|
|
129
|
+
// A generous anti-runaway backstop, not a council-size cap: a declared ensemble
|
|
130
|
+
// (e.g. 9 members × several rounds) must fit; the token budget is the real cost guard.
|
|
131
|
+
maxChildren: 64,
|
|
132
|
+
maxConcurrency: 4,
|
|
133
|
+
timeoutMs: 180_000, // IDLE window (resets on output) — kills a hung child, not a busy one
|
|
134
|
+
budgetTokens: 1_000_000,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const BUNDLED_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
138
|
+
const DATA_DIR = "persona";
|
|
139
|
+
|
|
140
|
+
/** The Pi global agent dir, overridable via PI_AGENT_DIR (handy for tests/sandboxes). */
|
|
141
|
+
function userAgentDir(): string {
|
|
142
|
+
return process.env.PI_AGENT_DIR || getAgentDir();
|
|
143
|
+
}
|
|
144
|
+
/** A spawned Pi resolves its global settings/auth from PI_CODING_AGENT_DIR, while pi-persona's
|
|
145
|
+
* test/user override is PI_AGENT_DIR. Pin the child to the same resolved directory the in-process
|
|
146
|
+
* createAgentSession receives, otherwise retry/provider settings diverge between engine backends. */
|
|
147
|
+
function childPiSettingsEnv(): Record<string, string> {
|
|
148
|
+
return { PI_CODING_AGENT_DIR: userAgentDir() };
|
|
149
|
+
}
|
|
150
|
+
function personaDataDir(): string {
|
|
151
|
+
return join(userAgentDir(), DATA_DIR);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Cross-process `contact_peer` roster (spec B7): scopes `brokerPeers` — the process-wide,
|
|
155
|
+
* pre-spawn registry keyed by handle (populated in `SupervisorBroker.adapterDeps`'s `register`,
|
|
156
|
+
* see below) — to the SAME per-engine group as the caller `self`, mirroring `engine/inproc.ts`'s
|
|
157
|
+
* per-engine-instance `peerLabels` map. `self`'s OWN recorded group is the source of truth
|
|
158
|
+
* here, NOT the wire's `group` argument the host would otherwise pass: the child's env
|
|
159
|
+
* carries no group (spec B6, the wire `register` frame stays minimal), so every wire group
|
|
160
|
+
* is "" and scoping by it would either always come back empty (this scope) or leak every
|
|
161
|
+
* concurrent run's peers into one flat list (the host's own default `group=""` scoping).
|
|
162
|
+
* `self` not found (not registered with `peers: true`) ⇒ empty roster, never a leak.
|
|
163
|
+
* Exported for direct unit/integration testing — `extension.ts`'s activation closure itself
|
|
164
|
+
* isn't a testable unit. Implementation lives in `./extension/broker-host.ts` now. */
|
|
165
|
+
export { listPeersForGroup } from "./extension/broker-host.ts";
|
|
166
|
+
|
|
167
|
+
interface CommandResultEntry {
|
|
168
|
+
label: string;
|
|
169
|
+
content: string;
|
|
170
|
+
ok: boolean;
|
|
171
|
+
failureKind?: FailureKind;
|
|
172
|
+
error?: string;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface NudgeEntry {
|
|
176
|
+
content: string;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Keep the immediate operator-facing settlement toast and the semantic completion delivery on one
|
|
181
|
+
* path. A supervisor-requested abort is terminal, but it is not an execution error: the tracker
|
|
182
|
+
* classifies it as `stopped`, so the UI reports it at info level while the completion still reaches
|
|
183
|
+
* the supervisor exactly once through the normal notifier.
|
|
184
|
+
*/
|
|
185
|
+
export function announceAsyncRunSettlement(
|
|
186
|
+
run: AsyncRun,
|
|
187
|
+
notify: ((message: string, level: "info" | "error") => void) | undefined,
|
|
188
|
+
enqueue: (run: AsyncRun) => void,
|
|
189
|
+
): void {
|
|
190
|
+
const id = compactInlineText(run.id, { maxChars: 80 }) || "run";
|
|
191
|
+
const agent = compactInlineText(run.agent, { maxChars: 80 }) || "agent";
|
|
192
|
+
if (run.status === "stopped") notify?.(`async run ${id} (${agent}) stopped`, "info");
|
|
193
|
+
else if (run.status === "failed") notify?.(`async run ${id} (${agent}) failed: ${compactInlineText(run.error ?? "(no detail)", { maxChars: 240 })}`, "error");
|
|
194
|
+
enqueue(run);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Agents actually IN FLIGHT: a RUNNING node with no children of its own. "Has a parent" is not the
|
|
198
|
+
* same as "is an agent" — a flow phase is a container that also has a parent, a root-level `async:`
|
|
199
|
+
* run is a leaf that has none, and a settled leg still sits in the tree until its root is torn
|
|
200
|
+
* down. Exported for direct unit testing (mirrors `listPeersForGroup` above). */
|
|
201
|
+
export function inFlightAgentCount(nodes: ReadonlyArray<{ id: string; parentId?: string | undefined; status: AgentNodeStatus }>): number {
|
|
202
|
+
const parents = new Set<string>();
|
|
203
|
+
for (const node of nodes) {
|
|
204
|
+
if (node.parentId !== undefined) parents.add(node.parentId);
|
|
205
|
+
}
|
|
206
|
+
let count = 0;
|
|
207
|
+
for (const node of nodes) {
|
|
208
|
+
if (node.status === "running" && !parents.has(node.id)) count++;
|
|
209
|
+
}
|
|
210
|
+
return count;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Per-invocation tree root ids. Two concurrent runs of the SAME strategy or flow (two
|
|
214
|
+
* `/orchestrate`s, the `flow` tool racing `/flow`) would otherwise share one deterministic root:
|
|
215
|
+
* the first to finish clears the other's stop/steer handles and removes its live subtree, leaving
|
|
216
|
+
* invisible orphans the overlay can no longer stop. The suffix carries no `/`, so `clearStops`'s
|
|
217
|
+
* `id === root || id.startsWith(root + "/")` scoping still covers exactly one run's subtree. */
|
|
218
|
+
export function makeRootIdAllocator(): (prefix: string) => string {
|
|
219
|
+
let seq = 0;
|
|
220
|
+
return (prefix) => {
|
|
221
|
+
seq += 1;
|
|
222
|
+
return `${prefix}#${seq}`;
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Bound a burst of blocking child questions before it becomes an automatic supervisor turn. Each
|
|
227
|
+
* item is already individually fenced; the line-safe completion bound preserves those quote lines. */
|
|
228
|
+
export function renderPendingAskBatch(asks: readonly PendingAsk[]): string {
|
|
229
|
+
return boundCompletionSurface(
|
|
230
|
+
asks.map((ask) => ask.text).join("\n\n"),
|
|
231
|
+
"blocking-ask batch truncated; pending questions remain available through intercom inbox",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The engine constructors `buildEngine` builds through. Indirected via one object so the deps
|
|
236
|
+
* each backend is actually handed are observable: the child engine can be watched through a fake
|
|
237
|
+
* `pi` binary, but the in-process one — the DEFAULT backend — creates a real session that needs a
|
|
238
|
+
* live model and provider, so its wiring would otherwise have no witness at all. */
|
|
239
|
+
export type { EngineFactories } from "./extension/engine.ts";
|
|
240
|
+
|
|
241
|
+
/** Activation-scoped overrides. Pi's `ExtensionFactory` is `(pi) => void`, so it never passes a
|
|
242
|
+
* second argument and this is absent in production; a caller that does supply it substitutes
|
|
243
|
+
* for that activation ALONE — nothing process-wide to restore, and nothing left behind for the
|
|
244
|
+
* next one if the caller dies mid-way. */
|
|
245
|
+
export interface PiPersonaOptions {
|
|
246
|
+
engineFactories?: EngineFactories;
|
|
247
|
+
/** Activation-local test seam for read-only legacy-spine inspection. */
|
|
248
|
+
spineLegacyIO?: SpineLegacyIO;
|
|
249
|
+
/** Activation-local test seam for exact seeded-default migrations. */
|
|
250
|
+
seedMigration?: SeedMigrationOptions;
|
|
251
|
+
/** Activation-local argv seam for Pi's boolean `--exocom=<code>` compatibility parser. */
|
|
252
|
+
exocomArgv?: readonly string[];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions = {}): void {
|
|
256
|
+
// Cross-process broker (v0.5, spec B3): a child spawned with `PI_PERSONA_BUS` set is a
|
|
257
|
+
// broker-connected sub-agent, not a supervisor — load ONLY the bridge (comm-plane tools +
|
|
258
|
+
// inbound follow-ups) and skip the entire persona/delegate/orchestration surface. Checked
|
|
259
|
+
// before anything else (even config resolution) so bridge mode can never be shadowed by
|
|
260
|
+
// the fork-bomb guard's `PI_PERSONA_DISABLE=1` short-circuit below, or by any other early
|
|
261
|
+
// return. `PI_PERSONA_BUS` unset (the default, and every existing test) ⇒ this branch never
|
|
262
|
+
// runs — zero behavior change.
|
|
263
|
+
if (isBridgeMode()) {
|
|
264
|
+
pi.on("session_start", (_event, ctx) => installBridge(pi, ctx));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const config = resolveConfig(process.env);
|
|
269
|
+
if (config.disabled) return;
|
|
270
|
+
const exocomArgs = parseExocomArgv(options.exocomArgv ?? process.argv);
|
|
271
|
+
const registerFollowUpRenderer = (customType: "pi-persona" | "exocom_received", label: string): void => {
|
|
272
|
+
pi.registerMessageRenderer(customType, (message, { expanded, outputPad }, theme) => {
|
|
273
|
+
const raw = typeof message.content === "string"
|
|
274
|
+
? message.content
|
|
275
|
+
: message.content.map((part) => part.type === "text" ? part.text : "[image]").join("\n");
|
|
276
|
+
const full = sanitizeTerminalText(raw);
|
|
277
|
+
const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
|
|
278
|
+
const firstLine = full.split("\n", 1)[0] ?? "";
|
|
279
|
+
// External peer text is data and must never choose our UI chrome. Internal follow-ups own
|
|
280
|
+
// their first status line; parse only that line and do not turn a healthy "0 failed" red.
|
|
281
|
+
const isFailure = customType === "pi-persona"
|
|
282
|
+
&& !/\b0 failed\b/i.test(firstLine)
|
|
283
|
+
&& /\b(?:failed|failure|error|rejected|unresolved)\b/i.test(firstLine);
|
|
284
|
+
const titleColor = isFailure ? "error" : customType === "exocom_received" ? "accent" : "success";
|
|
285
|
+
const title = theme.fg(titleColor, theme.bold(label));
|
|
286
|
+
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full || "(no output)")}`, outputPad, 0);
|
|
287
|
+
const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
288
|
+
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, outputPad, 0);
|
|
289
|
+
});
|
|
290
|
+
};
|
|
291
|
+
// These follow-ups remain complete in the model context, but the human-facing card starts as a
|
|
292
|
+
// bounded digest. This is the central progressive-disclosure boundary for async completions and
|
|
293
|
+
// external peer messages; expanding the card reveals the exact sanitized payload.
|
|
294
|
+
registerFollowUpRenderer("pi-persona", "pi-persona · update");
|
|
295
|
+
registerFollowUpRenderer("exocom_received", "exocom · inbound");
|
|
296
|
+
pi.registerEntryRenderer("pi-persona-result", (entry, { expanded }, theme) => {
|
|
297
|
+
const data = entry.data as CommandResultEntry;
|
|
298
|
+
const full = sanitizeTerminalText(data.content || "(no output)");
|
|
299
|
+
const cause = compactVisibleText(
|
|
300
|
+
[data.failureKind, data.error].filter((part): part is string => !!part).join(" · "),
|
|
301
|
+
{ maxLines: 1, maxLineChars: 80 },
|
|
302
|
+
).text;
|
|
303
|
+
const status = data.ok ? "complete" : "failed";
|
|
304
|
+
const label = compactInlineText(data.label, { maxChars: 96 }) || "result";
|
|
305
|
+
const title = theme.fg(data.ok ? "success" : "error", theme.bold(`${label} · ${status}${!data.ok && cause !== "(no output)" ? ` · ${cause}` : ""}`));
|
|
306
|
+
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
|
|
307
|
+
const preview = compactVisibleText(full, { maxLines: 3, maxLineChars: 100 });
|
|
308
|
+
const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
309
|
+
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
|
|
310
|
+
});
|
|
311
|
+
pi.registerEntryRenderer("pi-persona-nudge", (entry, { expanded }, theme) => {
|
|
312
|
+
const data = entry.data as NudgeEntry;
|
|
313
|
+
const full = sanitizeTerminalText(data.content || "⟢ pi-persona · checkpoint");
|
|
314
|
+
const lines = full.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
315
|
+
const header = compactInlineText(lines[0]?.replace(/^⟢\s*/, "") ?? "", { maxChars: 96 }) || "pi-persona · checkpoint";
|
|
316
|
+
const details = lines.slice(1);
|
|
317
|
+
const title = theme.fg("warning", theme.bold(`⟢ ${header}`));
|
|
318
|
+
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", details.join("\n") || "(no detail)")}`, 0, 0);
|
|
319
|
+
const trigger = details.find((line) => line.startsWith("Trigger:"));
|
|
320
|
+
const action = details.find((line) => /^(?:Action|Verify|Otherwise):/.test(line));
|
|
321
|
+
const selected = [trigger, action].filter((line): line is string => line !== undefined);
|
|
322
|
+
const previewLines = selected.length > 0 ? selected : details.slice(0, 2);
|
|
323
|
+
const preview = compactVisibleText(previewLines.join("\n"), { maxLines: 2, maxLineChars: 100 });
|
|
324
|
+
const hasMore = preview.truncated || details.some((line) => !previewLines.includes(line));
|
|
325
|
+
const hint = hasMore ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
326
|
+
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
|
|
327
|
+
});
|
|
328
|
+
const appendNudgeEntry = (content: string): void => {
|
|
329
|
+
pi.appendEntry("pi-persona-nudge", { content } satisfies NudgeEntry);
|
|
330
|
+
};
|
|
331
|
+
const appendCommandResult = (
|
|
332
|
+
label: string,
|
|
333
|
+
content: string,
|
|
334
|
+
ok: boolean,
|
|
335
|
+
meta: { failureKind?: FailureKind; error?: string } = {},
|
|
336
|
+
): void => {
|
|
337
|
+
pi.appendEntry("pi-persona-result", {
|
|
338
|
+
label,
|
|
339
|
+
content,
|
|
340
|
+
ok,
|
|
341
|
+
...(meta.failureKind ? { failureKind: meta.failureKind } : {}),
|
|
342
|
+
...(meta.error ? { error: meta.error } : {}),
|
|
343
|
+
} satisfies CommandResultEntry);
|
|
344
|
+
};
|
|
345
|
+
const enabledSpineRoles = (): SpineLegacySelection => ({
|
|
346
|
+
supervisor: config.spine === "on",
|
|
347
|
+
worker: config.spineLegs === "on",
|
|
348
|
+
});
|
|
349
|
+
// The plugin's storage root shed its package-name prefix (see core/data-root.ts). Fold an
|
|
350
|
+
// existing `<agentDir>/pi-persona` into the persona data dir HERE, before anything opens the
|
|
351
|
+
// exocom rendezvous or the broker socket, so this Pi and its peers agree on one path for the
|
|
352
|
+
// whole session. Idempotent, so every later activation costs one missing-directory lstat.
|
|
353
|
+
const dataRootMigration: DataRootMigrationResult = ((): DataRootMigrationResult => {
|
|
354
|
+
try {
|
|
355
|
+
return migrateLegacyDataRoot(userAgentDir());
|
|
356
|
+
} catch (error) {
|
|
357
|
+
// The module reports per-entry failures rather than throwing; this is the backstop that
|
|
358
|
+
// keeps an unforeseen one out of activation, where it would cost the user the session.
|
|
359
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
360
|
+
return { moved: [], kept: [], warnings: [`data root migration failed: ${message}`] };
|
|
361
|
+
}
|
|
362
|
+
})();
|
|
363
|
+
const reportDataRootMigration = (ctx: ExtensionContext): void => {
|
|
364
|
+
if (dataRootMigration.moved.length > 0) {
|
|
365
|
+
const message = `pi-persona: moved ${dataRootMigration.moved.length} file(s) from the legacy pi-persona storage root into ${personaDataDir()}`;
|
|
366
|
+
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
367
|
+
else process.stderr.write(`${message}\n`);
|
|
368
|
+
}
|
|
369
|
+
for (const warning of dataRootMigration.warnings) {
|
|
370
|
+
if (ctx.hasUI) ctx.ui.notify(warning, "warning");
|
|
371
|
+
else process.stderr.write(`${warning}\n`);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
const migrateSeededDefaults = (): SeedMigrationResult => {
|
|
375
|
+
try {
|
|
376
|
+
return migratePristineSeededDefaults(BUNDLED_DIR, personaDataDir(), options.seedMigration);
|
|
377
|
+
} catch (error) {
|
|
378
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
379
|
+
return { migrated: [], installed: [], skipped: [], warnings: [`seed migration failed: ${message}`] };
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
// Version migrations are byte-exact and independent of first-run seeding. Run before the first
|
|
383
|
+
// definition discovery so a pristine old built-in can never become the active prompt for even
|
|
384
|
+
// one turn; custom bytes, unknown files and unsafe filesystem shapes remain untouched.
|
|
385
|
+
let seedMigration = migrateSeededDefaults();
|
|
386
|
+
const refreshSeedMigration = (): SeedMigrationResult => {
|
|
387
|
+
const current = migrateSeededDefaults();
|
|
388
|
+
seedMigration = {
|
|
389
|
+
migrated: [...new Set([...seedMigration.migrated, ...current.migrated])],
|
|
390
|
+
installed: [...new Set([...seedMigration.installed, ...current.installed])],
|
|
391
|
+
skipped: current.skipped,
|
|
392
|
+
warnings: current.warnings,
|
|
393
|
+
};
|
|
394
|
+
return current;
|
|
395
|
+
};
|
|
396
|
+
const reportSeedMigration = (ctx: ExtensionContext, result: SeedMigrationResult): void => {
|
|
397
|
+
if (result.migrated.length > 0) {
|
|
398
|
+
const message = `pi-persona: upgraded ${result.migrated.length} exact pristine seeded default(s): ${result.migrated.join(", ")}`;
|
|
399
|
+
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
400
|
+
else process.stderr.write(`${message}\n`);
|
|
401
|
+
}
|
|
402
|
+
if (result.installed.length > 0) {
|
|
403
|
+
const message = `pi-persona: installed ${result.installed.length} required default dependency file(s): ${result.installed.join(", ")}`;
|
|
404
|
+
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
405
|
+
else process.stderr.write(`${message}\n`);
|
|
406
|
+
}
|
|
407
|
+
for (const warning of result.warnings) {
|
|
408
|
+
if (ctx.hasUI) ctx.ui.notify(warning, "warning");
|
|
409
|
+
else process.stderr.write(`${warning}\n`);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
// v1.8.1 rewrote the spine pair, but `on` deliberately prefers a seeded user copy and the
|
|
413
|
+
// one-shot seed marker means a normal package upgrade never calls runSeed(). Detect ONLY the
|
|
414
|
+
// exact v1.8.0 bytes and bypass them during `on` resolution; never rewrite a user-controlled file
|
|
415
|
+
// automatically. Each role is inspected only when its selector consults that user-dir file.
|
|
416
|
+
const activationLegacy = inspectLegacySeededSpines(
|
|
417
|
+
personaDataDir(),
|
|
418
|
+
enabledSpineRoles(),
|
|
419
|
+
options.spineLegacyIO ?? {},
|
|
420
|
+
);
|
|
421
|
+
|
|
422
|
+
// The engine constructors THIS activation builds through (see PiPersonaOptions).
|
|
423
|
+
const engineFactories = options.engineFactories ?? DEFAULT_ENGINE_FACTORIES;
|
|
424
|
+
|
|
425
|
+
// The spine (docs/SPINE.md): the shared behavioral layer that sits between Pi's base prompt
|
|
426
|
+
// and the persona body, with the WORKER variant leading every delegated leg's prompt (a leg
|
|
427
|
+
// runs headless, so the supervisor text's "confirm before irreversible" would have it stall
|
|
428
|
+
// on a user it does not have). Resolved ONCE — a layer that could change mid-conversation
|
|
429
|
+
// would let two turns run under different rules; `/persona reload`, `seed`, and `restore` are
|
|
430
|
+
// the deliberate refresh gestures. Off by default ⇒
|
|
431
|
+
// both texts stay "" and every composition below is byte-identical to a pre-spine pi-persona.
|
|
432
|
+
const spineSources = (): SpineSources => ({
|
|
433
|
+
selector: config.spine,
|
|
434
|
+
workerSelector: config.spineLegs,
|
|
435
|
+
userPath: join(personaDataDir(), "spine.md"),
|
|
436
|
+
bundledPath: bundledSpinePath(),
|
|
437
|
+
workerUserPath: join(personaDataDir(), "spine.worker.md"),
|
|
438
|
+
workerBundledPath: bundledWorkerSpinePath(),
|
|
439
|
+
skipUserPath: spineLegacy.legacy.includes(join(personaDataDir(), "spine.md")),
|
|
440
|
+
skipWorkerUserPath: spineLegacy.legacy.includes(join(personaDataDir(), "spine.worker.md")),
|
|
441
|
+
read: readSpineFile,
|
|
442
|
+
});
|
|
443
|
+
let spineLegacy = activationLegacy;
|
|
444
|
+
let spine = resolveSpine(spineSources());
|
|
445
|
+
let spineText = spine.text ?? "";
|
|
446
|
+
let workerSpineText = spine.worker ?? "";
|
|
447
|
+
function resolveSpineNow(): void {
|
|
448
|
+
spine = resolveSpine(spineSources());
|
|
449
|
+
spineText = spine.text ?? "";
|
|
450
|
+
workerSpineText = spine.worker ?? "";
|
|
451
|
+
}
|
|
452
|
+
function inspectEnabledLegacySpines(): SpineLegacyResult {
|
|
453
|
+
const result = inspectLegacySeededSpines(
|
|
454
|
+
personaDataDir(),
|
|
455
|
+
enabledSpineRoles(),
|
|
456
|
+
options.spineLegacyIO ?? {},
|
|
457
|
+
);
|
|
458
|
+
spineLegacy = result;
|
|
459
|
+
resolveSpineNow();
|
|
460
|
+
return result;
|
|
461
|
+
}
|
|
462
|
+
function refreshSpineAfterSeed(): void {
|
|
463
|
+
inspectEnabledLegacySpines();
|
|
464
|
+
}
|
|
465
|
+
function currentSpineWarnings(): string[] {
|
|
466
|
+
return [...new Set([...spineLegacy.warnings, ...(spine.warning ? [spine.warning] : [])])];
|
|
467
|
+
}
|
|
468
|
+
/** A spine was asked for and something did not supply one: say so, then carry on without it.
|
|
469
|
+
* A missing prompt file must never cost the user their session. Emitted from the hook rather
|
|
470
|
+
* than at resolve time — the factory has no `ctx` yet. */
|
|
471
|
+
function reportSpineWarning(ctx: ExtensionContext): void {
|
|
472
|
+
for (const warning of currentSpineWarnings()) {
|
|
473
|
+
if (ctx.hasUI) ctx.ui.notify(warning, "warning");
|
|
474
|
+
else process.stderr.write(`${warning}\n`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// exocom (opt-in, T9): `--exocom` is a per-invocation convenience alongside PI_PERSONA_EXOCOM
|
|
479
|
+
// (config.exocom) — the flag declaration is inert unless either is on (see startExocom below).
|
|
480
|
+
pi.registerFlag("exocom", {
|
|
481
|
+
description: "Join Exocom for this workspace; use --exocom=Ab0T to join that workspace scope from another workspace (4-char case-sensitive Base62 code)",
|
|
482
|
+
type: "boolean",
|
|
483
|
+
default: false,
|
|
484
|
+
});
|
|
485
|
+
// `--persona <name>` — start with a persona active for this run, the CLI equivalent of
|
|
486
|
+
// PI_PERSONA_DEFAULT (which it overrides). e.g. `pi --persona my-supervisor`.
|
|
487
|
+
pi.registerFlag("persona", {
|
|
488
|
+
description: "Start with this persona active for the run (e.g. `--persona my-supervisor`) — overrides PI_PERSONA_DEFAULT and the remembered persona.",
|
|
489
|
+
type: "string",
|
|
490
|
+
default: "",
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
let lastCtx: ExtensionContext | undefined;
|
|
494
|
+
let disposed = false; // set on session_shutdown; gates late async-run callbacks of a torn-down instance
|
|
495
|
+
let orchestrating = false; // re-entrancy guard for the mandatory input hook
|
|
496
|
+
let processingDeferredOrchestration = false;
|
|
497
|
+
const MAX_DEFERRED_ORCHESTRATIONS = 16;
|
|
498
|
+
const deferredOrchestrations: Array<{ task: string; orchestration: OrchestrationGrammar }> = [];
|
|
499
|
+
// A finished mandatory orchestration, injected (hidden) into the next turn's system prompt.
|
|
500
|
+
type MandatoryOutcome = {
|
|
501
|
+
label: string;
|
|
502
|
+
output: string;
|
|
503
|
+
ok: boolean;
|
|
504
|
+
error?: string;
|
|
505
|
+
failureKind?: FailureKind;
|
|
506
|
+
};
|
|
507
|
+
let pendingOrchestration: MandatoryOutcome | undefined;
|
|
508
|
+
|
|
509
|
+
// The unified live tree of every in-flight agent — strategy cores, delegate
|
|
510
|
+
// sub-agents, dynamic specialists — rendered as one sticky widget above the input.
|
|
511
|
+
const agentTree = new AgentTree();
|
|
512
|
+
// Explicit telemetry is the public observability seam consumed by pi-persona-flow. It is
|
|
513
|
+
// session-scoped and starts only after persona + exocom reconciliation in session_start.
|
|
514
|
+
let telemetry: TelemetryProducer | undefined;
|
|
515
|
+
// callId → the open supervisor tool call. The NAME rides along because the close may happen at
|
|
516
|
+
// the turn boundary rather than on a tool_result, where the event that carries it is gone.
|
|
517
|
+
const telemetryToolStartedAt = new Map<string, { startedAt: number; name: string }>();
|
|
518
|
+
// ask envelope id → who asked, so a supervisor reply is attributed to the child it answers
|
|
519
|
+
// rather than to a "subagent" literal that names nobody. Bounded; cleared on teardown.
|
|
520
|
+
const telemetryAskSenders = new Map<string, string>();
|
|
521
|
+
const MAX_TRACKED_ASK_SENDERS = 256;
|
|
522
|
+
type PendingExocomTelemetry = { id: string; sessionId: string; target: string; size: number };
|
|
523
|
+
const telemetryExocomPending = new Map<string, PendingExocomTelemetry[]>();
|
|
524
|
+
// …and the same for intercom: both channels open at tool_call and close at tool_result, so both are
|
|
525
|
+
// left hanging when Pi resolves a call as "immediate" and never reaches a result.
|
|
526
|
+
type PendingIntercomTelemetry = { kind: string; to: string; size: number; replyTo?: string };
|
|
527
|
+
const telemetryIntercomPending = new Map<string, PendingIntercomTelemetry>();
|
|
528
|
+
function telemetryKind(node: AgentNode): TelemetryAgentKind {
|
|
529
|
+
if (node.kind) return node.kind;
|
|
530
|
+
if (node.id.startsWith("flow:")) return "flow";
|
|
531
|
+
if (node.id.startsWith("strategy:") || node.id.startsWith("council:")) return "council";
|
|
532
|
+
if (node.id.startsWith("delegate:")) return "delegate";
|
|
533
|
+
return "subagent";
|
|
534
|
+
}
|
|
535
|
+
function telemetryStatus(node: AgentNode): TelemetryAgentStatus {
|
|
536
|
+
if (node.status !== "running") return node.status;
|
|
537
|
+
// "queued" is a marker WE set (the seeded roster / a launched async run), so it is a real
|
|
538
|
+
// state. Nothing ever sets `detail` to a waiting state — it is a usage string, an error, or
|
|
539
|
+
// `toolActivity(name, args)`, which splices the tool's own argument text — so a substring
|
|
540
|
+
// test for "waiting" could only ever fire on a leg that is running normally.
|
|
541
|
+
if (isQueuedMarker(node.detail)) return "queued";
|
|
542
|
+
return "running";
|
|
543
|
+
}
|
|
544
|
+
function telemetryAgent(node: AgentNode): TelemetryAgentInput {
|
|
545
|
+
return {
|
|
546
|
+
id: node.id,
|
|
547
|
+
label: node.label,
|
|
548
|
+
kind: telemetryKind(node),
|
|
549
|
+
status: telemetryStatus(node),
|
|
550
|
+
...(node.parentId !== undefined ? { parentId: node.parentId } : {}),
|
|
551
|
+
...(node.agent !== undefined ? { agent: node.agent } : {}),
|
|
552
|
+
...(node.model !== undefined ? { model: node.model } : {}),
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
/** Who an intercom action actually addresses. A REPLY carries no `to` — it goes back to the
|
|
556
|
+
* child whose ask it answers — so the "subagent" literal it would otherwise fall back to names
|
|
557
|
+
* nobody and merges every child's replies into one node. */
|
|
558
|
+
function intercomRecipient(input: Record<string, unknown>, askId: string | undefined): string {
|
|
559
|
+
const asked = askId !== undefined ? telemetryAskSenders.get(askId) : undefined;
|
|
560
|
+
if (asked) return asked;
|
|
561
|
+
return typeof input.to === "string" && input.to ? input.to : "subagent";
|
|
562
|
+
}
|
|
563
|
+
function publishAgentTool(agentId: string, event: ToolEvent): void {
|
|
564
|
+
const callId = `${agentId}/${event.callId}`;
|
|
565
|
+
telemetry?.publish(event.phase === "start" ? "tool.started" : "tool.finished", {
|
|
566
|
+
callId,
|
|
567
|
+
agentId,
|
|
568
|
+
name: event.name,
|
|
569
|
+
status: event.phase === "start" ? "running" : event.failed ? "failed" : "done",
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// node id → the last projection published for it. The tree emits "updated" whenever `detail` or
|
|
574
|
+
// `output` moves — every streamed token delta — while the projection keeps neither, so an
|
|
575
|
+
// unfiltered publish appends one byte-identical duplicate per chunk: a 5k-chunk leg writes
|
|
576
|
+
// ~1.5MB of them and evicts genuine history from the producer's file budget.
|
|
577
|
+
const lastAgentProjection = new Map<string, string>();
|
|
578
|
+
function publishAgentTreeChange(change: AgentTreeChange): void {
|
|
579
|
+
const producer = telemetry;
|
|
580
|
+
if (!producer) return;
|
|
581
|
+
if (change.type === "added") {
|
|
582
|
+
lastAgentProjection.set(change.node.id, JSON.stringify(telemetryAgent(change.node)));
|
|
583
|
+
producer.publishAgentAdded(telemetryAgent(change.node));
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
586
|
+
if (change.type === "updated") {
|
|
587
|
+
const projection = JSON.stringify(telemetryAgent(change.node));
|
|
588
|
+
if (lastAgentProjection.get(change.node.id) === projection) return;
|
|
589
|
+
lastAgentProjection.set(change.node.id, projection);
|
|
590
|
+
producer.publishAgentUpdated(change.node.id, telemetryAgent(change.node));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (change.type === "removed") {
|
|
594
|
+
for (const node of change.nodes) {
|
|
595
|
+
lastAgentProjection.delete(node.id);
|
|
596
|
+
const status = node.status === "running" ? "stopped" : telemetryStatus(node);
|
|
597
|
+
producer.publish("agent.removed", { id: node.id, status });
|
|
598
|
+
}
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
lastAgentProjection.clear();
|
|
602
|
+
producer.publish("agent.cleared", {});
|
|
603
|
+
}
|
|
604
|
+
agentTree.onChange(publishAgentTreeChange);
|
|
605
|
+
// Delegation nudge: watches the supervisor's OWN tool-result stream and reminds it to hand off
|
|
606
|
+
// when it grinds heavy work by hand (config.nudge; gated to delegating personas at the hook).
|
|
607
|
+
const delegationNudge = new DelegationNudge();
|
|
608
|
+
// Persistence nudge: the counterweight — when a delegated leg comes back BLOCKED/UNKNOWN, remind
|
|
609
|
+
// the supervisor not to bank a premature surrender (same config.nudge gate + hook as above).
|
|
610
|
+
const persistenceNudge = new PersistenceNudge();
|
|
611
|
+
// Every visible run (strategy, council, flow) gets its own tree root — see makeRootIdAllocator.
|
|
612
|
+
const nextRootId = makeRootIdAllocator();
|
|
613
|
+
// node id → abort that one agent (so the overlay can STOP a single sub-agent).
|
|
614
|
+
const stopRegistry = new Map<string, () => void>();
|
|
615
|
+
// A second stop is an explicit force-clear request. Keep the real cancel handle until the
|
|
616
|
+
// run settles so that retry can signal the engine again before force-settling the UI tracker.
|
|
617
|
+
const stopRequested = new Set<string>();
|
|
618
|
+
const clearStops = (prefix: string): void => {
|
|
619
|
+
for (const k of [...stopRegistry.keys()]) {
|
|
620
|
+
if (k === prefix || k.startsWith(`${prefix}/`)) {
|
|
621
|
+
stopRegistry.delete(k);
|
|
622
|
+
stopRequested.delete(k);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
function stopAgent(nodeId: string): boolean {
|
|
627
|
+
const fn = stopRegistry.get(nodeId);
|
|
628
|
+
if (!fn) return false;
|
|
629
|
+
fn();
|
|
630
|
+
agentTree.update(nodeId, { detail: "stopping…" }); // visible at once; the run's own settle path writes the terminal status
|
|
631
|
+
flushAgentWidgetRender();
|
|
632
|
+
stopRequested.add(nodeId);
|
|
633
|
+
steerRegistry.delete(nodeId); // a hard-stopped agent is no longer steerable (mirror the strategy path)
|
|
634
|
+
return true;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// node id → steer that one agent (in-process engine only): inject a live user message.
|
|
638
|
+
const steerRegistry = new Map<string, SteerFn>();
|
|
639
|
+
const clearSteers = (prefix: string): void => {
|
|
640
|
+
for (const k of [...steerRegistry.keys()]) if (k === prefix || k.startsWith(`${prefix}/`)) steerRegistry.delete(k);
|
|
641
|
+
};
|
|
642
|
+
function steerAgent(nodeId: string, text: string): boolean {
|
|
643
|
+
const fn = steerRegistry.get(nodeId);
|
|
644
|
+
if (!fn || !text.trim()) return false;
|
|
645
|
+
try {
|
|
646
|
+
const delivered = fn(text);
|
|
647
|
+
return delivered !== false;
|
|
648
|
+
} catch {
|
|
649
|
+
// the handle may point at a just-finished/disposed session — treat as "not steerable"
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// The live count of agents in flight (leaf cores/legs), published as a status so a
|
|
655
|
+
// custom UI (e.g. pi-1337's frame) can show "N agents" — covers strategy/council
|
|
656
|
+
// cores too, which pi-1337's own delegate-only counter misses.
|
|
657
|
+
const WIDGET_RENDER_DELAY_MS = 16;
|
|
658
|
+
let widgetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
659
|
+
let widgetRenderPending = false;
|
|
660
|
+
let widgetLinesInitialized = false;
|
|
661
|
+
let lastWidgetLines: readonly string[] | undefined;
|
|
662
|
+
let widgetStatusInitialized = false;
|
|
663
|
+
let lastWidgetStatus: string | undefined;
|
|
664
|
+
const widgetNodeStatuses = new Map<string, AgentNodeStatus>();
|
|
665
|
+
function sameWidgetLines(a: readonly string[] | undefined, b: readonly string[] | undefined): boolean {
|
|
666
|
+
if (a === b) return true;
|
|
667
|
+
if (!a || !b || a.length !== b.length) return false;
|
|
668
|
+
return a.every((line, i) => line === b[i]);
|
|
669
|
+
}
|
|
670
|
+
function renderAgentWidget(nodes: readonly AgentNode[] = agentTree.snapshot()): void {
|
|
671
|
+
if (!lastCtx) return;
|
|
672
|
+
const empty = nodes.length === 0;
|
|
673
|
+
const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS });
|
|
674
|
+
const status = empty ? undefined : String(inFlightAgentCount(nodes));
|
|
675
|
+
try {
|
|
676
|
+
if (!widgetLinesInitialized || !sameWidgetLines(lastWidgetLines, lines)) {
|
|
677
|
+
lastCtx.ui.setWidget("persona-agents", lines, { placement: "aboveEditor" });
|
|
678
|
+
lastWidgetLines = lines;
|
|
679
|
+
widgetLinesInitialized = true;
|
|
680
|
+
}
|
|
681
|
+
} catch {
|
|
682
|
+
/* cosmetic — the widget is best-effort */
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
if (!widgetStatusInitialized || lastWidgetStatus !== status) {
|
|
686
|
+
lastCtx.ui.setStatus("persona-agents", status);
|
|
687
|
+
lastWidgetStatus = status;
|
|
688
|
+
widgetStatusInitialized = true;
|
|
689
|
+
}
|
|
690
|
+
} catch {
|
|
691
|
+
/* cosmetic */
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function renderAgentStatus(): void {
|
|
695
|
+
if (!lastCtx) return;
|
|
696
|
+
const nodes = agentTree.snapshot();
|
|
697
|
+
const status = nodes.length === 0 ? undefined : String(inFlightAgentCount(nodes));
|
|
698
|
+
try {
|
|
699
|
+
if (!widgetStatusInitialized || lastWidgetStatus !== status) {
|
|
700
|
+
lastCtx.ui.setStatus("persona-agents", status);
|
|
701
|
+
lastWidgetStatus = status;
|
|
702
|
+
widgetStatusInitialized = true;
|
|
703
|
+
}
|
|
704
|
+
} catch {
|
|
705
|
+
/* cosmetic */
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
/** Track status transitions in O(1), so output-only progress snapshots do not rescan the tree
|
|
709
|
+
* merely to rediscover the same published count. Structural changes still recompute the leaf
|
|
710
|
+
* count because adding/removing a child can change whether its running parent counts. */
|
|
711
|
+
function widgetStatusMayHaveChanged(change: AgentTreeChange): boolean {
|
|
712
|
+
if (change.type === "added") {
|
|
713
|
+
widgetNodeStatuses.set(change.node.id, change.node.status);
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
if (change.type === "updated") {
|
|
717
|
+
const previous = widgetNodeStatuses.get(change.node.id);
|
|
718
|
+
widgetNodeStatuses.set(change.node.id, change.node.status);
|
|
719
|
+
return previous !== change.node.status;
|
|
720
|
+
}
|
|
721
|
+
if (change.type === "removed") {
|
|
722
|
+
for (const node of change.nodes) widgetNodeStatuses.delete(node.id);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
widgetNodeStatuses.clear();
|
|
726
|
+
return true;
|
|
727
|
+
}
|
|
728
|
+
function cancelAgentWidgetRender(): void {
|
|
729
|
+
if (widgetTimer !== undefined) clearTimeout(widgetTimer);
|
|
730
|
+
widgetTimer = undefined;
|
|
731
|
+
widgetRenderPending = false;
|
|
732
|
+
}
|
|
733
|
+
function requestAgentWidgetRender(refreshStatus: boolean): void {
|
|
734
|
+
if (!lastCtx || disposed) return;
|
|
735
|
+
if (refreshStatus) renderAgentStatus();
|
|
736
|
+
widgetRenderPending = true;
|
|
737
|
+
if (widgetTimer !== undefined) return;
|
|
738
|
+
widgetTimer = setTimeout(() => {
|
|
739
|
+
widgetTimer = undefined;
|
|
740
|
+
if (!widgetRenderPending || disposed) {
|
|
741
|
+
widgetRenderPending = false;
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
widgetRenderPending = false;
|
|
745
|
+
renderAgentWidget();
|
|
746
|
+
}, WIDGET_RENDER_DELAY_MS);
|
|
747
|
+
widgetTimer.unref?.();
|
|
748
|
+
}
|
|
749
|
+
function flushAgentWidgetRender(): void {
|
|
750
|
+
if (!widgetRenderPending) return;
|
|
751
|
+
cancelAgentWidgetRender();
|
|
752
|
+
renderAgentWidget();
|
|
753
|
+
}
|
|
754
|
+
// All tree deltas share the host's 16ms frame cadence: a wide fan-out and a streamed output burst
|
|
755
|
+
// each produce at most one widget/status composition per frame. A one-second live clock keeps
|
|
756
|
+
// elapsed time and the stall badge moving without a progress event, and stops itself when idle.
|
|
757
|
+
const widgetClock = new LiveClock({ intervalMs: 1_000, isLive: () => agentTree.hasRunning(), onTick: renderAgentWidget });
|
|
758
|
+
agentTree.onChange((change) => {
|
|
759
|
+
const refreshStatus = widgetStatusMayHaveChanged(change);
|
|
760
|
+
if (disposed) {
|
|
761
|
+
// session_shutdown clears the tree after marking this activation disposed. Keep teardown
|
|
762
|
+
// synchronous so the old widget/status cannot leak into the next session.
|
|
763
|
+
cancelAgentWidgetRender();
|
|
764
|
+
renderAgentWidget();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
requestAgentWidgetRender(refreshStatus);
|
|
768
|
+
if (change.type === "added" || change.type === "updated") widgetClock.start();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
// The navigable agent overlay (f9 / /agents): ↑↓ navigate, ⏎ drill into an
|
|
772
|
+
// agent's output, esc back/close. Live — it re-renders as the tree changes.
|
|
773
|
+
async function openAgentOverlay(ctx: ExtensionContext): Promise<void> {
|
|
774
|
+
if (!ctx.hasUI || ctx.mode !== "tui") {
|
|
775
|
+
ctx.ui.notify("agents: the navigable overlay needs the interactive TUI", "warning");
|
|
776
|
+
return;
|
|
777
|
+
}
|
|
778
|
+
await ctx.ui.custom<void>(
|
|
779
|
+
(tui, theme, _kb, done) =>
|
|
780
|
+
new AgentOverlay(agentTree, tui, theme, () => done(undefined), {
|
|
781
|
+
onStop: stopAgent,
|
|
782
|
+
onSteer: steerAgent,
|
|
783
|
+
canSteer: (id) => steerRegistry.has(id),
|
|
784
|
+
canStop: (id) => stopRegistry.has(id),
|
|
785
|
+
stallMs: STALL_FLAG_MS,
|
|
786
|
+
}),
|
|
787
|
+
// Near-fullscreen: watching sub-agents work is a reading surface, not a popup.
|
|
788
|
+
{ overlay: true, overlayOptions: { width: "90%", maxHeight: "90%" } },
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
let personas: Persona[] = [];
|
|
792
|
+
let agents: AgentConfig[] = [];
|
|
793
|
+
let teams: Record<string, RosterMember[]> = {};
|
|
794
|
+
let contractDefs: Record<string, ContractDef> = {};
|
|
795
|
+
let shadowed: Array<{ name: string; scope: string; path: string }> = [];
|
|
796
|
+
let definitionCollisions: LoadResult["collisions"] = [];
|
|
797
|
+
let definitionWarnings: string[] = [];
|
|
798
|
+
let seedSourceCollisions: string[] = [];
|
|
799
|
+
|
|
800
|
+
// Remembered selection lives in the persona data folder; only user gestures write it.
|
|
801
|
+
const stateFile = config.stateFile ?? join(personaDataDir(), "state.json");
|
|
802
|
+
const readRememberedPersona = (): string | undefined => {
|
|
803
|
+
if (!config.persist) return undefined;
|
|
804
|
+
return readLastPersona(stateFile);
|
|
805
|
+
};
|
|
806
|
+
const persist = (name: string | undefined): void => {
|
|
807
|
+
if (config.persist) writeLastPersona(stateFile, name);
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
// Per-persona config (model assignments today, open-ended), indexed by persona name.
|
|
811
|
+
const configFile = join(personaDataDir(), "config.json");
|
|
812
|
+
const readConfigStore = (): PersonaConfigStore => {
|
|
813
|
+
return readPersonaConfigs(configFile);
|
|
814
|
+
};
|
|
815
|
+
let personaConfigs: PersonaConfigStore = {};
|
|
816
|
+
const modelsPrompted = new Set<string>(); // ask-once-per-session guard
|
|
817
|
+
|
|
818
|
+
// Personas/agents load ONLY from the user dir (populated by `/persona seed|restore`) and the
|
|
819
|
+
// project `.pi/agents`. The bundled defaults are a *seed source*, NOT a live discovery layer,
|
|
820
|
+
// so a fresh install shows no personas until the user opts in — if you don't want them, or want
|
|
821
|
+
// only your own, they simply aren't there. (Contracts/presets/teams keep a builtin layer below:
|
|
822
|
+
// they aren't "personas" and are needed by strategies once personas are installed.)
|
|
823
|
+
const defDirs = (cwd: string): ScopedDir[] => [
|
|
824
|
+
{ path: join(personaDataDir(), "agents"), scope: "user" },
|
|
825
|
+
{ path: join(cwd, ".pi", "agents"), scope: "project" },
|
|
826
|
+
...config.extraDirs.map((p) => ({ path: p, scope: "env" })),
|
|
827
|
+
];
|
|
828
|
+
const teamFiles = (cwd: string): string[] => [
|
|
829
|
+
join(BUNDLED_DIR, "teams.yaml"),
|
|
830
|
+
join(personaDataDir(), "teams.yaml"),
|
|
831
|
+
join(cwd, ".pi", "teams.yaml"),
|
|
832
|
+
];
|
|
833
|
+
const contractDirs = (cwd: string): ScopedDir[] => [
|
|
834
|
+
{ path: join(BUNDLED_DIR, "contracts"), scope: "builtin" },
|
|
835
|
+
{ path: join(personaDataDir(), "contracts"), scope: "user" },
|
|
836
|
+
{ path: join(cwd, ".pi", "contracts"), scope: "project" },
|
|
837
|
+
];
|
|
838
|
+
const presetDirs = (cwd: string): ScopedDir[] => [
|
|
839
|
+
{ path: join(BUNDLED_DIR, "presets"), scope: "builtin" },
|
|
840
|
+
{ path: join(personaDataDir(), "presets"), scope: "user" },
|
|
841
|
+
{ path: join(cwd, ".pi", "presets"), scope: "project" },
|
|
842
|
+
];
|
|
843
|
+
function reload(cwd: string): void {
|
|
844
|
+
const result = loadDefinitions(defDirs(cwd));
|
|
845
|
+
// Expand each persona's council `preset` into concrete strategy/roster/params.
|
|
846
|
+
const presets = loadPresets(presetDirs(cwd));
|
|
847
|
+
personas = result.personas.map((p) => (p.council?.preset ? { ...p, council: expandCouncilPreset(p.council, presets) } : p));
|
|
848
|
+
agents = result.agents;
|
|
849
|
+
shadowed = result.shadowed.map((f) => ({ name: f.name, scope: f.scope, path: f.path }));
|
|
850
|
+
definitionCollisions = result.collisions;
|
|
851
|
+
teams = loadTeams(teamFiles(cwd));
|
|
852
|
+
const contractsLoad = loadContracts(contractDirs(cwd));
|
|
853
|
+
contractDefs = contractsLoad.contracts;
|
|
854
|
+
definitionWarnings = [...result.warnings, ...contractsLoad.warnings];
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Install the bundled defaults into the user's agent dir — this is the ONLY way personas/agents
|
|
858
|
+
// become active (they are not a live builtin layer). `/persona seed` pulls in missing defaults,
|
|
859
|
+
// `/persona restore` force-overwrites them back to the originals. First-run auto-install is
|
|
860
|
+
// OPT-IN (PI_PERSONA_SEED=on) and happens once (guarded by the marker); by default nothing is
|
|
861
|
+
// installed. Best-effort — never block startup on a write error.
|
|
862
|
+
const seedMarker = (): string => join(personaDataDir(), ".pi-persona-seeded");
|
|
863
|
+
function runSeed(force: boolean): SeedResult {
|
|
864
|
+
const result = seedDefaults(BUNDLED_DIR, personaDataDir(), force);
|
|
865
|
+
seedSourceCollisions = result.collisions ?? [];
|
|
866
|
+
try {
|
|
867
|
+
mkdirSync(personaDataDir(), { recursive: true });
|
|
868
|
+
writeFileSync(seedMarker(), "pi-persona: bundled defaults seeded. Delete this file to re-seed on next start.\n");
|
|
869
|
+
} catch {
|
|
870
|
+
/* marker is best-effort */
|
|
871
|
+
}
|
|
872
|
+
return result;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function reportDefinitionCollisions(ctx: ExtensionContext): void {
|
|
876
|
+
if (definitionCollisions.length > 0) {
|
|
877
|
+
const names = definitionCollisions.map((collision) => collision.name).join(", ");
|
|
878
|
+
const message = `pi-persona: ${definitionCollisions.length} persona/agent definition collision(s) were omitted: ${names}. Run /doctor for source paths.`;
|
|
879
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
880
|
+
else process.stderr.write(`${message}\n`);
|
|
881
|
+
}
|
|
882
|
+
if (definitionWarnings.length > 0) {
|
|
883
|
+
const shown = definitionWarnings.slice(0, 5).join("; ");
|
|
884
|
+
const more = definitionWarnings.length > 5 ? ` (+${definitionWarnings.length - 5} more)` : "";
|
|
885
|
+
const message = `pi-persona: ${definitionWarnings.length} definition problem(s): ${shown}${more}. Run /doctor for details.`;
|
|
886
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
887
|
+
else process.stderr.write(`${message}\n`);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
function reportSeedSourceCollisions(ctx: ExtensionContext, result: SeedResult): void {
|
|
892
|
+
if (!result.collisions?.length) return;
|
|
893
|
+
const message = `pi-persona: bundled persona/agent name collision(s): ${result.collisions.join(", ")}. The persona file was seeded; the ambiguous agent was omitted.`;
|
|
894
|
+
if (ctx.hasUI) ctx.ui.notify(message, "warning");
|
|
895
|
+
else process.stderr.write(`${message}\n`);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
let monitors: MonitorSession | undefined;
|
|
899
|
+
const host: PersonaHost = {
|
|
900
|
+
allToolNames: () => {
|
|
901
|
+
try {
|
|
902
|
+
return pi.getAllTools().map((t) => t.name);
|
|
903
|
+
} catch {
|
|
904
|
+
return [];
|
|
905
|
+
}
|
|
906
|
+
},
|
|
907
|
+
knownAgents: () => agents.map((a) => a.name),
|
|
908
|
+
setActiveTools: (names) => {
|
|
909
|
+
try {
|
|
910
|
+
pi.setActiveTools(names);
|
|
911
|
+
} catch {
|
|
912
|
+
/* ignore */
|
|
913
|
+
}
|
|
914
|
+
monitors?.reconcilePermissions();
|
|
915
|
+
},
|
|
916
|
+
getThinkingLevel: () => {
|
|
917
|
+
try {
|
|
918
|
+
return pi.getThinkingLevel();
|
|
919
|
+
} catch {
|
|
920
|
+
return "medium";
|
|
921
|
+
}
|
|
922
|
+
},
|
|
923
|
+
setThinkingLevel: (level) => {
|
|
924
|
+
try {
|
|
925
|
+
// `level` is our local ThinkingLevel — a SUPERSET that may include an upstream level
|
|
926
|
+
// (e.g. `max`) the installed pi predates. Cast to pi's parameter type at the boundary:
|
|
927
|
+
// we can't statically match every pi version's union, and pi clamps an unknown level.
|
|
928
|
+
// (A wider level only ever reaches here on a pi that actually supports it, since it
|
|
929
|
+
// originates from pi.getThinkingLevel() — so the cast is safe in practice, not just typed.)
|
|
930
|
+
pi.setThinkingLevel(level as Parameters<typeof pi.setThinkingLevel>[0]);
|
|
931
|
+
} catch {
|
|
932
|
+
/* clamped/ignored */
|
|
933
|
+
}
|
|
934
|
+
},
|
|
935
|
+
getModel: () => {
|
|
936
|
+
const m = lastCtx?.model;
|
|
937
|
+
return m ? { provider: m.provider, id: m.id } : undefined;
|
|
938
|
+
},
|
|
939
|
+
findModel: (ref) => {
|
|
940
|
+
try {
|
|
941
|
+
const all = lastCtx?.modelRegistry.getAll() ?? [];
|
|
942
|
+
const m = all.find((x) => `${x.provider}/${x.id}` === ref || x.id === ref);
|
|
943
|
+
return m ? { provider: m.provider, id: m.id } : undefined;
|
|
944
|
+
} catch {
|
|
945
|
+
return undefined;
|
|
946
|
+
}
|
|
947
|
+
},
|
|
948
|
+
setModel: async (handle: ModelHandle) => {
|
|
949
|
+
try {
|
|
950
|
+
const all = lastCtx?.modelRegistry.getAll() ?? [];
|
|
951
|
+
const m = all.find((x) => x.provider === handle.provider && x.id === handle.id);
|
|
952
|
+
if (m) await pi.setModel(m);
|
|
953
|
+
} catch {
|
|
954
|
+
/* keep current */
|
|
955
|
+
}
|
|
956
|
+
},
|
|
957
|
+
setStatus: (text) => {
|
|
958
|
+
try {
|
|
959
|
+
lastCtx?.ui.setStatus("persona", text === undefined ? undefined : compactInlineText(text, { maxChars: 80 }));
|
|
960
|
+
} catch {
|
|
961
|
+
/* cosmetic */
|
|
962
|
+
}
|
|
963
|
+
},
|
|
964
|
+
warn: (message) => {
|
|
965
|
+
try {
|
|
966
|
+
if (lastCtx?.hasUI) lastCtx.ui.notify(message, "warning");
|
|
967
|
+
else process.stderr.write(`${message}\n`);
|
|
968
|
+
} catch {
|
|
969
|
+
/* cosmetic */
|
|
970
|
+
}
|
|
971
|
+
},
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
const controller = new PersonaController(host, config.delegateDefaultAllow);
|
|
975
|
+
|
|
976
|
+
// The semantic comm plane (guardrails §4.2/§4.9): the in-process bus carries a child's
|
|
977
|
+
// `contact_supervisor` messages to the supervisor (handle "supervisor"). Distinct from
|
|
978
|
+
// engine events (runtime) and the agent-tree ProgressView (derived). The `intercom` tool
|
|
979
|
+
// is the supervisor's read/reply side; coaching personas inject `contact_supervisor`.
|
|
980
|
+
const SUPERVISOR = "supervisor";
|
|
981
|
+
const bus = new InProcessBus();
|
|
982
|
+
bus.register(SUPERVISOR);
|
|
983
|
+
// Sub-agent output is UNTRUSTED: it surfaces to the supervisor as follow-up user turns and
|
|
984
|
+
// tool results, so a sub-agent could otherwise inject "ignore your instructions…". Fence it
|
|
985
|
+
// (core/fence.ts) in a tagged data block with a standing do-not-obey clause.
|
|
986
|
+
/** Drain child→supervisor messages into a compact block for a sync tool result (push). */
|
|
987
|
+
const drainBusBlock = (): string => {
|
|
988
|
+
const msgs = bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply);
|
|
989
|
+
return msgs.length > 0 ? `\n\n📨 from sub-agents:\n${fenceUntrusted(formatInbox(msgs))}` : "";
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
// Shared "deliver to the supervisor only when it is idle" plumbing (see IdleCoalescingNotifier).
|
|
993
|
+
// Both async-run completions and a child's blocking intercom ask flow through it so they reach
|
|
994
|
+
// the supervisor as a fresh turn instead of stranding as orphaned "sticky" follow-ups in pi's
|
|
995
|
+
// queue (pi only drains that queue from an active turn, one-at-a-time, skipping errored turns).
|
|
996
|
+
const idleDelivery = {
|
|
997
|
+
isIdle: () => canDeliverPersonaNotification(orchestrating, processingDeferredOrchestration, lastCtx?.isIdle?.() === true),
|
|
998
|
+
deliver: (message: string) => sendPersonaFollowUp(pi, message),
|
|
999
|
+
setTimer: (fn: () => void, ms: number) => {
|
|
1000
|
+
const h = setTimeout(fn, ms);
|
|
1001
|
+
h.unref?.();
|
|
1002
|
+
return h;
|
|
1003
|
+
},
|
|
1004
|
+
clearTimer: (h: unknown) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
1005
|
+
};
|
|
1006
|
+
// Async runs outlive the turn that launched them; on completion we surface the result(s) back as
|
|
1007
|
+
// ONE coalesced notice so the (idle) supervisor can react — e.g. retry a failure with another model.
|
|
1008
|
+
// With delegate now background-by-default (interactive), this is the COMMON delivery path, so the
|
|
1009
|
+
// premature-surrender counterweight must ride it too: a settled leg whose report carries a
|
|
1010
|
+
// [BLOCKED]/FLAG:UNKNOWN marker gets the persistence note appended (the sync tool_result hook can't
|
|
1011
|
+
// see a background run — its report arrives here as a fresh follow-up, not a delegate result).
|
|
1012
|
+
const completionNotifier = new IdleCoalescingNotifier<AsyncRun>({
|
|
1013
|
+
...idleDelivery,
|
|
1014
|
+
render: (runs) => {
|
|
1015
|
+
const report = renderCompletion(runs, fenceUntrusted, (t) => scanForSurrender(t));
|
|
1016
|
+
// Retention is a bound, not a promise: a fan-out wider than it settles more payloads than the
|
|
1017
|
+
// tracker can hold. Name the ids this very report points at that can no longer be fetched,
|
|
1018
|
+
// while their summaries are still in front of the supervisor — the alternative is a bare
|
|
1019
|
+
// "no retained async run" later, when nothing can be recovered.
|
|
1020
|
+
const dropped = runs.filter((r) => tracker.wasDropped(r.id)).map((r) => r.id);
|
|
1021
|
+
const overflow = buildRetentionOverflowNote(dropped, tracker.retention);
|
|
1022
|
+
return overflow ? boundCompletionSurface(`${report}\n\n${overflow}`) : report;
|
|
1023
|
+
},
|
|
1024
|
+
});
|
|
1025
|
+
// A child's blocking ask (decision/interview) — coalesced and idle-gated so it can't strand and
|
|
1026
|
+
// leave the child blocked until its 10-minute ask timeout (bus.ask default).
|
|
1027
|
+
const intercomNotifier = new IdleCoalescingNotifier<PendingAsk>({
|
|
1028
|
+
...idleDelivery,
|
|
1029
|
+
render: renderPendingAskBatch,
|
|
1030
|
+
});
|
|
1031
|
+
// A buffered question is actionable only while its bus ask is live. This also covers
|
|
1032
|
+
// timeout, cancellation and a departed child, without waiting for an intercom reply call.
|
|
1033
|
+
bus.onAskSettled(({ id }) => {
|
|
1034
|
+
intercomNotifier.discard((ask) => ask.askId === id);
|
|
1035
|
+
telemetryAskSenders.delete(id);
|
|
1036
|
+
});
|
|
1037
|
+
// Supervisor-armable alarms: when a timer expires it WAKES the session by routing the fire
|
|
1038
|
+
// through the same idle-delivery path (an idle delivery starts a fresh turn, so the supervisor
|
|
1039
|
+
// resumes on its own — no token-burning poll loop). Coalesced so several timers firing close
|
|
1040
|
+
// together arrive as one wake. The scheduler itself is the pure core kernel (src/core/timer.ts).
|
|
1041
|
+
const timerNotifier = new IdleCoalescingNotifier<TimerEntry>({
|
|
1042
|
+
...idleDelivery,
|
|
1043
|
+
render: (entries) => renderTimerFire(entries),
|
|
1044
|
+
});
|
|
1045
|
+
const timerScheduler = new TimerScheduler({
|
|
1046
|
+
now: () => Date.now(),
|
|
1047
|
+
setTimer: (fn, ms) => {
|
|
1048
|
+
const h = setTimeout(fn, ms);
|
|
1049
|
+
h.unref?.(); // never keep the host process alive just for a pending alarm
|
|
1050
|
+
return h;
|
|
1051
|
+
},
|
|
1052
|
+
clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
1053
|
+
onFire: (entry) => timerNotifier.notify(entry),
|
|
1054
|
+
});
|
|
1055
|
+
const canRunMonitor = () => {
|
|
1056
|
+
if (disposed) return false;
|
|
1057
|
+
const caps = controller.capabilities;
|
|
1058
|
+
const active = pi.getActiveTools();
|
|
1059
|
+
return active.includes("monitor") && active.includes("bash")
|
|
1060
|
+
&& (!caps || (canCallTool(caps, "monitor") && canCallTool(caps, "bash")));
|
|
1061
|
+
};
|
|
1062
|
+
monitors = createMonitorSession({
|
|
1063
|
+
isIdle: idleDelivery.isIdle,
|
|
1064
|
+
deliver: (text) => sendPersonaFollowUp(pi, text),
|
|
1065
|
+
canRun: canRunMonitor,
|
|
1066
|
+
});
|
|
1067
|
+
const tracker = new AsyncRunTracker({ maxRetained: config.asyncRetain });
|
|
1068
|
+
// The premature-surrender counterweight rides the SAME kill switch as the by-hand nudge
|
|
1069
|
+
// (PI_PERSONA_NUDGE=off). Both completion surfaces — the background follow-up and `intercom wait` —
|
|
1070
|
+
// go through here, so "off" means off wherever a settled leg is collected, not just on the
|
|
1071
|
+
// synchronous tool_result hook.
|
|
1072
|
+
const scanForSurrender = (text: string): string | undefined => (config.nudge ? persistenceNudge.scan(text) : undefined);
|
|
1073
|
+
// A run id that is neither live nor retained has two very different meanings: it never existed, or
|
|
1074
|
+
// its payload was evicted under retention pressure. Only the second deserves an explanation — and a
|
|
1075
|
+
// bare "no such run" for a result the supervisor was just TOLD to fetch is exactly the dead end
|
|
1076
|
+
// this distinction exists to prevent.
|
|
1077
|
+
function missingRunMessage(id: string, display: string | undefined): string {
|
|
1078
|
+
if (!tracker.wasDropped(id)) return `No retained async run "${display}".`;
|
|
1079
|
+
return (
|
|
1080
|
+
`Async run "${display}" settled, but its full payload is gone: more runs settled than the ${tracker.retention}-run retention bound keeps. ` +
|
|
1081
|
+
"Its summary was delivered in the completion follow-up. Raise PI_PERSONA_ASYNC_RETAIN, or fan out in narrower waves and collect as you go, to keep more."
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
// Turns the periodic peek from a poll into an exception signal: it surfaces a leg only when it
|
|
1085
|
+
// NEWLY crosses the stall window, so a healthy background run produces no wakeup at all.
|
|
1086
|
+
const peekWatcher = new PeekWatcher();
|
|
1087
|
+
tracker.onComplete((run) => {
|
|
1088
|
+
agentTree.update(`async:${run.id}`, { status: run.status, detail: run.error ?? run.result?.error ?? "" });
|
|
1089
|
+
agentTree.remove(`async:${run.id}`); // clear the async node from the tree on completion
|
|
1090
|
+
steerRegistry.delete(`async:${run.id}`); // its steer handle is dead once it finishes
|
|
1091
|
+
stopRegistry.delete(`async:${run.id}`); // …and so is its stop handle
|
|
1092
|
+
stopRequested.delete(`async:${run.id}`);
|
|
1093
|
+
if (disposed) return; // instance torn down — don't notify the next session or re-arm a cancelled timer
|
|
1094
|
+
// Immediate human feedback + one semantic completion. Explicit stops are informational,
|
|
1095
|
+
// while natural failures retain the error toast and every terminal result is still delivered.
|
|
1096
|
+
announceAsyncRunSettlement(
|
|
1097
|
+
run,
|
|
1098
|
+
lastCtx ? (message, level) => lastCtx?.ui.notify(message, level) : undefined,
|
|
1099
|
+
(settled) => completionNotifier.notify(settled),
|
|
1100
|
+
);
|
|
1101
|
+
if (tracker.running().length === 0) stopPeek(); // no live runs → stop the idle peek
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
// Event wake (default on): a child's BLOCKING question (decision/interview) surfaces at once
|
|
1105
|
+
// as a follow-up so the free (async) supervisor can answer it via the `intercom` tool.
|
|
1106
|
+
bus.onMessage((env) => {
|
|
1107
|
+
telemetry?.publish("message.received", {
|
|
1108
|
+
id: env.id,
|
|
1109
|
+
channel: "intercom",
|
|
1110
|
+
from: env.from,
|
|
1111
|
+
to: env.to,
|
|
1112
|
+
kind: env.kind,
|
|
1113
|
+
status: "delivered",
|
|
1114
|
+
expectsReply: env.expectsReply,
|
|
1115
|
+
size: Buffer.byteLength(env.text, "utf8"),
|
|
1116
|
+
});
|
|
1117
|
+
if (disposed || env.to !== SUPERVISOR || !env.expectsReply) return;
|
|
1118
|
+
// Only an ask can be replied to, so only an ask needs its sender remembered. Oldest first
|
|
1119
|
+
// out: a long session must not grow this without bound.
|
|
1120
|
+
if (telemetryAskSenders.size >= MAX_TRACKED_ASK_SENDERS) {
|
|
1121
|
+
const oldest = telemetryAskSenders.keys().next();
|
|
1122
|
+
if (!oldest.done) telemetryAskSenders.delete(oldest.value);
|
|
1123
|
+
}
|
|
1124
|
+
telemetryAskSenders.set(env.id, env.from);
|
|
1125
|
+
// Idle-gated so the ask reaches the (free) supervisor as a turn it can answer via the
|
|
1126
|
+
// intercom tool, rather than stranding mid-stream as a sticky follow-up.
|
|
1127
|
+
intercomNotifier.notify({
|
|
1128
|
+
askId: env.id,
|
|
1129
|
+
text: `[pi-persona] sub-agent ${sanitizeDisplayLabel(env.from)} needs a ${sanitizeDisplayLabel(env.kind, "decision", 32)}:\n\n${fenceUntrusted(env.text)}\n\nAnswer with the intercom tool: { action: "reply", askId: "${sanitizeDisplayLabel(env.id, "ask")}", message: "…" }`,
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
// Peek watchdog (the timed supervisor wakeup, on by default — PI_PERSONA_PEEK_MS=0 opts out): while
|
|
1134
|
+
// async children run, a tick checks their progress but stays SILENT unless there is something to act
|
|
1135
|
+
// on, so a healthy background run never interrupts the supervisor. It wakes on two signals: a leg
|
|
1136
|
+
// that NEWLY looks stalled or an unread message (fast, PI_PERSONA_PEEK_MS granularity), and a routine
|
|
1137
|
+
// direction check-in (slow, PI_PERSONA_CHECKIN_MS) to catch a leg going off-track. Bounded: unref'd,
|
|
1138
|
+
// self-stops when no runs remain.
|
|
1139
|
+
let peekTimer: ReturnType<typeof setInterval> | undefined;
|
|
1140
|
+
// When the supervisor was last woken about a run (a stall alert, a message, or a routine check-in).
|
|
1141
|
+
// Gates the slow check-in cadence so it counts from the last time we actually surfaced something.
|
|
1142
|
+
let lastPeekAt = 0;
|
|
1143
|
+
function stopPeek(): void {
|
|
1144
|
+
if (peekTimer) {
|
|
1145
|
+
clearInterval(peekTimer);
|
|
1146
|
+
peekTimer = undefined;
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
function startPeek(): void {
|
|
1150
|
+
// The two signals opt out independently: the fast stall/message wakeup follows PI_PERSONA_PEEK_MS,
|
|
1151
|
+
// the routine check-in follows PI_PERSONA_CHECKIN_MS. The carrier timer runs while EITHER is on and
|
|
1152
|
+
// ticks at the faster of the two (a disabled one is Infinity, so it never drives).
|
|
1153
|
+
const fastMs = config.peekEveryMs > 0 ? config.peekEveryMs : Number.POSITIVE_INFINITY;
|
|
1154
|
+
const checkMs = config.checkInEveryMs > 0 ? config.checkInEveryMs : Number.POSITIVE_INFINITY;
|
|
1155
|
+
const tickMs = Math.min(fastMs, checkMs);
|
|
1156
|
+
if (!Number.isFinite(tickMs) || peekTimer) return;
|
|
1157
|
+
lastPeekAt = Date.now(); // first routine check-in lands checkInEveryMs after the runs begin
|
|
1158
|
+
peekTimer = setInterval(() => {
|
|
1159
|
+
const runs = tracker.running();
|
|
1160
|
+
if (runs.length === 0) {
|
|
1161
|
+
stopPeek();
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
// Only peek a free, unqueued supervisor: an idle delivery triggers a clean turn, while a
|
|
1165
|
+
// busy one would pile up as a sticky follow-up. Skipping is safe — the next tick re-surfaces.
|
|
1166
|
+
if (!canDeliverPersonaNotification(orchestrating, processingDeferredOrchestration, lastCtx?.isIdle?.() === true) || lastCtx?.hasPendingMessages?.() === true) return;
|
|
1167
|
+
// The peek is NOT a poll. Two signals, two cadences: (1) the FAST wakeup (PI_PERSONA_PEEK_MS) —
|
|
1168
|
+
// a leg that NEWLY crossed the stall window, or an unread sub-agent message — the "is it dead or
|
|
1169
|
+
// wedged" check; (2) the SLOW routine check-in (PI_PERSONA_CHECKIN_MS) — a progress digest that
|
|
1170
|
+
// catches a leg going off-track (not stalled, just wrong) early. A healthy, quiet run between
|
|
1171
|
+
// check-ins produces no wakeup. Completions always arrive on their own (completionNotifier), and
|
|
1172
|
+
// the full status view stays on demand via `/peek`.
|
|
1173
|
+
const now = Date.now();
|
|
1174
|
+
const fast = config.peekEveryMs > 0;
|
|
1175
|
+
const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS) : [];
|
|
1176
|
+
// Drain only progress messages; blocking asks (expectsReply) are surfaced by the intercom
|
|
1177
|
+
// notifier and left for the `intercom inbox` tool — so peek never double-shows them.
|
|
1178
|
+
const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
|
|
1179
|
+
const dueCheckIn = config.checkInEveryMs > 0 && now - lastPeekAt >= config.checkInEveryMs;
|
|
1180
|
+
if (stuck.length === 0 && unread.length === 0 && !dueCheckIn) return; // healthy + quiet ⇒ stay silent
|
|
1181
|
+
const parts: string[] = [];
|
|
1182
|
+
if (stuck.length > 0) parts.push(buildPeekAlert(stuck, { now }));
|
|
1183
|
+
else if (dueCheckIn) parts.push(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS }));
|
|
1184
|
+
if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
|
|
1185
|
+
// Reset the check-in cadence only on a PROGRESS surfacing (a stall alert or a check-in), NOT on a
|
|
1186
|
+
// message-only wake — else a chatty child would postpone the routine off-track glance forever.
|
|
1187
|
+
if (stuck.length > 0 || dueCheckIn) lastPeekAt = now;
|
|
1188
|
+
try {
|
|
1189
|
+
sendPersonaFollowUp(pi, `[pi-persona] ${parts.join("\n\n")}`);
|
|
1190
|
+
} catch {
|
|
1191
|
+
/* ignore */
|
|
1192
|
+
}
|
|
1193
|
+
}, tickMs);
|
|
1194
|
+
peekTimer.unref?.();
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// Cross-process broker (v0.5, spec B1-B7): on by default so MCP/worktree/child-engine
|
|
1198
|
+
// legs expose steer. Off (`PI_PERSONA_BROKER=off`) ⇒ `broker.ensure`/`adapterDeps` are never
|
|
1199
|
+
// called, so `deps.broker` stays undefined and the child engine spawns byte-identical to
|
|
1200
|
+
// pre-broker pi-persona. Lifecycle (lazy start, failure warning, doctor, teardown) lives in
|
|
1201
|
+
// `SupervisorBroker` — see `extension/broker-host.ts`.
|
|
1202
|
+
const broker = new SupervisorBroker({
|
|
1203
|
+
bus,
|
|
1204
|
+
supervisorHandle: SUPERVISOR,
|
|
1205
|
+
warn: (message) => {
|
|
1206
|
+
let shown = false;
|
|
1207
|
+
if (lastCtx?.hasUI) {
|
|
1208
|
+
try { lastCtx.ui.notify(message, "warning"); shown = true; } catch { /* session may be tearing down */ }
|
|
1209
|
+
}
|
|
1210
|
+
if (!shown || process.env.PI_PERSONA_DEBUG) process.stderr.write(`[pi-persona] ${message}\n`);
|
|
1211
|
+
},
|
|
1212
|
+
});
|
|
1213
|
+
function makeBrokerDeps(ctx: ExtensionContext): EngineAdapterBroker | undefined {
|
|
1214
|
+
const sessionId = (ctx as ExtensionContext & { sessionManager?: { getSessionId?: () => string } }).sessionManager?.getSessionId?.() ?? "";
|
|
1215
|
+
return broker.adapterDeps(brokerEndpoint(sessionId));
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
const identity = installIdentity(pi, {
|
|
1219
|
+
capabilities: () => controller.capabilities,
|
|
1220
|
+
reservedNames: () => [...personas.flatMap((p) => [p.name, p.label]), ...agents.map((a) => a.name)],
|
|
1221
|
+
exocomActive: () => exocom.plane !== undefined,
|
|
1222
|
+
onChanged: (ctx) => {
|
|
1223
|
+
exocom.refreshIdentity();
|
|
1224
|
+
telemetry?.publish("instance.updated", exocom.currentTelemetryInstance(ctx));
|
|
1225
|
+
},
|
|
1226
|
+
});
|
|
1227
|
+
const exocom = installExocom(pi, {
|
|
1228
|
+
pi,
|
|
1229
|
+
identity,
|
|
1230
|
+
get config() { return config; },
|
|
1231
|
+
get controller() { return controller; },
|
|
1232
|
+
get lastCtx() { return lastCtx; },
|
|
1233
|
+
get disposed() { return disposed; },
|
|
1234
|
+
get orchestrating() { return orchestrating; },
|
|
1235
|
+
get processingDeferredOrchestration() { return processingDeferredOrchestration; },
|
|
1236
|
+
get telemetry() { return telemetry; },
|
|
1237
|
+
get delegationNudge() { return delegationNudge; },
|
|
1238
|
+
exocomArgs,
|
|
1239
|
+
userAgentDir,
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
const innerBuildEngine = createBuildEngine(() => ({
|
|
1243
|
+
agents,
|
|
1244
|
+
contractDefs,
|
|
1245
|
+
controller,
|
|
1246
|
+
host,
|
|
1247
|
+
config,
|
|
1248
|
+
personaConfigs,
|
|
1249
|
+
lastCtx,
|
|
1250
|
+
workerSpineText,
|
|
1251
|
+
engineFactories,
|
|
1252
|
+
makeBrokerDeps,
|
|
1253
|
+
userAgentDir,
|
|
1254
|
+
childPiSettingsEnv,
|
|
1255
|
+
runLimits: RUN_LIMITS,
|
|
1256
|
+
bus,
|
|
1257
|
+
supervisorHandle: SUPERVISOR,
|
|
1258
|
+
onFallback: (info) => {
|
|
1259
|
+
try {
|
|
1260
|
+
lastCtx?.ui.notify(`${info.agent}: ${info.from} failed, retrying on ${info.to}`, "warning");
|
|
1261
|
+
} catch {
|
|
1262
|
+
/* cosmetic — a toast must never break a run */
|
|
1263
|
+
}
|
|
1264
|
+
},
|
|
1265
|
+
}));
|
|
1266
|
+
const childUsage = new ChildUsageLedger();
|
|
1267
|
+
const publishPersonaCost = (): void => {
|
|
1268
|
+
try {
|
|
1269
|
+
lastCtx?.ui.setStatus(PERSONA_COST_STATUS_KEY, formatPersonaCostStatus(childUsage.pending()));
|
|
1270
|
+
} catch {
|
|
1271
|
+
/* cosmetic — the status is best-effort */
|
|
1272
|
+
}
|
|
1273
|
+
};
|
|
1274
|
+
const buildEngine: typeof innerBuildEngine = (signal, onProgress, engOpts) => {
|
|
1275
|
+
const engine = innerBuildEngine(signal, onProgress, engOpts);
|
|
1276
|
+
return {
|
|
1277
|
+
run: async (spec, progress, sig, steer) => {
|
|
1278
|
+
const result = await engine.run(spec, progress, sig, steer);
|
|
1279
|
+
childUsage.add(result.usage);
|
|
1280
|
+
publishPersonaCost();
|
|
1281
|
+
return result;
|
|
1282
|
+
},
|
|
1283
|
+
};
|
|
1284
|
+
};
|
|
1285
|
+
|
|
1286
|
+
async function ensurePersonaModels(ctx: ExtensionContext, roster: RosterMember[]): Promise<void> {
|
|
1287
|
+
const persona = controller.activePersona?.name;
|
|
1288
|
+
if (!persona || !ctx.hasUI) return;
|
|
1289
|
+
// A member that carries its own inline model needs no picked one; dedupe by agent name.
|
|
1290
|
+
const pickable = [...new Set(roster.map((m) => rosterSpec(m)).filter((s) => !s.model).map((s) => s.agent))];
|
|
1291
|
+
if (pickable.length < 2) return; // an ensemble of one distinct core can't be diversified
|
|
1292
|
+
if (modelsPrompted.has(persona)) return;
|
|
1293
|
+
const configured = personaConfigs[persona]?.models ?? {};
|
|
1294
|
+
const missing = pickable.filter((a) => !configured[a]);
|
|
1295
|
+
if (missing.length === 0) return;
|
|
1296
|
+
const available = configuredModels(ctx);
|
|
1297
|
+
if (available.length < 2) return; // can't diversify with a single configured model
|
|
1298
|
+
modelsPrompted.add(persona);
|
|
1299
|
+
const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
|
|
1300
|
+
const chosen: Record<string, string> = {};
|
|
1301
|
+
try {
|
|
1302
|
+
for (const agent of missing) {
|
|
1303
|
+
// Name the core's verticalization in the picker too: choosing a model for "the
|
|
1304
|
+
// Conservatore" is a different judgement than choosing one for a bare "balthasar".
|
|
1305
|
+
const purpose = corePurpose(agent);
|
|
1306
|
+
const title = `Model for "${agent}"${purpose ? ` (${purpose})` : ""} · ${persona}`;
|
|
1307
|
+
// In the TUI: a searchable picker (type to filter) whose viewport follows the
|
|
1308
|
+
// selection — the built-in select can't scroll a hundreds-long provider list
|
|
1309
|
+
// usefully. Outside the TUI (RPC), fall back to the built-in select.
|
|
1310
|
+
const pick =
|
|
1311
|
+
ctx.mode === "tui"
|
|
1312
|
+
? await ctx.ui.custom<string | undefined>(
|
|
1313
|
+
(tui, theme, _kb, done) =>
|
|
1314
|
+
new ModelPicker(tui, theme, title, options, ctx.model?.provider, (ref) => done(ref)),
|
|
1315
|
+
{ overlay: true },
|
|
1316
|
+
)
|
|
1317
|
+
: await ctx.ui.select(title, options);
|
|
1318
|
+
if (pick) chosen[agent] = pick;
|
|
1319
|
+
}
|
|
1320
|
+
} catch {
|
|
1321
|
+
/* dismissed / no UI → fall back to the default model */
|
|
1322
|
+
}
|
|
1323
|
+
if (Object.keys(chosen).length === 0) return;
|
|
1324
|
+
// The picks are live for this session either way (the in-memory store is updated first), so
|
|
1325
|
+
// a save failure costs only PERSISTENCE — but silently swallowing it would re-prompt on every
|
|
1326
|
+
// future session with no hint that the assignment never landed.
|
|
1327
|
+
personaConfigs = withPersonaModels(personaConfigs, persona, chosen);
|
|
1328
|
+
try {
|
|
1329
|
+
writePersonaConfigs(configFile, personaConfigs);
|
|
1330
|
+
} catch (err) {
|
|
1331
|
+
ctx.ui.notify(
|
|
1332
|
+
`pi-persona: could not save the model assignment for "${persona}" (${err instanceof Error ? err.message : String(err)}) — it applies to this session only.`,
|
|
1333
|
+
"error",
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** A core's declared verticalization ("Propulsore", "Critico") — the lens it argues from, so a
|
|
1339
|
+
* council reads as a set of ROLES rather than a set of names. Absent for an agent that declares
|
|
1340
|
+
* none, and the label simply omits it. */
|
|
1341
|
+
function corePurpose(agent: string): string | undefined {
|
|
1342
|
+
return agents.find((a) => a.name === agent)?.purpose;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** The session's own model, for a strategy's last-resort recovery of a member whose model broke
|
|
1346
|
+
* with no healthy peer to borrow from (`orchestration/model-retry.ts`). */
|
|
1347
|
+
function sessionModelDep(ctx: ExtensionContext): { sessionModel?: string } {
|
|
1348
|
+
return ctx.model ? { sessionModel: `${ctx.model.provider}/${ctx.model.id}` } : {};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// Each core's model beside its name: per-persona assignment → agent default → session.
|
|
1352
|
+
function coreModel(ctx: ExtensionContext, agent: string): string | undefined {
|
|
1353
|
+
const persona = controller.activePersona?.name;
|
|
1354
|
+
const configured = persona ? personaModels(personaConfigs, persona) : {};
|
|
1355
|
+
return configured[agent] ?? agents.find((a) => a.name === agent)?.model ?? (ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
|
|
1356
|
+
}
|
|
1357
|
+
function coreTelemetryMeta(ctx: ExtensionContext, agent: string): { agent: string; model?: string } {
|
|
1358
|
+
const model = coreModel(ctx, agent);
|
|
1359
|
+
return { agent, ...(model ? { model } : {}) };
|
|
1360
|
+
}
|
|
1361
|
+
function coreLabel(ctx: ExtensionContext, agent: string, key: string = agent): string {
|
|
1362
|
+
const short = shortModel(coreModel(ctx, agent));
|
|
1363
|
+
// `key` is the disambiguated node id (`agent` for a solo member, `agent · HINT` for a
|
|
1364
|
+
// roster-role one) — display that + what the core is FOR + the model, so a watcher reads the
|
|
1365
|
+
// council as roles rather than names, and three `reviewer` lenses still read distinctly.
|
|
1366
|
+
// A roster-ROLE member already carries its lens in `key` (`agent · HINT`) and its standing
|
|
1367
|
+
// purpose would repeat it — but a plain repeat (`melchior#2`) is the same core twice and
|
|
1368
|
+
// still wants its lens, so key off the role separator, not off `key !== agent`.
|
|
1369
|
+
const purpose = key.includes(" · ") ? undefined : corePurpose(agent);
|
|
1370
|
+
return [key, purpose, short].filter(Boolean).join(" · ");
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
|
|
1374
|
+
// `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
|
|
1375
|
+
// cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
|
|
1376
|
+
function strategyTreeDeps(ctx: ExtensionContext, rootId: string) {
|
|
1377
|
+
return {
|
|
1378
|
+
onAgentStart: (agent: string, abort: () => void, key?: string) => {
|
|
1379
|
+
stopRegistry.set(`${rootId}/${key ?? agent}`, abort);
|
|
1380
|
+
},
|
|
1381
|
+
onAgentSteerable: (agent: string, steer: SteerFn, key?: string) => {
|
|
1382
|
+
steerRegistry.set(`${rootId}/${key ?? agent}`, steer);
|
|
1383
|
+
},
|
|
1384
|
+
onAgentStatus: (agent: string, st: AgentStatus, result?: AgentResult, key?: string) => {
|
|
1385
|
+
const nodeKey = key ?? agent;
|
|
1386
|
+
const id = `${rootId}/${nodeKey}`;
|
|
1387
|
+
if (st === "running") {
|
|
1388
|
+
// detail "" clears the seeded "queued" marker — this core is actually live now.
|
|
1389
|
+
// Stamp the clock here too: the seed's stamp (from AgentTree.add's upsert) is queue
|
|
1390
|
+
// time, not run time, so a queued leg would otherwise show elapsed time it never
|
|
1391
|
+
// spent running and could raise a false stall badge once it goes live.
|
|
1392
|
+
const now = Date.now();
|
|
1393
|
+
agentTree.add({ id, label: coreLabel(ctx, agent, nodeKey), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, agent), detail: "", startedAt: now, lastAdvanceAt: now });
|
|
1394
|
+
return;
|
|
1395
|
+
}
|
|
1396
|
+
stopRegistry.delete(id);
|
|
1397
|
+
steerRegistry.delete(id);
|
|
1398
|
+
const patch: { status: AgentNodeStatus; detail?: string; output?: string } = { status: st };
|
|
1399
|
+
if (result) {
|
|
1400
|
+
const u = formatUsage(result.usage);
|
|
1401
|
+
if (u) patch.detail = u;
|
|
1402
|
+
if (result.output || result.structured) patch.output = compactMemberResult(result);
|
|
1403
|
+
}
|
|
1404
|
+
agentTree.update(id, patch);
|
|
1405
|
+
},
|
|
1406
|
+
onAgentProgress: (agent: string, p: AgentProgress, key?: string) => {
|
|
1407
|
+
const id = `${rootId}/${key ?? agent}`;
|
|
1408
|
+
if (p.toolEvent) publishAgentTool(id, p.toolEvent);
|
|
1409
|
+
agentTree.update(id, progressPatch(p, Date.now()));
|
|
1410
|
+
},
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
function strategySpawnGate(): { canSpawn?: (agent: string) => boolean } {
|
|
1415
|
+
const caps = controller.capabilities;
|
|
1416
|
+
return caps ? { canSpawn: (agent) => canDelegateTo(caps, agent) } : {};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// Run a persona strategy with the unified tree wired in: assign models on first run,
|
|
1420
|
+
// seed the roster (cores show by name at once), flip ⏳ → ✓/✗ live, clear when done.
|
|
1421
|
+
async function runStrategyVisible(
|
|
1422
|
+
ctx: ExtensionContext,
|
|
1423
|
+
orch: OrchestrationGrammar,
|
|
1424
|
+
task: string,
|
|
1425
|
+
idPrefix: string,
|
|
1426
|
+
signal?: AbortSignal,
|
|
1427
|
+
) {
|
|
1428
|
+
const label = resolveStrategyName(orch) ?? "strategy";
|
|
1429
|
+
const roster = orch.roster ? (teams[orch.roster] ?? []) : [];
|
|
1430
|
+
await ensurePersonaModels(ctx, roster);
|
|
1431
|
+
const rootId = nextRootId(`${idPrefix}:${label}`);
|
|
1432
|
+
agentTree.add({ id: rootId, label, status: "running", kind: "council" });
|
|
1433
|
+
// Seed the whole roster at once (cores show by name immediately); "queued" until the
|
|
1434
|
+
// engine actually starts each one — an honest view under the concurrency limit.
|
|
1435
|
+
const seedKeys = rosterNodeKeys(roster);
|
|
1436
|
+
roster.forEach((m, i) => {
|
|
1437
|
+
const a = rosterSpec(m).agent;
|
|
1438
|
+
const key = seedKeys[i] ?? a;
|
|
1439
|
+
agentTree.add({ id: `${rootId}/${key}`, label: coreLabel(ctx, a, key), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a), detail: "queued" });
|
|
1440
|
+
});
|
|
1441
|
+
try {
|
|
1442
|
+
// The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
|
|
1443
|
+
// cooperatively between rounds, so an aborted run stops convening instead of running every
|
|
1444
|
+
// remaining round against an already-cancelled engine (docs/STRATEGIES.md).
|
|
1445
|
+
const result = await runPersonaStrategy(orch, task, { engine: buildEngine(signal), teams, limits: RUN_LIMITS, ...sessionModelDep(ctx), ...(signal ? { signal } : {}), ...strategyTreeDeps(ctx, rootId), ...strategySpawnGate() });
|
|
1446
|
+
agentTree.update(rootId, { status: signal?.aborted ? "stopped" : result?.ok === false ? "failed" : "done" });
|
|
1447
|
+
return result ?? undefined;
|
|
1448
|
+
} catch (error) {
|
|
1449
|
+
agentTree.update(rootId, { status: signal?.aborted ? "stopped" : "failed" });
|
|
1450
|
+
throw error;
|
|
1451
|
+
} finally {
|
|
1452
|
+
clearStops(rootId);
|
|
1453
|
+
clearSteers(rootId);
|
|
1454
|
+
agentTree.remove(rootId);
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
// ── flows (v0.5): a DAG over strategies, discovered as *.flow.json ────────────
|
|
1459
|
+
const flowDirs = (cwd: string): string[] => [
|
|
1460
|
+
join(BUNDLED_DIR, "flows"),
|
|
1461
|
+
join(personaDataDir(), "flows"),
|
|
1462
|
+
join(cwd, ".pi", "flows"),
|
|
1463
|
+
];
|
|
1464
|
+
function listFlows(cwd: string): string[] {
|
|
1465
|
+
const names = new Set<string>();
|
|
1466
|
+
for (const dir of flowDirs(cwd)) {
|
|
1467
|
+
try {
|
|
1468
|
+
for (const f of readdirSync(dir)) if (f.endsWith(".flow.json")) names.add(f.replace(/\.flow\.json$/, ""));
|
|
1469
|
+
} catch {
|
|
1470
|
+
/* dir absent */
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
return [...names].sort();
|
|
1474
|
+
}
|
|
1475
|
+
function loadFlow(cwd: string, name: string): ReturnType<typeof parseFlow> | undefined {
|
|
1476
|
+
// Later dirs win (project > user > builtin) — keep the last readable file's content.
|
|
1477
|
+
let found: string | undefined;
|
|
1478
|
+
for (const dir of flowDirs(cwd)) {
|
|
1479
|
+
try {
|
|
1480
|
+
found = readFileSync(join(dir, `${name}.flow.json`), "utf8");
|
|
1481
|
+
} catch {
|
|
1482
|
+
/* not here */
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
if (found === undefined) return undefined;
|
|
1486
|
+
return parseFlow(found);
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Run a flow's DAG with the unified tree (phases as nodes, cores beneath) + journaled
|
|
1490
|
+
// resume: a prior run's journal (keyed by flow@hash) skips already-done phases; the
|
|
1491
|
+
// journal is cleared on a fully-successful run.
|
|
1492
|
+
async function runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, baseTask: string, signal?: AbortSignal): Promise<FlowOutcome> {
|
|
1493
|
+
const hash = flowHash(spec);
|
|
1494
|
+
const journalDir = join(personaDataDir(), "flows");
|
|
1495
|
+
try {
|
|
1496
|
+
mkdirSync(journalDir, { recursive: true });
|
|
1497
|
+
} catch {
|
|
1498
|
+
/* best effort */
|
|
1499
|
+
}
|
|
1500
|
+
const journalPath = join(journalDir, journalFileName(spec)); // encodes the name — any name is storable
|
|
1501
|
+
const resume = readJournal(journalPath, hash);
|
|
1502
|
+
|
|
1503
|
+
const rosterMembers = spec.phases.flatMap((p) => (p.roster ? (teams[p.roster] ?? []) : []));
|
|
1504
|
+
await ensurePersonaModels(ctx, rosterMembers);
|
|
1505
|
+
|
|
1506
|
+
const flowRoot = nextRootId(`flow:${spec.name}`);
|
|
1507
|
+
agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow" });
|
|
1508
|
+
for (const p of spec.phases) {
|
|
1509
|
+
const pid = `${flowRoot}/${p.id}`;
|
|
1510
|
+
const node: AddNodeInput = { id: pid, label: `${p.id} · ${p.strategy}`, parentId: flowRoot, status: resume[p.id] ? "done" : "running", kind: "phase" };
|
|
1511
|
+
if (resume[p.id]) node.detail = "resumed";
|
|
1512
|
+
agentTree.add(node);
|
|
1513
|
+
}
|
|
1514
|
+
try {
|
|
1515
|
+
// Yield so a concurrent flow can seed its own tree before we fail-closed on refs —
|
|
1516
|
+
// the check still runs BEFORE any phase/engine work.
|
|
1517
|
+
await Promise.resolve();
|
|
1518
|
+
const refs = verifyFlowRefs(spec, {
|
|
1519
|
+
strategies: strategyNames(),
|
|
1520
|
+
teams: new Set(Object.keys(teams)),
|
|
1521
|
+
});
|
|
1522
|
+
if (!refs.ok) {
|
|
1523
|
+
agentTree.update(flowRoot, { status: "failed", detail: refs.error });
|
|
1524
|
+
const failed: FlowOutcome = {
|
|
1525
|
+
ok: false,
|
|
1526
|
+
results: {},
|
|
1527
|
+
output: refs.error,
|
|
1528
|
+
error: refs.error,
|
|
1529
|
+
failureKind: "contract",
|
|
1530
|
+
};
|
|
1531
|
+
if (spec.phases[0]?.id) failed.failedPhase = spec.phases[0].id;
|
|
1532
|
+
return failed;
|
|
1533
|
+
}
|
|
1534
|
+
const outcome = await runFlow(spec, baseTask, {
|
|
1535
|
+
hash,
|
|
1536
|
+
resume,
|
|
1537
|
+
...(signal ? { signal } : {}),
|
|
1538
|
+
journal: journalWriter(journalPath),
|
|
1539
|
+
onPhase: (id, status, result) => {
|
|
1540
|
+
const patch: { status: AgentNodeStatus; output?: string; detail?: string } = {
|
|
1541
|
+
status: status === "running" ? "running" : status === "done" ? "done" : "failed",
|
|
1542
|
+
};
|
|
1543
|
+
if (result?.output) patch.output = result.output;
|
|
1544
|
+
if (result?.error) patch.detail = result.error;
|
|
1545
|
+
agentTree.update(`${flowRoot}/${id}`, patch);
|
|
1546
|
+
},
|
|
1547
|
+
runPhase: async ({ phase, task }) => {
|
|
1548
|
+
const pid = `${flowRoot}/${phase.id}`;
|
|
1549
|
+
const roster = phase.roster ? (teams[phase.roster] ?? []) : [];
|
|
1550
|
+
const seedKeys = rosterNodeKeys(roster);
|
|
1551
|
+
roster.forEach((m, i) => {
|
|
1552
|
+
const a = rosterSpec(m).agent;
|
|
1553
|
+
const key = seedKeys[i] ?? a;
|
|
1554
|
+
agentTree.add({ id: `${pid}/${key}`, label: coreLabel(ctx, a, key), parentId: pid, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a), detail: "queued" });
|
|
1555
|
+
});
|
|
1556
|
+
const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
|
|
1557
|
+
if (phase.roster) orch.roster = phase.roster;
|
|
1558
|
+
const r = await runPersonaStrategy(orch, task, {
|
|
1559
|
+
engine: buildEngine(signal),
|
|
1560
|
+
teams,
|
|
1561
|
+
limits: RUN_LIMITS,
|
|
1562
|
+
...sessionModelDep(ctx),
|
|
1563
|
+
...(signal ? { signal } : {}), // cooperative per-round abort inside the phase's strategy, not just the engine
|
|
1564
|
+
...strategyTreeDeps(ctx, pid),
|
|
1565
|
+
...strategySpawnGate(),
|
|
1566
|
+
});
|
|
1567
|
+
return r ?? { agent: phase.id, output: `unknown strategy: ${phase.strategy}`, usage: emptyUsage(), ok: false, error: "unknown strategy" };
|
|
1568
|
+
},
|
|
1569
|
+
// Checkpoint gate: only an explicit interactive approval may release dependents.
|
|
1570
|
+
// Headless, dismissal, and UI failure all reject: a human checkpoint is a safety
|
|
1571
|
+
// boundary, never an informational prompt. Approval is journaled for resume.
|
|
1572
|
+
approveGate: async (phase, result) => {
|
|
1573
|
+
if (!ctx.hasUI) return false;
|
|
1574
|
+
const preview = result.output.replace(/\s+/g, " ").slice(0, 160);
|
|
1575
|
+
try {
|
|
1576
|
+
const pick = await ctx.ui.select(`Checkpoint "${phase.id}" — approve and continue the flow?\n${preview}`, ["Approve", "Reject"]);
|
|
1577
|
+
return pick === "Approve";
|
|
1578
|
+
} catch {
|
|
1579
|
+
return false;
|
|
1580
|
+
}
|
|
1581
|
+
},
|
|
1582
|
+
});
|
|
1583
|
+
if (outcome.ok) {
|
|
1584
|
+
try {
|
|
1585
|
+
rmSync(journalPath, { force: true }); // clean journal once the whole flow succeeds
|
|
1586
|
+
} catch {
|
|
1587
|
+
/* ignore */
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
agentTree.update(flowRoot, { status: signal?.aborted ? "stopped" : outcome.ok ? "done" : "failed" });
|
|
1591
|
+
return outcome;
|
|
1592
|
+
} catch (error) {
|
|
1593
|
+
agentTree.update(flowRoot, { status: signal?.aborted ? "stopped" : "failed" });
|
|
1594
|
+
throw error;
|
|
1595
|
+
} finally {
|
|
1596
|
+
clearStops(flowRoot);
|
|
1597
|
+
clearSteers(flowRoot);
|
|
1598
|
+
agentTree.remove(flowRoot);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
// Search/list available models (provider/id), session provider first (★).
|
|
1603
|
+
function searchModels(ctx: ExtensionContext, query?: string): { lines: string[]; total: number; capped: boolean } {
|
|
1604
|
+
const session = ctx.model?.provider;
|
|
1605
|
+
const isSession = (ref: string): boolean => session !== undefined && ref.startsWith(`${session}/`);
|
|
1606
|
+
const all = configuredModels(ctx).map((m) => `${m.provider}/${m.id}`);
|
|
1607
|
+
const filtered = orderModelRefs(filterModels(all, query ?? ""), session);
|
|
1608
|
+
const cap = 40;
|
|
1609
|
+
const lines = filtered.slice(0, cap).map((r) => `${isSession(r) ? "★ " : " "}${r}`);
|
|
1610
|
+
return { lines, total: filtered.length, capped: filtered.length > cap };
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
// The per-turn delegation brief (core/brief.ts): live roster + standing hand-off default,
|
|
1614
|
+
// appended to the TAIL of the system prompt each turn — where a decayed top-of-prompt
|
|
1615
|
+
// persona line has no force, and regenerated from the live registry so it cannot desync.
|
|
1616
|
+
// Capability-aware: a persona that denies `delegate` gets none, and the agent list is
|
|
1617
|
+
// filtered to the persona's allowed targets.
|
|
1618
|
+
function delegationBrief(ctx: ExtensionContext): string | undefined {
|
|
1619
|
+
const caps = controller.capabilities;
|
|
1620
|
+
if (caps && !canFanOut(caps)) return undefined;
|
|
1621
|
+
const targets = caps ? agents.filter((a) => canDelegateTo(caps, a.name)) : agents;
|
|
1622
|
+
const delegation = controller.activePersona?.delegation;
|
|
1623
|
+
const teamAgents: Record<string, string[]> = {};
|
|
1624
|
+
for (const [name, members] of Object.entries(teams)) teamAgents[name] = members.map((m) => rosterSpec(m).agent);
|
|
1625
|
+
return buildDelegationBrief({
|
|
1626
|
+
agents: targets.map((a) => ({
|
|
1627
|
+
name: a.name,
|
|
1628
|
+
...(a.description ? { description: a.description } : {}),
|
|
1629
|
+
...(a.tools ? { tools: a.tools } : {}),
|
|
1630
|
+
...(a.excludeTools ? { excludeTools: a.excludeTools } : {}),
|
|
1631
|
+
...(a.mcp ? { mcp: true } : {}),
|
|
1632
|
+
...(a.isolation ? { isolation: a.isolation } : {}),
|
|
1633
|
+
})),
|
|
1634
|
+
teams: teamAgents,
|
|
1635
|
+
flows: listFlows(ctx.cwd),
|
|
1636
|
+
standing: controller.activePersona !== undefined,
|
|
1637
|
+
// Interactive sessions delegate in the background by default; headless (`pi -p`)
|
|
1638
|
+
// stays sync (the single turn must carry the result) — mirror that in the copy.
|
|
1639
|
+
asyncDefault: ctx.hasUI === true,
|
|
1640
|
+
...(delegation?.requireBrief !== undefined ? { requireBrief: delegation.requireBrief } : {}),
|
|
1641
|
+
...(delegation?.outputContract ? { outputContract: delegation.outputContract } : {}),
|
|
1642
|
+
...(delegation?.requireDisjointWrites !== undefined ? { requireDisjointWrites: delegation.requireDisjointWrites } : {}),
|
|
1643
|
+
...(delegation?.requireFreshVerification !== undefined ? { requireFreshVerification: delegation.requireFreshVerification } : {}),
|
|
1644
|
+
// The FULL registry count (pre capability-filter): lets the brief tell "nothing installed
|
|
1645
|
+
// yet (seed)" from "this persona's allowlist filtered every target away (widen it)" when
|
|
1646
|
+
// `targets` came back empty — otherwise a restrictive persona gets the fresh-install lie.
|
|
1647
|
+
installedCount: agents.length,
|
|
1648
|
+
});
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// `resolveStrategyName` fails loudly on a grammar that names a MODE without naming what to run
|
|
1652
|
+
// (`mode: strategy` with no `strategy:`, `mode: flow` with no `flow:`) — a persona-file error,
|
|
1653
|
+
// not a runtime one. Every entry point that calls it as a guard renders this instead of letting
|
|
1654
|
+
// it propagate out of a hook or command handler.
|
|
1655
|
+
function personaGrammarError(err: unknown): string {
|
|
1656
|
+
const who = controller.activePersona?.name ?? "(none)";
|
|
1657
|
+
return `pi-persona: persona "${who}" has an invalid orchestration — ${err instanceof Error ? err.message : String(err)}`;
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
function doctorReport(): string {
|
|
1661
|
+
const lines: string[] = [];
|
|
1662
|
+
lines.push(`pi-persona — active: ${controller.activePersona?.label ?? "none"}`);
|
|
1663
|
+
lines.push(`engine backend: ${config.engine === "child" ? "child-process" : "in-process"}`);
|
|
1664
|
+
const piVersion = installedPiVersion();
|
|
1665
|
+
lines.push(`pi: ${piVersion ?? "unknown"} (requires ≥ ${MIN_PI_VERSION})${piVersion && !satisfiesFloor(piVersion, MIN_PI_VERSION) ? " — BELOW FLOOR" : ""}`);
|
|
1666
|
+
const spineRole = (selector: string, source: string | undefined): string => {
|
|
1667
|
+
if (!selector) return "off";
|
|
1668
|
+
return source ? `${selector} -> ${source}` : `${selector} -> degraded`;
|
|
1669
|
+
};
|
|
1670
|
+
lines.push(`spine: supervisor=${spineRole(config.spine, spine.source)}; worker=${spineRole(config.spineLegs, spine.workerSource)}`);
|
|
1671
|
+
if (spineLegacy.legacy.length > 0) {
|
|
1672
|
+
lines.push(`spine legacy bypass: ${spineLegacy.legacy.join(", ")} (pristine v1.8.0 left untouched; using current bundle — /persona restore updates disk)`);
|
|
1673
|
+
}
|
|
1674
|
+
for (const warning of currentSpineWarnings()) {
|
|
1675
|
+
lines.push(warning.startsWith("spine legacy inspection warning:") ? warning : `spine warning: ${warning}`);
|
|
1676
|
+
}
|
|
1677
|
+
if (seedMigration.migrated.length > 0) {
|
|
1678
|
+
lines.push(`seed migration: upgraded ${seedMigration.migrated.join(", ")} (exact known-pristine defaults only)`);
|
|
1679
|
+
}
|
|
1680
|
+
if (seedMigration.installed.length > 0) {
|
|
1681
|
+
lines.push(`seed migration dependencies: installed ${seedMigration.installed.join(", ")}`);
|
|
1682
|
+
}
|
|
1683
|
+
for (const warning of seedMigration.warnings) {
|
|
1684
|
+
lines.push(warning.startsWith("seed migration") ? warning : `seed migration warning: ${warning}`);
|
|
1685
|
+
}
|
|
1686
|
+
if (dataRootMigration.moved.length > 0 || dataRootMigration.kept.length > 0) {
|
|
1687
|
+
lines.push(`data root migration: moved ${dataRootMigration.moved.length}, kept ${dataRootMigration.kept.length} (legacy pi-persona root folded into ${personaDataDir()})`);
|
|
1688
|
+
}
|
|
1689
|
+
for (const warning of dataRootMigration.warnings) lines.push(warning);
|
|
1690
|
+
lines.push(`personas (${personas.length}): ${personas.map((p) => p.name).join(", ") || "—"}`);
|
|
1691
|
+
lines.push(`agents (${agents.length}): ${agents.map((a) => a.name).join(", ") || "—"}`);
|
|
1692
|
+
if (definitionCollisions.length > 0) {
|
|
1693
|
+
lines.push(`definition collisions (${definitionCollisions.length}, omitted): ${definitionCollisions.map((collision) => collision.name).join(", ")}`);
|
|
1694
|
+
for (const collision of definitionCollisions) {
|
|
1695
|
+
lines.push(` - ${collision.name}: persona=${collision.persona.path}; agent=${collision.agent.path}`);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
if (definitionWarnings.length > 0) {
|
|
1699
|
+
lines.push(`definition warnings (${definitionWarnings.length}):`);
|
|
1700
|
+
for (const w of definitionWarnings) lines.push(` - ${w}`);
|
|
1701
|
+
}
|
|
1702
|
+
if (seedSourceCollisions.length > 0) {
|
|
1703
|
+
lines.push(`bundled seed collisions (${seedSourceCollisions.length}): ${seedSourceCollisions.join(", ")}`);
|
|
1704
|
+
}
|
|
1705
|
+
const teamNames = Object.keys(teams);
|
|
1706
|
+
lines.push(`teams (${teamNames.length}): ${teamNames.join(", ") || "—"}`);
|
|
1707
|
+
const flows = lastCtx ? listFlows(lastCtx.cwd) : [];
|
|
1708
|
+
lines.push(`flows (${flows.length}): ${flows.join(", ") || "—"}`);
|
|
1709
|
+
const contractNames = installedContractNames(contractDefs);
|
|
1710
|
+
lines.push(`contracts (${contractNames.length}): ${contractNames.join(", ")}`);
|
|
1711
|
+
if (shadowed.length > 0) {
|
|
1712
|
+
lines.push("shadowed (lower-precedence, overridden):");
|
|
1713
|
+
for (const s of shadowed) lines.push(` - ${s.name} [${s.scope}] ${s.path}`);
|
|
1714
|
+
}
|
|
1715
|
+
const caps = controller.capabilities;
|
|
1716
|
+
if (caps) {
|
|
1717
|
+
lines.push(`effective-capabilities: tools=${caps.tools.size}, delegate-targets=${caps.delegateTargets.size}, canFanOut=${canFanOut(caps)}`);
|
|
1718
|
+
}
|
|
1719
|
+
lines.push(`run limits: children≤${RUN_LIMITS.maxChildren}, concurrency≤${RUN_LIMITS.maxConcurrency}, timeout=${RUN_LIMITS.timeoutMs}ms`);
|
|
1720
|
+
lines.push("strategies:");
|
|
1721
|
+
for (const name of strategyNames()) {
|
|
1722
|
+
const schema = knownParams(name);
|
|
1723
|
+
const entries = schema
|
|
1724
|
+
? Object.entries(schema).map(([k, p]) => `${k} (${p.type}${p.default !== undefined ? `, default ${JSON.stringify(p.default)}` : ""})`)
|
|
1725
|
+
: [];
|
|
1726
|
+
const params = entries.length > 0 ? entries.join(", ") : "(no params)";
|
|
1727
|
+
lines.push(` - ${name}: ${params}`);
|
|
1728
|
+
}
|
|
1729
|
+
const coaching = controller.activePersona?.coaching ?? false;
|
|
1730
|
+
const peek = config.peekEveryMs > 0 ? `${config.peekEveryMs}ms` : "off";
|
|
1731
|
+
const checkIn = config.checkInEveryMs > 0 ? `${config.checkInEveryMs}ms` : "off";
|
|
1732
|
+
lines.push(`comm plane: coaching=${coaching ? "on (children get contact_supervisor)" : "off"}, peek-watchdog=${peek}, check-in=${checkIn}, bus-peers=${bus.participants().length}`);
|
|
1733
|
+
if (config.broker) lines.push(broker.doctorLine());
|
|
1734
|
+
return lines.join("\n");
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
const hookHost: HookHost = {
|
|
1738
|
+
identity,
|
|
1739
|
+
get config() { return config; },
|
|
1740
|
+
get controller() { return controller; },
|
|
1741
|
+
get personaHost() { return host; },
|
|
1742
|
+
get lastCtx() { return lastCtx; },
|
|
1743
|
+
set lastCtx(value) { lastCtx = value; },
|
|
1744
|
+
get disposed() { return disposed; },
|
|
1745
|
+
set disposed(value) { disposed = value; },
|
|
1746
|
+
get orchestrating() { return orchestrating; },
|
|
1747
|
+
set orchestrating(value) { orchestrating = value; },
|
|
1748
|
+
get processingDeferredOrchestration() { return processingDeferredOrchestration; },
|
|
1749
|
+
set processingDeferredOrchestration(value) { processingDeferredOrchestration = value; },
|
|
1750
|
+
get pendingOrchestration() { return pendingOrchestration; },
|
|
1751
|
+
set pendingOrchestration(value) { pendingOrchestration = value; },
|
|
1752
|
+
MAX_DEFERRED_ORCHESTRATIONS,
|
|
1753
|
+
deferredOrchestrations,
|
|
1754
|
+
get telemetry() { return telemetry; },
|
|
1755
|
+
set telemetry(value) { telemetry = value; },
|
|
1756
|
+
telemetryToolStartedAt,
|
|
1757
|
+
telemetryExocomPending,
|
|
1758
|
+
telemetryIntercomPending,
|
|
1759
|
+
lastAgentProjection,
|
|
1760
|
+
telemetryAskSenders,
|
|
1761
|
+
agentTree,
|
|
1762
|
+
telemetryAgent,
|
|
1763
|
+
delegationNudge,
|
|
1764
|
+
persistenceNudge,
|
|
1765
|
+
appendNudgeEntry,
|
|
1766
|
+
completionNotifier,
|
|
1767
|
+
intercomNotifier,
|
|
1768
|
+
timerNotifier,
|
|
1769
|
+
timerScheduler,
|
|
1770
|
+
monitors,
|
|
1771
|
+
peekWatcher,
|
|
1772
|
+
stopPeek,
|
|
1773
|
+
stopRegistry,
|
|
1774
|
+
stopRequested,
|
|
1775
|
+
steerRegistry,
|
|
1776
|
+
broker,
|
|
1777
|
+
get spineText() { return spineText; },
|
|
1778
|
+
delegationBrief,
|
|
1779
|
+
get agents() { return agents; },
|
|
1780
|
+
get personas() { return personas; },
|
|
1781
|
+
reload,
|
|
1782
|
+
readConfigStore,
|
|
1783
|
+
get personaConfigs() { return personaConfigs; },
|
|
1784
|
+
set personaConfigs(value) { personaConfigs = value; },
|
|
1785
|
+
readRememberedPersona,
|
|
1786
|
+
runSeed,
|
|
1787
|
+
seedMarker,
|
|
1788
|
+
personaDataDir,
|
|
1789
|
+
reportSeedSourceCollisions,
|
|
1790
|
+
refreshSpineAfterSeed,
|
|
1791
|
+
inspectEnabledLegacySpines,
|
|
1792
|
+
reportSeedMigration,
|
|
1793
|
+
get seedMigration() { return seedMigration; },
|
|
1794
|
+
reportDataRootMigration,
|
|
1795
|
+
reportSpineWarning,
|
|
1796
|
+
reportDefinitionCollisions,
|
|
1797
|
+
userAgentDir,
|
|
1798
|
+
loadFlow,
|
|
1799
|
+
runFlowVisible,
|
|
1800
|
+
runStrategyVisible,
|
|
1801
|
+
personaGrammarError,
|
|
1802
|
+
intercomRecipient,
|
|
1803
|
+
idleDelivery,
|
|
1804
|
+
drainBusBlock,
|
|
1805
|
+
childUsage,
|
|
1806
|
+
publishPersonaCost,
|
|
1807
|
+
};
|
|
1808
|
+
installHooks(pi, hookHost, exocom);
|
|
1809
|
+
|
|
1810
|
+
registerDelegateTool(pi, {
|
|
1811
|
+
get lastCtx() { return lastCtx; },
|
|
1812
|
+
set lastCtx(value) { lastCtx = value; },
|
|
1813
|
+
controller,
|
|
1814
|
+
get agents() { return agents; },
|
|
1815
|
+
contractNames: () => installedContractNames(contractDefs),
|
|
1816
|
+
buildEngine,
|
|
1817
|
+
agentTree,
|
|
1818
|
+
nextRootId,
|
|
1819
|
+
tracker,
|
|
1820
|
+
config: { ledgerV2: config.ledgerV2 },
|
|
1821
|
+
RUN_LIMITS,
|
|
1822
|
+
publishAgentTool,
|
|
1823
|
+
stopRegistry,
|
|
1824
|
+
steerRegistry,
|
|
1825
|
+
stopRequested,
|
|
1826
|
+
ensurePersonaModels,
|
|
1827
|
+
clearStops,
|
|
1828
|
+
clearSteers,
|
|
1829
|
+
drainBusBlock,
|
|
1830
|
+
startPeek,
|
|
1831
|
+
childUsage,
|
|
1832
|
+
publishPersonaCost,
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
// ── f8 cycle ────────────────────────────────────────────────────────────────
|
|
1836
|
+
const cycleShortcut = {
|
|
1837
|
+
description: "Cycle persona (pi-persona)",
|
|
1838
|
+
handler: async (ctx: ExtensionContext) => {
|
|
1839
|
+
lastCtx = ctx;
|
|
1840
|
+
if (personas.length === 0) return;
|
|
1841
|
+
const current = controller.activePersona;
|
|
1842
|
+
const idx = current ? personas.findIndex((p) => p.name === current.name) : -1;
|
|
1843
|
+
const next = idx + 1;
|
|
1844
|
+
if (next >= personas.length) {
|
|
1845
|
+
await controller.deactivate();
|
|
1846
|
+
persist(undefined);
|
|
1847
|
+
} else {
|
|
1848
|
+
await controller.activate(personas[next]!);
|
|
1849
|
+
persist(personas[next]!.name);
|
|
1850
|
+
}
|
|
1851
|
+
await exocom.onPersonaChanged(ctx); // the persona just changed — re-gate the bus, clear the by-hand run
|
|
1852
|
+
},
|
|
1853
|
+
};
|
|
1854
|
+
type KeyId = Parameters<ExtensionAPI["registerShortcut"]>[0];
|
|
1855
|
+
// PI_PERSONA_KEY is user-supplied; an unrecognised key must not break extension load. Try it,
|
|
1856
|
+
// and fall back to the default "f8" if Pi rejects it.
|
|
1857
|
+
try {
|
|
1858
|
+
pi.registerShortcut(config.keybinding as KeyId, cycleShortcut);
|
|
1859
|
+
} catch {
|
|
1860
|
+
try {
|
|
1861
|
+
pi.registerShortcut("f8" as KeyId, cycleShortcut);
|
|
1862
|
+
} catch {
|
|
1863
|
+
/* no shortcut available — /persona still works */
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
registerIntercomTool(pi, {
|
|
1868
|
+
get lastCtx() { return lastCtx; },
|
|
1869
|
+
set lastCtx(value) { lastCtx = value; },
|
|
1870
|
+
tracker,
|
|
1871
|
+
completionNotifier,
|
|
1872
|
+
intercomNotifier,
|
|
1873
|
+
controller,
|
|
1874
|
+
bus,
|
|
1875
|
+
SUPERVISOR,
|
|
1876
|
+
STALL_FLAG_MS,
|
|
1877
|
+
missingRunMessage,
|
|
1878
|
+
stopAgent,
|
|
1879
|
+
steerAgent,
|
|
1880
|
+
steerRegistry,
|
|
1881
|
+
stopRequested,
|
|
1882
|
+
drainBusBlock,
|
|
1883
|
+
scanForSurrender,
|
|
1884
|
+
get disposed() { return disposed; },
|
|
1885
|
+
childUsage,
|
|
1886
|
+
publishPersonaCost,
|
|
1887
|
+
});
|
|
1888
|
+
|
|
1889
|
+
registerTimerTool(pi, {
|
|
1890
|
+
get lastCtx() { return lastCtx; },
|
|
1891
|
+
set lastCtx(value) { lastCtx = value; },
|
|
1892
|
+
timerScheduler,
|
|
1893
|
+
});
|
|
1894
|
+
registerMonitorTool(pi, {
|
|
1895
|
+
get lastCtx() { return lastCtx; },
|
|
1896
|
+
set lastCtx(value) { lastCtx = value; },
|
|
1897
|
+
canRun: canRunMonitor,
|
|
1898
|
+
monitors,
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
registerCouncilTool(pi, {
|
|
1902
|
+
get lastCtx() { return lastCtx; },
|
|
1903
|
+
set lastCtx(value) { lastCtx = value; },
|
|
1904
|
+
controller,
|
|
1905
|
+
get personas() { return personas; },
|
|
1906
|
+
runStrategyVisible,
|
|
1907
|
+
drainBusBlock,
|
|
1908
|
+
childUsage,
|
|
1909
|
+
publishPersonaCost,
|
|
1910
|
+
});
|
|
1911
|
+
|
|
1912
|
+
registerFlowTool(pi, {
|
|
1913
|
+
get lastCtx() { return lastCtx; },
|
|
1914
|
+
set lastCtx(value) { lastCtx = value; },
|
|
1915
|
+
loadFlow,
|
|
1916
|
+
listFlows,
|
|
1917
|
+
runFlowVisible,
|
|
1918
|
+
childUsage,
|
|
1919
|
+
publishPersonaCost,
|
|
1920
|
+
});
|
|
1921
|
+
|
|
1922
|
+
// ── f9: navigable agent overlay ──────────────────────────────────────────────
|
|
1923
|
+
pi.registerShortcut("f9" as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
1924
|
+
description: "Open the navigable agent tree (pi-persona)",
|
|
1925
|
+
handler: async (ctx) => {
|
|
1926
|
+
lastCtx = ctx;
|
|
1927
|
+
await openAgentOverlay(ctx);
|
|
1928
|
+
},
|
|
1929
|
+
});
|
|
1930
|
+
pi.registerCommand("agents", {
|
|
1931
|
+
description: "Open the navigable agent tree overlay (↑↓ navigate, ⏎ open, esc close)",
|
|
1932
|
+
handler: async (_args, ctx) => {
|
|
1933
|
+
lastCtx = ctx;
|
|
1934
|
+
await openAgentOverlay(ctx);
|
|
1935
|
+
},
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
// ── /persona ─────────────────────────────────────────────────────────────────
|
|
1939
|
+
pi.registerCommand("persona", {
|
|
1940
|
+
description: "Switch the active supervisor persona: /persona [name|off|list|reload|seed|restore]",
|
|
1941
|
+
handler: async (args, ctx) => {
|
|
1942
|
+
lastCtx = ctx;
|
|
1943
|
+
const arg = args.trim();
|
|
1944
|
+
if (arg === "off" || arg === "none") {
|
|
1945
|
+
await controller.deactivate();
|
|
1946
|
+
persist(undefined);
|
|
1947
|
+
await exocom.onPersonaChanged(ctx); // the persona just changed — re-gate the bus, clear the by-hand run
|
|
1948
|
+
ctx.ui.notify("persona: cleared (default supervisor)", "info");
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
if (arg === "reload") {
|
|
1952
|
+
const activeName = controller.activePersona?.name;
|
|
1953
|
+
const migration = refreshSeedMigration();
|
|
1954
|
+
reload(ctx.cwd);
|
|
1955
|
+
// Resolve-once-per-session is what keeps two turns of one conversation under the
|
|
1956
|
+
// same rules; an explicit reload is the user asking for exactly this file to be
|
|
1957
|
+
// re-read, so the spine comes along with the personas.
|
|
1958
|
+
inspectEnabledLegacySpines();
|
|
1959
|
+
reportSpineWarning(ctx);
|
|
1960
|
+
reportSeedMigration(ctx, migration);
|
|
1961
|
+
reportDefinitionCollisions(ctx);
|
|
1962
|
+
if (activeName) {
|
|
1963
|
+
const fresh = personas.find((p) => p.name === activeName);
|
|
1964
|
+
if (fresh) await controller.activate(fresh);
|
|
1965
|
+
else await controller.deactivate();
|
|
1966
|
+
}
|
|
1967
|
+
await exocom.onPersonaChanged(ctx);
|
|
1968
|
+
ctx.ui.notify(`persona: reloaded ${personas.length} personas, ${agents.length} agents`, "info");
|
|
1969
|
+
return;
|
|
1970
|
+
}
|
|
1971
|
+
// seed = copy any MISSING bundled defaults into the user dir (pull new ones);
|
|
1972
|
+
// restore = force-overwrite them back to the bundled originals (discards your edits).
|
|
1973
|
+
if (arg === "seed" || arg === "restore") {
|
|
1974
|
+
const force = arg === "restore";
|
|
1975
|
+
const r = runSeed(force);
|
|
1976
|
+
reportSeedSourceCollisions(ctx, r);
|
|
1977
|
+
const migration = refreshSeedMigration();
|
|
1978
|
+
// These commands can create, migrate, preserve, or overwrite either prompt file. Refresh
|
|
1979
|
+
// both cached roles now so the very next supervisor turn and delegated leg agree without
|
|
1980
|
+
// requiring a second `/persona reload` or a process restart.
|
|
1981
|
+
refreshSpineAfterSeed();
|
|
1982
|
+
reportSpineWarning(ctx);
|
|
1983
|
+
reportSeedMigration(ctx, migration);
|
|
1984
|
+
reload(ctx.cwd);
|
|
1985
|
+
reportDefinitionCollisions(ctx);
|
|
1986
|
+
// Re-apply the active persona so a restored definition takes effect immediately.
|
|
1987
|
+
const active = controller.activePersona?.name;
|
|
1988
|
+
const fresh = active ? personas.find((p) => p.name === active) : undefined;
|
|
1989
|
+
if (fresh) await controller.activate(fresh);
|
|
1990
|
+
await exocom.onPersonaChanged(ctx); // a restored persona file may have changed canUseBus — re-gate; clear the by-hand run
|
|
1991
|
+
const kept = r.skipped.length > 0 ? `, kept ${r.skipped.length} existing` : "";
|
|
1992
|
+
ctx.ui.notify(`persona: ${force ? "restored" : "seeded"} ${r.copied.length} default(s) to ${userAgentDir()}${kept}.`, "info");
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
if (arg === "" || arg === "list") {
|
|
1996
|
+
// Empty by design on a fresh install: personas load only from the user dir, which the
|
|
1997
|
+
// user populates on purpose. Point them at the install gesture rather than leaving a
|
|
1998
|
+
// bare "(none)" that reads like something is broken.
|
|
1999
|
+
if (personas.length === 0) {
|
|
2000
|
+
ctx.ui.notify(
|
|
2001
|
+
"No personas installed. pi-persona does not auto-install — run `/persona seed` to copy the bundled defaults into your agent dir (edit them freely; `/persona restore` re-installs the originals).",
|
|
2002
|
+
"info",
|
|
2003
|
+
);
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
const lines = personas.map(
|
|
2007
|
+
(p) => `${p.name === controller.activePersona?.name ? "▶ " : " "}${p.label} (${p.name})`,
|
|
2008
|
+
);
|
|
2009
|
+
ctx.ui.notify(
|
|
2010
|
+
[`Personas (active: ${controller.activePersona?.label ?? "none"}):`, ...lines].join("\n"),
|
|
2011
|
+
"info",
|
|
2012
|
+
);
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
const persona = personas.find((p) => p.name === arg || p.label === arg);
|
|
2016
|
+
if (!persona) {
|
|
2017
|
+
ctx.ui.notify(`persona: "${arg}" not found. Try /persona list`, "error");
|
|
2018
|
+
return;
|
|
2019
|
+
}
|
|
2020
|
+
await controller.activate(persona);
|
|
2021
|
+
persist(persona.name);
|
|
2022
|
+
await exocom.onPersonaChanged(ctx); // the persona just changed — re-gate the bus, clear the by-hand run
|
|
2023
|
+
ctx.ui.notify(`persona: ${persona.label} active`, "info");
|
|
2024
|
+
},
|
|
2025
|
+
});
|
|
2026
|
+
|
|
2027
|
+
// ── /doctor ──────────────────────────────────────────────────────────────────
|
|
2028
|
+
pi.registerCommand("doctor", {
|
|
2029
|
+
description: "Explain pi-persona resolution (personas/agents/teams/shadowed)",
|
|
2030
|
+
handler: async (_args, ctx) => {
|
|
2031
|
+
lastCtx = ctx;
|
|
2032
|
+
ctx.ui.notify(doctorReport(), "info");
|
|
2033
|
+
},
|
|
2034
|
+
});
|
|
2035
|
+
|
|
2036
|
+
// ── /exocom (T9): the external peer-to-peer plane's live pool ─────────────────
|
|
2037
|
+
pi.registerCommand("exocom", {
|
|
2038
|
+
description: "Show the active Exocom scope, workspace code, and peers: /exocom",
|
|
2039
|
+
handler: async (_args, ctx) => {
|
|
2040
|
+
lastCtx = ctx;
|
|
2041
|
+
if (!exocom.plane) {
|
|
2042
|
+
ctx.ui.notify(
|
|
2043
|
+
"exocom: not active — use --exocom for this workspace or --exocom=Ab0T to join another workspace, with a persona that allows the bus (canUseBus).",
|
|
2044
|
+
"warning",
|
|
2045
|
+
);
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
exocom.renderWidget();
|
|
2049
|
+
const peers = exocom.plane.listPeers();
|
|
2050
|
+
const scope = exocom.scope;
|
|
2051
|
+
const lines = peers.map(
|
|
2052
|
+
(p) => {
|
|
2053
|
+
const name = sanitizePeerField(p.displayName, 48) || "peer";
|
|
2054
|
+
const persona = sanitizePeerField(p.persona, 48) || "—";
|
|
2055
|
+
const model = sanitizePeerField(p.model, 96) || "?";
|
|
2056
|
+
const contextPct = Number.isFinite(p.context_pct) ? Math.max(0, Math.min(100, Math.round(p.context_pct))) : 0;
|
|
2057
|
+
const workspaceId = p.workspace_id ?? scope?.scopeWorkspaceId;
|
|
2058
|
+
const workspaceCode = p.workspace_code ?? (scope && workspaceId === scope.scopeWorkspaceId ? scope.scopeCode : "?");
|
|
2059
|
+
const workspaceLabel = sanitizePeerField(
|
|
2060
|
+
p.workspace_label ?? (scope && workspaceId === scope.homeWorkspaceId ? scope.homeWorkspaceLabel : "workspace"),
|
|
2061
|
+
80,
|
|
2062
|
+
) || "workspace";
|
|
2063
|
+
const locality = workspaceId === scope?.homeWorkspaceId ? "same workspace" : "external workspace";
|
|
2064
|
+
return `• ${name} (${persona} · ${model}, ctx ${contextPct}%) · ${workspaceLabel} [${workspaceCode}] · ${locality}`;
|
|
2065
|
+
},
|
|
2066
|
+
);
|
|
2067
|
+
const scopeLine = scope
|
|
2068
|
+
? scope.joined
|
|
2069
|
+
? `joined scope [${scope.scopeCode}] · home ${scope.homeWorkspaceLabel} [${scope.homeWorkspaceCode}] · advisory for writes`
|
|
2070
|
+
: `workspace ${scope.homeWorkspaceLabel} [${scope.scopeCode}]`
|
|
2071
|
+
: "scope unavailable";
|
|
2072
|
+
const peerLines = peers.length === 0
|
|
2073
|
+
? `${scopeLine}\nno other peers in this scope right now`
|
|
2074
|
+
: `${scopeLine}\n${peers.length} peer${peers.length === 1 ? "" : "s"}:\n${lines.join("\n")}`;
|
|
2075
|
+
appendCommandResult(
|
|
2076
|
+
`exocom ${exocom.name}`,
|
|
2077
|
+
`${peerLines}\n${formatLedgerStatus(exocom.ledgerState(), exocom.sessionId, exocom.peerLabelFor, Date.now())}`,
|
|
2078
|
+
true,
|
|
2079
|
+
);
|
|
2080
|
+
},
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
// ── /models + a `models` tool — discover/search model ids ─────────────────────
|
|
2084
|
+
pi.registerCommand("models", {
|
|
2085
|
+
description: "List or search available models (provider/id): /models [query]",
|
|
2086
|
+
handler: async (args, ctx) => {
|
|
2087
|
+
lastCtx = ctx;
|
|
2088
|
+
const { lines, total, capped } = searchModels(ctx, args);
|
|
2089
|
+
if (lines.length === 0) {
|
|
2090
|
+
ctx.ui.notify(`models: no match for "${args.trim()}"`, "warning");
|
|
2091
|
+
return;
|
|
2092
|
+
}
|
|
2093
|
+
const head = `models (${total}${capped ? ", showing 40" : ""})${ctx.model ? ` · session ★ ${ctx.model.provider}/${ctx.model.id}` : ""}`;
|
|
2094
|
+
ctx.ui.notify(`${head}\n${lines.join("\n")}${capped ? "\n…refine with /models <query>" : ""}`, "info");
|
|
2095
|
+
},
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
registerModelsTool(pi, {
|
|
2099
|
+
get lastCtx() { return lastCtx; },
|
|
2100
|
+
set lastCtx(value) { lastCtx = value; },
|
|
2101
|
+
searchModels,
|
|
2102
|
+
});
|
|
2103
|
+
|
|
2104
|
+
// ── /peek (async run progress) ───────────────────────────────────────────────
|
|
2105
|
+
pi.registerCommand("peek", {
|
|
2106
|
+
description: "Show background async runs and their progress: /peek [runId]",
|
|
2107
|
+
handler: async (args, ctx) => {
|
|
2108
|
+
lastCtx = ctx;
|
|
2109
|
+
const id = args.trim();
|
|
2110
|
+
if (id) {
|
|
2111
|
+
const run = tracker.peek(id);
|
|
2112
|
+
ctx.ui.notify(run ? buildPeekDigest([run], { now: Date.now(), stallMs: STALL_FLAG_MS }) : `peek: no run "${id}"`, run ? "info" : "warning");
|
|
2113
|
+
} else {
|
|
2114
|
+
ctx.ui.notify(buildPeekDigest(tracker.list(), { now: Date.now(), stallMs: STALL_FLAG_MS }), "info");
|
|
2115
|
+
}
|
|
2116
|
+
},
|
|
2117
|
+
});
|
|
2118
|
+
|
|
2119
|
+
// ── /orchestrate (run the active persona's strategy) ──────────────────────────
|
|
2120
|
+
pi.registerCommand("orchestrate", {
|
|
2121
|
+
description: "Run the active persona's strategy on a task: /orchestrate <task>",
|
|
2122
|
+
handler: async (args, ctx) => {
|
|
2123
|
+
lastCtx = ctx;
|
|
2124
|
+
const task = args.trim();
|
|
2125
|
+
const orch = controller.activePersona?.orchestration;
|
|
2126
|
+
if (!task) {
|
|
2127
|
+
ctx.ui.notify("orchestrate: provide a task — /orchestrate <task>", "warning");
|
|
2128
|
+
return;
|
|
2129
|
+
}
|
|
2130
|
+
// A flow persona (`mode: flow`) is just as runnable — run its flow, like the input hook does.
|
|
2131
|
+
if (orch?.mode === "flow" && orch.flow) {
|
|
2132
|
+
const parsed = loadFlow(ctx.cwd, orch.flow);
|
|
2133
|
+
if (!parsed) {
|
|
2134
|
+
ctx.ui.notify(`orchestrate: no flow named "${orch.flow}"`, "error");
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
if (!parsed.ok) {
|
|
2138
|
+
ctx.ui.notify(`orchestrate: flow "${orch.flow}" is invalid: ${parsed.error}`, "error");
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
try {
|
|
2142
|
+
const outcome = await runFlowVisible(ctx, parsed.flow, task);
|
|
2143
|
+
appendCommandResult(`flow ${orch.flow}`, outcome.output || "(flow produced no output)", outcome.ok, {
|
|
2144
|
+
...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
|
|
2145
|
+
...(outcome.error ? { error: outcome.error } : {}),
|
|
2146
|
+
});
|
|
2147
|
+
} catch (err) {
|
|
2148
|
+
ctx.ui.notify(`orchestrate failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
2149
|
+
}
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
let strategyName: string | undefined;
|
|
2153
|
+
try {
|
|
2154
|
+
strategyName = orch ? resolveStrategyName(orch) : undefined;
|
|
2155
|
+
} catch (err) {
|
|
2156
|
+
ctx.ui.notify(personaGrammarError(err), "error");
|
|
2157
|
+
return;
|
|
2158
|
+
}
|
|
2159
|
+
if (!orch || !strategyName) {
|
|
2160
|
+
ctx.ui.notify("orchestrate: the active persona declares no runnable strategy/mode", "warning");
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
try {
|
|
2164
|
+
const result = await runStrategyVisible(ctx, orch, task, "orchestrate");
|
|
2165
|
+
appendCommandResult(
|
|
2166
|
+
`orchestrate ${strategyName}`,
|
|
2167
|
+
result?.output || "(no output)",
|
|
2168
|
+
result?.ok ?? false,
|
|
2169
|
+
{
|
|
2170
|
+
...(result?.failureKind ? { failureKind: result.failureKind } : {}),
|
|
2171
|
+
...(result?.error ? { error: result.error } : {}),
|
|
2172
|
+
},
|
|
2173
|
+
);
|
|
2174
|
+
} catch (err) {
|
|
2175
|
+
ctx.ui.notify(`orchestrate failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
2176
|
+
}
|
|
2177
|
+
},
|
|
2178
|
+
});
|
|
2179
|
+
|
|
2180
|
+
// ── /flow (run a *.flow.json DAG over strategies; journaled resume) ───────────
|
|
2181
|
+
pi.registerCommand("flow", {
|
|
2182
|
+
description: "Run a flow (DAG over strategies): /flow <name> <task> — or /flow to list",
|
|
2183
|
+
handler: async (args, ctx) => {
|
|
2184
|
+
lastCtx = ctx;
|
|
2185
|
+
const trimmed = args.trim();
|
|
2186
|
+
if (!trimmed || trimmed === "list") {
|
|
2187
|
+
const flows = listFlows(ctx.cwd);
|
|
2188
|
+
ctx.ui.notify(
|
|
2189
|
+
flows.length ? `flows: ${flows.join(", ")} · /flow <name> <task>` : "no flows — add a *.flow.json under .pi/flows/",
|
|
2190
|
+
"info",
|
|
2191
|
+
);
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
2194
|
+
const sp = trimmed.search(/\s/);
|
|
2195
|
+
const name = sp < 0 ? trimmed : trimmed.slice(0, sp);
|
|
2196
|
+
const task = sp < 0 ? "" : trimmed.slice(sp + 1).trim();
|
|
2197
|
+
if (!task) {
|
|
2198
|
+
ctx.ui.notify(`flow: provide a task — /flow ${name} <task>`, "warning");
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
const parsed = loadFlow(ctx.cwd, name);
|
|
2202
|
+
if (!parsed) {
|
|
2203
|
+
ctx.ui.notify(`flow: no flow named "${name}" (try /flow to list)`, "warning");
|
|
2204
|
+
return;
|
|
2205
|
+
}
|
|
2206
|
+
if (!parsed.ok) {
|
|
2207
|
+
ctx.ui.notify(`flow "${name}" is invalid: ${parsed.error}`, "error");
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
try {
|
|
2211
|
+
const outcome = await runFlowVisible(ctx, parsed.flow, task);
|
|
2212
|
+
appendCommandResult(`flow ${name}`, outcome.output || "(flow produced no output)", outcome.ok, {
|
|
2213
|
+
...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
|
|
2214
|
+
...(outcome.error ? { error: outcome.error } : {}),
|
|
2215
|
+
});
|
|
2216
|
+
} catch (err) {
|
|
2217
|
+
ctx.ui.notify(`flow failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
2218
|
+
}
|
|
2219
|
+
},
|
|
2220
|
+
});
|
|
2221
|
+
}
|