specpi 0.11.2 → 0.12.0
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/CHANGELOG.md +13 -1
- package/NPM_RELEASE.md +3 -1
- package/README.md +38 -28
- package/SECURITY_MODEL.md +30 -0
- package/THIRD_PARTY.md +14 -0
- package/docs/delegation/README.md +264 -0
- package/docs/delegation/design-protocol.md +382 -0
- package/docs/delegation/design.md +525 -0
- package/docs/delegation/evaluation.md +307 -0
- package/docs/delegation/protocol.md +271 -0
- package/docs/delegation/research.md +216 -0
- package/extensions/command-guard/index.ts +118 -33
- package/extensions/delegation/core.mjs +772 -0
- package/extensions/delegation/errors.mjs +8 -0
- package/extensions/delegation/extension.mjs +475 -0
- package/extensions/delegation/index.ts +9 -0
- package/extensions/delegation/managed-files.mjs +13 -0
- package/extensions/delegation/native.mjs +155 -0
- package/extensions/delegation/presentation.mjs +315 -0
- package/extensions/delegation/protocol.mjs +296 -0
- package/extensions/delegation/provider.mjs +689 -0
- package/extensions/delegation/snapshot.mjs +532 -0
- package/extensions/delegation/worker.mjs +218 -0
- package/extensions/workflow-controls/index.ts +5 -1
- package/package.json +4 -2
- package/scripts/check-package.mjs +21 -3
- package/scripts/check-pi-package.mjs +4 -0
- package/scripts/check-syntax.mjs +61 -0
- package/scripts/specpi.mjs +6 -0
- package/site/logo.svg +1 -9
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Only code-owned diagnostics may cross the delegation UI/model boundary. */
|
|
2
|
+
export class DelegationError extends Error {}
|
|
3
|
+
|
|
4
|
+
export function publicErrorMessage(error) {
|
|
5
|
+
return error instanceof DelegationError
|
|
6
|
+
? error.message
|
|
7
|
+
: "Delegation operation failed. Retry after checking Pi configuration.";
|
|
8
|
+
}
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import { DelegationError, publicErrorMessage } from "./errors.mjs";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createDelegationController } from "./core.mjs";
|
|
4
|
+
import { createLivePanel, createToolRenderers, readableLimits, readableStatus } from "./presentation.mjs";
|
|
5
|
+
|
|
6
|
+
const integer = { type: "integer", minimum: 0 };
|
|
7
|
+
const string = { type: "string" };
|
|
8
|
+
const strings = { type: "array", items: string };
|
|
9
|
+
const object = (properties, required = Object.keys(properties)) => ({
|
|
10
|
+
type: "object",
|
|
11
|
+
properties,
|
|
12
|
+
required,
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
});
|
|
15
|
+
const operation = (name, properties = {}, optional = []) =>
|
|
16
|
+
object({ operation: { const: name, type: "string" }, ...properties }, [
|
|
17
|
+
"operation",
|
|
18
|
+
...Object.keys(properties).filter((key) => !optional.includes(key)),
|
|
19
|
+
]);
|
|
20
|
+
const binding = {
|
|
21
|
+
batchId: string,
|
|
22
|
+
jobId: string,
|
|
23
|
+
attemptId: string,
|
|
24
|
+
packetDigest: string,
|
|
25
|
+
generation: integer,
|
|
26
|
+
resultRevision: integer,
|
|
27
|
+
};
|
|
28
|
+
export const DELEGATE_SCHEMA = {
|
|
29
|
+
anyOf: [
|
|
30
|
+
operation("run", {
|
|
31
|
+
requestId: string,
|
|
32
|
+
packet: object({
|
|
33
|
+
objective: string,
|
|
34
|
+
requirements: { type: "array", items: object({ id: string, text: string }) },
|
|
35
|
+
decisions: strings,
|
|
36
|
+
nonGoals: strings,
|
|
37
|
+
reason: object({
|
|
38
|
+
benefit: {
|
|
39
|
+
type: "string",
|
|
40
|
+
enum: ["independent_review", "parallel_analysis", "context_isolation"],
|
|
41
|
+
},
|
|
42
|
+
why: string,
|
|
43
|
+
parentWork: {
|
|
44
|
+
type: "string",
|
|
45
|
+
description:
|
|
46
|
+
"Useful independent parent work; required for parallel_analysis, otherwise may be empty.",
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
jobs: {
|
|
50
|
+
type: "array",
|
|
51
|
+
minItems: 1,
|
|
52
|
+
maxItems: 2,
|
|
53
|
+
items: object({
|
|
54
|
+
id: string,
|
|
55
|
+
mode: { type: "string", enum: ["review", "scout"] },
|
|
56
|
+
question: string,
|
|
57
|
+
context: string,
|
|
58
|
+
sources: strings,
|
|
59
|
+
requirements: {
|
|
60
|
+
type: "array",
|
|
61
|
+
minItems: 1,
|
|
62
|
+
items: string,
|
|
63
|
+
description: "Only the requirement IDs assigned to this worker.",
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
},
|
|
67
|
+
}),
|
|
68
|
+
}),
|
|
69
|
+
operation("status"),
|
|
70
|
+
operation(
|
|
71
|
+
"collect",
|
|
72
|
+
{ batchId: string, afterCursor: integer, waitMs: { type: "integer", minimum: 0, maximum: 30000 } },
|
|
73
|
+
["afterCursor", "waitMs"],
|
|
74
|
+
),
|
|
75
|
+
operation("follow_up", { requestId: string, ...binding, prompt: string }),
|
|
76
|
+
operation("resolve", {
|
|
77
|
+
requestId: string,
|
|
78
|
+
...binding,
|
|
79
|
+
decision: { type: "string", enum: ["accept", "discard", "needs_check"] },
|
|
80
|
+
findings: {
|
|
81
|
+
type: "array",
|
|
82
|
+
items: object({
|
|
83
|
+
id: string,
|
|
84
|
+
decision: { type: "string", enum: ["confirmed", "rejected", "needs_check"] },
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
operation("cancel", { requestId: string, batchId: string, jobId: string }, ["jobId"]),
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** The native entry supplies a preflighted Pi child-session host for the active context. */
|
|
93
|
+
export function createDelegationExtension(
|
|
94
|
+
getHost,
|
|
95
|
+
{ root = process.cwd(), controllerOptions = {}, prepareContext = () => {}, presentation } = {},
|
|
96
|
+
) {
|
|
97
|
+
let currentPi;
|
|
98
|
+
let currentContext;
|
|
99
|
+
let toolsReady = false;
|
|
100
|
+
let bindingEpoch = 0;
|
|
101
|
+
let detach = () => {};
|
|
102
|
+
|
|
103
|
+
let requested = false;
|
|
104
|
+
let requestedGuard;
|
|
105
|
+
let boundHost;
|
|
106
|
+
let pending;
|
|
107
|
+
let pauseReason;
|
|
108
|
+
|
|
109
|
+
const getGuard = () => {
|
|
110
|
+
let replies = 0;
|
|
111
|
+
let mode;
|
|
112
|
+
currentPi?.events.emit("specpi:guard-state", {
|
|
113
|
+
reply(value) {
|
|
114
|
+
replies += 1;
|
|
115
|
+
mode = value?.mode;
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
if (replies === 0) {
|
|
120
|
+
return "absent";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return replies === 1 ? mode : "ambiguous";
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const controller = createDelegationController({
|
|
127
|
+
...controllerOptions,
|
|
128
|
+
getHost,
|
|
129
|
+
root,
|
|
130
|
+
getGuard,
|
|
131
|
+
onChange: updatePresentation,
|
|
132
|
+
});
|
|
133
|
+
const panel = presentation ? createLivePanel(() => controller.presentation(), presentation) : undefined;
|
|
134
|
+
|
|
135
|
+
const status = () => ({
|
|
136
|
+
...controller.status(),
|
|
137
|
+
requested,
|
|
138
|
+
updating: Boolean(pending),
|
|
139
|
+
pauseReason: pauseReason ?? null,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
function updatePresentation() {
|
|
143
|
+
const state = controller.status();
|
|
144
|
+
syncActiveTool(state.enabled);
|
|
145
|
+
currentContext?.ui?.setStatus?.(
|
|
146
|
+
"specpi-delegation",
|
|
147
|
+
state.enabled
|
|
148
|
+
? `Delegate ${state.active}/${state.limits.concurrency} workers · ${state.sessionCalls}/${state.limits.sessionCalls} calls`
|
|
149
|
+
: requested
|
|
150
|
+
? pending
|
|
151
|
+
? "Delegate updating model"
|
|
152
|
+
: "Delegate paused"
|
|
153
|
+
: undefined,
|
|
154
|
+
);
|
|
155
|
+
panel?.update();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function invalidate(reason) {
|
|
159
|
+
requested = false;
|
|
160
|
+
pending = undefined;
|
|
161
|
+
boundHost = undefined;
|
|
162
|
+
pauseReason = undefined;
|
|
163
|
+
controller.invalidate(reason);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function syncActiveTool(enabled) {
|
|
167
|
+
if (
|
|
168
|
+
!toolsReady ||
|
|
169
|
+
typeof currentPi?.getActiveTools !== "function" ||
|
|
170
|
+
typeof currentPi?.setActiveTools !== "function"
|
|
171
|
+
) {
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const active = currentPi.getActiveTools();
|
|
176
|
+
if (active.includes("delegate") !== enabled) {
|
|
177
|
+
currentPi.setActiveTools(enabled ? [...active, "delegate"] : active.filter((name) => name !== "delegate"));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const factory = (pi) => {
|
|
182
|
+
panel?.dispose();
|
|
183
|
+
detach();
|
|
184
|
+
bindingEpoch += 1;
|
|
185
|
+
const issuedEpoch = bindingEpoch;
|
|
186
|
+
const isBound = () => issuedEpoch === bindingEpoch;
|
|
187
|
+
toolsReady = false;
|
|
188
|
+
prepareContext(undefined, true);
|
|
189
|
+
invalidate("runtime factory rebound");
|
|
190
|
+
currentPi = pi;
|
|
191
|
+
currentContext = undefined;
|
|
192
|
+
const refreshSelection = async (ctx) => {
|
|
193
|
+
if (!isBound() || !requested) {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
currentContext = ctx;
|
|
198
|
+
if (getGuard() !== requestedGuard) {
|
|
199
|
+
invalidate("guard policy changed");
|
|
200
|
+
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let host;
|
|
205
|
+
try {
|
|
206
|
+
prepareContext(ctx);
|
|
207
|
+
host = getHost();
|
|
208
|
+
} catch (error) {
|
|
209
|
+
pending = undefined;
|
|
210
|
+
boundHost = undefined;
|
|
211
|
+
pauseReason = publicErrorMessage(error);
|
|
212
|
+
controller.invalidate("model setup failed");
|
|
213
|
+
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (boundHost === host && controller.status().enabled) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (pending && pending.host === host) {
|
|
222
|
+
return pending.promise;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const attempt = { host, promise: undefined };
|
|
226
|
+
pending = attempt;
|
|
227
|
+
boundHost = undefined;
|
|
228
|
+
pauseReason = undefined;
|
|
229
|
+
controller.invalidate("model or thinking level changed");
|
|
230
|
+
const isCurrent = () => isBound() && requested && pending === attempt;
|
|
231
|
+
attempt.promise = (async () => {
|
|
232
|
+
try {
|
|
233
|
+
await host?.ready?.();
|
|
234
|
+
if (!isCurrent()) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (getGuard() !== requestedGuard) {
|
|
239
|
+
invalidate("guard policy changed during model setup");
|
|
240
|
+
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (getHost() !== host) {
|
|
245
|
+
throw new DelegationError(
|
|
246
|
+
"Pi changed during model setup; delegation will refresh before the next turn.",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
controller.enable();
|
|
251
|
+
boundHost = host;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (isCurrent()) {
|
|
254
|
+
pauseReason = publicErrorMessage(error);
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
if (pending === attempt) {
|
|
258
|
+
pending = undefined;
|
|
259
|
+
updatePresentation();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
|
|
264
|
+
return attempt.promise;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const subscriptions = [];
|
|
268
|
+
detach = () => {
|
|
269
|
+
for (const off of subscriptions.splice(0)) {
|
|
270
|
+
off();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const subscribe = (name, handler) => {
|
|
275
|
+
const off = pi.events.on(name, (value) => {
|
|
276
|
+
if (isBound()) {
|
|
277
|
+
handler(value);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
if (typeof off === "function") {
|
|
281
|
+
subscriptions.push(off);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
subscribe("specpi:delegation-policy", (request) => {
|
|
286
|
+
try {
|
|
287
|
+
request.reply(controller.policyFor(request.input));
|
|
288
|
+
} catch {
|
|
289
|
+
request.reply(undefined);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
subscribe("specpi:guard-policy-changed", () => invalidate("guard policy changed"));
|
|
293
|
+
subscribe("specpi:task-contract-changed", (event) => {
|
|
294
|
+
if (event?.digest !== event?.previousDigest) {
|
|
295
|
+
invalidate("task contract changed");
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
let scopeBinding;
|
|
299
|
+
subscribe("specpi:workflow-status", (event) => {
|
|
300
|
+
const next = JSON.stringify({
|
|
301
|
+
active: event?.active,
|
|
302
|
+
generation: event?.generation,
|
|
303
|
+
taskStale: event?.taskStale,
|
|
304
|
+
});
|
|
305
|
+
if (scopeBinding !== undefined && scopeBinding !== next) {
|
|
306
|
+
invalidate("workflow scope changed");
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
scopeBinding = next;
|
|
310
|
+
});
|
|
311
|
+
for (const event of ["session_before_switch", "session_before_fork", "session_before_tree", "session_tree"]) {
|
|
312
|
+
pi.on(event, () => {
|
|
313
|
+
if (isBound()) {
|
|
314
|
+
invalidate(event);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const event of ["model_select", "thinking_level_select"]) {
|
|
320
|
+
pi.on(event, async (_event, ctx) => {
|
|
321
|
+
await refreshSelection(ctx);
|
|
322
|
+
if (isBound() && requested && pauseReason) {
|
|
323
|
+
ctx.ui.notify(`Delegation paused: ${pauseReason}`, "warning");
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
pi.on("session_start", (_event, ctx) => {
|
|
329
|
+
if (!isBound()) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
currentContext = ctx;
|
|
334
|
+
toolsReady = true;
|
|
335
|
+
panel?.bind(ctx);
|
|
336
|
+
invalidate("session started or resources reloaded");
|
|
337
|
+
try {
|
|
338
|
+
prepareContext(ctx, true);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
pauseReason = publicErrorMessage(error);
|
|
341
|
+
ctx.ui.notify(`Delegation unavailable: ${pauseReason}`, "warning");
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
pi.on("session_shutdown", () => {
|
|
345
|
+
if (!isBound()) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
invalidate("session shutdown");
|
|
350
|
+
panel?.dispose();
|
|
351
|
+
prepareContext(undefined, true);
|
|
352
|
+
detach();
|
|
353
|
+
currentContext = undefined;
|
|
354
|
+
toolsReady = false;
|
|
355
|
+
bindingEpoch += 1;
|
|
356
|
+
});
|
|
357
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
358
|
+
await refreshSelection(ctx);
|
|
359
|
+
if (isBound() && controller.status().enabled) {
|
|
360
|
+
return {
|
|
361
|
+
message: {
|
|
362
|
+
customType: "specpi-delegation-policy",
|
|
363
|
+
content:
|
|
364
|
+
"Delegation is enabled for independent review of frozen work or substantial selected-source analysis. Stay with one agent for small edits, routine searches, sequential debugging, unsettled interfaces or duplicate opinions. Prefer parallel tools for straightforward retrieval. Give each worker a distinct question and only its requirement IDs, relevant constraints and evidence, without your reasoning or verdict. Use one worker unless two questions are independently useful. State independent parent work only when claiming parallelism; a final review may wait. collect can wait up to 30000ms. Verify source references and resolve findings. You remain the sole writer; delegation is experimental and no SpecPi quality/cost improvement has been measured.",
|
|
365
|
+
display: false,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
pi.registerCommand("delegate", {
|
|
371
|
+
description: "Enable bounded read-only delegation or inspect its session limits",
|
|
372
|
+
getArgumentCompletions: (prefix) =>
|
|
373
|
+
["on", "off", "status", "limits", "cancel"]
|
|
374
|
+
.filter((value) => value.startsWith(prefix.trim()))
|
|
375
|
+
.map((value) => ({ value, label: value })),
|
|
376
|
+
handler: async (args, ctx) => {
|
|
377
|
+
if (!isBound()) {
|
|
378
|
+
ctx.ui.notify("Delegation was reloaded; use the current command.", "error");
|
|
379
|
+
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
currentContext = ctx;
|
|
384
|
+
const [action = "status", id, extra] = args.trim().split(/\s+/u).filter(Boolean);
|
|
385
|
+
try {
|
|
386
|
+
if (extra || (id && action !== "cancel")) {
|
|
387
|
+
throw new DelegationError("Usage: /delegate [on|off|status|limits|cancel <batchId>]");
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (action === "on") {
|
|
391
|
+
if (!ctx.hasUI) {
|
|
392
|
+
throw new DelegationError("Delegation activation requires a human interactive command");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
requested = true;
|
|
396
|
+
requestedGuard = getGuard();
|
|
397
|
+
await refreshSelection(ctx);
|
|
398
|
+
if (!isBound() || !requested) {
|
|
399
|
+
throw new DelegationError(
|
|
400
|
+
"Pi changed during delegation setup; enable delegation in the current session",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const state = controller.status();
|
|
405
|
+
if (!state.enabled) {
|
|
406
|
+
throw new DelegationError(pauseReason ?? "Delegation model setup is still pending.");
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
syncActiveTool(true);
|
|
410
|
+
ctx.ui.notify(
|
|
411
|
+
`Delegation enabled: Pi child sessions for review and scout, ${state.model.provider}/${state.model.id}, thinking ${state.model.thinkingLevel ?? "Pi configured"}. At most 2 workers, 4 batches and 32 SDK inference calls per Pi process; 120 seconds per job. Workers see only supplied text and selected snapshots. Pi owns configured authentication; temporary parent provider/auth overrides and parent hooks are not inherited. No shell, edits, recursion, automatic retries or compaction. /delegate off cancels workers; SDK requests still settling retain their slots. These limits do not guarantee remote termination or a billing cap.`,
|
|
412
|
+
"info",
|
|
413
|
+
);
|
|
414
|
+
} else if (action === "off") {
|
|
415
|
+
invalidate("disabled by human");
|
|
416
|
+
ctx.ui.notify(
|
|
417
|
+
"Delegation disabled. Existing requests are aborted; budgets remain consumed.",
|
|
418
|
+
"info",
|
|
419
|
+
);
|
|
420
|
+
} else if (action === "cancel" && id) {
|
|
421
|
+
await controller.execute({
|
|
422
|
+
operation: "cancel",
|
|
423
|
+
requestId: `human-${randomUUID()}`,
|
|
424
|
+
batchId: id,
|
|
425
|
+
});
|
|
426
|
+
ctx.ui.notify("Batch cancelled. Provider settlement may still be pending.", "info");
|
|
427
|
+
} else if (["status", "limits"].includes(action)) {
|
|
428
|
+
const state = status();
|
|
429
|
+
ctx.ui.notify(action === "limits" ? readableLimits(state) : readableStatus(state), "info");
|
|
430
|
+
} else {
|
|
431
|
+
throw new DelegationError("Usage: /delegate [on|off|status|limits|cancel <batchId>]");
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
ctx.ui.notify(publicErrorMessage(error), "error");
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
pi.registerTool({
|
|
439
|
+
name: "delegate",
|
|
440
|
+
label: "Delegate",
|
|
441
|
+
description:
|
|
442
|
+
"Delegate an independent frozen review or substantial selected-source analysis to a real Pi child session. Only after human /delegate on. review: check artifacts against assigned requirements in fresh context; scout: answer a distinct evidence question over selected sources. Prefer one worker and parent-only execution for small, sequential or routine work. No shell, edits or live web. run returns immediately; collect waits for advisory evidence, then resolve findings after verification. One changed-input follow_up shares the original budget/deadline. Never grants permission or proves task completion.",
|
|
443
|
+
parameters: DELEGATE_SCHEMA,
|
|
444
|
+
...(presentation ? createToolRenderers(presentation) : {}),
|
|
445
|
+
execute: async (_id, input, signal, _update, ctx) => {
|
|
446
|
+
try {
|
|
447
|
+
if (!isBound()) {
|
|
448
|
+
throw new DelegationError("Delegation was reloaded; use the current tool");
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
currentContext = ctx;
|
|
452
|
+
prepareContext(ctx);
|
|
453
|
+
const result = await controller.execute(input, signal);
|
|
454
|
+
const output = input.operation === "status" ? status() : result;
|
|
455
|
+
|
|
456
|
+
return { content: [{ type: "text", text: JSON.stringify(output) }], details: output };
|
|
457
|
+
} catch (error) {
|
|
458
|
+
return {
|
|
459
|
+
content: [{ type: "text", text: `Delegation: ${publicErrorMessage(error)}` }],
|
|
460
|
+
details: { error: true },
|
|
461
|
+
isError: true,
|
|
462
|
+
};
|
|
463
|
+
} finally {
|
|
464
|
+
try {
|
|
465
|
+
updatePresentation();
|
|
466
|
+
} catch {
|
|
467
|
+
// Optional terminal rendering cannot change a tool's outcome.
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
return factory;
|
|
475
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import * as sdk from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
import { registerNativeDelegation, withPiCompatibility } from "./native.mjs";
|
|
5
|
+
|
|
6
|
+
export default async function (pi: ExtensionAPI) {
|
|
7
|
+
const compatibleSdk = await withPiCompatibility(sdk, () => import("@earendil-works/pi-ai/compat"));
|
|
8
|
+
registerNativeDelegation(pi, compatibleSdk, { truncateToWidth, wrapTextWithAnsi });
|
|
9
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const DELEGATION_MANAGED_FILES = Object.freeze([
|
|
2
|
+
"index.ts",
|
|
3
|
+
"native.mjs",
|
|
4
|
+
"extension.mjs",
|
|
5
|
+
"core.mjs",
|
|
6
|
+
"protocol.mjs",
|
|
7
|
+
"provider.mjs",
|
|
8
|
+
"snapshot.mjs",
|
|
9
|
+
"worker.mjs",
|
|
10
|
+
"errors.mjs",
|
|
11
|
+
"presentation.mjs",
|
|
12
|
+
"managed-files.mjs",
|
|
13
|
+
]);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { createDelegationExtension } from "./extension.mjs";
|
|
5
|
+
import { createNativePiHost, getPiSessionCompatibilityError } from "./provider.mjs";
|
|
6
|
+
import { DelegationError } from "./errors.mjs";
|
|
7
|
+
|
|
8
|
+
const stateKey = Symbol.for("specpi.delegation.native.v1");
|
|
9
|
+
const revision = 7;
|
|
10
|
+
|
|
11
|
+
export async function withPiCompatibility(sdk, loadCompatibility) {
|
|
12
|
+
if (typeof sdk.clampThinkingLevel === "function") {
|
|
13
|
+
return sdk;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let clampThinkingLevel;
|
|
17
|
+
try {
|
|
18
|
+
({ clampThinkingLevel } = await loadCompatibility());
|
|
19
|
+
} catch {
|
|
20
|
+
// Register the command even if this optional public subpath was removed.
|
|
21
|
+
// The capability check supplies a controlled diagnostic on activation.
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return { ...sdk, clampThinkingLevel };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function canonical(directory) {
|
|
28
|
+
return fs.realpathSync.native(path.resolve(directory));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sameRoot(left, right) {
|
|
32
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function createState(root, sdk, presentation) {
|
|
36
|
+
let getThinkingLevel = () => undefined;
|
|
37
|
+
let host;
|
|
38
|
+
let epoch = 0;
|
|
39
|
+
let boundRegistry;
|
|
40
|
+
let boundModel;
|
|
41
|
+
let boundThinking;
|
|
42
|
+
let available = false;
|
|
43
|
+
const matchesRoot = (context) => {
|
|
44
|
+
try {
|
|
45
|
+
return typeof context?.cwd === "string" && sameRoot(canonical(context.cwd), root);
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const prepareContext = (context, reset = false) => {
|
|
52
|
+
if (reset) {
|
|
53
|
+
epoch += 1;
|
|
54
|
+
host = undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!context) {
|
|
58
|
+
epoch += 1;
|
|
59
|
+
host = undefined;
|
|
60
|
+
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const thinkingLevel = getThinkingLevel();
|
|
65
|
+
let compatibilityError;
|
|
66
|
+
if (!matchesRoot(context)) {
|
|
67
|
+
compatibilityError =
|
|
68
|
+
"Pi's working directory differs from delegation's startup directory. Restart Pi in the intended working directory.";
|
|
69
|
+
} else if (!context.model) {
|
|
70
|
+
compatibilityError = "Select a Pi model before enabling delegation.";
|
|
71
|
+
} else if (
|
|
72
|
+
typeof context.modelRegistry?.getProviderAuthStatus !== "function" ||
|
|
73
|
+
typeof context.modelRegistry?.getRegisteredProviderIds !== "function"
|
|
74
|
+
) {
|
|
75
|
+
compatibilityError =
|
|
76
|
+
"Pi's model registry is missing the provider metadata APIs required for delegation. Update Pi or SpecPi and restart Pi.";
|
|
77
|
+
} else {
|
|
78
|
+
compatibilityError = getPiSessionCompatibilityError(sdk);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (
|
|
82
|
+
!compatibilityError &&
|
|
83
|
+
available &&
|
|
84
|
+
host?.isCurrent() &&
|
|
85
|
+
boundRegistry === context.modelRegistry &&
|
|
86
|
+
boundModel === context.model &&
|
|
87
|
+
boundThinking === thinkingLevel
|
|
88
|
+
) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
epoch += 1;
|
|
93
|
+
const issuedEpoch = epoch;
|
|
94
|
+
boundRegistry = context.modelRegistry;
|
|
95
|
+
boundModel = context.model;
|
|
96
|
+
boundThinking = thinkingLevel;
|
|
97
|
+
const boundCwd = context.cwd;
|
|
98
|
+
const id = randomUUID();
|
|
99
|
+
const isCurrent = (streaming = false) =>
|
|
100
|
+
epoch === issuedEpoch &&
|
|
101
|
+
context.cwd === boundCwd &&
|
|
102
|
+
context.modelRegistry === boundRegistry &&
|
|
103
|
+
context.model === boundModel &&
|
|
104
|
+
getThinkingLevel() === thinkingLevel &&
|
|
105
|
+
(streaming || matchesRoot(context));
|
|
106
|
+
available = !compatibilityError;
|
|
107
|
+
if (compatibilityError) {
|
|
108
|
+
const unavailable = async () => {
|
|
109
|
+
throw new DelegationError(compatibilityError);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
host = Object.freeze({
|
|
113
|
+
id,
|
|
114
|
+
isCurrent,
|
|
115
|
+
model: Object.freeze({ id: context.model?.id, provider: context.model?.provider, thinkingLevel }),
|
|
116
|
+
ready: unavailable,
|
|
117
|
+
openSession: unavailable,
|
|
118
|
+
});
|
|
119
|
+
} else {
|
|
120
|
+
host = createNativePiHost(context, { id, isCurrent, sdk, thinkingLevel });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const extensionFactory = createDelegationExtension(() => host, { root, prepareContext, presentation });
|
|
125
|
+
const factory = (pi) => {
|
|
126
|
+
getThinkingLevel = () => pi.getThinkingLevel();
|
|
127
|
+
extensionFactory(pi);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return Object.freeze({ revision, root, factory });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Pi reloads extension code, so process memory retains the original controller and
|
|
135
|
+
* pending SDK requests. A reload cannot reset quotas or free occupied slots.
|
|
136
|
+
* Restart Pi to load a different runtime revision or change the working root.
|
|
137
|
+
* Trusted extensions share this process; this is not a security boundary against them.
|
|
138
|
+
*/
|
|
139
|
+
export function registerNativeDelegation(pi, sdk, presentation) {
|
|
140
|
+
const root = canonical(process.cwd());
|
|
141
|
+
let state = globalThis[stateKey];
|
|
142
|
+
if (state === undefined) {
|
|
143
|
+
state = createState(root, sdk, presentation);
|
|
144
|
+
Object.defineProperty(globalThis, stateKey, { value: state });
|
|
145
|
+
} else if (
|
|
146
|
+
state.revision !== revision ||
|
|
147
|
+
typeof state.factory !== "function" ||
|
|
148
|
+
typeof state.root !== "string" ||
|
|
149
|
+
!sameRoot(state.root, root)
|
|
150
|
+
) {
|
|
151
|
+
throw new DelegationError("Restart Pi before changing the delegation runtime or working directory");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
state.factory(pi);
|
|
155
|
+
}
|