specpi 0.16.0 → 0.17.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.
@@ -1,8 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { createSnapshot } from "./snapshot.mjs";
3
- import { LIMITS, digest, timeoutLimits, validateOperation } from "./protocol.mjs";
3
+ import { LIMITS, digest, timeoutLimits, budgetLimits, validateOperation } from "./protocol.mjs";
4
4
  import { runWorker } from "./worker.mjs";
5
- import { DelegationError, publicErrorMessage } from "./errors.mjs";
5
+ import { DelegationError, publicErrorMessage, workerFailureMessage } from "./errors.mjs";
6
6
 
7
7
  const terminal = new Set(["complete", "partial", "needs_context", "failed", "cancelled", "expired", "stale"]);
8
8
  const quiet = new Set(["cancelled", "expired", "stale"]);
@@ -21,7 +21,11 @@ export function createDelegationController({
21
21
  Object.fromEntries(
22
22
  Object.entries(LIMITS).map(([key, maximum]) => {
23
23
  const value = limits[key] ?? maximum;
24
- if (!Number.isSafeInteger(value) || value < 1 || value > maximum) {
24
+ const modelDefault = key === "outputTokens" && value === null;
25
+ if (
26
+ !modelDefault &&
27
+ (!Number.isSafeInteger(value) || value < 1 || (maximum !== null && value > maximum))
28
+ ) {
25
29
  throw new DelegationError("Limits may only lower the fixed delegation ceilings");
26
30
  }
27
31
 
@@ -137,6 +141,7 @@ export function createDelegationController({
137
141
  !batch.retired &&
138
142
  !quiet.has(job.state) &&
139
143
  !job.disposition &&
144
+ !job.limitReason &&
140
145
  Date.now() < Math.min(job.deadline, batch.deadline) &&
141
146
  (!terminal.has(job.state) || job.followUps < 1);
142
147
 
@@ -346,6 +351,7 @@ export function createDelegationController({
346
351
  batch.calls >= policy.batchCalls ||
347
352
  totalCalls >= policy.sessionCalls
348
353
  ) {
354
+ job.limitReason = "model-call allowance";
349
355
  throw new DelegationError("Delegation model-call allowance exhausted");
350
356
  }
351
357
 
@@ -370,14 +376,16 @@ export function createDelegationController({
370
376
  assertJobLive();
371
377
  checkSources(batch);
372
378
  finish(batch, job, result.status, result);
373
- } catch {
379
+ } catch (error) {
374
380
  // Provider errors can contain URLs, credentials, or user content. Do not return them.
375
381
  finish(
376
382
  batch,
377
383
  job,
378
384
  "failed",
379
385
  undefined,
380
- "Worker failed or exhausted a bound; inspect status and use one changed-input follow-up if appropriate.",
386
+ job.limitReason
387
+ ? `Worker exhausted its ${job.limitReason}. Review /delegate limits; a human can raise /delegate budget while off and start a fresh batch.`
388
+ : workerFailureMessage(error),
381
389
  );
382
390
  } finally {
383
391
  job.controller.abort();
@@ -520,6 +528,19 @@ export function createDelegationController({
520
528
 
521
529
  const { batch, job } = findBoundJob(input);
522
530
  if (input.operation === "follow_up") {
531
+ if (
532
+ job.limitReason ||
533
+ job.calls >= policy.jobCalls ||
534
+ batch.calls >= policy.batchCalls ||
535
+ totalCalls >= policy.sessionCalls ||
536
+ job.toolCalls >= policy.toolCalls ||
537
+ job.toolBytes >= policy.toolBytes
538
+ ) {
539
+ throw new DelegationError(
540
+ "Follow-up has no usable budget remaining. A human can raise /delegate budget while off, then start a fresh batch.",
541
+ );
542
+ }
543
+
523
544
  if (
524
545
  job.settling ||
525
546
  job.followUps >= 1 ||
@@ -685,7 +706,7 @@ export function createDelegationController({
685
706
  try {
686
707
  entry.value = mutate(input);
687
708
  // 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.
709
+ // (one run and at most two follow-ups/two dispositions per batch). Never evict them.
689
710
  if (input.operation === "cancel" || (input.operation === "resolve" && input.decision === "needs_check")) {
690
711
  recentRequests.set(input.requestId, entry);
691
712
  if (recentRequests.size > 128) {
@@ -708,6 +729,16 @@ export function createDelegationController({
708
729
  presentation,
709
730
  invalidate,
710
731
  // Human command only; never exposed through execute() or the model schema.
732
+ setBudgetMultiplier(multiplier) {
733
+ const budget = budgetLimits(multiplier);
734
+ if (enabled) {
735
+ throw new DelegationError("Turn delegation off before changing its budget: /delegate off");
736
+ }
737
+
738
+ invalidate("budget policy changed");
739
+ policy = Object.freeze({ ...policy, ...budget });
740
+ changed();
741
+ },
711
742
  setTimeoutMinutes(minutes) {
712
743
  const times = timeoutLimits(minutes);
713
744
  if (enabled) {
@@ -1,6 +1,70 @@
1
1
  /** Only code-owned diagnostics may cross the delegation UI/model boundary. */
2
2
  export class DelegationError extends Error {}
3
3
 
4
+ const workerStages = new Map(
5
+ Object.entries({
6
+ setup: "Worker session setup failed; check Pi provider configuration.",
7
+ provider: "Worker provider request failed; raw provider details were withheld.",
8
+ stream: "Worker response stream failed validation or exceeded its retained-response allowance.",
9
+ context: "Worker request exceeded its context allowance or could not be serialized.",
10
+ response: "Worker provider response exceeded its retained-response allowance or was invalid.",
11
+ tool: "Worker source-tool request failed; check the selected source IDs and tool argument limits.",
12
+ policy: "Worker requested an unavailable tool or changed its inference policy.",
13
+ lease: "Worker lease was cancelled or expired; check the original deadline and current model/Guard state.",
14
+ incomplete: "Worker provider returned no complete assistant response.",
15
+ output: "Worker hit the provider output-token limit before completing its report; narrow the review or reduce thinking effort.",
16
+ empty: "Worker returned no report text.",
17
+ json: "Worker report was not valid JSON; the follow-up must request only the required JSON object without Markdown fences.",
18
+ result: "Worker report failed schema or evidence validation.",
19
+ size: "Worker report exceeded the final-result byte allowance; request a shorter report.",
20
+ }),
21
+ );
22
+ const workerDetails = new Set([
23
+ "Invalid closed delegation object",
24
+ "Invalid delegation text",
25
+ "Invalid delegation identifier",
26
+ "Invalid delegation list",
27
+ "Invalid delegation integer",
28
+ "Invalid worker result size or status",
29
+ "Worker referenced an unknown source",
30
+ "Worker requirement coverage is invalid",
31
+ "Worker must cover each requirement exactly once",
32
+ "Invalid finding confidence",
33
+ "Observed findings require a source reference",
34
+ "Worker finding identifiers must be unique",
35
+ "Source is not selected for this job",
36
+ "Snapshot read request rejected.",
37
+ "Snapshot search request rejected.",
38
+ "Snapshot search scope rejected.",
39
+ "Snapshot line exceeds response quota.",
40
+ "Snapshot source changed.",
41
+ "Snapshot source unavailable or changed.",
42
+ "Snapshot path binding changed.",
43
+ "Snapshot closed.",
44
+ ]);
45
+ const workerMessages = new WeakMap();
46
+
47
+ // Retain only fixed diagnostics, never an SDK cause, stack, response, or arbitrary message.
48
+ export function workerFailure(stage, error) {
49
+ if (workerMessages.has(error)) {
50
+ return error;
51
+ }
52
+
53
+ const detail =
54
+ error instanceof DelegationError && workerDetails.has(error.message)
55
+ ? ` ${error.message.replace(/\.$/u, "")}.`
56
+ : "";
57
+ const message = (workerStages.get(stage) ?? "Worker failed; no safe diagnostic was available.") + detail;
58
+ const failure = new DelegationError(message);
59
+ workerMessages.set(failure, message);
60
+
61
+ return failure;
62
+ }
63
+
64
+ export function workerFailureMessage(error) {
65
+ return workerMessages.get(error) ?? "Worker failed; no safe diagnostic was available.";
66
+ }
67
+
4
68
  export function publicErrorMessage(error) {
5
69
  return error instanceof DelegationError
6
70
  ? error.message
@@ -1,10 +1,11 @@
1
1
  import { DelegationError, publicErrorMessage } from "./errors.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { createDelegationController } from "./core.mjs";
4
- import { DEFAULT_TIMEOUT_MINUTES, timeoutLimits } from "./protocol.mjs";
4
+ import { DEFAULT_TIMEOUT_MINUTES, DEFAULT_BUDGET_MULTIPLIER, timeoutLimits, budgetLimits } from "./protocol.mjs";
5
5
  import { createLivePanel, createToolRenderers, readableLimits, readableStatus } from "./presentation.mjs";
6
6
 
7
- const USAGE = "Usage: /delegate [on|off|status|limits|timeout [<minutes>|reset]|cancel <batchId>]";
7
+ const USAGE =
8
+ "Usage: /delegate [on|off|status|limits|budget [<multiplier>|reset]|timeout [<minutes>|reset]|cancel <batchId>]";
8
9
 
9
10
  const integer = { type: "integer", minimum: 0 };
10
11
  const string = { type: "string" };
@@ -58,7 +59,11 @@ export const DELEGATE_SCHEMA = {
58
59
  mode: { type: "string", enum: ["review", "scout"] },
59
60
  question: string,
60
61
  context: string,
61
- sources: strings,
62
+ sources: {
63
+ ...strings,
64
+ description:
65
+ "Exact workspace-relative text filenames. Ordinary auth, credential, session and history source names are allowed. Private runtime storage, credential stores and keys are excluded; no directories, globs or absolute paths.",
66
+ },
62
67
  requirements: {
63
68
  type: "array",
64
69
  minItems: 1,
@@ -137,7 +142,13 @@ export function createDelegationExtension(
137
142
  let timeoutLoaded = !timeoutStore;
138
143
  const loadTimeout = () => {
139
144
  if (!timeoutLoaded) {
140
- controller.setTimeoutMinutes(timeoutStore.load());
145
+ const minutes = timeoutStore.load();
146
+ const multiplier = timeoutStore.loadBudget?.();
147
+ if (multiplier !== undefined) {
148
+ controller.setBudgetMultiplier(multiplier);
149
+ }
150
+
151
+ controller.setTimeoutMinutes(minutes);
141
152
  timeoutLoaded = true;
142
153
  }
143
154
  };
@@ -400,13 +411,19 @@ export function createDelegationExtension(
400
411
  });
401
412
  pi.registerCommand("delegate", {
402
413
  description:
403
- "Control read-only delegation (on at Pi startup), inspect limits, or save timeout <minutes> (1–60; reset: 10)",
414
+ "Control delegation, inspect limits, or save budget <multiplier> (1–64; default 8) and timeout <minutes> (1–60; default 10)",
404
415
  getArgumentCompletions: (prefix) =>
405
416
  [
406
417
  "on",
407
418
  "off",
408
419
  "status",
409
420
  "limits",
421
+ "budget",
422
+ "budget 8",
423
+ "budget 16",
424
+ "budget 32",
425
+ "budget 64",
426
+ "budget reset",
410
427
  "timeout",
411
428
  "timeout 5",
412
429
  "timeout 10",
@@ -428,7 +445,7 @@ export function createDelegationExtension(
428
445
  currentContext = ctx;
429
446
  const [action = "status", id, extra] = args.trim().split(/\s+/u).filter(Boolean);
430
447
  try {
431
- if (extra || (id && !["cancel", "timeout"].includes(action))) {
448
+ if (extra || (id && !["cancel", "timeout", "budget"].includes(action))) {
432
449
  throw new DelegationError(USAGE);
433
450
  }
434
451
 
@@ -454,7 +471,36 @@ export function createDelegationExtension(
454
471
 
455
472
  syncActiveTool(true);
456
473
  ctx.ui.notify(
457
- `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; ${state.limits.jobMs / 60_000} minutes per job, ${state.limits.batchMs / 60_000} minutes per batch. Configure with /delegate timeout <minutes> while off (saved across restarts). 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.`,
474
+ `Delegation enabled: Pi child sessions for review and scout, ${state.model.provider}/${state.model.id}, thinking ${state.model.thinkingLevel ?? "Pi configured"}. At most ${state.limits.concurrency} workers, ${state.limits.sessionBatches} batches and ${state.limits.sessionCalls} SDK inference calls per Pi process; ${state.limits.jobMs / 60_000} minutes per job, ${state.limits.batchMs / 60_000} minutes per batch. Configure /delegate budget and /delegate timeout while off (saved across restarts). 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 provider retries or compaction. Workers can correct tool arguments and reports within their existing budgets. /delegate off cancels workers; SDK requests still settling retain their slots. These limits do not guarantee remote termination or a billing cap.`,
475
+ "info",
476
+ );
477
+ } else if (action === "budget") {
478
+ if (id) {
479
+ if (!ctx.hasUI) {
480
+ throw new DelegationError("Budget changes require a human interactive command");
481
+ }
482
+
483
+ if (requested || pending || controller.status().enabled) {
484
+ throw new DelegationError(
485
+ "Turn delegation off before changing its budget: /delegate off",
486
+ );
487
+ }
488
+
489
+ const multiplier =
490
+ id === "reset" ? DEFAULT_BUDGET_MULTIPLIER : /^\d+$/u.test(id) ? Number(id) : NaN;
491
+ budgetLimits(multiplier);
492
+ if (!timeoutStore?.saveBudget) {
493
+ throw new DelegationError("Persistent delegation settings are unavailable");
494
+ }
495
+
496
+ timeoutStore.saveBudget(multiplier);
497
+ controller.setBudgetMultiplier(multiplier);
498
+ } else {
499
+ loadTimeout();
500
+ }
501
+
502
+ ctx.ui.notify(
503
+ `${readableLimits(status())}\n${id ? "Budget saved. " : ""}Use /delegate budget <multiplier> (1–64; default 8) while off. Counts, source-output bytes and model-context bytes scale together; spent usage and deadlines do not reset.`,
458
504
  "info",
459
505
  );
460
506
  } else if (action === "timeout") {
@@ -4,6 +4,25 @@ import { createHash } from "node:crypto";
4
4
  export const DEFAULT_TIMEOUT_MINUTES = 10;
5
5
  export const MAX_TIMEOUT_MINUTES = 60;
6
6
  export const MAX_JOB_MS = MAX_TIMEOUT_MINUTES * 60_000;
7
+ export const DEFAULT_BUDGET_MULTIPLIER = 8;
8
+ export const MAX_BUDGET_MULTIPLIER = 64;
9
+
10
+ export function budgetLimits(multiplier) {
11
+ if (!Number.isSafeInteger(multiplier) || multiplier < 1 || multiplier > MAX_BUDGET_MULTIPLIER) {
12
+ throw new DelegationError(`Delegation budget must be a whole multiplier from 1 to ${MAX_BUDGET_MULTIPLIER}`);
13
+ }
14
+
15
+ return {
16
+ sessionBatches: 4 * multiplier,
17
+ sessionCalls: 32 * multiplier,
18
+ batchCalls: 8 * multiplier,
19
+ jobCalls: 4 * multiplier,
20
+ toolBytes: 64 * 1024 * multiplier,
21
+ toolCalls: 12 * multiplier,
22
+ contextBytes: 256 * 1024 * multiplier,
23
+ retainedResponseBytes: 128 * 1024 * multiplier,
24
+ };
25
+ }
7
26
 
8
27
  export function timeoutLimits(minutes) {
9
28
  if (!Number.isSafeInteger(minutes) || minutes < 1 || minutes > MAX_TIMEOUT_MINUTES) {
@@ -15,18 +34,12 @@ export function timeoutLimits(minutes) {
15
34
 
16
35
  export const LIMITS = Object.freeze({
17
36
  concurrency: 2,
18
- sessionBatches: 4,
19
- sessionCalls: 32,
20
37
  batchJobs: 2,
21
- batchCalls: 8,
22
- jobCalls: 4,
38
+ ...budgetLimits(DEFAULT_BUDGET_MULTIPLIER),
23
39
  ...timeoutLimits(DEFAULT_TIMEOUT_MINUTES),
24
- contextBytes: 256 * 1024,
25
- retainedResponseBytes: 256 * 1024,
26
- toolBytes: 64 * 1024,
27
- toolCalls: 12,
40
+ packetBytes: 256 * 1024,
28
41
  resultBytes: 16 * 1024,
29
- outputTokens: 8192,
42
+ outputTokens: null,
30
43
  });
31
44
 
32
45
  export function bytes(value) {
@@ -162,7 +175,7 @@ function validatePacketShape(packet) {
162
175
  if (
163
176
  !packet.jobs.length ||
164
177
  new Set(packet.jobs.map((job) => job.id)).size !== packet.jobs.length ||
165
- bytes(packet) > LIMITS.contextBytes
178
+ bytes(packet) > LIMITS.packetBytes
166
179
  ) {
167
180
  throw new DelegationError("Delegation packet exceeds its bound or repeats job identifiers");
168
181
  }
@@ -200,7 +213,7 @@ export function validateOperation(input) {
200
213
  operations[input.operation],
201
214
  operations[input.operation].filter((key) => !optional.includes(key)),
202
215
  );
203
- if (bytes(input) > LIMITS.contextBytes + 4096) {
216
+ if (bytes(input) > LIMITS.packetBytes + 4096) {
204
217
  throw new DelegationError("Delegation operation exceeds its bound");
205
218
  }
206
219
 
@@ -1,8 +1,9 @@
1
- import { DelegationError } from "./errors.mjs";
2
- import { MAX_JOB_MS } from "./protocol.mjs";
1
+ import { DelegationError, workerFailure } from "./errors.mjs";
2
+ import { MAX_JOB_MS, MAX_BUDGET_MULTIPLIER, budgetLimits } from "./protocol.mjs";
3
3
 
4
4
  const MAX_CONTEXT_BYTES = 256 * 1024;
5
- const MAX_OUTPUT_TOKENS = 8192;
5
+ const MAX_RUN_CONTEXT_BYTES = budgetLimits(MAX_BUDGET_MULTIPLIER).contextBytes;
6
+ const MAX_RETAINED_RESPONSE_BYTES = budgetLimits(MAX_BUDGET_MULTIPLIER).retainedResponseBytes;
6
7
  const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
7
8
  const MODEL_FIELDS = [
8
9
  "id",
@@ -35,16 +36,16 @@ export function getPiSessionCompatibilityError(sdk) {
35
36
  : undefined;
36
37
  }
37
38
 
38
- function bounded(value, maximum = MAX_CONTEXT_BYTES) {
39
+ function bounded(value, maximum = MAX_CONTEXT_BYTES, stage) {
39
40
  let serialized;
40
41
  try {
41
42
  serialized = JSON.stringify(value);
42
43
  } catch {
43
- throw new DelegationError("Delegation data is not serializable");
44
+ throw stage ? workerFailure(stage) : new DelegationError("Delegation data is not serializable");
44
45
  }
45
46
 
46
47
  if (typeof serialized !== "string" || Buffer.byteLength(serialized, "utf8") > maximum) {
47
- throw new DelegationError("Delegation data exceeds its retained data allowance");
48
+ throw stage ? workerFailure(stage) : new DelegationError("Delegation data exceeds its retained data allowance");
48
49
  }
49
50
 
50
51
  return serialized;
@@ -306,7 +307,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
306
307
 
307
308
  const check = (streaming = false) => {
308
309
  if (!current(streaming)) {
309
- throw new DelegationError("Delegation parent lease is no longer current");
310
+ throw workerFailure("lease");
310
311
  }
311
312
 
312
313
  if (
@@ -459,7 +460,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
459
460
  active.controls.signal.aborted ||
460
461
  Date.now() >= active.controls.deadline
461
462
  ) {
462
- throw new DelegationError("Delegation session was cancelled or expired");
463
+ throw workerFailure("lease");
463
464
  }
464
465
 
465
466
  active.controls.assertLive(streaming);
@@ -478,7 +479,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
478
479
  try {
479
480
  assertRun();
480
481
  if (!names.includes(call.toolCall.name)) {
481
- throw new DelegationError("Delegation requested an unavailable tool");
482
+ throw workerFailure("policy");
482
483
  }
483
484
 
484
485
  const result = await beforeToolCall?.call(session.agent, call, signal);
@@ -499,27 +500,30 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
499
500
  if (
500
501
  requestModel !== childModel ||
501
502
  !sameValue(descriptor(requestModel), selected) ||
502
- context.tools?.some((tool) => !names.includes(tool.name)) ||
503
- context.messages.some((message) => message.role === "toolResult" && message.isError)
503
+ context.tools?.some((tool) => !names.includes(tool.name))
504
504
  ) {
505
- throw new DelegationError("Delegation inference policy changed");
505
+ throw workerFailure("policy");
506
506
  }
507
507
 
508
- bounded(context, run.controls.limits.contextBytes);
508
+ bounded(context, run.controls.limits.contextBytes, "context");
509
509
  run.controls.admitCall();
510
510
  const requestOptions = {
511
511
  ...options,
512
512
  signal: AbortSignal.any(
513
513
  [options.signal, run.controls.signal, run.controller.signal].filter(Boolean),
514
514
  ),
515
- maxTokens: Math.min(MAX_OUTPUT_TOKENS, childModel.maxTokens, run.controls.limits.outputTokens),
515
+ // null uses Pi's normal provider/model output and thinking budget.
516
+ maxTokens:
517
+ run.controls.limits.outputTokens === null
518
+ ? options.maxTokens
519
+ : Math.min(childModel.maxTokens, run.controls.limits.outputTokens),
516
520
  maxRetries: 0,
517
521
  timeoutMs: Math.max(1, Math.min(MAX_JOB_MS, run.controls.deadline - Date.now())),
518
522
  onPayload: async (payload, providerModel) => {
519
523
  assertRun();
520
524
  const result = (await options.onPayload?.(payload, providerModel)) ?? payload;
521
525
  assertRun();
522
- bounded(result, run.controls.limits.contextBytes);
526
+ bounded(result, run.controls.limits.contextBytes, "context");
523
527
 
524
528
  return result;
525
529
  },
@@ -536,7 +540,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
536
540
  .then(
537
541
  (result) => {
538
542
  run.controls.onUsage(result?.usage);
539
- bounded(result, run.controls.limits.retainedResponseBytes);
543
+ bounded(result, run.controls.limits.retainedResponseBytes, "response");
540
544
  if (
541
545
  result?.role !== "assistant" ||
542
546
  !Array.isArray(result.content) ||
@@ -544,9 +548,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
544
548
  (part) => part.type === "toolCall" && !names.includes(part.name),
545
549
  )
546
550
  ) {
547
- throw new DelegationError(
548
- "Provider returned an invalid response or unavailable tool",
549
- );
551
+ throw workerFailure("response");
550
552
  }
551
553
 
552
554
  return result;
@@ -575,7 +577,11 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
575
577
  try {
576
578
  for await (const event of stream) {
577
579
  assertRun(event.type?.endsWith("_delta") === true);
578
- inspectEvent(event);
580
+ try {
581
+ inspectEvent(event);
582
+ } catch (error) {
583
+ throw workerFailure("stream", error);
584
+ }
579
585
 
580
586
  yield event;
581
587
  }
@@ -621,14 +627,15 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
621
627
  ["assertLive", "admitCall", "onUsage", "abort"].some(
622
628
  (key) => typeof controls[key] !== "function",
623
629
  ) ||
624
- !Number.isInteger(controls.limits?.outputTokens) ||
625
- controls.limits.outputTokens < 1 ||
626
- controls.limits.outputTokens > MAX_OUTPUT_TOKENS ||
630
+ (controls.limits?.outputTokens !== null &&
631
+ (!Number.isSafeInteger(controls.limits?.outputTokens) ||
632
+ controls.limits.outputTokens < 1)) ||
627
633
  ["contextBytes", "retainedResponseBytes"].some(
628
634
  (key) =>
629
635
  !Number.isInteger(controls.limits[key]) ||
630
636
  controls.limits[key] < 1 ||
631
- controls.limits[key] > MAX_CONTEXT_BYTES,
637
+ controls.limits[key] >
638
+ (key === "contextBytes" ? MAX_RUN_CONTEXT_BYTES : MAX_RETAINED_RESPONSE_BYTES),
632
639
  )
633
640
  ) {
634
641
  throw new DelegationError("Invalid bounded delegation session run");
@@ -636,13 +643,13 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
636
643
 
637
644
  const run = { controls, controller: new AbortController(), pending: [], failure: undefined };
638
645
  active = run;
639
- const cancel = () => fail(new DelegationError("Delegation session was cancelled or expired"));
646
+ const cancel = () => fail(workerFailure("lease"));
640
647
  controls.signal.addEventListener("abort", cancel, { once: true });
641
648
  const timer = setTimeout(cancel, Math.max(1, Math.min(MAX_JOB_MS, controls.deadline - Date.now())));
642
649
  timer.unref?.();
643
650
  try {
644
651
  assertRun();
645
- bounded(prompt, controls.limits.contextBytes);
652
+ bounded(prompt, controls.limits.contextBytes, "context");
646
653
  await session.prompt(prompt, { expandPromptTemplates: false });
647
654
  await Promise.allSettled(run.pending);
648
655
  if (run.failure) {
@@ -656,12 +663,14 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
656
663
  !Array.isArray(result.content) ||
657
664
  !["stop", "length"].includes(result.stopReason)
658
665
  ) {
659
- throw new DelegationError("Pi worker session did not return a complete assistant response");
666
+ throw workerFailure("incomplete");
660
667
  }
661
668
 
662
- bounded(result, controls.limits.retainedResponseBytes);
669
+ bounded(result, controls.limits.retainedResponseBytes, "response");
663
670
 
664
671
  return result;
672
+ } catch (error) {
673
+ throw workerFailure("provider", run.failure ?? error);
665
674
  } finally {
666
675
  clearTimeout(timer);
667
676
  controls.signal.removeEventListener("abort", cancel);
@@ -679,7 +688,7 @@ export function createNativePiHost(ctx, { id, isCurrent, sdk, thinkingLevel } =
679
688
 
680
689
  released = true;
681
690
  if (active) {
682
- fail(new DelegationError("Delegation session was released"));
691
+ fail(workerFailure("lease"));
683
692
  } else {
684
693
  dispose();
685
694
  }
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createHash, randomUUID } from "node:crypto";
5
- import { DEFAULT_TIMEOUT_MINUTES, timeoutLimits } from "./protocol.mjs";
5
+ import { DEFAULT_TIMEOUT_MINUTES, DEFAULT_BUDGET_MULTIPLIER, timeoutLimits, budgetLimits } from "./protocol.mjs";
6
6
  import { DelegationError } from "./errors.mjs";
7
7
 
8
8
  const MAX_SETTINGS_BYTES = 4096;
@@ -94,18 +94,59 @@ export function createTimeoutStore(
94
94
  if (
95
95
  !settings ||
96
96
  settings.schema !== 1 ||
97
- Object.keys(settings).length !== 2 ||
97
+ Object.keys(settings).some((key) => !["schema", "timeoutMinutes", "budgetMultiplier"].includes(key)) ||
98
98
  !Object.hasOwn(settings, "timeoutMinutes")
99
99
  ) {
100
100
  throw new Error("Invalid settings schema");
101
101
  }
102
102
 
103
103
  timeoutLimits(settings.timeoutMinutes);
104
+ if (Object.hasOwn(settings, "budgetMultiplier")) {
105
+ budgetLimits(settings.budgetMultiplier);
106
+ }
107
+
108
+ return { content, minutes: settings.timeoutMinutes, budgetMultiplier: settings.budgetMultiplier };
109
+ };
110
+
111
+ const save = (patch) => {
112
+ const previous = read();
113
+ const { dir, file } = location();
114
+ directory(dir, true);
115
+ if (previous) {
116
+ atomicWrite(
117
+ `${file}.bak`,
118
+ `${JSON.stringify({ sha256: createHash("sha256").update(previous.content).digest("hex"), content: previous.content })}\n`,
119
+ MAX_BACKUP_BYTES,
120
+ );
121
+ }
104
122
 
105
- return { content, minutes: settings.timeoutMinutes };
123
+ const settings = {
124
+ schema: 1,
125
+ timeoutMinutes: previous?.minutes ?? DEFAULT_TIMEOUT_MINUTES,
126
+ ...(previous?.budgetMultiplier === undefined ? {} : { budgetMultiplier: previous.budgetMultiplier }),
127
+ ...patch,
128
+ };
129
+ atomicWrite(file, `${JSON.stringify(settings)}\n`);
106
130
  };
107
131
 
108
132
  return {
133
+ loadBudget() {
134
+ try {
135
+ return read()?.budgetMultiplier ?? DEFAULT_BUDGET_MULTIPLIER;
136
+ } catch {
137
+ throw new DelegationError(
138
+ "Cannot read delegation budget settings; check the owned settings file and restart Pi.",
139
+ );
140
+ }
141
+ },
142
+ saveBudget(multiplier) {
143
+ budgetLimits(multiplier);
144
+ try {
145
+ save({ budgetMultiplier: multiplier });
146
+ } catch {
147
+ throw new DelegationError("Cannot save delegation budget settings; the active budget is unchanged.");
148
+ }
149
+ },
109
150
  load() {
110
151
  try {
111
152
  return read()?.minutes ?? DEFAULT_TIMEOUT_MINUTES;
@@ -118,18 +159,7 @@ export function createTimeoutStore(
118
159
  save(minutes) {
119
160
  timeoutLimits(minutes);
120
161
  try {
121
- const previous = read();
122
- const { dir, file } = location();
123
- directory(dir, true);
124
- if (previous) {
125
- atomicWrite(
126
- `${file}.bak`,
127
- `${JSON.stringify({ sha256: createHash("sha256").update(previous.content).digest("hex"), content: previous.content })}\n`,
128
- MAX_BACKUP_BYTES,
129
- );
130
- }
131
-
132
- atomicWrite(file, `${JSON.stringify({ schema: 1, timeoutMinutes: minutes })}\n`);
162
+ save({ timeoutMinutes: minutes });
133
163
  } catch {
134
164
  throw new DelegationError(
135
165
  "Cannot save delegation settings. Check <agent-dir>/specpi/delegation/settings.json and its permissions; the active timeout is unchanged.",