pi-daddy 0.41.0 → 0.43.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 (138) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/contracts/ledger-record/v1/fixtures/capability-decision.json +3 -1
  3. package/contracts/ledger-record/v1/fixtures/child-lifecycle.json +25 -0
  4. package/contracts/ledger-record/v1/fixtures/episode-cost-gate.json +14 -0
  5. package/contracts/ledger-record/v1/fixtures/episode-outcome.json +12 -0
  6. package/contracts/ledger-record/v1/fixtures/ledger-record.jsonl +6 -3
  7. package/contracts/ledger-record/v1/fixtures/session-config.json +14 -0
  8. package/contracts/ledger-record/v1/governance-event.schema.json +356 -0
  9. package/dist/advisors/advisor.d.ts +24 -1
  10. package/dist/advisors/advisor.d.ts.map +1 -1
  11. package/dist/advisors/advisor.js +19 -1
  12. package/dist/advisors/advisor.js.map +1 -1
  13. package/dist/advisors/decider.d.ts +11 -0
  14. package/dist/advisors/decider.d.ts.map +1 -1
  15. package/dist/advisors/decider.js.map +1 -1
  16. package/dist/advisors/jev.d.ts.map +1 -1
  17. package/dist/advisors/jev.js +32 -1
  18. package/dist/advisors/jev.js.map +1 -1
  19. package/dist/cli.d.ts +7 -1
  20. package/dist/cli.d.ts.map +1 -1
  21. package/dist/cli.js +87 -1
  22. package/dist/cli.js.map +1 -1
  23. package/dist/executors/activity-session.d.ts +13 -0
  24. package/dist/executors/activity-session.d.ts.map +1 -1
  25. package/dist/executors/activity-session.js +44 -2
  26. package/dist/executors/activity-session.js.map +1 -1
  27. package/dist/governance/episode-cost-gate.d.ts +31 -0
  28. package/dist/governance/episode-cost-gate.d.ts.map +1 -0
  29. package/dist/governance/episode-cost-gate.js +112 -0
  30. package/dist/governance/episode-cost-gate.js.map +1 -0
  31. package/dist/governance/ledger-events.d.ts +108 -1
  32. package/dist/governance/ledger-events.d.ts.map +1 -1
  33. package/dist/governance/ledger-events.js +64 -0
  34. package/dist/governance/ledger-events.js.map +1 -1
  35. package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
  36. package/dist/governance/ledger-v3-validation.js +124 -0
  37. package/dist/governance/ledger-v3-validation.js.map +1 -1
  38. package/dist/governance/ledger.d.ts +8 -2
  39. package/dist/governance/ledger.d.ts.map +1 -1
  40. package/dist/governance/ledger.js +18 -2
  41. package/dist/governance/ledger.js.map +1 -1
  42. package/dist/kernel/context-handoff.d.ts.map +1 -1
  43. package/dist/kernel/context-handoff.js +4 -1
  44. package/dist/kernel/context-handoff.js.map +1 -1
  45. package/dist/kernel/definitions.d.ts +5 -0
  46. package/dist/kernel/definitions.d.ts.map +1 -1
  47. package/dist/kernel/definitions.js +30 -1
  48. package/dist/kernel/definitions.js.map +1 -1
  49. package/dist/kernel/delegate-types.d.ts +4 -0
  50. package/dist/kernel/delegate-types.d.ts.map +1 -1
  51. package/dist/kernel/delegate-types.js.map +1 -1
  52. package/dist/kernel/delegate.d.ts.map +1 -1
  53. package/dist/kernel/delegate.js +20 -5
  54. package/dist/kernel/delegate.js.map +1 -1
  55. package/dist/kernel/env-names.d.ts +5 -0
  56. package/dist/kernel/env-names.d.ts.map +1 -1
  57. package/dist/kernel/env-names.js +9 -0
  58. package/dist/kernel/env-names.js.map +1 -1
  59. package/dist/kernel/grant-env.d.ts +2 -0
  60. package/dist/kernel/grant-env.d.ts.map +1 -1
  61. package/dist/kernel/grant-env.js +1 -0
  62. package/dist/kernel/grant-env.js.map +1 -1
  63. package/dist/kernel/propagation.d.ts +2 -2
  64. package/dist/kernel/propagation.d.ts.map +1 -1
  65. package/dist/kernel/propagation.js +7 -2
  66. package/dist/kernel/propagation.js.map +1 -1
  67. package/dist/products/dashboard-cli.d.ts +4 -0
  68. package/dist/products/dashboard-cli.d.ts.map +1 -1
  69. package/dist/products/dashboard-cli.js +32 -4
  70. package/dist/products/dashboard-cli.js.map +1 -1
  71. package/dist/products/dashboard-handshake.d.ts +8 -0
  72. package/dist/products/dashboard-handshake.d.ts.map +1 -1
  73. package/dist/products/dashboard-handshake.js +3 -0
  74. package/dist/products/dashboard-handshake.js.map +1 -1
  75. package/dist/products/dashboard-herdr.d.ts +4 -0
  76. package/dist/products/dashboard-herdr.d.ts.map +1 -1
  77. package/dist/products/dashboard-herdr.js +10 -2
  78. package/dist/products/dashboard-herdr.js.map +1 -1
  79. package/dist/products/dashboard-projection.d.ts.map +1 -1
  80. package/dist/products/dashboard-projection.js +13 -6
  81. package/dist/products/dashboard-projection.js.map +1 -1
  82. package/dist/products/dashboard-render.d.ts +10 -0
  83. package/dist/products/dashboard-render.d.ts.map +1 -1
  84. package/dist/products/dashboard-render.js +9 -0
  85. package/dist/products/dashboard-render.js.map +1 -1
  86. package/dist/products/dashboard-session-client.d.ts +17 -0
  87. package/dist/products/dashboard-session-client.d.ts.map +1 -0
  88. package/dist/products/dashboard-session-client.js +28 -0
  89. package/dist/products/dashboard-session-client.js.map +1 -0
  90. package/dist/products/episode-outcomes.d.ts +32 -0
  91. package/dist/products/episode-outcomes.d.ts.map +1 -0
  92. package/dist/products/episode-outcomes.js +280 -0
  93. package/dist/products/episode-outcomes.js.map +1 -0
  94. package/dist/products/episode-report.d.ts +59 -0
  95. package/dist/products/episode-report.d.ts.map +1 -0
  96. package/dist/products/episode-report.js +311 -0
  97. package/dist/products/episode-report.js.map +1 -0
  98. package/extensions/chain-ledger.ts +2 -0
  99. package/extensions/chain-plan.ts +4 -2
  100. package/extensions/context-shape.ts +19 -4
  101. package/extensions/dashboard-session-server.ts +86 -0
  102. package/extensions/definition-runtime.ts +93 -0
  103. package/extensions/delegate-chain.ts +31 -2
  104. package/extensions/delegation-ledger.ts +2 -0
  105. package/extensions/delegation.ts +24 -0
  106. package/extensions/effort-advice.ts +12 -0
  107. package/extensions/execute-child.ts +148 -9
  108. package/extensions/grants-command.ts +32 -2
  109. package/extensions/grants.ts +23 -1
  110. package/extensions/pruning-advice.ts +3 -1
  111. package/extensions/run-delegation.ts +54 -6
  112. package/extensions/session-model-prompt.ts +179 -0
  113. package/extensions/session.ts +104 -3
  114. package/package.json +1 -1
  115. package/src/advisors/advisor.ts +45 -2
  116. package/src/advisors/decider.ts +8 -0
  117. package/src/advisors/jev.ts +34 -1
  118. package/src/cli.ts +90 -2
  119. package/src/executors/activity-session.ts +56 -2
  120. package/src/governance/episode-cost-gate.ts +128 -0
  121. package/src/governance/ledger-events.ts +173 -1
  122. package/src/governance/ledger-v3-validation.ts +146 -0
  123. package/src/governance/ledger.ts +29 -1
  124. package/src/kernel/context-handoff.ts +5 -1
  125. package/src/kernel/definitions.ts +32 -1
  126. package/src/kernel/delegate-types.ts +4 -0
  127. package/src/kernel/delegate.ts +22 -5
  128. package/src/kernel/env-names.ts +10 -0
  129. package/src/kernel/grant-env.ts +3 -0
  130. package/src/kernel/propagation.ts +7 -0
  131. package/src/products/dashboard-cli.ts +32 -4
  132. package/src/products/dashboard-handshake.ts +5 -0
  133. package/src/products/dashboard-herdr.ts +15 -1
  134. package/src/products/dashboard-projection.ts +13 -6
  135. package/src/products/dashboard-render.ts +11 -0
  136. package/src/products/dashboard-session-client.ts +35 -0
  137. package/src/products/episode-outcomes.ts +317 -0
  138. package/src/products/episode-report.ts +372 -0
