@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/AGENTS.md +22 -3
- package/CHANGELOG.md +30 -0
- package/README.md +13 -2
- package/docs/ARCHITECTURE.md +46 -8
- package/docs/REFERENCE.md +107 -3
- package/package.json +2 -2
- package/src/core/config.ts +1 -1
- package/src/core/settings.ts +251 -0
- package/src/engine/adapter.ts +1 -1
- package/src/engine/async.ts +53 -13
- package/src/engine/child.ts +1 -1
- package/src/engine/inproc.ts +3 -3
- package/src/engine/stream.ts +20 -1
- package/src/extension/companion-settings.ts +29 -0
- package/src/extension/persona-model-settings.ts +318 -0
- package/src/extension/persona-settings.ts +459 -0
- package/src/extension.ts +148 -37
- package/src/orchestration/sdk.ts +2 -0
- package/src/persona/config-store.ts +6 -195
- package/src/persona/model-participants.ts +1 -1
- package/src/persona/settings-store.ts +636 -0
- package/src/telemetry/contract.ts +1 -1
- package/src/tools/delegate-tool.ts +6 -6
- package/src/ui/persona-settings.ts +322 -0
- package/src/persona/state.ts +0 -28
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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(
|
|
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
|
-
//
|
|
1246
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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]]",
|
package/src/orchestration/sdk.ts
CHANGED
|
@@ -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
|
-
*
|
|
3
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
*
|
|
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`.
|