@aeondave/pi-persona 1.16.3 → 1.17.1

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/src/extension.ts CHANGED
@@ -21,6 +21,7 @@ import { Text } from "@earendil-works/pi-tui";
21
21
  import type { AgentConfig } from "./agents/agent.ts";
22
22
  import { installBridge, isBridgeMode } from "./bridge.ts";
23
23
  import { resolveConfig } from "./core/config.ts";
24
+ import { resolvePersonaConfig } from "./core/settings.ts";
24
25
  import { type ContractDef, installedContractNames } from "./core/contract.ts";
25
26
  import { type DataRootMigrationResult, migrateLegacyDataRoot } from "./core/data-root.ts";
26
27
  import {
@@ -77,6 +78,9 @@ export {
77
78
  } from "./extension/shared.ts";
78
79
  import { installExocom } from "./exocom/install.ts";
79
80
  import { installIdentity } from "./extension/identity.ts";
81
+ import { registerPersonaSettings } from "./extension/persona-settings.ts";
82
+ import { openPersonaModelSettings } from "./extension/persona-model-settings.ts";
83
+ import { collectCompanionSettings } from "./extension/companion-settings.ts";
80
84
  import { parseExocomArgv } from "./exocom/activation.ts";
81
85
  import { formatLedgerStatus } from "./exocom/status.ts";
82
86
  import { registerDelegateTool } from "./tools/delegate-tool.ts";
@@ -87,7 +91,7 @@ import { createMonitorSession, type MonitorSession } from "./monitor/session.ts"
87
91
  import { registerCouncilTool } from "./tools/council.ts";
88
92
  import { registerFlowTool } from "./tools/flow.ts";
89
93
  import { registerModelsTool } from "./tools/models.ts";
90
- import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn, buildPeekAlert, buildPeekDigest, buildRetentionOverflowNote, IdleCoalescingNotifier, PeekWatcher, renderCompletion, STALL_FLAG_MS } from "./engine/async.ts";
94
+ import { type AsyncRun, AsyncRunTracker, boundCompletionSurface, buildCheckIn, buildPeekAlert, buildPeekDigest, buildRetentionOverflowNote, isAsyncRunStalled, IdleCoalescingNotifier, PeekWatcher, renderCompletion, STALL_FLAG_MS } from "./engine/async.ts";
91
95
  import { emptyUsage, type ToolEvent } from "./engine/stream.ts";
92
96
  import { brokerEndpoint } from "./bus/broker/paths.ts";
93
97
  import { InProcessBus } from "./bus/inproc.ts";
@@ -108,17 +112,15 @@ import {
108
112
  pendingParticipants,
109
113
  type ModelParticipant,
110
114
  } from "./persona/model-participants.ts";
