pi-daddy 0.27.3 → 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 (48) hide show
  1. package/CHANGELOG.md +16 -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/herdr-name.d.ts +1 -35
  28. package/dist/herdr-name.d.ts.map +1 -1
  29. package/dist/herdr-name.js +9 -48
  30. package/dist/herdr-name.js.map +1 -1
  31. package/dist/measured-session.d.ts +1 -1
  32. package/extensions/activity-timeline.ts +90 -0
  33. package/extensions/execute-child.ts +6 -6
  34. package/extensions/grants-command.ts +1 -1
  35. package/extensions/grants-connected-command.ts +2 -2
  36. package/extensions/grants.ts +7 -6
  37. package/extensions/reload-environment.ts +1 -1
  38. package/extensions/session-environment.ts +6 -3
  39. package/extensions/session-report.ts +1 -0
  40. package/extensions/session.ts +21 -35
  41. package/package.json +2 -2
  42. package/src/activity-timeline.ts +133 -0
  43. package/src/dashboard-cli.ts +29 -4
  44. package/src/dashboard-display-controls.ts +6 -3
  45. package/src/dashboard-handshake.ts +18 -13
  46. package/src/delegate-types.ts +4 -0
  47. package/src/delegate.ts +13 -6
  48. package/src/herdr-name.ts +9 -49
