pi-daddy 0.40.1 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/contracts/ledger-record/v1/fixtures/capability-decision.json +1 -0
  3. package/contracts/ledger-record/v1/fixtures/child-lifecycle.json +16 -0
  4. package/contracts/ledger-record/v1/fixtures/ledger-record.jsonl +3 -3
  5. package/contracts/ledger-record/v1/fixtures/workspace-lease.json +1 -0
  6. package/contracts/ledger-record/v1/governance-event.schema.json +100 -0
  7. package/dist/advisors/advisor.d.ts +19 -1
  8. package/dist/advisors/advisor.d.ts.map +1 -1
  9. package/dist/advisors/advisor.js +62 -2
  10. package/dist/advisors/advisor.js.map +1 -1
  11. package/dist/advisors/decider.d.ts +2 -4
  12. package/dist/advisors/decider.d.ts.map +1 -1
  13. package/dist/advisors/decider.js.map +1 -1
  14. package/dist/advisors/settings.d.ts +3 -1
  15. package/dist/advisors/settings.d.ts.map +1 -1
  16. package/dist/advisors/settings.js +4 -3
  17. package/dist/advisors/settings.js.map +1 -1
  18. package/dist/executors/activity-session.d.ts +7 -0
  19. package/dist/executors/activity-session.d.ts.map +1 -1
  20. package/dist/executors/activity-session.js +101 -3
  21. package/dist/executors/activity-session.js.map +1 -1
  22. package/dist/governance/ledger-events.d.ts +23 -0
  23. package/dist/governance/ledger-events.d.ts.map +1 -1
  24. package/dist/governance/ledger-events.js +7 -0
  25. package/dist/governance/ledger-events.js.map +1 -1
  26. package/dist/governance/ledger-v3-validation.d.ts.map +1 -1
  27. package/dist/governance/ledger-v3-validation.js +29 -0
  28. package/dist/governance/ledger-v3-validation.js.map +1 -1
  29. package/dist/governance/ledger.d.ts +5 -1
  30. package/dist/governance/ledger.d.ts.map +1 -1
  31. package/dist/governance/ledger.js +4 -0
  32. package/dist/governance/ledger.js.map +1 -1
  33. package/dist/kernel/context-handoff.d.ts.map +1 -1
  34. package/dist/kernel/context-handoff.js +4 -1
  35. package/dist/kernel/context-handoff.js.map +1 -1
  36. package/dist/kernel/delegate-types.d.ts +2 -0
  37. package/dist/kernel/delegate-types.d.ts.map +1 -1
  38. package/dist/kernel/delegate-types.js.map +1 -1
  39. package/dist/kernel/delegate.d.ts.map +1 -1
  40. package/dist/kernel/delegate.js +3 -1
  41. package/dist/kernel/delegate.js.map +1 -1
  42. package/dist/kernel/env-names.d.ts +3 -0
  43. package/dist/kernel/env-names.d.ts.map +1 -1
  44. package/dist/kernel/env-names.js +5 -0
  45. package/dist/kernel/env-names.js.map +1 -1
  46. package/dist/kernel/episode-id.d.ts +6 -0
  47. package/dist/kernel/episode-id.d.ts.map +1 -0
  48. package/dist/kernel/episode-id.js +13 -0
  49. package/dist/kernel/episode-id.js.map +1 -0
  50. package/dist/kernel/propagation.d.ts +4 -2
  51. package/dist/kernel/propagation.d.ts.map +1 -1
  52. package/dist/kernel/propagation.js +6 -2
  53. package/dist/kernel/propagation.js.map +1 -1
  54. package/dist/products/activity-timeline.d.ts +3 -0
  55. package/dist/products/activity-timeline.d.ts.map +1 -1
  56. package/dist/products/activity-timeline.js +6 -0
  57. package/dist/products/activity-timeline.js.map +1 -1
  58. package/extensions/activity-timeline.ts +11 -5
  59. package/extensions/advisor-session.ts +3 -0
  60. package/extensions/chain-ledger.ts +1 -0
  61. package/extensions/context-shape.ts +19 -4
  62. package/extensions/delegation-ledger.ts +1 -0
  63. package/extensions/effort-advice.ts +6 -5
  64. package/extensions/execute-child.ts +16 -0
  65. package/extensions/grant-store-refusal.ts +1 -0
  66. package/extensions/grants-command.ts +4 -4
  67. package/extensions/grants.ts +4 -1
  68. package/extensions/pruning-advice.ts +37 -6
  69. package/extensions/reload-environment.ts +1 -0
  70. package/extensions/run-delegation.ts +10 -1
  71. package/extensions/session-environment.ts +9 -0
  72. package/extensions/session.ts +27 -4
  73. package/extensions/workspace-runtime.ts +5 -0
  74. package/package.json +1 -1
  75. package/src/advisors/advisor.ts +83 -3
  76. package/src/advisors/decider.ts +2 -4
  77. package/src/advisors/settings.ts +6 -4
  78. package/src/executors/activity-session.ts +105 -3
  79. package/src/governance/ledger-events.ts +35 -1
  80. package/src/governance/ledger-v3-validation.ts +44 -0
  81. package/src/governance/ledger.ts +8 -0
  82. package/src/kernel/context-handoff.ts +5 -1
  83. package/src/kernel/delegate-types.ts +2 -0
  84. package/src/kernel/delegate.ts +2 -0
  85. package/src/kernel/env-names.ts +5 -0
  86. package/src/kernel/episode-id.ts +18 -0
  87. package/src/kernel/propagation.ts +8 -0
  88. package/src/products/activity-timeline.ts +8 -0
