@aeondave/pi-persona 1.16.2 → 1.17.0

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/CHANGELOG.md CHANGED
@@ -6,6 +6,40 @@ used in its [GitHub releases](https://github.com/AeonDave/pi-persona/releases).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.17.0](https://github.com/AeonDave/pi-persona/releases/tag/v1.17.0) - 2026-10-19
10
+
11
+ ### Added
12
+
13
+ - `/persona models [council-persona]` refreshes the host catalog and reopens model selection for
14
+ the active caller's council participants, preserving cancelled choices, unrelated assignments,
15
+ and explicit inline model pins. No supervisor switch or model request is required.
16
+ - A hermetic installed-host Pi 1.3.0 qualification script covers real command dispatch, assignment
17
+ persistence, custom-message starts and missing-auth council recovery without network requests.
18
+
19
+ ### Fixed
20
+
21
+ - Publish terminal progress for single delegates before removing their tree node, preserving `done`
22
+ status in Flow and other telemetry consumers.
23
+ - Preserve Exocom reply references and optional target-correlated transport IDs while keeping each
24
+ queued/terminal telemetry row stable; retain bounded broadcast delivery receipts.
25
+ - Report an exact acknowledged broadcast receipt as delivered even when failures for other peers
26
+ exceed the retained sample; recipients without exact evidence remain conservatively unknown.
27
+ - Classify Pi's `No API key found for <provider>` diagnostic as a provider failure in both engines,
28
+ allowing the existing single main-model recovery for councils/strategies/flows. Ordinary delegate
29
+ provider pins remain strict; aborts, timeouts, contract and agent failures remain terminal.
30
+ - Report a refused persona model switch instead of silently recording an override when Pi rejects
31
+ it for missing authentication; keep the actual session model and restoration state intact.
32
+ - Remove a timing race from the supervisor-ask telemetry test: verify the persisted timeout
33
+ transition history rather than polling a transient 20 ms state through asynchronous disk writes.
34
+
35
+ ## [1.16.3](https://github.com/AeonDave/pi-persona/releases/tag/v1.16.3) - 2026-10-13
36
+
37
+ ### Fixed
38
+
39
+ - Reject Pi `+name`/`-name` tool modifiers in agent and per-run allowlists before either engine
40
+ starts. Those selectors are literal allowlist entries in pi-persona; Pi 1.2 instead applies them
41
+ to its default tools. Use `deny` or `excludeTools` for denials.
42
+
9
43
  ## [1.16.2](https://github.com/AeonDave/pi-persona/releases/tag/v1.16.2) - 2026-10-08
10
44
 
11
45
  ### Fixed
package/README.md CHANGED
@@ -39,9 +39,9 @@ your customizations.
39
39
  | Install option | Command |
40
40
  |---|---|
41
41
  | This project only | `pi install -l npm:@aeondave/pi-persona` |
42
- | Pin this release | `pi install npm:@aeondave/pi-persona@1.16.2` |
42
+ | Pin this release | `pi install npm:@aeondave/pi-persona@1.17.0` |
43
43
  | Update an unpinned install | `pi update npm:@aeondave/pi-persona` |
44
- | Git alternative | `pi install git:github.com/AeonDave/pi-persona@v1.16.2` |
44
+ | Git alternative | `pi install git:github.com/AeonDave/pi-persona@v1.17.0` |
45
45
 
46
46
  Restart Pi after updating. For a pinned install, install the new version or tag explicitly.
47
47
  If switching from a Git install, remove that source first with
@@ -91,6 +91,7 @@ the worker has acted on it.
91
91
  | `/peek` | Show a compact progress summary |
92
92
  | `/persona list` | List installed personas |
93
93
  | `/persona off` | Turn off the active persona |
94
+ | `/persona models [council-persona]` | Refresh the catalog and reselect council models for the active persona |
94
95
  | `/models <query>` | Find an available model |
95
96
  | `/doctor` | Check configuration and available capabilities |
96
97
 
@@ -151,6 +152,8 @@ Strategies are not restricted to the similarly named persona: `council` can sele
151
152
  custom role-based groups without team files. Model selection includes external arbiters. In councils
152
153
  and flows, a provider failure or unavailable model can recover once on the supervisor's model, with
153
154
  the switch visible and both attempts accounted; ordinary delegates keep explicit provider pins strict.
155
+ To replace old council assignments without changing supervisors, run `/persona models magi`
156
+ (or `/persona models` for the active council). Cancel a choice to keep its previous value.
154
157
  See [the reference](docs/REFERENCE.md) for an inline council example and recovery limits.
155
158
 
156
159
  ## Make it yours
@@ -187,6 +190,8 @@ npm test
187
190
  ```
188
191
 
189
192
  Pi loads the TypeScript extension directly; there is no build step.
193
+ To qualify a separately installed Pi 1.3.0 host without cloud calls or your profile, run
194
+ `node --import tsx scripts/qualify-host.mjs <absolute-path-to-pi-coding-agent-package>`.
190
195
  The [CI workflow](https://github.com/AeonDave/pi-persona/actions/workflows/test.yml) runs strict
191
196
  typechecking, dependency auditing and the test suite on Linux and Windows. The OpenSSF badge links to the project's
192
197
  scored supply-chain checks; it is not a certification that the code is free of vulnerabilities.
@@ -300,6 +300,19 @@ inline model pins taking precedence. If distinct full roles share a short displa
300
300
  keeps the hint (with `#N` suffixes for repeated node keys) but omits the uncertain model label rather
301
301
  than borrowing another role's choice. Routing still resolves the full role key, not the display hint.
302
302
 
303
+ **Model assignment repair is configuration, not execution recovery.** `/persona models [profile]`
304
+ is an explicit user edit in the Pi adapter: it reuses `resolveCouncilInvocation`,
305
+ `collectModelParticipants` and the existing locked config writer, without adding another roster,
306
+ model store or provider-routing policy. The active caller owns saved assignments even when borrowing
307
+ another council profile. Automatic discovery stays never-nag; explicit editing may replace saved or
308
+ agent defaults, but not inline pins. Host catalog refresh has a 20-second cooperative deadline;
309
+ reported cancellation/errors or a caller/session change leave assignments untouched. Selection uses
310
+ the refreshed host availability snapshot rather than treating all catalog entries as authenticated.
311
+ Cancelled rows preserve their old choices; accepted choices merge without clearing other personas.
312
+ Running engines retain their captured assignments. Missing credentials are classified at the engine
313
+ adapter boundary; only the SDK decides whether a main-model recovery is admissible. This separation
314
+ avoids a global reset or automatic repinning that could silently change billing and panel diversity.
315
+
303
316
  ### MCP (and other `session_start`-scoped extensions) in sub-agents
304
317
 
305
318
  **A sub-agent does NOT share the supervisor's MCP session, and an in-process sub-agent gets NO MCP at
package/docs/REFERENCE.md CHANGED
@@ -189,9 +189,11 @@ files are seed sources. A fresh install has no installed personas until `/person
189
189
  | Preset | `presets/*.preset.json` | reusable council strategy, roster, and parameter bundle |
190
190
 
191
191
  `tools` accepts an allowlist such as `[read, grep]`, an `allow`/`deny` object, or `[]` for no tools.
192
- Omit it to inherit the session tool set. `model`, `isolation: worktree`, `mcp: true`, and `purpose`
193
- are optional agent metadata. The body is the agent prompt. Inline roster members specialise one
194
- agent with a role, model, or skills without requiring another agent file.
192
+ Entries are plain tool names or `*` patterns; Pi's `+name`/`-name` default-tool modifier
193
+ syntax is not an allowlist and is rejected. Use the `deny` field to remove tools. Omit `tools` to
194
+ inherit the session tool set. `model`, `isolation: worktree`,
195
+ `mcp: true`, and `purpose` are optional agent metadata. The body is the agent prompt. Inline roster
196
+ members specialise one agent with a role, model, or skills without requiring another agent file.
195
197
 
196
198
  ### Multi-agent quick start
197
199
 
@@ -301,10 +303,28 @@ With an active persona, UI, and at least two configured models, unassigned parti
301
303
  picked and saved for the active persona, even when borrowing another persona's council profile.
302
304
  Dismissing a picker suppresses another prompt for that participant during the session, not for
303
305
  later teams with different participants. Headless runs never prompt.
306
+
307
+ Run `/persona models` to explicitly reselect the active council's models, or `/persona models magi`
308
+ to edit the MAGI panel while keeping another supervisor active. The optional name selects an
309
+ installed council persona, not a strategy or team. The command refreshes the host catalog first,
310
+ then offers each editable participant, including previously dismissed choices and saved or agent
311
+ defaults. One available model is enough for this explicit edit. Choices are saved under the **active
312
+ caller's** persona, just as for `council({ persona: "magi" })`; it never activates the borrowed
313
+ profile. Cancelled choices, unrelated assignments and other personas remain unchanged. Inline
314
+ `members[].model` pins still win and are not editable here. TUI/RPC UI is required; no model call
315
+ is made. Changes apply to future runs, not workers already running. `/persona magi` still switches
316
+ the supervisor; `/persona reload` reloads definitions without resetting saved models.
317
+ Refresh is bounded to 20 seconds cooperatively; provider errors, cancellation, or a persona/session
318
+ change cancel the edit without replacing assignments. The list uses the host's refreshed availability,
319
+ not every model in its catalog.
320
+
304
321
  An unavailable choice is not overwritten: a provider or unknown-model failure may recover once
305
322
  on the main model, with the switch reported and both attempts accounted. This does not relax
306
323
  ordinary delegate provider pins. The MAGI persona retains its canonical core team; the Judge
307
324
  persona retains impartial arbitration. Other personas choose whichever strategy fits the task.
325
+ The exact Pi diagnostic `No API key found for <provider>` counts as a provider failure in both
326
+ engines, so it can use this same bounded recovery. If the main model is the failing model too,
327
+ recovery cannot help: use `/login` or change `/model`, then reselect with `/persona models`.
308
328
 
309
329
  **A delegation policy on any persona** — fields are generic and are never keyed to a persona name.
310
330
  The runtime rejects incomplete briefs before a model call, supplies a missing contract, rejects
package/docs/TELEMETRY.md CHANGED
@@ -8,6 +8,14 @@ waiting from tool arguments, output text or a quiet clock.
8
8
 
9
9
  Telemetry is a vendor-neutral v2 envelope. `producerId` and `producerVersion` identify the emitting plugin; `version` identifies the wire schema. `seq` is monotonic only within `(producerId, sessionId)`, and `id` is deduplicated within that same scope.
10
10
 
11
+ Message payload `id` is the stable lifecycle-row key: queued and terminal updates keep it unchanged.
12
+ Optional `wireId` identifies the actual transport message, and `replyTo` references that transport's
13
+ original ID. Exocom sends attach `wireId` only after an exact target-correlated successful receipt;
14
+ broadcast receipts are bounded and omitted mappings remain unknown. Do not replace the queued row's
15
+ `id` with a wire ID, infer a mapping by timestamp/alias, or merge observations from different streams.
16
+ Single delegates publish their terminal progress before tree removal, so a completed worker remains
17
+ `done` in the telemetry instead of being mistaken for a stopped in-flight leg.
18
+
11
19
  The pi-persona producer writes semantic projections under `telemetry/v2/<workspace>/<producerId>/`. `<workspace>` always identifies the Pi process's actual cwd; joining another Exocom scope does not rewrite telemetry identity. It never persists task/prompt text, tool arguments/activity, model output, paths, secrets, or message bodies. Known pi-persona event payloads are allowlisted; an unreviewed producer event retains only its envelope/type (payload `{}`) at the producer and consumer boundary. Future producers must add a reviewed allowlist/schema before publishing data intended for a consumer.
12
20
 
13
21
  JSONL compaction is atomic and hysteretic. It preserves the initial `instance.started` anchor and complete records; the configured byte limit is therefore a soft bound for an anchor plus one complete event. The `.previous` backup is recovered after an interrupted swap.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aeondave/pi-persona",
3
- "version": "1.16.2",
3
+ "version": "1.17.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Supervised multi-agent orchestration for Pi — async workers, live steering, cross-session collaboration, and switchable personas.",
@@ -104,7 +104,7 @@
104
104
  "typescript": "^7.0.2"
105
105
  },
106
106
  "pi": {
107
- "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.16.2/assets/banner.png",
107
+ "image": "https://raw.githubusercontent.com/AeonDave/pi-persona/v1.17.0/assets/banner.png",
108
108
  "video": "https://cdn.jsdelivr.net/gh/AeonDave/pi-persona@6655d281ce7965a75303dd704bd29db73db04070/assets/demo1.mp4",
109
109
  "extensions": [
110
110
  "./src/extension.ts"
@@ -18,7 +18,7 @@ import { type ChildEngineOptions, type ChildRunSpec, runChildAgent } from "./chi
18
18
  import { looksLikeProviderError } from "./errors.ts";
19
19
  import { nextChildHandle } from "./handles.ts";
20
20
  import { combineSignals } from "./signals.ts";
21
- import { unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
21
+ import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
22
22
  import type { ToolEvent } from "./stream.ts";
23
23
 
24
24
  /** The engine-side face of the cross-process broker (spec B3/B4/B7) — a child-engine
@@ -113,6 +113,9 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
113
113
  ): Promise<AgentResult> {
114
114
  const cfg = deps.resolveAgent(spec.agent);
115
115
  if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
116
+ const tools = spec.tools ?? cfg.tools;
117
+ const invalidTools = toolModifierFailure(spec.agent, tools);
118
+ if (invalidTools) return invalidTools;
116
119
 
117
120
  // Resolved (and pinned) up front: the SAME def both instructs the member and
118
121
  // validates its output — an instruction/validation drift is impossible. A named
@@ -132,7 +135,6 @@ export function makeEngine(deps: EngineAdapterDeps): StrategyEngine {
132
135
  // a child without it defaults to "adaptive", which some models reject.
133
136
  if (model && deps.childThinking && !model.includes(":")) model = `${model}:${deps.childThinking}`;
134
137
  if (model) childSpec.model = model;
135
- const tools = spec.tools ?? cfg.tools;
136
138
  if (tools !== undefined) childSpec.tools = tools;
137
139
  if (cfg.excludeTools && cfg.excludeTools.length > 0) childSpec.excludeTools = cfg.excludeTools;
138
140
  // The shared behavioral layer (docs/SPINE.md) + the agent's own persona + any
@@ -19,6 +19,7 @@ const PROVIDER_PATTERNS: readonly RegExp[] = [
19
19
  /\brate.?limit|\b429\b|too many requests/i,
20
20
  /\bquota\b|billing|insufficient.?credits/i,
21
21
  /\b401\b|\b403\b|unauthorized|invalid[\s_-]?api[\s_-]?key/i,
22
+ /\bno api key found for [a-z0-9][a-z0-9._-]*\b/i,
22
23
  /\b5\d\d\b|internal server error|bad gateway|service unavailable|gateway timeout|overloaded|capacity/i,
23
24
  /model.{0,24}(not.{0,8}(supported|found|exist)|does not exist)|unsupported.{0,12}model/i,
24
25
  /\beconnreset\b|\betimedout\b|\beconnrefused\b|socket hang up|fetch failed/i,
@@ -35,7 +35,7 @@ import type { AgentResult } from "../orchestration/types.ts";
35
35
  import { looksLikeProviderError } from "./errors.ts";
36
36
  import { nextChildHandle } from "./handles.ts";
37
37
  import { combineSignals } from "./signals.ts";
38
- import { unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
38
+ import { toolModifierFailure, unknownAgentFailure, unknownContractFailure } from "./spec-preflight.ts";
39
39
  import { applyEvent, createStreamState, emptyUsage, type ProgressSnapshot, snapshot } from "./stream.ts";
40
40
 
41
41
  /** The minimal slice of Pi's AgentSession the engine drives — also the test seam. */
@@ -315,6 +315,9 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
315
315
  async run(spec: AgentRunSpec, onProgress?, callSignal?, onSteerable?): Promise<AgentResult> {
316
316
  const cfg = deps.resolveAgent(spec.agent);
317
317
  if (!cfg) return unknownAgentFailure(spec.agent, deps.listAgents?.() ?? []);
318
+ const tools = spec.tools ?? cfg.tools;
319
+ const invalidTools = toolModifierFailure(spec.agent, tools);
320
+ if (invalidTools) return invalidTools;
318
321
  // A requested contract is a runtime guard, not merely prompt decoration. Resolve it
319
322
  // before model/session construction so a missing name can never run unconstrained.
320
323
  const requestedContract = spec.outputContract?.trim();
@@ -346,7 +349,6 @@ export function makeInProcessEngine(deps: InProcessDeps): StrategyEngine {
346
349
  const rm = model as { provider?: string; id?: string };
347
350
  const resolvedRef = rm.provider && rm.id ? `${rm.provider}/${rm.id}` : ref;
348
351
  const thinkingLevel = isThinkingLevel(deps.childThinking) ? deps.childThinking : undefined;
349
- const tools = spec.tools ?? cfg.tools;
350
352
  const excludeTools = cfg.excludeTools;
351
353
 
352
354
  // Diagnostic tag: which agent + which model ref + whether it was dynamically
@@ -31,3 +31,16 @@ export function unknownAgentFailure(agent: string, installed: string[]): AgentRe
31
31
  export function unknownContractFailure(agent: string, requested: string, installed: string[]): AgentResult {
32
32
  return failure(agent, `[${agent}] output contract "${requested}" not found${installedHint("contracts", installed)}`, "contract");
33
33
  }
34
+
35
+ /** Pi's SDK treats leading `+`/`-` entries in `tools` as default-tool modifiers, not as
36
+ * literal allowlist entries. AgentRunSpec.tools and agent frontmatter use this field as an
37
+ * allowlist, so reject that syntax before either engine reaches Pi. Use excludeTools for denies. */
38
+ export function toolModifierFailure(agent: string, tools: readonly string[] | undefined): AgentResult | undefined {
39
+ const modifier = tools?.find((selector) => /^[+-]/.test(selector.trim()));
40
+ if (modifier === undefined) return undefined;
41
+ return failure(
42
+ agent,
43
+ `[${agent}] unsupported tool modifier "${modifier.trim()}" in tools allowlist; use plain tool names/patterns or excludeTools for denials`,
44
+ "contract",
45
+ );
46
+ }
@@ -61,7 +61,7 @@ export interface HookHost {
61
61
  get telemetry(): TelemetryProducer | undefined;
62
62
  set telemetry(value: TelemetryProducer | undefined);
63
63
  readonly telemetryToolStartedAt: Map<string, { startedAt: number; name: string }>;
64
- readonly telemetryExocomPending: Map<string, Array<{ id: string; sessionId: string; target: string; size: number }>>;
64
+ readonly telemetryExocomPending: Map<string, Array<{ id: string; sessionId: string; target: string; size: number; replyTo?: string }>>;
65
65
  readonly telemetryIntercomPending: Map<string, { kind: string; to: string; size: number; replyTo?: string }>;
66
66
  readonly lastAgentProjection: Map<string, string>;
67
67
  readonly telemetryAskSenders: Map<string, string>;
@@ -306,6 +306,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
306
306
  status: "failed",
307
307
  expectsReply: false,
308
308
  size: message.size,
309
+ ...(message.replyTo ? { replyTo: message.replyTo } : {}),
309
310
  });
310
311
  }
311
312
  h.telemetryExocomPending.clear();
@@ -557,6 +558,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
557
558
  if (event.toolName === "exocom_send") {
558
559
  const text = typeof input.message === "string" ? input.message : "";
559
560
  const target = typeof input.target === "string" ? input.target : "";
561
+ const replyTo = typeof input.in_reply_to === "string" && input.in_reply_to.length > 0 ? input.in_reply_to : undefined;
560
562
  let peers: DisplayPeer[] = [];
561
563
  try { peers = exocom.plane?.listPeers() ?? []; } catch { /* h.telemetry cannot break the tool */ }
562
564
  const sessions = canonicalExocomTelemetryTargets(peers, target);
@@ -566,6 +568,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
566
568
  sessionId,
567
569
  target: target === "*" ? (peers.find((peer) => peer.session_id === sessionId)?.target ?? sessionId) : target,
568
570
  size,
571
+ ...(replyTo ? { replyTo } : {}),
569
572
  }));
