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.
- package/CHANGELOG.md +16 -0
- package/PRODUCT-GUIDE.md +22 -3
- package/README.md +9 -9
- package/REQUIREMENTS.md +1 -1
- package/dist/activity-timeline.d.ts +109 -0
- package/dist/activity-timeline.d.ts.map +1 -0
- package/dist/activity-timeline.js +208 -0
- package/dist/activity-timeline.js.map +1 -0
- package/dist/dashboard-cli.d.ts +6 -0
- package/dist/dashboard-cli.d.ts.map +1 -1
- package/dist/dashboard-cli.js +41 -5
- package/dist/dashboard-cli.js.map +1 -1
- package/dist/dashboard-display-controls.d.ts +5 -0
- package/dist/dashboard-display-controls.d.ts.map +1 -1
- package/dist/dashboard-display-controls.js +12 -3
- package/dist/dashboard-display-controls.js.map +1 -1
- package/dist/dashboard-handshake.d.ts +6 -1
- package/dist/dashboard-handshake.d.ts.map +1 -1
- package/dist/dashboard-handshake.js +21 -10
- package/dist/dashboard-handshake.js.map +1 -1
- package/dist/delegate-types.d.ts +8 -0
- package/dist/delegate-types.d.ts.map +1 -1
- package/dist/delegate-types.js.map +1 -1
- package/dist/delegate.d.ts.map +1 -1
- package/dist/delegate.js +14 -6
- package/dist/delegate.js.map +1 -1
- package/dist/herdr-name.d.ts +1 -35
- package/dist/herdr-name.d.ts.map +1 -1
- package/dist/herdr-name.js +9 -48
- package/dist/herdr-name.js.map +1 -1
- package/dist/measured-session.d.ts +1 -1
- package/extensions/activity-timeline.ts +90 -0
- package/extensions/execute-child.ts +6 -6
- package/extensions/grants-command.ts +1 -1
- package/extensions/grants-connected-command.ts +2 -2
- package/extensions/grants.ts +7 -6
- package/extensions/reload-environment.ts +1 -1
- package/extensions/session-environment.ts +6 -3
- package/extensions/session-report.ts +1 -0
- package/extensions/session.ts +21 -35
- package/package.json +2 -2
- package/src/activity-timeline.ts +133 -0
- package/src/dashboard-cli.ts +29 -4
- package/src/dashboard-display-controls.ts +6 -3
- package/src/dashboard-handshake.ts +18 -13
- package/src/delegate-types.ts +4 -0
- package/src/delegate.ts +13 -6
- 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(
|
|
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
|
}
|
package/extensions/grants.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
25
|
-
session.
|
|
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"}]`,
|
package/extensions/session.ts
CHANGED
|
@@ -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
|
-
|
|
253
|
-
|
|
254
|
-
//
|
|
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
|
|
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.
|
|
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
|
+
}
|