pi-daddy 0.27.4 → 0.28.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 (43) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/PRODUCT-GUIDE.md +22 -3
  3. package/README.md +9 -9
  4. package/REQUIREMENTS.md +1 -1
  5. package/dist/activity-timeline.d.ts +109 -0
  6. package/dist/activity-timeline.d.ts.map +1 -0
  7. package/dist/activity-timeline.js +208 -0
  8. package/dist/activity-timeline.js.map +1 -0
  9. package/dist/dashboard-cli.d.ts +6 -0
  10. package/dist/dashboard-cli.d.ts.map +1 -1
  11. package/dist/dashboard-cli.js +41 -5
  12. package/dist/dashboard-cli.js.map +1 -1
  13. package/dist/dashboard-display-controls.d.ts +5 -0
  14. package/dist/dashboard-display-controls.d.ts.map +1 -1
  15. package/dist/dashboard-display-controls.js +12 -3
  16. package/dist/dashboard-display-controls.js.map +1 -1
  17. package/dist/dashboard-handshake.d.ts +6 -1
  18. package/dist/dashboard-handshake.d.ts.map +1 -1
  19. package/dist/dashboard-handshake.js +21 -10
  20. package/dist/dashboard-handshake.js.map +1 -1
  21. package/dist/delegate-types.d.ts +8 -0
  22. package/dist/delegate-types.d.ts.map +1 -1
  23. package/dist/delegate-types.js.map +1 -1
  24. package/dist/delegate.d.ts.map +1 -1
  25. package/dist/delegate.js +14 -6
  26. package/dist/delegate.js.map +1 -1
  27. package/dist/measured-session.d.ts +1 -1
  28. package/extensions/activity-timeline.ts +90 -0
  29. package/extensions/execute-child.ts +6 -6
  30. package/extensions/grants-command.ts +1 -1
  31. package/extensions/grants-connected-command.ts +2 -2
  32. package/extensions/grants.ts +7 -6
  33. package/extensions/reload-environment.ts +1 -1
  34. package/extensions/session-environment.ts +6 -3
  35. package/extensions/session-report.ts +1 -0
  36. package/extensions/session.ts +21 -35
  37. package/package.json +2 -2
  38. package/src/activity-timeline.ts +133 -0
  39. package/src/dashboard-cli.ts +29 -4
  40. package/src/dashboard-display-controls.ts +6 -3
  41. package/src/dashboard-handshake.ts +18 -13
  42. package/src/delegate-types.ts +4 -0
  43. package/src/delegate.ts +13 -6
@@ -20,7 +20,6 @@
20
20
  * spawn triggered the human prompt, and `childEnv` clamps it to the grant again on the way out. Each
21
21
  * child derives its own grant from the tool array of its first provider request.
22
22
  */
23
-
24
23
  import { fileURLToPath } from "node:url";
25
24
  import { homedir } from "node:os";
26
25
  import { join, resolve } from "node:path";
@@ -51,9 +50,10 @@ import { createDailyDashboardSession } from "./daily-dashboard-session.ts";
51
50
  import { createWorkSession } from "./work-session.ts";
52
51
  import { createLearningSession } from "./learning-session.ts";
53
52
  import { replacePublishedDailyWork, type PublishedDailyWork } from "./daily-work-session.ts";