570
573
  if (pending.length > 0) h.telemetryExocomPending.set(event.toolCallId, pending);
571
574
  for (const message of pending) h.telemetry?.publish("message.sent", {
@@ -577,6 +580,7 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
577
580
  status: "queued",
578
581
  expectsReply: false,
579
582
  size: message.size,
583
+ ...(message.replyTo ? { replyTo: message.replyTo } : {}),
580
584
  });
581
585
  }
582
586
  return undefined;
@@ -606,8 +610,24 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
606
610
  const pending = h.telemetryExocomPending.get(event.toolCallId) ?? [];
607
611
  h.telemetryExocomPending.delete(event.toolCallId);
608
612
  const details = event.details && typeof event.details === "object" && !Array.isArray(event.details)
609
- ? event.details as { failed?: Array<{ target?: unknown }>; failedCount?: unknown; omittedFailures?: unknown }
613
+ ? event.details as {
614
+ target?: unknown;
615
+ msg_id?: unknown;
616
+ sent?: unknown;
617
+ failed?: Array<{ target?: unknown }>;
618
+ failedCount?: unknown;
619
+ omittedFailures?: unknown;
620
+ }
610
621
  : undefined;
622
+ const receipts = Array.isArray(details?.sent)
623
+ ? details.sent.flatMap((receipt) => {
624
+ if (!receipt || typeof receipt !== "object" || Array.isArray(receipt)) return [];
625
+ const item = receipt as { target?: unknown; msg_id?: unknown };
626
+ return typeof item.target === "string" && typeof item.msg_id === "string" && item.msg_id.length > 0
627
+ ? [{ target: item.target, msgId: item.msg_id }]
628
+ : [];
629
+ })
630
+ : [];
611
631
  const sampledFailures = details?.failed ?? [];