@@ -54,6 +54,8 @@ export async function recordDelegationDecision(input: {
54
54
  humanDenied: approval?.humanDenied ?? approvalFacts?.humanDenied,
55
55
  gateOutcome: approval?.gateOutcome,
56
56
  definitionDigest: plan.definitionDigest,
57
+ definitionHash: plan.definitionHash,
58
+ definitionPackageVersion: plan.definitionPackageVersion,
57
59
  taskDigest: plan.taskDigest,
58
60
  correlation: plan.correlation,
59
61
  refusal: plan.refusal,
@@ -37,6 +37,7 @@ import { newDelegationOccurrence } from "./execution-occurrence.ts";
37
37
  import { correlationShape as buildCorrelationShape } from "./correlation-shape.ts";
38
38
  import { assertDelegationAuthority } from "./delegation-authority.ts";
39
39
  import { contextShape } from "./context-shape.ts";
40
+ import { ensureSessionModelPrompt } from "./session-model-prompt.ts";
40
41
 
41
42
  /**
42
43
  * Wire a set of children to pi's partial-result channel — ADR-0032.
@@ -201,6 +202,9 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
201
202
  maxItems: MAX_CHILDREN_PER_CALL,
202
203
  description: "The sub-agents to run concurrently. Each is independent and unaware of the others.",
203
204
  }),
205
+ episodeCostCeiling: Type.Optional(
206
+ Type.Number({ exclusiveMinimum: 0, description: "Override the USD ceiling for this whole episode." }),
207
+ ),
204
208
  });
205
209
 
206
210
  const delegateParams = Type.Object({
@@ -223,6 +227,9 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
223
227
  }),
224
228
  ),
225
229
  thinking: thinkingShape,
230
+ episodeCostCeiling: Type.Optional(
231
+ Type.Number({ exclusiveMinimum: 0, description: "Override the USD ceiling for this whole episode." }),
232
+ ),
226
233
  context: Type.Optional(contextShape()),
227
234
  correlation: Type.Optional(correlationShape),
228
235
  workspace: Type.Optional(workspaceShape),
@@ -239,6 +246,11 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
239
246
  parameters: delegateParams,
240
247
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
241
248
  assertDelegationAuthority(session);
249
+ await ensureSessionModelPrompt(session, params.agent ? [params.agent] : [], {
250
+ hasUI: ctx.hasUI && typeof ctx.ui.input === "function",
251
+ input: (title, placeholder) => ctx.ui.input(title, placeholder),
252
+ notify: (message, type) => ctx.ui.notify(message, type),
253
+ });
242
254
  // ADR-0032: one child, same block. `_onUpdate` was discarded here, so a delegation showed the bare word
243
255
  // `delegate` for up to DEFAULT_TIMEOUT_MS — sixty minutes by default.
244
256
  const progress = progressReporter(session, [params.agent ?? "delegate"], onUpdate as never);
@@ -250,6 +262,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
250
262
  tools: params.tools,
251
263
  model: params.model,
252
264
  thinking: params.thinking,
265
+ episodeCostCeiling: params.episodeCostCeiling,
253
266
  context: params.context,
254
267
  correlation: params.correlation,
255
268
  workspace: params.workspace,
@@ -318,6 +331,15 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
318
331
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
319
332
  assertDelegationAuthority(session);
320
333
  const children = params.children ?? [];
334
+ await ensureSessionModelPrompt(
335
+ session,
336
+ children.flatMap((child) => (child.agent ? [child.agent] : [])),
337
+ {
338
+ hasUI: ctx.hasUI && typeof ctx.ui.input === "function",
339
+ input: (title, placeholder) => ctx.ui.input(title, placeholder),
340
+ notify: (message, type) => ctx.ui.notify(message, type),
341
+ },
342
+ );
321
343
  const split = splitBudget(session.fanoutBudget, children.length);
322
344
  if (!split.ok) {
323
345
  // Thrown, not returned: a returned `isError` is discarded by pi, so a refusal that came back as a
@@ -325,6 +347,8 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
325
347
  throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED", `fan-out refused: ${split.reason}`));
326
348
  }
327
349
 
350
+ if (params.episodeCostCeiling !== undefined) session.episodeCostGate.setCeiling(params.episodeCostCeiling);
351
+
328
352
  // ADR-0032: ONE status block covering every child. `onUpdate` replaces the tool's rendered result, so a
329
353
  // painter per child would have each overwriting the others.
330
354
  const progress = progressReporter(
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { supportedModelEfforts } from "../src/kernel/model-preflight.ts";
16
16
  import type { Advisor } from "../src/advisors/advisor.ts";
17
+ import { resolvedModelOf, type ResolvedDefinitionRuntime } from "./definition-runtime.ts";
17
18
 
18
19
  /** What pi's resolved catalogue entry carries that decides which efforts exist. */
19
20
  interface ResolvedModel {
@@ -40,6 +41,7 @@ export async function adviseEffort(input: {
40
41
  task: string;
41
42
  executionId?: string;
42
43
  agent?: string;
44
+ fallback?: ResolvedDefinitionRuntime;
43
45
  signal?: AbortSignal;
44
46
  }): Promise<string | undefined> {
45
47
  const advisor = input.session.advisorSession.advisor;
@@ -71,6 +73,16 @@ export async function adviseEffort(input: {
71
73
  },
72
74
  input.signal,
73
75
  input.executionId,
76
+ (result) => {
77
+ const answer = result?.answers.effort;
78
+ const advised = answer?.kind === "choice" && (levels as readonly string[]).includes(answer.value);
79
+ return {
80
+ resolvedModel: resolvedModelOf(input.fallback?.model ?? input.model),
81
+ modelSource: input.fallback?.modelSource ?? "pi",
82
+ thinkingLevel: advised ? answer.value : (input.fallback?.thinking ?? null),
83
+ thinkingSource: advised ? "advisor" : (input.fallback?.thinkingSource ?? "pi"),
84
+ };
85
+ },
74
86
  );
75
87
  const chosen = advice?.answers.effort;
76
88
  // Belt and braces: `parseAnswer` already refuses a choice outside the options it was given, so this can only
@@ -4,7 +4,7 @@ import {
4
4
  retentionConfigurationDigest,
5
5
  type RetentionStatus,
6
6
  } from "../src/governance/execution-retention.ts";
7
- import { appendLedgerEvent, buildChildLifecycleEvent } from "../src/governance/ledger.ts";
7
+ import { appendLedgerEvent, buildChildLifecycleEvent, buildEpisodeCostGateEvent } from "../src/governance/ledger.ts";
8
8
  import { hasFinalizerError } from "../src/governance/finalization.ts";
9
9
  import { mergeChildEnv } from "../src/kernel/propagation.ts";
10
10
  import type { Capability } from "../src/kernel/resolve.ts";
@@ -22,9 +22,11 @@ import { resolveWorkspace } from "../src/executors/herdr-cli.ts";
22
22
  import { HerdrWriterCloseError, runHerdrPane } from "../src/executors/run-herdr.ts";
23
23
  import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/kernel/refusals.ts";
24
24
  import { ENV_HERDR_KEEP_PANE, type GrantsSession } from "./session.ts";
25
- import { ENV_EPISODE_ID } from "../src/kernel/env-names.ts";
25
+ import { CHILD_ATTRIBUTION_ENV_KEYS, ENV_EPISODE_ID } from "../src/kernel/env-names.ts";
26
26
  import { releaseDelegationWorkspace, type PreparedWorkspace } from "./workspace-runtime.ts";
27
27
  import { ActivityTimelineRecorder, ENV_ACTIVITY_PARENT_TASK } from "../src/products/activity-timeline.ts";
28
+ import { loadEpisodeCosts } from "../src/governance/episode-cost-gate.ts";
29
+ import { resolvedModelOf, type ResolvedDefinitionRuntime } from "./definition-runtime.ts";
28
30
  export interface DelegationOutcome {
29
31
  ok: boolean;
30
32
  text: string;
@@ -105,11 +107,26 @@ export async function executePlannedChild(input: {
105
107
  toolCallId?: string;
106
108
  cwd: string;
107
109
  preparedWorkspace?: PreparedWorkspace;
110
+ resolvedRuntime?: ResolvedDefinitionRuntime;
108
111
  signal?: AbortSignal;
109
112
  onProgress?: (update: ChildProgressUpdate) => void;
113
+ costGateUI?: {
114
+ hasUI: boolean;
115
+ input(title: string, placeholder: string, signal?: AbortSignal): Promise<string | undefined>;
116
+ notify(message: string): void;
117
+ };
110
118
  }): Promise<DelegationOutcome> {
111
119
  const { session, plan, childId, executionId, parentExecutionId, preparedWorkspace, signal, onProgress } = input;
112
120
  const ledgerPath = session.ledgerPath;
121
+ const runtimeAttribution = (observed?: {
122
+ resolvedModel?: { provider: string; modelId: string };
123
+ thinking?: string;
124
+ }) => ({
125
+ resolvedModel: observed?.resolvedModel ?? resolvedModelOf(input.resolvedRuntime?.model) ?? undefined,
126
+ modelSource: input.resolvedRuntime?.modelSource ?? ("pi" as const),
127
+ effectiveThinkingLevel: observed?.thinking ?? input.resolvedRuntime?.thinking,
128
+ thinkingSource: input.resolvedRuntime?.thinkingSource ?? ("pi" as const),
129
+ });
113
130
  let activityParent: string | undefined,
114
131
  activity = new ActivityTimelineRecorder(input.cwd),
115
132
  activityStarted = false,
@@ -165,11 +182,90 @@ export async function executePlannedChild(input: {
165
182
  // The process executor adds a second signal that is live during a tool call: the child tree's CPU time and
166
183
  // descendants (`process-activity.ts`). Herdr children are started by the daemon, so their pid is not known here.
167
184
  let childPid: number | undefined;
185
+ let costStopped = false;
186
+ const costUI = input.costGateUI ?? {
187
+ hasUI: false,
188
+ input: async () => undefined,
189
+ notify: () => undefined,
190
+ };
191
+ const costAbort = new AbortController();
192
+ const observeCost = async (cost: number | undefined, terminal: boolean) => {
193
+ if (!session.episodeCostGate) return;
194
+ if (ledgerPath) {
195
+ const recorded = await loadEpisodeCosts(ledgerPath, session.episodeId);
196
+ for (const [recordedExecution, recordedCost] of recorded) {
197
+ session.episodeCostGate.seedCost(recordedExecution, recordedCost);
198
+ }
199
+ }
200
+ await session.episodeCostGate.observe(executionId, cost, terminal, {
201
+ warn: (message) => costUI.notify(message),
202
+ pause: async () => {
203
+ if (session.executor.kind === "herdr") {
204
+ costUI.notify("pi-daddy: Herdr cannot pause a pane; stopping it at the episode cost ceiling");
205
+ return;
206
+ }
207
+ if (childPid !== undefined)
208
+ try {
209
+ process.kill(childPid, "SIGSTOP");
210
+ } catch {
211
+ /* the child may have exited between its final usage write and this observation */
212
+ }
213
+ },
214
+ ask: async () => {
215
+ if (session.executor.kind === "herdr" || !costUI.hasUI) return null;
216
+ const value = await costUI.input(
217
+ "pi-daddy: episode cost ceiling crossed",
218
+ "New USD ceiling above the current cost; leave blank to stop",
219
+ signal,
220
+ );
221
+ if (value === undefined || value.trim() === "") return null;
222
+ const parsed = Number(value);
223
+ return Number.isFinite(parsed) ? parsed : null;
224
+ },
225
+ resume: async () => {
226
+ if (childPid !== undefined)
227
+ try {
228
+ process.kill(childPid, "SIGCONT");
229
+ } catch {
230
+ /* already exited */
231
+ }
232
+ },
233
+ stop: async () => {
234
+ costStopped = true;
235
+ if (childPid !== undefined)
236
+ try {
237
+ process.kill(childPid, "SIGCONT");
238
+ } catch {
239
+ /* already exited */
240
+ }
241
+ costAbort.abort();
242
+ },
243
+ gate: async (event) => {
244
+ if (!ledgerPath) return;
245
+ await appendLedgerEvent(
246
+ {
247
+ path: ledgerPath,
248
+ strict: true,
249
+ },
250
+ buildEpisodeCostGateEvent({
251
+ episodeId: session.episodeId,
252
+ executionId,
253
+ parentExecutionId,
254
+ childId,
255
+ ...event,
256
+ now: new Date(),
257
+ }),
258
+ );
259
+ },
260
+ });
261
+ };
168
262
  const probe = async () => {
169
- const [file, tree] = await Promise.all([
263
+ const [file, tree, usage] = await Promise.all([
170
264
  activitySession.probe(),
171
265
  childPid === undefined ? undefined : processTreeActivity(childPid),
266
+ activitySession.usage(),
172
267
  ]);
268
+ await observeCost(usage.usage?.cost.total, false);
173
269
  return file === undefined && tree === undefined ? undefined : `${file ?? "-"}|${tree ?? "-"}`;
174
270
  };
175
271
  const startedAt = new Date();
@@ -187,6 +283,10 @@ export async function executePlannedChild(input: {
187
283
  executor: session.executor.kind,
188
284
  deadlineAt,
189
285
  idleTimeoutMs: configuredIdleMs,
286
+ exportedEnvironment: CHILD_ATTRIBUTION_ENV_KEYS,
287
+ ...runtimeAttribution(),
288
+ definitionHash: plan.definitionHash,
289
+ definitionPackageVersion: plan.definitionPackageVersion,
190
290
  correlation: plan.correlation,
191
291
  now: startedAt,
192
292
  }),
@@ -219,11 +319,12 @@ export async function executePlannedChild(input: {
219
319
  leaseLost = true;
220
320
  leaseAbort.abort();
221
321
  });
222
- const executionSignal = signal
223
- ? AbortSignal.any([signal, leaseAbort.signal])
224
- : writerLease
225
- ? leaseAbort.signal
226
- : undefined;
322
+ const executionSignals = [
323
+ costAbort.signal,
324
+ ...(signal ? [signal] : []),
325
+ ...(writerLease ? [leaseAbort.signal] : []),
326
+ ];
327
+ const executionSignal = AbortSignal.any(executionSignals);
227
328
  const retention = beginExecutionRetention({
228
329
  executionId,
229
330
  parentExecutionId,
@@ -265,6 +366,10 @@ export async function executePlannedChild(input: {
265
366
  deadlineAt,
266
367
  idleTimeoutMs: configuredIdleMs,
267
368
  ...(pane ? { herdrPaneId: pane.id, herdrAgentName: pane.agentName } : {}),
369
+ exportedEnvironment: CHILD_ATTRIBUTION_ENV_KEYS,
370
+ ...runtimeAttribution(),
371
+ definitionHash: plan.definitionHash,
372
+ definitionPackageVersion: plan.definitionPackageVersion,
268
373
  correlation: plan.correlation,
269
374
  now: new Date(),
270
375
  }),
@@ -343,6 +448,7 @@ export async function executePlannedChild(input: {
343
448
  failed: childFailed,
344
449
  });
345
450
  const usageObservation = await activitySession.usage();
451
+ await observeCost(usageObservation.usage?.cost.total, usageObservation.usage === undefined);
346
452
  releaseReason = output.timedOut ? "timeout" : output.aborted ? "cancelled" : childFailed ? "failed" : "completed";
347
453
  if (activityStarted)
348
454
  try {
@@ -386,9 +492,22 @@ export async function executePlannedChild(input: {
386
492
  idleTimeoutMs: configuredIdleMs,
387
493
  reason:
388
494
  output.spawnError ?? (output.timedOut ? (output.idle ? "idle-timeout" : "wall-clock") : undefined),
495
+ exportedEnvironment: CHILD_ATTRIBUTION_ENV_KEYS,
496
+ definitionHash: plan.definitionHash,
497
+ definitionPackageVersion: plan.definitionPackageVersion,
498
+ ...runtimeAttribution({
499
+ ...(usageObservation.resolvedModel ? { resolvedModel: usageObservation.resolvedModel } : {}),
500
+ ...(usageObservation.effectiveThinkingLevel
501
+ ? { thinking: usageObservation.effectiveThinkingLevel }
502
+ : {}),
503
+ }),
504
+ ...(usageObservation.tokenDetail ? { tokenDetail: usageObservation.tokenDetail } : {}),
389
505
  ...(usageObservation.usage
390
506
  ? { usage: usageObservation.usage }
391
507
  : { usageUnavailable: usageObservation.unavailable! }),
508
+ ...(usageObservation.compactionCount !== undefined
509
+ ? { compactionCount: usageObservation.compactionCount }
510
+ : {}),
392
511
  correlation: plan.correlation,
393
512
  now: new Date(),
394
513
  }),
@@ -403,7 +522,9 @@ export async function executePlannedChild(input: {
403
522
  : output.aborted
404
523
  ? leaseWasLost
405
524
  ? "lost the exclusive writer lease protecting its workspace and was stopped"
406
- : "was cancelled"
525
+ : costStopped
526
+ ? "crossed the episode cost ceiling and was stopped"
527
+ : "was cancelled"
407
528
  : output.timedOut
408
529
  ? output.idle
409
530
  ? `showed no activity for ${describeBound(configuredIdleMs)} and was killed ` +
@@ -473,6 +594,11 @@ export async function executePlannedChild(input: {
473
594
  });
474
595
  retainWriterLease = Boolean(writerLease && isHerdrWriterCloseFailure(error));
475
596
  const usageObservation = await activitySession.usage();
597
+ try {
598
+ await observeCost(usageObservation.usage?.cost.total, usageObservation.usage === undefined);
599
+ } catch {
600
+ /* cost observation must not replace the executor's primary failure */
601
+ }
476
602
  if (ledgerPath && !terminalAttempted) {
477
603
  // Best-effort: this records the failure, so it must not REPLACE the failure. A strict append that
478
604
  // throws here would discard the original error — including HerdrWriterCloseError, whose whole
@@ -498,9 +624,22 @@ export async function executePlannedChild(input: {
498
624
  : error instanceof Error
499
625
  ? error.name
500
626
  : "unknown executor error",
627
+ exportedEnvironment: CHILD_ATTRIBUTION_ENV_KEYS,
628
+ definitionHash: plan.definitionHash,
629
+ definitionPackageVersion: plan.definitionPackageVersion,
630
+ ...runtimeAttribution({
631
+ ...(usageObservation.resolvedModel ? { resolvedModel: usageObservation.resolvedModel } : {}),
632
+ ...(usageObservation.effectiveThinkingLevel
633
+ ? { thinking: usageObservation.effectiveThinkingLevel }
634
+ : {}),
635
+ }),
636
+ ...(usageObservation.tokenDetail ? { tokenDetail: usageObservation.tokenDetail } : {}),
501
637
  ...(usageObservation.usage
502
638
  ? { usage: usageObservation.usage }
503
639
  : { usageUnavailable: usageObservation.unavailable! }),
640
+ ...(usageObservation.compactionCount !== undefined
641
+ ? { compactionCount: usageObservation.compactionCount }
642
+ : {}),
504
643
  correlation: plan.correlation,
505
644
  now: new Date(),
506
645
  }),
@@ -20,6 +20,7 @@ import type { GatedPlan } from "./run-delegation.ts";
20
20
  import { handleConnectedCommand } from "./grants-connected-command.ts";
21
21
  import { loadApprovals, revokeAll, revokeApproval, type SubjectLookup } from "../src/governance/approval-store.ts";
22
22
  import { verifyLedger } from "../src/governance/ledger.ts";
23
+ import type { ResolvedDefinitionRuntime } from "./definition-runtime.ts";
23
24
 
24
25
  export interface GrantsCommandContext {
25
26
  cwd: string;
@@ -60,6 +61,13 @@ export interface GrantsCommandContext {
60
61
  * R-28 discipline where it belongs: this command asks what would happen instead of working it out.
61
62
  */
62
63
  previewDelegation: (name: string) => Promise<GatedPlan>;
64
+ runtimeFor: (name: string) => ResolvedDefinitionRuntime;
65
+ changeModels: (ui: {
66
+ input: GrantsCommandContext["modelInput"];
67
+ notify: GrantsCommandContext["modelNotify"];
68
+ }) => Promise<string>;
69
+ modelInput(title: string, placeholder?: string): Promise<string | undefined>;
70
+ modelNotify(message: string, type?: "info" | "warning" | "error"): void;
63
71
  /**
64
72
  * Run `/grants init`: scaffold definitions, ask about withheld capabilities, atomically store grant plus
65
73
  * project-ledger consent outside the workspace, and apply both now (ADR-0030, ADR-0037).
@@ -83,7 +91,15 @@ export interface GrantsCommandContext {
83
91
  const PREVIEW_LIMIT = 12;
84
92
 
85
93
  /** The verbs `/grants` answers to. Anything else is refused rather than silently treated as no verb. */
86
- const KNOWN_SUBCOMMANDS: readonly string[] = ["init", "dashboard", "ledger", "approvals", "revoke", "workspaces"];
94
+ const KNOWN_SUBCOMMANDS: readonly string[] = [
95
+ "init",
96
+ "dashboard",
97
+ "ledger",
98
+ "approvals",
99
+ "revoke",
100
+ "workspaces",
101
+ "models",
102
+ ];
87
103
 
88
104
  export const grantsCommand = {
89
105
  description:
@@ -110,6 +126,7 @@ export const grantsCommand = {
110
126
  inheritedApprovals,
111
127
  snapshotOf,
112
128
  previewDelegation,
129
+ runtimeFor,
113
130
  } = ctx.grants as GrantsCommandContext;
114
131
 
115
132
  const [sub, target] = args.trim().split(/\s+/).filter(Boolean);
@@ -127,6 +144,15 @@ export const grantsCommand = {
127
144
  )
128
145
  return;
129
146
 
147
+ if (sub === "models") {
148
+ const rendered = await ctx.grants.changeModels({
149
+ input: ctx.grants.modelInput,
150
+ notify: ctx.grants.modelNotify,
151
+ });
152
+ ctx.ui.notify(`grants: current session model overrides\n${rendered}`, "info");
153
+ return;
154
+ }
155
+
130
156
  if (sub === "workspaces") {
131
157
  // The one verb that WRITES, and it takes its ability to do so from the caller — the same shape
132
158
  // `/grants init` uses, so this screen cannot become a governance path by accident.
@@ -445,6 +471,10 @@ export const grantsCommand = {
445
471
  const shown = [...definitions].slice(0, PREVIEW_LIMIT);
446
472
  for (const [name] of shown) {
447
473
  const { plan, approval } = await previewDelegation(name);
474
+ const runtime = runtimeFor(name);
475
+ const runtimeText =
476
+ `model ${runtime.model ?? "pi default"} (${runtime.modelSource}); ` +
477
+ `thinking ${runtime.thinking ?? "pi default"} (${runtime.thinkingSource})`;
448
478
  // Why it is allowed, when a standing approval is the reason. An `allow` that silently depends on a
449
479
  // 30-day entry in a file elsewhere is precisely what an operator runs this command to discover, and
450
480
  // R-38 was the version of this listing that could not have told them (it said BLOCK instead).
@@ -455,7 +485,7 @@ export const grantsCommand = {
455
485
  ? ` (${approval.approved.map((c) => `${c} approved: ${approval.sources[c] ?? "?"}`).join("; ")})`
456
486
  : "";
457
487
  lines.push(
458
- ` ${plan.ok ? "allow" : "BLOCK"} ${name}` +
488
+ ` ${plan.ok ? "allow" : "BLOCK"} ${name} [${runtimeText}]` +
459
489
  (plan.ok ? ` ${plan.effective.join(", ")}${because}` : ` — ${plan.reason}`),
460
490
  );
461
491
  }
@@ -55,6 +55,9 @@ import { legacyEnvironmentWarning } from "../src/kernel/env-names.ts";
55
55
  import { agentDir } from "../src/kernel/project-paths.ts";
56
56
  import { importLegacyLedger } from "../src/governance/ledger.ts";
57
57
  import { legacyProjectLedgerPath, projectLedgerPath } from "../src/kernel/project-paths.ts";
58
+ import { resolveDefinitionRuntime } from "./definition-runtime.ts";
59
+ import { changeSessionModels, renderSessionModels } from "./session-model-prompt.ts";
60
+ import { ensureDashboardSessionServer } from "./dashboard-session-server.ts";
58
61
  export default function (pi: ExtensionAPI) {
59
62
  // The path pi loads as the extension, so a child granted `tool:delegate` can be started with `-e <this>`.
60
63
  const extensionPath = (() => {
@@ -227,6 +230,7 @@ export default function (pi: ExtensionAPI) {
227
230
  pluginRoot: dashboardPluginRoot,
228
231
  preferencePath: dashboardPaths.preferencePath,
229
232
  paneStatePath: dashboardPaths.paneStatePath,
233
+ sessionEndpoint: await ensureDashboardSessionServer(session),
230
234
  ui: ctx.ui,
231
235
  });
232
236
  } catch (error) {
@@ -413,7 +417,7 @@ export default function (pi: ExtensionAPI) {
413
417
  await loadProjectDefinitions(session, ctx.cwd);
414
418
  delegation.refreshSpawnable();
415
419
  }),
416
- openDashboard: () =>
420
+ openDashboard: async () =>
417
421
  openDashboardCommand({
418
422
  env: process.env,
419
423
  pid: process.pid,
@@ -421,8 +425,26 @@ export default function (pi: ExtensionAPI) {
421
425
  ledgerPath: session.ledgerPath ?? defaultActivityTimelinePath(ctx.cwd),
422
426
  pluginRoot: dashboardPluginRoot,
423
427
  paneStatePath: dashboardPaths.paneStatePath,
428
+ sessionEndpoint: await ensureDashboardSessionServer(session),
424
429
  }),
425
430
  previewDelegation: (name: string) => planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
431
+ modelInput: (title: string, placeholder?: string) => ctx.ui.input(title, placeholder),
432
+ modelNotify: (message: string, type?: "info" | "warning" | "error") => ctx.ui.notify(message, type),
433
+ changeModels: async (ui: {
434
+ input(title: string, placeholder?: string): Promise<string | undefined>;
435
+ notify(message: string, type?: "info" | "warning" | "error"): void;
436
+ }) => {
437
+ await changeSessionModels(session, { hasUI: ctx.hasUI, ...ui });
438
+ return renderSessionModels(session);
439
+ },
440
+ runtimeFor: (name: string) =>
441
+ resolveDefinitionRuntime({
442
+ definition: name,
443
+ explicit: {},
444
+ session: session.definitionRuntimeOverrides,
445
+ settings: session.definitionRuntimeSettings,
446
+ piModel: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
447
+ }),
426
448
  },
427
449
  }),
428
450
  });
@@ -15,7 +15,7 @@
15
15
  * selection stands — the same "identical to today" property the effort decision point has.
16
16
  */
17
17
  import { selectPrunedTurns, type ContextRequest } from "../src/kernel/context-handoff.ts";
18
- import type { Advisor } from "../src/advisors/advisor.ts";
18
+ import type { AdviceAttribution, Advisor } from "../src/advisors/advisor.ts";
19
19
  import type { Question } from "../src/advisors/decider.ts";
20
20
 
21
21
  export const PRUNING_PURPOSE = "handoff-pruning";
@@ -39,6 +39,7 @@ export async function advisePruning(input: {
39
39
  executionId?: string;
40
40
  /** The current delegation call, excluded from advisor input because its arguments contain the raw task. */
41
41
  toolCallId?: string;
42
+ attribution?: AdviceAttribution;
42
43
  signal?: AbortSignal;
43
44
  }): Promise<string[] | undefined> {
44
45
  if (input.granted.mode !== "pruned" || !input.session.parentSession) return undefined;
@@ -85,6 +86,7 @@ export async function advisePruning(input: {
85
86
  { state: {}, questions },
86
87
  input.signal,
87
88
  input.executionId,
89
+ input.attribution,
88
90
  );
89
91
  if (!advice) return undefined;
90
92
  // Every candidate or none. A response missing eleven of twelve answers would otherwise read as "drop eleven",
@@ -32,6 +32,8 @@ import { GovernanceRefusal, refusal as structuredRefusal } from "../src/kernel/r
32
32
  import { executePlannedChild, type DelegationOutcome } from "./execute-child.ts";
33
33
  import { recordDelegationDecision, type ApprovalLedgerFacts } from "./delegation-ledger.ts";
34
34
  import type { ExecutionOccurrenceIds } from "./execution-occurrence.ts";
35
+ import { ENV_EPISODE_COST_CEILING } from "../src/kernel/env-names.ts";
36
+ import { resolvedModelOf, resolveDefinitionRuntime } from "./definition-runtime.ts";
35
37
  import {
36
38
  governedWorkspaceAccess,
37
39
  prepareDelegationWorkspace,
@@ -47,6 +49,8 @@ interface ChildSpec {
47
49
  tools?: string[];
48
50
  model?: string;
49
51
  thinking?: string;
52
+ /** Positive USD override for the whole episode, applied only when this delegation will run. */
53
+ episodeCostCeiling?: number;
50
54
  /** ADR-0078: what of the parent's session crosses. Validated in the kernel, never here. */
51
55
  context?: unknown;
52
56
  correlation?: CorrelationMetadata;
@@ -246,14 +250,25 @@ export async function runOneDelegation(
246
250
  // pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
247
251
  // alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
248
252
  const defaultModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
253
+ const configured = resolveDefinitionRuntime({
254
+ definition: spec.agent,
255
+ explicit: { model: spec.model, thinking: spec.thinking },
256
+ session: session.definitionRuntimeOverrides,
257
+ settings: session.definitionRuntimeSettings,
258
+ piModel: defaultModel,
259
+ });
260
+ let resolvedRuntime = configured;
249
261
  const request = {
250
262
  task: spec.task,
251
263
  agent: spec.agent,
252
264
  tools: spec.tools,
253
- model: spec.model ?? defaultModel,
265
+ model: configured.model,
254
266
  // Filled below, once the refusals that doom a delegation are known: asking first shipped the task text for a
255
267
  // child that never starts, which is the ordering this module already fixed for the approval dialog.
256
- thinking: spec.thinking,
268
+ thinking:
269
+ configured.thinkingSource === "explicit" || configured.thinkingSource === "session"
270
+ ? configured.thinking
271
+ : undefined,
257
272
  context: spec.context,
258
273
  correlation: spec.workspace
259
274
  ? { ...(spec.correlation ?? {}), workspace_id: spec.workspace.workspace_id }
@@ -296,17 +311,28 @@ export async function runOneDelegation(
296
311
 
297
312
  // ADR-0077's first decision point, after the refusal checks for the reason above. Fills a blank from the levels
298
313
  // the CHILD's model reports; never overrules a caller, and yields today's behaviour whenever there is no answer.
299
- if (!executorRefusal && !modelRefusal)
300
- request.thinking = await adviseEffort({
314
+ if (!executorRefusal && !modelRefusal) {
315
+ const advisedThinking = await adviseEffort({
301
316
  session,
302
- requested: spec.thinking,
303
- model: spec.model ?? defaultModel,
317
+ requested: request.thinking,
318
+ model: configured.model,
304
319
  registry: ctx.modelRegistry,
305
320
  task: spec.task,
306
321
  executionId: ids.executionId,
307
322
  agent: spec.agent,
323
+ fallback: configured,
308
324
  signal,
309
325
  });
326
+ resolvedRuntime = resolveDefinitionRuntime({
327
+ definition: spec.agent,
328
+ explicit: { model: spec.model, thinking: spec.thinking },
329
+ session: session.definitionRuntimeOverrides,
330
+ settings: session.definitionRuntimeSettings,
331
+ piModel: defaultModel,
332
+ advisorThinking: advisedThinking,
333
+ });
334
+ request.thinking = resolvedRuntime.thinking;
335
+ }
310
336
 
311
337
  const planContext = await handoffPlanContext({
312
338
  session,
@@ -316,6 +342,12 @@ export async function runOneDelegation(
316
342
  toolCallId,
317
343
  blocked: Boolean(executorRefusal || modelRefusal),
318
344
  preview: () => planWithApprovals(session, request, extra, null, signal, preApproved).then((r) => r.plan),
345
+ attribution: {
346
+ resolvedModel: resolvedModelOf(resolvedRuntime.model),
347
+ modelSource: resolvedRuntime.modelSource,
348
+ thinkingLevel: resolvedRuntime.thinking ?? null,
349
+ thinkingSource: resolvedRuntime.thinkingSource,
350
+ },
319
351
  ...(signal ? { signal } : {}),
320
352
  });
321
353
  let preparedWorkspace: PreparedWorkspace | undefined;
@@ -427,6 +459,14 @@ export async function runOneDelegation(
427
459
  };
428
460
  }
429
461
 
462
+ if (spec.episodeCostCeiling !== undefined) session.episodeCostGate.setCeiling(spec.episodeCostCeiling);
463
+ plan = {
464
+ ...plan,
465
+ env: { ...plan.env, [ENV_EPISODE_COST_CEILING]: String(session.episodeCostGate.ceiling) },
466
+ };
467
+ const costUI = ctx.ui as typeof ctx.ui & {
468
+ input(title: string, placeholder?: string, opts?: { signal?: AbortSignal }): Promise<string | undefined>;
469
+ };
430
470
  return executePlannedChild({
431
471
  session,
432
472
  plan,
@@ -437,8 +477,14 @@ export async function runOneDelegation(
437
477
  toolCallId: options.toolCallId,
438
478
  cwd: ctx.cwd,
439
479
  preparedWorkspace,
480
+ resolvedRuntime,
440
481
  signal,
441
482
  onProgress,
483
+ costGateUI: {
484
+ hasUI: ctx.hasUI,
485
+ input: (title, placeholder, gateSignal) => costUI.input(title, placeholder, { signal: gateSignal }),
486
+ notify: (message) => ctx.ui.notify(message, "warning"),
487
+ },
442
488
  });
443
489
  }
444
490
 
@@ -462,6 +508,7 @@ export async function handoffPlanContext(input: {
462
508
  blocked: boolean;
463
509
  /** Plans with no human in the loop; its result decides whether an advisor is consulted at all. */
464
510
  preview: () => Promise<{ handoff?: { mode: string } }>;
511
+ attribution?: Parameters<typeof advisePruning>[0]["attribution"];
465
512
  signal?: AbortSignal;
466
513
  }): Promise<Record<string, unknown>> {
467
514
  if (input.blocked) return { ...input.base };
@@ -473,6 +520,7 @@ export async function handoffPlanContext(input: {
473
520
  task: input.task,
474
521
  ...(input.executionId ? { executionId: input.executionId } : {}),
475
522
  ...(input.toolCallId ? { toolCallId: input.toolCallId } : {}),
523
+ ...(input.attribution ? { attribution: input.attribution } : {}),
476
524
  ...(input.signal ? { signal: input.signal } : {}),
477
525
  });
478
526
  return ids ? { ...input.base, handoffTurnIds: ids } : { ...input.base };