@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/README.md +14 -17
- package/delegate.ts +0 -8
- package/dispatch.ts +25 -2
- package/extension.ts +14 -6
- package/file-tracking.ts +286 -15
- package/format.ts +107 -21
- package/isolated-workspace.ts +550 -62
- package/key-hints.ts +38 -0
- package/lifecycle.ts +559 -80
- package/manual.ts +1 -1
- package/model.ts +6 -1
- package/package.json +13 -9
- package/pool.ts +37 -6
- package/quiescence.ts +19 -6
- package/render-branches.ts +243 -85
- package/render-result.ts +116 -16
- package/runner.ts +361 -82
- package/session-quarantine.ts +266 -0
- package/spill.ts +93 -22
- package/task-resolution.ts +63 -50
- package/telemetry.ts +1 -1
- package/ticket-format.ts +15 -9
- package/tickets.ts +36 -14
- package/tools.ts +5 -4
- package/types.ts +92 -8
- package/utils.ts +123 -2
- package/workspace.ts +133 -0
- package/settings.ts +0 -418
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
|
-
}
|