111
- import { expandCouncilPreset, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
115
+ import { expandCouncilPreset, resolveCouncilInvocation, type OrchestrationGrammar, type Persona } from "./persona/persona.ts";
112
116
  import { bundledSpinePath, bundledWorkerSpinePath, readSpineFile, resolveSpine, type SpineSources } from "./persona/spine.ts";
113
- import { readLastPersona, writeLastPersona } from "./persona/state.ts";
117
+ import { migratePersonaSettings, patchPersonaSettings, readPersonaSettings, type PersonaSettings } from "./persona/settings-store.ts";
114
118
  import { type AgentKind as TelemetryAgentKind, type AgentStatus as TelemetryAgentStatus } from "./telemetry/contract.ts";
115
119
  import { TelemetryProducer, type TelemetryAgentInput } from "./telemetry/producer.ts";
116
120
  import {
117
121
  type PersonaConfigStore,
118
122
  personaModels,
119
- readPersonaConfigs,
120
123
  withPersonaModels,
121
- writePersonaConfigs,
122
124
  } from "./persona/config-store.ts";
123
125
  import { shortModel } from "./tools/delegate.ts";
124
126
  import { formatInbox } from "./tools/intercom.ts";
@@ -183,6 +185,7 @@ interface NudgeEntry {
183
185
 
184
186
  interface CheckInEntry {
185
187
  content: string;
188
+ kind?: "routine" | "stall";
186
189
  }
187
190
 
188
191
  /**
@@ -279,7 +282,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
279
282
  return;
280
283
  }
281
284
 
282
- const config = resolveConfig(process.env);
285
+ let config = resolveConfig(process.env);
283
286
  if (config.disabled) return;
284
287
  const exocomArgs = parseExocomArgv(options.exocomArgv ?? process.argv);
285
288
  const registerFollowUpRenderer = (customType: "pi-persona" | "exocom_received", label: string): void => {
@@ -342,7 +345,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
342
345
  pi.registerEntryRenderer("pi-persona-status", (entry, { expanded }, theme) => {
343
346
  const data = entry.data as CheckInEntry;
344
347
  const full = sanitizeTerminalText(data.content || "Async activity · routine check-in");
345
- const title = theme.fg("accent", theme.bold("pi-persona · async status"));
348
+ const title = theme.fg(data.kind === "stall" ? "warning" : "accent", theme.bold(data.kind === "stall" ? "pi-persona · possible stall" : "pi-persona · async status"));
346
349
  if (expanded) return new Text(`${title}\n${theme.fg("toolOutput", full)}`, 0, 0);
347
350
  // The entry title already labels this as async status. Spend the compact preview on the
348
351
  // aggregate and first run row so the operator sees the selected alias and usage label.
@@ -355,8 +358,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
355
358
  const appendNudgeEntry = (content: string): void => {
356
359
  pi.appendEntry("pi-persona-nudge", { content } satisfies NudgeEntry);
357
360
  };
358
- const appendCheckInEntry = (content: string): void => {
359
- pi.appendEntry("pi-persona-status", { content } satisfies CheckInEntry);
361
+ const appendCheckInEntry = (content: string, kind: "routine" | "stall" = "routine"): void => {
362
+ pi.appendEntry("pi-persona-status", { content, kind } satisfies CheckInEntry);
360
363
  };
361
364
  const appendCommandResult = (
362
365
  label: string,
@@ -390,7 +393,37 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
390
393
  return { moved: [], kept: [], warnings: [`data root migration failed: ${message}`] };
391
394
  }
392
395
  })();
396
+ // One persistent settings owner. The root migration must run before importing the two
397
+ // retired stores; fork-guarded children returned before any filesystem access above.
398
+ const settingsFile = join(personaDataDir(), "settings.json");
399
+ let savedSettings: PersonaSettings = { version: 1, preferences: {}, personas: {}, lastPersona: null };
400
+ const settingsWarnings: string[] = [];
401
+ const reportedSettingsWarnings = new Set<string>();
402
+ try {
403
+ const result = migratePersonaSettings(settingsFile, {
404
+ legacyConfigFile: join(personaDataDir(), "config.json"),
405
+ legacyStateFile: config.stateFile ?? join(personaDataDir(), "state.json"),
406
+ });
407
+ savedSettings = result.settings;
408
+ settingsWarnings.push(...result.warnings);
409
+ } catch (error) {
410
+ settingsWarnings.push(`could not load settings: ${error instanceof Error ? error.message : String(error)}`);
411
+ }
412
+ config = resolvePersonaConfig(savedSettings.preferences, process.env);
413
+ function reportSettingsWarning(ctx: ExtensionContext | undefined, message: string): void {
414
+ if (reportedSettingsWarnings.has(message)) return;
415
+ reportedSettingsWarnings.add(message);
416
+ const text = `pi-persona settings: ${compactInlineText(message, { maxChars: 500 })}`;
417
+ try {
418
+ if (ctx?.hasUI) ctx.ui.notify(text, "warning");
419
+ else process.stderr.write(`${text}\n`);
420
+ } catch {
421
+ // A failed UI must not turn recoverable configuration trouble into activation failure.
422
+ try { process.stderr.write(`${text}\n`); } catch { /* Non-fatal diagnostic. */ }
423
+ }
424
+ }
393
425
  const reportDataRootMigration = (ctx: ExtensionContext): void => {
426
+ for (const warning of settingsWarnings) reportSettingsWarning(ctx, warning);
394
427
  if (dataRootMigration.moved.length > 0) {
395
428
  const message = `pi-persona: moved ${dataRootMigration.moved.length} file(s) from the legacy pi-persona storage root into ${personaDataDir()}`;
396
429
  if (ctx.hasUI) ctx.ui.notify(message, "info");
@@ -549,7 +582,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
549
582
  // rather than to a "subagent" literal that names nobody. Bounded; cleared on teardown.
550
583
  const telemetryAskSenders = new Map<string, string>();
551
584
  const MAX_TRACKED_ASK_SENDERS = 256;
552
- type PendingExocomTelemetry = { id: string; sessionId: string; target: string; size: number };
585
+ type PendingExocomTelemetry = { id: string; sessionId: string; target: string; size: number; replyTo?: string };
553
586
  const telemetryExocomPending = new Map<string, PendingExocomTelemetry[]>();
554
587
  // …and the same for intercom: both channels open at tool_call and close at tool_result, so both are
555
588
  // left hanging when Pi resolves a call as "immediate" and never reaches a result.
@@ -849,21 +882,36 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
849
882
  let seedSourceCollisions: string[] = [];
850
883
 
851
884
  // Remembered selection lives in the persona data folder; only user gestures write it.
852
- const stateFile = config.stateFile ?? join(personaDataDir(), "state.json");
853
885
  const readRememberedPersona = (): string | undefined => {
854
886
  if (!config.persist) return undefined;
855
- return readLastPersona(stateFile);
887
+ try {
888
+ savedSettings = readPersonaSettings(settingsFile);
889
+ return savedSettings.lastPersona ?? undefined;
890
+ } catch (error) {
891
+ reportSettingsWarning(lastCtx, `could not read remembered persona: ${error instanceof Error ? error.message : String(error)}`);
892
+ return undefined;
893
+ }
856
894
  };
857
895
  const persist = (name: string | undefined): void => {
858
- if (config.persist) writeLastPersona(stateFile, name);
896
+ if (!config.persist) return;
897
+ try {
898
+ savedSettings = patchPersonaSettings(settingsFile, { lastPersona: name ?? null });
899
+ } catch (error) {
900
+ reportSettingsWarning(lastCtx, `could not save remembered persona: ${error instanceof Error ? error.message : String(error)}`);
901
+ }
859
902
  };
860
903
 
861
- // Per-persona config (model assignments today, open-ended), indexed by persona name.
862
- const configFile = join(personaDataDir(), "config.json");
904
+ // Model preferences share the canonical file with extension preferences and selection.
863
905
  const readConfigStore = (): PersonaConfigStore => {
864
- return readPersonaConfigs(configFile);
906
+ try {
907
+ savedSettings = readPersonaSettings(settingsFile);
908
+ return savedSettings.personas;
909
+ } catch (error) {
910
+ reportSettingsWarning(lastCtx, `could not read model settings: ${error instanceof Error ? error.message : String(error)}`);
911
+ return savedSettings.personas;
912
+ }
865
913
  };
866
- let personaConfigs: PersonaConfigStore = {};
914
+ let personaConfigs: PersonaConfigStore = savedSettings.personas;
867
915
  // A participant is asked for a model at most ONCE per session — dismissal included, so a
868
916
  // closed picker never becomes a nag. Keyed by persona + assignment key, NOT by persona: a new
869
917
  // roster (or a new strategy's arbiter) is a genuinely new participant and may ask the first time
@@ -1013,9 +1061,9 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1013
1061
  try {
1014
1062
  const all = lastCtx?.modelRegistry.getAll() ?? [];
1015
1063
  const m = all.find((x) => x.provider === handle.provider && x.id === handle.id);
1016
- if (m) await pi.setModel(m);
1064
+ return m ? await pi.setModel(m) : false;
1017
1065
  } catch {
1018
- /* keep current */
1066
+ return false; // keep current, but let the controller report that the switch failed
1019
1067
  }
1020
1068
  },
1021
1069
  setStatus: (text) => {
@@ -1223,13 +1271,12 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1223
1271
  return;
1224
1272
  }
1225
1273
  const now = Date.now();
1226
- const dueCheckIn = config.checkInEveryMs > 0 && now - lastPeekAt >= config.checkInEveryMs;
1227
- // Routine status is durable operator history, not a model notification: append it on cadence even
1228
- // while a turn is busy or human input is queued. This keeps visibility independent of wake eligibility.
1229
- if (dueCheckIn) {
1230
- lastPeekAt = now;
1274
+ const waitingRuns = waitingAsyncRunIds();
1275
+ const fast = config.peekEveryMs > 0;
1276
+ const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
1277
+ const appendStatus = (content: string, kind: "routine" | "stall"): void => {
1231
1278
  try {
1232
- appendCheckInEntry(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingAsyncRunIds() }));
1279
+ appendCheckInEntry(content, kind);
1233
1280
  } catch (error) {
1234
1281
  const detail = compactInlineText(error instanceof Error ? error.message : String(error), { maxChars: 120 });
1235
1282
  const warning = `pi-persona: could not append async status entry${detail ? ` (${detail})` : ""}`;
@@ -1241,27 +1288,30 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1241
1288
  try { process.stderr.write(`[pi-persona] ${warning}\n`); } catch { /* diagnostics must not block an actionable wake */ }
1242
1289
  }
1243
1290
  }