612
632
  const failedTargets = new Set(sampledFailures.flatMap((failure) => typeof failure.target === "string" ? [failure.target] : []));
613
633
  // A broadcast samples its failures (`MAX_BROADCAST_DETAIL_ITEMS` in tools/exocom.ts) and
@@ -618,16 +638,31 @@ export function installHooks(pi: ExtensionAPI, h: HookHost, exocom: ExocomInstal
618
638
  typeof details?.omittedFailures === "number" ? details.omittedFailures : 0,
619
639
  declaredFailures - sampledFailures.length,
620
640
  );
621
- for (const message of pending) h.telemetry?.publish("message.sent", {
622
- id: message.id,
623
- channel: "exocom",
624
- from: exocom.telemetrySessionId(ctx),
625
- to: message.sessionId,
626
- kind: "message",
627
- status: event.isError || failedTargets.has(message.target) ? "failed" : omittedFailures > 0 ? "unknown" : "delivered",
628
- expectsReply: false,
629
- size: message.size,
630
- });
641
+ for (const message of pending) {
642
+ const matchingReceipts = !event.isError && details?.target === "*"
643
+ ? receipts.filter((receipt) => receipt.target === message.target)
644
+ : [];
645
+ const failed = event.isError || failedTargets.has(message.target);
646
+ const wireId = failed ? undefined
647
+ : details?.target === message.target && typeof details.msg_id === "string" && details.msg_id.length > 0
648
+ ? details.msg_id
649
+ : matchingReceipts.length === 1 ? matchingReceipts[0]!.msgId : undefined;
650
+ h.telemetry?.publish("message.sent", {
651
+ id: message.id,
652
+ channel: "exocom",
653
+ from: exocom.telemetrySessionId(ctx),
654
+ to: message.sessionId,
655
+ kind: "message",
656
+ // A matching successful ACK receipt settles this recipient even when the bounded
657
+ // broadcast failure sample omitted failures for other peers. Without a receipt, an
658
+ // incomplete failure sample cannot prove this particular row was delivered.
659
+ status: failed ? "failed" : omittedFailures > 0 && wireId === undefined ? "unknown" : "delivered",
660
+ expectsReply: false,
661
+ size: message.size,
662
+ ...(message.replyTo ? { replyTo: message.replyTo } : {}),
663
+ ...(wireId ? { wireId } : {}),
664
+ });
665
+ }
631
666
  }
