@bermudi/pi-delegate 0.1.13 → 0.1.14

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/settings.ts DELETED
@@ -1,418 +0,0 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
- import { VALID_THINKING } from "./constants.ts";
6
-
7
- /**
8
- * One-release compatibility bridge for delegate overrides formerly stored in
9
- * Pi's user/project settings.json. Only model and thinking are bridged; tools
10
- * remain operator-controlled because project files must not restore shell
11
- * capability. Modern delegate.json values win field-by-field.
12
- */
13
-
14
- function isRecord(value: unknown): value is Record<string, unknown> {
15
- return value !== null && typeof value === "object" && !Array.isArray(value);
16
- }
17
-
18
- /** Read and validate a JSON settings object, returning null on I/O or parse errors. */
19
- export function readDelegateSettingsFile(
20
- filePath: string,
21
- ): Record<string, unknown> | null {
22
- try {
23
- const raw = fs.readFileSync(filePath, "utf-8");
24
- const parsed = JSON.parse(raw);
25
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
26
- console.warn(
27
- `[delegate] ignoring malformed settings file ${filePath}: expected a JSON object.`,
28
- );
29
- return null;
30
- }
31
- return parsed as Record<string, unknown>;
32
- } catch (error) {
33
- if (
34
- error instanceof Error &&
35
- "code" in error &&
36
- (error as NodeJS.ErrnoException).code === "ENOENT"
37
- ) {
38
- return null;
39
- }
40
- console.warn(
41
- `[delegate] could not read settings file ${filePath}: ${error instanceof Error ? error.message : String(error)}`,
42
- );
43
- return null;
44
- }
45
- }
46
-
47
- interface LegacyDetectionCacheEntry {
48
- signature: string;
49
- carriesDelegateBlock: boolean;
50
- }
51
-
52
- const legacyDetectionCache = new Map<string, LegacyDetectionCacheEntry>();
53
- const warnedLegacyDetectionFailures = new Set<string>();
54
-
55
- /** A cheap file identity used to invalidate cached misses and parse failures
56
- * when a settings file is created or edited. */
57
- function settingsFileSignature(filePath: string): string {
58
- try {
59
- const stat = fs.statSync(filePath);
60
- return [
61
- "present",
62
- stat.dev,
63
- stat.ino,
64
- stat.size,
65
- stat.mtimeMs,
66
- stat.ctimeMs,
67
- stat.mode,
68
- ].join(":");
69
- } catch (error) {
70
- if (
71
- error instanceof Error &&
72
- "code" in error &&
73
- (error as NodeJS.ErrnoException).code === "ENOENT"
74
- ) {
75
- return "missing";
76
- }
77
- // An inaccessible or otherwise unstatable file is not a stable miss. Let
78
- // the read path report it on each attempt rather than hiding a later fix.
79
- return `unstatable:${Date.now()}`;
80
- }
81
- }
82
-
83
- function settingsCarryDelegateBlock(filePath: string): boolean {
84
- const key = path.resolve(filePath);
85
- const signature = settingsFileSignature(key);
86
- const cached = legacyDetectionCache.get(key);
87
- if (cached?.signature === signature) return cached.carriesDelegateBlock;
88
-
89
- const parsed = readDelegateSettingsFile(key);
90
- const carriesDelegateBlock = parsed !== null && isRecord(parsed.delegate);
91
- legacyDetectionCache.set(key, { signature, carriesDelegateBlock });
92
- return carriesDelegateBlock;
93
- }
94
-
95
- /** Paths of pi settings files (user + nearest project `.pi/settings.json`)
96
- * that still carry a `delegate` block. Fail-open: never throws. */
97
- export function findLegacyDelegateSettings(cwd: string): string[] {
98
- const paths: string[] = [];
99
- try {
100
- const userPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
101
- if (settingsCarryDelegateBlock(userPath)) paths.push(userPath);
102
-
103
- // Nearest `.pi` directory walking up from cwd — the same discovery the
104
- // old loader used, so a project that relied on overrides is still caught.
105
- let dir = path.resolve(cwd);
106
- const root = path.resolve("/");
107
- for (;;) {
108
- if (fs.existsSync(path.join(dir, ".pi"))) {
109
- const projectPath = path.join(dir, ".pi", "settings.json");
110
- if (settingsCarryDelegateBlock(projectPath)) paths.push(projectPath);
111
- break;
112
- }
113
- if (dir === root) break;
114
- const parent = path.dirname(dir);
115
- if (parent === dir) break;
116
- dir = parent;
117
- }
118
- } catch (error) {
119
- // Detection is best-effort and must never block a dispatch, but silently
120
- // losing the migration signal would make ignored legacy overrides hard to
121
- // diagnose. Report each affected cwd once to avoid warning floods when a
122
- // dispatch resolves several tasks in the same project.
123
- if (!warnedLegacyDetectionFailures.has(cwd)) {
124
- warnedLegacyDetectionFailures.add(cwd);
125
- const detail = error instanceof Error ? error.message : String(error);
126
- console.warn(
127
- `[delegate] could not check for legacy delegate settings from '${cwd}': ${detail}. Overrides in pi settings.json may be ignored; move them to ~/.pi/agent/delegate.json.`,
128
- );
129
- }
130
- }
131
- return paths;
132
- }
133
-
134
- const warnedLegacySources = new Set<string>();
135
-
136
- /** Shape returned by the temporary compatibility loader. */
137
- export interface DelegateSettings {
138
- agentOverrides?: Record<string, AgentOverride>;
139
- agentOverridesByParentModel?: Record<string, Record<string, AgentOverride>>;
140
- }
141
-
142
- export interface AgentOverride {
143
- model?: string;
144
- thinking?: ThinkingLevel;
145
- }
146
-
147
- function normalizeOverride(
148
- raw: unknown,
149
- source: string,
150
- agentName: string,
151
- ): AgentOverride | null {
152
- if (!isRecord(raw)) {
153
- console.warn(
154
- `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: expected an object.`,
155
- );
156
- return null;
157
- }
158
-
159
- const result: AgentOverride = {};
160
- for (const [key, value] of Object.entries(raw)) {
161
- if (key === "model") {
162
- if (typeof value !== "string" || value.trim().length === 0) {
163
- console.warn(
164
- `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: model must be a nonempty string.`,
165
- );
166
- return null;
167
- }
168
- result.model = value.trim();
169
- } else if (key === "thinking") {
170
- if (typeof value !== "string" || !VALID_THINKING.has(value)) {
171
- console.warn(
172
- `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: thinking must be a supported level.`,
173
- );
174
- return null;
175
- }
176
- result.thinking = value as ThinkingLevel;
177
- } else if (key === "tools") {
178
- console.warn(
179
- `[delegate] ignoring legacy tools override for agent '${agentName}' in ${source}: the temporary compatibility bridge honors only model and thinking.`,
180
- );
181
- } else {
182
- console.warn(
183
- `[delegate] ignoring malformed settings override for agent '${agentName}' in ${source}: unknown field '${key}'.`,
184
- );
185
- return null;
186
- }
187
- }
188
- return result;
189
- }
190
-
191
- function normalizeOverrides(
192
- raw: unknown,
193
- source: string,
194
- ): Record<string, AgentOverride> {
195
- if (!isRecord(raw)) {
196
- console.warn(
197
- `[delegate] ignoring malformed agentOverrides in ${source}: expected an object.`,
198
- );
199
- return {};
200
- }
201
-
202
- const result = Object.create(null) as Record<string, AgentOverride>;
203
- const seenNames = new Map<string, string>();
204
- for (const [agentName, value] of Object.entries(raw)) {
205
- const normalizedAgentName = agentName.trim();
206
- if (normalizedAgentName.length === 0) {
207
- console.warn(
208
- `[delegate] ignoring malformed settings override in ${source}: agent name must be nonempty.`,
209
- );
210
- continue;
211
- }
212
- const previousName = seenNames.get(normalizedAgentName);
213
- if (previousName !== undefined) {
214
- console.warn(
215
- `[delegate] ignoring duplicate settings override in ${source}: agent keys '${previousName}' and '${agentName}' both normalize to '${normalizedAgentName}'.`,
216
- );
217
- continue;
218
- }
219
- seenNames.set(normalizedAgentName, agentName);
220
- const override = normalizeOverride(value, source, normalizedAgentName);
221
- if (override) result[normalizedAgentName] = override;
222
- }
223
- return result;
224
- }
225
-
226
- function normalizeOverridesByParentModel(
227
- raw: unknown,
228
- source: string,
229
- ): Record<string, Record<string, AgentOverride>> {
230
- if (!isRecord(raw)) {
231
- console.warn(
232
- `[delegate] ignoring malformed agentOverridesByParentModel in ${source}: expected an object.`,
233
- );
234
- return {};
235
- }
236
-
237
- const result = Object.create(null) as Record<
238
- string,
239
- Record<string, AgentOverride>
240
- >;
241
- const seenModels = new Map<string, string>();
242
- for (const [parentModel, overrides] of Object.entries(raw)) {
243
- const normalizedParentModel = parentModel.trim();
244
- if (normalizedParentModel.length === 0) {
245
- console.warn(
246
- `[delegate] ignoring malformed parent-model override in ${source}: model key must be nonempty.`,
247
- );
248
- continue;
249
- }
250
- const previousModel = seenModels.get(normalizedParentModel);
251
- if (previousModel !== undefined) {
252
- console.warn(
253
- `[delegate] ignoring duplicate parent-model override in ${source}: model keys '${previousModel}' and '${parentModel}' both normalize to '${normalizedParentModel}'.`,
254
- );
255
- continue;
256
- }
257
- seenModels.set(normalizedParentModel, parentModel);
258
- result[normalizedParentModel] = normalizeOverrides(
259
- overrides,
260
- `${source} (parent model '${normalizedParentModel}')`,
261
- );
262
- }
263
- return result;
264
- }
265
-
266
- function readLegacyDelegateSettings(filePath: string): DelegateSettings | null {
267
- const settings = readDelegateSettingsFile(filePath);
268
- if (!isRecord(settings?.delegate)) {
269
- if (settings?.delegate !== undefined) {
270
- console.warn(
271
- `[delegate] ignoring malformed delegate settings in ${filePath}: expected an object.`,
272
- );
273
- }
274
- return null;
275
- }
276
-
277
- const result: DelegateSettings = {};
278
- if (settings.delegate.agentOverrides !== undefined) {
279
- result.agentOverrides = normalizeOverrides(
280
- settings.delegate.agentOverrides,
281
- filePath,
282
- );
283
- }
284
- if (settings.delegate.agentOverridesByParentModel !== undefined) {
285
- result.agentOverridesByParentModel = normalizeOverridesByParentModel(
286
- settings.delegate.agentOverridesByParentModel,
287
- filePath,
288
- );
289
- }
290
- return result;
291
- }
292
-
293
- function mergeOverride(
294
- base: AgentOverride | undefined,
295
- override: AgentOverride | undefined,
296
- ): AgentOverride {
297
- return { ...(base ?? {}), ...(override ?? {}) };
298
- }
299
-
300
- function mergeOverrides(
301
- user: Record<string, AgentOverride> | undefined,
302
- project: Record<string, AgentOverride> | undefined,
303
- ): Record<string, AgentOverride> {
304
- const result = Object.create(null) as Record<string, AgentOverride>;
305
- for (const name of new Set([
306
- ...Object.keys(user ?? {}),
307
- ...Object.keys(project ?? {}),
308
- ])) {
309
- result[name] = mergeOverride(user?.[name], project?.[name]);
310
- }
311
- return result;
312
- }
313
-
314
- function mergeParentModelOverrides(
315
- user: Record<string, Record<string, AgentOverride>> | undefined,
316
- project: Record<string, Record<string, AgentOverride>> | undefined,
317
- ): Record<string, Record<string, AgentOverride>> {
318
- const result = Object.create(null) as Record<
319
- string,
320
- Record<string, AgentOverride>
321
- >;
322
- for (const model of new Set([
323
- ...Object.keys(user ?? {}),
324
- ...Object.keys(project ?? {}),
325
- ])) {
326
- result[model] = mergeOverrides(user?.[model], project?.[model]);
327
- }
328
- return result;
329
- }
330
-
331
- /**
332
- * Temporary compatibility reader. It accepts a working directory and returns
333
- * the merged view (project fields override user fields). Deliberately rereads
334
- * the two small JSON files: this bridge lasts one release, and avoiding a cache
335
- * makes file create/edit/delete visible on the very next dispatch.
336
- */
337
- export function loadDelegateSettings(cwd: string): DelegateSettings | null {
338
- const key = path.resolve(cwd);
339
- const userPath = path.join(os.homedir(), ".pi", "agent", "settings.json");
340
- let projectPath: string | undefined;
341
- let dir = key;
342
- const root = path.resolve("/");
343
- for (;;) {
344
- if (fs.existsSync(path.join(dir, ".pi"))) {
345
- projectPath = path.join(dir, ".pi", "settings.json");
346
- break;
347
- }
348
- if (dir === root) break;
349
- const parent = path.dirname(dir);
350
- if (parent === dir) break;
351
- dir = parent;
352
- }
353
-
354
- const user = readLegacyDelegateSettings(userPath);
355
- const project = projectPath ? readLegacyDelegateSettings(projectPath) : null;
356
- if (!user && !project) {
357
- return null;
358
- }
359
-
360
- const result: DelegateSettings = {
361
- ...(user?.agentOverrides || project?.agentOverrides
362
- ? {
363
- agentOverrides: mergeOverrides(
364
- user?.agentOverrides,
365
- project?.agentOverrides,
366
- ),
367
- }
368
- : {}),
369
- ...(user?.agentOverridesByParentModel ||
370
- project?.agentOverridesByParentModel
371
- ? {
372
- agentOverridesByParentModel: mergeParentModelOverrides(
373
- user?.agentOverridesByParentModel,
374
- project?.agentOverridesByParentModel,
375
- ),
376
- }
377
- : {}),
378
- };
379
- return result;
380
- }
381
-
382
- /** Warn (once per cwd) when pi settings files still carry a legacy `delegate`
383
- * block. The same message goes to stderr and, when available, the TUI. Keys
384
- * include both cwd and source so a newly-created project setting is not hidden
385
- * by an earlier user-setting warning. */
386
- export function warnLegacyDelegateSettingsMoved(
387
- cwd: string,
388
- notify?: (message: string) => void,
389
- ): void {
390
- const cwdKey = path.resolve(cwd);
391
- const paths = findLegacyDelegateSettings(cwd);
392
- if (paths.length === 0) return;
393
- for (const filePath of paths) {
394
- const warningKey = `${cwdKey}\0${filePath}`;
395
- if (warnedLegacySources.has(warningKey)) continue;
396
- warnedLegacySources.add(warningKey);
397
- const message =
398
- `[delegate] TEMPORARY legacy compatibility: using model/thinking overrides from ${filePath}. ` +
399
- "Move user-global overrides to ~/.pi/agent/delegate.json. Project-local overrides have no future config-file equivalent; use .pi/agents/*.md or explicit task model/thinking fields. " +
400
- "Legacy settings compatibility is available for v0.1.12 only and will be removed in v0.1.13. Legacy tools overrides are not honored.";
401
- console.warn(message);
402
- try {
403
- notify?.(message);
404
- } catch (error) {
405
- console.error(
406
- "[delegate] legacy settings TUI notification failed",
407
- error,
408
- );
409
- }
410
- }
411
- }
412
-
413
- /** @deprecated Clear the set of cwd's already warned about legacy settings. */
414
- export function clearDelegateSettingsCache(): void {
415
- warnedLegacySources.clear();
416
- warnedLegacyDetectionFailures.clear();
417
- legacyDetectionCache.clear();
418
- }