1291
+ };
1292
+ // Every new stall episode is durable operator history, including while the model is busy.
1293
+ if (stuck.length > 0) appendStatus(buildCheckIn(stuck, { now, stallMs: STALL_FLAG_MS, reason: "stall", waitingForSupervisor: waitingRuns }), "stall");
1294
+ const dueCheckIn = config.checkInEveryMs > 0 && now - lastPeekAt >= config.checkInEveryMs;
1295
+ if (dueCheckIn) {
1296
+ lastPeekAt = now;
1297
+ appendStatus(buildCheckIn(runs, { now, stallMs: STALL_FLAG_MS, waitingForSupervisor: waitingRuns }), "routine");
1244
1298
  }
1245
- // Poll actionable model wakes only for a free supervisor. A busy/queued session keeps messages
1246
- // unread and leaves stall state untouched for the next tick, rather than stacking sticky follow-ups.
1299
+ // Interactive stalls are operator-only. Headless sessions may consume one model wake per
1300
+ // run lifetime; unread non-ask messages retain their existing actionable route.
1247
1301
  if (!canDeliverPersonaNotification(orchestrating, processingDeferredOrchestration, lastCtx?.isIdle?.() === true) || lastCtx?.hasPendingMessages?.() === true) return;
1248
- // The watchdog is not a model polling loop: the FAST path wakes on newly-stalled legs or unread
1249
- // progress messages. Completions arrive separately through completionNotifier; `/peek` remains on demand.
1250
- const fast = config.peekEveryMs > 0;
1251
- const waitingRuns = waitingAsyncRunIds();
1252
- const stuck = fast ? peekWatcher.poll(runs, now, STALL_FLAG_MS, waitingRuns) : [];
1253
- // Drain only progress messages; blocking asks (expectsReply) are surfaced by the intercom
1254
- // notifier and left for the `intercom inbox` tool — so peek never double-shows them.
1255
1302
  const unread = fast ? bus.takeWhere(SUPERVISOR, (e) => !e.expectsReply) : [];