@@ -0,0 +1,90 @@
1
+ /** Pi hook wiring for local activity observation. It neither decides grants nor adds child authority. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
6
+ import { Type } from "typebox";
7
+ import { ActivityTimelineRecorder, ENV_ACTIVITY_TASK, type ActivityIdentity, type ActivitySkill } from "../src/activity-timeline.ts";
8
+
9
+ export interface ActivitySessionState { activityRootId?: string; activity?: ActivityIdentity & { taskId?: string } }
10
+ interface HookContext { cwd: string; model?: { id?: string }; thinkingLevel?: string; ui: { notify(message: string, level?: "info" | "warning" | "error"): void } }
11
+ const digest = (text: string) => createHash("sha256").update(text).digest("hex");
12
+
13
+ /**
14
+ * Register observation hooks. `lifecycleTool` is false in a leaf observer injected with `-e`: that
15
+ * extension has no tools at all, so a child without tool:delegate receives no widened surface.
16
+ */
17
+ export function registerActivityTimeline(pi: ExtensionAPI, session?: ActivitySessionState, lifecycleTool = true): void {
18
+ let recorder: ActivityTimelineRecorder | undefined, pendingFinal: string | undefined, finalizedUser: string | undefined;
19
+ let paths = new Map<string, string>(), available = new Map<string, ActivitySkill>();
20
+ let reported = false;
21
+ const report = (ctx: HookContext | undefined, error: unknown) => { if (!reported && ctx) { reported = true; ctx.ui.notify(`pi-daddy activity: observation unavailable (${error instanceof Error ? error.message : String(error)}); governance is unchanged.`, "warning"); } };
22
+ const current = (ctx: HookContext): ActivityTimelineRecorder => {
23
+ const env = session?.activityRootId && !process.env.PI_DADDY_ACTIVITY_ROOT ? { ...process.env, PI_DADDY_ACTIVITY_ROOT: session.activityRootId } : process.env;
24
+ // session_start binds reload ownership after this hook registration; re-create before the first turn
25
+ // if that binding recovered a prior root identity rather than the factory's provisional UUID.
26
+ if (!recorder || (!process.env.PI_DADDY_ACTIVITY_ROOT && session?.activityRootId && recorder.rootId !== session.activityRootId)) recorder = new ActivityTimelineRecorder(ctx.cwd, env);
27
+ return recorder;
28
+ };
29
+ const skills = async (event: unknown, target: ActivityTimelineRecorder) => {
30
+ available = new Map();
31
+ const entries = ((event as { systemPromptOptions?: { skills?: Array<{ name?: string; filePath?: string }> } }).systemPromptOptions?.skills ?? []);
32
+ for (const item of entries) {
33
+ if (!item.name || !item.filePath) continue;
34
+ const source = await readFile(item.filePath, "utf8");
35
+ if (Buffer.byteLength(source) > 256 * 1024) continue;
36
+ const fact = { name: item.name, source: item.filePath, digest: digest(source) };
37
+ available.set(resolve(item.filePath), fact); await target.skill("skill_available", fact);
38
+ }
39
+ };
40
+ if (lifecycleTool) pi.registerTool({
41
+ name: "activity_lifecycle", label: "Activity lifecycle",
42
+ description: "Declare a runtime skill active, finished, or superseded for the local activity timeline. This is not compliance evidence.",
43
+ parameters: Type.Object({ state: Type.Union([Type.Literal("active"), Type.Literal("finished"), Type.Literal("superseded")]), name: Type.String({ minLength: 1, maxLength: 128 }), source: Type.String({ minLength: 1, maxLength: 1024 }), digest: Type.String({ pattern: "^[a-f0-9]{64}$" }) }),
44
+ async execute(_id, params) { const kind = params.state === "active" ? "skill_active" : params.state === "finished" ? "skill_finished" : "skill_superseded"; await recorder?.skill(kind, { name: params.name, source: params.source, digest: params.digest }); return { content: [{ type: "text", text: `Activity recorded: ${params.name} ${params.state}.` }], details: {} }; },
45
+ });
46
+ pi.on("session_start", (_event, ctx: HookContext) => {
47
+ try {
48
+ recorder = current(ctx);
49
+ // A leaf observer attaches to the parent-owned child event; a root gets a stable session identity.
50
+ if (process.env[ENV_ACTIVITY_TASK]) recorder.attach(process.env[ENV_ACTIVITY_TASK]!);
51
+ const identity: ActivityIdentity = { rootId: recorder.rootId, path: recorder.path, ...(recorder.task ? { taskId: recorder.task } : {}) };
52
+ if (session) session.activity = identity;
53
+ ctx.ui.notify(recorder.enabled ? `pi-daddy activity: local observation on (${recorder.contentEnabled ? "private prompt/final content" : "metadata only"}); governance is unchanged.` : "pi-daddy activity: observation off; governance is unchanged.", "info");
54
+ } catch (error) { report(ctx, error); }
55
+ return undefined;
56
+ });
57
+ pi.on("before_agent_start", async (event, ctx: HookContext) => {
58
+ try {
59
+ const target = current(ctx);
60
+ // An injected leaf observer must not fabricate a second child task; its parent-owned execution seam
61
+ // already wrote task_started. Root turns always get a fresh task id under their stable root identity.
62
+ if (!process.env[ENV_ACTIVITY_TASK]) {
63
+ const taskId = await target.start(finalizedUser ?? (event as { prompt?: string }).prompt ?? "", ctx.model?.id, ctx.thinkingLevel);
64
+ finalizedUser = undefined;
65
+ if (session?.activity) session.activity = { rootId: target.rootId, path: target.path, ...(taskId ? { taskId } : {}) };
66
+ }
67
+ await skills(event, target);
68
+ } catch (error) { report(ctx, error); }
69
+ return undefined;
70
+ });
71
+ pi.on("message_end", (event) => {
72
+ const message = (event as { message?: { role?: string; content?: string | Array<{ type?: string; text?: string }> } }).message;
73
+ const text = typeof message?.content === "string" ? message.content : Array.isArray(message?.content) ? message!.content.filter(part => part.type === "text").map(part => part.text ?? "").join("") : "";
74
+ if (message?.role === "user") finalizedUser = text;
75
+ if (message?.role === "assistant") pendingFinal = text;
76
+ return undefined;
77
+ });
78
+ pi.on("tool_execution_start", (event) => { const value = event as { toolCallId?: string; args?: { path?: unknown } }; if (value.toolCallId && typeof value.args?.path === "string") paths.set(value.toolCallId, value.args.path); return undefined; });
79
+ pi.on("tool_execution_end", async (event) => {
80
+ try { const value = event as { isError?: boolean; toolName?: string; toolCallId?: string }; const path = value.toolCallId ? paths.get(value.toolCallId) : undefined; if (value.toolCallId) paths.delete(value.toolCallId); const skill = path ? available.get(resolve(path)) : undefined; if (!value.isError && value.toolName === "read" && skill) await recorder?.skill("skill_read", skill); } catch { /* observation cannot affect tool execution */ }
81
+ return undefined;
82
+ });
83
+ pi.on("agent_settled", async (event, ctx: HookContext) => {
84
+ try { if (recorder && !process.env[ENV_ACTIVITY_TASK]) await recorder.finish(pendingFinal, (event as { cancelled?: boolean; error?: unknown }).cancelled ? "cancelled" : (event as { error?: unknown }).error ? "failed" : "completed"); pendingFinal = undefined; } catch (error) { report(ctx, error); }
85
+ return undefined;
86
+ });
87
+ }
88
+
89
+ /** A no-tool observer used for governed leaves that intentionally do not load grants.ts. */
90
+ export default function activityObserver(pi: ExtensionAPI): void { registerActivityTimeline(pi, undefined, false); }
@@ -12,7 +12,7 @@ import { GovernanceRefusal, refusal, type StructuredRefusal } from "../src/refus
12
12
  import { ENV_HERDR_KEEP_PANE, type GrantsSession } from "./session.ts";