53
+ import { defaultActivityTimelinePath } from "../src/activity-timeline.ts";
54
+ import { registerActivityTimeline } from "./activity-timeline.ts";
54
55
  export default function (pi: ExtensionAPI) {
55
56
  // The path pi loads as the extension, so a child granted `tool:delegate` can be started with `-e <this>`.
56
- // Only this file can say so about itself, which is why the session takes it rather than deriving it.
57
57
  const extensionPath = (() => {
58
58
  try {
59
59
  return fileURLToPath(import.meta.url);
@@ -61,8 +61,10 @@ export default function (pi: ExtensionAPI) {
61
61
  return undefined;
62
62
  }
63
63
  })();
64
- const session = createGrantsSession(extensionPath);
64
+ const observerExtensionPath = fileURLToPath(new URL("./activity-timeline.ts", import.meta.url));
65
+ const session = createGrantsSession(extensionPath, undefined, observerExtensionPath);
65
66
  associateOrdinaryHost(pi,session);
67
+ registerActivityTimeline(pi, session);
66
68
  const dailyHost=createDailyDashboardSession({ordinary:()=>ordinaryChildrenFor(pi),declared:()=>session.declaredWork,rebind:state=>{session.declaredWork=state;},cwd:()=>session.cwd,env:process.env,author:"local-operator"});
67
69
  const dashboardPluginRoot = fileURLToPath(new URL("../herdr-plugin/", import.meta.url));
68
70
  const dashboardPaths = defaultDashboardPaths(
@@ -74,7 +76,6 @@ export default function (pi: ExtensionAPI) {
74
76
  const publishedDailyWork: PublishedDailyWork = {};
75
77
  const workSession=createWorkSession(session,dailyHost,()=>replacePublishedDailyWork(process.env,publishedDailyWork,session.declaredWork));
76
78
  const learningSession=createLearningSession(dailyHost);
77
-
78
79
  pi.on("session_start", async (_event, ctx) => {
79
80
  // Real SDK contexts always supply SessionManager. The fallback keeps lightweight wiring fixtures
80
81
  // isolated; it cannot carry state into another extension instance.
@@ -207,7 +208,7 @@ export default function (pi: ExtensionAPI) {
207
208
  env: process.env,
208
209
  pid: process.pid,
209
210
  cwd: ctx.cwd,
210
- ledgerPath: session.ledgerPath,
211
+ ledgerPath: session.ledgerPath ?? defaultActivityTimelinePath(ctx.cwd),
211
212
  pluginRoot: dashboardPluginRoot,
212
213
  preferencePath: dashboardPaths.preferencePath,
213
214
  paneStatePath: dashboardPaths.paneStatePath,
@@ -385,7 +386,7 @@ export default function (pi: ExtensionAPI) {
385
386
  env: process.env,
386
387
  pid: process.pid,
387
388
  cwd: ctx.cwd,
388
- ledgerPath: session.ledgerPath,
389
+ ledgerPath: session.ledgerPath ?? defaultActivityTimelinePath(ctx.cwd),
389
390
  pluginRoot: dashboardPluginRoot,
390
391
  paneStatePath: dashboardPaths.paneStatePath,
391
392
  }),
@@ -1,7 +1,7 @@
1
1
  import { GRANT_ENV_KEYS } from "../src/propagation.ts";
2
2
 
3
3
  /** The root baseline and latest child publication for one real Pi session owner. */
4
- export interface ReloadLifecycle { root: Record<string, string | undefined>; published?: Record<string, string | undefined> }
4
+ export interface ReloadLifecycle { root: Record<string, string | undefined>; published?: Record<string, string | undefined>; activityRootId?: string }
5
5
  type SessionOwner = object;
6
6
 
7
7
  interface ReloadState {
@@ -10,7 +10,7 @@ import { storedGrantSessionState } from "./stored-grant-session.ts";
10
10
  import { chooseExecutor, ENV_HERDR } from "../src/executor.ts";
11
11
  import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/model-preflight.ts";
12
12
  import type { ReloadLifecycle } from "./reload-environment.ts";
13
- import type { GrantsSession } from "./session.ts";
13
+ import { ENV_GOVERNANCE, type GrantsSession } from "./session.ts";
14
14
 
15
15
  /** Rebuild every authority-bearing factory input once session_start identifies its real SDK owner. */
16
16
  export function reconcileSessionEnvironment(
@@ -21,8 +21,9 @@ export function reconcileSessionEnvironment(
21
21
  const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]);
22
22
  const ledgerRaw = environment[ENV_LEDGER];
23
23
  session.ledgerFromEnvironment = ledgerRaw !== undefined;
24
- session.governed = stored.governed;
25
- session.inherited = stored.inherited;
24
+ const governanceOff = environment[ENV_GOVERNANCE]?.trim() === "off" || environment[ENV_GOVERNANCE]?.trim() === "0";
25
+ session.governed = !governanceOff;
26
+ session.inherited = governanceOff ? stored.inherited : stored.governed ? stored.inherited : [WILDCARD];
26
27
  session.depth = bounds.depth;
27
28
  session.maxDepth = bounds.maxDepth;
28
29
  session.malformedBounds = bounds.malformed;
@@ -35,6 +36,8 @@ export function reconcileSessionEnvironment(
35
36
  session.mayDelegate = !session.governed || session.inherited.includes(DELEGATE_CAPABILITY) || session.inherited.includes(WILDCARD);
36
37
  session.allowUnresolvedModels = environment[ENV_ALLOW_UNRESOLVED_MODELS] === "1";
37
38
  session.reloadLifecycle = lifecycle;
39
+ session.activityRootId = lifecycle.activityRootId ?? session.activityRootId;
40
+ lifecycle.activityRootId = session.activityRootId;
38
41
  session.inheritedApprovals = parseInherited(environment[ENV_APPROVED]);
39
42
  session.ownGrant = deriveOwnGrant(session.inherited, null);
40
43
  session.observed = false;
@@ -183,6 +183,7 @@ export async function reportSessionStart(session: GrantsSession, ctx: SessionRep
183
183
  ctx.ui.notify(`grants: executor — ${session.executor.disclosure}`, "error");
184
184
  }
185
185
 
186
+ if (!session.governed) info.push("grants: governance off by PI_DADDY_GOVERNANCE; observation and the local timeline remain independent.");
186
187
  if (session.governed) {
187
188
  info.push(
188
189
  `grants: depth ${session.depth}/${session.maxDepth}, holding [${session.ownGrant.join(", ") || "nothing"}]`,
@@ -13,7 +13,7 @@
13
13
  * module reads them **through this object**, live, rather than capturing a copy at load time; capturing a
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
17
  import { parseInherited, type InheritableApproval } from "../src/approval.ts";
18
18
  import type { ApprovalBinding } from "../src/correlation.ts";
19
19
  import { createApprovalGateProvider } from "../src/approval-prompt.ts";
@@ -81,19 +81,10 @@ export { ENV_HERDR } from "../src/executor.ts";
81
81
  export { ENV_HERDR_WORKSPACE } from "../src/herdr-cli.ts";
82
82
  /** Keep each child's pane after it finishes, for inspection. Off by default: fan-out would flood it. */
83
83
  export const ENV_HERDR_KEEP_PANE = "PI_GRANTS_HERDR_KEEP_PANE";
84
-
84
+ export const ENV_GOVERNANCE = "PI_DADDY_GOVERNANCE";
85
85
  export interface VariantRunAccounting {runId:string;primaryExecutionId:string;shadowExecutionIds:string[];state:"running"|"settled";outcomes:null|{executionId:string;role:"primary"|"shadow";ok:boolean;reason:string|null}[]}
86
86
  export interface GrantsSession extends NativeSessionHost {
87
- /**
88
- * False when neither `PI_GRANTS_GRANT` nor a stored grant applies: the session holds the wildcard and
89
- * nothing is governed.
90
- *
91
- * **Mutable, because `/grants init` makes an ungoverned session governed mid-run.** The first version of
92
- * ADR-0030 left this readonly on the reasoning that a store is read at creation and `init` only runs where
93
- * one exists — which is false for the first `init` in a directory, the most common case there is. The
94
- * session then bounded every spawn by the new grant while `/grants` reported "inactive", so the status
95
- * line contradicted the enforcer. Found by running it.
96
- */
87
+ /** False only for the explicit PI_DADDY_GOVERNANCE opt-out; otherwise roots are observed-bound. */
97
88
  governed: boolean;
98
89
  /** The upper bound handed down by the delegator, before this session's own tools are observed. */
99
90
  inherited: Capability[];
@@ -133,6 +124,11 @@ export interface GrantsSession extends NativeSessionHost {
133
124
  readonly modelResolutionCache: Map<string, boolean>;
134
125
  /** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
135
126
  readonly extensionPath?: string;
127
+ /** Hook-only observer path for leaf children; it exposes no tools. */
128
+ readonly observerExtensionPath?: string;
129
+ /** Stable root identity plus current turn, used only to join local activity facts. */
130
+ activityRootId: string;
131
+ activity?: { rootId: string; path: string; taskId?: string };
136
132
  declaredWork?: DeclaredWorkState; // Explicit operator selection; absence leaves execution visibly unbound.
137
133
  /** Primary-return fan-outs retained by this original session; bounded and human-readable via /grants variants. */
138
134
  readonly variantRuns: Map<string, VariantRunAccounting>;
@@ -171,7 +167,6 @@ export interface GrantsSession extends NativeSessionHost {
171
167
  * but non-deterministically: the same delegation succeeded or failed on timing alone.
172
168
  */
173
169
  catalogReady: Promise<Catalog>;
174
-
175
170
  /**
176
171
  * The one place a delegation context is built — and therefore the one place each field is spelled.
177
172
  *
@@ -185,7 +180,6 @@ export interface GrantsSession extends NativeSessionHost {
185
180
  * diagnostic that disagrees with enforcement is not expressible.
186
181
  */
187
182
  delegationContext(approved?: InheritableApproval[]): Promise<DelegationContext>;
188
-
189
183
  /**
190
184
  * Publish what children inherit. Written once at session start, and republished whenever this
191
185
  * session's own approvals change (see `obtainApprovals`) — never once per spawn. That distinction is
@@ -215,7 +209,6 @@ export interface GrantsSession extends NativeSessionHost {
215
209
  adoptGrant(grant: Capability[], projectLedger?: string): void;
216
210
  reconcileEnvironment(environment: NodeJS.ProcessEnv, lifecycle: ReloadLifecycle): void;
217
211
  }
218
-
219
212
  /**
220
213
  * Parse the environment once and build the session every other module reads through.
221
214
  *
@@ -244,30 +237,23 @@ export async function loadProjectDefinitions(session: GrantsSession, cwd: string
244
237
  });
245
238
  session.catalog = await session.catalogReady;
246
239
  }
247
-
248
- export function createGrantsSession(extensionPath: string | undefined, lifecycle?: ReloadLifecycle): GrantsSession {
240
+ export function createGrantsSession(extensionPath: string | undefined, lifecycle?: ReloadLifecycle, observerExtensionPath?: string): GrantsSession {
249
241
  const started = lifecycle ? undefined : beginExtensionLifecycle();
250
242
  const activeLifecycle = lifecycle ?? started!.lifecycle;
251
243
  const environment = lifecycle ? process.env : started!.environment;
252
- // Governance is opt-in: with PI_GRANTS_GRANT unset AND no stored grant for this directory, the session
253
- // holds the wildcard and nothing is blocked. This extension must never silently tighten a normal
254
- // workflow.
255
- //
256
- // **Two sources, and the environment always wins** (ADR-0030). The variable is how a CHILD is governed
257
- // and how CI is configured, so a store that could override it would let a directory quietly widen or
258
- // narrow a child its parent had already bounded. The store is consulted only when the variable is absent,
259
- // which is exactly the case it was added for: a human at a terminal who ran `/grants init` here.
260
- //
261
- // `process.cwd()` rather than `ctx.cwd`, because this runs in the extension factory — before any hook,
262
- // and therefore before `ctx` exists. That ordering is forced by S-5: whether `delegate` is registered at
263
- // all is decided here, and a grant arriving later could not inform it. `session_start` re-checks the two
264
- // against each other and says so if they differ, which is the only case this can get wrong.
244
+ const activityRootId = activeLifecycle.activityRootId ?? randomUUID();
245
+ activeLifecycle.activityRootId = activityRootId;
246
+ // Local governance is on unless PI_DADDY_GOVERNANCE opts out; explicit inherited grants still win.
247
+ // The factory precedes ctx, so its cwd/store identity is reconciled at session_start.
265
248
  const grantRaw = environment[ENV_GRANT];
266
249
  const storeCwd = process.cwd();
267
250
  // One root-only store read supplies both decisions made by `/grants init`. A child always has ENV_GRANT,
268
251
  // so it cannot activate a ledger merely because its routed cwd happens to have a v2 store (ADR-0037).
269
252
  const storedState = storedGrantSessionState(grantRaw, storeCwd);
270
- const { governed, inherited, refusal: grantStoreRefusal } = storedState;
253
+ const governanceOff = environment[ENV_GOVERNANCE]?.trim() === "off" || environment[ENV_GOVERNANCE]?.trim() === "0";
254
+ const governed = governanceOff ? false : true;
255
+ const inherited = governanceOff ? storedState.inherited : storedState.governed ? storedState.inherited : [WILDCARD];
256
+ const grantStoreRefusal = storedState.refusal;
271
257
  const ledgerRaw = environment[ENV_LEDGER];
272
258
  // Capture provenance before publishChildEnv writes this session's derived default into process.env. A later
273
259
  // `/grants init` for ctx.cwd must not mistake our own publication for an operator override.
@@ -278,7 +264,6 @@ export function createGrantsSession(extensionPath: string | undefined, lifecycle
278
264
  const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]);
279
265
  const { depth, maxDepth } = bounds;
280
266
  const emptyCatalog = makeCatalog([]);
281
-
282
267
  const session: GrantsSession = {
283
268
  governed,
284
269
  inherited,
@@ -319,6 +304,8 @@ export function createGrantsSession(extensionPath: string | undefined, lifecycle
319
304
  nativeSessionRoot: nativeSessionRootFromEnv(process.env),
320
305
  modelResolutionCache: new Map<string, boolean>(),
321
306
  extensionPath,
307
+ observerExtensionPath,
308
+ activityRootId,
322
309
  variantRuns: new Map(),
323
310
  reloadLifecycle: activeLifecycle,
324
311
  sessionApprovals: new Set<string>(),
@@ -339,6 +326,8 @@ export function createGrantsSession(extensionPath: string | undefined, lifecycle
339
326
  gated: session.gated,
340
327
  ledgerPath: session.ledgerPath,
341
328
  extensionPath: session.extensionPath,
329
+ observerExtensionPath: session.observerExtensionPath,
330
+ activity: session.activity,
342
331
  catalog: await session.catalogReady,
343
332
  // R-32: where each granted skill lives, so `planSpawn` can pass `--skill` for those and only those.
344
333
  // Derived from the catalog's own `source`, so it cannot drift from what was discovered.
@@ -355,10 +344,8 @@ export function createGrantsSession(extensionPath: string | undefined, lifecycle
355
344
  interactive: session.executor.kind === "herdr",
356
345
  ...(approved ? { approved } : {}),
357
346
  }),
358
-
359
347
  storeCwd,
360
348
  ...(grantStoreRefusal ? { grantStoreRefusal } : {}),
361
-
362
349
  adoptGrant: (grant: Capability[], projectLedger?: string) => {
363
350
  // Governed too, not just bounded. A session that starts with no grant and then runs `/grants init` is
364
351
  // governed from that moment: every spawn is bounded by what was just stored. Leaving this false made
@@ -373,7 +360,6 @@ export function createGrantsSession(extensionPath: string | undefined, lifecycle
373
360
  }
374
361
  session.publishChildEnv();
375
362
  },
376
-
377
363
  reconcileEnvironment: (environment, lifecycle) => reconcileSessionEnvironment(session, environment, lifecycle),
378
364
  publishChildEnv: () => {
379
365
  const env = childEnv({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.27.4",
3
+ "version": "0.28.0",
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",
@@ -258,7 +258,7 @@
258
258
  "test": "node scripts/unit-tests.ts",
259
259
  "test:dashboard-pty": "if [ \"$(uname -s)\" = Linux ]; then python3 test/dashboard-cli-pty.py; else echo 'dashboard PTY probe requires Unix pty/Unix sockets'; fi",
260
260
  "test:integration": "node --test test-integration/*.it.ts",
261
- "test:integration:ci": "node --test test-integration/approval.it.ts test-integration/governance.it.ts",
261
+ "test:integration:ci": "node --test test-integration/approval.it.ts test-integration/governance.it.ts test-integration/activity.it.ts",
262
262
  "contracts:generate": "node scripts/generate-ledger-v3-contract.ts",
263
263
  "contracts:generate:v4": "node scripts/generate-ledger-v4-contract.ts contracts/ledger/v4",
264
264
  "prepack": "npm run build",
@@ -0,0 +1,133 @@
1
+ /** Durable, local-only activity facts. Timeline events never infer skill compliance or provider reasoning. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import { appendFile, mkdir, open, readFile, realpath, rename, writeFile } from "node:fs/promises";
5
+ import { dirname, join, resolve, sep } from "node:path";
6
+
7
+ export const ACTIVITY_TIMELINE_VERSION = 1 as const;
8
+ export const ENV_ACTIVITY_TIMELINE = "PI_DADDY_ACTIVITY_TIMELINE";
9
+ export const ENV_ACTIVITY_CONTENT = "PI_DADDY_ACTIVITY_CONTENT";
10
+ /** Per-child transport; these are observation identities, not capability authority. */
11
+ export const ENV_ACTIVITY_PATH = "PI_DADDY_ACTIVITY_PATH";
12
+ export const ENV_ACTIVITY_ROOT = "PI_DADDY_ACTIVITY_ROOT";
13
+ export const ENV_ACTIVITY_TASK = "PI_DADDY_ACTIVITY_TASK";
14
+ export const ENV_ACTIVITY_PARENT_TASK = "PI_DADDY_ACTIVITY_PARENT_TASK";
15
+ const MAX_EVENT_BYTES = 16 * 1024, MAX_CONTENT_BYTES = 256 * 1024;
16
+ const DIGEST = /^[a-f0-9]{64}$/, REF = /^content\/[a-f0-9]{64}$/;
17
+ export type TimelineFilter = "everything" | "agents" | "skills" | "needs-you";
18
+ export interface ContentReference { digest: string; bytes: number; ref?: string }
19
+ export interface ActivitySkill { name: string; source: string; digest: string }
20
+ export interface ActivityEvent {
21
+ version: 1; id: string; kind: string; at: string; rootId: string; taskId: string; parentTaskId?: string;
22
+ agentId?: string; agent?: string; outcome?: string; model?: string; thinking?: string;
23
+ prompt?: ContentReference; final?: ContentReference; skill?: ActivitySkill;
24
+ }
25
+ export interface TimelineSkill extends ActivitySkill { available: boolean; read: boolean; active: boolean; superseded: boolean }
26
+ export interface TimelineTask {
27
+ id: string; rootId: string; parentTaskId?: string; startedAt: string; endedAt?: string;
28
+ status: "active" | "finished" | "failed" | "cancelled" | "needs-you";
29
+ model?: string; thinking?: string; prompt?: ContentReference; final?: ContentReference;
30
+ skills: TimelineSkill[]; agents: Array<{ id: string; parentTaskId?: string; agent: string; state: string }>;
31
+ }
32
+ export interface ActivityTimeline { tasks: TimelineTask[]; refusals: string[] }
33
+ export interface ActivityIdentity { rootId: string; path: string; taskId?: string; parentTaskId?: string }
34
+
35
+ const sha = (value: string) => createHash("sha256").update(value).digest("hex");
36
+ const validId = (value: unknown) => typeof value === "string" && value.length > 0 && value.length <= 200;
37
+ /** Stable, unambiguous local selector: encoded root identity and task id, separated once by `:`. */
38
+ export const activityTaskKey = (rootId: string, taskId: string) => `${encodeURIComponent(rootId)}:${encodeURIComponent(taskId)}`;
39
+ function taskFromKey(value: string): { rootId: string; taskId: string } | null {
40
+ const parts = value.split(":"); if (parts.length !== 2 || value.length > 1400) return null;
41
+ try { const [rootId, taskId] = parts.map(decodeURIComponent); return validId(rootId) && validId(taskId) && value === activityTaskKey(rootId, taskId) ? { rootId, taskId } : null; } catch { return null; }
42
+ }
43
+ function validRef(value: unknown): value is ContentReference {
44
+ const ref = value as ContentReference | null;
45
+ return !!ref && typeof ref === "object" && DIGEST.test(String(ref.digest)) && Number.isSafeInteger(ref.bytes) && ref.bytes >= 0 &&
46
+ (ref.ref === undefined || (ref.bytes <= MAX_CONTENT_BYTES && REF.test(ref.ref)));
47
+ }
48
+ function eventOf(value: unknown): ActivityEvent | null {
49
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
50
+ const event = value as Partial<ActivityEvent>;
51
+ if (event.version !== 1 || !validId(event.id) || !validId(event.kind) || !validId(event.rootId) || !validId(event.taskId) ||
52
+ typeof event.at !== "string" || !Number.isFinite(Date.parse(event.at))) return null;
53
+ if ((event.parentTaskId && !validId(event.parentTaskId)) || (event.agentId && !validId(event.agentId)) ||
54
+ (event.agent && !validId(event.agent)) || (event.prompt && !validRef(event.prompt)) || (event.final && !validRef(event.final))) return null;
55
+ if (event.skill && (!validId(event.skill.name) || typeof event.skill.source !== "string" || event.skill.source.length > 2048 || !DIGEST.test(event.skill.digest))) return null;
56
+ return event as ActivityEvent;
57
+ }
58
+ export function parseActivityTimeline(text: string): ActivityTimeline {
59
+ const tasks = new Map<string, TimelineTask>(), refusals: string[] = [];
60
+ for (const [index, raw] of text.split("\n").entries()) {
61
+ if (!raw.trim()) continue;
62
+ let event: ActivityEvent | null = null;
63
+ try { event = eventOf(JSON.parse(raw)); } catch { /* reported below */ }
64
+ if (!event) { refusals.push(`line ${index + 1}: invalid or unsafe timeline event`); continue; }
65
+ const key = `${event.rootId}\0${event.taskId}`;
66
+ let task = tasks.get(key);
67
+ if (!task) { task = { id: event.taskId, rootId: event.rootId, parentTaskId: event.parentTaskId, startedAt: event.at, status: "active", skills: [], agents: [] }; tasks.set(key, task); }
68
+ if (event.kind === "task_started") { task.model = event.model; task.thinking = event.thinking; task.prompt = event.prompt; task.parentTaskId = event.parentTaskId ?? task.parentTaskId; }
69
+ if (event.kind === "task_finished") { task.endedAt = event.at; task.final = event.final; task.status = event.outcome === "cancelled" ? "cancelled" : ["failed", "error"].includes(event.outcome ?? "") ? "failed" : "finished"; }
70
+ if (event.kind === "needs_you") task.status = "needs-you";
71
+ if (event.skill) {
72
+ let skill = task.skills.find(value => value.name === event!.skill!.name && value.digest === event!.skill!.digest && value.source === event!.skill!.source);
73
+ if (!skill) { skill = { ...event.skill, available: false, read: false, active: false, superseded: false }; task.skills.push(skill); }
74
+ if (event.kind === "skill_available") skill.available = true;
75
+ if (event.kind === "skill_read") skill.read = true;
76
+ if (event.kind === "skill_active") skill.active = true;
77
+ if (event.kind === "skill_finished") skill.active = false;
78
+ if (event.kind === "skill_superseded") { skill.superseded = true; skill.active = false; }
79
+ }
80
+ if (event.kind === "agent_started" && event.agentId) task.agents.push({ id: event.agentId, parentTaskId: event.parentTaskId, agent: event.agent ?? "unknown", state: "active" });
81
+ if (event.kind === "agent_finished" && event.agentId) { const agent = task.agents.find(value => value.id === event!.agentId); if (agent) agent.state = event.outcome ?? "finished"; }
82
+ }
83
+ return { tasks: [...tasks.values()].sort((a, b) => a.startedAt.localeCompare(b.startedAt)), refusals };
84
+ }
85
+ function mark(status: TimelineTask["status"]): string { return status === "active" ? "●" : status === "needs-you" ? "!" : status === "failed" || status === "cancelled" ? "×" : "✓"; }
86
+ export function renderActivityTimeline(timeline: ActivityTimeline, options: { filter?: TimelineFilter; details?: boolean; content?: { taskKey: string; field: "prompt" | "final"; text: string } } = {}): string {
87
+ const filter = options.filter ?? "everything", lines = ["PI-DADDY ACTIVITY", ""];
88
+ const tasks = timeline.tasks.filter(task => filter === "everything" || filter === "needs-you" && task.status === "needs-you" || filter === "agents" && task.agents.length > 0 || filter === "skills" && task.skills.length > 0);
89
+ for (const task of tasks) {
90
+ lines.push(`${task.parentTaskId ? " └─ " : ""}${mark(task.status)} ${task.id} ${task.status}${task.model ? ` · ${task.model}` : ""}${task.thinking ? ` · requested thinking ${task.thinking}` : ""}`);
91
+ if (filter !== "agents") for (const skill of task.skills) lines.push(` skill ${skill.name} · ${skill.available ? "available" : "availability unknown"}; ${skill.read ? "read" : "not observed read"}; ${skill.active ? "declared active" : "not declared active"}${skill.superseded ? "; superseded" : ""}`);
92
+ if (filter !== "skills") for (const agent of task.agents) lines.push(` agent ${agent.agent} · ${agent.state}${agent.parentTaskId ? ` · parent ${agent.parentTaskId}` : ""}`);
93
+ if (options.details && (task.prompt || task.final)) { const key = activityTaskKey(task.rootId, task.id); lines.push(` private details: p ${key} (submitted prompt) · f ${key} (final response)`); }
94
+ }
95
+ if (options.content) lines.push("", `PRIVATE ${options.content.field.toUpperCase()} · ${options.content.taskKey}`, options.content.text);
96
+ if (!tasks.length) lines.push("No activity recorded yet.");
97
+ if (timeline.refusals.length) lines.push(...timeline.refusals.map(refusal => `timeline refusal: ${refusal}`));
98
+ lines.push("", "Filters: Everything · Agents · Skills · Needs-you. Type p <root-id:task-id> or f <root-id:task-id> after d Details; shorthand task ids are refused. Read is observation; active is a declaration; neither proves compliance.");
99
+ return lines.join("\n");
100
+ }
101
+ export const defaultActivityTimelinePath = (cwd: string) => join(cwd, ".pi", "pi-daddy", "activity.jsonl");
102
+ export function activityIdentity(cwd: string, env: NodeJS.ProcessEnv = process.env): ActivityIdentity {
103
+ const configured = env[ENV_ACTIVITY_TIMELINE]?.trim(), transported = env[ENV_ACTIVITY_PATH]?.trim();
104
+ const path = transported ? resolve(transported) : configured && configured !== "1" && configured !== "off" && configured !== "0" ? resolve(cwd, configured) : defaultActivityTimelinePath(cwd);
105
+ return { rootId: env[ENV_ACTIVITY_ROOT]?.trim() || randomUUID(), path, ...(env[ENV_ACTIVITY_TASK]?.trim() ? { taskId: env[ENV_ACTIVITY_TASK].trim() } : {}), ...(env[ENV_ACTIVITY_PARENT_TASK]?.trim() ? { parentTaskId: env[ENV_ACTIVITY_PARENT_TASK].trim() } : {}) };
106
+ }
107
+ export class ActivityTimelineRecorder {
108
+ readonly enabled: boolean; readonly contentEnabled: boolean; readonly path: string; readonly rootId: string; private taskId?: string;
109
+ constructor(cwd: string, env: NodeJS.ProcessEnv = process.env) { const configured = env[ENV_ACTIVITY_TIMELINE]?.trim(); this.enabled = configured !== "off" && configured !== "0"; this.contentEnabled = env[ENV_ACTIVITY_CONTENT]?.trim() !== "metadata-only" && env[ENV_ACTIVITY_CONTENT]?.trim() !== "off"; const identity = activityIdentity(cwd, env); this.path = identity.path; this.rootId = identity.rootId; this.taskId = identity.taskId; }
110
+ get task(): string | undefined { return this.taskId; }
111
+ attach(taskId: string): void { this.taskId = taskId; }
112
+ async start(prompt: string, model?: string, thinking?: string, parentTaskId?: string): Promise<string | undefined> { if (!this.enabled) return undefined; this.taskId = randomUUID(); await this.append("task_started", { parentTaskId, model, thinking, prompt: await this.content(prompt) }); return this.taskId; }
113
+ async childStarted(taskId: string, parentTaskId: string | undefined, agent: string, prompt: string, model?: string, thinking?: string): Promise<void> { if (!this.enabled) return; this.taskId = taskId; await this.append("task_started", { parentTaskId, agentId: taskId, agent, model, thinking, prompt: await this.content(prompt) }); await this.append("agent_started", { parentTaskId, agentId: taskId, agent }); }
114
+ async childFinished(taskId: string, parentTaskId: string | undefined, agent: string, final: string, outcome: "completed" | "failed" | "cancelled"): Promise<void> { if (!this.enabled) return; this.taskId = taskId; await this.append("agent_finished", { parentTaskId, agentId: taskId, agent, outcome }); await this.finish(final, outcome); }
115
+ async finish(final: string | undefined, outcome: "completed" | "failed" | "cancelled" = "completed"): Promise<void> { if (this.enabled && this.taskId) await this.append("task_finished", { outcome, ...(final === undefined ? {} : { final: await this.content(final) }) }); }
116
+ async skill(kind: "skill_available" | "skill_read" | "skill_active" | "skill_finished" | "skill_superseded", skill: ActivitySkill): Promise<void> { if (this.taskId) await this.append(kind, { skill }); }
117
+ async append(kind: string, fields: Omit<Partial<ActivityEvent>, "version" | "id" | "kind" | "at" | "rootId" | "taskId">): Promise<void> { if (!this.enabled || !this.taskId) return; const event: ActivityEvent = { version: 1, id: randomUUID(), kind, at: new Date().toISOString(), rootId: this.rootId, taskId: this.taskId, ...fields }; const text = JSON.stringify(event); if (Buffer.byteLength(text) > MAX_EVENT_BYTES) throw Error("activity timeline event exceeds bound"); await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); await appendFile(this.path, `${text}\n`, { mode: 0o600 }); }
118
+ private async content(value: string): Promise<ContentReference> { const digest = sha(value), bytes = Buffer.byteLength(value); if (!this.contentEnabled || bytes > MAX_CONTENT_BYTES) return { digest, bytes }; const root = join(dirname(this.path), "content"), target = join(root, digest); await mkdir(root, { recursive: true, mode: 0o700 }); try { await readFile(target, "utf8"); } catch { const temporary = `${target}.${process.pid}.${randomUUID()}.tmp`; await writeFile(temporary, value, { encoding: "utf8", mode: 0o600 }); await rename(temporary, target); } return { digest, bytes, ref: `content/${digest}` }; }
119
+ }
120
+ export async function readActivityContent(timelinePath: string, reference: ContentReference): Promise<string> {
121
+ if (!validRef(reference) || !reference.ref) throw Error("TIMELINE_REFERENCE_INVALID");
122
+ const root = resolve(dirname(timelinePath), "content"), target = resolve(dirname(timelinePath), reference.ref);
123
+ if (!target.startsWith(`${root}${sep}`) || await realpath(root) !== root) throw Error("TIMELINE_REFERENCE_INVALID");
124
+ const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
125
+ try { const before = await handle.stat(); if (!before.isFile() || before.nlink !== 1 || before.size > MAX_CONTENT_BYTES) throw Error("TIMELINE_REFERENCE_INVALID"); const value = await handle.readFile("utf8"), after = await handle.stat(); if (after.size !== before.size || Buffer.byteLength(value) !== reference.bytes || sha(value) !== reference.digest) throw Error("TIMELINE_REFERENCE_TAMPERED"); return value; } finally { await handle.close(); }
126
+ }
127
+ export async function detailForTimeline(timelinePath: string, taskKey: string, field: "prompt" | "final"): Promise<{ taskKey: string; field: "prompt" | "final"; text: string }> {
128
+ const identity = taskFromKey(taskKey); if (!identity) throw Error("TIMELINE_DETAIL_INVALID");
129
+ const timeline = parseActivityTimeline(await readFile(timelinePath, "utf8"));
130
+ const task = timeline.tasks.find(value => value.rootId === identity.rootId && value.id === identity.taskId);
131
+ const reference = task?.[field]; if (!reference) throw Error("TIMELINE_DETAIL_UNAVAILABLE");
132
+ return { taskKey, field, text: await readActivityContent(timelinePath, reference) };
133
+ }
@@ -8,6 +8,7 @@ import { resolve } from "node:path";
8
8
  import { pathToFileURL } from "node:url";
9
9
  import { parseDashboardLedger } from "./dashboard-projection.ts";
10
10
  import { renderDashboard } from "./dashboard-render.ts";
11
+ import { defaultActivityTimelinePath, detailForTimeline, parseActivityTimeline, renderActivityTimeline, type TimelineFilter } from "./activity-timeline.ts";
11
12
  import { createDashboardDisplayControls } from "./dashboard-display-controls.ts";
12
13
  import { createDailyViewReader, isDailyViewReader, readDailyView, type DailyViewOptions } from "./daily-view.ts";
13
14
  import { renderDailyView } from "./daily-view-render.ts";
@@ -46,6 +47,8 @@ export interface DashboardFrameOptions {
46
47
  width?: number;
47
48
  details?: boolean;
48
49
  history?: boolean;
50
+ filter?: TimelineFilter;
51
+ activityDetail?: { taskKey: string; field: "prompt" | "final" };
49
52
  now?: Date;
50
53
  dailyView?: DailyViewOptions;
51
54
  dailyReader?: ReturnType<typeof createDailyViewReader>;
@@ -137,9 +140,24 @@ export async function dashboardFrame(options: DashboardFrameOptions): Promise<st
137
140
  return "PI-DADDY — DAILY VIEW UNAVAILABLE\nInvalid or unavailable read-only input. No previous acceptance or continuity was substituted.";
138
141
  }
139
142
  }
140
- if (!options.ledgerPath) return setupFrame(options.cwd);
143
+ // Timeline is the default local activity surface. Retain the existing setup frame until a real
144
+ // extension-owned timeline exists, so opening a plugin before Pi has run remains explanatory.
145
+ const configuredPath = options.ledgerPath ?? defaultActivityTimelinePath(options.cwd);
146
+ if (!options.ledgerPath) {
147
+ try {
148
+ const activityPath = resolve(options.cwd, configuredPath);
149
+ const activity = await readFile(activityPath, "utf8");
150
+ const timeline = parseActivityTimeline(activity);
151
+ let content: { taskKey: string; field: "prompt" | "final"; text: string } | undefined;
152
+ if (options.activityDetail) try { content = await detailForTimeline(activityPath, options.activityDetail.taskKey, options.activityDetail.field); } catch (error) { return `PI-DADDY — ACTIVITY DETAIL UNAVAILABLE\n${error instanceof Error ? error.message : String(error)}`; }
153
+ return renderActivityTimeline(timeline, { details: options.details, filter: options.filter, content });
154
+ } catch (error) {
155
+ if ((error as { code?: string }).code === "ENOENT") return setupFrame(options.cwd);
156
+ return "PI-DADDY — ACTIVITY UNAVAILABLE\nTimeline could not be read; no substitute view was shown.";
157
+ }
158
+ }
141
159
 
142
- const ledgerPath = resolve(options.cwd, options.ledgerPath);
160
+ const ledgerPath = resolve(options.cwd, configuredPath);
143
161
  let text = "";
144
162
  let waiting = false;
145
163
  try {
@@ -157,7 +175,14 @@ export async function dashboardFrame(options: DashboardFrameOptions): Promise<st
157
175
  }
158
176
  }
159
177
 
160
- const rendered = renderDashboard(parseDashboardLedger(text, { now: options.now }), {
178
+ let timeline: string | undefined;
179
+ if (text.trimStart().startsWith('{"version":1')) {
180
+ const activity = parseActivityTimeline(text);
181
+ let content: { taskKey: string; field: "prompt" | "final"; text: string } | undefined;
182
+ if (options.activityDetail) try { content = await detailForTimeline(ledgerPath, options.activityDetail.taskKey, options.activityDetail.field); } catch (error) { return `PI-DADDY — ACTIVITY DETAIL UNAVAILABLE\n${error instanceof Error ? error.message : String(error)}`; }
183
+ timeline = renderActivityTimeline(activity, { details: options.details, filter: options.filter, content });
184
+ }
185
+ const rendered = timeline ?? renderDashboard(parseDashboardLedger(text, { now: options.now }), {
161
186
  color: options.color,
162
187
  width: options.width,
163
188
  details: options.details,
@@ -233,7 +258,7 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
233
258
  }
234
259
  const dailyReader = createDailyViewReader();
235
260
  const actionMenu=createDashboardMenu();
236
- const display = createDashboardDisplayControls(cli.details, Boolean(ledgerPath && !connected && !debrief && !cli.debriefJson && !dailyView));
261
+ const display = createDashboardDisplayControls(cli.details, Boolean(!connected && !debrief && !cli.debriefJson && !dailyView));
237
262
  let previous = "";
238
263
  let feedback = "";
239
264
  let input: ReturnType<typeof createInterface> | null = null;
@@ -1,17 +1,20 @@
1
1
  /** Local display controls; these never dispatch actions to an owner or change ledger state. */
2
2
  export function createDashboardDisplayControls(details: boolean, ledger: boolean) {
3
- const state = { details, history: false };
3
+ const state: { details: boolean; history: boolean; filter?: "everything" | "agents" | "skills" | "needs-you"; activityDetail?: { taskKey: string; field: "prompt" | "final" } } = { details, history: false, ...(ledger ? { filter: "everything" as const } : {}) };
4
4
  return {
5
5
  state,
6
6
  input(line: string): boolean {
7
- const key = line.trim().toLowerCase();
7
+ const input = line.trim(), key = input.toLowerCase();
8
8
  if (key === "d") { state.details = !state.details; return true; }
9
9
  if (ledger && key === "h") { state.history = !state.history; return true; }
10
+ if (ledger && ["everything", "agents", "skills", "needs-you"].includes(key)) { state.filter = key as NonNullable<typeof state.filter>; return true; }
11
+ const detail = /^(p|f)\s+([^\s]{1,1400})$/i.exec(input);
12
+ if (ledger && detail) { state.activityDetail = { field: detail[1]!.toLowerCase() === "p" ? "prompt" : "final", taskKey: detail[2]! }; return true; }
10
13
  return false;
11
14
  },
12
15
  prompt(): string {
13
16
  return ledger
14
- ? `d Details / h ${state.history ? "Collapse" : "Expand"} history, then Enter: `
17
+ ? `d Details / h ${state.history ? "Collapse" : "Expand"} history / Everything|Agents|Skills|Needs-you / p|f <root-id:task-id>, then Enter: `
15
18
  : "Action number / d Details, then Enter: ";
16
19
  },
17
20
  };
@@ -13,6 +13,7 @@ import {
13
13
  } from "./dashboard-herdr.ts";
14
14
  import { DASHBOARD_PROTOCOL_VERSION } from "./dashboard-cli.ts";
15
15
  import type { HerdrExec } from "./herdr-cli.ts";
16
+ import { defaultActivityTimelinePath, parseActivityTimeline, renderActivityTimeline } from "./activity-timeline.ts";
16
17
 
17
18
  export type DashboardPromptChoice = "not-now" | "never";
18
19
  interface DashboardPreference {
@@ -87,7 +88,13 @@ export async function offerDashboardHandshake(input: DashboardHandshakeInput): P
87
88
  const preference = await loadPreference(input.preferencePath);
88
89
  if (preference) return "suppressed";
89
90
  const plugin = await inspectDashboardPlugin(exec, input.pluginRoot);
90
- if (plugin.state === "compatible") return "already-installed";
91
+ // A compatible bundled panel is local software already chosen by the operator. Present it by default;
92
+ // pane identity makes this one panel per caller tab/session and --no-focus avoids focus theft.
93
+ if (plugin.state === "compatible") {
94
+ const opened = await openOrReuseDashboard({ exec, host: host.host, ledgerPath: input.ledgerPath ?? "", cwd: input.cwd, statePath: input.paneStatePath, pluginRoot: input.pluginRoot, allowInactive: true });
95
+ input.ui.notify(`pi-daddy dashboard ${opened.kind} in pane ${opened.paneId} without changing focus.`, "info");
96
+ return "already-installed";
97
+ }
91
98
  const replacingDifferentPackage = plugin.state === "incompatible" && plugin.incompatibility === "package-root";
92
99
  if (plugin.state !== "absent" && !replacingDifferentPackage) {
93
100
  input.ui.notify(`pi-daddy dashboard: ${plugin.diagnostic}`, "error");
@@ -157,21 +164,19 @@ export interface DashboardCommandInput {
157
164
  paneStatePath: string;
158
165
  }
159
166
 
160
- export async function openDashboardCommand(input: DashboardCommandInput): Promise<DashboardOpenResult> {
167
+ export type DashboardCommandResult = DashboardOpenResult | { kind: "fallback"; frame: string; visibleBesideCaller: false };
168
+ async function piFallback(cwd: string, reason: string): Promise<Extract<DashboardCommandResult, { kind: "fallback" }>> {
169
+ try { return { kind: "fallback", visibleBesideCaller: false, frame: `${reason}\n\n${renderActivityTimeline(parseActivityTimeline(await readFile(defaultActivityTimelinePath(cwd), "utf8")))}` }; }
170
+ catch { return { kind: "fallback", visibleBesideCaller: false, frame: `${reason}\n\nNo local activity recorded yet.` }; }
171
+ }
172
+ export async function openDashboardCommand(input: DashboardCommandInput): Promise<DashboardCommandResult> {
161
173
  const exec = input.exec ?? dashboardHerdrExec;
162
- // Order is deliberate: a server elsewhere must not make an outside-pi command look hosted.
174
+ // A panel can only target this verified process; otherwise /grants dashboard renders the local Pi fallback.
163
175
  const host = await verifyHerdrHost({ env: input.env, pid: input.pid, exec });
164
- if (!host.ok) throw new Error(host.diagnostic);
165
- if (!input.ledgerPath?.trim()) {
166
- throw new Error("no pi-daddy ledger is configured — set PI_GRANTS_LEDGER, then retry /grants dashboard");
167
- }
176
+ if (!host.ok) return piFallback(input.cwd, `Herdr panel unavailable: ${host.diagnostic}`);
177
+ if (!input.ledgerPath?.trim()) return piFallback(input.cwd, "Herdr panel has no governance ledger; showing the local activity timeline.");
168
178
  const plugin = await inspectDashboardPlugin(exec, input.pluginRoot);
169
- if (plugin.state === "absent") {
170
- throw new Error(
171
- `the pi-daddy Herdr plugin is not installed. Nothing was installed automatically. Run: ` +
172
- `herdr plugin link ${quote(resolve(input.pluginRoot))} --enabled`,
173
- );
174
- }
179
+ if (plugin.state === "absent") return piFallback(input.cwd, `Herdr panel is not linked. Run: herdr plugin link ${quote(resolve(input.pluginRoot))} --enabled`);
175
180
  if (plugin.state === "disabled") {
176
181
  throw new Error(`the pi-daddy Herdr plugin is disabled. Run: herdr plugin enable ${DASHBOARD_PLUGIN_ID}`);
177
182
  }
@@ -61,6 +61,10 @@ export interface DelegationContext {
61
61
  ledgerPath?: string;
62
62
  /** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
63
63
  extensionPath?: string;
64
+ /** No-tool activity observer injected only for governed leaf children. */
65
+ observerExtensionPath?: string;
66
+ /** Parent-owned local activity identity for this exact turn. */
67
+ activity?: { rootId: string; path: string; taskId?: string };
64
68
  /** Live capability catalog. When supplied, capabilities absent from it are refused as unknown. */
65
69
  catalog?: Catalog;
66
70
  /**
package/src/delegate.ts CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  import { resolveDelegationApproval } from "./delegation-approval.ts";
35
35
  import type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
36
36
  import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
37
+ import { ENV_ACTIVITY_PARENT_TASK, ENV_ACTIVITY_PATH, ENV_ACTIVITY_ROOT, ENV_ACTIVITY_TASK } from "./activity-timeline.ts";
37
38
  export type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
38
39
 
39
40
  export function planDelegation(request: DelegationRequest, ctx: DelegationContext): Delegation {
@@ -286,13 +287,11 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
286
287
  }, "DEFINITION_UNREADABLE");
287
288
  }
288
289
 
289
- // A child may only delegate further if it was granted the capability AND has the extension to do it.
290
+ // `-e` loads even under `--no-extensions`. A delegate-capable child loads the governance extension;
291
+ // a leaf gets only the no-tool observer. Neither path alters --tools or the computed grant.
290
292
  const args = [...plan.args];
291
- if (canSubDelegate && ctx.extensionPath) {
292
- // `-e` loads even under `--no-extensions`, which planSpawn sets — that is precisely why the
293
- // extension is added explicitly here and nowhere else.
294
- args.splice(args.length - 1, 0, "-e", ctx.extensionPath);
295
- }
293
+ const observer = canSubDelegate ? ctx.extensionPath : ctx.observerExtensionPath;
294
+ if (observer) args.splice(args.length - 1, 0, "-e", observer);
296
295
 
297
296
  // `inheritableGrant`, not `result.effective` directly: this is the path a DELEGATED child's grant
298
297
  // actually travels, and the "held but never inherited" rule for `tool:*` and `workspace:*` was enforced
@@ -322,6 +321,14 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
322
321
  // reason on the other path.
323
322
  env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], inheritable).join(",");
324
323
  if (ctx.ledgerPath) env[ENV_LEDGER] = ctx.ledgerPath;
324
+ // These local observation values are per-child argv environment, never process-global grant state.
325
+ // A child cannot acquire a tool or a larger grant by receiving them.
326
+ if (ctx.activity?.taskId && ctx.childExecutionId) {
327
+ env[ENV_ACTIVITY_PATH] = ctx.activity.path;
328
+ env[ENV_ACTIVITY_ROOT] = ctx.activity.rootId;
329
+ env[ENV_ACTIVITY_TASK] = ctx.childExecutionId;
330
+ env[ENV_ACTIVITY_PARENT_TASK] = ctx.activity.taskId;
331
+ }
325
332
 
326
333
  return {
327
334
  ok: true,