1256
- if (stuck.length === 0 && unread.length === 0) return; // healthy + quiet ⇒ stay silent
1303
+ const wakeCandidates = fast && lastCtx?.hasUI === false
1304
+ ? peekWatcher.firstWakeCandidates(runs.filter((run) => isAsyncRunStalled(run, now, STALL_FLAG_MS)))
1305
+ : [];
1306
+ if (wakeCandidates.length === 0 && unread.length === 0) return;
1257
1307
  const parts: string[] = [];
1258
- if (stuck.length > 0) parts.push(buildPeekAlert(stuck, { now }));
1308
+ if (wakeCandidates.length > 0) parts.push(buildPeekAlert(wakeCandidates, { now }));
1259
1309
  if (unread.length > 0) parts.push(`📨 from sub-agents:\n${fenceUntrusted(formatInbox(unread))}`);
1260
- if (stuck.length > 0) lastPeekAt = now; // message-only wakes do not postpone the routine status cadence
1261
1310
  try {
1262
1311
  sendPersonaFollowUp(pi, `[pi-persona] ${parts.join("\n\n")}`);
1312
+ peekWatcher.acknowledgeModelWake(wakeCandidates.map((run) => run.id));
1263
1313
  } catch {
1264
- /* ignore */
1314
+ peekWatcher.retryReports(wakeCandidates);
1265
1315
  }
