@aeondave/pi-persona 1.17.0 → 1.17.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +79 -29
- package/CHANGELOG.md +264 -0
- package/README.md +27 -12
- package/agents/architect.md +21 -0
- package/agents/balthasar.md +17 -22
- package/agents/casper.md +17 -23
- package/agents/evidence-verifier.md +31 -16
- package/agents/melchior.md +17 -22
- package/agents/operator.md +15 -55
- package/agents/research.md +14 -48
- package/agents/reviewer.md +20 -39
- package/agents/scout.md +14 -15
- package/agents/verifier.md +15 -16
- package/docs/ARCHITECTURE.md +223 -57
- package/docs/EXPERIENCE.md +19 -9
- package/docs/MONITORS.md +62 -3
- package/docs/REFERENCE.md +189 -35
- package/docs/SPINE.md +2 -2
- package/docs/STRATEGIES.md +23 -8
- package/docs/TELEMETRY.md +1 -0
- package/flows/deep-review.flow.json +1 -1
- package/package.json +9 -8
- package/personas/audit.md +28 -17
- package/personas/dev.md +30 -32
- package/personas/elite.md +223 -221
- package/personas/judge.md +25 -19
- package/personas/magi.md +26 -29
- package/personas/planner.md +31 -24
- package/personas/researcher.md +25 -26
- package/personas/swarm.md +35 -25
- package/personas/verify.md +30 -19
- package/src/agents/agent.ts +20 -0
- package/src/bridge.ts +3 -2
- package/src/core/brief.ts +429 -356
- package/src/core/config.ts +16 -11
- package/src/core/model-catalog.ts +171 -0
- package/src/core/model-tier.ts +89 -0
- package/src/core/models.ts +13 -4
- package/src/core/monitor-output.ts +85 -0
- package/src/core/monitor.ts +34 -2
- package/src/core/nudge.ts +176 -23
- package/src/core/seed.ts +99 -6
- package/src/core/session-runs.ts +87 -0
- package/src/core/settings.ts +251 -0
- package/src/core/timer.ts +34 -9
- package/src/engine/adapter.ts +12 -8
- package/src/engine/async.ts +195 -136
- package/src/engine/child.ts +1 -1
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +11 -6
- package/src/engine/stream.ts +3 -0
- package/src/exocom/install.ts +2 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/engine.ts +43 -16
- package/src/extension/hooks.ts +106 -36
- package/src/extension/persona-model-settings.ts +336 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension/shared.ts +15 -7
- package/src/extension.ts +642 -267
- package/src/monitor/archive.ts +235 -0
- package/src/monitor/session.ts +21 -1
- package/src/orchestration/flow-journal.ts +26 -5
- package/src/orchestration/flow-run.ts +38 -13
- package/src/orchestration/flow.ts +9 -2
- package/src/orchestration/reducers.ts +9 -5
- package/src/orchestration/render.ts +1 -1
- package/src/orchestration/sdk.ts +17 -3
- package/src/orchestration/strategies/compete.ts +5 -1
- package/src/orchestration/strategies/council-rounds.ts +2 -0
- package/src/orchestration/strategies/critic-loop.ts +3 -0
- package/src/orchestration/strategies/debate.ts +2 -0
- package/src/orchestration/strategies/fanout.ts +3 -2
- package/src/orchestration/strategies/judge.ts +5 -1
- package/src/orchestration/strategies/magi.ts +40 -23
- package/src/orchestration/strategies/map.ts +13 -3
- package/src/orchestration/strategies/pair.ts +2 -0
- package/src/orchestration/strategies/pipeline.ts +2 -0
- package/src/orchestration/strategies/synthesize.ts +27 -7
- package/src/orchestration/strategy.ts +31 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/persona.ts +7 -3
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/background-run.ts +151 -0
- package/src/tools/council.ts +77 -52
- package/src/tools/delegate-tool.ts +315 -285
- package/src/tools/delegate.ts +22 -210
- package/src/tools/exocom-work.ts +18 -53
- package/src/tools/exocom.ts +15 -19
- package/src/tools/flow.ts +85 -28
- package/src/tools/intercom-tool.ts +108 -118
- package/src/tools/intercom.ts +1 -1
- package/src/tools/models.ts +19 -9
- package/src/tools/monitor.ts +39 -6
- package/src/tools/timer.ts +11 -13
- package/src/ui/agent-overlay.ts +55 -14
- package/src/ui/agent-tree.ts +84 -18
- package/src/ui/compact-widget.ts +25 -0
- package/src/ui/failure-summary.ts +55 -0
- package/src/ui/literal-tool-result.ts +24 -0
- package/src/ui/model-picker.ts +158 -81
- package/src/ui/persona-settings.ts +328 -0
- package/src/ui/presentation.ts +24 -0
- package/src/ui/report-body.ts +63 -0
- package/src/ui/session-cards.ts +189 -0
- package/src/ui/wake-rows.ts +262 -0
- package/teams.yaml +5 -1
- package/src/persona/state.ts +0 -28
package/src/extension.ts
CHANGED
|
@@ -11,16 +11,18 @@
|
|
|
11
11
|
* isolation; see docs/ARCHITECTURE.md).
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import { mkdirSync, readdirSync, readFileSync,
|
|
14
|
+
import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { dirname, join, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
17
18
|
|
|
18
19
|
import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
19
|
-
import {
|
|
20
|
+
import { registerSessionCards } from "./ui/session-cards.ts";
|
|
20
21
|
|
|
21
22
|
import type { AgentConfig } from "./agents/agent.ts";
|
|
22
23
|
import { installBridge, isBridgeMode } from "./bridge.ts";
|
|
23
24
|
import { resolveConfig } from "./core/config.ts";
|
|
25
|
+
import { resolvePersonaConfig } from "./core/settings.ts";
|
|
24
26
|
import { type ContractDef, installedContractNames } from "./core/contract.ts";
|
|
25
27
|
import { type DataRootMigrationResult, migrateLegacyDataRoot } from "./core/data-root.ts";
|
|
26
28
|
import {
|
|
@@ -34,11 +36,14 @@ import {
|
|
|
34
36
|
type SpineLegacyResult,
|
|
35
37
|
type SpineLegacySelection,
|
|
36
38
|
} from "./core/seed.ts";
|
|
37
|
-
import { buildDelegationBrief } from "./core/brief.ts";
|
|
39
|
+
import { type BriefFlow, buildDelegationBrief } from "./core/brief.ts";
|
|
38
40
|
import { canCallTool, canDelegateTo, canFanOut, type RunLimits } from "./core/capabilities.ts";
|
|
39
41
|
import { fenceUntrusted } from "./core/fence.ts";
|
|
40
42
|
import { sanitizeDisplayLabel } from "./core/display-label.ts";
|
|
41
|
-
import { DelegationNudge, PersistenceNudge } from "./core/nudge.ts";
|
|
43
|
+
import { DelegationNudge, orderByMention, PersistenceNudge, PollGuard, renderNudgeModels, renderNudgeRoster } from "./core/nudge.ts";
|
|
44
|
+
import { orderModelCandidates, recommendModels, shortlistModels } from "./core/model-catalog.ts";
|
|
45
|
+
import { summarizeFailure } from "./ui/failure-summary.ts";
|
|
46
|
+
import { modelCandidateDetails, modelCostTiers, type ModelCostMetadata } from "./core/model-tier.ts";
|
|
42
47
|
import { installedPiVersion, MIN_PI_VERSION, satisfiesFloor } from "./core/pi-compat.ts";
|
|
43
48
|
import { type EngineAdapterBroker } from "./engine/adapter.ts";
|
|
44
49
|
import { SupervisorBroker } from "./extension/broker-host.ts";
|
|
@@ -52,7 +57,6 @@ import {
|
|
|
52
57
|
type PendingAsk,
|
|
53
58
|
} from "./extension/shared.ts";
|
|
54
59
|
export {
|
|
55
|
-
agentNodeStatusForDelegate,
|
|
56
60
|
boundExocomInboundBatch,
|
|
57
61
|
canDeliverPersonaNotification,
|
|
58
62
|
canonicalExocomTelemetryTargets,
|
|
@@ -77,25 +81,31 @@ export {
|
|
|
77
81
|
} from "./extension/shared.ts";
|
|
78
82
|
import { installExocom } from "./exocom/install.ts";
|
|
79
83
|
import { installIdentity } from "./extension/identity.ts";
|
|
84
|
+
import { registerPersonaSettings } from "./extension/persona-settings.ts";
|
|
85
|
+
import { openPersonaModelSettings } from "./extension/persona-model-settings.ts";
|
|
86
|
+
import { collectCompanionSettings } from "./extension/companion-settings.ts";
|
|
80
87
|
import { parseExocomArgv } from "./exocom/activation.ts";
|
|
81
88
|
import { formatLedgerStatus } from "./exocom/status.ts";
|
|
82
89
|
import { registerDelegateTool } from "./tools/delegate-tool.ts";
|
|
83
|
-
import { registerIntercomTool } from "./tools/intercom-tool.ts";
|
|
90
|
+
import { describeRuns, registerIntercomTool } from "./tools/intercom-tool.ts";
|
|
84
91
|
import { registerTimerTool } from "./tools/timer.ts";
|
|
85
|
-
import { registerMonitorTool } from "./tools/monitor.ts";
|
|
92
|
+
import { registerMonitorTool, renderMonitorLog } from "./tools/monitor.ts";
|
|
93
|
+
import { MonitorArchive } from "./monitor/archive.ts";
|
|
86
94
|
import { createMonitorSession, type MonitorSession } from "./monitor/session.ts";
|
|
95
|
+
import { createBackgroundRuns, type TrackedRun } from "./tools/background-run.ts";
|
|
87
96
|
import { registerCouncilTool } from "./tools/council.ts";
|
|
88
97
|
import { registerFlowTool } from "./tools/flow.ts";
|
|
89
98
|
import { registerModelsTool } from "./tools/models.ts";
|
|
90
|
-
import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn,
|
|
99
|
+
import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn, buildPeekDigest, buildRetentionOverflowNote, IdleCoalescingNotifier, PeekWatcher, renderCompletion, STALL_FLAG_MS } from "./engine/async.ts";
|
|
91
100
|
import { emptyUsage, type ToolEvent } from "./engine/stream.ts";
|
|
92
101
|
import { brokerEndpoint } from "./bus/broker/paths.ts";
|
|
93
102
|
import { InProcessBus } from "./bus/inproc.ts";
|
|
94
103
|
import { loadContracts, loadDefinitions, loadPresets, loadTeams, type LoadResult, type ScopedDir } from "./loader.ts";
|
|
95
|
-
import { type FlowSpec,
|
|
96
|
-
import { journalFileName, journalWriter, readJournal } from "./orchestration/flow-journal.ts";
|
|
104
|
+
import { type FlowSpec, flowRunHash, parseFlow, verifyFlowRefs } from "./orchestration/flow.ts";
|
|
105
|
+
import { clearJournal, journalFileName, journalWriter, readJournal } from "./orchestration/flow-journal.ts";
|
|
97
106
|
import { runFlow } from "./orchestration/flow-run.ts";
|
|
98
|
-
import
|
|
107
|
+
import { Semaphore } from "./orchestration/parallel.ts";
|
|
108
|
+
import { flowDisposition, type FlowOutcome } from "./orchestration/flow-run.ts";
|
|
99
109
|
import { type RosterMember, roleHint, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
|
|
100
110
|
import type { AgentProgress, AgentStatus, SteerFn } from "./orchestration/sdk.ts";
|
|
101
111
|
import { knownParams, strategyNames } from "./orchestration/strategy.ts";
|
|
@@ -110,27 +120,27 @@ import {
|
|
|
110
120
|
} from "./persona/model-participants.ts";
|
|
111
121
|
import { expandCouncilPreset, resolveCouncilInvocation, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
|
|
112
122
|
import { bundledSpinePath, bundledWorkerSpinePath, readSpineFile, resolveSpine, type SpineSources } from "./persona/spine.ts";
|
|
113
|
-
import {
|
|
123
|
+
import { migratePersonaSettings, patchPersonaSettings, readPersonaSettings, type PersonaSettings } from "./persona/settings-store.ts";
|
|
114
124
|
import { type AgentKind as TelemetryAgentKind, type AgentStatus as TelemetryAgentStatus } from "./telemetry/contract.ts";
|
|
115
125
|
import { TelemetryProducer, type TelemetryAgentInput } from "./telemetry/producer.ts";
|
|
116
126
|
import {
|
|
117
127
|
type PersonaConfigStore,
|
|
118
128
|
personaModels,
|
|
119
|
-
readPersonaConfigs,
|
|
120
129
|
withPersonaModels,
|
|
121
|
-
writePersonaConfigs,
|
|
122
130
|
} from "./persona/config-store.ts";
|
|
123
131
|
import { shortModel } from "./tools/delegate.ts";
|
|
124
132
|
import { formatInbox } from "./tools/intercom.ts";
|
|
125
133
|
import { renderTimerFire, TimerScheduler, type TimerEntry } from "./core/timer.ts";
|
|
126
134
|
import { AgentOverlay } from "./ui/agent-overlay.ts";
|
|
127
|
-
import { type AddNodeInput, type AgentNode, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
|
|
135
|
+
import { type AddNodeInput, type AgentNode, type AgentNodePatch, type AgentTreeChange, AgentTree, type AgentNodeStatus, deriveWaitingNodeIds, isQueuedMarker, isWakeSource, progressPatch, renderAgentTreeSummary } from "./ui/agent-tree.ts";
|
|
136
|
+
import { WakeRows, type WakeRowChange } from "./ui/wake-rows.ts";
|
|
128
137
|
import { LiveClock } from "./ui/live-clock.ts";
|
|
129
|
-
import { filterModels, ModelPicker, orderModelRefs } from "./ui/model-picker.ts";
|
|
130
|
-
import { compactInlineText,
|
|
138
|
+
import { filterModels, ModelPicker, orderModelRefs, type ModelPickerMetadata } from "./ui/model-picker.ts";
|
|
139
|
+
import { compactInlineText, gateReviewText } from "./ui/presentation.ts";
|
|
140
|
+
import { compactWidget } from "./ui/compact-widget.ts";
|
|
131
141
|
import { ChildUsageLedger, formatPersonaCostStatus, formatUsage, PERSONA_COST_STATUS_KEY } from "./ui/usage.ts";
|
|
132
142
|
|
|
133
|
-
const RUN_LIMITS: RunLimits = {
|
|
143
|
+
export const RUN_LIMITS: RunLimits = {
|
|
134
144
|
// A generous anti-runaway backstop, not a council-size cap: a declared ensemble
|
|
135
145
|
// (e.g. 9 members × several rounds) must fit; the token budget is the real cost guard.
|
|
136
146
|
maxChildren: 64,
|
|
@@ -173,6 +183,8 @@ interface CommandResultEntry {
|
|
|
173
183
|
label: string;
|
|
174
184
|
content: string;
|
|
175
185
|
ok: boolean;
|
|
186
|
+
/** A flow that paused at a rejected checkpoint: not a failure, so it is titled "stopped". */
|
|
187
|
+
stoppedAtGate?: string;
|
|
176
188
|
failureKind?: FailureKind;
|
|
177
189
|
error?: string;
|
|
178
190
|
}
|
|
@@ -183,10 +195,20 @@ interface NudgeEntry {
|
|
|
183
195
|
|
|
184
196
|
interface CheckInEntry {
|
|
185
197
|
content: string;
|
|
198
|
+
kind?: "routine" | "stall";
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
interface RunFailureEntry {
|
|
202
|
+
id: string;
|
|
203
|
+
label: string;
|
|
204
|
+
agent: string;
|
|
205
|
+
model?: string;
|
|
206
|
+
durationMs?: number;
|
|
207
|
+
error: string;
|
|
186
208
|
}
|
|
187
209
|
|
|
188
210
|
/**
|
|
189
|
-
* Keep the immediate operator-facing settlement
|
|
211
|
+
* Keep the immediate operator-facing settlement feedback and the semantic completion delivery on one
|
|
190
212
|
* path. A supervisor-requested abort is terminal, but it is not an execution error: the tracker
|
|
191
213
|
* classifies it as `stopped`, so the UI reports it at info level while the completion still reaches
|
|
192
214
|
* the supervisor exactly once through the normal notifier.
|
|
@@ -195,31 +217,51 @@ export function announceAsyncRunSettlement(
|
|
|
195
217
|
run: AsyncRun,
|
|
196
218
|
notify: ((message: string, level: "info" | "error") => void) | undefined,
|
|
197
219
|
enqueue: (run: AsyncRun) => void,
|
|
220
|
+
presentFailure?: (run: AsyncRun) => void,
|
|
198
221
|
): void {
|
|
199
222
|
const id = compactInlineText(run.id, { maxChars: 80 }) || "run";
|
|
200
223
|
const agent = compactInlineText(run.label ?? run.agent, { maxChars: 80 }) || compactInlineText(run.agent, { maxChars: 80 }) || "agent";
|
|
201
224
|
try {
|
|
202
225
|
if (run.status === "done") notify?.(`async run ${id} (${agent}) completed`, "info");
|
|
203
226
|
else if (run.status === "stopped") notify?.(`async run ${id} (${agent}) stopped`, "info");
|
|
204
|
-
else if (run.status === "failed")
|
|
227
|
+
else if (run.status === "failed") {
|
|
228
|
+
const toast = () => notify?.(`async run ${id} (${agent}) failed: ${summarizeFailure(run.error ?? "(no detail)")}`, "error");
|
|
229
|
+
if (presentFailure) {
|
|
230
|
+
try { presentFailure(run); }
|
|
231
|
+
catch { toast(); }
|
|
232
|
+
} else toast();
|
|
233
|
+
}
|
|
205
234
|
} catch {
|
|
206
235
|
// Cosmetic notification only: a broken UI must never swallow the terminal report.
|
|
207
236
|
}
|
|
208
237
|
enqueue(run);
|
|
209
238
|
}
|
|
210
239
|
|
|
240
|
+
/** An operator-run `/flow` or `/orchestrate` result as the supervisor's NEXT turn sees it: one header
|
|
241
|
+
* line, then the output fenced as data (it is sub-agent text) and bounded like a completion. */
|
|
242
|
+
export function renderOperatorResult(label: string, output: string, ok: boolean, stoppedAtGate?: string): string {
|
|
243
|
+
const disposition = flowDisposition({ ok, stoppedAtGate });
|
|
244
|
+
const state = disposition === "done" ? "finished" : disposition === "stopped" ? `stopped at checkpoint "${compactInlineText(stoppedAtGate ?? "", { maxChars: 64 })}"` : "failed";
|
|
245
|
+
return boundCompletionSurface(
|
|
246
|
+
`[pi-persona] The operator ran ${compactInlineText(label, { maxChars: 96 })} (${state}); its output:\n${fenceUntrusted(output)}`,
|
|
247
|
+
"operator result truncated at 12000 characters; only the operator can see the rest (ask them to paste what you need)",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
211
251
|
/** Agents actually IN FLIGHT: a RUNNING node with no children of its own. "Has a parent" is not the
|
|
212
252
|
* same as "is an agent" — a flow phase is a container that also has a parent, a root-level `async:`
|
|
213
253
|
* run is a leaf that has none, and a settled leg still sits in the tree until its root is torn
|
|
214
|
-
* down.
|
|
215
|
-
|
|
254
|
+
* down. A fan-out's `delegate` header row is a container even in the beat after its last leg
|
|
255
|
+
* left, so it never counts. A wake source (a timer or monitor row) is not an agent at all and
|
|
256
|
+
* never counts, running or not. Exported for direct unit testing (mirrors `listPeersForGroup` above). */
|
|
257
|
+
export function inFlightAgentCount(nodes: ReadonlyArray<{ id: string; parentId?: string | undefined; status: AgentNodeStatus; kind?: NonNullable<AgentNode["kind"]> }>): number {
|
|
216
258
|
const parents = new Set<string>();
|
|
217
259
|
for (const node of nodes) {
|
|
218
260
|
if (node.parentId !== undefined) parents.add(node.parentId);
|
|
219
261
|
}
|
|
220
262
|
let count = 0;
|
|
221
263
|
for (const node of nodes) {
|
|
222
|
-
if (node.status === "running" && !parents.has(node.id)) count++;
|
|
264
|
+
if (node.status === "running" && node.kind !== "delegate" && !isWakeSource(node) && !parents.has(node.id)) count++;
|
|
223
265
|
}
|
|
224
266
|
return count;
|
|
225
267
|
}
|
|
@@ -279,99 +321,52 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
279
321
|
return;
|
|
280
322
|
}
|
|
281
323
|
|
|
282
|
-
|
|
324
|
+
let config = resolveConfig(process.env);
|
|
283
325
|
if (config.disabled) return;
|
|
284
326
|
const exocomArgs = parseExocomArgv(options.exocomArgv ?? process.argv);
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
? message.content
|
|
289
|
-
: message.content.map((part) => part.type === "text" ? part.text : "[image]").join("\n");
|
|
290
|
-
const full = sanitizeTerminalText(raw);
|
|
291
|
-
const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
|
|
292
|
-
const firstLine = full.split("\n", 1)[0] ?? "";
|
|
293
|
-
// External peer text is data and must never choose our UI chrome. Internal follow-ups own
|
|
294
|
-
// their first status line; parse only that line and do not turn a healthy "0 failed" red.
|
|
295
|
-
const isFailure = customType === "pi-persona"
|
|
296
|
-
&& !/\b0 failed\b/i.test(firstLine)
|
|
297
|
-
&& /\b(?:failed|failure|error|rejected|unresolved)\b/i.test(firstLine);
|
|
298
|
-
const titleColor = isFailure ? "error" : customType === "exocom_received" ? "accent" : "success";
|
|
299
|
-
const title = theme.fg(titleColor, theme.bold(label));
|
|
300
|
-
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full || "(no output)")}`, outputPad, 0);
|
|
301
|
-
const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
302
|
-
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, outputPad, 0);
|
|
303
|
-
});
|
|
304
|
-
};
|
|
305
|
-
// These follow-ups remain complete in the model context, but the human-facing card starts as a
|
|
306
|
-
// bounded digest. This is the central progressive-disclosure boundary for async completions and
|
|
307
|
-
// external peer messages; expanding the card reveals the exact sanitized payload.
|
|
308
|
-
registerFollowUpRenderer("pi-persona", "pi-persona · update");
|
|
309
|
-
registerFollowUpRenderer("exocom_received", "exocom · inbound");
|
|
310
|
-
pi.registerEntryRenderer("pi-persona-result", (entry, { expanded }, theme) => {
|
|
311
|
-
const data = entry.data as CommandResultEntry;
|
|
312
|
-
const full = sanitizeTerminalText(data.content || "(no output)");
|
|
313
|
-
const cause = compactVisibleText(
|
|
314
|
-
[data.failureKind, data.error].filter((part): part is string => !!part).join(" · "),
|
|
315
|
-
{ maxLines: 1, maxLineChars: 80 },
|
|
316
|
-
).text;
|
|
317
|
-
const status = data.ok ? "complete" : "failed";
|
|
318
|
-
const label = compactInlineText(data.label, { maxChars: 96 }) || "result";
|
|
319
|
-
const title = theme.fg(data.ok ? "success" : "error", theme.bold(`${label} · ${status}${!data.ok && cause !== "(no output)" ? ` · ${cause}` : ""}`));
|
|
320
|
-
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
|
|
321
|
-
const preview = compactVisibleText(full, { maxLines: 3, maxLineChars: 100 });
|
|
322
|
-
const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
323
|
-
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
|
|
324
|
-
});
|
|
325
|
-
pi.registerEntryRenderer("pi-persona-nudge", (entry, { expanded }, theme) => {
|
|
326
|
-
const data = entry.data as NudgeEntry;
|
|
327
|
-
const full = sanitizeTerminalText(data.content || "⟢ pi-persona · checkpoint");
|
|
328
|
-
const lines = full.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
329
|
-
const header = compactInlineText(lines[0]?.replace(/^⟢\s*/, "") ?? "", { maxChars: 96 }) || "pi-persona · checkpoint";
|
|
330
|
-
const details = lines.slice(1);
|
|
331
|
-
const title = theme.fg("warning", theme.bold(`⟢ ${header}`));
|
|
332
|
-
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", details.join("\n") || "(no detail)")}`, 0, 0);
|
|
333
|
-
const trigger = details.find((line) => line.startsWith("Trigger:"));
|
|
334
|
-
const action = details.find((line) => /^(?:Action|Verify|Otherwise):/.test(line));
|
|
335
|
-
const selected = [trigger, action].filter((line): line is string => line !== undefined);
|
|
336
|
-
const previewLines = selected.length > 0 ? selected : details.slice(0, 2);
|
|
337
|
-
const preview = compactVisibleText(previewLines.join("\n"), { maxLines: 2, maxLineChars: 100 });
|
|
338
|
-
const hasMore = preview.truncated || details.some((line) => !previewLines.includes(line));
|
|
339
|
-
const hint = hasMore ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
340
|
-
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
|
|
341
|
-
});
|
|
342
|
-
pi.registerEntryRenderer("pi-persona-status", (entry, { expanded }, theme) => {
|
|
343
|
-
const data = entry.data as CheckInEntry;
|
|
344
|
-
const full = sanitizeTerminalText(data.content || "Async activity · routine check-in");
|
|
345
|
-
const title = theme.fg("accent", theme.bold("pi-persona · async status"));
|
|
346
|
-
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
|
|
347
|
-
// The entry title already labels this as async status. Spend the compact preview on the
|
|
348
|
-
// aggregate and first run row so the operator sees the selected alias and usage label.
|
|
349
|
-
const digestLines = full.split("\n").slice(1).filter((line) => line.trim().length > 0);
|
|
350
|
-
const preview = compactVisibleText(digestLines.slice(0, 2).join("\n") || full, { maxLines: 2, maxLineChars: 140 });
|
|
351
|
-
const hasMore = preview.truncated || digestLines.length > 2;
|
|
352
|
-
const hint = hasMore ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
353
|
-
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
|
|
327
|
+
registerSessionCards(pi, {
|
|
328
|
+
detailHint: expandDetailHint,
|
|
329
|
+
monitorLog: (record) => renderMonitorLog(record, true),
|
|
354
330
|
});
|
|
355
331
|
const appendNudgeEntry = (content: string): void => {
|
|
356
332
|
pi.appendEntry("pi-persona-nudge", { content } satisfies NudgeEntry);
|
|
357
333
|
};
|
|
358
|
-
const appendCheckInEntry = (content: string): void => {
|
|
359
|
-
pi.appendEntry("pi-persona-status", { content } satisfies CheckInEntry);
|
|
334
|
+
const appendCheckInEntry = (content: string, kind: "routine" | "stall" = "routine"): void => {
|
|
335
|
+
pi.appendEntry("pi-persona-status", { content, kind } satisfies CheckInEntry);
|
|
360
336
|
};
|
|
361
337
|
const appendCommandResult = (
|
|
362
338
|
label: string,
|
|
363
339
|
content: string,
|
|
364
340
|
ok: boolean,
|
|
365
|
-
meta: { failureKind?: FailureKind; error?: string } = {},
|
|
341
|
+
meta: { failureKind?: FailureKind; error?: string; stoppedAtGate?: string } = {},
|
|
366
342
|
): void => {
|
|
367
343
|
pi.appendEntry("pi-persona-result", {
|
|
368
344
|
label,
|
|
369
345
|
content,
|
|
370
346
|
ok,
|
|
347
|
+
...(meta.stoppedAtGate !== undefined ? { stoppedAtGate: meta.stoppedAtGate } : {}),
|
|
371
348
|
...(meta.failureKind ? { failureKind: meta.failureKind } : {}),
|
|
372
349
|
...(meta.error ? { error: meta.error } : {}),
|
|
373
350
|
} satisfies CommandResultEntry);
|
|
374
351
|
};
|
|
352
|
+
/** An operator-run `/flow` or `/orchestrate` result: the card for the operator, and the same text for the
|
|
353
|
+
* supervisor's NEXT turn — `nextTurn` is passive (it never starts one), so "now implement the plan" or
|
|
354
|
+
* "apply the winning diff" finds the result in context instead of nothing. */
|
|
355
|
+
const appendOperatorResult: typeof appendCommandResult = (label, content, ok, meta = {}) => {
|
|
356
|
+
appendCommandResult(label, content, ok, meta);
|
|
357
|
+
try {
|
|
358
|
+
pi.sendMessage({ customType: "pi-persona", content: renderOperatorResult(label, content, ok, meta.stoppedAtGate), display: false }, { deliverAs: "nextTurn" });
|
|
359
|
+
} catch {
|
|
360
|
+
/* the card above is the operator's copy; a host that cannot queue the hand-off must not fail the command */
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
/** `/flow` and `/orchestrate` result card; `ok` stays honest, a rejected checkpoint is "stopped". */
|
|
364
|
+
const appendFlowResult = (flow: string, outcome: FlowOutcome): void =>
|
|
365
|
+
appendOperatorResult(`flow ${flow}`, outcome.output || "(flow produced no output)", outcome.ok, {
|
|
366
|
+
...(outcome.stoppedAtGate !== undefined ? { stoppedAtGate: outcome.stoppedAtGate } : {}),
|
|
367
|
+
...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
|
|
368
|
+
...(outcome.error ? { error: outcome.error } : {}),
|
|
369
|
+
});
|
|
375
370
|
const enabledSpineRoles = (): SpineLegacySelection => ({
|
|
376
371
|
supervisor: config.spine === "on",
|
|
377
372
|
worker: config.spineLegs === "on",
|
|
@@ -390,7 +385,37 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
390
385
|
return { moved: [], kept: [], warnings: [`data root migration failed: ${message}`] };
|
|
391
386
|
}
|
|
392
387
|
})();
|
|
388
|
+
// One persistent settings owner. The root migration must run before importing the two
|
|
389
|
+
// retired stores; fork-guarded children returned before any filesystem access above.
|
|
390
|
+
const settingsFile = join(personaDataDir(), "settings.json");
|
|
391
|
+
let savedSettings: PersonaSettings = { version: 1, preferences: {}, personas: {}, lastPersona: null };
|
|
392
|
+
const settingsWarnings: string[] = [];
|
|
393
|
+
const reportedSettingsWarnings = new Set<string>();
|
|
394
|
+
try {
|
|
395
|
+
const result = migratePersonaSettings(settingsFile, {
|
|
396
|
+
legacyConfigFile: join(personaDataDir(), "config.json"),
|
|
397
|
+
legacyStateFile: config.stateFile ?? join(personaDataDir(), "state.json"),
|
|
398
|
+
});
|
|
399
|
+
savedSettings = result.settings;
|
|
400
|
+
settingsWarnings.push(...result.warnings);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
settingsWarnings.push(`could not load settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
403
|
+
}
|
|
404
|
+
config = resolvePersonaConfig(savedSettings.preferences, process.env);
|
|
405
|
+
function reportSettingsWarning(ctx: ExtensionContext | undefined, message: string): void {
|
|
406
|
+
if (reportedSettingsWarnings.has(message)) return;
|
|
407
|
+
reportedSettingsWarnings.add(message);
|
|
408
|
+
const text = `pi-persona settings: ${compactInlineText(message, { maxChars: 500 })}`;
|
|
409
|
+
try {
|
|
410
|
+
if (ctx?.hasUI) ctx.ui.notify(text, "warning");
|
|
411
|
+
else process.stderr.write(`${text}\n`);
|
|
412
|
+
} catch {
|
|
413
|
+
// A failed UI must not turn recoverable configuration trouble into activation failure.
|
|
414
|
+
try { process.stderr.write(`${text}\n`); } catch { /* Non-fatal diagnostic. */ }
|
|
415
|
+
}
|
|
416
|
+
}
|
|
393
417
|
const reportDataRootMigration = (ctx: ExtensionContext): void => {
|
|
418
|
+
for (const warning of settingsWarnings) reportSettingsWarning(ctx, warning);
|
|
394
419
|
if (dataRootMigration.moved.length > 0) {
|
|
395
420
|
const message = `pi-persona: moved ${dataRootMigration.moved.length} file(s) from the legacy pi-persona storage root into ${personaDataDir()}`;
|
|
396
421
|
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
@@ -539,6 +564,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
539
564
|
// The unified live tree of every in-flight agent — strategy cores, delegate
|
|
540
565
|
// sub-agents, dynamic specialists — rendered as one sticky widget above the input.
|
|
541
566
|
const agentTree = new AgentTree();
|
|
567
|
+
const wakeRows = new WakeRows();
|
|
542
568
|
// Explicit telemetry is the public observability seam consumed by pi-persona-flow. It is
|
|
543
569
|
// session-scoped and starts only after persona + exocom reconciliation in session_start.
|
|
544
570
|
let telemetry: TelemetryProducer | undefined;
|
|
@@ -561,7 +587,6 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
561
587
|
if (node.kind) return node.kind;
|
|
562
588
|
if (node.id.startsWith("flow:")) return "flow";
|
|
563
589
|
if (node.id.startsWith("strategy:") || node.id.startsWith("council:")) return "council";
|
|
564
|
-
if (node.id.startsWith("delegate:")) return "delegate";
|
|
565
590
|
return "subagent";
|
|
566
591
|
}
|
|
567
592
|
function telemetryStatus(node: AgentNode): TelemetryAgentStatus {
|
|
@@ -627,6 +652,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
627
652
|
function publishAgentTreeChange(change: AgentTreeChange): void {
|
|
628
653
|
const producer = telemetry;
|
|
629
654
|
if (!producer) return;
|
|
655
|
+
// Wake rows (timers, monitors) are not agents, and their labels are free text the telemetry
|
|
656
|
+
// contract never persists: they stay on the local widget and never reach the bus.
|
|
657
|
+
if ((change.type === "added" || change.type === "updated") && isWakeSource(change.node)) return;
|
|
630
658
|
if (change.type === "added") {
|
|
631
659
|
lastAgentProjection.set(change.node.id, JSON.stringify(telemetryAgent(change.node)));
|
|
632
660
|
producer.publishAgentAdded(telemetryAgent(change.node));
|
|
@@ -637,7 +665,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
637
665
|
return;
|
|
638
666
|
}
|
|
639
667
|
if (change.type === "removed") {
|
|
640
|
-
for (const node of change.nodes) {
|
|
668
|
+
for (const node of change.nodes.filter((n) => !isWakeSource(n))) {
|
|
641
669
|
lastAgentProjection.delete(node.id);
|
|
642
670
|
const status = node.status === "running" ? "stopped" : telemetryStatus(node);
|
|
643
671
|
producer.publish("agent.removed", { id: node.id, status });
|
|
@@ -650,10 +678,17 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
650
678
|
agentTree.onChange(publishAgentTreeChange);
|
|
651
679
|
// Delegation nudge: watches the supervisor's OWN tool-result stream and reminds it to hand off
|
|
652
680
|
// when it grinds heavy work by hand (config.nudge; gated to delegating personas at the hook).
|
|
653
|
-
|
|
681
|
+
// Its reminder cites the live delegate targets and the session provider's models (read when it
|
|
682
|
+
// fires: agents change on /persona reload, the session model on a switch).
|
|
683
|
+
const delegationNudge = new DelegationNudge(undefined, {
|
|
684
|
+
roster: () => renderNudgeRoster(orderByMention(delegateTargets(), controller.activePersona?.body)),
|
|
685
|
+
models: () => (canDiscoverModels() && lastCtx?.model ? currentModelCatalog(lastCtx).candidates : undefined),
|
|
686
|
+
});
|
|
654
687
|
// Persistence nudge: the counterweight — when a delegated leg comes back BLOCKED/UNKNOWN, remind
|
|
655
688
|
// the supervisor not to bank a premature surrender (same config.nudge gate + hook as above).
|
|
656
689
|
const persistenceNudge = new PersistenceNudge();
|
|
690
|
+
// Poll guard: refuses an unchanged repeat of intercom peek/result (same kill switch).
|
|
691
|
+
const pollGuard = config.nudge ? new PollGuard() : undefined;
|
|
657
692
|
// Every visible run (strategy, council, flow) gets its own tree root — see makeRootIdAllocator.
|
|
658
693
|
const nextRootId = makeRootIdAllocator();
|
|
659
694
|
// node id → abort that one agent (so the overlay can STOP a single sub-agent).
|
|
@@ -675,7 +710,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
675
710
|
fn();
|
|
676
711
|
agentTree.update(nodeId, { detail: "stopping…" }); // visible at once; the run's own settle path writes the terminal status
|
|
677
712
|
flushAgentWidgetRender();
|
|
678
|
-
|
|
713
|
+
// A wake source settles synchronously inside `fn()` and clears its own handle, so there is no
|
|
714
|
+
// pending stop to remember (tracker runs keep their handle until they settle, so they are unchanged).
|
|
715
|
+
if (stopRegistry.has(nodeId)) stopRequested.add(nodeId);
|
|
679
716
|
steerRegistry.delete(nodeId); // a hard-stopped agent is no longer steerable (mirror the strategy path)
|
|
680
717
|
return true;
|
|
681
718
|
}
|
|
@@ -684,7 +721,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
684
721
|
return deriveWaitingNodeIds(nodes, (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true);
|
|
685
722
|
}
|
|
686
723
|
function waitingAsyncRunIds(): Set<string> {
|
|
687
|
-
|
|
724
|
+
// Through the tree, so a council/flow run (a root whose LEGS hold the steer handles) reads as waiting
|
|
725
|
+
// exactly when every live leg does, the same rule a delegate leg answers directly.
|
|
726
|
+
const waiting = waitingTreeIds();
|
|
727
|
+
return new Set(tracker.running().filter((run) => waiting.has(`async:${run.id}`)).map((run) => run.id));
|
|
688
728
|
}
|
|
689
729
|
const clearSteers = (prefix: string): void => {
|
|
690
730
|
for (const k of [...steerRegistry.keys()]) if (k === prefix || k.startsWith(`${prefix}/`)) steerRegistry.delete(k);
|
|
@@ -707,6 +747,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
707
747
|
const WIDGET_RENDER_DELAY_MS = 16;
|
|
708
748
|
let widgetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
709
749
|
let widgetRenderPending = false;
|
|
750
|
+
let publishingMonitorOutput = false;
|
|
710
751
|
let widgetLinesInitialized = false;
|
|
711
752
|
let lastWidgetLines: readonly string[] | undefined;
|
|
712
753
|
let widgetStatusInitialized = false;
|
|
@@ -717,14 +758,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
717
758
|
if (!a || !b || a.length !== b.length) return false;
|
|
718
759
|
return a.every((line, i) => line === b[i]);
|
|
719
760
|
}
|
|
720
|
-
|
|
761
|
+
/** The "N agents" status answers how many agents are working, so a tree holding only wake rows
|
|
762
|
+
* (timers, monitors) publishes none, while the widget lines still show them. */
|
|
763
|
+
function agentCountStatus(nodes: readonly AgentNode[]): string | undefined {
|
|
764
|
+
const agents = nodes.filter((node) => !isWakeSource(node));
|
|
765
|
+
return agents.length === 0 ? undefined : String(inFlightAgentCount(agents));
|
|
766
|
+
}
|
|
767
|
+
function renderAgentWidget(): void {
|
|
721
768
|
if (!lastCtx) return;
|
|
769
|
+
// Publish dirty monitor tails into the tree BEFORE this frame takes its snapshot. The tree's
|
|
770
|
+
// overlay listeners still see the patches, but our own listener must not request a second frame.
|
|
771
|
+
if (!disposed) {
|
|
772
|
+
publishingMonitorOutput = true;
|
|
773
|
+
try {
|
|
774
|
+
for (const change of wakeRows.flushMonitorOutput()) agentTree.update(change.id, change.patch);
|
|
775
|
+
} finally { publishingMonitorOutput = false; }
|
|
776
|
+
}
|
|
777
|
+
const nodes = agentTree.snapshot();
|
|
722
778
|
const empty = nodes.length === 0;
|
|
723
779
|
const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingTreeIds(nodes) });
|
|
724
|
-
const status =
|
|
780
|
+
const status = agentCountStatus(nodes);
|
|
725
781
|
try {
|
|
726
782
|
if (!widgetLinesInitialized || !sameWidgetLines(lastWidgetLines, lines)) {
|
|
727
|
-
lastCtx.ui.setWidget("persona-agents", lines, { placement: "aboveEditor" });
|
|
783
|
+
lastCtx.ui.setWidget("persona-agents", lines ? compactWidget(lines) : undefined, { placement: "aboveEditor" });
|
|
728
784
|
lastWidgetLines = lines;
|
|
729
785
|
widgetLinesInitialized = true;
|
|
730
786
|
}
|
|
@@ -743,8 +799,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
743
799
|
}
|
|
744
800
|
function renderAgentStatus(): void {
|
|
745
801
|
if (!lastCtx) return;
|
|
746
|
-
const
|
|
747
|
-
const status = nodes.length === 0 ? undefined : String(inFlightAgentCount(nodes));
|
|
802
|
+
const status = agentCountStatus(agentTree.snapshot());
|
|
748
803
|
try {
|
|
749
804
|
if (!widgetStatusInitialized || lastWidgetStatus !== status) {
|
|
750
805
|
lastCtx.ui.setStatus("persona-agents", status);
|
|
@@ -806,6 +861,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
806
861
|
// elapsed time and the stall badge moving without a progress event, and stops itself when idle.
|
|
807
862
|
const widgetClock = new LiveClock({ intervalMs: 1_000, isLive: () => agentTree.hasRunning(), onTick: renderAgentWidget });
|
|
808
863
|
agentTree.onChange((change) => {
|
|
864
|
+
if (publishingMonitorOutput) return;
|
|
809
865
|
const refreshStatus = widgetStatusMayHaveChanged(change);
|
|
810
866
|
if (disposed) {
|
|
811
867
|
// session_shutdown clears the tree after marking this activation disposed. Keep teardown
|
|
@@ -849,21 +905,36 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
849
905
|
let seedSourceCollisions: string[] = [];
|
|
850
906
|
|
|
851
907
|
// Remembered selection lives in the persona data folder; only user gestures write it.
|
|
852
|
-
const stateFile = config.stateFile ?? join(personaDataDir(), "state.json");
|
|
853
908
|
const readRememberedPersona = (): string | undefined => {
|
|
854
909
|
if (!config.persist) return undefined;
|
|
855
|
-
|
|
910
|
+
try {
|
|
911
|
+
savedSettings = readPersonaSettings(settingsFile);
|
|
912
|
+
return savedSettings.lastPersona ?? undefined;
|
|
913
|
+
} catch (error) {
|
|
914
|
+
reportSettingsWarning(lastCtx, `could not read remembered persona: ${error instanceof Error ? error.message : String(error)}`);
|
|
915
|
+
return undefined;
|
|
916
|
+
}
|
|
856
917
|
};
|
|
857
918
|
const persist = (name: string | undefined): void => {
|
|
858
|
-
if (config.persist)
|
|
919
|
+
if (!config.persist) return;
|
|
920
|
+
try {
|
|
921
|
+
savedSettings = patchPersonaSettings(settingsFile, { lastPersona: name ?? null });
|
|
922
|
+
} catch (error) {
|
|
923
|
+
reportSettingsWarning(lastCtx, `could not save remembered persona: ${error instanceof Error ? error.message : String(error)}`);
|
|
924
|
+
}
|
|
859
925
|
};
|
|
860
926
|
|
|
861
|
-
//
|
|
862
|
-
const configFile = join(personaDataDir(), "config.json");
|
|
927
|
+
// Model preferences share the canonical file with extension preferences and selection.
|
|
863
928
|
const readConfigStore = (): PersonaConfigStore => {
|
|
864
|
-
|
|
929
|
+
try {
|
|
930
|
+
savedSettings = readPersonaSettings(settingsFile);
|
|
931
|
+
return savedSettings.personas;
|
|
932
|
+
} catch (error) {
|
|
933
|
+
reportSettingsWarning(lastCtx, `could not read model settings: ${error instanceof Error ? error.message : String(error)}`);
|
|
934
|
+
return savedSettings.personas;
|
|
935
|
+
}
|
|
865
936
|
};
|
|
866
|
-
let personaConfigs: PersonaConfigStore =
|
|
937
|
+
let personaConfigs: PersonaConfigStore = savedSettings.personas;
|
|
867
938
|
// A participant is asked for a model at most ONCE per session — dismissal included, so a
|
|
868
939
|
// closed picker never becomes a nag. Keyed by persona + assignment key, NOT by persona: a new
|
|
869
940
|
// roster (or a new strategy's arbiter) is a genuinely new participant and may ask the first time
|
|
@@ -918,6 +989,11 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
918
989
|
definitionWarnings = [...result.warnings, ...contractsLoad.warnings];
|
|
919
990
|
}
|
|
920
991
|
|
|
992
|
+
// Empty by design on a fresh install (see above): point at the install gesture from every
|
|
993
|
+
// surface that would otherwise be silent or read as broken — `/persona list`, /doctor, F8.
|
|
994
|
+
const NO_PERSONAS_HINT =
|
|
995
|
+
"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).";
|
|
996
|
+
|
|
921
997
|
// Install the bundled defaults into the user's agent dir — this is the ONLY way personas/agents
|
|
922
998
|
// become active (they are not a live builtin layer). `/persona seed` pulls in missing defaults,
|
|
923
999
|
// `/persona restore` force-overwrites them back to the originals. First-run auto-install is
|
|
@@ -1069,12 +1145,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1069
1145
|
};
|
|
1070
1146
|
// Async runs outlive the turn that launched them; on completion we surface the result(s) back as
|
|
1071
1147
|
// ONE coalesced notice so the (idle) supervisor can react — e.g. retry a failure with another model.
|
|
1072
|
-
//
|
|
1073
|
-
// premature-surrender counterweight
|
|
1074
|
-
//
|
|
1075
|
-
//
|
|
1148
|
+
// Interactive delegate ALWAYS runs in the background, so this is the COMMON delivery path: the
|
|
1149
|
+
// premature-surrender counterweight rides it (the sync tool_result hook can't see a background run),
|
|
1150
|
+
// and a `tasks` fan-out is held until its LAST leg settles so the supervisor is woken once with every
|
|
1151
|
+
// result instead of once per leg. Single legs are never held.
|
|
1076
1152
|
const completionNotifier = new IdleCoalescingNotifier<AsyncRun>({
|
|
1077
1153
|
...idleDelivery,
|
|
1154
|
+
isReady: (run) => tracker.batchSettled(run),
|
|
1078
1155
|
render: (runs) => {
|
|
1079
1156
|
const report = renderCompletion(runs, fenceUntrusted, (t) => scanForSurrender(t));
|
|
1080
1157
|
// Retention is a bound, not a promise: a fan-out wider than it settles more payloads than the
|
|
@@ -1107,6 +1184,21 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1107
1184
|
...idleDelivery,
|
|
1108
1185
|
render: (entries) => renderTimerFire(entries),
|
|
1109
1186
|
});
|
|
1187
|
+
// Wake sources (timers, monitors) are rows beside the sub-agents: added when armed, updated on
|
|
1188
|
+
// events, settled like an async run. The final update and the removal are synchronous and the
|
|
1189
|
+
// widget composition is coalesced, so the final status reaches the tree change stream, never the
|
|
1190
|
+
// screen; the row simply leaves, together with its stop handle.
|
|
1191
|
+
function settleWakeRow(change: WakeRowChange): void {
|
|
1192
|
+
// session_shutdown clears the tree and both registries itself right after cancelAll, so
|
|
1193
|
+
// settling rows one by one would only repaint a widget it is about to clear (the tree
|
|
1194
|
+
// listener repaints synchronously once disposed). The leftover timer:/monitor: stop handles
|
|
1195
|
+
// are harmless: cancelling an unknown id is a no-op.
|
|
1196
|
+
if (disposed) return;
|
|
1197
|
+
agentTree.update(change.id, change.patch);
|
|
1198
|
+
agentTree.remove(change.id);
|
|
1199
|
+
stopRegistry.delete(change.id);
|
|
1200
|
+
stopRequested.delete(change.id);
|
|
1201
|
+
}
|
|
1110
1202
|
const timerScheduler = new TimerScheduler({
|
|
1111
1203
|
now: () => Date.now(),
|
|
1112
1204
|
setTimer: (fn, ms) => {
|
|
@@ -1115,7 +1207,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1115
1207
|
return h;
|
|
1116
1208
|
},
|
|
1117
1209
|
clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
1118
|
-
|
|
1210
|
+
onArmed: (entry) => {
|
|
1211
|
+
const row = wakeRows.timerArmed(entry);
|
|
1212
|
+
stopRegistry.set(row.id, () => { timerScheduler.cancel(entry.id); });
|
|
1213
|
+
agentTree.add(row);
|
|
1214
|
+
},
|
|
1215
|
+
onCancelled: (entry) => settleWakeRow(wakeRows.timerCancelled(entry)),
|
|
1216
|
+
onFire: (entry) => { settleWakeRow(wakeRows.timerFired(entry)); timerNotifier.notify(entry); },
|
|
1119
1217
|
});
|
|
1120
1218
|
const canRunMonitor = () => {
|
|
1121
1219
|
if (disposed) return false;
|
|
@@ -1124,26 +1222,66 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1124
1222
|
return active.includes("monitor") && active.includes("bash")
|
|
1125
1223
|
&& (!caps || (canCallTool(caps, "monitor") && canCallTool(caps, "bash")));
|
|
1126
1224
|
};
|
|
1225
|
+
// Session-native custom entries own the bounded logs: they are not model messages and
|
|
1226
|
+
// deleting the session removes them without orphan sidecars. Shutdown persists, never deletes.
|
|
1227
|
+
const monitorArchive = new MonitorArchive({
|
|
1228
|
+
entries: () => lastCtx?.sessionManager?.getEntries?.() ?? [],
|
|
1229
|
+
append: (customType, data) => pi.appendEntry(customType, data),
|
|
1230
|
+
});
|
|
1127
1231
|
monitors = createMonitorSession({
|
|
1232
|
+
idPrefix: `monitor-${randomUUID().slice(0, 8)}`,
|
|
1128
1233
|
isIdle: idleDelivery.isIdle,
|
|
1129
1234
|
deliver: (text) => sendPersonaFollowUp(pi, text),
|
|
1130
1235
|
canRun: canRunMonitor,
|
|
1236
|
+
observe: {
|
|
1237
|
+
armed: (entry) => {
|
|
1238
|
+
monitorArchive.armed(entry);
|
|
1239
|
+
if (disposed) return;
|
|
1240
|
+
const row = wakeRows.monitorArmed(entry);
|
|
1241
|
+
stopRegistry.set(row.id, () => { monitors?.cancel(entry.id); });
|
|
1242
|
+
agentTree.add(row);
|
|
1243
|
+
},
|
|
1244
|
+
output: (output) => {
|
|
1245
|
+
monitorArchive.output(output);
|
|
1246
|
+
if (disposed) return;
|
|
1247
|
+
if (wakeRows.monitorOutput(output)) requestAgentWidgetRender(false);
|
|
1248
|
+
},
|
|
1249
|
+
event: (event) => {
|
|
1250
|
+
monitorArchive.event(event);
|
|
1251
|
+
if (disposed) return;
|
|
1252
|
+
const change = wakeRows.monitorEvent(event);
|
|
1253
|
+
if (change.settled) settleWakeRow(change);
|
|
1254
|
+
else agentTree.update(change.id, change.patch);
|
|
1255
|
+
},
|
|
1256
|
+
removed: (entry) => {
|
|
1257
|
+
monitorArchive.removed(entry);
|
|
1258
|
+
if (disposed) return;
|
|
1259
|
+
settleWakeRow(wakeRows.monitorRemoved(entry));
|
|
1260
|
+
},
|
|
1261
|
+
},
|
|
1131
1262
|
});
|
|
1132
1263
|
const tracker = new AsyncRunTracker({ maxRetained: config.asyncRetain });
|
|
1133
1264
|
// The premature-surrender counterweight rides the SAME kill switch as the by-hand nudge
|
|
1134
|
-
// (PI_PERSONA_NUDGE=off).
|
|
1135
|
-
//
|
|
1136
|
-
// synchronous tool_result hook.
|
|
1265
|
+
// (PI_PERSONA_NUDGE=off). The background completion follow-up goes through here, so "off" means
|
|
1266
|
+
// off there too, not just on the synchronous tool_result hook.
|
|
1137
1267
|
const scanForSurrender = (text: string): string | undefined => (config.nudge ? persistenceNudge.scan(text) : undefined);
|
|
1138
1268
|
// A run id that is neither live nor retained has two very different meanings: it never existed, or
|
|
1139
1269
|
// its payload was evicted under retention pressure. Only the second deserves an explanation — and a
|
|
1140
1270
|
// bare "no such run" for a result the supervisor was just TOLD to fetch is exactly the dead end
|
|
1141
1271
|
// this distinction exists to prevent.
|
|
1142
1272
|
function missingRunMessage(id: string, display: string | undefined): string {
|
|
1143
|
-
if (
|
|
1273
|
+
if (tracker.wasDropped(id)) {
|
|
1274
|
+
return (
|
|
1275
|
+
`Async run "${display}" settled, but its full payload is gone: more runs settled than the ${tracker.retention}-run retention bound keeps. ` +
|
|
1276
|
+
"Its summary is, or will be, in the completion follow-up. Raise PI_PERSONA_ASYNC_RETAIN, or fan out in narrower waves and collect as you go, to keep more."
|
|
1277
|
+
);
|
|
1278
|
+
}
|
|
1279
|
+
// Never existed, or ended with the session that launched it (a call-sign is not an address either):
|
|
1280
|
+
// list what does exist — running first, then the newest settled — so the next call can be right.
|
|
1281
|
+
const settled = tracker.list().filter((run) => run.status !== "running").reverse().sort((a, b) => (b.settledAt ?? 0) - (a.settledAt ?? 0));
|
|
1144
1282
|
return (
|
|
1145
|
-
`
|
|
1146
|
-
|
|
1283
|
+
`No retained async run "${display}". Runs live only in the session that launched them (a restart, /new or reload ends them; collected results are evicted first once retention is full). ` +
|
|
1284
|
+
`Known runs: ${describeRuns([...tracker.running(), ...settled], { status: true })}.`
|
|
1147
1285
|
);
|
|
1148
1286
|
}
|
|
1149
1287
|
// Turns the periodic peek from a poll into an exception signal: it surfaces a leg only when it
|
|
@@ -1157,11 +1295,21 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1157
1295
|
stopRequested.delete(`async:${run.id}`);
|
|
1158
1296
|
if (disposed) return; // instance torn down — don't notify the next session or re-arm a cancelled timer
|
|
1159
1297
|
// Immediate human feedback + one semantic completion. Explicit stops are informational,
|
|
1160
|
-
// while
|
|
1298
|
+
// while failures get an expandable TUI card (a toast elsewhere). Every result is still delivered.
|
|
1161
1299
|
announceAsyncRunSettlement(
|
|
1162
1300
|
run,
|
|
1163
1301
|
lastCtx ? (message, level) => lastCtx?.ui.notify(message, level) : undefined,
|
|
1164
1302
|
(settled) => completionNotifier.notify(settled),
|
|
1303
|
+
lastCtx?.hasUI && lastCtx.mode === "tui" ? (failed) => {
|
|
1304
|
+
const model = failed.result?.modelUsed ?? failed.model;
|
|
1305
|
+
const durationMs = failed.startedAt !== undefined && failed.settledAt !== undefined
|
|
1306
|
+
? Math.max(0, failed.settledAt - failed.startedAt) : undefined;
|
|
1307
|
+
pi.appendEntry("pi-persona-run-failure", {
|
|
1308
|
+
id: failed.id, label: failed.label ?? failed.agent, agent: failed.agent,
|
|
1309
|
+
...(model ? { model } : {}), ...(durationMs !== undefined ? { durationMs } : {}),
|
|
1310
|
+
error: failed.error ?? "(no detail)",
|
|
1311
|
+
} satisfies RunFailureEntry);
|
|
1312
|
+
} : undefined,
|
|
1165
1313
|
);
|
|
1166
1314
|
if (tracker.running().length === 0) stopPeek(); // no live runs → stop the idle peek
|
|
1167
1315
|
});
|
|
@@ -1196,9 +1344,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1196
1344
|
});
|
|
1197
1345
|
});
|
|
1198
1346
|
|
|
1199
|
-
// Async-run watchdog: the fast PI_PERSONA_PEEK_MS cadence
|
|
1200
|
-
// leg
|
|
1201
|
-
//
|
|
1347
|
+
// Async-run watchdog: the fast PI_PERSONA_PEEK_MS cadence appends an operator-only card for a
|
|
1348
|
+
// newly-stalled leg and wakes the supervisor only for an unread message. The slower
|
|
1349
|
+
// PI_PERSONA_CHECKIN_MS cadence appends a durable operator-only status card; it does not start a
|
|
1350
|
+
// model turn. Bounded: unref'd, self-stops when no runs remain.
|
|
1202
1351
|
let peekTimer: ReturnType<typeof setInterval> | undefined;
|
|
1203
1352
|
// Last time progress status was surfaced (a new stall alert or routine card), for the slow cadence.
|
|
1204
1353
|
let lastPeekAt = 0;
|
|
@@ -1209,7 +1358,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1209
1358
|
}
|
|
1210
1359
|
}
|
|
1211
1360
|
function startPeek(): void {
|
|
1212
|
-
// Fast
|
|
1361
|
+
// Fast ticks and routine cards opt out independently. The carrier timer runs while EITHER is on
|
|
1213
1362
|
// and ticks at the faster of the two (a disabled one is Infinity, so it never drives).
|
|
1214
1363
|
const fastMs = config.peekEveryMs > 0 ? config.peekEveryMs : Number.POSITIVE_INFINITY;
|
|
1215
1364
|
const checkMs = config.checkInEveryMs > 0 ? config.checkInEveryMs : Number.POSITIVE_INFINITY;
|
|
@@ -1223,13 +1372,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1223
1372
|
return;
|
|
1224
1373
|
}
|
|
1225
1374
|
const now = Date.now();
|
|
1226
|
-
const
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
lastPeekAt = now;
|
|
1375
|
+
const waitingRuns = waitingAsyncRunIds();
|
|
1376
|
+
const fast = config.peekEveryMs > 0;
|
|
1377
|
+
const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
|
|
1378
|
+
const appendStatus = (content: string, kind: "routine" | "stall"): void => {
|
|
1231
1379
|
try {
|
|
1232
|
-
appendCheckInEntry(
|
|
1380
|
+
appendCheckInEntry(content, kind);
|
|
1233
1381
|
} catch (error) {
|
|
1234
1382
|
const detail = compactInlineText(error instanceof Error ? error.message : String(error), { maxChars: 120 });
|
|
1235
1383
|
const warning = `pi-persona: could not append async status entry${detail ? ` (${detail})` : ""}`;
|
|
@@ -1241,28 +1389,22 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1241
1389
|
try { process.stderr.write(`[pi-persona] ${warning}\n`); } catch { /* diagnostics must not block an actionable wake */ }
|
|
1242
1390
|
}
|
|
1243
1391
|
}
|
|
1392
|
+
};
|
|
1393
|
+
// Every new stall episode is durable operator history, including while the model is busy.
|
|
1394
|
+
if (stuck.length > 0) appendStatus(buildCheckIn(stuck, { now, stallMs: STALL_FLAG_MS, reason: "stall", waitingForSupervisor: waitingRuns }), "stall");
|
|
1395
|
+
const dueCheckIn = config.checkInEveryMs > 0 && now - lastPeekAt >= config.checkInEveryMs;
|
|
1396
|
+
if (dueCheckIn) {
|
|
1397
|
+
lastPeekAt = now;
|
|
1398
|
+
appendStatus(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingRuns }), "routine");
|
|
1244
1399
|
}
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1400
|
+
// A stall is operator-only (appended above) and never costs a model turn; unread non-ask
|
|
1401
|
+
// messages keep their actionable route.
|
|
1247
1402
|
if (!canDeliverPersonaNotification(orchestrating, processingDeferredOrchestration, lastCtx?.isIdle?.() === true) || lastCtx?.hasPendingMessages?.() === true) return;
|
|
1248
|
-
// The watchdog is not a model polling loop: the FAST path wakes on newly-stalled legs or unread
|
|
1249
|
-
// progress messages. Completions arrive separately through completionNotifier; `/peek` remains on demand.
|
|
1250
|
-
const fast = config.peekEveryMs > 0;
|
|
1251
|
-
const waitingRuns = waitingAsyncRunIds();
|
|
1252
|
-
const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
|
|
1253
|
-
// Drain only progress messages; blocking asks (expectsReply) are surfaced by the intercom
|
|
1254
|
-
// notifier and left for the `intercom inbox` tool — so peek never double-shows them.
|
|
1255
1403
|
const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
|
|
1256
|
-
if (
|
|
1257
|
-
const parts: string[] = [];
|
|
1258
|
-
if (stuck.length > 0) parts.push(buildPeekAlert(stuck, { now }));
|
|
1259
|
-
if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
|
|
1260
|
-
if (stuck.length > 0) lastPeekAt = now; // message-only wakes do not postpone the routine status cadence
|
|
1404
|
+
if (unread.length === 0) return;
|
|
1261
1405
|
try {
|
|
1262
|
-
sendPersonaFollowUp(pi, `[pi-persona] ${
|
|
1263
|
-
} catch {
|
|
1264
|
-
/* ignore */
|
|
1265
|
-
}
|
|
1406
|
+
sendPersonaFollowUp(pi, `[pi-persona] 📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
|
|
1407
|
+
} catch { /* best-effort wake: the session may be tearing down */ }
|
|
1266
1408
|
}, tickMs);
|
|
1267
1409
|
peekTimer.unref?.();
|
|
1268
1410
|
}
|
|
@@ -1360,8 +1502,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1360
1502
|
* Every participant a run really spawns — roster members (inline ones included) plus the
|
|
1361
1503
|
* auxiliary actors its strategy declares — with the model each one resolves to.
|
|
1362
1504
|
*/
|
|
1363
|
-
function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }): ModelParticipant[] {
|
|
1364
|
-
const persona = controller.activePersona?.name;
|
|
1505
|
+
function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }, persona = controller.activePersona?.name): ModelParticipant[] {
|
|
1365
1506
|
return collectModelParticipants({
|
|
1366
1507
|
members: opts.members,
|
|
1367
1508
|
strategy: opts.strategy,
|
|
@@ -1382,15 +1523,34 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1382
1523
|
* Only a live UI ever sees a dialog (`ctx.hasUI`), and only when there is more than one
|
|
1383
1524
|
* authenticated model to choose between.
|
|
1384
1525
|
*/
|
|
1385
|
-
async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[]): Promise<void> {
|
|
1526
|
+
async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], signal: AbortSignal, rootId: string, tracked?: TrackedRun): Promise<void> {
|
|
1386
1527
|
const scope = modelSelectionScope(ctx);
|
|
1387
|
-
if (!scope || !ctx.hasUI) return;
|
|
1528
|
+
if (!scope || !ctx.hasUI || signal.aborted) return;
|
|
1388
1529
|
const missing = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) });
|
|
1389
1530
|
if (missing.length === 0) return;
|
|
1390
1531
|
const available = configuredModels(ctx);
|
|
1391
1532
|
if (available.length < 2) return; // automatic setup cannot diversify a single model
|
|
1392
|
-
|
|
1393
|
-
|
|
1533
|
+
// A tracked run parked at this picker is waiting on the OPERATOR: say so, or it reads "queued" and,
|
|
1534
|
+
// past the stall window, "possibly stuck".
|
|
1535
|
+
tracked?.onAwaiting("waiting for operator to pick models");
|
|
1536
|
+
agentTree.update(rootId, { detail: "waiting for operator to pick models" });
|
|
1537
|
+
try {
|
|
1538
|
+
const choose = async (): Promise<void> => {
|
|
1539
|
+
if (signal.aborted || !scope.isCurrent()) return;
|
|
1540
|
+
// A concurrent run may have assigned or dismissed these participants while this one waited.
|
|
1541
|
+
const assigned = personaModels(personaConfigs, scope.persona);
|
|
1542
|
+
const pending = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) })
|
|
1543
|
+
.filter((p) => !assigned[p.key] && !assigned[p.agent]);
|
|
1544
|
+
const chosen = await choosePersonaModels(ctx, pending, available, scope, signal);
|
|
1545
|
+
if (!signal.aborted && chosen && Object.keys(chosen).length > 0) savePersonaModels(ctx, scope.persona, chosen);
|
|
1546
|
+
};
|
|
1547
|
+
// Every automatic setup shares the checkpoint dialog line, including commands and mandatory
|
|
1548
|
+
// orchestration. The run's stop closes the active picker or removes it from the line.
|
|
1549
|
+
await inGateLine(signal, undefined, choose);
|
|
1550
|
+
} finally {
|
|
1551
|
+
tracked?.onAwaiting(undefined);
|
|
1552
|
+
agentTree.update(rootId, { detail: "", lastAdvanceAt: Date.now() });
|
|
1553
|
+
}
|
|
1394
1554
|
}
|
|
1395
1555
|
|
|
1396
1556
|
type ModelSelectionScope = { persona: string; isCurrent(): boolean };
|
|
@@ -1427,15 +1587,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1427
1587
|
}
|
|
1428
1588
|
|
|
1429
1589
|
/** UI adapter shared by setup and editing; callers own eligibility and availability policy. */
|
|
1430
|
-
async function choosePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], available:
|
|
1590
|
+
async function choosePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], available: ModelPickerMetadata[], scope: ModelSelectionScope, signal?: AbortSignal): Promise<Record<string, string> | undefined> {
|
|
1431
1591
|
const { persona } = scope;
|
|
1432
1592
|
const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
|
|
1433
1593
|
const chosen: Record<string, string> = {};
|
|
1594
|
+
const addedPromptKeys = new Set<string>();
|
|
1434
1595
|
try {
|
|
1435
1596
|
for (const p of participants) {
|
|
1597
|
+
if (signal?.aborted) return;
|
|
1436
1598
|
// Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
|
|
1437
|
-
// for this session, exactly like an answer is not asked twice.
|
|
1438
|
-
|
|
1599
|
+
// for this session, exactly like an answer is not asked twice. Aborted setup discards
|
|
1600
|
+
// all its picks, so only its newly added markers are rolled back below.
|
|
1601
|
+
const promptKey = `${persona}\0${p.key}`;
|
|
1602
|
+
if (!promptedParticipants.has(promptKey)) addedPromptKeys.add(promptKey);
|
|
1603
|
+
promptedParticipants.add(promptKey);
|
|
1439
1604
|
// Name the core's verticalization in the picker too: choosing a model for "the
|
|
1440
1605
|
// Conservatore" is a different judgement than choosing one for a bare "balthasar".
|
|
1441
1606
|
// An auxiliary actor names the param that declared it ("arbiter judge").
|
|
@@ -1448,14 +1613,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1448
1613
|
// In the TUI: a searchable picker (type to filter) whose viewport follows the
|
|
1449
1614
|
// selection — the built-in select can't scroll a hundreds-long provider list
|
|
1450
1615
|
// usefully. Outside the TUI (RPC), fall back to the built-in select.
|
|
1451
|
-
|
|
1452
|
-
|
|
1616
|
+
let pick: string | undefined;
|
|
1617
|
+
let cancelPicker: (() => void) | undefined;
|
|
1618
|
+
try {
|
|
1619
|
+
pick = ctx.mode === "tui"
|
|
1453
1620
|
? await ctx.ui.custom<string | undefined>(
|
|
1454
|
-
(tui, theme,
|
|
1455
|
-
|
|
1621
|
+
(tui, theme, keybindings, done) => {
|
|
1622
|
+
// custom() has no signal option: its factory's done callback owns dismissal.
|
|
1623
|
+
cancelPicker = () => done(undefined);
|
|
1624
|
+
if (signal?.aborted) cancelPicker();
|
|
1625
|
+
else signal?.addEventListener("abort", cancelPicker, { once: true });
|
|
1626
|
+
return new ModelPicker(tui, theme, title, options, ctx.model?.provider, (ref) => done(ref), {
|
|
1627
|
+
models: available,
|
|
1628
|
+
...(p.model !== undefined ? { currentRef: p.model } : {}),
|
|
1629
|
+
...(keybindings ? { keybindings } : {}),
|
|
1630
|
+
});
|
|
1631
|
+
},
|
|
1456
1632
|
{ overlay: true },
|
|
1457
|
-
|
|
1458
|
-
: await ctx.ui.select(title, options);
|
|
1633
|
+
)
|
|
1634
|
+
: await ctx.ui.select(title, options, signal ? { signal } : undefined);
|
|
1635
|
+
} finally {
|
|
1636
|
+
if (cancelPicker) signal?.removeEventListener("abort", cancelPicker);
|
|
1637
|
+
}
|
|
1638
|
+
if (signal?.aborted) return;
|
|
1459
1639
|
if (!scope.isCurrent()) {
|
|
1460
1640
|
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
1461
1641
|
return;
|
|
@@ -1464,7 +1644,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1464
1644
|
}
|
|
1465
1645
|
} catch {
|
|
1466
1646
|
/* dismissed / no UI → fall back to the default model */
|
|
1647
|
+
} finally {
|
|
1648
|
+
if (signal?.aborted) for (const key of addedPromptKeys) promptedParticipants.delete(key);
|
|
1467
1649
|
}
|
|
1650
|
+
if (signal?.aborted) return;
|
|
1468
1651
|
if (!scope.isCurrent()) {
|
|
1469
1652
|
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
1470
1653
|
return;
|
|
@@ -1478,7 +1661,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1478
1661
|
// future session with no hint that the assignment never landed.
|
|
1479
1662
|
personaConfigs = withPersonaModels(personaConfigs, persona, chosen);
|
|
1480
1663
|
try {
|
|
1481
|
-
|
|
1664
|
+
savedSettings = patchPersonaSettings(settingsFile, { models: { [persona]: chosen } });
|
|
1665
|
+
personaConfigs = savedSettings.personas;
|
|
1482
1666
|
} catch (err) {
|
|
1483
1667
|
ctx.ui.notify(
|
|
1484
1668
|
`pi-persona: could not save the model assignment for "${persona}" (${err instanceof Error ? err.message : String(err)}) — it applies to this session only.`,
|
|
@@ -1599,7 +1783,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1599
1783
|
// The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
|
|
1600
1784
|
// `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
|
|
1601
1785
|
// cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
|
|
1602
|
-
function strategyTreeDeps(ctx: ExtensionContext, rootId: string, nodeParticipants: ReadonlyMap<string, ModelParticipant | null> = new Map()) {
|
|
1786
|
+
function strategyTreeDeps(ctx: ExtensionContext, rootId: string, nodeParticipants: ReadonlyMap<string, ModelParticipant | null> = new Map(), tracked?: TrackedRun) {
|
|
1603
1787
|
return {
|
|
1604
1788
|
onAgentStart: (agent: string, abort: () => void, key?: string) => {
|
|
1605
1789
|
stopRegistry.set(`${rootId}/${key ?? agent}`, abort);
|
|
@@ -1638,6 +1822,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1638
1822
|
agentTree.update(id, progressPatch(p, now));
|
|
1639
1823
|
// Child engine events refresh liveness, not proof of useful work; repaint alone never advances it.
|
|
1640
1824
|
agentTree.update(rootId, { lastAdvanceAt: now });
|
|
1825
|
+
tracked?.onProgress(id, p); // …and the tracked run's own clock and token total, if it has one
|
|
1641
1826
|
},
|
|
1642
1827
|
};
|
|
1643
1828
|
}
|
|
@@ -1647,6 +1832,23 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1647
1832
|
return caps ? { canSpawn: (agent) => canDelegateTo(caps, agent) } : {};
|
|
1648
1833
|
}
|
|
1649
1834
|
|
|
1835
|
+
/** The tree root and run-level stop of one visible run. A tracked run (the `council`/`flow` tools) arrives
|
|
1836
|
+
* with both: `async:<runId>` and the signal its stop key aborts. Anything else (a mandated orchestration,
|
|
1837
|
+
* `/orchestrate`, `/flow`) is rooted here and registers its OWN stop under the root's id, so `stopAgent`,
|
|
1838
|
+
* the overlay's `canStop` and shutdown reach the whole run — Pi's `ctx.signal` is undefined while the agent
|
|
1839
|
+
* is idle, which is exactly when those run, so there is no host signal to lean on. `live` is what going
|
|
1840
|
+
* live adds to the root row: a tracked root already exists, "queued", and restarts its clock here. */
|
|
1841
|
+
function visibleRoot(prefix: string, tracked: TrackedRun | undefined): { rootId: string; signal: AbortSignal; live: AgentNodePatch } {
|
|
1842
|
+
if (tracked) {
|
|
1843
|
+
const now = Date.now();
|
|
1844
|
+
return { rootId: tracked.rootId, signal: tracked.signal, live: { detail: "", startedAt: now, lastAdvanceAt: now } };
|
|
1845
|
+
}
|
|
1846
|
+
const ac = new AbortController();
|
|
1847
|
+
const rootId = nextRootId(prefix);
|
|
1848
|
+
stopRegistry.set(rootId, () => ac.abort());
|
|
1849
|
+
return { rootId, signal: ac.signal, live: {} };
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1650
1852
|
// Run a persona strategy with the unified tree wired in: assign models on first run,
|
|
1651
1853
|
// seed the roster (cores show by name at once), flip ⏳ → ✓/✗ live, clear when done.
|
|
1652
1854
|
async function runStrategyVisible(
|
|
@@ -1654,34 +1856,39 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1654
1856
|
orch: OrchestrationGrammar,
|
|
1655
1857
|
task: string,
|
|
1656
1858
|
idPrefix: string,
|
|
1657
|
-
|
|
1859
|
+
tracked?: TrackedRun,
|
|
1658
1860
|
) {
|
|
1659
1861
|
const label = resolveStrategyName(orch) ?? "strategy";
|
|
1660
1862
|
const roster = resolveOrchestrationMembers(orch, teams);
|
|
1661
1863
|
const participants = runParticipants(ctx, { strategy: label, ...(orch.params ? { params: orch.params } : {}), members: roster });
|
|
1662
|
-
await ensurePersonaModels(ctx, participants);
|
|
1663
1864
|
const participantsByNode = participantByNode(participants);
|
|
1664
|
-
const rootId =
|
|
1665
|
-
agentTree.add({ id: rootId, label, status: "running", kind: "council" });
|
|
1666
|
-
// Seed the whole roster at once (cores show by name immediately); "queued" until the
|
|
1667
|
-
// engine actually starts each one — an honest view under the concurrency limit.
|
|
1668
|
-
const seedKeys = rosterNodeKeys(roster);
|
|
1669
|
-
roster.forEach((m, i) => {
|
|
1670
|
-
const a = rosterSpec(m).agent;
|
|
1671
|
-
const key = seedKeys[i] ?? a;
|
|
1672
|
-
const participant = participantForNode(participantsByNode, key);
|
|
1673
|
-
const assignmentKey = participant?.key ?? bareAssignmentKey(key);
|
|
1674
|
-
agentTree.add({ id: `${rootId}/${key}`, label: coreLabel(ctx, a, key, assignmentKey, participant), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a, assignmentKey, participant), detail: "queued" });
|
|
1675
|
-
});
|
|
1865
|
+
const { rootId, signal, live } = visibleRoot(`${idPrefix}:${label}`, tracked);
|
|
1866
|
+
agentTree.add({ id: rootId, label, status: "running", kind: "council", ...live });
|
|
1676
1867
|
try {
|
|
1868
|
+
await ensurePersonaModels(ctx, participants, signal, rootId, tracked);
|
|
1869
|
+
if (signal.aborted) {
|
|
1870
|
+
agentTree.update(rootId, { status: "stopped" });
|
|
1871
|
+
const cancelled: AgentResult = { agent: label, output: "cancelled before orchestration started", usage: emptyUsage(), ok: false, error: "cancelled", failureKind: "abort" };
|
|
1872
|
+
return cancelled;
|
|
1873
|
+
}
|
|
1874
|
+
// Seed the whole roster at once (cores show by name immediately); "queued" until the
|
|
1875
|
+
// engine actually starts each one — an honest view under the concurrency limit.
|
|
1876
|
+
const seedKeys = rosterNodeKeys(roster);
|
|
1877
|
+
roster.forEach((m, i) => {
|
|
1878
|
+
const a = rosterSpec(m).agent;
|
|
1879
|
+
const key = seedKeys[i] ?? a;
|
|
1880
|
+
const participant = participantForNode(participantsByNode, key);
|
|
1881
|
+
const assignmentKey = participant?.key ?? bareAssignmentKey(key);
|
|
1882
|
+
agentTree.add({ id: `${rootId}/${key}`, label: coreLabel(ctx, a, key, assignmentKey, participant), parentId: rootId, status: "running", kind: "subagent", ...coreTelemetryMeta(ctx, a, assignmentKey, participant), detail: "queued" });
|
|
1883
|
+
});
|
|
1677
1884
|
// The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
|
|
1678
1885
|
// cooperatively between rounds, so an aborted run stops convening instead of running every
|
|
1679
1886
|
// remaining round against an already-cancelled engine (docs/STRATEGIES.md).
|
|
1680
|
-
const result = await runPersonaStrategy(orch, task, { engine: buildEngine(signal, undefined, { providerFallback: false }), teams, limits: RUN_LIMITS, ...sessionModelDep(ctx),
|
|
1681
|
-
agentTree.update(rootId, { status: signal
|
|
1887
|
+
const result = await runPersonaStrategy(orch, task, { engine: buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }), teams, limits: RUN_LIMITS, ...sessionModelDep(ctx), signal, ...strategyTreeDeps(ctx, rootId, participantsByNode, tracked), ...strategySpawnGate(), ...modelFallbackDep(ctx) });
|
|
1888
|
+
agentTree.update(rootId, { status: signal.aborted ? "stopped" : result?.ok === false ? "failed" : "done" });
|
|
1682
1889
|
return result ?? undefined;
|
|
1683
1890
|
} catch (error) {
|
|
1684
|
-
agentTree.update(rootId, { status: signal
|
|
1891
|
+
agentTree.update(rootId, { status: signal.aborted ? "stopped" : "failed" });
|
|
1685
1892
|
throw error;
|
|
1686
1893
|
} finally {
|
|
1687
1894
|
clearStops(rootId);
|
|
@@ -1720,12 +1927,45 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1720
1927
|
if (found === undefined) return undefined;
|
|
1721
1928
|
return parseFlow(found);
|
|
1722
1929
|
}
|
|
1930
|
+
// Installed flows with what a chooser needs: the description and whether a checkpoint pauses
|
|
1931
|
+
// for a human. An unparseable file stays a bare name (running it reports the parse error).
|
|
1932
|
+
function flowCatalog(cwd: string): BriefFlow[] {
|
|
1933
|
+
return listFlows(cwd).map((name): BriefFlow => {
|
|
1934
|
+
const parsed = loadFlow(cwd, name);
|
|
1935
|
+
const flow = parsed?.ok ? parsed.flow : undefined;
|
|
1936
|
+
return {
|
|
1937
|
+
name,
|
|
1938
|
+
...(flow?.description ? { description: flow.description } : {}),
|
|
1939
|
+
...(flow?.phases.some((p) => p.gate) ? { gated: true } : {}),
|
|
1940
|
+
};
|
|
1941
|
+
});
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
// Pi's extension selector is ONE slot: a second concurrent `ctx.ui.select` displaces the first without ever
|
|
1945
|
+
// resolving it, orphaning a run behind a prompt nobody can see. Background model setup and flow checkpoints
|
|
1946
|
+
// share this line, so their prompts open one at a time in arrival order. A stopped run leaves it at once.
|
|
1947
|
+
let gateLine: Promise<unknown> = Promise.resolve();
|
|
1948
|
+
function inGateLine<T>(signal: AbortSignal, stopped: T, open: () => Promise<T>): Promise<T> {
|
|
1949
|
+
const turn = gateLine.then(() => (signal.aborted ? stopped : open()));
|
|
1950
|
+
gateLine = turn.catch(() => undefined);
|
|
1951
|
+
let onAbort: (() => void) | undefined;
|
|
1952
|
+
const left = new Promise<T>((resolve) => {
|
|
1953
|
+
if (signal.aborted) resolve(stopped);
|
|
1954
|
+
else {
|
|
1955
|
+
onAbort = () => resolve(stopped);
|
|
1956
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1957
|
+
}
|
|
1958
|
+
});
|
|
1959
|
+
return Promise.race([turn, left]).finally(() => {
|
|
1960
|
+
if (onAbort) signal.removeEventListener("abort", onAbort);
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1723
1963
|
|
|
1724
1964
|
// Run a flow's DAG with the unified tree (phases as nodes, cores beneath) + journaled
|
|
1725
|
-
// resume: a prior run's journal (keyed by flow
|
|
1965
|
+
// resume: a prior run's journal (keyed by flow + task + cwd) skips already-done phases; the
|
|
1726
1966
|
// journal is cleared on a fully-successful run.
|
|
1727
|
-
async function runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, baseTask: string,
|
|
1728
|
-
const hash =
|
|
1967
|
+
async function runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, baseTask: string, tracked?: TrackedRun): Promise<FlowOutcome> {
|
|
1968
|
+
const hash = flowRunHash(spec, baseTask, ctx.cwd);
|
|
1729
1969
|
const journalDir = join(personaDataDir(), "flows");
|
|
1730
1970
|
try {
|
|
1731
1971
|
mkdirSync(journalDir, { recursive: true });
|
|
@@ -1751,17 +1991,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1751
1991
|
return [phase.id, { members, participants }] as const;
|
|
1752
1992
|
}),
|
|
1753
1993
|
);
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
const flowRoot = nextRootId(`flow:${spec.name}`);
|
|
1757
|
-
agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow" });
|
|
1758
|
-
for (const p of spec.phases) {
|
|
1759
|
-
const pid = `${flowRoot}/${p.id}`;
|
|
1760
|
-
const node: AddNodeInput = { id: pid, label: `${p.id} · ${p.strategy}`, parentId: flowRoot, status: resume[p.id] ? "done" : "running", kind: "phase" };
|
|
1761
|
-
if (resume[p.id]) node.detail = "resumed";
|
|
1762
|
-
agentTree.add(node);
|
|
1763
|
-
}
|
|
1994
|
+
const { rootId: flowRoot, signal, live } = visibleRoot(`flow:${spec.name}`, tracked);
|
|
1995
|
+
agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow", ...live });
|
|
1764
1996
|
try {
|
|
1997
|
+
await ensurePersonaModels(ctx, spec.phases.flatMap((p) => phasePlans.get(p.id)?.participants ?? []), signal, flowRoot, tracked);
|
|
1998
|
+
if (signal.aborted) {
|
|
1999
|
+
agentTree.update(flowRoot, { status: "stopped" });
|
|
2000
|
+
return { ok: false, results: {}, output: "cancelled before flow started", error: "cancelled", failureKind: "abort", cancelled: true };
|
|
2001
|
+
}
|
|
2002
|
+
for (const p of spec.phases) {
|
|
2003
|
+
const pid = `${flowRoot}/${p.id}`;
|
|
2004
|
+
const node: AddNodeInput = { id: pid, label: `${p.id} · ${p.strategy}`, parentId: flowRoot, status: resume[p.id] ? "done" : "running", kind: "phase" };
|
|
2005
|
+
if (resume[p.id]) node.detail = "resumed";
|
|
2006
|
+
agentTree.add(node);
|
|
2007
|
+
}
|
|
1765
2008
|
// Yield so a concurrent flow can seed its own tree before we fail-closed on refs —
|
|
1766
2009
|
// the check still runs BEFORE any phase/engine work.
|
|
1767
2010
|
await Promise.resolve();
|
|
@@ -1784,7 +2027,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1784
2027
|
const outcome = await runFlow(spec, baseTask, {
|
|
1785
2028
|
hash,
|
|
1786
2029
|
resume,
|
|
1787
|
-
|
|
2030
|
+
signal,
|
|
1788
2031
|
journal: journalWriter(journalPath),
|
|
1789
2032
|
onPhase: (id, status, result) => {
|
|
1790
2033
|
const patch: { status: AgentNodeStatus; output?: string; detail?: string } = {
|
|
@@ -1810,12 +2053,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1810
2053
|
const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
|
|
1811
2054
|
if (phase.roster) orch.roster = phase.roster;
|
|
1812
2055
|
const r = await runPersonaStrategy(orch, task, {
|
|
1813
|
-
engine: buildEngine(signal, undefined, { providerFallback: false }),
|
|
2056
|
+
engine: buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }),
|
|
1814
2057
|
teams,
|
|
1815
2058
|
limits: RUN_LIMITS,
|
|
1816
2059
|
...sessionModelDep(ctx),
|
|
1817
|
-
|
|
1818
|
-
...strategyTreeDeps(ctx, pid, participantsByNode),
|
|
2060
|
+
signal, // cooperative per-round abort inside the phase's strategy, not just the engine
|
|
2061
|
+
...strategyTreeDeps(ctx, pid, participantsByNode, tracked),
|
|
1819
2062
|
...strategySpawnGate(),
|
|
1820
2063
|
...modelFallbackDep(ctx),
|
|
1821
2064
|
});
|
|
@@ -1826,26 +2069,34 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1826
2069
|
// boundary, never an informational prompt. Approval is journaled for resume.
|
|
1827
2070
|
approveGate: async (phase, result) => {
|
|
1828
2071
|
if (!ctx.hasUI) return false;
|
|
1829
|
-
const
|
|
2072
|
+
const next = spec.phases.filter((p) => p.needs?.includes(phase.id));
|
|
2073
|
+
// The run is the OPERATOR's to release here, not the model's: say so on the tree row and, for a
|
|
2074
|
+
// tracked run, on its peek line — silence at a modal is neither a stall nor a reason to wake anyone.
|
|
2075
|
+
const waiting = `waiting for operator at checkpoint ${phase.id}`;
|
|
2076
|
+
agentTree.update(flowRoot, { detail: waiting });
|
|
2077
|
+
tracked?.onAwaiting(waiting);
|
|
1830
2078
|
try {
|
|
1831
|
-
|
|
2079
|
+
// Reject first: a stray Enter on the default row fails closed. The run signal dismisses
|
|
2080
|
+
// the prompt, so a stopped flow is not held open by a question nobody will answer.
|
|
2081
|
+
const pick = await inGateLine(signal, undefined, () =>
|
|
2082
|
+
ctx.ui.select(gateReviewText({ flow: spec.name, phase: phase.id, next, output: result.output }), ["Reject", "Approve"], { signal }),
|
|
2083
|
+
);
|
|
1832
2084
|
return pick === "Approve";
|
|
1833
2085
|
} catch {
|
|
1834
2086
|
return false;
|
|
2087
|
+
} finally {
|
|
2088
|
+
tracked?.onAwaiting(undefined);
|
|
2089
|
+
agentTree.update(flowRoot, { detail: "", lastAdvanceAt: Date.now() });
|
|
1835
2090
|
}
|
|
1836
2091
|
},
|
|
1837
2092
|
});
|
|
1838
|
-
if (outcome.ok)
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
/* ignore */
|
|
1843
|
-
}
|
|
1844
|
-
}
|
|
1845
|
-
agentTree.update(flowRoot, { status: signal?.aborted ? "stopped" : outcome.ok ? "done" : "failed" });
|
|
2093
|
+
if (outcome.ok) clearJournal(journalPath, hash); // this run is finished; other tasks' progress stays
|
|
2094
|
+
// A rejected checkpoint is a pause the operator chose, not a failure (same call as the flow tool).
|
|
2095
|
+
const disposition = flowDisposition(outcome);
|
|
2096
|
+
agentTree.update(flowRoot, { status: disposition === "failed" && signal.aborted ? "stopped" : disposition });
|
|
1846
2097
|
return outcome;
|
|
1847
2098
|
} catch (error) {
|
|
1848
|
-
agentTree.update(flowRoot, { status: signal
|
|
2099
|
+
agentTree.update(flowRoot, { status: signal.aborted ? "stopped" : "failed" });
|
|
1849
2100
|
throw error;
|
|
1850
2101
|
} finally {
|
|
1851
2102
|
clearStops(flowRoot);
|
|
@@ -1854,15 +2105,53 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1854
2105
|
}
|
|
1855
2106
|
}
|
|
1856
2107
|
|
|
1857
|
-
//
|
|
1858
|
-
|
|
2108
|
+
// Discovery is acquired once per base model/session. Repeated turns and nudges reuse it;
|
|
2109
|
+
// explicit discovery/refresh replaces it. Execution preflights still consult the live registry.
|
|
2110
|
+
let modelCatalog: { key: string; models: ModelCostMetadata[]; candidates: string | undefined } | undefined;
|
|
2111
|
+
function modelCatalogKey(ctx: ExtensionContext): string {
|
|
2112
|
+
return `${ctx.sessionManager?.getSessionId?.() ?? ""}\0${ctx.model?.provider ?? ""}\0${ctx.model?.id ?? ""}`;
|
|
2113
|
+
}
|
|
2114
|
+
async function refreshModelCatalog(ctx: ExtensionContext, signal: AbortSignal) {
|
|
2115
|
+
// Pi contexts have live getters: capture before awaiting so an old refresh cannot
|
|
2116
|
+
// discard the snapshot acquired after a session or base-model switch.
|
|
2117
|
+
const key = modelCatalogKey(ctx);
|
|
2118
|
+
try {
|
|
2119
|
+
return await ctx.modelRegistry.refresh?.({ signal });
|
|
2120
|
+
} finally {
|
|
2121
|
+
// A failed/aborted refresh may still have updated some providers.
|
|
2122
|
+
if (modelCatalog?.key === key) modelCatalog = undefined;
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
function canDiscoverModels(): boolean {
|
|
2126
|
+
const caps = controller.capabilities;
|
|
2127
|
+
return pi.getActiveTools().includes("models") && (!caps || canCallTool(caps, "models"));
|
|
2128
|
+
}
|
|
2129
|
+
function currentModelCatalog(ctx: ExtensionContext, refresh = false) {
|
|
2130
|
+
const key = modelCatalogKey(ctx);
|
|
2131
|
+
if (refresh || modelCatalog?.key !== key) {
|
|
2132
|
+
const models = configuredModels(ctx);
|
|
2133
|
+
modelCatalog = { key, models, candidates: renderNudgeModels(models, ctx.model) };
|
|
2134
|
+
}
|
|
2135
|
+
return modelCatalog;
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Default discovery stays near the base model; explicit searches keep the full eligible catalog.
|
|
2139
|
+
function searchModels(ctx: ExtensionContext, query?: string): { lines: string[]; total: number; capped: boolean; shortlist: boolean } {
|
|
1859
2140
|
const session = ctx.model?.provider;
|
|
1860
2141
|
const isSession = (ref: string): boolean => session !== undefined && ref.startsWith(`${session}/`);
|
|
1861
|
-
const
|
|
1862
|
-
const
|
|
2142
|
+
const catalog = currentModelCatalog(ctx, true).models;
|
|
2143
|
+
const metadata = new Map(catalog.map((m) => [`${m.provider}/${m.id}`, m]));
|
|
2144
|
+
const tiers = modelCostTiers(catalog, shortlistModels(catalog, undefined, Number.MAX_SAFE_INTEGER));
|
|
2145
|
+
const shortlist = !query?.trim();
|
|
2146
|
+
const all = catalog.map((m) => `${m.provider}/${m.id}`);
|
|
2147
|
+
const filtered = orderModelCandidates(filterModels(all, query ?? "").map((ref) => metadata.get(ref)!), ctx.model).map((m) => `${m.provider}/${m.id}`);
|
|
1863
2148
|
const cap = 40;
|
|
1864
|
-
const
|
|
1865
|
-
|
|
2149
|
+
const shown = shortlist
|
|
2150
|
+
? recommendModels(catalog, ctx.model, cap).map((m) => `${m.provider}/${m.id}`)
|
|
2151
|
+
: filtered.slice(0, cap);
|
|
2152
|
+
const self = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
2153
|
+
const lines = shown.map((r) => `${isSession(r) ? "★ " : " "}${r} ${modelCandidateDetails(metadata.get(r)!, tiers.get(r))}${r === self ? " (you)" : ""}`);
|
|
2154
|
+
return { lines, total: filtered.length, capped: filtered.length > lines.length, shortlist };
|
|
1866
2155
|
}
|
|
1867
2156
|
|
|
1868
2157
|
// The per-turn delegation brief (core/brief.ts): live roster + standing hand-off default,
|
|
@@ -1870,11 +2159,19 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1870
2159
|
// persona line has no force, and regenerated from the live registry so it cannot desync.
|
|
1871
2160
|
// Capability-aware: a persona that denies `delegate` gets none, and the agent list is
|
|
1872
2161
|
// filtered to the persona's allowed targets.
|
|
2162
|
+
/** The installed agents the active capabilities allow as delegate targets (all, with no persona). */
|
|
2163
|
+
function delegateTargets(): AgentConfig[] {
|
|
2164
|
+
const caps = controller.capabilities;
|
|
2165
|
+
return caps ? agents.filter((a) => canDelegateTo(caps, a.name)) : agents;
|
|
2166
|
+
}
|
|
2167
|
+
|
|
1873
2168
|
function delegationBrief(ctx: ExtensionContext): string | undefined {
|
|
1874
2169
|
const caps = controller.capabilities;
|
|
1875
2170
|
if (caps && !canFanOut(caps)) return undefined;
|
|
1876
|
-
const targets =
|
|
2171
|
+
const targets = delegateTargets();
|
|
1877
2172
|
const delegation = controller.activePersona?.delegation;
|
|
2173
|
+
const canListModels = canDiscoverModels();
|
|
2174
|
+
const candidates = canListModels && ctx.model ? currentModelCatalog(ctx).candidates : undefined;
|
|
1878
2175
|
const teamAgents: Record<string, string[]> = {};
|
|
1879
2176
|
for (const [name, members] of Object.entries(teams)) teamAgents[name] = members.map((m) => rosterSpec(m).agent);
|
|
1880
2177
|
return buildDelegationBrief({
|
|
@@ -1887,11 +2184,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1887
2184
|
...(a.isolation ? { isolation: a.isolation } : {}),
|
|
1888
2185
|
})),
|
|
1889
2186
|
teams: teamAgents,
|
|
1890
|
-
|
|
2187
|
+
// Methods are only taught to a persona that can call them: an allowlist without `council`/`flow` is not
|
|
2188
|
+
// told to convene one (the call would be refused by the capability gate).
|
|
2189
|
+
flows: !caps || canCallTool(caps, "flow") ? flowCatalog(ctx.cwd) : [],
|
|
2190
|
+
...(caps && !canCallTool(caps, "council") ? { council: false } : {}),
|
|
2191
|
+
canListModels,
|
|
2192
|
+
...(candidates ? { modelCandidates: candidates } : {}),
|
|
1891
2193
|
standing: controller.activePersona !== undefined,
|
|
1892
|
-
// Interactive sessions delegate in the background
|
|
1893
|
-
//
|
|
1894
|
-
|
|
2194
|
+
// Interactive sessions ALWAYS delegate in the background; headless (`pi -p`) always
|
|
2195
|
+
// blocks (the single turn must carry the result) — mirror that in the copy.
|
|
2196
|
+
background: ctx.hasUI === true,
|
|
1895
2197
|
...(delegation?.requireBrief !== undefined ? { requireBrief: delegation.requireBrief } : {}),
|
|
1896
2198
|
...(delegation?.outputContract ? { outputContract: delegation.outputContract } : {}),
|
|
1897
2199
|
...(delegation?.requireDisjointWrites !== undefined ? { requireDisjointWrites: delegation.requireDisjointWrites } : {}),
|
|
@@ -1915,6 +2217,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1915
2217
|
function doctorReport(): string {
|
|
1916
2218
|
const lines: string[] = [];
|
|
1917
2219
|
lines.push(`pi-persona — active: ${controller.activePersona?.label ?? "none"}`);
|
|
2220
|
+
lines.push(`settings: ${settingsFile} (/persona-settings; preferences: next session or Pi /reload)`);
|
|
1918
2221
|
lines.push(`engine backend: ${config.engine === "child" ? "child-process" : "in-process"}`);
|
|
1919
2222
|
const piVersion = installedPiVersion();
|
|
1920
2223
|
lines.push(`pi: ${piVersion ?? "unknown"} (requires ≥ ${MIN_PI_VERSION})${piVersion && !satisfiesFloor(piVersion, MIN_PI_VERSION) ? " — BELOW FLOOR" : ""}`);
|
|
@@ -1942,7 +2245,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1942
2245
|
lines.push(`data root migration: moved ${dataRootMigration.moved.length}, kept ${dataRootMigration.kept.length} (legacy pi-persona root folded into ${personaDataDir()})`);
|
|
1943
2246
|
}
|
|
1944
2247
|
for (const warning of dataRootMigration.warnings) lines.push(warning);
|
|
1945
|
-
lines.push(`personas (${personas.length}): ${personas.map((p) => p.name).join(", ") ||
|
|
2248
|
+
lines.push(`personas (${personas.length}): ${personas.map((p) => p.name).join(", ") || NO_PERSONAS_HINT}`);
|
|
1946
2249
|
lines.push(`agents (${agents.length}): ${agents.map((a) => a.name).join(", ") || "—"}`);
|
|
1947
2250
|
if (definitionCollisions.length > 0) {
|
|
1948
2251
|
lines.push(`definition collisions (${definitionCollisions.length}, omitted): ${definitionCollisions.map((collision) => collision.name).join(", ")}`);
|
|
@@ -1997,7 +2300,14 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1997
2300
|
get lastCtx() { return lastCtx; },
|
|
1998
2301
|
set lastCtx(value) { lastCtx = value; },
|
|
1999
2302
|
get disposed() { return disposed; },
|
|
2000
|
-
set disposed(value) {
|
|
2303
|
+
set disposed(value) {
|
|
2304
|
+
disposed = value;
|
|
2305
|
+
if (value) {
|
|
2306
|
+
cancelAgentWidgetRender();
|
|
2307
|
+
widgetClock.stop();
|
|
2308
|
+
wakeRows.clearMonitors();
|
|
2309
|
+
}
|
|
2310
|
+
},
|
|
2001
2311
|
get orchestrating() { return orchestrating; },
|
|
2002
2312
|
set orchestrating(value) { orchestrating = value; },
|
|
2003
2313
|
get processingDeferredOrchestration() { return processingDeferredOrchestration; },
|
|
@@ -2017,6 +2327,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2017
2327
|
telemetryAgent,
|
|
2018
2328
|
delegationNudge,
|
|
2019
2329
|
persistenceNudge,
|
|
2330
|
+
pollGuard,
|
|
2331
|
+
tracker,
|
|
2020
2332
|
appendNudgeEntry,
|
|
2021
2333
|
completionNotifier,
|
|
2022
2334
|
intercomNotifier,
|
|
@@ -2062,6 +2374,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2062
2374
|
};
|
|
2063
2375
|
installHooks(pi, hookHost, exocom);
|
|
2064
2376
|
|
|
2377
|
+
// ONE launch pool for every background run: a delegate leg, a `council` and a `flow` each take a slot
|
|
2378
|
+
// (maxConcurrency). The strategy SDK's own per-run semaphore gates the legs INSIDE a council or flow.
|
|
2379
|
+
const launchPool = new Semaphore(RUN_LIMITS.maxConcurrency);
|
|
2380
|
+
const backgroundRuns = createBackgroundRuns({
|
|
2381
|
+
tracker,
|
|
2382
|
+
agentTree,
|
|
2383
|
+
stopRegistry,
|
|
2384
|
+
slots: launchPool,
|
|
2385
|
+
completionNotifier,
|
|
2386
|
+
startPeek,
|
|
2387
|
+
childUsage,
|
|
2388
|
+
publishPersonaCost,
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2065
2391
|
registerDelegateTool(pi, {
|
|
2066
2392
|
get lastCtx() { return lastCtx; },
|
|
2067
2393
|
set lastCtx(value) { lastCtx = value; },
|
|
@@ -2070,21 +2396,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2070
2396
|
contractNames: () => installedContractNames(contractDefs),
|
|
2071
2397
|
buildEngine,
|
|
2072
2398
|
agentTree,
|
|
2073
|
-
nextRootId,
|
|
2074
2399
|
tracker,
|
|
2400
|
+
runs: backgroundRuns,
|
|
2401
|
+
launchPool,
|
|
2075
2402
|
config: { ledgerV2: config.ledgerV2 },
|
|
2076
2403
|
RUN_LIMITS,
|
|
2077
2404
|
publishAgentTool,
|
|
2078
2405
|
stopRegistry,
|
|
2079
2406
|
steerRegistry,
|
|
2080
|
-
stopRequested,
|
|
2081
|
-
ensurePersonaModels,
|
|
2082
|
-
clearStops,
|
|
2083
|
-
clearSteers,
|
|
2084
2407
|
drainBusBlock,
|
|
2085
2408
|
startPeek,
|
|
2086
|
-
childUsage,
|
|
2087
|
-
publishPersonaCost,
|
|
2088
2409
|
});
|
|
2089
2410
|
|
|
2090
2411
|
// ── f8 cycle ────────────────────────────────────────────────────────────────
|
|
@@ -2092,7 +2413,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2092
2413
|
description: "Cycle persona (pi-persona)",
|
|
2093
2414
|
handler: async (ctx: ExtensionContext) => {
|
|
2094
2415
|
lastCtx = ctx;
|
|
2095
|
-
if (personas.length === 0)
|
|
2416
|
+
if (personas.length === 0) {
|
|
2417
|
+
if (ctx.hasUI) ctx.ui.notify(NO_PERSONAS_HINT, "info");
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2096
2420
|
const current = controller.activePersona;
|
|
2097
2421
|
const idx = current ? personas.findIndex((p) => p.name === current.name) : -1;
|
|
2098
2422
|
const next = idx + 1;
|
|
@@ -2130,14 +2454,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2130
2454
|
SUPERVISOR,
|
|
2131
2455
|
STALL_FLAG_MS,
|
|
2132
2456
|
waitingRunIds: waitingAsyncRunIds,
|
|
2457
|
+
pollGuard,
|
|
2133
2458
|
missingRunMessage,
|
|
2134
2459
|
stopAgent,
|
|
2135
2460
|
steerAgent,
|
|
2136
2461
|
steerRegistry,
|
|
2137
2462
|
stopRequested,
|
|
2138
|
-
drainBusBlock,
|
|
2139
2463
|
scanForSurrender,
|
|
2140
|
-
get disposed() { return disposed; },
|
|
2141
2464
|
childUsage,
|
|
2142
2465
|
publishPersonaCost,
|
|
2143
2466
|
});
|
|
@@ -2152,6 +2475,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2152
2475
|
set lastCtx(value) { lastCtx = value; },
|
|
2153
2476
|
canRun: canRunMonitor,
|
|
2154
2477
|
monitors,
|
|
2478
|
+
archive: monitorArchive,
|
|
2155
2479
|
});
|
|
2156
2480
|
|
|
2157
2481
|
registerCouncilTool(pi, {
|
|
@@ -2160,9 +2484,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2160
2484
|
controller,
|
|
2161
2485
|
get personas() { return personas; },
|
|
2162
2486
|
runStrategyVisible,
|
|
2487
|
+
runs: backgroundRuns,
|
|
2163
2488
|
drainBusBlock,
|
|
2164
|
-
childUsage,
|
|
2165
|
-
publishPersonaCost,
|
|
2166
2489
|
});
|
|
2167
2490
|
|
|
2168
2491
|
registerFlowTool(pi, {
|
|
@@ -2171,8 +2494,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2171
2494
|
loadFlow,
|
|
2172
2495
|
listFlows,
|
|
2173
2496
|
runFlowVisible,
|
|
2174
|
-
|
|
2175
|
-
publishPersonaCost,
|
|
2497
|
+
runs: backgroundRuns,
|
|
2176
2498
|
});
|
|
2177
2499
|
|
|
2178
2500
|
// ── f9: navigable agent overlay ──────────────────────────────────────────────
|
|
@@ -2191,6 +2513,67 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2191
2513
|
},
|
|
2192
2514
|
});
|
|
2193
2515
|
|
|
2516
|
+
// The dedicated settings surface never replaces Pi's /settings and never calls /reload.
|
|
2517
|
+
registerPersonaSettings(pi, {
|
|
2518
|
+
file: settingsFile,
|
|
2519
|
+
companions: () => collectCompanionSettings(pi.events),
|
|
2520
|
+
activeConfig: config,
|
|
2521
|
+
env: process.env,
|
|
2522
|
+
read: () => {
|
|
2523
|
+
savedSettings = readPersonaSettings(settingsFile);
|
|
2524
|
+
personaConfigs = savedSettings.personas;
|
|
2525
|
+
return savedSettings;
|
|
2526
|
+
},
|
|
2527
|
+
write: (patch) => {
|
|
2528
|
+
savedSettings = patchPersonaSettings(settingsFile, patch);
|
|
2529
|
+
personaConfigs = savedSettings.personas;
|
|
2530
|
+
},
|
|
2531
|
+
isCurrent: (ctx) => !disposed && !ctx.signal?.aborted &&
|
|
2532
|
+
(ctx.sessionManager?.getSessionId?.() === undefined ||
|
|
2533
|
+
lastCtx?.sessionManager?.getSessionId?.() === ctx.sessionManager.getSessionId()),
|
|
2534
|
+
diagnostics: doctorReport,
|
|
2535
|
+
models: async (ctx) => {
|
|
2536
|
+
lastCtx = ctx;
|
|
2537
|
+
personaConfigs = readConfigStore();
|
|
2538
|
+
const session = ctx.sessionManager?.getSessionId?.();
|
|
2539
|
+
await openPersonaModelSettings(ctx, {
|
|
2540
|
+
owners: () => personas.map((persona) => ({ name: persona.name, label: persona.label })),
|
|
2541
|
+
activeOwner: () => controller.activePersona?.name,
|
|
2542
|
+
profiles: (owner) => {
|
|
2543
|
+
const persona = personas.find((p) => p.name === owner);
|
|
2544
|
+
return [
|
|
2545
|
+
...(persona?.orchestration?.strategy ? [{ name: "@orchestration", label: "Automatic orchestration" }] : []),
|
|
2546
|
+
...personas.filter((p) => p.name !== owner && (p.council?.strategy || p.council?.members || p.council?.membersProblem))
|
|
2547
|
+
.map((p) => ({ name: p.name, label: `Council: ${p.label}` })),
|
|
2548
|
+
];
|
|
2549
|
+
},
|
|
2550
|
+
participants: (owner, profile) => {
|
|
2551
|
+
const persona = personas.find((p) => p.name === owner);
|
|
2552
|
+
if (!persona) throw new Error(`persona "${owner}" is no longer installed`);
|
|
2553
|
+
const orchestrationStrategy = persona.orchestration?.strategy;
|
|
2554
|
+
if (profile === "@orchestration" && orchestrationStrategy && persona.orchestration) {
|
|
2555
|
+
const orchestration = persona.orchestration;
|
|
2556
|
+
const panel = resolveOrchestrationMembers(orchestration, teams);
|
|
2557
|
+
return runParticipants(ctx, { strategy: orchestrationStrategy, members: panel,
|
|
2558
|
+
...(orchestration.params ? { params: orchestration.params } : {}) }, owner);
|
|
2559
|
+
}
|
|
2560
|
+
const resolved = resolveCouncilInvocation(personas, persona, profile ? { persona: profile } : {});
|
|
2561
|
+
if (!resolved.ok) throw new Error(resolved.error);
|
|
2562
|
+
const { strategy, roster, members, params } = resolved.value;
|
|
2563
|
+
const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
|
|
2564
|
+
return runParticipants(ctx, { strategy, members: panel, params }, owner);
|
|
2565
|
+
},
|
|
2566
|
+
saved: (owner) => personaModels(personaConfigs, owner),
|
|
2567
|
+
write: (owner, models) => {
|
|
2568
|
+
savedSettings = patchPersonaSettings(settingsFile, { models: { [owner]: models } });
|
|
2569
|
+
personaConfigs = savedSettings.personas;
|
|
2570
|
+
},
|
|
2571
|
+
isCurrent: () => !disposed && (session === undefined || lastCtx?.sessionManager?.getSessionId?.() === session),
|
|
2572
|
+
refreshCatalog: (signal) => refreshModelCatalog(ctx, signal),
|
|
2573
|
+
});
|
|
2574
|
+
},
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2194
2577
|
// ── /persona ─────────────────────────────────────────────────────────────────
|
|
2195
2578
|
pi.registerCommand("persona", {
|
|
2196
2579
|
description: "Switch persona or edit council models: /persona [name|off|list|reload|seed|restore|models [council-persona]]",
|
|
@@ -2219,7 +2602,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2219
2602
|
const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
|
|
2220
2603
|
const deadline = AbortSignal.timeout(20_000);
|
|
2221
2604
|
const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
|
|
2222
|
-
const result = await ctx
|
|
2605
|
+
const result = await refreshModelCatalog(ctx, signal);
|
|
2223
2606
|
if (!scope.isCurrent()) {
|
|
2224
2607
|
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
2225
2608
|
return;
|
|
@@ -2291,14 +2674,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2291
2674
|
return;
|
|
2292
2675
|
}
|
|
2293
2676
|
if (arg === "" || arg === "list") {
|
|
2294
|
-
// Empty by design on a fresh install: personas load only from the user dir, which the
|
|
2295
|
-
// user populates on purpose. Point them at the install gesture rather than leaving a
|
|
2296
|
-
// bare "(none)" that reads like something is broken.
|
|
2297
2677
|
if (personas.length === 0) {
|
|
2298
|
-
ctx.ui.notify(
|
|
2299
|
-
"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).",
|
|
2300
|
-
"info",
|
|
2301
|
-
);
|
|
2678
|
+
ctx.ui.notify(NO_PERSONAS_HINT, "info");
|
|
2302
2679
|
return;
|
|
2303
2680
|
}
|
|
2304
2681
|
const lines = personas.map(
|
|
@@ -2380,16 +2757,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2380
2757
|
|
|
2381
2758
|
// ── /models + a `models` tool — discover/search model ids ─────────────────────
|
|
2382
2759
|
pi.registerCommand("models", {
|
|
2383
|
-
description: "
|
|
2760
|
+
description: "Show model candidates or search the full catalog (provider/id): /models [query]",
|
|
2384
2761
|
handler: async (args, ctx) => {
|
|
2385
2762
|
lastCtx = ctx;
|
|
2386
|
-
const { lines, total, capped } = searchModels(ctx, args);
|
|
2763
|
+
const { lines, total, capped, shortlist } = searchModels(ctx, args);
|
|
2387
2764
|
if (lines.length === 0) {
|
|
2388
2765
|
ctx.ui.notify(`models: no match for "${args.trim()}"`, "warning");
|
|
2389
2766
|
return;
|
|
2390
2767
|
}
|
|
2391
|
-
const head = `models (${total}${capped ?
|
|
2392
|
-
ctx.ui.notify(`${head}\n${lines.join("\n")}
|
|
2768
|
+
const head = `models (${total}${shortlist || capped ? `, showing ${lines.length}${shortlist ? " nearby family/version candidates" : " matches"}` : ""})${ctx.model ? ` · session ★ ${ctx.model.provider}/${ctx.model.id}` : ""}`;
|
|
2769
|
+
ctx.ui.notify(`${head}\n${lines.join("\n")}\nAccount support may vary.${shortlist || capped ? " Search the full catalog with /models <query>." : ""}`, "info");
|
|
2393
2770
|
},
|
|
2394
2771
|
});
|
|
2395
2772
|
|
|
@@ -2437,11 +2814,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2437
2814
|
return;
|
|
2438
2815
|
}
|
|
2439
2816
|
try {
|
|
2440
|
-
|
|
2441
|
-
appendCommandResult(`flow ${orch.flow}`, outcome.output || "(flow produced no output)", outcome.ok, {
|
|
2442
|
-
...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
|
|
2443
|
-
...(outcome.error ? { error: outcome.error } : {}),
|
|
2444
|
-
});
|
|
2817
|
+
appendFlowResult(orch.flow, await runFlowVisible(ctx, parsed.flow, task));
|
|
2445
2818
|
} catch (err) {
|
|
2446
2819
|
ctx.ui.notify(`orchestrate failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
2447
2820
|
}
|
|
@@ -2460,7 +2833,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2460
2833
|
}
|
|
2461
2834
|
try {
|
|
2462
2835
|
const result = await runStrategyVisible(ctx, orch, task, "orchestrate");
|
|
2463
|
-
|
|
2836
|
+
appendOperatorResult(
|
|
2464
2837
|
`orchestrate ${strategyName}`,
|
|
2465
2838
|
result?.output || "(no output)",
|
|
2466
2839
|
result?.ok ?? false,
|
|
@@ -2482,9 +2855,15 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2482
2855
|
lastCtx = ctx;
|
|
2483
2856
|
const trimmed = args.trim();
|
|
2484
2857
|
if (!trimmed || trimmed === "list") {
|
|
2485
|
-
const flows =
|
|
2858
|
+
const flows = flowCatalog(ctx.cwd);
|
|
2486
2859
|
ctx.ui.notify(
|
|
2487
|
-
flows.length
|
|
2860
|
+
flows.length
|
|
2861
|
+
? [
|
|
2862
|
+
"flows — /flow <name> <task>",
|
|
2863
|
+
...flows.map((f) =>
|
|
2864
|
+
` ${f.name}${f.description ? ` — ${compactInlineText(f.description, { maxChars: 96 })}` : ""}${f.gated ? " [pauses at a checkpoint for your approval]" : ""}`),
|
|
2865
|
+
].join("\n")
|
|
2866
|
+
: "no flows found — the bundled ones did not load (check the pi-persona install); your own go in .pi/flows/*.flow.json",
|
|
2488
2867
|
"info",
|
|
2489
2868
|
);
|
|
2490
2869
|
return;
|
|
@@ -2506,11 +2885,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2506
2885
|
return;
|
|
2507
2886
|
}
|
|
2508
2887
|
try {
|
|
2509
|
-
|
|
2510
|
-
appendCommandResult(`flow ${name}`, outcome.output || "(flow produced no output)", outcome.ok, {
|
|
2511
|
-
...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
|
|
2512
|
-
...(outcome.error ? { error: outcome.error } : {}),
|
|
2513
|
-
});
|
|
2888
|
+
appendFlowResult(name, await runFlowVisible(ctx, parsed.flow, task));
|
|
2514
2889
|
} catch (err) {
|
|
2515
2890
|
ctx.ui.notify(`flow failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
2516
2891
|
}
|