@aeondave/pi-persona 1.17.0 → 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";
@@ -110,15 +114,13 @@ import {
110
114
  } from "./persona/model-participants.ts";
111
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");
@@ -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
@@ -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,
@@ -1478,7 +1527,8 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1478
1527
  // future session with no hint that the assignment never landed.
1479
1528
  personaConfigs = withPersonaModels(personaConfigs, persona, chosen);
1480
1529
  try {
1481
- writePersonaConfigs(configFile, personaConfigs);
1530
+ savedSettings = patchPersonaSettings(settingsFile, { models: { [persona]: chosen } });
1531
+ personaConfigs = savedSettings.personas;
1482
1532
  } catch (err) {
1483
1533
  ctx.ui.notify(
1484
1534
  `pi-persona: could not save the model assignment for "${persona}" (${err instanceof Error ? err.message : String(err)}) — it applies to this session only.`,
@@ -1915,6 +1965,7 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
1915
1965
  function doctorReport(): string {
1916
1966
  const lines: string[] = [];
1917
1967
  lines.push(`pi-persona — active: ${controller.activePersona?.label ?? "none"}`);
1968
+ lines.push(`settings: ${settingsFile} (/persona-settings; preferences: next session or Pi /reload)`);
1918
1969
  lines.push(`engine backend: ${config.engine === "child" ? "child-process" : "in-process"}`);
1919
1970
  const piVersion = installedPiVersion();
1920
1971
  lines.push(`pi: ${piVersion ?? "unknown"} (requires ≥ ${MIN_PI_VERSION})${piVersion && !satisfiesFloor(piVersion, MIN_PI_VERSION) ? " — BELOW FLOOR" : ""}`);
@@ -2191,6 +2242,66 @@ export default function piPersona(pi: ExtensionAPI, options: PiPersonaOptions =
2191
2242
  },
2192
2243
  });
2193
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
+
2194
2305
  // ── /persona ─────────────────────────────────────────────────────────────────
2195
2306
  pi.registerCommand("persona", {
2196
2307
  description: "Switch persona or edit council models: /persona [name|off|list|reload|seed|restore|models [council-persona]]",
@@ -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
  }
@@ -1,215 +1,26 @@
1
1
  /**
2
- * Per-persona configuration, indexed by persona name and persisted as one JSON
3
- * file in the global persona folder. Intentionally open-ended: a persona that
4
- * needs settings gets an entry, others don't. Two personas that share a strategy
5
- * keep independent configs. Today it carries per-agent model assignments (so an
6
- * ensemble like magi can run its cores on *different* models); more keys later.
2
+ * Pure per-persona model configuration helpers. Persistence is owned exclusively
3
+ * by settings-store.ts (<agentDir>/persona/settings.json).
7
4
  */
8
5
 
9
- import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
10
- import { dirname } from "node:path";
11
-
12
6
  export interface PersonaConfig {
13
- /** Roster agent name → provider-qualified model id (e.g. "claude-pro-max-native/claude-opus-4-8"). */
7
+ /** Participant key → provider-qualified model id. */
14
8
  models?: Record<string, string>;
15
9
  }
16
10
 
17
11
  export type PersonaConfigStore = Record<string, PersonaConfig>;
18
12
 
19
- function isPlainObject(value: unknown): value is Record<string, unknown> {
20
- return typeof value === "object" && value !== null && !Array.isArray(value);
21
- }
22
-
23
- /**
24
- * Read the store. NOTE: this reader WRITES on one path — an unusable file is copied to
25
- * `${file}.bak` before the next save replaces it (once; an existing backup is never touched).
26
- */
27
- export function readPersonaConfigs(file: string): PersonaConfigStore {
28
- let text: string;
29
- try {
30
- text = readFileSync(file, "utf8");
31
- } catch {
32
- return {}; // no config yet (the common case) or unreadable → start empty
33
- }
34
- try {
35
- const parsed: unknown = JSON.parse(text);
36
- // Valid JSON that isn't a config object is as unusable as unparseable text — and the next
37
- // save replaces it just the same, so it gets the same backup treatment.
38
- if (!isPlainObject(parsed)) throw new Error("not a config object");
39
- const store: PersonaConfigStore = {};
40
- for (const [name, cfg] of Object.entries(parsed)) {
41
- if (!isPlainObject(cfg)) continue;
42
- const entry: PersonaConfig = {};
43
- if (isPlainObject(cfg.models)) {
44
- const models: Record<string, string> = {};
45
- for (const [agent, model] of Object.entries(cfg.models)) {
46
- if (typeof model === "string") models[agent] = model;
47
- }
48
- entry.models = models;
49
- }
50
- store[name] = entry;
51
- }
52
- return store;
53
- } catch {
54
- // An unusable file is about to be overwritten by the next save; keep it beside the
55
- // config so the assignments stay recoverable instead of vanishing silently. `wx` makes
56
- // this the FIRST copy only: the underlying fault (a sync client, a failing disk) tends to
57
- // recur, and a second bad parse would otherwise overwrite the one copy that still had the
58
- // user's assignments in it.
59
- try {
60
- writeFileSync(`${file}.bak`, text, { encoding: "utf8", flag: "wx" });
61
- } catch {
62
- /* best effort — an existing backup, or an unwritable dir, must not stop the session */
63
- }
64
- return {};
65
- }
66
- }
67
-
68
- /** Injection seams for the rename retry below (tests drive it on any OS). */
69
- export interface ConfigWriteIO {
70
- rename?: (from: string, to: string) => void;
71
- sleep?: (ms: number) => void;
72
- /** Test seam for the cross-process lock around the read/merge/write transaction. */
73
- acquireLock?: (file: string, sleep: (ms: number) => void) => () => void;
74
- }
75
-
76
- // A handle held by a virus scanner, a sync client or another pi instance mid-read is transient,
77
- // so a few short waits usually win the race — 25+50+75 ms of backoff at worst, on a save.
78
- const RENAME_ATTEMPTS = 4;
79
- const RENAME_BACKOFF_MS = 25;
80
- const LOCK_ATTEMPTS = 80;
81
- const LOCK_BACKOFF_MS = 25;
82
- const STALE_LOCK_MS = 5 * 60 * 1000;
83
-
84
- function sleepSync(ms: number): void {
85
- // The whole store API is synchronous (this runs on a model assignment, not on a hot path),
86
- // so the backoff has to block; Atomics.wait is the standard synchronous sleep.
87
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
88
- }
89
-
90
- /** Acquire a small sidecar lock so two independent Pi processes cannot both read the same old
91
- * config and race their read-modify-write transactions. `wx` is the portable atomic primitive on
92
- * POSIX and Windows. A very old lock is treated as abandoned after a crashed process; active
93
- * writers renew no state because the critical section is synchronous and short. */
94
- function acquireFileLock(file: string, sleep: (ms: number) => void): () => void {
95
- const lock = `${file}.lock`;
96
- for (let attempt = 1; attempt <= LOCK_ATTEMPTS; attempt++) {
97
- try {
98
- const fd = openSync(lock, "wx");
99
- try {
100
- writeSync(fd, `${process.pid}\n`, null, "utf8");
101
- } catch (error) {
102
- try {
103
- closeSync(fd);
104
- } finally {
105
- try {
106
- unlinkSync(lock);
107
- } catch {
108
- /* best effort; retain the original write error */
109
- }
110
- }
111
- throw error;
112
- }
113
- return () => {
114
- try {
115
- closeSync(fd);
116
- } finally {
117
- try {
118
- unlinkSync(lock);
119
- } catch {
120
- /* another cleanup path or an external remover already won */
121
- }
122
- }
123
- };
124
- } catch (error) {
125
- const code = (error as NodeJS.ErrnoException).code;
126
- if (code !== "EEXIST") throw error;
127
- try {
128
- if (Date.now() - statSync(lock).mtimeMs > STALE_LOCK_MS) unlinkSync(lock);
129
- } catch {
130
- /* It disappeared between stat/unlink; retry normally. */
131
- }
132
- if (attempt < LOCK_ATTEMPTS) {
133
- sleep(LOCK_BACKOFF_MS);
134
- continue;
135
- }
136
- throw new Error(`timed out acquiring persona config lock: ${lock}`);
137
- }
138
- }
139
- throw new Error(`timed out acquiring persona config lock: ${lock}`);
140
- }
141
-
142
- /**
143
- * MERGE `store` into the file (read-modify-write), then save it.
144
- *
145
- * A removal is NOT expressible: an assignment missing from `store` is preserved, not deleted.
146
- * The merge exists so a concurrent pi instance's save survives ours, and it cannot tell "the
147
- * user cleared this" from "this store was read before that was written" — a clearing gesture
148
- * would need its own delete-aware call.
149
- */
150
- export function writePersonaConfigs(file: string, store: PersonaConfigStore, io: ConfigWriteIO = {}): void {
151
- mkdirSync(dirname(file), { recursive: true });
152
- const sleep = io.sleep ?? sleepSync;
153
- const release = (io.acquireLock ?? acquireFileLock)(file, sleep);
154
- try {
155
- // The caller holds a store read at session start; another pi instance in the same workspace
156
- // may have saved since. Re-read and merge so its work survives our whole-store write — down
157
- // to the agent, since two instances of the *same* persona assign different roster members.
158
- const merged: PersonaConfigStore = { ...readPersonaConfigs(file) };
159
- for (const [persona, cfg] of Object.entries(store)) {
160
- const prev = merged[persona];
161
- merged[persona] = prev ? { ...prev, ...cfg, models: { ...prev.models, ...cfg.models } } : cfg;
162
- }
163
- const payload = `${JSON.stringify(merged, null, 2)}\n`;
164
- // Temp file + same-volume rename, so a crash mid-write can never leave a truncated
165
- // config.json behind. That is atomic on POSIX; Windows is NOT the same — rename fails with
166
- // EPERM/EBUSY while ANY other handle holds the target open (Defender, a sync client, a second
167
- // pi instance mid-read), and the persona folder is an ordinary user directory where that is
168
- // routine. So a contended rename is retried, and then written in place: a non-atomic save
169
- // that lands beats an atomic one that loses the user's assignments every session.
170
- const tmp = `${file}.${process.pid}.tmp`;
171
- const rename = io.rename ?? renameSync;
172
- writeFileSync(tmp, payload, "utf8");
173
- for (let attempt = 1; ; attempt++) {
174
- try {
175
- rename(tmp, file);
176
- return;
177
- } catch (err) {
178
- const code = (err as NodeJS.ErrnoException).code;
179
- if (attempt < RENAME_ATTEMPTS && (code === "EPERM" || code === "EBUSY" || code === "EACCES")) {
180
- sleep(RENAME_BACKOFF_MS * attempt);
181
- continue;
182
- }
183
- try {
184
- // The scratch file must not be left behind for every pid that ever tried, whether
185
- // the in-place write succeeds or throws (the caller still sees a real failure).
186
- writeFileSync(file, payload, "utf8");
187
- } finally {
188
- try {
189
- rmSync(tmp, { force: true });
190
- } catch {
191
- /* best effort */
192
- }
193
- }
194
- return;
195
- }
196
- }
197
- } finally {
198
- release();
199
- }
200
- }
201
-
202
13
  /** The model assignments for a persona (empty object when none configured). */
203
14
  export function personaModels(store: PersonaConfigStore, persona: string): Record<string, string> {
204
- return store[persona]?.models ?? {};
15
+ return Object.hasOwn(store, persona) ? store[persona]?.models ?? {} : {};
205
16
  }
206
17
 
207
- /** Merge model assignments into a persona's config, returning a new store. */
18
+ /** Merge session model assignments without mutating the owner's saved snapshot. */
208
19
  export function withPersonaModels(
209
20
  store: PersonaConfigStore,
210
21
  persona: string,
211
22
  models: Record<string, string>,
212
23
  ): PersonaConfigStore {
213
- const prev = store[persona] ?? {};
24
+ const prev = Object.hasOwn(store, persona) ? store[persona] ?? {} : {};
214
25
  return { ...store, [persona]: { ...prev, models: { ...prev.models, ...models } } };
215
26
  }
@@ -16,7 +16,7 @@
16
16
  * > the session model (the default, NOT an assignment — it must not suppress the picker).
17
17
  *
18
18
  * The persisted assignment KEY is `agent` for a bare member — the historical shape, so every
19
- * existing `config.json` keeps applying — and `agent#<hash-of-agent+role>` for a member carrying a
19
+ * migrated model assignments keep applying — and `agent#<hash-of-agent+role>` for a member carrying a
20
20
  * role. The hash keeps the full role as the identity without persisting the role PROMPT (which is
21
21
  * an instruction, not a key, and would leak strategy prose into the user's config file). The human
22
22
  * label stays separate, so the picker and the tree can still say `reviewer · SECURITY`.