632
667
  if (event.toolName === "intercom") {
633
668
  const action = typeof telemetryInput.action === "string" ? telemetryInput.action : "";
package/src/extension.ts CHANGED
@@ -108,7 +108,7 @@ import {
108
108
  pendingParticipants,
109
109
  type ModelParticipant,
110
110
  } from "./persona/model-participants.ts";
111
- import { expandCouncilPreset, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
111
+ import { expandCouncilPreset, resolveCouncilInvocation, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
112
112
  import { bundledSpinePath, bundledWorkerSpinePath, readSpineFile, resolveSpine, type SpineSources } from "./persona/spine.ts";
113
113
  import { readLastPersona, writeLastPersona } from "./persona/state.ts";
114
114
  import { type AgentKind as TelemetryAgentKind, type AgentStatus as TelemetryAgentStatus } from "./telemetry/contract.ts";
@@ -549,7 +549,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
549
549
  // rather than to a "subagent" literal that names nobody. Bounded; cleared on teardown.
550
550
  const telemetryAskSenders = new Map<string, string>();
551
551
  const MAX_TRACKED_ASK_SENDERS = 256;
552
- type PendingExocomTelemetry = { id: string; sessionId: string; target: string; size: number };
552
+ type PendingExocomTelemetry = { id: string; sessionId: string; target: string; size: number; replyTo?: string };
553
553
  const telemetryExocomPending = new Map<string, PendingExocomTelemetry[]>();
554
554
  // …and the same for intercom: both channels open at tool_call and close at tool_result, so both are
555
555
  // left hanging when Pi resolves a call as "immediate" and never reaches a result.
@@ -1013,9 +1013,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1013
1013
  try {
1014
1014
  const all = lastCtx?.modelRegistry.getAll() ?? [];
1015
1015
  const m = all.find((x) => x.provider === handle.provider && x.id === handle.id);
1016
- if (m) await pi.setModel(m);
1016
+ return m ? await pi.setModel(m) : false;
1017
1017
  } catch {
1018
- /* keep current */
1018
+ return false; // keep current, but let the controller report that the switch failed
1019
1019
  }
1020
1020
  },
1021
1021
  setStatus: (text) => {
@@ -1383,16 +1383,56 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1383
1383
  * authenticated model to choose between.
1384
1384
  */
1385
1385
  async function ensurePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[]): Promise<void> {
1386
- const persona = controller.activePersona?.name;
1387
- if (!persona || !ctx.hasUI) return;
1388
- const missing = pendingParticipants(participants, { prompted: promptedFor(persona, participants) });
1386
+ const scope = modelSelectionScope(ctx);
1387
+ if (!scope || !ctx.hasUI) return;
1388
+ const missing = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) });
1389
1389
  if (missing.length === 0) return;
