specpi 0.11.2 → 0.13.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +21 -1
  2. package/NPM_RELEASE.md +3 -1
  3. package/README.md +41 -29
  4. package/SECURITY_MODEL.md +36 -0
  5. package/THIRD_PARTY.md +18 -2
  6. package/docs/browser-testing.md +76 -0
  7. package/docs/delegation/README.md +264 -0
  8. package/docs/delegation/design-protocol.md +382 -0
  9. package/docs/delegation/design.md +525 -0
  10. package/docs/delegation/evaluation.md +307 -0
  11. package/docs/delegation/protocol.md +271 -0
  12. package/docs/delegation/research.md +216 -0
  13. package/extensions/browser/core.d.mts +64 -0
  14. package/extensions/browser/diagnostics.ts +275 -0
  15. package/extensions/browser/index.ts +349 -57
  16. package/extensions/browser/interactions.ts +118 -0
  17. package/extensions/browser/lifecycle.ts +28 -0
  18. package/extensions/command-guard/index.ts +118 -33
  19. package/extensions/delegation/core.mjs +772 -0
  20. package/extensions/delegation/errors.mjs +8 -0
  21. package/extensions/delegation/extension.mjs +475 -0
  22. package/extensions/delegation/index.ts +9 -0
  23. package/extensions/delegation/managed-files.mjs +13 -0
  24. package/extensions/delegation/native.mjs +155 -0
  25. package/extensions/delegation/presentation.mjs +315 -0
  26. package/extensions/delegation/protocol.mjs +296 -0
  27. package/extensions/delegation/provider.mjs +689 -0
  28. package/extensions/delegation/snapshot.mjs +532 -0
  29. package/extensions/delegation/worker.mjs +218 -0
  30. package/extensions/tool-wishlist/verification.mjs +1 -0
  31. package/extensions/workflow-controls/index.ts +5 -1
  32. package/package.json +17 -4
  33. package/scripts/check-package.mjs +29 -3
  34. package/scripts/check-pi-package.mjs +5 -0
  35. package/scripts/check-syntax.mjs +61 -0
  36. package/scripts/run-browser-tests.mjs +61 -0
  37. package/scripts/setup-browser-tests.mjs +38 -0
  38. package/scripts/site-browser.mjs +272 -0
  39. package/scripts/specpi.mjs +24 -0
  40. package/site/logo.svg +1 -9
  41. package/templates/AGENTS.md +1 -0
