@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/index.ts
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/background-tasks — Module entry
|
|
3
|
+
*
|
|
4
|
+
* Master-toggle gate: when config `enabled` is false, this registers NOTHING
|
|
5
|
+
* (no tools, no commands, no hooks, no UI).
|
|
6
|
+
*
|
|
7
|
+
* Ported from pi-background-tasks src/extension.ts. Conventions ours:
|
|
8
|
+
* /unipi:* commands, temp-root runtime dir, UNIPI_BG_* env, update-check
|
|
9
|
+
* dropped (our updater module owns updates).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import { loadBackgroundTasksConfig } from "./config.js";
|
|
14
|
+
import { BackgroundTaskRegistry } from "./registry.js";
|
|
15
|
+
import {
|
|
16
|
+
installBackgroundTaskExtensionApi,
|
|
17
|
+
type BackgroundTaskExtensionService,
|
|
18
|
+
} from "./extension-api.js";
|
|
19
|
+
import { registerToolsAndCommands } from "./tools.js";
|
|
20
|
+
import { registerFusionExtension } from "./fusion-extension.js";
|
|
21
|
+
import { registerDelegateExtension } from "./delegate-extension.js";
|
|
22
|
+
import { taskDisplayName, type BgTask, type StartAttestedPiTaskOptions, type StartTaskOptions } from "./types.js";
|
|
23
|
+
|
|
24
|
+
const STATUS_INTERVAL_MS = 1000;
|
|
25
|
+
|
|
26
|
+
export default function backgroundTasksExtension(pi: ExtensionAPI): void {
|
|
27
|
+
const { config, warnings } = loadBackgroundTasksConfig(process.cwd());
|
|
28
|
+
|
|
29
|
+
for (const warning of warnings) {
|
|
30
|
+
console.error(`[background-tasks] ${warning}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!config.enabled) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const seenTaskIds = new Set<string>();
|
|
38
|
+
let currentCtx: ExtensionContext | undefined;
|
|
39
|
+
let dockOpen = false;
|
|
40
|
+
let statusInterval: NodeJS.Timeout | undefined;
|
|
41
|
+
|
|
42
|
+
const registry = new BackgroundTaskRegistry({
|
|
43
|
+
maxOutputBytes: config.maxOutputBytes,
|
|
44
|
+
maxRecentTasks: config.maxFinishedTasks,
|
|
45
|
+
onChange: () => {
|
|
46
|
+
updateUi();
|
|
47
|
+
},
|
|
48
|
+
sendCompletionNotification: (message, options) => {
|
|
49
|
+
// Our sendMessage path (same contract as reference; pi delivers followUp + triggerTurn)
|
|
50
|
+
pi.sendMessage(message as never, options as never);
|
|
51
|
+
},
|
|
52
|
+
publishTerminal: (task) => {
|
|
53
|
+
eventService.publishTerminal(task);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const eventService: BackgroundTaskExtensionService = installBackgroundTaskExtensionApi({
|
|
57
|
+
events: pi.events,
|
|
58
|
+
registry,
|
|
59
|
+
getContext: () => currentCtx,
|
|
60
|
+
isShuttingDown: () => registry.isShuttingDown(),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function unseenFinishedTasks(): BgTask[] {
|
|
64
|
+
return registry
|
|
65
|
+
.allTasks()
|
|
66
|
+
.filter((task) => task.status !== "running" && !seenTaskIds.has(task.id));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clearFinishedNotices(ctx?: ExtensionContext): number {
|
|
70
|
+
const unseen = unseenFinishedTasks();
|
|
71
|
+
for (const task of unseen) seenTaskIds.add(task.id);
|
|
72
|
+
updateUi(ctx);
|
|
73
|
+
return unseen.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function updateUi(ctx?: ExtensionContext): void {
|
|
77
|
+
if (registry.isShuttingDown()) return;
|
|
78
|
+
const target = ctx ?? currentCtx;
|
|
79
|
+
if (!target) return;
|
|
80
|
+
try {
|
|
81
|
+
if (!target.hasUI) return;
|
|
82
|
+
const allTasks = registry.allTasks();
|
|
83
|
+
const running = allTasks.filter((task) => task.status === "running");
|
|
84
|
+
const unseenFailed = allTasks.filter((task) => task.status === "failed" && !seenTaskIds.has(task.id));
|
|
85
|
+
const unseenStopped = allTasks.filter((task) => task.status === "killed" && !seenTaskIds.has(task.id));
|
|
86
|
+
const unseenDone = allTasks.filter((task) => task.status === "completed" && !seenTaskIds.has(task.id));
|
|
87
|
+
const unseenFinishedCount = unseenFailed.length + unseenStopped.length + unseenDone.length;
|
|
88
|
+
|
|
89
|
+
target.ui.setWidget("background-tasks", undefined);
|
|
90
|
+
if (running.length === 0 && unseenFinishedCount === 0) {
|
|
91
|
+
target.ui.setStatus("background-tasks", undefined);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const parts: string[] = [];
|
|
96
|
+
if (running.length > 0) parts.push(`${String(running.length)} running`);
|
|
97
|
+
if (unseenFailed.length > 0) parts.push(`${String(unseenFailed.length)} failed`);
|
|
98
|
+
if (unseenStopped.length > 0) parts.push(`${String(unseenStopped.length)} stopped`);
|
|
99
|
+
if (unseenDone.length > 0) parts.push(`${String(unseenDone.length)} done`);
|
|
100
|
+
const entryHint = dockOpen ? "focused" : `Shift↓${unseenFinishedCount > 0 ? " · /unipi:bg-clear" : ""}`;
|
|
101
|
+
const label = ` bg ${[...parts, entryHint].join(" · ")} `;
|
|
102
|
+
target.ui.setStatus("background-tasks", label);
|
|
103
|
+
} catch (error) {
|
|
104
|
+
console.error(
|
|
105
|
+
`[background-tasks] UI update failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
106
|
+
);
|
|
107
|
+
currentCtx = undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function startTask(ctx: ExtensionContext, command: string, opts: StartTaskOptions = {}): Promise<BgTask> {
|
|
112
|
+
currentCtx = ctx;
|
|
113
|
+
return registry.startTask(ctx, command, opts);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function startAttestedPiTask(
|
|
117
|
+
ctx: ExtensionContext,
|
|
118
|
+
opts: StartAttestedPiTaskOptions,
|
|
119
|
+
): Promise<BgTask> {
|
|
120
|
+
currentCtx = ctx;
|
|
121
|
+
return registry.startAttestedPiTask(ctx, opts);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function openTaskManager(ctx: ExtensionContext, initialTaskId?: string): Promise<void> {
|
|
125
|
+
currentCtx = ctx;
|
|
126
|
+
if (!ctx.hasUI) {
|
|
127
|
+
ctx.ui.notify(
|
|
128
|
+
"Background task manager requires an interactive UI. Use /unipi:jobs, /unipi:logs, or the bg_status/bg_logs tools in non-interactive mode.",
|
|
129
|
+
"error",
|
|
130
|
+
);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
dockOpen = true;
|
|
134
|
+
updateUi(ctx);
|
|
135
|
+
try {
|
|
136
|
+
// Task manager overlay on our slot patterns — dynamic import keeps the
|
|
137
|
+
// component tree lazy. FleetView-style: bottom-center anchored overlay.
|
|
138
|
+
const { BackgroundTasksManager } = await import("./task-manager.js");
|
|
139
|
+
await ctx.ui.custom<"closed">(
|
|
140
|
+
(tui, theme, _keybindings, done) =>
|
|
141
|
+
new BackgroundTasksManager(tui, theme, done, {
|
|
142
|
+
getTasks: () => registry.allTasks(),
|
|
143
|
+
stopTask: async (task) => {
|
|
144
|
+
await registry.stopTask(registry.resolveTask(task.id), "user");
|
|
145
|
+
updateUi(ctx);
|
|
146
|
+
},
|
|
147
|
+
stopAllRunning: async () => {
|
|
148
|
+
const result = await registry.stopAllRunning("user");
|
|
149
|
+
updateUi(ctx);
|
|
150
|
+
return result;
|
|
151
|
+
},
|
|
152
|
+
rerunTask: async (task) => {
|
|
153
|
+
if (task.fusion !== undefined || task.delegate !== undefined) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
"Only shell-command tasks can be rerun from the dock; relaunch this typed workflow through its owning tool.",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
const rerunOptions: StartTaskOptions = {
|
|
159
|
+
name: taskDisplayName(task),
|
|
160
|
+
isAgent: task.isAgent,
|
|
161
|
+
notifyOnCompletion: true,
|
|
162
|
+
triggerOnCompletion: false,
|
|
163
|
+
};
|
|
164
|
+
if (task.description !== undefined) rerunOptions.description = task.description;
|
|
165
|
+
if (task.timeoutSeconds !== undefined) rerunOptions.timeoutSeconds = task.timeoutSeconds;
|
|
166
|
+
const rerun = await startTask(ctx, task.command, rerunOptions);
|
|
167
|
+
updateUi(ctx);
|
|
168
|
+
return rerun;
|
|
169
|
+
},
|
|
170
|
+
showOutputPath: (task) => {
|
|
171
|
+
ctx.ui.notify(`Output path for ${taskDisplayName(task)} (${task.id}):\n${task.outputPath}`, "info");
|
|
172
|
+
},
|
|
173
|
+
markSeen: (taskId: string) => {
|
|
174
|
+
seenTaskIds.add(taskId);
|
|
175
|
+
updateUi(ctx);
|
|
176
|
+
},
|
|
177
|
+
markFinishedSeen: (taskIds: string[]) => {
|
|
178
|
+
for (const taskId of taskIds) seenTaskIds.add(taskId);
|
|
179
|
+
updateUi(ctx);
|
|
180
|
+
},
|
|
181
|
+
isSeen: (taskId: string) => seenTaskIds.has(taskId),
|
|
182
|
+
...(initialTaskId ? { initialTaskId } : {}),
|
|
183
|
+
}),
|
|
184
|
+
{
|
|
185
|
+
overlay: true,
|
|
186
|
+
overlayOptions: {
|
|
187
|
+
anchor: "bottom-center",
|
|
188
|
+
width: "96%",
|
|
189
|
+
minWidth: 64,
|
|
190
|
+
maxHeight: "60%",
|
|
191
|
+
margin: { bottom: 1, left: 1, right: 1 },
|
|
192
|
+
} as never,
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
} finally {
|
|
196
|
+
dockOpen = false;
|
|
197
|
+
updateUi(ctx);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function openSettings(ctx: ExtensionContext): Promise<void> {
|
|
202
|
+
currentCtx = ctx;
|
|
203
|
+
if (!ctx.hasUI) {
|
|
204
|
+
ctx.ui.notify(
|
|
205
|
+
"Background-tasks settings require an interactive UI. Edit ~/.unipi/config/background-tasks.json directly instead.",
|
|
206
|
+
"error",
|
|
207
|
+
);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const { renderBackgroundTasksSettingsOverlay } = await import("./settings-overlay.js");
|
|
211
|
+
await ctx.ui.custom(
|
|
212
|
+
renderBackgroundTasksSettingsOverlay({ cwd: ctx.cwd ?? process.cwd() }),
|
|
213
|
+
{
|
|
214
|
+
overlay: true,
|
|
215
|
+
overlayOptions: {
|
|
216
|
+
anchor: "center",
|
|
217
|
+
width: "70%",
|
|
218
|
+
minWidth: 56,
|
|
219
|
+
margin: 2,
|
|
220
|
+
} as never,
|
|
221
|
+
},
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
registerToolsAndCommands({
|
|
226
|
+
pi,
|
|
227
|
+
registry,
|
|
228
|
+
startTask,
|
|
229
|
+
startAttestedPiTask,
|
|
230
|
+
openTaskManager,
|
|
231
|
+
clearFinishedNotices,
|
|
232
|
+
openSettings,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
registerFusionExtension(pi, {
|
|
236
|
+
startManagedTask: async (ctx, options) => {
|
|
237
|
+
currentCtx = ctx;
|
|
238
|
+
return registry.startManagedTask(ctx, options);
|
|
239
|
+
},
|
|
240
|
+
snapshot: (task) => registry.snapshot(task),
|
|
241
|
+
updateManagedTask: (task, state, line) => registry.updateManagedTask(task, state, line),
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
registerDelegateExtension(pi, {
|
|
245
|
+
startDelegateTask: async (ctx, options) => {
|
|
246
|
+
currentCtx = ctx;
|
|
247
|
+
return registry.startDelegateTask(ctx, options);
|
|
248
|
+
},
|
|
249
|
+
snapshot: (task) => registry.snapshot(task),
|
|
250
|
+
resolveTask: (idOrPrefix) => registry.resolveTask(idOrPrefix),
|
|
251
|
+
claimFusionUsage: (task) => registry.claimFusionUsage(task),
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
255
|
+
registry.setShuttingDown(false);
|
|
256
|
+
currentCtx = ctx;
|
|
257
|
+
await registry.ensureRuntimeDir(ctx);
|
|
258
|
+
updateUi(ctx);
|
|
259
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
260
|
+
statusInterval = setInterval(() => {
|
|
261
|
+
updateUi();
|
|
262
|
+
}, STATUS_INTERVAL_MS);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
266
|
+
registry.setShuttingDown(true);
|
|
267
|
+
currentCtx = undefined;
|
|
268
|
+
if (statusInterval) {
|
|
269
|
+
clearInterval(statusInterval);
|
|
270
|
+
statusInterval = undefined;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
const running = registry.allTasks().filter((task) => task.status === "running");
|
|
274
|
+
if (running.length === 0) return;
|
|
275
|
+
|
|
276
|
+
const failures: string[] = [];
|
|
277
|
+
await Promise.all(
|
|
278
|
+
running.map(async (task) => {
|
|
279
|
+
try {
|
|
280
|
+
await registry.stopTask(task, "shutdown", "Killed during Pi session shutdown/reload");
|
|
281
|
+
} catch (error) {
|
|
282
|
+
const message = `${task.id}: ${error instanceof Error ? error.message : String(error)}`;
|
|
283
|
+
failures.push(message);
|
|
284
|
+
console.error(`[background-tasks] shutdown cleanup failed for ${message}`);
|
|
285
|
+
}
|
|
286
|
+
}),
|
|
287
|
+
);
|
|
288
|
+
if (failures.length > 0 && ctx.hasUI) {
|
|
289
|
+
ctx.ui.notify(`Background task cleanup failed:\n${failures.join("\n")}`, "error");
|
|
290
|
+
}
|
|
291
|
+
} finally {
|
|
292
|
+
eventService.close();
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
}
|
package/src/pi-launch.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import type { Stats } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export interface PiLaunchSpec {
|
|
8
|
+
readonly executable: string;
|
|
9
|
+
readonly argvPrefix: readonly string[];
|
|
10
|
+
readonly kind: 'path' | 'package-node-cli';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface PiLaunchDependencies {
|
|
14
|
+
readonly platform?: NodeJS.Platform;
|
|
15
|
+
readonly execPath?: string;
|
|
16
|
+
readonly resolvePackageJson?: (specifier: string) => string;
|
|
17
|
+
readonly readFile?: (path: string) => string | Buffer;
|
|
18
|
+
readonly realpath?: (path: string) => string;
|
|
19
|
+
readonly stat?: (path: string) => Pick<Stats, 'isFile'>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class PiLaunchResolutionError extends Error {
|
|
23
|
+
readonly code = 'pi_executable_resolution_failed';
|
|
24
|
+
|
|
25
|
+
constructor(message: string) {
|
|
26
|
+
super(`pi_executable_resolution_failed: ${message}`);
|
|
27
|
+
this.name = 'PiLaunchResolutionError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class PiCommandLineLimitError extends Error {
|
|
32
|
+
readonly code = 'pi_command_line_too_long';
|
|
33
|
+
readonly stage: string;
|
|
34
|
+
readonly measuredLength: number;
|
|
35
|
+
readonly limit: number;
|
|
36
|
+
|
|
37
|
+
constructor(stage: string, measuredLength: number, limit: number) {
|
|
38
|
+
super(
|
|
39
|
+
`pi_command_line_too_long: ${stage} measured UTF-16 command line length ${String(measuredLength)} exceeds limit ${String(limit)}`,
|
|
40
|
+
);
|
|
41
|
+
this.name = 'PiCommandLineLimitError';
|
|
42
|
+
this.stage = stage;
|
|
43
|
+
this.measuredLength = measuredLength;
|
|
44
|
+
this.limit = limit;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const PI_PACKAGE_NAME = '@earendil-works/pi-coding-agent';
|
|
49
|
+
const PI_PACKAGE_MANIFEST = `${PI_PACKAGE_NAME}/package.json`;
|
|
50
|
+
const WINDOWS_COMMAND_LINE_LIMIT = 32767;
|
|
51
|
+
|
|
52
|
+
interface JsonRecord {
|
|
53
|
+
readonly [key: string]: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function defaultResolvePackageJson(specifier: string): string {
|
|
57
|
+
const requireForPi = createRequire(import.meta.url);
|
|
58
|
+
try {
|
|
59
|
+
return requireForPi.resolve(specifier);
|
|
60
|
+
} catch (manifestError) {
|
|
61
|
+
if (specifier !== PI_PACKAGE_MANIFEST) throw manifestError;
|
|
62
|
+
let packageEntry: string;
|
|
63
|
+
try {
|
|
64
|
+
packageEntry = fileURLToPath(import.meta.resolve(PI_PACKAGE_NAME));
|
|
65
|
+
} catch (entryError) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${errorMessage(manifestError)}; package entry resolve failed: ${errorMessage(entryError)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
const diagnostics: string[] = [];
|
|
71
|
+
let dir = dirname(packageEntry);
|
|
72
|
+
for (;;) {
|
|
73
|
+
const candidate = join(dir, 'package.json');
|
|
74
|
+
try {
|
|
75
|
+
if (statSync(candidate).isFile()) return candidate;
|
|
76
|
+
diagnostics.push(`${candidate} is not a regular file`);
|
|
77
|
+
} catch (statError) {
|
|
78
|
+
diagnostics.push(`${candidate}: ${errorMessage(statError)}`);
|
|
79
|
+
}
|
|
80
|
+
const parent = dirname(dir);
|
|
81
|
+
if (parent === dir) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`${errorMessage(manifestError)}; package entry search failed: ${diagnostics.join('; ')}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
dir = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function failResolution(message: string): never {
|
|
92
|
+
throw new PiLaunchResolutionError(message);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function errorMessage(error: unknown): string {
|
|
96
|
+
return error instanceof Error ? error.message : String(error);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function readPath<T>(label: string, path: string, action: () => T): T {
|
|
100
|
+
try {
|
|
101
|
+
return action();
|
|
102
|
+
} catch (error) {
|
|
103
|
+
failResolution(`${label} failed for ${path}: ${errorMessage(error)}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isJsonRecord(value: unknown): value is JsonRecord {
|
|
108
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseManifest(raw: string | Buffer, manifestPath: string): JsonRecord {
|
|
112
|
+
let parsed: unknown;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(Buffer.isBuffer(raw) ? raw.toString('utf8') : raw);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
failResolution(`manifest JSON is invalid at ${manifestPath}: ${errorMessage(error)}`);
|
|
117
|
+
}
|
|
118
|
+
if (!isJsonRecord(parsed)) failResolution(`manifest is not an object at ${manifestPath}`);
|
|
119
|
+
return parsed;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function readPiBin(manifest: JsonRecord, manifestPath: string): string {
|
|
123
|
+
const bin = manifest['bin'];
|
|
124
|
+
if (typeof bin === 'string' && bin.trim().length > 0) return bin;
|
|
125
|
+
if (isJsonRecord(bin)) {
|
|
126
|
+
const pi = bin['pi'];
|
|
127
|
+
if (typeof pi === 'string' && pi.trim().length > 0) return pi;
|
|
128
|
+
}
|
|
129
|
+
failResolution(`manifest bin.pi is missing or malformed at ${manifestPath}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function pathInside(parent: string, child: string): boolean {
|
|
133
|
+
const rel = relative(parent, child);
|
|
134
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel) && !rel.split(sep).includes('..'));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function resolvePiLaunch(deps: PiLaunchDependencies = {}): PiLaunchSpec {
|
|
138
|
+
const platform = deps.platform ?? process.platform;
|
|
139
|
+
if (platform !== 'win32') return { executable: 'pi', argvPrefix: [], kind: 'path' };
|
|
140
|
+
|
|
141
|
+
const resolvePackageJson = deps.resolvePackageJson ?? defaultResolvePackageJson;
|
|
142
|
+
const readFile = deps.readFile ?? readFileSync;
|
|
143
|
+
const realpath = deps.realpath ?? realpathSync;
|
|
144
|
+
const stat = deps.stat ?? statSync;
|
|
145
|
+
const execPath = deps.execPath ?? process.execPath;
|
|
146
|
+
|
|
147
|
+
let manifestPath: string;
|
|
148
|
+
try {
|
|
149
|
+
manifestPath = resolvePackageJson(PI_PACKAGE_MANIFEST);
|
|
150
|
+
} catch (error) {
|
|
151
|
+
failResolution(`package manifest resolve failed for ${PI_PACKAGE_MANIFEST}: ${errorMessage(error)}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const packageRoot = dirname(manifestPath);
|
|
155
|
+
const packageRootReal = readPath('package root realpath', packageRoot, () => realpath(packageRoot));
|
|
156
|
+
const manifest = parseManifest(
|
|
157
|
+
readPath('manifest read', manifestPath, () => readFile(manifestPath)),
|
|
158
|
+
manifestPath,
|
|
159
|
+
);
|
|
160
|
+
const bin = readPiBin(manifest, manifestPath);
|
|
161
|
+
const targetCandidate = join(packageRoot, bin);
|
|
162
|
+
const targetReal = readPath('bin target realpath', targetCandidate, () => realpath(targetCandidate));
|
|
163
|
+
if (!pathInside(packageRootReal, targetReal)) {
|
|
164
|
+
failResolution('Pi package bin target resolves outside the package root');
|
|
165
|
+
}
|
|
166
|
+
const targetStat = readPath('bin target stat', targetReal, () => stat(targetReal));
|
|
167
|
+
if (!targetStat.isFile()) failResolution('Pi package bin target is not a regular file');
|
|
168
|
+
|
|
169
|
+
const extension = extname(targetReal).toLowerCase();
|
|
170
|
+
if (extension === '.js' || extension === '.cjs' || extension === '.mjs') {
|
|
171
|
+
return { executable: execPath, argvPrefix: [targetReal], kind: 'package-node-cli' };
|
|
172
|
+
}
|
|
173
|
+
if (extension === '.exe' || extension === '.com') {
|
|
174
|
+
return { executable: targetReal, argvPrefix: [], kind: 'package-node-cli' };
|
|
175
|
+
}
|
|
176
|
+
failResolution(`Pi package bin target extension is unsupported: ${extension || '<none>'}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function piLaunchArgv(launch: PiLaunchSpec, piArgs: readonly string[]): string[] {
|
|
180
|
+
return [...launch.argvPrefix, ...piArgs];
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function renderWindowsArgument(value: string): string {
|
|
184
|
+
if (value.length > 0 && !/[ \t"]/.test(value)) return value;
|
|
185
|
+
let rendered = '"';
|
|
186
|
+
let backslashes = 0;
|
|
187
|
+
for (const char of value) {
|
|
188
|
+
if (char === '\\') {
|
|
189
|
+
backslashes += 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (char === '"') {
|
|
193
|
+
rendered += '\\'.repeat(backslashes * 2 + 1);
|
|
194
|
+
rendered += '"';
|
|
195
|
+
backslashes = 0;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (backslashes > 0) {
|
|
199
|
+
rendered += '\\'.repeat(backslashes);
|
|
200
|
+
backslashes = 0;
|
|
201
|
+
}
|
|
202
|
+
rendered += char;
|
|
203
|
+
}
|
|
204
|
+
if (backslashes > 0) rendered += '\\'.repeat(backslashes * 2);
|
|
205
|
+
rendered += '"';
|
|
206
|
+
return rendered;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function renderWindowsCommandLine(parts: readonly string[]): string {
|
|
210
|
+
return parts.map(renderWindowsArgument).join(' ');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function assertWindowsCommandLineWithinLimit(
|
|
214
|
+
launch: PiLaunchSpec,
|
|
215
|
+
piArgs: readonly string[],
|
|
216
|
+
platform: NodeJS.Platform,
|
|
217
|
+
stage: string,
|
|
218
|
+
): void {
|
|
219
|
+
if (platform !== 'win32') return;
|
|
220
|
+
const measuredLength =
|
|
221
|
+
renderWindowsCommandLine([launch.executable, ...launch.argvPrefix, ...piArgs]).length + 1;
|
|
222
|
+
if (measuredLength > WINDOWS_COMMAND_LINE_LIMIT) {
|
|
223
|
+
throw new PiCommandLineLimitError(stage, measuredLength, WINDOWS_COMMAND_LINE_LIMIT);
|
|
224
|
+
}
|
|
225
|
+
}
|