@aeondave/pi-persona 1.17.1 → 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 +59 -28
- package/CHANGELOG.md +234 -0
- package/README.md +16 -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 +183 -55
- package/docs/EXPERIENCE.md +19 -9
- package/docs/MONITORS.md +62 -3
- package/docs/REFERENCE.md +82 -32
- 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 +15 -10
- 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 +1 -1
- package/src/core/timer.ts +34 -9
- package/src/engine/adapter.ts +12 -8
- package/src/engine/async.ts +165 -146
- package/src/engine/child.ts +2 -2
- package/src/engine/host-module.ts +12 -0
- package/src/engine/inproc.ts +12 -7
- package/src/engine/stream.ts +4 -20
- package/src/exocom/install.ts +2 -1
- package/src/extension/engine.ts +43 -16
- package/src/extension/hooks.ts +106 -36
- package/src/extension/persona-model-settings.ts +31 -13
- package/src/extension/shared.ts +15 -7
- package/src/extension.ts +505 -241
- 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 -5
- 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/persona.ts +7 -3
- 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 +310 -280
- 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 +9 -3
- 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/extension.ts
CHANGED
|
@@ -11,12 +11,13 @@
|
|
|
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";
|
|
@@ -35,11 +36,14 @@ import {
|
|
|
35
36
|
type SpineLegacyResult,
|
|
36
37
|
type SpineLegacySelection,
|
|
37
38
|
} from "./core/seed.ts";
|
|
38
|
-
import { buildDelegationBrief } from "./core/brief.ts";
|
|
39
|
+
import { type BriefFlow, buildDelegationBrief } from "./core/brief.ts";
|
|
39
40
|
import { canCallTool, canDelegateTo, canFanOut, type RunLimits } from "./core/capabilities.ts";
|
|
40
41
|
import { fenceUntrusted } from "./core/fence.ts";
|
|
41
42
|
import { sanitizeDisplayLabel } from "./core/display-label.ts";
|
|
42
|
-
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";
|
|
43
47
|
import { installedPiVersion, MIN_PI_VERSION, satisfiesFloor } from "./core/pi-compat.ts";
|
|
44
48
|
import { type EngineAdapterBroker } from "./engine/adapter.ts";
|
|
45
49
|
import { SupervisorBroker } from "./extension/broker-host.ts";
|
|
@@ -53,7 +57,6 @@ import {
|
|
|
53
57
|
type PendingAsk,
|
|
54
58
|
} from "./extension/shared.ts";
|
|
55
59
|
export {
|
|
56
|
-
agentNodeStatusForDelegate,
|
|
57
60
|
boundExocomInboundBatch,
|
|
58
61
|
canDeliverPersonaNotification,
|
|
59
62
|
canonicalExocomTelemetryTargets,
|
|
@@ -84,22 +87,25 @@ import { collectCompanionSettings } from "./extension/companion-settings.ts";
|
|
|
84
87
|
import { parseExocomArgv } from "./exocom/activation.ts";
|
|
85
88
|
import { formatLedgerStatus } from "./exocom/status.ts";
|
|
86
89
|
import { registerDelegateTool } from "./tools/delegate-tool.ts";
|
|
87
|
-
import { registerIntercomTool } from "./tools/intercom-tool.ts";
|
|
90
|
+
import { describeRuns, registerIntercomTool } from "./tools/intercom-tool.ts";
|
|
88
91
|
import { registerTimerTool } from "./tools/timer.ts";
|
|
89
|
-
import { registerMonitorTool } from "./tools/monitor.ts";
|
|
92
|
+
import { registerMonitorTool, renderMonitorLog } from "./tools/monitor.ts";
|
|
93
|
+
import { MonitorArchive } from "./monitor/archive.ts";
|
|
90
94
|
import { createMonitorSession, type MonitorSession } from "./monitor/session.ts";
|
|
95
|
+
import { createBackgroundRuns, type TrackedRun } from "./tools/background-run.ts";
|
|
91
96
|
import { registerCouncilTool } from "./tools/council.ts";
|
|
92
97
|
import { registerFlowTool } from "./tools/flow.ts";
|
|
93
98
|
import { registerModelsTool } from "./tools/models.ts";
|
|
94
|
-
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";
|
|
95
100
|
import { emptyUsage, type ToolEvent } from "./engine/stream.ts";
|
|
96
101
|
import { brokerEndpoint } from "./bus/broker/paths.ts";
|
|
97
102
|
import { InProcessBus } from "./bus/inproc.ts";
|
|
98
103
|
import { loadContracts, loadDefinitions, loadPresets, loadTeams, type LoadResult, type ScopedDir } from "./loader.ts";
|
|
99
|
-
import { type FlowSpec,
|
|
100
|
-
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";
|
|
101
106
|
import { runFlow } from "./orchestration/flow-run.ts";
|
|
102
|
-
import
|
|
107
|
+
import { Semaphore } from "./orchestration/parallel.ts";
|
|
108
|
+
import { flowDisposition, type FlowOutcome } from "./orchestration/flow-run.ts";
|
|
103
109
|
import { type RosterMember, roleHint, rosterNodeKeys, rosterSpec } from "./orchestration/roster.ts";
|
|
104
110
|
import type { AgentProgress, AgentStatus, SteerFn } from "./orchestration/sdk.ts";
|
|
105
111
|
import { knownParams, strategyNames } from "./orchestration/strategy.ts";
|
|
@@ -126,13 +132,15 @@ import { shortModel } from "./tools/delegate.ts";
|
|
|
126
132
|
import { formatInbox } from "./tools/intercom.ts";
|
|
127
133
|
import { renderTimerFire, TimerScheduler, type TimerEntry } from "./core/timer.ts";
|
|
128
134
|
import { AgentOverlay } from "./ui/agent-overlay.ts";
|
|
129
|
-
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";
|
|
130
137
|
import { LiveClock } from "./ui/live-clock.ts";
|
|
131
|
-
import { filterModels, ModelPicker, orderModelRefs } from "./ui/model-picker.ts";
|
|
132
|
-
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";
|
|
133
141
|
import { ChildUsageLedger, formatPersonaCostStatus, formatUsage, PERSONA_COST_STATUS_KEY } from "./ui/usage.ts";
|
|
134
142
|
|
|
135
|
-
const RUN_LIMITS: RunLimits = {
|
|
143
|
+
export const RUN_LIMITS: RunLimits = {
|
|
136
144
|
// A generous anti-runaway backstop, not a council-size cap: a declared ensemble
|
|
137
145
|
// (e.g. 9 members × several rounds) must fit; the token budget is the real cost guard.
|
|
138
146
|
maxChildren: 64,
|
|
@@ -175,6 +183,8 @@ interface CommandResultEntry {
|
|
|
175
183
|
label: string;
|
|
176
184
|
content: string;
|
|
177
185
|
ok: boolean;
|
|
186
|
+
/** A flow that paused at a rejected checkpoint: not a failure, so it is titled "stopped". */
|
|
187
|
+
stoppedAtGate?: string;
|
|
178
188
|
failureKind?: FailureKind;
|
|
179
189
|
error?: string;
|
|
180
190
|
}
|
|
@@ -188,8 +198,17 @@ interface CheckInEntry {
|
|
|
188
198
|
kind?: "routine" | "stall";
|
|
189
199
|
}
|
|
190
200
|
|
|
201
|
+
interface RunFailureEntry {
|
|
202
|
+
id: string;
|
|
203
|
+
label: string;
|
|
204
|
+
agent: string;
|
|
205
|
+
model?: string;
|
|
206
|
+
durationMs?: number;
|
|
207
|
+
error: string;
|
|
208
|
+
}
|
|
209
|
+
|
|
191
210
|
/**
|
|
192
|
-
* Keep the immediate operator-facing settlement
|
|
211
|
+
* Keep the immediate operator-facing settlement feedback and the semantic completion delivery on one
|
|
193
212
|
* path. A supervisor-requested abort is terminal, but it is not an execution error: the tracker
|
|
194
213
|
* classifies it as `stopped`, so the UI reports it at info level while the completion still reaches
|
|
195
214
|
* the supervisor exactly once through the normal notifier.
|
|
@@ -198,31 +217,51 @@ export function announceAsyncRunSettlement(
|
|
|
198
217
|
run: AsyncRun,
|
|
199
218
|
notify: ((message: string, level: "info" | "error") => void) | undefined,
|
|
200
219
|
enqueue: (run: AsyncRun) => void,
|
|
220
|
+
presentFailure?: (run: AsyncRun) => void,
|
|
201
221
|
): void {
|
|
202
222
|
const id = compactInlineText(run.id, { maxChars: 80 }) || "run";
|
|
203
223
|
const agent = compactInlineText(run.label ?? run.agent, { maxChars: 80 }) || compactInlineText(run.agent, { maxChars: 80 }) || "agent";
|
|
204
224
|
try {
|
|
205
225
|
if (run.status === "done") notify?.(`async run ${id} (${agent}) completed`, "info");
|
|
206
226
|
else if (run.status === "stopped") notify?.(`async run ${id} (${agent}) stopped`, "info");
|
|
207
|
-
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
|
+
}
|
|
208
234
|
} catch {
|
|
209
235
|
// Cosmetic notification only: a broken UI must never swallow the terminal report.
|
|
210
236
|
}
|
|
211
237
|
enqueue(run);
|
|
212
238
|
}
|
|
213
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
|
+
|
|
214
251
|
/** Agents actually IN FLIGHT: a RUNNING node with no children of its own. "Has a parent" is not the
|
|
215
252
|
* same as "is an agent" — a flow phase is a container that also has a parent, a root-level `async:`
|
|
216
253
|
* run is a leaf that has none, and a settled leg still sits in the tree until its root is torn
|
|
217
|
-
* down.
|
|
218
|
-
|
|
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 {
|
|
219
258
|
const parents = new Set<string>();
|
|
220
259
|
for (const node of nodes) {
|
|
221
260
|
if (node.parentId !== undefined) parents.add(node.parentId);
|
|
222
261
|
}
|
|
223
262
|
let count = 0;
|
|
224
263
|
for (const node of nodes) {
|
|
225
|
-
if (node.status === "running" && !parents.has(node.id)) count++;
|
|
264
|
+
if (node.status === "running" && node.kind !== "delegate" && !isWakeSource(node) && !parents.has(node.id)) count++;
|
|
226
265
|
}
|
|
227
266
|
return count;
|
|
228
267
|
}
|
|
@@ -285,75 +324,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
285
324
|
let config = resolveConfig(process.env);
|
|
286
325
|
if (config.disabled) return;
|
|
287
326
|
const exocomArgs = parseExocomArgv(options.exocomArgv ?? process.argv);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
? message.content
|
|
292
|
-
: message.content.map((part) => part.type === "text" ? part.text : "[image]").join("\n");
|
|
293
|
-
const full = sanitizeTerminalText(raw);
|
|
294
|
-
const preview = compactVisibleText(full, { maxLines: 4, maxLineChars: 100 });
|
|
295
|
-
const firstLine = full.split("\n", 1)[0] ?? "";
|
|
296
|
-
// External peer text is data and must never choose our UI chrome. Internal follow-ups own
|
|
297
|
-
// their first status line; parse only that line and do not turn a healthy "0 failed" red.
|
|
298
|
-
const isFailure = customType === "pi-persona"
|
|
299
|
-
&& !/\b0 failed\b/i.test(firstLine)
|
|
300
|
-
&& /\b(?:failed|failure|error|rejected|unresolved)\b/i.test(firstLine);
|
|
301
|
-
const titleColor = isFailure ? "error" : customType === "exocom_received" ? "accent" : "success";
|
|
302
|
-
const title = theme.fg(titleColor, theme.bold(label));
|
|
303
|
-
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full || "(no output)")}`, outputPad, 0);
|
|
304
|
-
const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
305
|
-
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, outputPad, 0);
|
|
306
|
-
});
|
|
307
|
-
};
|
|
308
|
-
// These follow-ups remain complete in the model context, but the human-facing card starts as a
|
|
309
|
-
// bounded digest. This is the central progressive-disclosure boundary for async completions and
|
|
310
|
-
// external peer messages; expanding the card reveals the exact sanitized payload.
|
|
311
|
-
registerFollowUpRenderer("pi-persona", "pi-persona · update");
|
|
312
|
-
registerFollowUpRenderer("exocom_received", "exocom · inbound");
|
|
313
|
-
pi.registerEntryRenderer("pi-persona-result", (entry, { expanded }, theme) => {
|
|
314
|
-
const data = entry.data as CommandResultEntry;
|
|
315
|
-
const full = sanitizeTerminalText(data.content || "(no output)");
|
|
316
|
-
const cause = compactVisibleText(
|
|
317
|
-
[data.failureKind, data.error].filter((part): part is string => !!part).join(" · "),
|
|
318
|
-
{ maxLines: 1, maxLineChars: 80 },
|
|
319
|
-
).text;
|
|
320
|
-
const status = data.ok ? "complete" : "failed";
|
|
321
|
-
const label = compactInlineText(data.label, { maxChars: 96 }) || "result";
|
|
322
|
-
const title = theme.fg(data.ok ? "success" : "error", theme.bold(`${label} · ${status}${!data.ok && cause !== "(no output)" ? ` · ${cause}` : ""}`));
|
|
323
|
-
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
|
|
324
|
-
const preview = compactVisibleText(full, { maxLines: 3, maxLineChars: 100 });
|
|
325
|
-
const hint = preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
326
|
-
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
|
|
327
|
-
});
|
|
328
|
-
pi.registerEntryRenderer("pi-persona-nudge", (entry, { expanded }, theme) => {
|
|
329
|
-
const data = entry.data as NudgeEntry;
|
|
330
|
-
const full = sanitizeTerminalText(data.content || "⟢ pi-persona · checkpoint");
|
|
331
|
-
const lines = full.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
332
|
-
const header = compactInlineText(lines[0]?.replace(/^⟢\s*/, "") ?? "", { maxChars: 96 }) || "pi-persona · checkpoint";
|
|
333
|
-
const details = lines.slice(1);
|
|
334
|
-
const title = theme.fg("warning", theme.bold(`⟢ ${header}`));
|
|
335
|
-
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", details.join("\n") || "(no detail)")}`, 0, 0);
|
|
336
|
-
const trigger = details.find((line) => line.startsWith("Trigger:"));
|
|
337
|
-
const action = details.find((line) => /^(?:Action|Verify|Otherwise):/.test(line));
|
|
338
|
-
const selected = [trigger, action].filter((line): line is string => line !== undefined);
|
|
339
|
-
const previewLines = selected.length > 0 ? selected : details.slice(0, 2);
|
|
340
|
-
const preview = compactVisibleText(previewLines.join("\n"), { maxLines: 2, maxLineChars: 100 });
|
|
341
|
-
const hasMore = preview.truncated || details.some((line) => !previewLines.includes(line));
|
|
342
|
-
const hint = hasMore ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
343
|
-
return new Text(`${title}\n${theme.fg("toolOutput", preview.text)}${hint}`, 0, 0);
|
|
344
|
-
});
|
|
345
|
-
pi.registerEntryRenderer("pi-persona-status", (entry, { expanded }, theme) => {
|
|
346
|
-
const data = entry.data as CheckInEntry;
|
|
347
|
-
const full = sanitizeTerminalText(data.content || "Async activity · routine check-in");
|
|
348
|
-
const title = theme.fg(data.kind === "stall" ? "warning" : "accent", theme.bold(data.kind === "stall" ? "pi-persona · possible stall" : "pi-persona · async status"));
|
|
349
|
-
if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
|
|
350
|
-
// The entry title already labels this as async status. Spend the compact preview on the
|
|
351
|
-
// aggregate and first run row so the operator sees the selected alias and usage label.
|
|
352
|
-
const digestLines = full.split("\n").slice(1).filter((line) => line.trim().length > 0);
|
|
353
|
-
const preview = compactVisibleText(digestLines.slice(0, 2).join("\n") || full, { maxLines: 2, maxLineChars: 140 });
|
|
354
|
-
const hasMore = preview.truncated || digestLines.length > 2;
|
|
355
|
-
const hint = hasMore ? `\n${theme.fg("dim", expandDetailHint())}` : "";
|
|
356
|
-
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),
|
|
357
330
|
});
|
|
358
331
|
const appendNudgeEntry = (content: string): void => {
|
|
359
332
|
pi.appendEntry("pi-persona-nudge", { content } satisfies NudgeEntry);
|
|
@@ -365,16 +338,35 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
365
338
|
label: string,
|
|
366
339
|
content: string,
|
|
367
340
|
ok: boolean,
|
|
368
|
-
meta: { failureKind?: FailureKind; error?: string } = {},
|
|
341
|
+
meta: { failureKind?: FailureKind; error?: string; stoppedAtGate?: string } = {},
|
|
369
342
|
): void => {
|
|
370
343
|
pi.appendEntry("pi-persona-result", {
|
|
371
344
|
label,
|
|
372
345
|
content,
|
|
373
346
|
ok,
|
|
347
|
+
...(meta.stoppedAtGate !== undefined ? { stoppedAtGate: meta.stoppedAtGate } : {}),
|
|
374
348
|
...(meta.failureKind ? { failureKind: meta.failureKind } : {}),
|
|
375
349
|
...(meta.error ? { error: meta.error } : {}),
|
|
376
350
|
} satisfies CommandResultEntry);
|
|
377
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
|
+
});
|
|
378
370
|
const enabledSpineRoles = (): SpineLegacySelection => ({
|
|
379
371
|
supervisor: config.spine === "on",
|
|
380
372
|
worker: config.spineLegs === "on",
|
|
@@ -572,6 +564,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
572
564
|
// The unified live tree of every in-flight agent — strategy cores, delegate
|
|
573
565
|
// sub-agents, dynamic specialists — rendered as one sticky widget above the input.
|
|
574
566
|
const agentTree = new AgentTree();
|
|
567
|
+
const wakeRows = new WakeRows();
|
|
575
568
|
// Explicit telemetry is the public observability seam consumed by pi-persona-flow. It is
|
|
576
569
|
// session-scoped and starts only after persona + exocom reconciliation in session_start.
|
|
577
570
|
let telemetry: TelemetryProducer | undefined;
|
|
@@ -594,7 +587,6 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
594
587
|
if (node.kind) return node.kind;
|
|
595
588
|
if (node.id.startsWith("flow:")) return "flow";
|
|
596
589
|
if (node.id.startsWith("strategy:") || node.id.startsWith("council:")) return "council";
|
|
597
|
-
if (node.id.startsWith("delegate:")) return "delegate";
|
|
598
590
|
return "subagent";
|
|
599
591
|
}
|
|
600
592
|
function telemetryStatus(node: AgentNode): TelemetryAgentStatus {
|
|
@@ -660,6 +652,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
660
652
|
function publishAgentTreeChange(change: AgentTreeChange): void {
|
|
661
653
|
const producer = telemetry;
|
|
662
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;
|
|
663
658
|
if (change.type === "added") {
|
|
664
659
|
lastAgentProjection.set(change.node.id, JSON.stringify(telemetryAgent(change.node)));
|
|
665
660
|
producer.publishAgentAdded(telemetryAgent(change.node));
|
|
@@ -670,7 +665,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
670
665
|
return;
|
|
671
666
|
}
|
|
672
667
|
if (change.type === "removed") {
|
|
673
|
-
for (const node of change.nodes) {
|
|
668
|
+
for (const node of change.nodes.filter((n) => !isWakeSource(n))) {
|
|
674
669
|
lastAgentProjection.delete(node.id);
|
|
675
670
|
const status = node.status === "running" ? "stopped" : telemetryStatus(node);
|
|
676
671
|
producer.publish("agent.removed", { id: node.id, status });
|
|
@@ -683,10 +678,17 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
683
678
|
agentTree.onChange(publishAgentTreeChange);
|
|
684
679
|
// Delegation nudge: watches the supervisor's OWN tool-result stream and reminds it to hand off
|
|
685
680
|
// when it grinds heavy work by hand (config.nudge; gated to delegating personas at the hook).
|
|
686
|
-
|
|
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
|
+
});
|
|
687
687
|
// Persistence nudge: the counterweight — when a delegated leg comes back BLOCKED/UNKNOWN, remind
|
|
688
688
|
// the supervisor not to bank a premature surrender (same config.nudge gate + hook as above).
|
|
689
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;
|
|
690
692
|
// Every visible run (strategy, council, flow) gets its own tree root — see makeRootIdAllocator.
|
|
691
693
|
const nextRootId = makeRootIdAllocator();
|
|
692
694
|
// node id → abort that one agent (so the overlay can STOP a single sub-agent).
|
|
@@ -708,7 +710,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
708
710
|
fn();
|
|
709
711
|
agentTree.update(nodeId, { detail: "stopping…" }); // visible at once; the run's own settle path writes the terminal status
|
|
710
712
|
flushAgentWidgetRender();
|
|
711
|
-
|
|
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);
|
|
712
716
|
steerRegistry.delete(nodeId); // a hard-stopped agent is no longer steerable (mirror the strategy path)
|
|
713
717
|
return true;
|
|
714
718
|
}
|
|
@@ -717,7 +721,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
717
721
|
return deriveWaitingNodeIds(nodes, (id) => steerRegistry.get(id)?.isWaitingForSupervisor?.() === true);
|
|
718
722
|
}
|
|
719
723
|
function waitingAsyncRunIds(): Set<string> {
|
|
720
|
-
|
|
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));
|
|
721
728
|
}
|
|
722
729
|
const clearSteers = (prefix: string): void => {
|
|
723
730
|
for (const k of [...steerRegistry.keys()]) if (k === prefix || k.startsWith(`${prefix}/`)) steerRegistry.delete(k);
|
|
@@ -740,6 +747,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
740
747
|
const WIDGET_RENDER_DELAY_MS = 16;
|
|
741
748
|
let widgetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
742
749
|
let widgetRenderPending = false;
|
|
750
|
+
let publishingMonitorOutput = false;
|
|
743
751
|
let widgetLinesInitialized = false;
|
|
744
752
|
let lastWidgetLines: readonly string[] | undefined;
|
|
745
753
|
let widgetStatusInitialized = false;
|
|
@@ -750,14 +758,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
750
758
|
if (!a || !b || a.length !== b.length) return false;
|
|
751
759
|
return a.every((line, i) => line === b[i]);
|
|
752
760
|
}
|
|
753
|
-
|
|
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 {
|
|
754
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();
|
|
755
778
|
const empty = nodes.length === 0;
|
|
756
779
|
const lines = empty ? undefined : renderAgentTreeSummary([...nodes], 8, { now: Date.now(), stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingTreeIds(nodes) });
|
|
757
|
-
const status =
|
|
780
|
+
const status = agentCountStatus(nodes);
|
|
758
781
|
try {
|
|
759
782
|
if (!widgetLinesInitialized || !sameWidgetLines(lastWidgetLines, lines)) {
|
|
760
|
-
lastCtx.ui.setWidget("persona-agents", lines, { placement: "aboveEditor" });
|
|
783
|
+
lastCtx.ui.setWidget("persona-agents", lines ? compactWidget(lines) : undefined, { placement: "aboveEditor" });
|
|
761
784
|
lastWidgetLines = lines;
|
|
762
785
|
widgetLinesInitialized = true;
|
|
763
786
|
}
|
|
@@ -776,8 +799,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
776
799
|
}
|
|
777
800
|
function renderAgentStatus(): void {
|
|
778
801
|
if (!lastCtx) return;
|
|
779
|
-
const
|
|
780
|
-
const status = nodes.length === 0 ? undefined : String(inFlightAgentCount(nodes));
|
|
802
|
+
const status = agentCountStatus(agentTree.snapshot());
|
|
781
803
|
try {
|
|
782
804
|
if (!widgetStatusInitialized || lastWidgetStatus !== status) {
|
|
783
805
|
lastCtx.ui.setStatus("persona-agents", status);
|
|
@@ -839,6 +861,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
839
861
|
// elapsed time and the stall badge moving without a progress event, and stops itself when idle.
|
|
840
862
|
const widgetClock = new LiveClock({ intervalMs: 1_000, isLive: () => agentTree.hasRunning(), onTick: renderAgentWidget });
|
|
841
863
|
agentTree.onChange((change) => {
|
|
864
|
+
if (publishingMonitorOutput) return;
|
|
842
865
|
const refreshStatus = widgetStatusMayHaveChanged(change);
|
|
843
866
|
if (disposed) {
|
|
844
867
|
// session_shutdown clears the tree after marking this activation disposed. Keep teardown
|
|
@@ -966,6 +989,11 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
966
989
|
definitionWarnings = [...result.warnings, ...contractsLoad.warnings];
|
|
967
990
|
}
|
|
968
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
|
+
|
|
969
997
|
// Install the bundled defaults into the user's agent dir — this is the ONLY way personas/agents
|
|
970
998
|
// become active (they are not a live builtin layer). `/persona seed` pulls in missing defaults,
|
|
971
999
|
// `/persona restore` force-overwrites them back to the originals. First-run auto-install is
|
|
@@ -1117,12 +1145,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1117
1145
|
};
|
|
1118
1146
|
// Async runs outlive the turn that launched them; on completion we surface the result(s) back as
|
|
1119
1147
|
// ONE coalesced notice so the (idle) supervisor can react — e.g. retry a failure with another model.
|
|
1120
|
-
//
|
|
1121
|
-
// premature-surrender counterweight
|
|
1122
|
-
//
|
|
1123
|
-
//
|
|
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.
|
|
1124
1152
|
const completionNotifier = new IdleCoalescingNotifier<AsyncRun>({
|
|
1125
1153
|
...idleDelivery,
|
|
1154
|
+
isReady: (run) => tracker.batchSettled(run),
|
|
1126
1155
|
render: (runs) => {
|
|
1127
1156
|
const report = renderCompletion(runs, fenceUntrusted, (t) => scanForSurrender(t));
|
|
1128
1157
|
// Retention is a bound, not a promise: a fan-out wider than it settles more payloads than the
|
|
@@ -1155,6 +1184,21 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1155
1184
|
...idleDelivery,
|
|
1156
1185
|
render: (entries) => renderTimerFire(entries),
|
|
1157
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
|
+
}
|
|
1158
1202
|
const timerScheduler = new TimerScheduler({
|
|
1159
1203
|
now: () => Date.now(),
|
|
1160
1204
|
setTimer: (fn, ms) => {
|
|
@@ -1163,7 +1207,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1163
1207
|
return h;
|
|
1164
1208
|
},
|
|
1165
1209
|
clearTimer: (h) => clearTimeout(h as ReturnType<typeof setTimeout>),
|
|
1166
|
-
|
|
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); },
|
|
1167
1217
|
});
|
|
1168
1218
|
const canRunMonitor = () => {
|
|
1169
1219
|
if (disposed) return false;
|
|
@@ -1172,26 +1222,66 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1172
1222
|
return active.includes("monitor") && active.includes("bash")
|
|
1173
1223
|
&& (!caps || (canCallTool(caps, "monitor") && canCallTool(caps, "bash")));
|
|
1174
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
|
+
});
|
|
1175
1231
|
monitors = createMonitorSession({
|
|
1232
|
+
idPrefix: `monitor-${randomUUID().slice(0, 8)}`,
|
|
1176
1233
|
isIdle: idleDelivery.isIdle,
|
|
1177
1234
|
deliver: (text) => sendPersonaFollowUp(pi, text),
|
|
1178
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
|
+
},
|
|
1179
1262
|
});
|
|
1180
1263
|
const tracker = new AsyncRunTracker({ maxRetained: config.asyncRetain });
|
|
1181
1264
|
// The premature-surrender counterweight rides the SAME kill switch as the by-hand nudge
|
|
1182
|
-
// (PI_PERSONA_NUDGE=off).
|
|
1183
|
-
//
|
|
1184
|
-
// 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.
|
|
1185
1267
|
const scanForSurrender = (text: string): string | undefined => (config.nudge ? persistenceNudge.scan(text) : undefined);
|
|
1186
1268
|
// A run id that is neither live nor retained has two very different meanings: it never existed, or
|
|
1187
1269
|
// its payload was evicted under retention pressure. Only the second deserves an explanation — and a
|
|
1188
1270
|
// bare "no such run" for a result the supervisor was just TOLD to fetch is exactly the dead end
|
|
1189
1271
|
// this distinction exists to prevent.
|
|
1190
1272
|
function missingRunMessage(id: string, display: string | undefined): string {
|
|
1191
|
-
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));
|
|
1192
1282
|
return (
|
|
1193
|
-
`
|
|
1194
|
-
|
|
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 })}.`
|
|
1195
1285
|
);
|
|
1196
1286
|
}
|
|
1197
1287
|
// Turns the periodic peek from a poll into an exception signal: it surfaces a leg only when it
|
|
@@ -1205,11 +1295,21 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1205
1295
|
stopRequested.delete(`async:${run.id}`);
|
|
1206
1296
|
if (disposed) return; // instance torn down — don't notify the next session or re-arm a cancelled timer
|
|
1207
1297
|
// Immediate human feedback + one semantic completion. Explicit stops are informational,
|
|
1208
|
-
// while
|
|
1298
|
+
// while failures get an expandable TUI card (a toast elsewhere). Every result is still delivered.
|
|
1209
1299
|
announceAsyncRunSettlement(
|
|
1210
1300
|
run,
|
|
1211
1301
|
lastCtx ? (message, level) => lastCtx?.ui.notify(message, level) : undefined,
|
|
1212
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,
|
|
1213
1313
|
);
|
|
1214
1314
|
if (tracker.running().length === 0) stopPeek(); // no live runs → stop the idle peek
|
|
1215
1315
|
});
|
|
@@ -1244,9 +1344,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1244
1344
|
});
|
|
1245
1345
|
});
|
|
1246
1346
|
|
|
1247
|
-
// Async-run watchdog: the fast PI_PERSONA_PEEK_MS cadence
|
|
1248
|
-
// leg
|
|
1249
|
-
//
|
|
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.
|
|
1250
1351
|
let peekTimer: ReturnType<typeof setInterval> | undefined;
|
|
1251
1352
|
// Last time progress status was surfaced (a new stall alert or routine card), for the slow cadence.
|
|
1252
1353
|
let lastPeekAt = 0;
|
|
@@ -1257,7 +1358,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1257
1358
|
}
|
|
1258
1359
|
}
|
|
1259
1360
|
function startPeek(): void {
|
|
1260
|
-
// Fast
|
|
1361
|
+
// Fast ticks and routine cards opt out independently. The carrier timer runs while EITHER is on
|
|
1261
1362
|
// and ticks at the faster of the two (a disabled one is Infinity, so it never drives).
|
|
1262
1363
|
const fastMs = config.peekEveryMs > 0 ? config.peekEveryMs : Number.POSITIVE_INFINITY;
|
|
1263
1364
|
const checkMs = config.checkInEveryMs > 0 ? config.checkInEveryMs : Number.POSITIVE_INFINITY;
|
|
@@ -1296,23 +1397,14 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1296
1397
|
lastPeekAt = now;
|
|
1297
1398
|
appendStatus(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingRuns }), "routine");
|
|
1298
1399
|
}
|
|
1299
|
-
//
|
|
1300
|
-
//
|
|
1400
|
+
// A stall is operator-only (appended above) and never costs a model turn; unread non-ask
|
|
1401
|
+
// messages keep their actionable route.
|
|
1301
1402
|
if (!canDeliverPersonaNotification(orchestrating, processingDeferredOrchestration, lastCtx?.isIdle?.() === true) || lastCtx?.hasPendingMessages?.() === true) return;
|
|
1302
1403
|
const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
|
|
1303
|
-
|
|
1304
|
-
? peekWatcher.firstWakeCandidates(runs.filter((run) => isAsyncRunStalled(run, now, STALL_FLAG_MS)))
|
|
1305
|
-
: [];
|
|
1306
|
-
if (wakeCandidates.length === 0 && unread.length === 0) return;
|
|
1307
|
-
const parts: string[] = [];
|
|
1308
|
-
if (wakeCandidates.length > 0) parts.push(buildPeekAlert(wakeCandidates, { now }));
|
|
1309
|
-
if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
|
|
1404
|
+
if (unread.length === 0) return;
|
|
1310
1405
|
try {
|
|
1311
|
-
sendPersonaFollowUp(pi, `[pi-persona] ${
|
|
1312
|
-
|
|
1313
|
-
} catch {
|
|
1314
|
-
peekWatcher.retryReports(wakeCandidates);
|
|
1315
|
-
}
|
|
1406
|
+
sendPersonaFollowUp(pi, `[pi-persona] 📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
|
|
1407
|
+
} catch { /* best-effort wake: the session may be tearing down */ }
|
|
1316
1408
|
}, tickMs);
|
|
1317
1409
|
peekTimer.unref?.();
|
|
1318
1410
|
}
|
|
@@ -1431,15 +1523,34 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1431
1523
|
* Only a live UI ever sees a dialog (`ctx.hasUI`), and only when there is more than one
|
|
1432
1524
|
* authenticated model to choose between.
|
|
1433
1525
|
*/
|
|
1434
|
-
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> {
|
|
1435
1527
|
const scope = modelSelectionScope(ctx);
|
|
1436
|
-
if (!scope || !ctx.hasUI) return;
|
|
1528
|
+
if (!scope || !ctx.hasUI || signal.aborted) return;
|
|
1437
1529
|
const missing = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) });
|
|
1438
1530
|
if (missing.length === 0) return;
|
|
1439
1531
|
const available = configuredModels(ctx);
|
|
1440
1532
|
if (available.length < 2) return; // automatic setup cannot diversify a single model
|
|
1441
|
-
|
|
1442
|
-
|
|
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
|
+
}
|
|
1443
1554
|
}
|
|
1444
1555
|
|
|
1445
1556
|
type ModelSelectionScope = { persona: string; isCurrent(): boolean };
|
|
@@ -1476,15 +1587,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1476
1587
|
}
|
|
1477
1588
|
|
|
1478
1589
|
/** UI adapter shared by setup and editing; callers own eligibility and availability policy. */
|
|
1479
|
-
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> {
|
|
1480
1591
|
const { persona } = scope;
|
|
1481
1592
|
const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
|
|
1482
1593
|
const chosen: Record<string, string> = {};
|
|
1594
|
+
const addedPromptKeys = new Set<string>();
|
|
1483
1595
|
try {
|
|
1484
1596
|
for (const p of participants) {
|
|
1597
|
+
if (signal?.aborted) return;
|
|
1485
1598
|
// Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
|
|
1486
|
-
// for this session, exactly like an answer is not asked twice.
|
|
1487
|
-
|
|
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);
|
|
1488
1604
|
// Name the core's verticalization in the picker too: choosing a model for "the
|
|
1489
1605
|
// Conservatore" is a different judgement than choosing one for a bare "balthasar".
|
|
1490
1606
|
// An auxiliary actor names the param that declared it ("arbiter judge").
|
|
@@ -1497,14 +1613,29 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1497
1613
|
// In the TUI: a searchable picker (type to filter) whose viewport follows the
|
|
1498
1614
|
// selection — the built-in select can't scroll a hundreds-long provider list
|
|
1499
1615
|
// usefully. Outside the TUI (RPC), fall back to the built-in select.
|
|
1500
|
-
|
|
1501
|
-
|
|
1616
|
+
let pick: string | undefined;
|
|
1617
|
+
let cancelPicker: (() => void) | undefined;
|
|
1618
|
+
try {
|
|
1619
|
+
pick = ctx.mode === "tui"
|
|
1502
1620
|
? await ctx.ui.custom<string | undefined>(
|
|
1503
|
-
(tui, theme,
|
|
1504
|
-
|
|
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
|
+
},
|
|
1505
1632
|
{ overlay: true },
|
|
1506
|
-
|
|
1507
|
-
: 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;
|
|
1508
1639
|
if (!scope.isCurrent()) {
|
|
1509
1640
|
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
1510
1641
|
return;
|
|
@@ -1513,7 +1644,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1513
1644
|
}
|
|
1514
1645
|
} catch {
|
|
1515
1646
|
/* dismissed / no UI → fall back to the default model */
|
|
1647
|
+
} finally {
|
|
1648
|
+
if (signal?.aborted) for (const key of addedPromptKeys) promptedParticipants.delete(key);
|
|
1516
1649
|
}
|
|
1650
|
+
if (signal?.aborted) return;
|
|
1517
1651
|
if (!scope.isCurrent()) {
|
|
1518
1652
|
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
1519
1653
|
return;
|
|
@@ -1649,7 +1783,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1649
1783
|
// The SDK lifecycle callbacks that drive the unified tree for one strategy run rooted at
|
|
1650
1784
|
// `rootId` — shared by /orchestrate, the council tool, and each flow phase. Seeds running
|
|
1651
1785
|
// cores, flips ⏳ → ✓/✗ with usage, streams progress, and registers stop/steer handles.
|
|
1652
|
-
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) {
|
|
1653
1787
|
return {
|
|
1654
1788
|
onAgentStart: (agent: string, abort: () => void, key?: string) => {
|
|
1655
1789
|
stopRegistry.set(`${rootId}/${key ?? agent}`, abort);
|
|
@@ -1688,6 +1822,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1688
1822
|
agentTree.update(id, progressPatch(p, now));
|
|
1689
1823
|
// Child engine events refresh liveness, not proof of useful work; repaint alone never advances it.
|
|
1690
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
|
|
1691
1826
|
},
|
|
1692
1827
|
};
|
|
1693
1828
|
}
|
|
@@ -1697,6 +1832,23 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1697
1832
|
return caps ? { canSpawn: (agent) => canDelegateTo(caps, agent) } : {};
|
|
1698
1833
|
}
|
|
1699
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
|
+
|
|
1700
1852
|
// Run a persona strategy with the unified tree wired in: assign models on first run,
|
|
1701
1853
|
// seed the roster (cores show by name at once), flip ⏳ → ✓/✗ live, clear when done.
|
|
1702
1854
|
async function runStrategyVisible(
|
|
@@ -1704,34 +1856,39 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1704
1856
|
orch: OrchestrationGrammar,
|
|
1705
1857
|
task: string,
|
|
1706
1858
|
idPrefix: string,
|
|
1707
|
-
|
|
1859
|
+
tracked?: TrackedRun,
|
|
1708
1860
|
) {
|
|
1709
1861
|
const label = resolveStrategyName(orch) ?? "strategy";
|
|
1710
1862
|
const roster = resolveOrchestrationMembers(orch, teams);
|
|
1711
1863
|
const participants = runParticipants(ctx, { strategy: label, ...(orch.params ? { params: orch.params } : {}), members: roster });
|
|
1712
|
-
await ensurePersonaModels(ctx, participants);
|
|
1713
1864
|
const participantsByNode = participantByNode(participants);
|
|
1714
|
-
const rootId =
|
|
1715
|
-
agentTree.add({ id: rootId, label, status: "running", kind: "council" });
|
|
1716
|
-
// Seed the whole roster at once (cores show by name immediately); "queued" until the
|
|
1717
|
-
// engine actually starts each one — an honest view under the concurrency limit.
|
|
1718
|
-
const seedKeys = rosterNodeKeys(roster);
|
|
1719
|
-
roster.forEach((m, i) => {
|
|
1720
|
-
const a = rosterSpec(m).agent;
|
|
1721
|
-
const key = seedKeys[i] ?? a;
|
|
1722
|
-
const participant = participantForNode(participantsByNode, key);
|
|
1723
|
-
const assignmentKey = participant?.key ?? bareAssignmentKey(key);
|
|
1724
|
-
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" });
|
|
1725
|
-
});
|
|
1865
|
+
const { rootId, signal, live } = visibleRoot(`${idPrefix}:${label}`, tracked);
|
|
1866
|
+
agentTree.add({ id: rootId, label, status: "running", kind: "council", ...live });
|
|
1726
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
|
+
});
|
|
1727
1884
|
// The signal goes to the STRATEGY as well as the engine: a multi-round strategy checks it
|
|
1728
1885
|
// cooperatively between rounds, so an aborted run stops convening instead of running every
|
|
1729
1886
|
// remaining round against an already-cancelled engine (docs/STRATEGIES.md).
|
|
1730
|
-
const result = await runPersonaStrategy(orch, task, { engine: buildEngine(signal, undefined, { providerFallback: false }), teams, limits: RUN_LIMITS, ...sessionModelDep(ctx),
|
|
1731
|
-
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" });
|
|
1732
1889
|
return result ?? undefined;
|
|
1733
1890
|
} catch (error) {
|
|
1734
|
-
agentTree.update(rootId, { status: signal
|
|
1891
|
+
agentTree.update(rootId, { status: signal.aborted ? "stopped" : "failed" });
|
|
1735
1892
|
throw error;
|
|
1736
1893
|
} finally {
|
|
1737
1894
|
clearStops(rootId);
|
|
@@ -1770,12 +1927,45 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1770
1927
|
if (found === undefined) return undefined;
|
|
1771
1928
|
return parseFlow(found);
|
|
1772
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
|
+
}
|
|
1773
1963
|
|
|
1774
1964
|
// Run a flow's DAG with the unified tree (phases as nodes, cores beneath) + journaled
|
|
1775
|
-
// 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
|
|
1776
1966
|
// journal is cleared on a fully-successful run.
|
|
1777
|
-
async function runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, baseTask: string,
|
|
1778
|
-
const hash =
|
|
1967
|
+
async function runFlowVisible(ctx: ExtensionContext, spec: FlowSpec, baseTask: string, tracked?: TrackedRun): Promise<FlowOutcome> {
|
|
1968
|
+
const hash = flowRunHash(spec, baseTask, ctx.cwd);
|
|
1779
1969
|
const journalDir = join(personaDataDir(), "flows");
|
|
1780
1970
|
try {
|
|
1781
1971
|
mkdirSync(journalDir, { recursive: true });
|
|
@@ -1801,17 +1991,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1801
1991
|
return [phase.id, { members, participants }] as const;
|
|
1802
1992
|
}),
|
|
1803
1993
|
);
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
const flowRoot = nextRootId(`flow:${spec.name}`);
|
|
1807
|
-
agentTree.add({ id: flowRoot, label: `flow ${spec.name}`, status: "running", kind: "flow" });
|
|
1808
|
-
for (const p of spec.phases) {
|
|
1809
|
-
const pid = `${flowRoot}/${p.id}`;
|
|
1810
|
-
const node: AddNodeInput = { id: pid, label: `${p.id} · ${p.strategy}`, parentId: flowRoot, status: resume[p.id] ? "done" : "running", kind: "phase" };
|
|
1811
|
-
if (resume[p.id]) node.detail = "resumed";
|
|
1812
|
-
agentTree.add(node);
|
|
1813
|
-
}
|
|
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 });
|
|
1814
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
|
+
}
|
|
1815
2008
|
// Yield so a concurrent flow can seed its own tree before we fail-closed on refs —
|
|
1816
2009
|
// the check still runs BEFORE any phase/engine work.
|
|
1817
2010
|
await Promise.resolve();
|
|
@@ -1834,7 +2027,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1834
2027
|
const outcome = await runFlow(spec, baseTask, {
|
|
1835
2028
|
hash,
|
|
1836
2029
|
resume,
|
|
1837
|
-
|
|
2030
|
+
signal,
|
|
1838
2031
|
journal: journalWriter(journalPath),
|
|
1839
2032
|
onPhase: (id, status, result) => {
|
|
1840
2033
|
const patch: { status: AgentNodeStatus; output?: string; detail?: string } = {
|
|
@@ -1860,12 +2053,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1860
2053
|
const orch: OrchestrationGrammar = { mode: "strategy", strategy: phase.strategy, params: phase.params ?? {} };
|
|
1861
2054
|
if (phase.roster) orch.roster = phase.roster;
|
|
1862
2055
|
const r = await runPersonaStrategy(orch, task, {
|
|
1863
|
-
engine: buildEngine(signal, undefined, { providerFallback: false }),
|
|
2056
|
+
engine: buildEngine(signal, undefined, { providerFallback: false, async: tracked?.background === true }),
|
|
1864
2057
|
teams,
|
|
1865
2058
|
limits: RUN_LIMITS,
|
|
1866
2059
|
...sessionModelDep(ctx),
|
|
1867
|
-
|
|
1868
|
-
...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),
|
|
1869
2062
|
...strategySpawnGate(),
|
|
1870
2063
|
...modelFallbackDep(ctx),
|
|
1871
2064
|
});
|
|
@@ -1876,26 +2069,34 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1876
2069
|
// boundary, never an informational prompt. Approval is journaled for resume.
|
|
1877
2070
|
approveGate: async (phase, result) => {
|
|
1878
2071
|
if (!ctx.hasUI) return false;
|
|
1879
|
-
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);
|
|
1880
2078
|
try {
|
|
1881
|
-
|
|
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
|
+
);
|
|
1882
2084
|
return pick === "Approve";
|
|
1883
2085
|
} catch {
|
|
1884
2086
|
return false;
|
|
2087
|
+
} finally {
|
|
2088
|
+
tracked?.onAwaiting(undefined);
|
|
2089
|
+
agentTree.update(flowRoot, { detail: "", lastAdvanceAt: Date.now() });
|
|
1885
2090
|
}
|
|
1886
2091
|
},
|
|
1887
2092
|
});
|
|
1888
|
-
if (outcome.ok)
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
/* ignore */
|
|
1893
|
-
}
|
|
1894
|
-
}
|
|
1895
|
-
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 });
|
|
1896
2097
|
return outcome;
|
|
1897
2098
|
} catch (error) {
|
|
1898
|
-
agentTree.update(flowRoot, { status: signal
|
|
2099
|
+
agentTree.update(flowRoot, { status: signal.aborted ? "stopped" : "failed" });
|
|
1899
2100
|
throw error;
|
|
1900
2101
|
} finally {
|
|
1901
2102
|
clearStops(flowRoot);
|
|
@@ -1904,15 +2105,53 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1904
2105
|
}
|
|
1905
2106
|
}
|
|
1906
2107
|
|
|
1907
|
-
//
|
|
1908
|
-
|
|
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 } {
|
|
1909
2140
|
const session = ctx.model?.provider;
|
|
1910
2141
|
const isSession = (ref: string): boolean => session !== undefined && ref.startsWith(`${session}/`);
|
|
1911
|
-
const
|
|
1912
|
-
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}`);
|
|
1913
2148
|
const cap = 40;
|
|
1914
|
-
const
|
|
1915
|
-
|
|
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 };
|
|
1916
2155
|
}
|
|
1917
2156
|
|
|
1918
2157
|
// The per-turn delegation brief (core/brief.ts): live roster + standing hand-off default,
|
|
@@ -1920,11 +2159,19 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1920
2159
|
// persona line has no force, and regenerated from the live registry so it cannot desync.
|
|
1921
2160
|
// Capability-aware: a persona that denies `delegate` gets none, and the agent list is
|
|
1922
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
|
+
|
|
1923
2168
|
function delegationBrief(ctx: ExtensionContext): string | undefined {
|
|
1924
2169
|
const caps = controller.capabilities;
|
|
1925
2170
|
if (caps && !canFanOut(caps)) return undefined;
|
|
1926
|
-
const targets =
|
|
2171
|
+
const targets = delegateTargets();
|
|
1927
2172
|
const delegation = controller.activePersona?.delegation;
|
|
2173
|
+
const canListModels = canDiscoverModels();
|
|
2174
|
+
const candidates = canListModels && ctx.model ? currentModelCatalog(ctx).candidates : undefined;
|
|
1928
2175
|
const teamAgents: Record<string, string[]> = {};
|
|
1929
2176
|
for (const [name, members] of Object.entries(teams)) teamAgents[name] = members.map((m) => rosterSpec(m).agent);
|
|
1930
2177
|
return buildDelegationBrief({
|
|
@@ -1937,11 +2184,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1937
2184
|
...(a.isolation ? { isolation: a.isolation } : {}),
|
|
1938
2185
|
})),
|
|
1939
2186
|
teams: teamAgents,
|
|
1940
|
-
|
|
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 } : {}),
|
|
1941
2193
|
standing: controller.activePersona !== undefined,
|
|
1942
|
-
// Interactive sessions delegate in the background
|
|
1943
|
-
//
|
|
1944
|
-
|
|
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,
|
|
1945
2197
|
...(delegation?.requireBrief !== undefined ? { requireBrief: delegation.requireBrief } : {}),
|
|
1946
2198
|
...(delegation?.outputContract ? { outputContract: delegation.outputContract } : {}),
|
|
1947
2199
|
...(delegation?.requireDisjointWrites !== undefined ? { requireDisjointWrites: delegation.requireDisjointWrites } : {}),
|
|
@@ -1993,7 +2245,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
1993
2245
|
lines.push(`data root migration: moved ${dataRootMigration.moved.length}, kept ${dataRootMigration.kept.length} (legacy pi-persona root folded into ${personaDataDir()})`);
|
|
1994
2246
|
}
|
|
1995
2247
|
for (const warning of dataRootMigration.warnings) lines.push(warning);
|
|
1996
|
-
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}`);
|
|
1997
2249
|
lines.push(`agents (${agents.length}): ${agents.map((a) => a.name).join(", ") || "—"}`);
|
|
1998
2250
|
if (definitionCollisions.length > 0) {
|
|
1999
2251
|
lines.push(`definition collisions (${definitionCollisions.length}, omitted): ${definitionCollisions.map((collision) => collision.name).join(", ")}`);
|
|
@@ -2048,7 +2300,14 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2048
2300
|
get lastCtx() { return lastCtx; },
|
|
2049
2301
|
set lastCtx(value) { lastCtx = value; },
|
|
2050
2302
|
get disposed() { return disposed; },
|
|
2051
|
-
set disposed(value) {
|
|
2303
|
+
set disposed(value) {
|
|
2304
|
+
disposed = value;
|
|
2305
|
+
if (value) {
|
|
2306
|
+
cancelAgentWidgetRender();
|
|
2307
|
+
widgetClock.stop();
|
|
2308
|
+
wakeRows.clearMonitors();
|
|
2309
|
+
}
|
|
2310
|
+
},
|
|
2052
2311
|
get orchestrating() { return orchestrating; },
|
|
2053
2312
|
set orchestrating(value) { orchestrating = value; },
|
|
2054
2313
|
get processingDeferredOrchestration() { return processingDeferredOrchestration; },
|
|
@@ -2068,6 +2327,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2068
2327
|
telemetryAgent,
|
|
2069
2328
|
delegationNudge,
|
|
2070
2329
|
persistenceNudge,
|
|
2330
|
+
pollGuard,
|
|
2331
|
+
tracker,
|
|
2071
2332
|
appendNudgeEntry,
|
|
2072
2333
|
completionNotifier,
|
|
2073
2334
|
intercomNotifier,
|
|
@@ -2113,6 +2374,20 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2113
2374
|
};
|
|
2114
2375
|
installHooks(pi, hookHost, exocom);
|
|
2115
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
|
+
|
|
2116
2391
|
registerDelegateTool(pi, {
|
|
2117
2392
|
get lastCtx() { return lastCtx; },
|
|
2118
2393
|
set lastCtx(value) { lastCtx = value; },
|
|
@@ -2121,21 +2396,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2121
2396
|
contractNames: () => installedContractNames(contractDefs),
|
|
2122
2397
|
buildEngine,
|
|
2123
2398
|
agentTree,
|
|
2124
|
-
nextRootId,
|
|
2125
2399
|
tracker,
|
|
2400
|
+
runs: backgroundRuns,
|
|
2401
|
+
launchPool,
|
|
2126
2402
|
config: { ledgerV2: config.ledgerV2 },
|
|
2127
2403
|
RUN_LIMITS,
|
|
2128
2404
|
publishAgentTool,
|
|
2129
2405
|
stopRegistry,
|
|
2130
2406
|
steerRegistry,
|
|
2131
|
-
stopRequested,
|
|
2132
|
-
ensurePersonaModels,
|
|
2133
|
-
clearStops,
|
|
2134
|
-
clearSteers,
|
|
2135
2407
|
drainBusBlock,
|
|
2136
2408
|
startPeek,
|
|
2137
|
-
childUsage,
|
|
2138
|
-
publishPersonaCost,
|
|
2139
2409
|
});
|
|
2140
2410
|
|
|
2141
2411
|
// ── f8 cycle ────────────────────────────────────────────────────────────────
|
|
@@ -2143,7 +2413,10 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2143
2413
|
description: "Cycle persona (pi-persona)",
|
|
2144
2414
|
handler: async (ctx: ExtensionContext) => {
|
|
2145
2415
|
lastCtx = ctx;
|
|
2146
|
-
if (personas.length === 0)
|
|
2416
|
+
if (personas.length === 0) {
|
|
2417
|
+
if (ctx.hasUI) ctx.ui.notify(NO_PERSONAS_HINT, "info");
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2147
2420
|
const current = controller.activePersona;
|
|
2148
2421
|
const idx = current ? personas.findIndex((p) => p.name === current.name) : -1;
|
|
2149
2422
|
const next = idx + 1;
|
|
@@ -2181,14 +2454,13 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2181
2454
|
SUPERVISOR,
|
|
2182
2455
|
STALL_FLAG_MS,
|
|
2183
2456
|
waitingRunIds: waitingAsyncRunIds,
|
|
2457
|
+
pollGuard,
|
|
2184
2458
|
missingRunMessage,
|
|
2185
2459
|
stopAgent,
|
|
2186
2460
|
steerAgent,
|
|
2187
2461
|
steerRegistry,
|
|
2188
2462
|
stopRequested,
|
|
2189
|
-
drainBusBlock,
|
|
2190
2463
|
scanForSurrender,
|
|
2191
|
-
get disposed() { return disposed; },
|
|
2192
2464
|
childUsage,
|
|
2193
2465
|
publishPersonaCost,
|
|
2194
2466
|
});
|
|
@@ -2203,6 +2475,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2203
2475
|
set lastCtx(value) { lastCtx = value; },
|
|
2204
2476
|
canRun: canRunMonitor,
|
|
2205
2477
|
monitors,
|
|
2478
|
+
archive: monitorArchive,
|
|
2206
2479
|
});
|
|
2207
2480
|
|
|
2208
2481
|
registerCouncilTool(pi, {
|
|
@@ -2211,9 +2484,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2211
2484
|
controller,
|
|
2212
2485
|
get personas() { return personas; },
|
|
2213
2486
|
runStrategyVisible,
|
|
2487
|
+
runs: backgroundRuns,
|
|
2214
2488
|
drainBusBlock,
|
|
2215
|
-
childUsage,
|
|
2216
|
-
publishPersonaCost,
|
|
2217
2489
|
});
|
|
2218
2490
|
|
|
2219
2491
|
registerFlowTool(pi, {
|
|
@@ -2222,8 +2494,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2222
2494
|
loadFlow,
|
|
2223
2495
|
listFlows,
|
|
2224
2496
|
runFlowVisible,
|
|
2225
|
-
|
|
2226
|
-
publishPersonaCost,
|
|
2497
|
+
runs: backgroundRuns,
|
|
2227
2498
|
});
|
|
2228
2499
|
|
|
2229
2500
|
// ── f9: navigable agent overlay ──────────────────────────────────────────────
|
|
@@ -2298,6 +2569,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2298
2569
|
personaConfigs = savedSettings.personas;
|
|
2299
2570
|
},
|
|
2300
2571
|
isCurrent: () => !disposed && (session === undefined || lastCtx?.sessionManager?.getSessionId?.() === session),
|
|
2572
|
+
refreshCatalog: (signal) => refreshModelCatalog(ctx, signal),
|
|
2301
2573
|
});
|
|
2302
2574
|
},
|
|
2303
2575
|
});
|
|
@@ -2330,7 +2602,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2330
2602
|
const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
|
|
2331
2603
|
const deadline = AbortSignal.timeout(20_000);
|
|
2332
2604
|
const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
|
|
2333
|
-
const result = await ctx
|
|
2605
|
+
const result = await refreshModelCatalog(ctx, signal);
|
|
2334
2606
|
if (!scope.isCurrent()) {
|
|
2335
2607
|
ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
|
|
2336
2608
|
return;
|
|
@@ -2402,14 +2674,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2402
2674
|
return;
|
|
2403
2675
|
}
|
|
2404
2676
|
if (arg === "" || arg === "list") {
|
|
2405
|
-
// Empty by design on a fresh install: personas load only from the user dir, which the
|
|
2406
|
-
// user populates on purpose. Point them at the install gesture rather than leaving a
|
|
2407
|
-
// bare "(none)" that reads like something is broken.
|
|
2408
2677
|
if (personas.length === 0) {
|
|
2409
|
-
ctx.ui.notify(
|
|
2410
|
-
"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).",
|
|
2411
|
-
"info",
|
|
2412
|
-
);
|
|
2678
|
+
ctx.ui.notify(NO_PERSONAS_HINT, "info");
|
|
2413
2679
|
return;
|
|
2414
2680
|
}
|
|
2415
2681
|
const lines = personas.map(
|
|
@@ -2491,16 +2757,16 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2491
2757
|
|
|
2492
2758
|
// ── /models + a `models` tool — discover/search model ids ─────────────────────
|
|
2493
2759
|
pi.registerCommand("models", {
|
|
2494
|
-
description: "
|
|
2760
|
+
description: "Show model candidates or search the full catalog (provider/id): /models [query]",
|
|
2495
2761
|
handler: async (args, ctx) => {
|
|
2496
2762
|
lastCtx = ctx;
|
|
2497
|
-
const { lines, total, capped } = searchModels(ctx, args);
|
|
2763
|
+
const { lines, total, capped, shortlist } = searchModels(ctx, args);
|
|
2498
2764
|
if (lines.length === 0) {
|
|
2499
2765
|
ctx.ui.notify(`models: no match for "${args.trim()}"`, "warning");
|
|
2500
2766
|
return;
|
|
2501
2767
|
}
|
|
2502
|
-
const head = `models (${total}${capped ?
|
|
2503
|
-
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");
|
|
2504
2770
|
},
|
|
2505
2771
|
});
|
|
2506
2772
|
|
|
@@ -2548,11 +2814,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2548
2814
|
return;
|
|
2549
2815
|
}
|
|
2550
2816
|
try {
|
|
2551
|
-
|
|
2552
|
-
appendCommandResult(`flow ${orch.flow}`, outcome.output || "(flow produced no output)", outcome.ok, {
|
|
2553
|
-
...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
|
|
2554
|
-
...(outcome.error ? { error: outcome.error } : {}),
|
|
2555
|
-
});
|
|
2817
|
+
appendFlowResult(orch.flow, await runFlowVisible(ctx, parsed.flow, task));
|
|
2556
2818
|
} catch (err) {
|
|
2557
2819
|
ctx.ui.notify(`orchestrate failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
2558
2820
|
}
|
|
@@ -2571,7 +2833,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2571
2833
|
}
|
|
2572
2834
|
try {
|
|
2573
2835
|
const result = await runStrategyVisible(ctx, orch, task, "orchestrate");
|
|
2574
|
-
|
|
2836
|
+
appendOperatorResult(
|
|
2575
2837
|
`orchestrate ${strategyName}`,
|
|
2576
2838
|
result?.output || "(no output)",
|
|
2577
2839
|
result?.ok ?? false,
|
|
@@ -2593,9 +2855,15 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2593
2855
|
lastCtx = ctx;
|
|
2594
2856
|
const trimmed = args.trim();
|
|
2595
2857
|
if (!trimmed || trimmed === "list") {
|
|
2596
|
-
const flows =
|
|
2858
|
+
const flows = flowCatalog(ctx.cwd);
|
|
2597
2859
|
ctx.ui.notify(
|
|
2598
|
-
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",
|
|
2599
2867
|
"info",
|
|
2600
2868
|
);
|
|
2601
2869
|
return;
|
|
@@ -2617,11 +2885,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
|
|
|
2617
2885
|
return;
|
|
2618
2886
|
}
|
|
2619
2887
|
try {
|
|
2620
|
-
|
|
2621
|
-
appendCommandResult(`flow ${name}`, outcome.output || "(flow produced no output)", outcome.ok, {
|
|
2622
|
-
...(outcome.failureKind ? { failureKind: outcome.failureKind } : {}),
|
|
2623
|
-
...(outcome.error ? { error: outcome.error } : {}),
|
|
2624
|
-
});
|
|
2888
|
+
appendFlowResult(name, await runFlowVisible(ctx, parsed.flow, task));
|
|
2625
2889
|
} catch (err) {
|
|
2626
2890
|
ctx.ui.notify(`flow failed: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
2627
2891
|
}
|