13
13
  import { releaseDelegationWorkspace, type PreparedWorkspace } from "./workspace-runtime.ts";
14
14
  import { beginDeclaredWorkAttempt } from "./work-runtime.ts";
15
-
15
+ import { ActivityTimelineRecorder, ENV_ACTIVITY_PARENT_TASK } from "../src/activity-timeline.ts";
16
16
  export interface DelegationOutcome {
17
17
  ok: boolean;
18
18
  text: string;
@@ -30,7 +30,6 @@ export interface DelegationOutcome {
30
30
  /** A loud best-effort post-execution observation failure; execution ownership is still settled. */
31
31
  control?: "failed";
32
32
  }
33
-
34
33
  /**
35
34
  * The upstream controller's token, honoured ONLY when the child otherwise exited cleanly non-zero.
36
35
  *
@@ -65,7 +64,6 @@ export function isHerdrWriterCloseFailure(error: unknown): boolean {
65
64
  return error instanceof HerdrWriterCloseError ||
66
65
  hasFinalizerError(error, (value) => value instanceof HerdrWriterCloseError);
67
66
  }
68
-
69
67
  export interface ChildProgressUpdate {
70
68
  chunk?: string;
71
69
  snapshot?: string[];
@@ -73,7 +71,6 @@ export interface ChildProgressUpdate {
73
71
  agentName?: string;
74
72
  state?: "starting" | "running" | "completed" | "failed";
75
73
  }
76
-
77
74
  /**
78
75
  * Execute one already-approved plan, record lifecycle, and release any workspace lease on every path
79
76
  * EXCEPT a failed herdr writer-tab close, which deliberately retains the lease because the pane may
@@ -102,6 +99,9 @@ async function executeChildBody(input: {
102
99
  }): Promise<DelegationOutcome> {
103
100
  const { session, plan, childId, executionId, parentExecutionId, preparedWorkspace, signal, onProgress } = input;
104
101
  const ledgerPath = session.ledgerPath;
102
+ let activityParent: string | undefined, activity = new ActivityTimelineRecorder(input.cwd), activityStarted = false, activityFinished = false;
103
+ try { const env = { ...process.env }; for (const name of ["PI_DADDY_ACTIVITY_PATH", "PI_DADDY_ACTIVITY_ROOT", "PI_DADDY_ACTIVITY_TASK", ENV_ACTIVITY_PARENT_TASK]) { const value = plan.env[name]; if (value) env[name] = value; } activity = new ActivityTimelineRecorder(input.cwd, env); activityParent = plan.env[ENV_ACTIVITY_PARENT_TASK]; } catch {}
104
+ try { await activity.childStarted(executionId, activityParent, input.agent ?? "governed child", (plan.args.at(-1) ?? "").trimStart()); activityStarted = true; } catch {}
105
105
  const configuredTimeoutMs = timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]);
106
106
  const startedAt = new Date();
107
107
  const deadlineAt = new Date(startedAt.getTime() + configuredTimeoutMs).toISOString();
@@ -237,6 +237,7 @@ async function executeChildBody(input: {
237
237
  retention.finish({ code: output.code, signal: output.signal ?? null, timedOut: output.timedOut,
238
238
  aborted: output.aborted, truncated: output.truncated, failed: childFailed });
239
239
  releaseReason = output.timedOut ? "timeout" : output.aborted ? "cancelled" : childFailed ? "failed" : "completed";
240
+ if (activityStarted) try { await activity.childFinished(executionId, activityParent, input.agent ?? "governed child", output.text, childFailed ? output.aborted ? "cancelled" : "failed" : "completed"); activityFinished = true; } catch { /* observation does not control execution */ }
240
241
  if (workAttempt) try { workTerminalAttempted = true; await workAttempt.finish(childFailed ? "failed" : "completed"); }
