@pi-unipi/background-tasks 2.6.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/README.md +87 -0
- package/extensions/anthropic-attribution.ts +1 -0
- package/extensions/delegate-child.ts +1 -0
- package/extensions/fusion-child.ts +1 -0
- package/package.json +40 -0
- package/src/__tests__/anthropic-attribution.test.ts +195 -0
- package/src/__tests__/config.test.ts +137 -0
- package/src/__tests__/core.test.ts +493 -0
- package/src/__tests__/delegate-artifacts.test.ts +528 -0
- package/src/__tests__/delegate-budget.test.ts +456 -0
- package/src/__tests__/delegate-launch.test.ts +676 -0
- package/src/__tests__/delegate-result-package.test.ts +350 -0
- package/src/__tests__/delegate-seed.test.ts +392 -0
- package/src/__tests__/durable-fs.test.ts +559 -0
- package/src/__tests__/extension-api.test.ts +579 -0
- package/src/__tests__/fusion-artifacts.test.ts +1039 -0
- package/src/__tests__/fusion-budget.test.ts +1356 -0
- package/src/__tests__/fusion-claude-cache.test.ts +320 -0
- package/src/__tests__/fusion-config.test.ts +335 -0
- package/src/__tests__/fusion-context-prompts.test.ts +670 -0
- package/src/__tests__/fusion-evaluation.test.ts +315 -0
- package/src/__tests__/fusion-extraction-equivalence.test.ts +58 -0
- package/src/__tests__/fusion-golden-bytes.test.ts +35 -0
- package/src/__tests__/fusion-high-cardinality.test.ts +192 -0
- package/src/__tests__/fusion-model-selector.test.ts +205 -0
- package/src/__tests__/fusion-orchestrator.test.ts +1194 -0
- package/src/__tests__/fusion-rpc.test.ts +369 -0
- package/src/__tests__/fusion-sdk.test.ts +1226 -0
- package/src/__tests__/fusion-v5-core.test.ts +219 -0
- package/src/__tests__/fusion-validate-orchestrator.test.ts +240 -0
- package/src/__tests__/fusion-web-fetch.test.ts +485 -0
- package/src/__tests__/fusion-workflows.test.ts +59 -0
- package/src/__tests__/helpers/delegate-deterministic-seed.ts +109 -0
- package/src/__tests__/helpers/delegate-seed-subprocess.ts +10 -0
- package/src/__tests__/helpers/fusion-canonical-subprocess.ts +21 -0
- package/src/__tests__/helpers/fusion-canonical.ts +140 -0
- package/src/__tests__/helpers/fusion-fake-pi.ts +279 -0
- package/src/__tests__/helpers/fusion-golden-corpus.ts +500 -0
- package/src/__tests__/helpers/fusion-high-cardinality.ts +140 -0
- package/src/__tests__/helpers/normalize.ts +22 -0
- package/src/__tests__/helpers/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/pi-launch.test.ts +202 -0
- package/src/__tests__/registry.test.ts +1580 -0
- package/src/__tests__/scripted-provider/delegate-ambient-provider.test.ts +130 -0
- package/src/__tests__/scripted-provider/delegate-child-guard.test.ts +631 -0
- package/src/__tests__/scripted-provider/delegate-guard-provider.ts +403 -0
- package/src/__tests__/scripted-provider/follow-up.test.ts +448 -0
- package/src/__tests__/scripted-provider/fusion-output-recovery.test.ts +132 -0
- package/src/__tests__/scripted-provider/fusion-reason.test.ts +310 -0
- package/src/__tests__/scripted-provider/fusion-runtime-guard.test.ts +163 -0
- package/src/__tests__/scripted-provider/hook-contract-provider.ts +179 -0
- package/src/__tests__/scripted-provider/hook-probe-a.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-b.ts +3 -0
- package/src/__tests__/scripted-provider/hook-probe-extension.ts +126 -0
- package/src/__tests__/scripted-provider/output-recovery-provider.ts +153 -0
- package/src/__tests__/scripted-provider/pi-hook-contract-evidence.json +18 -0
- package/src/__tests__/scripted-provider/pi-hook-contract.test.ts +477 -0
- package/src/__tests__/scripted-provider/runtime-guard-probe.ts +28 -0
- package/src/__tests__/scripted-provider/runtime-guard-provider.ts +49 -0
- package/src/__tests__/scripted-provider/scripted-provider-extension.ts +408 -0
- package/src/__tests__/task-manager.test.ts +479 -0
- package/src/__tests__/windows-taskkill.test.ts +161 -0
- package/src/anthropic-attribution-path.ts +21 -0
- package/src/anthropic-attribution.ts +1983 -0
- package/src/attested-pi-run.ts +612 -0
- package/src/child-process.ts +55 -0
- package/src/common.ts +8 -0
- package/src/config.ts +292 -0
- package/src/context-parent-snapshot.ts +142 -0
- package/src/context-token-budget.ts +903 -0
- package/src/context-visible-conversation-v2.ts +551 -0
- package/src/delegate/artifacts.ts +487 -0
- package/src/delegate/budget.ts +415 -0
- package/src/delegate/hook-contract-evidence.json +18 -0
- package/src/delegate/hook-contract.ts +154 -0
- package/src/delegate/launch.ts +497 -0
- package/src/delegate/result-package.ts +459 -0
- package/src/delegate/runner.ts +449 -0
- package/src/delegate/seed.ts +423 -0
- package/src/delegate/types.ts +323 -0
- package/src/delegate-child-extension.ts +978 -0
- package/src/delegate-extension.ts +806 -0
- package/src/durable-fs.ts +386 -0
- package/src/extension-api.ts +548 -0
- package/src/fixtures/delegate-context-incident.json +17 -0
- package/src/fixtures/fusion-golden-bytes.json +310 -0
- package/src/fixtures/fusion-validate-golden-bytes.json +282 -0
- package/src/fusion/artifacts.ts +967 -0
- package/src/fusion/budget.ts +1162 -0
- package/src/fusion/child-protocol.ts +305 -0
- package/src/fusion/claude-cache.ts +207 -0
- package/src/fusion/clean-context.ts +91 -0
- package/src/fusion/config.ts +449 -0
- package/src/fusion/context.ts +265 -0
- package/src/fusion/evaluation.ts +800 -0
- package/src/fusion/orchestrator.ts +1288 -0
- package/src/fusion/output-contract.ts +34 -0
- package/src/fusion/pi-child.ts +2373 -0
- package/src/fusion/prompts.ts +345 -0
- package/src/fusion/result-package.ts +959 -0
- package/src/fusion/source-policy.ts +257 -0
- package/src/fusion/types.ts +1139 -0
- package/src/fusion/web-fetch.ts +1060 -0
- package/src/fusion/workflows.ts +184 -0
- package/src/fusion-child-extension.ts +1052 -0
- package/src/fusion-extension.ts +1293 -0
- package/src/index.ts +295 -0
- package/src/pi-launch.ts +225 -0
- package/src/registry.ts +2424 -0
- package/src/settings-overlay.ts +208 -0
- package/src/task-manager.ts +774 -0
- package/src/tools.ts +530 -0
- package/src/turndown.d.ts +15 -0
- package/src/types.ts +963 -0
- package/src/ui/fusion-model-selector.ts +322 -0
- package/src/windows-taskkill.ts +250 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/background-tasks — Config management
|
|
3
|
+
*
|
|
4
|
+
* Loads config from ~/.unipi/config/background-tasks.json (global) and
|
|
5
|
+
* <workspace>/.unipi/config/background-tasks.json (override; workspace wins).
|
|
6
|
+
* Follows the subagents.json layering pattern. The master `enabled` key
|
|
7
|
+
* (default true) completely disables the module when false — no tools, no
|
|
8
|
+
* commands, no hooks, no UI.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { homedir } from "node:os";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
/** Full config surface. Reference keys preserved; ours added where noted. */
|
|
16
|
+
export interface BackgroundTasksConfig {
|
|
17
|
+
/** Master toggle. When false the module registers nothing at all. */
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
/** Default notifyOnCompletion for tool-launched tasks. */
|
|
20
|
+
notifyOnCompletion: boolean;
|
|
21
|
+
/** Default triggerOnCompletion (follow-up wake) for bg_run tasks. */
|
|
22
|
+
triggerOnCompletion: boolean;
|
|
23
|
+
/** Default timeoutSeconds for shell tasks (0 = none). */
|
|
24
|
+
defaultTimeoutSeconds: number;
|
|
25
|
+
/** Max finished tasks retained in memory. */
|
|
26
|
+
maxFinishedTasks: number;
|
|
27
|
+
/** Output cap in bytes before a task is killed+failed (default 20 MiB). */
|
|
28
|
+
maxOutputBytes: number;
|
|
29
|
+
/** Delegate defaults. */
|
|
30
|
+
delegate: {
|
|
31
|
+
extensionMode: "isolated" | "ambient";
|
|
32
|
+
autoDeliver: "never" | "when_small" | "always";
|
|
33
|
+
maxTurns: number;
|
|
34
|
+
maxToolCalls: number;
|
|
35
|
+
timeoutSeconds: number;
|
|
36
|
+
};
|
|
37
|
+
/** Fusion defaults. */
|
|
38
|
+
fusion: {
|
|
39
|
+
/** Five-slot model selections ('' = $current). */
|
|
40
|
+
candidates: [string, string, string];
|
|
41
|
+
evaluator: string;
|
|
42
|
+
merger: string;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DEFAULT_CONFIG: BackgroundTasksConfig = {
|
|
47
|
+
enabled: true,
|
|
48
|
+
notifyOnCompletion: true,
|
|
49
|
+
triggerOnCompletion: true,
|
|
50
|
+
defaultTimeoutSeconds: 0,
|
|
51
|
+
maxFinishedTasks: 30,
|
|
52
|
+
maxOutputBytes: 20 * 1024 * 1024,
|
|
53
|
+
delegate: {
|
|
54
|
+
extensionMode: "isolated",
|
|
55
|
+
autoDeliver: "when_small",
|
|
56
|
+
maxTurns: 40,
|
|
57
|
+
maxToolCalls: 120,
|
|
58
|
+
timeoutSeconds: 900,
|
|
59
|
+
},
|
|
60
|
+
fusion: {
|
|
61
|
+
candidates: ["", "", ""],
|
|
62
|
+
evaluator: "",
|
|
63
|
+
merger: "",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function getGlobalConfigPath(): string {
|
|
68
|
+
return join(homedir(), ".unipi", "config", "background-tasks.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function getWorkspaceConfigPath(cwd: string): string {
|
|
72
|
+
return join(cwd, ".unipi", "config", "background-tasks.json");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function ensureDirExists(dirPath: string): void {
|
|
76
|
+
if (!existsSync(dirPath)) {
|
|
77
|
+
mkdirSync(dirPath, { recursive: true });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function writeConfigAtomic(filePath: string, config: BackgroundTasksConfig): void {
|
|
82
|
+
const tmpPath = filePath + ".tmp";
|
|
83
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, 2), "utf-8");
|
|
84
|
+
renameSync(tmpPath, filePath);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadConfigFromPath(filePath: string): Partial<BackgroundTasksConfig> | null {
|
|
88
|
+
if (!existsSync(filePath)) return null;
|
|
89
|
+
try {
|
|
90
|
+
const parsed: unknown = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
91
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
92
|
+
return parsed as Partial<BackgroundTasksConfig>;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Shallow-merge partial layers over defaults (one level deep for nested blocks). */
|
|
99
|
+
function mergeLayers(base: BackgroundTasksConfig, ...partials: Array<Partial<BackgroundTasksConfig>>): BackgroundTasksConfig {
|
|
100
|
+
const merged: BackgroundTasksConfig = { ...base };
|
|
101
|
+
for (const partial of partials) {
|
|
102
|
+
if (!partial) continue;
|
|
103
|
+
for (const [key, value] of Object.entries(partial)) {
|
|
104
|
+
if (value === undefined) continue;
|
|
105
|
+
const typedKey = key as keyof BackgroundTasksConfig;
|
|
106
|
+
const baseValue = merged[typedKey];
|
|
107
|
+
if (
|
|
108
|
+
typeof baseValue === "object" &&
|
|
109
|
+
baseValue !== null &&
|
|
110
|
+
!Array.isArray(baseValue) &&
|
|
111
|
+
typeof value === "object" &&
|
|
112
|
+
value !== null &&
|
|
113
|
+
!Array.isArray(value)
|
|
114
|
+
) {
|
|
115
|
+
(merged as unknown as Record<string, unknown>)[typedKey] = { ...(baseValue as object), ...(value as object) };
|
|
116
|
+
} else {
|
|
117
|
+
(merged as unknown as Record<string, unknown>)[typedKey] = value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return merged;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Validate config values. Strict keys produce visible errors; unknown keys are
|
|
126
|
+
* ignored (forward compatibility). Returns a list of problems; empty means valid.
|
|
127
|
+
*/
|
|
128
|
+
export function validateBackgroundTasksConfig(config: unknown): string[] {
|
|
129
|
+
const problems: string[] = [];
|
|
130
|
+
if (typeof config !== "object" || config === null) {
|
|
131
|
+
return ["config must be an object"];
|
|
132
|
+
}
|
|
133
|
+
const c = config as Record<string, unknown>;
|
|
134
|
+
|
|
135
|
+
if (c.enabled !== undefined && typeof c.enabled !== "boolean") {
|
|
136
|
+
problems.push("enabled must be a boolean");
|
|
137
|
+
}
|
|
138
|
+
for (const key of ["notifyOnCompletion", "triggerOnCompletion"] as const) {
|
|
139
|
+
if (c[key] !== undefined && typeof c[key] !== "boolean") {
|
|
140
|
+
problems.push(`${key} must be a boolean`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (c.defaultTimeoutSeconds !== undefined) {
|
|
144
|
+
if (typeof c.defaultTimeoutSeconds !== "number" || !Number.isFinite(c.defaultTimeoutSeconds) || c.defaultTimeoutSeconds < 0) {
|
|
145
|
+
problems.push("defaultTimeoutSeconds must be a non-negative number");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (c.maxFinishedTasks !== undefined) {
|
|
149
|
+
if (typeof c.maxFinishedTasks !== "number" || !Number.isInteger(c.maxFinishedTasks) || c.maxFinishedTasks < 1) {
|
|
150
|
+
problems.push("maxFinishedTasks must be a positive integer");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (c.maxOutputBytes !== undefined) {
|
|
154
|
+
if (typeof c.maxOutputBytes !== "number" || !Number.isInteger(c.maxOutputBytes) || c.maxOutputBytes < 1024) {
|
|
155
|
+
problems.push("maxOutputBytes must be an integer >= 1024");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (c.delegate !== undefined) {
|
|
159
|
+
if (typeof c.delegate !== "object" || c.delegate === null) {
|
|
160
|
+
problems.push("delegate must be an object");
|
|
161
|
+
} else {
|
|
162
|
+
const d = c.delegate as Record<string, unknown>;
|
|
163
|
+
if (d.extensionMode !== undefined && d.extensionMode !== "isolated" && d.extensionMode !== "ambient") {
|
|
164
|
+
problems.push('delegate.extensionMode must be "isolated" or "ambient"');
|
|
165
|
+
}
|
|
166
|
+
if (
|
|
167
|
+
d.autoDeliver !== undefined &&
|
|
168
|
+
d.autoDeliver !== "never" &&
|
|
169
|
+
d.autoDeliver !== "when_small" &&
|
|
170
|
+
d.autoDeliver !== "always"
|
|
171
|
+
) {
|
|
172
|
+
problems.push('delegate.autoDeliver must be "never", "when_small", or "always"');
|
|
173
|
+
}
|
|
174
|
+
for (const key of ["maxTurns", "maxToolCalls"] as const) {
|
|
175
|
+
if (d[key] !== undefined && (typeof d[key] !== "number" || !Number.isInteger(d[key]) || (d[key] as number) < 1)) {
|
|
176
|
+
problems.push(`delegate.${key} must be a positive integer`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (d.timeoutSeconds !== undefined && (typeof d.timeoutSeconds !== "number" || !Number.isFinite(d.timeoutSeconds) || (d.timeoutSeconds as number) <= 0)) {
|
|
180
|
+
problems.push("delegate.timeoutSeconds must be a positive number");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (c.fusion !== undefined) {
|
|
185
|
+
if (typeof c.fusion !== "object" || c.fusion === null) {
|
|
186
|
+
problems.push("fusion must be an object");
|
|
187
|
+
} else {
|
|
188
|
+
const f = c.fusion as Record<string, unknown>;
|
|
189
|
+
for (const key of ["candidates", "evaluator", "merger"] as const) {
|
|
190
|
+
const value = f[key];
|
|
191
|
+
if (value === undefined) continue;
|
|
192
|
+
if (key === "candidates") {
|
|
193
|
+
if (
|
|
194
|
+
!Array.isArray(value) ||
|
|
195
|
+
value.length !== 3 ||
|
|
196
|
+
value.some((v) => typeof v !== "string")
|
|
197
|
+
) {
|
|
198
|
+
problems.push("fusion.candidates must be an array of exactly 3 strings");
|
|
199
|
+
}
|
|
200
|
+
} else if (typeof value !== "string") {
|
|
201
|
+
problems.push(`fusion.${key} must be a string`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return problems;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export interface LoadedBackgroundTasksConfig {
|
|
210
|
+
config: BackgroundTasksConfig;
|
|
211
|
+
/** Non-fatal problems encountered while loading individual layers. */
|
|
212
|
+
warnings: string[];
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Load config with workspace-wins layering. Auto-generates the global file on
|
|
217
|
+
* first run. Corrupt layers are skipped with a warning rather than crashing.
|
|
218
|
+
*/
|
|
219
|
+
export function loadBackgroundTasksConfig(cwd: string): LoadedBackgroundTasksConfig {
|
|
220
|
+
const warnings: string[] = [];
|
|
221
|
+
const globalPath = getGlobalConfigPath();
|
|
222
|
+
|
|
223
|
+
if (!existsSync(globalPath)) {
|
|
224
|
+
try {
|
|
225
|
+
ensureDirExists(join(globalPath, ".."));
|
|
226
|
+
writeConfigAtomic(globalPath, DEFAULT_CONFIG);
|
|
227
|
+
} catch {
|
|
228
|
+
warnings.push(`could not create global config at ${globalPath}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const globalLayer = loadConfigFromPath(globalPath);
|
|
233
|
+
if (globalLayer === null && existsSync(globalPath)) {
|
|
234
|
+
warnings.push(`global config at ${globalPath} is corrupt; using defaults`);
|
|
235
|
+
}
|
|
236
|
+
const workspaceLayer = cwd ? loadConfigFromPath(getWorkspaceConfigPath(cwd)) : null;
|
|
237
|
+
if (workspaceLayer === null && cwd && existsSync(getWorkspaceConfigPath(cwd))) {
|
|
238
|
+
warnings.push(`workspace config is corrupt; ignoring it`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const merged = mergeLayers(DEFAULT_CONFIG, globalLayer ?? {}, workspaceLayer ?? {});
|
|
242
|
+
const problems = validateBackgroundTasksConfig(merged);
|
|
243
|
+
if (problems.length > 0) {
|
|
244
|
+
warnings.push(...problems.map((p) => `config problem: ${p}`));
|
|
245
|
+
}
|
|
246
|
+
// Sanitize: fall back to defaults for invalid values instead of crashing.
|
|
247
|
+
const config: BackgroundTasksConfig = {
|
|
248
|
+
...merged,
|
|
249
|
+
enabled: typeof merged.enabled === "boolean" ? merged.enabled : true,
|
|
250
|
+
notifyOnCompletion:
|
|
251
|
+
typeof merged.notifyOnCompletion === "boolean" ? merged.notifyOnCompletion : DEFAULT_CONFIG.notifyOnCompletion,
|
|
252
|
+
triggerOnCompletion:
|
|
253
|
+
typeof merged.triggerOnCompletion === "boolean" ? merged.triggerOnCompletion : DEFAULT_CONFIG.triggerOnCompletion,
|
|
254
|
+
defaultTimeoutSeconds:
|
|
255
|
+
typeof merged.defaultTimeoutSeconds === "number" && Number.isFinite(merged.defaultTimeoutSeconds) && merged.defaultTimeoutSeconds >= 0
|
|
256
|
+
? merged.defaultTimeoutSeconds
|
|
257
|
+
: DEFAULT_CONFIG.defaultTimeoutSeconds,
|
|
258
|
+
maxFinishedTasks:
|
|
259
|
+
typeof merged.maxFinishedTasks === "number" && Number.isInteger(merged.maxFinishedTasks) && merged.maxFinishedTasks >= 1
|
|
260
|
+
? merged.maxFinishedTasks
|
|
261
|
+
: DEFAULT_CONFIG.maxFinishedTasks,
|
|
262
|
+
maxOutputBytes:
|
|
263
|
+
typeof merged.maxOutputBytes === "number" && Number.isInteger(merged.maxOutputBytes) && merged.maxOutputBytes >= 1024
|
|
264
|
+
? merged.maxOutputBytes
|
|
265
|
+
: DEFAULT_CONFIG.maxOutputBytes,
|
|
266
|
+
delegate: {
|
|
267
|
+
...DEFAULT_CONFIG.delegate,
|
|
268
|
+
...merged.delegate,
|
|
269
|
+
extensionMode: merged.delegate?.extensionMode === "ambient" ? "ambient" : "isolated",
|
|
270
|
+
autoDeliver:
|
|
271
|
+
merged.delegate?.autoDeliver === "never" || merged.delegate?.autoDeliver === "always"
|
|
272
|
+
? merged.delegate.autoDeliver
|
|
273
|
+
: "when_small",
|
|
274
|
+
},
|
|
275
|
+
fusion: {
|
|
276
|
+
candidates:
|
|
277
|
+
Array.isArray(merged.fusion?.candidates) && merged.fusion.candidates.length === 3
|
|
278
|
+
? ([...merged.fusion.candidates] as [string, string, string])
|
|
279
|
+
: DEFAULT_CONFIG.fusion.candidates,
|
|
280
|
+
evaluator: typeof merged.fusion?.evaluator === "string" ? merged.fusion.evaluator : "",
|
|
281
|
+
merger: typeof merged.fusion?.merger === "string" ? merged.fusion.merger : "",
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
return { config, warnings };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Persist settings back to the global config file (settings overlay save path). */
|
|
288
|
+
export function saveGlobalBackgroundTasksConfig(config: BackgroundTasksConfig): void {
|
|
289
|
+
const globalPath = getGlobalConfigPath();
|
|
290
|
+
ensureDirExists(join(globalPath, ".."));
|
|
291
|
+
writeConfigAtomic(globalPath, config);
|
|
292
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSessionContext,
|
|
3
|
+
convertToLlm,
|
|
4
|
+
type SessionEntry,
|
|
5
|
+
} from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import type { Message } from '@earendil-works/pi-ai';
|
|
7
|
+
import { isJsonObject, type JsonObject } from './types.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pi session adapter shared by every consumer of the visible-conversation
|
|
11
|
+
* transform.
|
|
12
|
+
*
|
|
13
|
+
* Responsible for exactly one thing: turning the parent's live Pi session into a
|
|
14
|
+
* frozen `Message[]` snapshot, with the in-flight tool call that requested the
|
|
15
|
+
* snapshot (and therefore its sibling calls) excluded from the branch.
|
|
16
|
+
*
|
|
17
|
+
* The transform itself lives in `visible-conversation-v2.ts` and never sees a
|
|
18
|
+
* `SessionManager`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export interface ReadonlyParentSessionManager {
|
|
22
|
+
getLeafId(): string | null;
|
|
23
|
+
getLeafEntry(): SessionEntry | undefined;
|
|
24
|
+
getEntries(): SessionEntry[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ParentContextSource {
|
|
28
|
+
cwd: string;
|
|
29
|
+
sessionManager: ReadonlyParentSessionManager;
|
|
30
|
+
getSystemPrompt(): string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ParentSnapshotOptions {
|
|
34
|
+
/** Tool call currently executing, when the snapshot is requested from a tool. */
|
|
35
|
+
toolCallId?: string | undefined;
|
|
36
|
+
/** Tool name used for leaf matching when no explicit call id is available. */
|
|
37
|
+
toolName: string;
|
|
38
|
+
/** Commands have no in-flight tool call and therefore never exclude a leaf. */
|
|
39
|
+
excludeActiveToolCallLeaf: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ParentSnapshot {
|
|
43
|
+
messages: readonly Message[];
|
|
44
|
+
leafId: string | null;
|
|
45
|
+
activeToolCallLeafExcluded: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function entriesById(entries: readonly SessionEntry[]): Map<string, SessionEntry> {
|
|
49
|
+
const byId = new Map<string, SessionEntry>();
|
|
50
|
+
for (const entry of entries) byId.set(entry.id, entry);
|
|
51
|
+
return byId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function readArray(record: JsonObject, key: string): readonly unknown[] | undefined {
|
|
55
|
+
const value = record[key];
|
|
56
|
+
return Array.isArray(value) ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function recordOf(value: unknown): JsonObject | undefined {
|
|
60
|
+
if (!isJsonObject(value) || Array.isArray(value)) return undefined;
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function entryMessage(entry: SessionEntry): JsonObject | undefined {
|
|
65
|
+
if (entry.type !== 'message') return undefined;
|
|
66
|
+
return recordOf(entry.message);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function toolCallPartMatches(
|
|
70
|
+
part: unknown,
|
|
71
|
+
toolCallId: string | undefined,
|
|
72
|
+
toolName: string,
|
|
73
|
+
): boolean {
|
|
74
|
+
const record = recordOf(part);
|
|
75
|
+
if (record === undefined || record['type'] !== 'toolCall') return false;
|
|
76
|
+
if (toolCallId !== undefined) return record['id'] === toolCallId;
|
|
77
|
+
return record['name'] === toolName;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function messageContainsToolCall(
|
|
81
|
+
message: JsonObject,
|
|
82
|
+
toolCallId: string | undefined,
|
|
83
|
+
toolName: string,
|
|
84
|
+
): boolean {
|
|
85
|
+
if (message['role'] !== 'assistant') return false;
|
|
86
|
+
const content = readArray(message, 'content');
|
|
87
|
+
if (content === undefined) return false;
|
|
88
|
+
for (const part of content) {
|
|
89
|
+
if (toolCallPartMatches(part, toolCallId, toolName)) return true;
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface EffectiveLeaf {
|
|
95
|
+
leafId: string | null;
|
|
96
|
+
activeToolCallLeafExcluded: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function effectiveLeafForTool(
|
|
100
|
+
sessionManager: ReadonlyParentSessionManager,
|
|
101
|
+
toolCallId: string | undefined,
|
|
102
|
+
toolName: string,
|
|
103
|
+
): EffectiveLeaf {
|
|
104
|
+
const leaf = sessionManager.getLeafEntry();
|
|
105
|
+
if (leaf === undefined)
|
|
106
|
+
return { leafId: sessionManager.getLeafId(), activeToolCallLeafExcluded: false };
|
|
107
|
+
const message = entryMessage(leaf);
|
|
108
|
+
if (message !== undefined && messageContainsToolCall(message, toolCallId, toolName)) {
|
|
109
|
+
return { leafId: leaf.parentId, activeToolCallLeafExcluded: true };
|
|
110
|
+
}
|
|
111
|
+
return { leafId: sessionManager.getLeafId(), activeToolCallLeafExcluded: false };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function resolveEffectiveLeaf(
|
|
115
|
+
sessionManager: ReadonlyParentSessionManager,
|
|
116
|
+
options: ParentSnapshotOptions,
|
|
117
|
+
): EffectiveLeaf {
|
|
118
|
+
if (!options.excludeActiveToolCallLeaf)
|
|
119
|
+
return { leafId: sessionManager.getLeafId(), activeToolCallLeafExcluded: false };
|
|
120
|
+
return effectiveLeafForTool(sessionManager, options.toolCallId, options.toolName);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Freeze the parent conversation into LLM messages.
|
|
125
|
+
*
|
|
126
|
+
* Callers must complete every downstream use of the returned snapshot without
|
|
127
|
+
* re-reading the session, so the seed cannot drift while a child is being
|
|
128
|
+
* launched.
|
|
129
|
+
*/
|
|
130
|
+
export function snapshotParentConversation(
|
|
131
|
+
ctx: ParentContextSource,
|
|
132
|
+
options: ParentSnapshotOptions,
|
|
133
|
+
): ParentSnapshot {
|
|
134
|
+
const entries = ctx.sessionManager.getEntries();
|
|
135
|
+
const leaf = resolveEffectiveLeaf(ctx.sessionManager, options);
|
|
136
|
+
const sessionContext = buildSessionContext(entries, leaf.leafId, entriesById(entries));
|
|
137
|
+
return {
|
|
138
|
+
messages: convertToLlm(sessionContext.messages),
|
|
139
|
+
leafId: leaf.leafId,
|
|
140
|
+
activeToolCallLeafExcluded: leaf.activeToolCallLeafExcluded,
|
|
141
|
+
};
|
|
142
|
+
}
|