1266
1316
  }, tickMs);
1267
1317
  peekTimer.unref?.();
@@ -1360,8 +1410,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1360
1410
  * Every participant a run really spawns — roster members (inline ones included) plus the
1361
1411
  * auxiliary actors its strategy declares — with the model each one resolves to.
1362
1412
  */
1363
- function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }): ModelParticipant[] {
1364
- const persona = controller.activePersona?.name;
1413
+ function runParticipants(ctx: ExtensionContext, opts: { strategy: string; params?: Record<string, unknown>; members: RosterMember[] }, persona = controller.activePersona?.name): ModelParticipant[] {
1365
1414
  return collectModelParticipants({
1366
1415
  members: opts.members,
1367
1416
  strategy: opts.strategy,
@@ -1383,16 +1432,56 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1383
1432
  * authenticated model to choose between.
1384
1433
  */
1385
1434
  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) });
1435
+ const scope = modelSelectionScope(ctx);
1436
+ if (!scope || !ctx.hasUI) return;
1437
+ const missing = pendingParticipants(participants, { prompted: promptedFor(scope.persona, participants) });
1389
1438
  if (missing.length === 0) return;
1390
1439
  const available = configuredModels(ctx);
1391
- if (available.length < 2) return; // can't diversify with a single configured model
1440
+ if (available.length < 2) return; // automatic setup cannot diversify a single model
1441
+ const chosen = await choosePersonaModels(ctx, missing, available, scope);
1442
+ if (chosen && Object.keys(chosen).length > 0) savePersonaModels(ctx, scope.persona, chosen);
1443
+ }
1444
+
1445
+ type ModelSelectionScope = { persona: string; isCurrent(): boolean };
1446
+ function modelSelectionScope(ctx: ExtensionContext): ModelSelectionScope | undefined {
1447
+ const active = controller.activePersona;
1448
+ if (!active) return undefined;
1449
+ const sessionId = ctx.sessionManager?.getSessionId?.();
1450
+ return { persona: active.name, isCurrent: () => !disposed && controller.activePersona === active &&
1451
+ (sessionId === undefined || lastCtx?.sessionManager?.getSessionId?.() === sessionId) };
1452
+ }
1453
+
1454
+ /** An explicit user edit is allowed to replace defaults, unlike automatic first-run setup. */
1455
+ async function editPersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], scope: ModelSelectionScope): Promise<void> {
1456
+ const editable = participants.filter((p) => p.source !== "inline");
1457
+ if (editable.length === 0) {
1458
+ ctx.ui.notify("persona models: no editable council participants (inline model pins are unchanged).", "info");
1459
+ return;
1460
+ }
1461
+ // Trust fresh availability here, not the discovery union with provisional catalog entries.
1462
+ const available = ctx.modelRegistry.getAvailable();
1463
+ if (available.length === 0) {
1464
+ ctx.ui.notify("persona models: no available models. Use /login, then retry /persona models.", "warning");
1465
+ return;
1466
+ }
1467
+ const chosen = await choosePersonaModels(ctx, editable, available, scope);
1468
+ if (!chosen) return;
1469
+ if (Object.keys(chosen).length === 0) {
1470
+ ctx.ui.notify("persona models: no model assignments changed; previous choices kept.", "info");
1471
+ return;
1472
+ }
1473
+ if (savePersonaModels(ctx, scope.persona, chosen)) {
1474
+ ctx.ui.notify(`persona models: updated ${Object.keys(chosen).length} model assignment(s) for "${scope.persona}"; applies to future runs.`, "info");
1475
+ }
1476
+ }
1477
+
1478
+ /** UI adapter shared by setup and editing; callers own eligibility and availability policy. */
1479
+ async function choosePersonaModels(ctx: ExtensionContext, participants: ModelParticipant[], available: Array<{ provider: string; id: string }>, scope: ModelSelectionScope): Promise<Record<string, string> | undefined> {
1480
+ const { persona } = scope;
1392
1481
  const options = orderModelRefs(available.map((m) => `${m.provider}/${m.id}`), ctx.model?.provider);
1393
1482
  const chosen: Record<string, string> = {};
1394
1483
  try {
1395
- for (const p of missing) {
1484
+ for (const p of participants) {
1396
1485
  // Recorded BEFORE the prompt: a dismissal (or a picker that dies) must not be retried
1397
1486
  // for this session, exactly like an answer is not asked twice.
1398
1487
  promptedParticipants.add(`${persona}\0${p.key}`);
@@ -1403,7 +1492,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1403
1492
  const role = p.param ? ` · ${p.param}` : "";
1404
1493
  // A participant label carries a role prompt (free prose from a team file or a
1405
1494
  // 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 });
1495
+ const current = p.assigned && p.model ? ` · current: ${p.model}` : "";
1496
+ const title = compactInlineText(`Model for "${p.label}"${purpose ? ` (${purpose})` : ""}${role} · ${persona}${current}`, { maxChars: 200 });
1407
1497
  // In the TUI: a searchable picker (type to filter) whose viewport follows the
1408
1498
  // selection — the built-in select can't scroll a hundreds-long provider list
1409
1499
  // usefully. Outside the TUI (RPC), fall back to the built-in select.
@@ -1415,24 +1505,38 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1415
1505
  { overlay: true },
1416
1506
  )
1417
1507
  : await ctx.ui.select(title, options);
1418
- if (pick) chosen[p.key] = pick;
1508
+ if (!scope.isCurrent()) {
1509
+ ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
1510
+ return;
1511
+ }
1512
+ if (pick && options.includes(pick)) chosen[p.key] = pick;
1419
1513
  }
1420
1514
  } catch {
1421
1515
  /* dismissed / no UI → fall back to the default model */
1422
1516
  }
1423
- if (Object.keys(chosen).length === 0) return;
1517
+ if (!scope.isCurrent()) {
1518
+ ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
1519
+ return;
1520
+ }
1521
+ return chosen;
1522
+ }
1523
+
1524
+ function savePersonaModels(ctx: ExtensionContext, persona: string, chosen: Record<string, string>): boolean {
1424
1525
  // The picks are live for this session either way (the in-memory store is updated first), so
1425
1526
  // a save failure costs only PERSISTENCE — but silently swallowing it would re-prompt on every
1426
1527
  // future session with no hint that the assignment never landed.
1427
1528
  personaConfigs = withPersonaModels(personaConfigs, persona, chosen);
1428
1529
  try {
1429
- writePersonaConfigs(configFile, personaConfigs);
1530
+ savedSettings = patchPersonaSettings(settingsFile, { models: { [persona]: chosen } });
1531
+ personaConfigs = savedSettings.personas;
1430
1532
  } catch (err) {
1431
1533
  ctx.ui.notify(
1432
1534
  `pi-persona: could not save the model assignment for "${persona}" (${err instanceof Error ? err.message : String(err)}) — it applies to this session only.`,
1433
1535
  "error",
1434
1536
  );
1537
+ return false;
1435
1538
  }
1539
+ return true;
1436
1540
  }