241
242
  catch (error) { teardownFailures.push(`declared work terminal record failed: ${String(error)}`); }
242
243
  if (ledgerPath) {
@@ -321,6 +322,7 @@ async function executeChildBody(input: {
321
322
  await teardown();
322
323
  return withTeardownNotes(succeeded);
323
324
  } catch (error) {
325
+ if (activityStarted && !activityFinished) try { await activity.childFinished(executionId, activityParent, input.agent ?? "governed child", "", signal?.aborted ? "cancelled" : "failed"); } catch { /* observation does not control execution */ }
324
326
  retention.finish({ code: null, signal: null, timedOut: false, aborted: Boolean(signal?.aborted), truncated: false, failed: true });
325
327
  if (workAttempt && !workTerminalAttempted) try { workTerminalAttempted = true; await workAttempt.finish("failed"); }
326
328
  catch (cause) { teardownFailures.push(`declared work terminal record failed: ${String(cause)}`); }
@@ -350,7 +352,6 @@ async function executeChildBody(input: {
350
352
  // collected into an array nothing read.
351
353
  throw errorWithTeardownNotes(error, teardownFailures);
352
354
  }
353
-
354
355
  /**
355
356
  * Surfaces a teardown failure WITHOUT discarding the result. The child already ran; telling the
356
357
  * orchestrator "ledger write failed" and nothing else made a completed delegation indistinguishable
@@ -374,7 +375,6 @@ function errorWithTeardownNotes(error: unknown, notes: readonly string[]): unkno
374
375
  if (error instanceof Error) return new Error([error.message, ...notes].join("; "), { cause: error });
375
376
  return error;
376
377
  }
377
-
378
378
  function withTeardownNotes(outcome: DelegationOutcome): DelegationOutcome {
379
379
  const observed = { ...outcome, retention: retention.status() };
380
380
  if (teardownFailures.length === 0) return observed;
@@ -63,7 +63,7 @@ export interface GrantsCommandContext {
63
63
  */
64
64
  runInit: () => Promise<void>;
65
65
  /** Open or reuse the read-only Herdr dashboard; injected so this diagnostic never becomes enforcement. */
66
- openDashboard: () => Promise<{ kind: "opened" | "reused"; paneId: string; visibleBesideCaller: boolean }>;
66
+ openDashboard: () => Promise<{ kind: "opened" | "reused"; paneId: string; visibleBesideCaller: boolean } | { kind: "fallback"; frame: string; visibleBesideCaller: false }>;
67
67
  /** Explicit same-process production host lifecycle; owns no model call and never cancels a child on stop. */
68
68
  runHost: (target: string) => Promise<string>;
69
69
  variantRuns: Map<string, VariantRunAccounting>;
@@ -1,6 +1,6 @@
1
1
  export interface GrantsConnectedCommandContext {
2
2
  runHost:(target:string)=>Promise<string>;
3
- openDashboard:()=>Promise<{kind:"opened"|"reused";paneId:string;visibleBesideCaller:boolean}>;
3
+ openDashboard:()=>Promise<{kind:"opened"|"reused";paneId:string;visibleBesideCaller:boolean}|{kind:"fallback";frame:string;visibleBesideCaller:false}>;
4
4
  ui:{notify(message:string,level:"info"|"error"):void};
5
5
  }
6
6
  /** Host lifecycle and pane placement live together; neither belongs in the capability-report renderer. */
@@ -11,7 +11,7 @@ export async function handleConnectedCommand(sub:string|undefined,target:string|
11
11
  return true;
12
12
  }
13
13
  if(sub==="dashboard"){
14
- try{const opened=await ctx.openDashboard();ctx.ui.notify(`grants: dashboard ${opened.kind} in Herdr pane ${opened.paneId} without changing focus`+(opened.visibleBesideCaller?".":" (the existing pane is in another tab)."),"info");}
14
+ try{const opened=await ctx.openDashboard();ctx.ui.notify(opened.kind==="fallback"?opened.frame:`grants: dashboard ${opened.kind} in Herdr pane ${opened.paneId} without changing focus`+(opened.visibleBesideCaller?".":" (the existing pane is in another tab)."),"info");}
15
15
  catch(error){ctx.ui.notify(`grants: dashboard unavailable — ${error instanceof Error?error.message:String(error)}`,"error");}
16
16
  return true;
17
17
  }
@@ -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.3",
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
+ }