1390
1390
  const available = configuredModels(ctx);
1391
- if (available.length < 2) return; // can't diversify with a single configured model
1391
+ if (available.length < 2) return; // automatic setup cannot diversify a single model
1392
+ const chosen = await choosePersonaModels(ctx, missing, available, scope);
1393
+ if (chosen && Object.keys(chosen).length > 0) savePersonaModels(ctx, scope.persona, chosen);
1394
+ }
1395
+
1396
+ type ModelSelectionScope = { persona: string; isCurrent(): boolean };
1397
+ function modelSelectionScope(ctx: ExtensionContext): ModelSelectionScope | undefined {
1398
+ const active = controller.activePersona;
1399
+ if (!active) return undefined;
1400
+ const sessionId = ctx.sessionManager?.getSessionId?.();
1401
+ return { persona: active.name, isCurrent: () => !disposed && controller.activePersona === active &&
1402
+ (sessionId === undefined || lastCtx?.sessionManager?.getSessionId?.() === sessionId) };
1403
+ }
1404
+
1405
+ /** An explicit user edit is allowed to replace defaults, unlike automatic first-run setup. */
1406
+ async function editPersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], scope: ModelSelectionScope): Promise<void> {
1407
+ const editable = participants.filter((p) => p.source !== "inline");
1408
+ if (editable.length === 0) {
1409
+ ctx.ui.notify("persona models: no editable council participants (inline model pins are unchanged).", "info");
1410
+ return;
1411
+ }
1412
+ // Trust fresh availability here, not the discovery union with provisional catalog entries.
1413
+ const available = ctx.modelRegistry.getAvailable();
1414
+ if (available.length === 0) {
1415
+ ctx.ui.notify("persona models: no available models. Use /login, then retry /persona models.", "warning");
1416
+ return;
1417
+ }
1418
+ const chosen = await choosePersonaModels(ctx, editable, available, scope);
1419
+ if (!chosen) return;
1420
+ if (Object.keys(chosen).length === 0) {
1421
+ ctx.ui.notify("persona models: no model assignments changed; previous choices kept.", "info");
1422
+ return;
1423
+ }
1424
+ if (savePersonaModels(ctx, scope.persona, chosen)) {
1425
+ ctx.ui.notify(`persona models: updated ${Object.keys(chosen).length} model assignment(s) for "${scope.persona}"; applies to future runs.`, "info");
1426
+ }
1427
+ }
1428
+
1429
+ /** UI adapter shared by setup and editing; callers own eligibility and availability policy. */
1430
+ async function choosePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], available: Array<{ provider: string; id: string }>, scope: ModelSelectionScope): Promise<Record<string, string> | undefined> {
1431
+ const { persona } = scope;
1392
1432
  const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
1393
1433
  const chosen: Record<string, string> = {};
1394
1434
  try {
1395
- for (const p of missing) {
1435
+ for (const p of participants) {
1396
1436
  // Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
1397
1437
  // for this session, exactly like an answer is not asked twice.
1398
1438
  promptedParticipants.add(`${persona}\0${p.key}`);
@@ -1403,7 +1443,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1403
1443
  const role = p.param ? ` · ${p.param}` : "";
1404
1444
  // A participant label carries a role prompt (free prose from a team file or a
1405
1445
  // caller) — sanitised here so a title can never repaint the TUI or forge a row.
1406
- const title = compactInlineText(`Model for "${p.label}"${purpose ? ` (${purpose})` : ""}${role} · ${persona}`, { maxChars: 120 });
1446
+ const current = p.assigned && p.model ? ` · current: ${p.model}` : "";
1447
+ const title = compactInlineText(`Model for "${p.label}"${purpose ? ` (${purpose})` : ""}${role} · ${persona}${current}`, { maxChars: 200 });
1407
1448
  // In the TUI: a searchable picker (type to filter) whose viewport follows the
1408
1449
  // selection — the built-in select can't scroll a hundreds-long provider list
1409
1450
  // usefully. Outside the TUI (RPC), fall back to the built-in select.
@@ -1415,12 +1456,23 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1415
1456
  { overlay: true },
1416
1457
  )
1417
1458
  : await ctx.ui.select(title, options);
1418
- if (pick) chosen[p.key] = pick;
1459
+ if (!scope.isCurrent()) {
1460
+ ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
1461
+ return;
1462
+ }
1463
+ if (pick && options.includes(pick)) chosen[p.key] = pick;
1419
1464
  }
1420
1465
  } catch {
1421
1466
  /* dismissed / no UI → fall back to the default model */
1422
1467
  }
1423
- if (Object.keys(chosen).length === 0) return;
1468
+ if (!scope.isCurrent()) {
1469
+ ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
1470
+ return;
1471
+ }
1472
+ return chosen;
1473
+ }
1474
+
1475
+ function savePersonaModels(ctx: ExtensionContext, persona: string, chosen: Record<string, string>): boolean {
1424
1476
  // The picks are live for this session either way (the in-memory store is updated first), so
1425
1477
  // a save failure costs only PERSISTENCE — but silently swallowing it would re-prompt on every
1426
1478
  // future session with no hint that the assignment never landed.
@@ -1432,7 +1484,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1432
1484
  `pi-persona: could not save the model assignment for "${persona}" (${err instanceof Error ? err.message : String(err)}) — it applies to this session only.`,
1433
1485
  "error",
1434
1486
  );
