@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.
@@ -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
  }
@@ -30,7 +30,7 @@ export interface PersonaHost {
30
30
  setThinkingLevel(level: ThinkingLevel): void;
31
31
  getModel(): ModelHandle | undefined;
32
32
  findModel(ref: string): ModelHandle | undefined;
33
- setModel(model: ModelHandle): void | Promise<void>;
33
+ setModel(model: ModelHandle): void | boolean | Promise<void | boolean>;
34
34
  setStatus(text: string | undefined): void;
35
35
  /** Optional: a declared-but-unavailable model/thinking would otherwise fail silently. */
36
36
  warn?(message: string): void;
@@ -108,11 +108,24 @@ export class PersonaController {
108
108
  if (persona.model) {
109
109
  const model = this.host.findModel(persona.model);
110
110
  if (model) {
111
- if (!this.modelOverridden) {
111
+ const firstOverride = !this.modelOverridden;
112
+ if (firstOverride) {
112
113
  this.baselineModel = this.host.getModel();
113
114
  this.modelOverridden = true;
114
115
  }
115
- await this.host.setModel(model);
116
+ const applied = await this.host.setModel(model);
117
+ if (applied === false) {
118
+ // Pi's setModel returns false when the provider has no configured auth. Do not
119
+ // leave a phantom override behind: a later persona or deactivation must not
120
+ // restore a baseline for a model that never became active.
121
+ if (firstOverride) {
122
+ this.modelOverridden = false;
123
+ this.baselineModel = undefined;
124
+ }
125
+ this.host.warn?.(
126
+ `persona "${persona.name}" declared model "${persona.model}" but provider authentication is not configured; keeping the current model`,
127
+ );
128
+ }
116
129
  } else {
117
130
  this.host.warn?.(
118
131
  `persona "${persona.name}" declared model "${persona.model}" which is not configured; keeping the current model`,
@@ -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`.