@@ -3,6 +3,7 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
5
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { ENV_EPISODE_ID } from "../src/kernel/env-names.ts";
6
7
  import { Type } from "typebox";
7
8
  import {
8
9
  ActivityTimelineRecorder,
@@ -13,6 +14,7 @@ import {
13
14
 
14
15
  export interface ActivitySessionState {
15
16
  activityRootId?: string;
17
+ episodeId?: string;
16
18
  activity?: ActivityIdentity & { taskId?: string };
17
19
  }
18
20
  interface HookContext {
@@ -44,15 +46,19 @@ export function registerActivityTimeline(pi: ExtensionAPI, session?: ActivitySes
44
46
  }
45
47
  };
46
48
  const current = (ctx: HookContext): ActivityTimelineRecorder => {
47
- const env =
48
- session?.activityRootId && !process.env.PI_DADDY_ACTIVITY_ROOT
49
- ? { ...process.env, PI_DADDY_ACTIVITY_ROOT: session.activityRootId }
50
- : process.env;
49
+ const env = {
50
+ ...process.env,
51
+ ...(session?.activityRootId && !process.env.PI_DADDY_ACTIVITY_ROOT
52
+ ? { PI_DADDY_ACTIVITY_ROOT: session.activityRootId }
53
+ : {}),
54
+ ...(session?.episodeId ? { [ENV_EPISODE_ID]: session.episodeId } : {}),
55
+ };
51
56
  // session_start binds reload ownership after this hook registration; re-create before the first turn
52
57
  // if that binding recovered a prior root identity rather than the factory's provisional UUID.
53
58
  if (
54
59
  !recorder ||
55
- (!process.env.PI_DADDY_ACTIVITY_ROOT && session?.activityRootId && recorder.rootId !== session.activityRootId)
60
+ (!process.env.PI_DADDY_ACTIVITY_ROOT && session?.activityRootId && recorder.rootId !== session.activityRootId) ||
61
+ (session?.episodeId !== undefined && recorder.episodeId !== session.episodeId)
56
62
  )
57
63
  recorder = new ActivityTimelineRecorder(ctx.cwd, env);
58
64
  return recorder;
@@ -33,6 +33,7 @@ export function createAdvisorSession(input: {
33
33
  block: unknown;
34
34
  ledgerPath?: string;
35
35
  env?: NodeJS.ProcessEnv;
36
+ episodeId?: string;
36
37
  }): AdvisorSession {
37
38
  const env = input.env ?? process.env;
38
39
  const settings = advisorSettingsFrom(input.block, env);
@@ -58,6 +59,8 @@ export function createAdvisorSession(input: {
58
59
  decider,
59
60
  record,
60
61
  enabled: settings.enabled,
62
+ taskEgress: settings.taskEgress,
63
+ ...(input.episodeId ? { episodeId: input.episodeId } : {}),
61
64
  ...(settings.timeoutMs ? { timeoutMs: settings.timeoutMs } : {}),
62
65
  }),
63
66
  };
@@ -21,6 +21,7 @@ export async function recordChainRefusal(input: {
21
21
  await appendRecord(
22
22
  { path: input.session.ledgerPath, strict: true },
23
23
  buildRecord({
24
+ episodeId: input.session.episodeId,
24
25
  executionId: input.executionId,
25
26
  parentExecutionId: input.parentExecutionId,
26
27
  parentId: input.session.ownSpawnId,
@@ -7,7 +7,12 @@
7
7
  * first. This shape exists to tell the model what the parameter is for.
8
8
  */
9
9
  import { Type } from "typebox";
10
- import { CONTEXT_MODES } from "../src/kernel/context-handoff.ts";
10
+ import {
11
+ CONTEXT_MODES,
12
+ DEFAULT_CONTEXT_TURNS,
13
+ MAX_CONTEXT_FILES,
14
+ MAX_CONTEXT_TURNS,
15
+ } from "../src/kernel/context-handoff.ts";
11
16
 
12
17
  export function contextShape() {
13
18
  return Type.Object({
@@ -18,13 +23,23 @@ export function contextShape() {
18
23
  "What of YOUR session this sub-agent receives. none: nothing beyond its definition and the task. " +
19
24
  "files: the contents of paths you name. pruned: recent turns of your session plus turns naming those " +
20
25
  "files. summary: what you write in 'summary'. fork: your whole session (needs a human's approval). " +
21
- "A definition's allowed-tools caps this; asking for more than it permits is refused.",
26
+ "A definition's allowed-tools caps this; asking for more than it permits is refused. If the child has " +
27
+ "read tools in the same workspace, prefer none and ask it to inspect the checkout instead of embedding files.",
22
28
  },
23
29
  ),
24
30
  files: Type.Optional(
25
- Type.Array(Type.String(), { description: "Repository-relative paths, for files and pruned." }),
31
+ Type.Array(Type.String({ minLength: 1 }), {
32
+ maxItems: MAX_CONTEXT_FILES,
33
+ description: `At most ${MAX_CONTEXT_FILES} repository-relative paths, for files and pruned.`,
34
+ }),
26
35
  ),
27
36
  summary: Type.Optional(Type.String({ description: "What the sub-agent needs to know, in your own words." })),
28
- turns: Type.Optional(Type.Number({ description: "For pruned: how many recent turns to keep. Default 6." })),
37
+ turns: Type.Optional(
38
+ Type.Integer({
39
+ minimum: 1,
40
+ maximum: MAX_CONTEXT_TURNS,
41
+ description: `For pruned: how many recent turns to keep. Default ${DEFAULT_CONTEXT_TURNS}.`,
42
+ }),
43
+ ),
29
44
  });
30
45
  }
@@ -29,6 +29,7 @@ export async function recordDelegationDecision(input: {
29
29
  await appendRecord(
30
30
  { path: ledgerPath, strict: true },
31
31
  buildRecord({
32
+ episodeId: session.episodeId,
32
33
  executionId: ids.executionId,
33
34
  parentExecutionId: ids.parentExecutionId,
34
35
  parentId: ids.parentId,
@@ -38,6 +38,7 @@ export async function adviseEffort(input: {
38
38
  model?: string;
39
39
  registry: { find(provider: string, modelId: string): unknown };
40
40
  task: string;
41
+ executionId?: string;
41
42
  agent?: string;
42
43
  signal?: AbortSignal;
43
44
  }): Promise<string | undefined> {
@@ -52,14 +53,13 @@ export async function adviseEffort(input: {
52
53
  // ledger line to be told the only thing that could be said.
53
54
  if (levels.length < 2) return undefined;
54
55
 
56
+ const task = advisor.task(input.task);
55
57
  const advice = await advisor.ask(
56
58
  EFFORT_PURPOSE,
57
59
  {
58
- // The task text is what the decision is actually about, and it is the one thing this package has never
59
- // stored (ADR-0021). It is sent to the advisor because an advisor cannot judge a task it cannot see, and it
60
- // is NOT recorded: `createAdvisor` writes the question keys and the answer, never the state. An operator who
61
- // is not willing to send task text to a third party leaves the advisor off, which is the default.
62
- state: { task: input.task, ...(input.agent ? { definition: input.agent } : {}) },
60
+ // Raw text is a separate opt-in from enabling the advisor. By default `advisor.task` returns only structural
61
+ // facts; raw mode preserves the prior request, including the definition name.
62
+ state: { task, ...(typeof task === "string" && input.agent ? { definition: input.agent } : {}) },
63
63
  questions: {
64
64
  effort: {
65
65
  kind: "choice",
@@ -70,6 +70,7 @@ export async function adviseEffort(input: {
70
70
  },
71
71
  },
72
72
  input.signal,
73
+ input.executionId,
73
74
  );
74
75
  const chosen = advice?.answers.effort;
75
76
  // Belt and braces: `parseAnswer` already refuses a choice outside the options it was given, so this can only
@@ -22,6 +22,7 @@ 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
26
  import { releaseDelegationWorkspace, type PreparedWorkspace } from "./workspace-runtime.ts";
26
27
  import { ActivityTimelineRecorder, ENV_ACTIVITY_PARENT_TASK } from "../src/products/activity-timeline.ts";
27
28
  export interface DelegationOutcome {
@@ -120,6 +121,7 @@ export async function executePlannedChild(input: {
120
121
  "PI_DADDY_ACTIVITY_ROOT",
121
122
  "PI_DADDY_ACTIVITY_TASK",
122
123
  ENV_ACTIVITY_PARENT_TASK,
124
+ ENV_EPISODE_ID,
123
125
  ]) {
124
126
  const value = plan.env[name];
125
127
  if (value) env[name] = value;
@@ -177,6 +179,7 @@ export async function executePlannedChild(input: {
177
179
  await appendLedgerEvent(
178
180
  { path: ledgerPath, strict: true },
179
181
  buildChildLifecycleEvent({
182
+ episodeId: session.episodeId,
180
183
  executionId,
181
184
  parentExecutionId,
182
185
  childId,
@@ -192,6 +195,7 @@ export async function executePlannedChild(input: {
192
195
  await releaseDelegationWorkspace({
193
196
  prepared: preparedWorkspace,
194
197
  childId,
198
+ episodeId: session.episodeId,
195
199
  executionId,
196
200
  parentExecutionId,
197
201
  reason: "ledger-failed",
@@ -252,6 +256,7 @@ export async function executePlannedChild(input: {
252
256
  onFailure: (cause) => teardownFailures.push(`child runtime identity record failed: ${String(cause)}`),
253
257
  },
254
258
  buildChildLifecycleEvent({
259
+ episodeId: session.episodeId,
255
260
  executionId,
256
261
  parentExecutionId,
257
262
  childId,
@@ -337,6 +342,7 @@ export async function executePlannedChild(input: {
337
342
  truncated: output.truncated,
338
343
  failed: childFailed,
339
344
  });
345
+ const usageObservation = await activitySession.usage();
340
346
  releaseReason = output.timedOut ? "timeout" : output.aborted ? "cancelled" : childFailed ? "failed" : "completed";
341
347
  if (activityStarted)
342
348
  try {
@@ -366,6 +372,7 @@ export async function executePlannedChild(input: {
366
372
  onFailure: (cause) => teardownFailures.push(`child lifecycle record failed: ${String(cause)}`),
367
373
  },
368
374
  buildChildLifecycleEvent({
375
+ episodeId: session.episodeId,
369
376
  executionId,
370
377
  parentExecutionId,
371
378
  childId,
@@ -379,6 +386,9 @@ export async function executePlannedChild(input: {
379
386
  idleTimeoutMs: configuredIdleMs,
380
387
  reason:
381
388
  output.spawnError ?? (output.timedOut ? (output.idle ? "idle-timeout" : "wall-clock") : undefined),
389
+ ...(usageObservation.usage
390
+ ? { usage: usageObservation.usage }
391
+ : { usageUnavailable: usageObservation.unavailable! }),
382
392
  correlation: plan.correlation,
383
393
  now: new Date(),
384
394
  }),
@@ -462,6 +472,7 @@ export async function executePlannedChild(input: {
462
472
  failed: true,
463
473
  });
464
474
  retainWriterLease = Boolean(writerLease && isHerdrWriterCloseFailure(error));
475
+ const usageObservation = await activitySession.usage();
465
476
  if (ledgerPath && !terminalAttempted) {
466
477
  // Best-effort: this records the failure, so it must not REPLACE the failure. A strict append that
467
478
  // throws here would discard the original error — including HerdrWriterCloseError, whose whole
@@ -474,6 +485,7 @@ export async function executePlannedChild(input: {
474
485
  onFailure: (cause) => teardownFailures.push(`child lifecycle record failed: ${String(cause)}`),
475
486
  },
476
487
  buildChildLifecycleEvent({
488
+ episodeId: session.episodeId,
477
489
  executionId,
478
490
  parentExecutionId,
479
491
  childId,
@@ -486,6 +498,9 @@ export async function executePlannedChild(input: {
486
498
  : error instanceof Error
487
499
  ? error.name
488
500
  : "unknown executor error",
501
+ ...(usageObservation.usage
502
+ ? { usage: usageObservation.usage }
503
+ : { usageUnavailable: usageObservation.unavailable! }),
489
504
  correlation: plan.correlation,
490
505
  now: new Date(),
491
506
  }),
@@ -536,6 +551,7 @@ export async function executePlannedChild(input: {
536
551
  await releaseDelegationWorkspace({
537
552
  prepared: preparedWorkspace,
538
553
  childId,
554
+ episodeId: session.episodeId,
539
555
  executionId,
540
556
  parentExecutionId,
541
557
  ledgerPath,
@@ -22,6 +22,7 @@ export async function reportGrantStoreRefusal(session: GrantsSession, ui: Refusa
22
22
  await appendRecord(
23
23
  { path: session.ledgerPath, strict: true },
24
24
  buildRecord({
25
+ episodeId: session.episodeId,
25
26
  executionId: newExecutionId(),
26
27
  parentExecutionId: null,
27
28
  parentId: session.ownSpawnId,
@@ -32,8 +32,8 @@ export interface GrantsCommandContext {
32
32
  * sentence from the one the session banner printed. Two spellings of one fact is R-28.
33
33
  */
34
34
  executor: ExecutorChoice;
35
- /** ADR-0077: which advisor is in force, or why none is. Reported because an advisor sends task text out. */
36
- advisor: { decider: string; refusal?: string };
35
+ /** ADR-0077: which advisor and task-egress mode are in force, or why none is. */
36
+ advisor: { decider: string; taskEgress: "digest" | "raw"; refusal?: string };
37
37
  /** ADR-0042: which ids this session pinned, so a routing refusal is discoverable before it happens. */
38
38
  workspacePin?: ReadonlyMap<string, string>;
39
39
  /** Registry ids this machine has never accepted: present, not routable, and worth naming (rule 8). */
@@ -384,10 +384,10 @@ export const grantsCommand = {
384
384
  ` depth ${depth} of max ${maxDepth}${maxDepth <= 0 ? " (spawning disabled)" : ""}`,
385
385
  // Rule 8's loud half, which review found missing: an operator who upgraded from 0.34.0, or who mistyped the
386
386
  // variable, saw an advisor silently absent and nothing saying why. This is also where an operator sees that
387
- // task text leaves the machine, which no other surface says.
387
+ // which representation of the task leaves the machine, which no other status surface says.
388
388
  advisor.decider === "none"
389
389
  ? ` advisor off${advisor.refusal ? ` — ${advisor.refusal}` : ""}`
390
- : ` advisor ${advisor.decider} — a delegation with no thinking level sends it the task text; ` +
390
+ : ` advisor ${advisor.decider} — task egress ${advisor.taskEgress}; ` +
391
391
  `a pruned context handoff also sends session turns`,
392
392
  ` ledger ${ledgerPath || "(not recording — set PI_DADDY_LEDGER)"}`,
393
393
  ` approvals ${sessionApprovals.size} this session, ${valid.size} persisted` +
@@ -35,7 +35,7 @@ import { registerDelegationTools } from "./delegation.ts";
35
35
  import { grantsCommand } from "./grants-command.ts";
36
36
  import { runInit } from "./init-command.ts";
37
37
  import { planWithApprovals } from "./run-delegation.ts";
38
- import { createGrantsSession, loadProjectDefinitions, type GrantsSession } from "./session.ts";
38
+ import { createGrantsSession, loadProjectDefinitions, reconcileAdvisorSession, type GrantsSession } from "./session.ts";
39
39
  import { acceptWorkspaces } from "../src/governance/workspace-acceptance.ts";
40
40
  import { loadWorkspaceRegistry } from "../src/kernel/workspace.ts";
41
41
  import { bindReloadLifecycle } from "./reload-environment.ts";
@@ -93,6 +93,7 @@ export default function (pi: ExtensionAPI) {
93
93
  // execution tree across workspaces. Resolve it once at the root's actual pi cwd; descendants inherit
94
94
  // this absolute identity verbatim, and resolve(abs) remains abs at every deeper session start.
95
95
  if (session.ledgerPath) session.ledgerPath = resolve(ctx.cwd, session.ledgerPath);
96
+ reconcileAdvisorSession(session, reload.environment);
96
97
  try {
97
98
  // ADR-0076 PR 3d: a pre-format project ledger is imported once into the record envelope, bodies verbatim, each
98
99
  // record marked with its source line; the old file is left untouched. Happens before any spawn can append.
@@ -341,6 +342,7 @@ export default function (pi: ExtensionAPI) {
341
342
  await appendRecord(
342
343
  { path: session.ledgerPath, strict: true },
343
344
  buildRecord({
345
+ episodeId: session.episodeId,
344
346
  parentId: `d${session.depth}`,
345
347
  childId: `${event.toolName}@d${session.depth + 1}`,
346
348
  depth: session.depth + 1,
@@ -378,6 +380,7 @@ export default function (pi: ExtensionAPI) {
378
380
  ledgerPath: session.ledgerPath,
379
381
  advisor: {
380
382
  decider: session.advisorSession.deciderName,
383
+ taskEgress: session.advisorSession.settings.taskEgress,
381
384
  ...(session.advisorSession.settings.refusal ? { refusal: session.advisorSession.settings.refusal } : {}),
382
385
  },
383
386
  ...(session.workspacePin ? { workspacePin: session.workspacePin } : {}),
@@ -36,6 +36,9 @@ export async function advisePruning(input: {
36
36
  session: { advisorSession: { advisor: Advisor }; parentSession?: ParentTurnSource };
37
37
  granted: ContextRequest;
38
38
  task: string;
39
+ executionId?: string;
40
+ /** The current delegation call, excluded from advisor input because its arguments contain the raw task. */
41
+ toolCallId?: string;
39
42
  signal?: AbortSignal;
40
43
  }): Promise<string[] | undefined> {
41
44
  if (input.granted.mode !== "pruned" || !input.session.parentSession) return undefined;
@@ -44,7 +47,12 @@ export async function advisePruning(input: {
44
47
  ...(input.granted.turns !== undefined ? { turns: input.granted.turns } : {}),
45
48
  ...(input.granted.files !== undefined ? { files: input.granted.files } : {}),
46
49
  }).kept;
47
- if (candidates.length < 2) return undefined;
50
+ const privateIds = new Set(
51
+ input.toolCallId ? all.filter((turn) => turn.toolCallIds.includes(input.toolCallId!)).map((turn) => turn.id) : [],
52
+ );
53
+ const privateCandidates = candidates.filter((turn) => privateIds.has(turn.id));
54
+ const candidatesForAdvice = candidates.filter((turn) => !privateCandidates.includes(turn));
55
+ if (candidatesForAdvice.length < 2) return undefined;
48
56
  // **Judge the MOST RECENT candidates, and keep the rest unjudged.** This was `> MAX_JUDGED_TURNS` → give up
49
57
  // entirely, which was fine while the rule offered six turns and became a silent feature death the moment
50
58
  // `DEFAULT_CONTEXT_TURNS` rose to 20: every default request would have exceeded the bound, so the pruning
@@ -54,16 +62,18 @@ export async function advisePruning(input: {
54
62
  // Judging a SUBSET is still only narrowing — an unjudged turn is kept, never dropped — so the attenuation
55
63
  // property is untouched. The recent end is judged because that is where the rule's own recall is
56
64
  // concentrated, and because a turn adjacent to the task is the one a wrong answer costs most.
57
- const unjudged = candidates.slice(0, Math.max(0, candidates.length - MAX_JUDGED_TURNS));
58
- const judged = candidates.slice(unjudged.length);
65
+ const unjudged = candidatesForAdvice.slice(0, Math.max(0, candidatesForAdvice.length - MAX_JUDGED_TURNS));
66
+ const judged = candidatesForAdvice.slice(unjudged.length);
59
67
 
68
+ const task = input.session.advisorSession.advisor.task(input.task);
69
+ const taskDescription = typeof task === "string" ? task.slice(0, 2000) : JSON.stringify(task);
60
70
  const questions: Record<string, Question> = {};
61
71
  for (const [index, turn] of judged.entries())
62
72
  questions[`turn${index}`] = {
63
73
  kind: "noul",
64
74
  // Both bounded: the task is embedded once per candidate, so an unbounded task became a request twelve times
65
75
  // its size, on a two-second budget and the operator's key.
66
- instructions: `Would a sub-agent doing this task be helped by seeing this part of the parent's session?\n\nTASK: ${input.task.slice(0, 2000)}\n\nPART:\n${turn.text.slice(0, 2000)}`,
76
+ instructions: `Would a sub-agent doing this task be helped by seeing this part of the parent's session?\n\nTASK: ${taskDescription}\n\nPART:\n${turn.text.slice(0, 2000)}`,
67
77
  whenTrue: "It bears on the task: a decision, a constraint, a fact the task depends on.",
68
78
  whenFalse: "It does not: unrelated work, chatter, or something the task already states.",
69
79
  };
@@ -74,12 +84,14 @@ export async function advisePruning(input: {
74
84
  // task and one turn rather than the whole session. Nothing here is recorded — `createAdvisor` writes keys.
75
85
  { state: {}, questions },
76
86
  input.signal,
87
+ input.executionId,
77
88
  );
78
89
  if (!advice) return undefined;
79
90
  // Every candidate or none. A response missing eleven of twelve answers would otherwise read as "drop eleven",
80
91
  // which is a narrowing nobody asked for rather than the "unrecognised response means no advice" contract.
81
92
  if (judged.some((_, index) => advice.answers[`turn${index}`]?.kind !== "noul")) return undefined;
82
93
  const kept = [
94
+ ...privateCandidates.map((turn) => turn.id),
83
95
  ...unjudged.map((turn) => turn.id),
84
96
  ...judged.filter((_, index) => (advice.answers[`turn${index}`] as { value: boolean }).value).map((turn) => turn.id),
85
97
  ];
@@ -88,12 +100,31 @@ export async function advisePruning(input: {
88
100
  return kept.length === 0 ? undefined : kept;
89
101
  }
90
102
 
91
- function turnsOf(session: ParentTurnSource): Array<{ id: string; text: string }> {
103
+ function turnsOf(session: ParentTurnSource): Array<{ id: string; text: string; toolCallIds: string[] }> {
92
104
  try {
93
105
  return session
94
106
  .getEntries()
95
107
  .filter((entry) => entry.type === "message")
96
- .map((entry) => ({ id: entry.id, text: JSON.stringify((entry as { message?: unknown }).message ?? entry) }));
108
+ .map((entry) => {
109
+ const message = (entry as { message?: unknown }).message ?? entry;
110
+ const content =
111
+ typeof message === "object" && message !== null && Array.isArray((message as { content?: unknown }).content)
112
+ ? (message as { content: unknown[] }).content
113
+ : [];
114
+ return {
115
+ id: entry.id,
116
+ text: JSON.stringify(message),
117
+ toolCallIds: content
118
+ .filter(
119
+ (part): part is { type: "toolCall"; id: string } =>
120
+ typeof part === "object" &&
121
+ part !== null &&
122
+ (part as { type?: unknown }).type === "toolCall" &&
123
+ typeof (part as { id?: unknown }).id === "string",
124
+ )
125
+ .map((part) => part.id),
126
+ };
127
+ });
97
128
  } catch {
98
129
  return [];
99
130
  }
@@ -5,6 +5,7 @@ export interface ReloadLifecycle {
5
5
  root: Record<string, string | undefined>;
6
6
  published?: Record<string, string | undefined>;
7
7
  activityRootId?: string;
8
+ episodeId?: string;
8
9
  /**
9
10
  * The destination pin this owner settled on (ADR-0042), which must survive an extension reload.
10
11
  *
@@ -242,7 +242,7 @@ export async function runOneDelegation(
242
242
  approvalFacts?: ApprovalLedgerFacts;
243
243
  } = {},
244
244
  ): Promise<DelegationOutcome> {
245
- const { onProgress, preApproved, taskFrom, taskFromExecutionId, approvalFacts } = options;
245
+ const { toolCallId, onProgress, preApproved, taskFrom, taskFromExecutionId, approvalFacts } = options;
246
246
  // pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
247
247
  // alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
248
248
  const defaultModel = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
@@ -303,6 +303,7 @@ export async function runOneDelegation(
303
303
  model: spec.model ?? defaultModel,
304
304
  registry: ctx.modelRegistry,
305
305
  task: spec.task,
306
+ executionId: ids.executionId,
306
307
  agent: spec.agent,
307
308
  signal,
308
309
  });
@@ -311,6 +312,8 @@ export async function runOneDelegation(
311
312
  session,
312
313
  base: extra,
313
314
  task: spec.task,
315
+ executionId: ids.executionId,
316
+ toolCallId,
314
317
  blocked: Boolean(executorRefusal || modelRefusal),
315
318
  preview: () => planWithApprovals(session, request, extra, null, signal, preApproved).then((r) => r.plan),
316
319
  ...(signal ? { signal } : {}),
@@ -332,6 +335,7 @@ export async function runOneDelegation(
332
335
  spec: { ...spec.workspace, access: governedWorkspaceAccess(spec.workspace.access, plan.requested) },
333
336
  correlation: spec.correlation,
334
337
  childId: ids.childId,
338
+ episodeId: session.episodeId,
335
339
  executionId: ids.executionId,
336
340
  parentExecutionId: ids.parentExecutionId,
337
341
  signal,
@@ -403,6 +407,7 @@ export async function runOneDelegation(
403
407
  await releaseDelegationWorkspace({
404
408
  prepared: preparedWorkspace,
405
409
  childId: ids.childId,
410
+ episodeId: session.episodeId,
406
411
  executionId: ids.executionId,
407
412
  parentExecutionId: ids.parentExecutionId,
408
413
  ledgerPath: session.ledgerPath,
@@ -451,6 +456,8 @@ export async function handoffPlanContext(input: {
451
456
  session: Parameters<typeof advisePruning>[0]["session"];
452
457
  base: Record<string, unknown>;
453
458
  task: string;
459
+ executionId?: string;
460
+ toolCallId?: string;
454
461
  /** A refusal is already certain, so nothing may be asked. */
455
462
  blocked: boolean;
456
463
  /** Plans with no human in the loop; its result decides whether an advisor is consulted at all. */
@@ -464,6 +471,8 @@ export async function handoffPlanContext(input: {
464
471
  session: input.session,
465
472
  granted: plan.handoff as Parameters<typeof advisePruning>[0]["granted"],
466
473
  task: input.task,
474
+ ...(input.executionId ? { executionId: input.executionId } : {}),
475
+ ...(input.toolCallId ? { toolCallId: input.toolCallId } : {}),
467
476
  ...(input.signal ? { signal: input.signal } : {}),
468
477
  });
469
478
  return ids ? { ...input.base, handoffTurnIds: ids } : { ...input.base };
@@ -1,6 +1,7 @@
1
1
  import { parseInherited } from "../src/kernel/approval.ts";
2
2
  import { DELEGATE_CAPABILITY } from "../src/kernel/delegate.ts";
3
3
  import { budgetFromEnv } from "../src/kernel/fanout.ts";
4
+ import { isEpisodeId } from "../src/kernel/episode-id.ts";
4
5
  import { WILDCARD } from "../src/kernel/pi-tools.ts";
5
6
  import {
6
7
  depthConfig,
@@ -9,6 +10,7 @@ import {
9
10
  ENV_APPROVED,
10
11
  ENV_DEPTH,
11
12
  ENV_EXECUTION_ID,
13
+ ENV_EPISODE_ID,
12
14
  ENV_FANOUT,
13
15
  ENV_GATED,
14
16
  ENV_GRANT,
@@ -45,6 +47,13 @@ export function reconcileSessionEnvironment(
45
47
  session.executor = chooseExecutor(environment[ENV_HERDR], null);
46
48
  session.ownSpawnId = environment[ENV_PARENT_ID]?.trim() || `d${session.depth}`;
47
49
  session.ownExecutionId = environment[ENV_EXECUTION_ID]?.trim() || undefined;
50
+ const inheritedEpisodeId = environment[ENV_EPISODE_ID]?.trim();
51
+ session.episodeId = isEpisodeId(inheritedEpisodeId)
52
+ ? inheritedEpisodeId
53
+ : isEpisodeId(lifecycle.episodeId)
54
+ ? lifecycle.episodeId
55
+ : session.episodeId;
56
+ lifecycle.episodeId = session.episodeId;
48
57
  session.fanoutBudget = budgetFromEnv(environment[ENV_FANOUT]);
49
58
  session.mayDelegate =
50
59
  !session.governed || session.inherited.includes(DELEGATE_CAPABILITY) || session.inherited.includes(WILDCARD);
@@ -14,6 +14,7 @@
14
14
  * copy of `ownGrant` before observation is exactly how a stale upper bound would become an enforced one.
15
15
  */
16
16
  import { randomUUID } from "node:crypto";
17
+ import { isEpisodeId, newEpisodeId } from "../src/kernel/episode-id.ts";
17
18
  import { parseInherited, type InheritableApproval } from "../src/kernel/approval.ts";
18
19
  import type { ApprovalBinding } from "../src/kernel/correlation.ts";
19
20
  import { createApprovalGateProvider } from "../src/governance/approval-prompt.ts";
@@ -100,7 +101,7 @@ import {
100
101
  ENV_ACTIVITY_ROOT,
101
102
  ENV_ACTIVITY_TASK,
102
103
  } from "../src/products/activity-timeline.ts";
103
- import { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE, ENV_ADVISOR } from "../src/kernel/env-names.ts";
104
+ import { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE, ENV_ADVISOR, ENV_EPISODE_ID } from "../src/kernel/env-names.ts";
104
105
  export { ENV_HERDR_KEEP_PANE, ENV_GOVERNANCE } from "../src/kernel/env-names.ts";
105
106
  import { adoptLegacyEnvironment } from "../src/kernel/env-names.ts";
106
107
 
@@ -148,6 +149,8 @@ export interface GrantsSession extends NativeSessionHost {
148
149
  ownSpawnId: string;
149
150
  /** Unique identity when this session is itself a governed child; roots have no governed parent. */
150
151
  ownExecutionId?: string;
152
+ /** Stable identity shared by this root session and every descendant. */
153
+ episodeId: string;
151
154
  /** Descendants this subtree may still create — the cardinality bound ADR-0008 never had. */
152
155
  fanoutBudget: number;
153
156
  /** Whether delegation tools are active. Reconciled against the owner-bound root at session_start. */
@@ -407,6 +410,13 @@ export function createGrantsSession(
407
410
  const environment = lifecycle ? process.env : started!.environment;
408
411
  const activityRootId = activeLifecycle.activityRootId ?? randomUUID();
409
412
  activeLifecycle.activityRootId = activityRootId;
413
+ const inheritedEpisodeId = environment[ENV_EPISODE_ID];
414
+ const episodeId = isEpisodeId(inheritedEpisodeId)
415
+ ? inheritedEpisodeId
416
+ : activeLifecycle.episodeId && isEpisodeId(activeLifecycle.episodeId)
417
+ ? activeLifecycle.episodeId
418
+ : newEpisodeId();
419
+ activeLifecycle.episodeId = episodeId;
410
420
  // Local governance is on unless PI_DADDY_GOVERNANCE opts out; explicit inherited grants still win.
411
421
  // The factory precedes ctx, so its cwd/store identity is reconciled at session_start.
412
422
  const grantRaw = environment[ENV_GRANT];
@@ -435,7 +445,8 @@ export function createGrantsSession(
435
445
  // Read from `storeCwd` for `loadStoredGrantStateSync`'s reason: this factory runs before any hook, so `ctx.cwd`
436
446
  // does not exist yet. Reading a workspace-writable file here is safe precisely because it can only narrow.
437
447
  const advisorSession = createAdvisorSession({
438
- block: projectAdvisorBlock(storeCwd),
448
+ block: projectAdvisorBlock(storeCwd, environment),
449
+ episodeId,
439
450
  ...(storedLedger ? { ledgerPath: storedLedger } : {}),
440
451
  });
441
452
  const session: GrantsSession = {
@@ -469,6 +480,7 @@ export function createGrantsSession(
469
480
  // every level restarting at `d0` and the ledger becoming unjoinable.
470
481
  ownSpawnId: environment[ENV_PARENT_ID]?.trim() || `d${depth}`,
471
482
  ownExecutionId: environment[ENV_EXECUTION_ID]?.trim() || undefined,
483
+ episodeId,
472
484
  // The cardinality bound ADR-0008 never had: it attenuates downward like depth, so a subtree can never
473
485
  // create more descendants than its root was given — with no shared state, no lock and no counter file.
474
486
  fanoutBudget: budgetFromEnv(environment[ENV_FANOUT]),
@@ -501,6 +513,7 @@ export function createGrantsSession(
501
513
  catalogReady: Promise.resolve(emptyCatalog),
502
514
  delegationContext: async (approved?: InheritableApproval[]) => ({
503
515
  ownGrant: session.ownGrant,
516
+ episodeId: session.episodeId,
504
517
  depth: session.depth,
505
518
  maxDepth: session.maxDepth,
506
519
  gated: session.gated,
@@ -559,6 +572,7 @@ export function createGrantsSession(
559
572
  publishChildEnv: () => {
560
573
  const env = childEnv({
561
574
  ownGrant: session.ownGrant,
575
+ episodeId: session.episodeId,
562
576
  depth: session.depth,
563
577
  maxDepth: session.maxDepth,
564
578
  gated: session.gated,
@@ -586,10 +600,19 @@ export function createGrantsSession(
586
600
  * Unreadable, absent or malformed all yield undefined: this file is the reviewable record, not authority, and the
587
601
  * only thing it can do to an advisor is turn one off. A parse failure therefore costs nothing worth reporting.
588
602
  */
589
- function projectAdvisorBlock(cwd: string): unknown {
603
+ export function reconcileAdvisorSession(session: GrantsSession, environment: NodeJS.ProcessEnv): void {
604
+ session.advisorSession = createAdvisorSession({
605
+ block: projectAdvisorBlock(session.storeCwd, environment),
606
+ episodeId: session.episodeId,
607
+ ...(session.ledgerPath ? { ledgerPath: session.ledgerPath } : {}),
608
+ env: environment,
609
+ });
610
+ }
611
+
612
+ function projectAdvisorBlock(cwd: string, env: NodeJS.ProcessEnv = process.env): unknown {
590
613
  // Only when an advisor could exist at all. This runs in every session including every child, before any hook, and
591
614
  // a child can never use the result because `PI_DADDY_ADVISOR` is stripped from it.
592
- if (!process.env[ENV_ADVISOR]?.trim()) return undefined;
615
+ if (!env[ENV_ADVISOR]?.trim()) return undefined;
593
616
  try {
594
617
  const path = projectSettingsPath(cwd);
595
618
  // Bounded and type-checked first: this is the third unbounded session-start read AGENTS.md warns about, and the
@@ -69,6 +69,7 @@ export async function prepareDelegationWorkspace(input: {
69
69
  spec: DelegationWorkspaceSpec;
70
70
  correlation?: CorrelationMetadata;
71
71
  childId: string;
72
+ episodeId?: string;
72
73
  executionId: string;
73
74
  parentExecutionId: string | null;
74
75
  signal?: AbortSignal;
@@ -120,6 +121,7 @@ export async function prepareDelegationWorkspace(input: {
120
121
  await appendLedgerEvent(
121
122
  { path: input.ledgerPath, strict: true },
122
123
  buildWorkspaceLeaseEvent({
124
+ ...(input.episodeId ? { episodeId: input.episodeId } : {}),
123
125
  executionId: input.executionId,
124
126
  parentExecutionId: input.parentExecutionId,
125
127
  childId: input.childId,
@@ -146,6 +148,7 @@ export async function prepareDelegationWorkspace(input: {
146
148
  await appendLedgerEvent(
147
149
  { path: input.ledgerPath, strict: true },
148
150
  buildWorkspaceLeaseEvent({
151
+ ...(input.episodeId ? { episodeId: input.episodeId } : {}),
149
152
  executionId: input.executionId,
150
153
  parentExecutionId: input.parentExecutionId,
151
154
  childId: input.childId,
@@ -174,6 +177,7 @@ export async function prepareDelegationWorkspace(input: {
174
177
  export async function releaseDelegationWorkspace(input: {
175
178
  prepared: PreparedWorkspace | undefined;
176
179
  childId: string;
180
+ episodeId?: string;
177
181
  executionId: string;
178
182
  parentExecutionId: string | null;
179
183
  ledgerPath?: string;
@@ -195,6 +199,7 @@ export async function releaseDelegationWorkspace(input: {
195
199
  await appendLedgerEvent(
196
200
  { path: input.ledgerPath, strict: true },
197
201
  buildWorkspaceLeaseEvent({
202
+ ...(input.episodeId ? { episodeId: input.episodeId } : {}),
198
203
  executionId: input.executionId,
199
204
  parentExecutionId: input.parentExecutionId,
200
205
  childId: input.childId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.40.1",
3
+ "version": "0.41.1",
4
4
  "description": "Capability governance for pi sub-agents: spawn Agent Skills (SKILL.md) definitions whose allowed-tools becomes a grant that can only narrow going down a delegation tree, enforced by pi's own --tools allowlist, with an append-only ledger.",
5
5
  "keywords": [
6
6
  "pi-package",