@@ -0,0 +1,772 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createSnapshot } from "./snapshot.mjs";
3
+ import { LIMITS, digest, validateOperation } from "./protocol.mjs";
4
+ import { runWorker } from "./worker.mjs";
5
+ import { DelegationError, publicErrorMessage } from "./errors.mjs";
6
+
7
+ const terminal = new Set(["complete", "partial", "needs_context", "failed", "cancelled", "expired", "stale"]);
8
+ const quiet = new Set(["cancelled", "expired", "stale"]);
9
+
10
+ /** One controller lives for the Pi process, including native extension reloads. */
11
+ export function createDelegationController({
12
+ getHost,
13
+ root,
14
+ onChange = () => {},
15
+ limits = LIMITS,
16
+ snapshotFactory = createSnapshot,
17
+ worker = runWorker,
18
+ getGuard = () => "absent",
19
+ }) {
20
+ const policy = Object.freeze(
21
+ Object.fromEntries(
22
+ Object.entries(LIMITS).map(([key, maximum]) => {
23
+ const value = limits[key] ?? maximum;
24
+ if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
25
+ throw new DelegationError("Limits may only lower the fixed delegation ceilings");
26
+ }
27
+
28
+ return [key, value];
29
+ }),
30
+ ),
31
+ );
32
+ let enabled = false;
33
+ let generation = 0;
34
+ let enabledHost;
35
+ let enabledGuard;
36
+ let active = 0;
37
+ let totalCalls = 0;
38
+ let totalBatches = 0;
39
+ let currentBatch;
40
+ const batches = new Map();
41
+ const history = new Map();
42
+ const requests = new Map();
43
+ const recentRequests = new Map();
44
+ const listeners = new Set();
45
+ const changed = () => {
46
+ for (const notify of listeners) {
47
+ notify();
48
+ }
49
+
50
+ try {
51
+ onChange();
52
+ } catch {
53
+ // An optional UI notification never changes resource ownership.
54
+ }
55
+ };
56
+
57
+ const summary = (batch) => ({
58
+ batchId: batch.id,
59
+ generation: batch.generation,
60
+ packetDigest: batch.packetDigest,
61
+ collectionCursor: batch.cursor,
62
+ calls: batch.calls,
63
+ jobs: [...batch.jobs.values()].map((job) => ({
64
+ jobId: job.spec.id,
65
+ state: job.state,
66
+ settling: job.settling,
67
+ calls: job.calls,
68
+ resultRevision: job.revision,
69
+ disposition: job.disposition?.decision ?? null,
70
+ })),
71
+ });
72
+ const status = () => ({
73
+ enabled,
74
+ policy: "bounded-pi-sessions-v1",
75
+ generation,
76
+ active,
77
+ limits: policy,
78
+ sessionCalls: totalCalls,
79
+ sessionBatches: totalBatches,
80
+ guard: getGuard() ?? "unavailable",
81
+ model: enabledHost?.model ?? null,
82
+ cost: "unavailable; no invoice cap",
83
+ batches: [...[...history.values()].map((item) => structuredClone(item)), ...[...batches.values()].map(summary)],
84
+ });
85
+ // Presentation reads counters only: no source checks, provider queries or retained worker text.
86
+ const presentation = () => ({
87
+ enabled,
88
+ active,
89
+ concurrency: policy.concurrency,
90
+ calls: totalCalls,
91
+ callLimit: policy.sessionCalls,
92
+ jobs: [...batches.values()].flatMap((batch) =>
93
+ [...batch.jobs.values()]
94
+ .filter((job) => job.settling || (!batch.retired && !quiet.has(job.state) && !job.disposition))
95
+ .map((job) => ({
96
+ id: job.spec.id,
97
+ mode: job.mode,
98
+ state: job.state,
99
+ settling: job.settling,
100
+ calls: job.calls,
101
+ tools: job.toolCalls,
102
+ elapsedMs: Math.max(
103
+ 0,
104
+ (job.settling ? Date.now() : (job.finishedAt ?? Date.now())) - job.startedAt,
105
+ ),
106
+ })),
107
+ ),
108
+ });
109
+ const finish = (batch, job, state, result, error) => {
110
+ if (terminal.has(job.state)) {
111
+ return;
112
+ }
113
+
114
+ job.state = state;
115
+ job.finishedAt = Date.now();
116
+ job.result = result;
117
+ job.error = error;
118
+ job.revision += 1;
119
+ job.cursor = ++batch.cursor;
120
+ changed();
121
+ };
122
+
123
+ const releaseJob = (job) => {
124
+ const release = job.release;
125
+ job.release = undefined;
126
+ job.child = undefined;
127
+ try {
128
+ // Cleanup is best effort, including an unexpected async SDK teardown.
129
+ // This never releases the slot: original worker settlement owns that.
130
+ void Promise.resolve(release?.()).catch(() => {});
131
+ } catch {
132
+ // Do not expose or retain SDK teardown errors.
133
+ }
134
+ };
135
+
136
+ const needsInput = (batch, job) =>
137
+ !batch.retired &&
138
+ !quiet.has(job.state) &&
139
+ !job.disposition &&
140
+ Date.now() < Math.min(job.deadline, batch.deadline) &&
141
+ (!terminal.has(job.state) || job.followUps < 1);
142
+
143
+ const cleanupBatch = (batch) => {
144
+ if (batch.archived) {
145
+ return;
146
+ }
147
+
148
+ for (const job of batch.jobs.values()) {
149
+ if (!needsInput(batch, job)) {
150
+ if (!terminal.has(job.state)) {
151
+ const state = batch.retired ? "stale" : "expired";
152
+ finish(batch, job, state, undefined, state);
153
+ job.controller?.abort();
154
+ }
155
+
156
+ clearTimeout(job.timer);
157
+ releaseJob(job);
158
+ if (!job.settling) {
159
+ job.spec = { id: job.spec.id };
160
+ job.followUpPrompt = undefined;
161
+ job.controller = undefined;
162
+ }
163
+ }
164
+ }
165
+
166
+ if ([...batch.jobs.values()].some((job) => needsInput(batch, job))) {
167
+ return;
168
+ }
169
+
170
+ if (!batch.inputClosed) {
171
+ batch.inputClosed = true;
172
+ batch.snapshot.destroy();
173
+ }
174
+
175
+ if ([...batch.jobs.values()].some((job) => job.settling)) {
176
+ return;
177
+ }
178
+
179
+ batch.packet = undefined;
180
+ batch.host = undefined;
181
+ if (batch.retired) {
182
+ batch.archived = true;
183
+ history.set(batch.id, { ...summary(batch), retired: true });
184
+ batches.delete(batch.id);
185
+ batch.snapshot = undefined;
186
+ for (const job of batch.jobs.values()) {
187
+ job.result = undefined;
188
+ job.disposition = undefined;
189
+ }
190
+ }
191
+ };
192
+
193
+ const cancelJob = (batch, job, state = "cancelled") => {
194
+ if (!terminal.has(job.state)) {
195
+ finish(batch, job, state, undefined, state);
196
+ } else if (!quiet.has(job.state) && !job.disposition) {
197
+ job.state = state;
198
+ job.result = undefined;
199
+ job.error = state;
200
+ job.revision += 1;
201
+ job.cursor = ++batch.cursor;
202
+ }
203
+
204
+ job.controller?.abort();
205
+ releaseJob(job);
206
+ cleanupBatch(batch);
207
+ changed();
208
+ };
209
+
210
+ const invalidate = (reason = "context changed") => {
211
+ enabled = false;
212
+ generation += 1;
213
+ for (const batch of batches.values()) {
214
+ batch.retired = true;
215
+ for (const job of batch.jobs.values()) {
216
+ cancelJob(batch, job, "stale");
217
+ }
218
+ }
219
+
220
+ enabledHost = undefined;
221
+ currentBatch = undefined;
222
+ changed();
223
+
224
+ return { enabled: false, generation, reason };
225
+ };
226
+
227
+ const assertLive = (batch, streaming = false) => {
228
+ const guard = getGuard();
229
+ if (
230
+ !enabled ||
231
+ !enabledHost?.isCurrent(streaming) ||
232
+ getHost()?.id !== enabledHost.id ||
233
+ guard !== enabledGuard ||
234
+ guard === "locked"
235
+ ) {
236
+ if (enabled) {
237
+ invalidate("session, model, or guard changed");
238
+ }
239
+
240
+ throw new DelegationError("Delegation is disabled or its host lease is stale");
241
+ }
242
+
243
+ if (batch && batch.generation !== generation) {
244
+ throw new DelegationError("Delegation result belongs to a stale generation");
245
+ }
246
+ };
247
+
248
+ const checkSources = (batch) => {
249
+ try {
250
+ batch.snapshot.assertFresh();
251
+ } catch {
252
+ for (const job of batch.jobs.values()) {
253
+ cancelJob(batch, job, "stale");
254
+ }
255
+
256
+ throw new DelegationError("Selected source bindings changed; create a fresh batch");
257
+ }
258
+ };
259
+
260
+ const findBatch = (id) => {
261
+ const batch = batches.get(id);
262
+ if (!batch) {
263
+ throw new DelegationError(
264
+ history.has(id) ? "Delegation batch was retired; its generation is stale" : "Unknown delegation batch",
265
+ );
266
+ }
267
+
268
+ return batch;
269
+ };
270
+
271
+ const binding = (batch, job) => ({
272
+ batchId: batch.id,
273
+ jobId: job.spec.id,
274
+ attemptId: job.attemptId,
275
+ packetDigest: batch.packetDigest,
276
+ generation: batch.generation,
277
+ resultRevision: job.revision,
278
+ });
279
+ const findBoundJob = (input) => {
280
+ const batch = findBatch(input.batchId);
281
+ assertLive(batch);
282
+ checkSources(batch);
283
+ const job = batch.jobs.get(input.jobId);
284
+ if (
285
+ !job ||
286
+ !terminal.has(job.state) ||
287
+ Object.entries(binding(batch, job)).some(([key, value]) => input[key] !== value)
288
+ ) {
289
+ throw new DelegationError("Delegation receipt is missing or stale");
290
+ }
291
+
292
+ return { batch, job };
293
+ };
294
+
295
+ const pump = () => {
296
+ for (const batch of batches.values()) {
297
+ for (const job of batch.jobs.values()) {
298
+ if (active >= policy.concurrency || job.state !== "queued") {
299
+ continue;
300
+ }
301
+
302
+ if (Date.now() >= job.deadline || Date.now() >= batch.deadline) {
303
+ cancelJob(batch, job, "expired");
304
+ continue;
305
+ }
306
+
307
+ try {
308
+ assertLive(batch);
309
+ checkSources(batch);
310
+ } catch {
311
+ cancelJob(batch, job, "stale");
312
+ continue;
313
+ }
314
+
315
+ job.state = "running";
316
+ job.settling = true;
317
+ job.controller = new AbortController();
318
+ active += 1;
319
+ const assertJobLive = (streaming = false) => {
320
+ assertLive(batch, streaming);
321
+ if (
322
+ terminal.has(job.state) ||
323
+ job.controller.signal.aborted ||
324
+ Date.now() >= job.deadline ||
325
+ Date.now() >= batch.deadline
326
+ ) {
327
+ throw new DelegationError("Worker lease expired or was revoked");
328
+ }
329
+ };
330
+
331
+ const execution = async () => {
332
+ try {
333
+ const result = await worker({
334
+ packet: batch.packet,
335
+ job,
336
+ host: batch.host,
337
+ snapshot: batch.snapshot,
338
+ signal: job.controller.signal,
339
+ abort: () => job.controller.abort(),
340
+ assertLive: assertJobLive,
341
+ limits: policy,
342
+ admitCall: () => {
343
+ assertJobLive();
344
+ if (
345
+ job.calls >= policy.jobCalls ||
346
+ batch.calls >= policy.batchCalls ||
347
+ totalCalls >= policy.sessionCalls
348
+ ) {
349
+ throw new DelegationError("Delegation model-call allowance exhausted");
350
+ }
351
+
352
+ job.calls += 1;
353
+ batch.calls += 1;
354
+ totalCalls += 1;
355
+ },
356
+ onUsage: (usage) => {
357
+ for (const key of Object.keys(job.usage)) {
358
+ const number = usage?.[key];
359
+ if (
360
+ Number.isSafeInteger(number) &&
361
+ number >= 0 &&
362
+ Number.isSafeInteger(job.usage[key] + number)
363
+ ) {
364
+ job.usage[key] += number;
365
+ job.usageReportedCalls[key] += 1;
366
+ }
367
+ }
368
+ },
369
+ });
370
+ assertJobLive();
371
+ checkSources(batch);
372
+ finish(batch, job, result.status, result);
373
+ } catch {
374
+ // Provider errors can contain URLs, credentials, or user content. Do not return them.
375
+ finish(
376
+ batch,
377
+ job,
378
+ "failed",
379
+ undefined,
380
+ "Worker failed or exhausted a bound; inspect status and use one changed-input follow-up if appropriate.",
381
+ );
382
+ } finally {
383
+ job.controller.abort();
384
+ job.settling = false;
385
+ if (!job.result || quiet.has(job.state)) {
386
+ releaseJob(job);
387
+ }
388
+
389
+ active -= 1;
390
+ cleanupBatch(batch);
391
+ changed();
392
+ pump();
393
+ }
394
+ };
395
+
396
+ void execution();
397
+ }
398
+ }
399
+ };
400
+
401
+ const armDeadline = (batch, job) => {
402
+ clearTimeout(job.timer);
403
+ job.timer = setTimeout(
404
+ () => {
405
+ if (Date.now() < Math.min(job.deadline, batch.deadline)) {
406
+ armDeadline(batch, job);
407
+
408
+ return;
409
+ }
410
+
411
+ if (terminal.has(job.state) && !job.settling) {
412
+ releaseJob(job);
413
+ } else {
414
+ cancelJob(batch, job, "expired");
415
+ }
416
+
417
+ cleanupBatch(batch);
418
+ pump();
419
+ },
420
+ Math.max(1, Math.min(job.deadline, batch.deadline) - Date.now()),
421
+ );
422
+ job.timer.unref?.();
423
+ };
424
+
425
+ const run = (input) => {
426
+ assertLive();
427
+ if (
428
+ totalBatches >= policy.sessionBatches ||
429
+ (currentBatch && [...currentBatch.jobs.values()].some((job) => !quiet.has(job.state) && !job.disposition))
430
+ ) {
431
+ throw new DelegationError("Finish or cancel the active batch; session batch limits do not reset");
432
+ }
433
+
434
+ // validateOperation already validated and cloned the complete handoff.
435
+ const packet = input.packet;
436
+ if (packet.jobs.length > policy.batchJobs) {
437
+ throw new DelegationError("Delegation batch job allowance exhausted");
438
+ }
439
+
440
+ const paths = [...new Set(packet.jobs.flatMap((job) => job.sources))];
441
+ const snapshot = snapshotFactory(root, paths);
442
+ const now = Date.now();
443
+ const batch = {
444
+ id: randomUUID(),
445
+ generation,
446
+ packet,
447
+ packetDigest: digest({
448
+ packet,
449
+ sources: snapshot.sources,
450
+ generation,
451
+ hostId: enabledHost.id,
452
+ model: enabledHost.model,
453
+ policy,
454
+ }),
455
+ snapshot,
456
+ host: enabledHost,
457
+ model: enabledHost.model,
458
+ deadline: now + policy.batchMs,
459
+ calls: 0,
460
+ cursor: 0,
461
+ jobs: new Map(),
462
+ };
463
+ for (const spec of packet.jobs) {
464
+ const job = {
465
+ spec,
466
+ mode: spec.mode,
467
+ startedAt: now,
468
+ finishedAt: undefined,
469
+ state: "queued",
470
+ settling: false,
471
+ calls: 0,
472
+ toolCalls: 0,
473
+ toolBytes: 0,
474
+ followUps: 0,
475
+ revision: 0,
476
+ cursor: 0,
477
+ deadline: now + policy.jobMs,
478
+ attemptId: randomUUID(),
479
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
480
+ usageReportedCalls: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
481
+ };
482
+ batch.jobs.set(spec.id, job);
483
+ armDeadline(batch, job);
484
+ }
485
+
486
+ if (currentBatch) {
487
+ currentBatch.retired = true;
488
+ cleanupBatch(currentBatch);
489
+ }
490
+
491
+ batches.set(batch.id, batch);
492
+ totalBatches += 1;
493
+ currentBatch = batch;
494
+ pump();
495
+
496
+ return summary(batch);
497
+ };
498
+
499
+ const mutate = (input) => {
500
+ if (input.operation === "run") {
501
+ return run(input);
502
+ }
503
+
504
+ if (input.operation === "cancel") {
505
+ const batch = findBatch(input.batchId);
506
+ if (input.jobId && !batch.jobs.has(input.jobId)) {
507
+ throw new DelegationError("Unknown delegation job");
508
+ }
509
+
510
+ for (const job of batch.jobs.values()) {
511
+ if (!input.jobId || input.jobId === job.spec.id) {
512
+ cancelJob(batch, job);
513
+ }
514
+ }
515
+
516
+ pump();
517
+
518
+ return summary(batch);
519
+ }
520
+
521
+ const { batch, job } = findBoundJob(input);
522
+ if (input.operation === "follow_up") {
523
+ if (
524
+ job.settling ||
525
+ job.followUps >= 1 ||
526
+ job.disposition ||
527
+ quiet.has(job.state) ||
528
+ Date.now() >= Math.min(job.deadline, batch.deadline)
529
+ ) {
530
+ throw new DelegationError("Follow-up is unavailable for this receipt");
531
+ }
532
+
533
+ job.followUps += 1;
534
+ job.followUpPrompt = input.prompt;
535
+ job.attemptId = randomUUID();
536
+ job.state = "queued";
537
+ job.finishedAt = undefined;
538
+ job.result = undefined;
539
+ job.error = undefined;
540
+ armDeadline(batch, job);
541
+ pump();
542
+
543
+ return summary(batch);
544
+ }
545
+
546
+ if (job.disposition) {
547
+ throw new DelegationError("This result already has a final parent disposition");
548
+ }
549
+
550
+ const expected = job.result?.findings.map((finding) => finding.id) ?? [];
551
+ const supplied = input.findings.map((finding) => finding.id);
552
+ if (
553
+ supplied.length !== expected.length ||
554
+ new Set(supplied).size !== expected.length ||
555
+ supplied.some((id) => !expected.includes(id))
556
+ ) {
557
+ throw new DelegationError("Disposition must account for each returned finding exactly once");
558
+ }
559
+
560
+ if (
561
+ input.decision === "accept" &&
562
+ (!job.result || input.findings.some((finding) => finding.decision === "needs_check"))
563
+ ) {
564
+ throw new DelegationError("Acceptance requires a result and checked finding dispositions");
565
+ }
566
+
567
+ const disposition = {
568
+ decision: input.decision,
569
+ findings: input.findings,
570
+ authority: "parent assessment only; not human approval or task verification",
571
+ };
572
+ if (input.decision !== "needs_check") {
573
+ job.disposition = disposition;
574
+ releaseJob(job);
575
+ cleanupBatch(batch);
576
+ }
577
+
578
+ return { ...binding(batch, job), disposition };
579
+ };
580
+
581
+ const execute = async (raw, signal) => {
582
+ const input = validateOperation(raw);
583
+ if (input.operation === "status") {
584
+ if (enabled) {
585
+ try {
586
+ assertLive();
587
+ } catch {
588
+ // Status remains available after invalidation.
589
+ }
590
+ }
591
+
592
+ return status();
593
+ }
594
+
595
+ if (input.operation === "collect") {
596
+ const batch = findBatch(input.batchId);
597
+ assertLive(batch);
598
+ checkSources(batch);
599
+ const cursor = input.afterCursor ?? 0;
600
+ if (cursor > batch.cursor) {
601
+ throw new DelegationError("Collection cursor is ahead of the batch");
602
+ }
603
+
604
+ if (
605
+ input.waitMs &&
606
+ batch.cursor === cursor &&
607
+ [...batch.jobs.values()].some((job) => !terminal.has(job.state))
608
+ ) {
609
+ await new Promise((resolve) => {
610
+ let timer;
611
+ const done = () => {
612
+ clearTimeout(timer);
613
+ listeners.delete(done);
614
+ signal?.removeEventListener("abort", done);
615
+ resolve();
616
+ };
617
+
618
+ listeners.add(done);
619
+ timer = setTimeout(done, input.waitMs);
620
+ signal?.addEventListener("abort", done, { once: true });
621
+ if (signal?.aborted) {
622
+ done();
623
+ }
624
+ });
625
+ }
626
+
627
+ assertLive(batch);
628
+ checkSources(batch);
629
+
630
+ return structuredClone({
631
+ ...summary(batch),
632
+ results: [...batch.jobs.values()]
633
+ .filter((job) => terminal.has(job.state) && job.cursor > cursor)
634
+ .map((job) => ({
635
+ receipt: {
636
+ ...binding(batch, job),
637
+ model: batch.model,
638
+ state: job.state,
639
+ settling: job.settling,
640
+ calls: job.calls,
641
+ toolCalls: job.toolCalls,
642
+ toolBytes: job.toolBytes,
643
+ usage: Object.fromEntries(
644
+ Object.entries(job.usage).map(([key, value]) => [
645
+ key,
646
+ job.usageReportedCalls[key] ? value : null,
647
+ ]),
648
+ ),
649
+ usageReportedCalls: job.usageReportedCalls,
650
+ usageComplete:
651
+ job.calls > 0 &&
652
+ Object.values(job.usageReportedCalls).every((count) => count === job.calls),
653
+ cost: null,
654
+ },
655
+ result: job.result ?? null,
656
+ error: job.error ?? null,
657
+ disposition: job.disposition ?? null,
658
+ })),
659
+ });
660
+ }
661
+
662
+ const fingerprint = digest(input);
663
+ const previous = requests.get(input.requestId) ?? recentRequests.get(input.requestId);
664
+ if (previous) {
665
+ if (previous.fingerprint !== fingerprint) {
666
+ throw new DelegationError("A request identifier cannot be reused with a different payload");
667
+ }
668
+
669
+ if (input.operation !== "cancel") {
670
+ assertLive();
671
+ if (previous.generation !== generation) {
672
+ throw new DelegationError("Idempotent request belongs to a stale generation");
673
+ }
674
+
675
+ const batchId = input.batchId ?? previous.value?.batchId;
676
+ if (batchId) {
677
+ checkSources(findBatch(batchId));
678
+ }
679
+ }
680
+
681
+ return structuredClone(previous.value);
682
+ }
683
+
684
+ const entry = { fingerprint, generation };
685
+ try {
686
+ entry.value = mutate(input);
687
+ // Spending and final-disposition receipts fit the fixed batch/job ceilings
688
+ // (at most 4 runs + 8 follow-ups + 8 final dispositions). Never evict them.
689
+ if (input.operation === "cancel" || (input.operation === "resolve" && input.decision === "needs_check")) {
690
+ recentRequests.set(input.requestId, entry);
691
+ if (recentRequests.size > 128) {
692
+ recentRequests.delete(recentRequests.keys().next().value);
693
+ }
694
+ } else {
695
+ requests.set(input.requestId, entry);
696
+ }
697
+
698
+ return structuredClone(entry.value);
699
+ } catch (error) {
700
+ // Failed requests reserve neither a key nor journal capacity.
701
+ throw new DelegationError(publicErrorMessage(error));
702
+ }
703
+ };
704
+
705
+ return {
706
+ execute,
707
+ status,
708
+ presentation,
709
+ invalidate,
710
+ enable() {
711
+ const host = getHost();
712
+ const guard = getGuard();
713
+ if (!host) {
714
+ throw new DelegationError(
715
+ "Delegation has no Pi child-session host. Select a model before enabling delegation.",
716
+ );
717
+ }
718
+
719
+ if (!host.isCurrent()) {
720
+ throw new DelegationError(
721
+ "The Pi model or working directory changed during delegation setup. Restart Pi in the intended working directory.",
722
+ );
723
+ }
724
+
725
+ if (guard === "locked") {
726
+ throw new DelegationError(
727
+ "Command Guard is locked. Review /guard status and unlock it before delegating.",
728
+ );
729
+ }
730
+
731
+ if (guard === "ambiguous") {
732
+ throw new DelegationError(
733
+ "Multiple Command Guard instances replied. Load only one instance and restart Pi.",
734
+ );
735
+ }
736
+
737
+ if (!["absent", "off", "guard", "strict"].includes(guard)) {
738
+ throw new DelegationError(
739
+ "Command Guard has not reported a ready policy. Check /guard status and its startup errors.",
740
+ );
741
+ }
742
+
743
+ if (enabled) {
744
+ assertLive();
745
+
746
+ return status();
747
+ }
748
+
749
+ enabledHost = host;
750
+ enabledGuard = guard;
751
+ enabled = true;
752
+ generation += 1;
753
+
754
+ return status();
755
+ },
756
+ policyFor(raw) {
757
+ const input = validateOperation(raw);
758
+
759
+ return {
760
+ fingerprint: digest({
761
+ input,
762
+ generation,
763
+ enabled,
764
+ model: enabledHost?.model ?? null,
765
+ policy,
766
+ inference: "pi-agent-session-v1",
767
+ }),
768
+ summary: `Delegation ${input.operation}; bounded Pi sessions; enabled=${enabled}; generation=${generation}; selected model ${enabledHost?.model?.provider ?? "unavailable"}/${enabledHost?.model?.id ?? "unavailable"}; up to ${policy.concurrency} read-only workers, ${policy.sessionCalls} SDK inference invocations per Pi process, ${policy.jobMs / 1000}s per job. Pi owns configured authentication; temporary parent provider/auth overrides are unsupported. Child sessions use explicit thinking and selected text, without parent hooks or ambient extensions. Review and scout only. No shell, writes, recursive delegation or live web. Cancellation is best effort; SDK settlement does not establish remote termination or invoice bounds.`,
769
+ };
770
+ },
771
+ };
772
+ }