@bermudi/pi-delegate 0.1.13 → 0.1.15

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/workspace.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
+ import { revalidateFileAttribution } from "./file-tracking.ts";
4
5
  import { scheduleDeadline } from "./timer.ts";
6
+ import type { FileAttribution } from "./types.ts";
5
7
 
6
8
  const SCRATCH_CONTAINER_NAME = ".pi-delegate-scratch";
7
9
  const SCRATCH_LEASE_PREFIX = "lease-";
@@ -29,6 +31,17 @@ export interface ScratchWorkspace {
29
31
  * compare with the host path they actually touched.
30
32
  */
31
33
  resolveAttributedPath(candidate: string): Promise<string | undefined>;
34
+ /** Project structured attribution without re-resolving its execution-time
35
+ * physical snapshot. Uncertain internal evidence is retained. */
36
+ resolveFileAttribution?(
37
+ attribution: FileAttribution,
38
+ ): Promise<FileAttribution | undefined>;
39
+ /** Project the lexical touched-path half of structured attribution. A
40
+ * certainly attributed copied symlink is suppressed only when both link
41
+ * identities remain stable while their matching targets are read. */
42
+ resolveAttributedLexicalTouch?(
43
+ attribution: FileAttribution,
44
+ ): Promise<string | undefined>;
32
45
  /** True when the existing path resolves inside the disposable tree. */
33
46
  isDisposablePath(candidate: string): Promise<boolean>;
34
47
  cleanup(): Promise<void>;
@@ -186,6 +199,30 @@ function isWithin(root: string, candidate: string): boolean {
186
199
  );
187
200
  }
188
201
 
202
+ function errnoOf(error: unknown): string {
203
+ return error instanceof Error && "code" in error
204
+ ? String((error as NodeJS.ErrnoException).code ?? "UNKNOWN")
205
+ : "UNKNOWN";
206
+ }
207
+
208
+ function logScratchProjectionFailure(
209
+ operation: "lstat" | "readlink",
210
+ candidate: string,
211
+ error: unknown,
212
+ ): void {
213
+ const safeCandidate = JSON.stringify(candidate)
214
+ .replace(/\u2028/g, "\\u2028")
215
+ .replace(/\u2029/g, "\\u2029");
216
+ console.error(
217
+ `[delegate] scratch attribution ${operation} failed for ${safeCandidate} (errno=${errnoOf(error)}); retaining lexical source evidence`,
218
+ );
219
+ }
220
+
221
+ function isExpectedPathRace(error: unknown): boolean {
222
+ const code = errnoOf(error);
223
+ return code === "ENOENT" || code === "ENOTDIR" || code === "EINVAL";
224
+ }
225
+
189
226
  function isProcessAlive(pid: number): boolean {
190
227
  try {
191
228
  process.kill(pid, 0);
@@ -794,6 +831,100 @@ export async function createScratchWorkspace(
794
831
  return isWithin(completedRoot, absolute) ? undefined : absolute;
795
832
  }
796
833
  };
834
+ const mapDisposableLexically = (candidate: string): string => {
835
+ const absolute = path.resolve(candidate);
836
+ return isWithin(completedRoot, absolute)
837
+ ? path.join(sourceRoot!, path.relative(completedRoot, absolute))
838
+ : absolute;
839
+ };
840
+ const resolveFileAttribution = async (
841
+ original: FileAttribution,
842
+ ): Promise<FileAttribution | undefined> => {
843
+ const attribution = revalidateFileAttribution(original);
844
+ const physical = attribution.preExecutionPhysicalPath;
845
+ // A certain physical snapshot inside scratch is disposable. Crucially, do
846
+ // not realpath it now: the tool may have replaced that node with a symlink.
847
+ if (
848
+ physical &&
849
+ !attribution.uncertain &&
850
+ isWithin(completedRoot, physical)
851
+ ) {
852
+ return undefined;
853
+ }
854
+ return {
855
+ ...attribution,
856
+ lexicalPath: mapDisposableLexically(attribution.lexicalPath),
857
+ preExecutionPhysicalPath: physical
858
+ ? mapDisposableLexically(physical)
859
+ : undefined,
860
+ };
861
+ };
862
+ const resolveAttributedLexicalTouch = async (
863
+ original: FileAttribution,
864
+ ): Promise<string | undefined> => {
865
+ const attribution = revalidateFileAttribution(original);
866
+ const lexical = path.resolve(attribution.lexicalPath);
867
+ if (!isWithin(completedRoot, lexical)) return lexical;
868
+ const source = path.join(
869
+ sourceRoot!,
870
+ path.relative(completedRoot, lexical),
871
+ );
872
+ // Suppression is an optimization for a copied, unchanged symlink node. An
873
+ // uncertain attribution cannot prove that the lexical node was harmless.
874
+ if (attribution.uncertain) return source;
875
+
876
+ const inspect = async (
877
+ candidate: string,
878
+ ): Promise<fs.Stats | undefined> => {
879
+ try {
880
+ return await fs.promises.lstat(candidate);
881
+ } catch (error) {
882
+ if (!isExpectedPathRace(error)) {
883
+ logScratchProjectionFailure("lstat", candidate, error);
884
+ }
885
+ return undefined;
886
+ }
887
+ };
888
+ const [scratchStat, sourceStat] = await Promise.all([
889
+ inspect(lexical),
890
+ inspect(source),
891
+ ]);
892
+ if (scratchStat?.isSymbolicLink() && sourceStat?.isSymbolicLink()) {
893
+ const readStableLink = async (
894
+ candidate: string,
895
+ before: fs.Stats,
896
+ ): Promise<string | undefined> => {
897
+ let target: string;
898
+ try {
899
+ target = await fs.promises.readlink(candidate);
900
+ } catch (error) {
901
+ if (!isExpectedPathRace(error)) {
902
+ logScratchProjectionFailure("readlink", candidate, error);
903
+ }
904
+ return undefined;
905
+ }
906
+ const after = await inspect(candidate);
907
+ return after?.isSymbolicLink() && sameFileIdentity(before, after)
908
+ ? target
909
+ : undefined;
910
+ };
911
+ const [scratchTarget, sourceTarget] = await Promise.all([
912
+ readStableLink(lexical, scratchStat),
913
+ readStableLink(source, sourceStat),
914
+ ]);
915
+ // A readlink or identity race is not evidence that the nodes matched.
916
+ // Keep the lexical source path rather than dropping it.
917
+ if (
918
+ scratchTarget !== undefined &&
919
+ sourceTarget !== undefined &&
920
+ scratchTarget === sourceTarget
921
+ ) {
922
+ return undefined;
923
+ }
924
+ }
925
+ // This is evidence about the lexical node, not its current target.
926
+ return source;
927
+ };
797
928
  return {
798
929
  sourceRoot: sourceRoot!,
799
930
  sourceCwd: sourceCwd!,
@@ -806,6 +937,8 @@ export async function createScratchWorkspace(
806
937
  },
807
938
  resolveReportedPath,
808
939
  resolveAttributedPath,
940
+ resolveFileAttribution,
941
+ resolveAttributedLexicalTouch,
809
942
  async isDisposablePath(candidate: string): Promise<boolean> {
810
943
  return (await resolveAttributedPath(candidate)) === undefined;
811
944
  },
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
- }