1487
+ return false;
1435
1488
  }
1489
+ return true;
1436
1490
  }
1437
1491
 
1438
1492
  /** A core's declared verticalization ("Propulsore", "Critico") — the lens it argues from, so a
@@ -2139,10 +2193,52 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2139
2193
 
2140
2194
  // ── /persona ─────────────────────────────────────────────────────────────────
2141
2195
  pi.registerCommand("persona", {
2142
- description: "Switch the active supervisor persona: /persona [name|off|list|reload|seed|restore]",
2196
+ description: "Switch persona or edit council models: /persona [name|off|list|reload|seed|restore|models [council-persona]]",
2143
2197
  handler: async (args, ctx) => {
2144
2198
  lastCtx = ctx;
2145
2199
  const arg = args.trim();
2200
+ if (arg === "models" || arg.startsWith("models ")) {
2201
+ const caller = controller.activePersona;
2202
+ const scope = modelSelectionScope(ctx);
2203
+ if (!caller || !scope) {
2204
+ ctx.ui.notify("persona models: activate a persona first with /persona <name>.", "warning");
2205
+ return;
2206
+ }
2207
+ if (!ctx.hasUI) {
2208
+ ctx.ui.notify("persona models: an interactive TUI or RPC UI is required to select council models; no assignments changed.", "warning");
2209
+ return;
2210
+ }
2211
+ const profile = arg.slice("models".length).trim();
2212
+ const resolved = resolveCouncilInvocation(personas, caller, profile ? { persona: profile } : {});
2213
+ if (!resolved.ok) {
2214
+ ctx.ui.notify(`persona models: ${resolved.error}`, "error");
2215
+ return;
2216
+ }
2217
+ try {
2218
+ const { strategy, roster, members, params } = resolved.value;
2219
+ const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
2220
+ const deadline = AbortSignal.timeout(20_000);
2221
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
2222
+ const result = await ctx.modelRegistry.refresh?.({ signal });
2223
+ if (!scope.isCurrent()) {
2224
+ ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
2225
+ return;
2226
+ }
2227
+ if (signal.aborted || result?.aborted) {
2228
+ ctx.ui.notify("persona models: refresh cancelled or timed out; previous assignments kept.", "warning");
2229
+ return;
2230
+ }
2231
+ if (result?.errors.size) {
2232
+ const providers = compactInlineText([...result.errors.keys()].join(", "), { maxChars: 160 });
2233
+ ctx.ui.notify(`persona models: refresh incomplete for ${providers}; previous assignments kept. Check provider configuration and retry.`, "warning");
2234
+ return;
2235
+ }
2236
+ await editPersonaModels(ctx, runParticipants(ctx, { strategy, members: panel, params }), scope);
2237
+ } catch (err) {
2238
+ ctx.ui.notify(`persona models: refresh failed (${err instanceof Error ? err.message : String(err)}); previous assignments kept.`, "error");
2239
+ }
2240
+ return;
2241
+ }
2146
2242
  if (arg === "off" || arg === "none") {
2147
2243
  await controller.deactivate();
2148
2244
  persist(undefined);
@@ -30,7 +30,7 @@ export interface PersonaHost {
30
30
  setThinkingLevel(level: ThinkingLevel): void;
31
31
  getModel(): ModelHandle | undefined;
32
32
  findModel(ref: string): ModelHandle | undefined;
33
- setModel(model: ModelHandle): void | Promise<void>;
33
+ setModel(model: ModelHandle): void | boolean | Promise<void | boolean>;
34
34
  setStatus(text: string | undefined): void;
35
35
  /** Optional: a declared-but-unavailable model/thinking would otherwise fail silently. */