1437
1541
 
1438
1542
  /** A core's declared verticalization ("Propulsore", "Critico") — the lens it argues from, so a
@@ -1861,6 +1965,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1861
1965
  function doctorReport(): string {
1862
1966
  const lines: string[] = [];
1863
1967
  lines.push(`pi-persona — active: ${controller.activePersona?.label ?? "none"}`);
1968
+ lines.push(`settings: ${settingsFile} (/persona-settings; preferences: next session or Pi /reload)`);
1864
1969
  lines.push(`engine backend: ${config.engine === "child" ? "child-process" : "in-process"}`);
1865
1970
  const piVersion = installedPiVersion();
1866
1971
  lines.push(`pi: ${piVersion ?? "unknown"} (requires ≥ ${MIN_PI_VERSION})${piVersion && !satisfiesFloor(piVersion, MIN_PI_VERSION) ? " — BELOW FLOOR" : ""}`);
@@ -2137,12 +2242,114 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2137
2242
  },
2138
2243
  });
2139
2244
 
2245
+ // The dedicated settings surface never replaces Pi's /settings and never calls /reload.
2246
+ registerPersonaSettings(pi, {
2247
+ file: settingsFile,
2248
+ companions: () => collectCompanionSettings(pi.events),
2249
+ activeConfig: config,
2250
+ env: process.env,
2251
+ read: () => {
2252
+ savedSettings = readPersonaSettings(settingsFile);
2253
+ personaConfigs = savedSettings.personas;
2254
+ return savedSettings;
2255
+ },
2256
+ write: (patch) => {
2257
+ savedSettings = patchPersonaSettings(settingsFile, patch);
2258
+ personaConfigs = savedSettings.personas;
2259
+ },
2260
+ isCurrent: (ctx) => !disposed && !ctx.signal?.aborted &&
2261
+ (ctx.sessionManager?.getSessionId?.() === undefined ||
2262
+ lastCtx?.sessionManager?.getSessionId?.() === ctx.sessionManager.getSessionId()),
2263
+ diagnostics: doctorReport,
2264
+ models: async (ctx) => {
2265
+ lastCtx = ctx;
2266
+ personaConfigs = readConfigStore();
2267
+ const session = ctx.sessionManager?.getSessionId?.();
2268
+ await openPersonaModelSettings(ctx, {
2269
+ owners: () => personas.map((persona) => ({ name: persona.name, label: persona.label })),
2270
+ activeOwner: () => controller.activePersona?.name,
2271
+ profiles: (owner) => {
2272
+ const persona = personas.find((p) => p.name === owner);
2273
+ return [
2274
+ ...(persona?.orchestration?.strategy ? [{ name: "@orchestration", label: "Automatic orchestration" }] : []),
2275
+ ...personas.filter((p) => p.name !== owner && (p.council?.strategy || p.council?.members || p.council?.membersProblem))
2276
+ .map((p) => ({ name: p.name, label: `Council: ${p.label}` })),
2277
+ ];
2278
+ },
2279
+ participants: (owner, profile) => {
2280
+ const persona = personas.find((p) => p.name === owner);
2281
+ if (!persona) throw new Error(`persona "${owner}" is no longer installed`);
2282
+ const orchestrationStrategy = persona.orchestration?.strategy;
2283
+ if (profile === "@orchestration" && orchestrationStrategy && persona.orchestration) {
2284
+ const orchestration = persona.orchestration;
2285
+ const panel = resolveOrchestrationMembers(orchestration, teams);
2286
+ return runParticipants(ctx, { strategy: orchestrationStrategy, members: panel,
2287
+ ...(orchestration.params ? { params: orchestration.params } : {}) }, owner);
2288
+ }
2289
+ const resolved = resolveCouncilInvocation(personas, persona, profile ? { persona: profile } : {});
2290
+ if (!resolved.ok) throw new Error(resolved.error);
2291
+ const { strategy, roster, members, params } = resolved.value;
2292
+ const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
2293
+ return runParticipants(ctx, { strategy, members: panel, params }, owner);
2294
+ },
2295
+ saved: (owner) => personaModels(personaConfigs, owner),
2296
+ write: (owner, models) => {
2297
+ savedSettings = patchPersonaSettings(settingsFile, { models: { [owner]: models } });
2298
+ personaConfigs = savedSettings.personas;
2299
+ },
2300
+ isCurrent: () => !disposed && (session === undefined || lastCtx?.sessionManager?.getSessionId?.() === session),
2301
+ });
2302
+ },
2303
+ });
2304
+
2140
2305
  // ── /persona ─────────────────────────────────────────────────────────────────
2141
2306
  pi.registerCommand("persona", {
2142
- description: "Switch the active supervisor persona: /persona [name|off|list|reload|seed|restore]",
2307
+ description: "Switch persona or edit council models: /persona [name|off|list|reload|seed|restore|models [council-persona]]",
2143
2308
  handler: async (args, ctx) => {
2144
2309
  lastCtx = ctx;
2145
2310
  const arg = args.trim();
2311
+ if (arg === "models" || arg.startsWith("models ")) {
2312
+ const caller = controller.activePersona;
2313
+ const scope = modelSelectionScope(ctx);
2314
+ if (!caller || !scope) {
2315
+ ctx.ui.notify("persona models: activate a persona first with /persona <name>.", "warning");
2316
+ return;
2317
+ }
2318
+ if (!ctx.hasUI) {
2319
+ ctx.ui.notify("persona models: an interactive TUI or RPC UI is required to select council models; no assignments changed.", "warning");
2320
+ return;
2321
+ }
2322
+ const profile = arg.slice("models".length).trim();
2323
+ const resolved = resolveCouncilInvocation(personas, caller, profile ? { persona: profile } : {});
2324
+ if (!resolved.ok) {
2325
+ ctx.ui.notify(`persona models: ${resolved.error}`, "error");
2326
+ return;
2327
+ }
2328
+ try {
2329
+ const { strategy, roster, members, params } = resolved.value;
2330
+ const panel = resolveOrchestrationMembers({ roster, ...(members ? { members } : {}) }, teams);
2331
+ const deadline = AbortSignal.timeout(20_000);
2332
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, deadline]) : deadline;
2333
+ const result = await ctx.modelRegistry.refresh?.({ signal });
2334
+ if (!scope.isCurrent()) {
2335
+ ctx.ui.notify("persona models: persona or session changed; no assignments changed.", "warning");
2336
+ return;
2337
+ }
2338
+ if (signal.aborted || result?.aborted) {
2339
+ ctx.ui.notify("persona models: refresh cancelled or timed out; previous assignments kept.", "warning");
2340
+ return;
2341
+ }
2342
+ if (result?.errors.size) {
2343
+ const providers = compactInlineText([...result.errors.keys()].join(", "), { maxChars: 160 });
2344
+ ctx.ui.notify(`persona models: refresh incomplete for ${providers}; previous assignments kept. Check provider configuration and retry.`, "warning");
2345
+ return;
2346
+ }
2347
+ await editPersonaModels(ctx, runParticipants(ctx, { strategy, members: panel, params }), scope);
2348
+ } catch (err) {
2349
+ ctx.ui.notify(`persona models: refresh failed (${err instanceof Error ? err.message : String(err)}); previous assignments kept.`, "error");
2350
+ }
2351
+ return;
2352
+ }
2146
2353
  if (arg === "off" || arg === "none") {
2147
2354
  await controller.deactivate();
2148
2355
  persist(undefined);
@@ -67,6 +67,8 @@ export interface AgentProgress {
67
67
  tokens?: number;
68
68
  /** The tool the agent is currently running (e.g. "grep src/…"), if any. */
69
69
  activity?: string;
70
+ /** Timestamp of newly streamed tool output; no tool output text crosses this seam. */
71
+ toolOutputAt?: number;
70
72
  /** One authoritative runtime tool lifecycle transition; never contains tool args or output. */
71
73
  toolEvent?: ToolEvent;
72
74
  }