36
36
  warn?(message: string): void;
@@ -108,11 +108,24 @@ export class PersonaController {
108
108
  if (persona.model) {
109
109
  const model = this.host.findModel(persona.model);
110
110
  if (model) {
111
- if (!this.modelOverridden) {
111
+ const firstOverride = !this.modelOverridden;
112
+ if (firstOverride) {
112
113
  this.baselineModel = this.host.getModel();
113
114
  this.modelOverridden = true;
114
115
  }
115
- await this.host.setModel(model);
116
+ const applied = await this.host.setModel(model);
117
+ if (applied === false) {
118
+ // Pi's setModel returns false when the provider has no configured auth. Do not
119
+ // leave a phantom override behind: a later persona or deactivation must not
120
+ // restore a baseline for a model that never became active.
121
+ if (firstOverride) {
122
+ this.modelOverridden = false;
123
+ this.baselineModel = undefined;
124
+ }
125
+ this.host.warn?.(
126
+ `persona "${persona.name}" declared model "${persona.model}" but provider authentication is not configured; keeping the current model`,
127
+ );
128
+ }
116
129
  } else {
117
130
  this.host.warn?.(
118
131
  `persona "${persona.name}" declared model "${persona.model}" which is not configured; keeping the current model`,
@@ -4,7 +4,7 @@ export const TELEMETRY_EVENT_NAME = "pi:telemetry" as const;
4
4
  export const LEGACY_TELEMETRY_EVENT_NAME = "pi-persona:telemetry" as const;
5
5
  export const LEGACY_TELEMETRY_VERSION = 1 as const;
6
6
  export const TELEMETRY_PRODUCER_ID = "pi-persona" as const;
7
- export const TELEMETRY_PRODUCER_VERSION = "1.16.2" as const;
7
+ export const TELEMETRY_PRODUCER_VERSION = "1.17.0" as const;
8
8
 
9
9
  export type InstanceStatus = "active" | "idle" | "stopped" | "stale" | (string & {});
10
10
  export type AgentKind = "subagent" | "delegate" | "council" | "flow" | "phase" | (string & {});
@@ -54,6 +54,8 @@ export interface MessageDescriptor {
54
54
  expectsReply: boolean;
55
55
  size: number;
56
56
  replyTo?: string;
57
+ /** Transport-native identity, distinct from `id`, which remains the stable telemetry-row key. */
58
+ wireId?: string;
57
59
  }
58
60
 
59
61
  export interface PeerDescriptor {
@@ -145,9 +147,9 @@ const KNOWN_FIELDS: Record<string, readonly string[]> = {
145
147
  "instance.stopped": ["reason"], "agent.added": ["id", "label", "kind", "status", "parentId", "agent", "persona", "model"],
146
148
  "agent.updated": ["id", "patch"], "agent.removed": ["id", "status"], "agent.cleared": [],
147
149
  "tool.started": ["callId", "agentId", "name", "status"], "tool.finished": ["callId", "agentId", "name", "status", "durationMs"],
148
- "message.sent": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
149
- "message.received": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
150
- "message.replied": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo"],
150
+ "message.sent": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo", "wireId"],
151
+ "message.received": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo", "wireId"],
152
+ "message.replied": ["id", "channel", "from", "to", "kind", "status", "expectsReply", "size", "replyTo", "wireId"],
151
153
  "peers.snapshot": ["peers"],
152
154
  };
153
155
 
@@ -198,7 +200,7 @@ function validKnownPayload(type: string, payload: Record<string, unknown>): bool
198
200
  if (type === "agent.removed") return stringField(payload, "id") && (payload.status === undefined || ["done", "failed", "stopped"].includes(payload.status as string));
199
201
  if (type === "tool.started") return stringField(payload, "callId") && stringField(payload, "agentId") && stringField(payload, "name") && stringField(payload, "status") && !TERMINAL_TOOL_STATUS.includes(payload.status as string);
200
202
  if (type === "tool.finished") return stringField(payload, "callId") && stringField(payload, "agentId") && stringField(payload, "name") && ["done", "failed"].includes(payload.status as string) && numberField(payload, "durationMs", { min: 0 });
201
- if (type === "message.sent" || type === "message.received" || type === "message.replied") return stringField(payload, "id") && stringField(payload, "channel") && stringField(payload, "from") && stringField(payload, "to") && stringField(payload, "kind") && stringField(payload, "status") && typeof payload.expectsReply === "boolean" && numberField(payload, "size", { integer: true, min: 0 }) && (payload.replyTo === undefined || stringField(payload, "replyTo"));
203
+ if (type === "message.sent" || type === "message.received" || type === "message.replied") return stringField(payload, "id") && stringField(payload, "channel") && stringField(payload, "from") && stringField(payload, "to") && stringField(payload, "kind") && stringField(payload, "status") && typeof payload.expectsReply === "boolean" && numberField(payload, "size", { integer: true, min: 0 }) && (payload.replyTo === undefined || stringField(payload, "replyTo")) && (payload.wireId === undefined || stringField(payload, "wireId"));
202
204
  if (type === "peers.snapshot") return Array.isArray(payload.peers) && payload.peers.every((peer) => { const p = objectPayload(peer); return p !== undefined && stringField(p, "sessionId") && stringField(p, "displayName") && stringField(p, "persona", false) && stringField(p, "model", false) && numberField(p, "contextPercent", { min: 0, max: 100 }) && typeof p.status === "string" && numberField(p, "sent", { integer: true, min: 0 }) && numberField(p, "received", { integer: true, min: 0 }); });
203
205
  return true;
204
206
  }
@@ -654,7 +654,9 @@ export async function runDelegate(
654
654
  (steer) => onLegSteerable?.(0, steer),
655
655
  );
656
656
  const text = r.ok ? r.output || "(no output)" : `Agent "${params.agent}" failed: ${r.error ?? "(no detail)"}`;
657
- return { text, results: [r], views: [viewOf(params.agent, label, r)], ok: r.ok };
657
+ const terminalView = viewOf(params.agent, label, r);
658
+ onProgress?.([{ ...terminalView }]);
659
+ return { text, results: [r], views: [terminalView], ok: r.ok };
658
660
  }
659
661
 
660
662
  return { text: "delegate: provide either { agent, task } or { tasks: [{ agent, task }, ...] }", results: [], views: [], ok: false };
@@ -36,6 +36,11 @@ export const MAX_BROADCAST_DETAIL_ITEMS = 64;
36
36
  const MODEL_BROADCAST_ID_SAMPLE = 12;
37
37
  const MODEL_BROADCAST_FAILURE_SAMPLE = 8;
38
38
 
39
+ /** Keep a correlation token only when it is already safe and bounded; never truncate a route token. */
40
+ function isExactBoundedMetadataText(value: unknown, max: number): value is string {
41
+ return typeof value === "string" && value.length > 0 && normalizeMetadataText(value, max, "") === value;
42
+ }
43
+
39
44
  const ExocomListParams = Type.Object({
40
45
  offset: Type.Optional(Type.Integer({ minimum: 0, maximum: 1_000_000, description: "Zero-based page offset into the live peer list." })),
41
46
  limit: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_MODEL_PEER_ROWS, description: `Maximum peers to show (default ${MAX_MODEL_PEER_ROWS}).` })),
@@ -68,6 +73,8 @@ type ExocomSendDetails =
68
73
  queuedCount: number;
69
74
  failedCount: number;
70
75
  msg_ids: string[];
76
+ /** Bounded transport receipts, retaining the exact route token needed to correlate each peer. */
77
+ sent: Array<{ target: string; msg_id: string }>;
71
78
  failed: Array<{ target: string; error: string }>;
72
79
  omittedMsgIds: number;
73
80
  omittedFailures: number;
@@ -386,6 +393,11 @@ export function registerExocomTools(
386
393
  `${normalizeMetadataText(item.target, 80, "peer")}: ${rejectedTargets.has(item.target) ? "rejected by the peer; reason quoted below" : normalizeMetadataText(item.error, 160, "send failed")}`,
387
394
  );
388
395
  const omittedFailures = failed.length - shownFailures.length;
396
+ const shownReceipts = sent.flatMap((item) =>
397
+ isExactBoundedMetadataText(item.target, 80) && isExactBoundedMetadataText(item.msg_id, 128)
398
+ ? [{ target: item.target, msg_id: item.msg_id }]
399
+ : [],
400
+ ).slice(0, MAX_BROADCAST_DETAIL_ITEMS);
389
401
  const action = omittedIds > 0 || omittedFailures > 0
390
402
  ? `; ${[
391
403
  shownIds.length > 0 ? `msg_ids: ${shownIds.join(", ")}${omittedIds > 0 ? `; +${omittedIds} more msg_ids omitted` : ""}` : "",
@@ -402,11 +414,12 @@ export function registerExocomTools(
402
414
  queuedCount: sent.length,
403
415
  failedCount: failed.length,
404
416
  msg_ids: sent.slice(0, MAX_BROADCAST_DETAIL_ITEMS).map((item) => normalizeMetadataText(item.msg_id, 128, "msg")),
417
+ sent: shownReceipts,
405
418
  failed: failed.slice(0, MAX_BROADCAST_DETAIL_ITEMS).map((failure) => ({
406
419
  target: normalizeMetadataText(failure.target, 80, "peer"),
407
420
  error: normalizeMetadataText(failure.error, 240, "send failed"),
408
421
  })),
409
- omittedMsgIds: Math.max(0, sent.length - MAX_BROADCAST_DETAIL_ITEMS),
422
+ omittedMsgIds: Math.max(0, sent.length - shownReceipts.length),
410
423
  omittedFailures: Math.max(0, failed.length - MAX_BROADCAST_DETAIL_ITEMS),
411
424
  },
412
425
  };