pi-daddy 0.27.4 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -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/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/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
|
+
}
|
package/src/dashboard-cli.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { resolve } from "node:path";
|
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
9
|
import { parseDashboardLedger } from "./dashboard-projection.ts";
|
|
10
10
|
import { renderDashboard } from "./dashboard-render.ts";
|
|
11
|
+
import { defaultActivityTimelinePath, detailForTimeline, parseActivityTimeline, renderActivityTimeline, type TimelineFilter } from "./activity-timeline.ts";
|
|
11
12
|
import { createDashboardDisplayControls } from "./dashboard-display-controls.ts";
|
|
12
13
|
import { createDailyViewReader, isDailyViewReader, readDailyView, type DailyViewOptions } from "./daily-view.ts";
|
|
13
14
|
import { renderDailyView } from "./daily-view-render.ts";
|
|
@@ -46,6 +47,8 @@ export interface DashboardFrameOptions {
|
|
|
46
47
|
width?: number;
|
|
47
48
|
details?: boolean;
|
|
48
49
|
history?: boolean;
|
|
50
|
+
filter?: TimelineFilter;
|
|
51
|
+
activityDetail?: { taskKey: string; field: "prompt" | "final" };
|
|
49
52
|
now?: Date;
|
|
50
53
|
dailyView?: DailyViewOptions;
|
|
51
54
|
dailyReader?: ReturnType<typeof createDailyViewReader>;
|
|
@@ -137,9 +140,24 @@ export async function dashboardFrame(options: DashboardFrameOptions): Promise<st
|
|
|
137
140
|
return "PI-DADDY — DAILY VIEW UNAVAILABLE\nInvalid or unavailable read-only input. No previous acceptance or continuity was substituted.";
|
|
138
141
|
}
|
|
139
142
|
}
|
|
140
|
-
|
|
143
|
+
// Timeline is the default local activity surface. Retain the existing setup frame until a real
|
|
144
|
+
// extension-owned timeline exists, so opening a plugin before Pi has run remains explanatory.
|
|
145
|
+
const configuredPath = options.ledgerPath ?? defaultActivityTimelinePath(options.cwd);
|
|
146
|
+
if (!options.ledgerPath) {
|
|
147
|
+
try {
|
|
148
|
+
const activityPath = resolve(options.cwd, configuredPath);
|
|
149
|
+
const activity = await readFile(activityPath, "utf8");
|
|
150
|
+
const timeline = parseActivityTimeline(activity);
|
|
151
|
+
let content: { taskKey: string; field: "prompt" | "final"; text: string } | undefined;
|
|
152
|
+
if (options.activityDetail) try { content = await detailForTimeline(activityPath, options.activityDetail.taskKey, options.activityDetail.field); } catch (error) { return `PI-DADDY — ACTIVITY DETAIL UNAVAILABLE\n${error instanceof Error ? error.message : String(error)}`; }
|
|
153
|
+
return renderActivityTimeline(timeline, { details: options.details, filter: options.filter, content });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if ((error as { code?: string }).code === "ENOENT") return setupFrame(options.cwd);
|
|
156
|
+
return "PI-DADDY — ACTIVITY UNAVAILABLE\nTimeline could not be read; no substitute view was shown.";
|
|
157
|
+
}
|
|
158
|
+
}
|
|
141
159
|
|
|
142
|
-
const ledgerPath = resolve(options.cwd,
|
|
160
|
+
const ledgerPath = resolve(options.cwd, configuredPath);
|
|
143
161
|
let text = "";
|
|
144
162
|
let waiting = false;
|
|
145
163
|
try {
|
|
@@ -157,7 +175,14 @@ export async function dashboardFrame(options: DashboardFrameOptions): Promise<st
|
|
|
157
175
|
}
|
|
158
176
|
}
|
|
159
177
|
|
|
160
|
-
|
|
178
|
+
let timeline: string | undefined;
|
|
179
|
+
if (text.trimStart().startsWith('{"version":1')) {
|
|
180
|
+
const activity = parseActivityTimeline(text);
|
|
181
|
+
let content: { taskKey: string; field: "prompt" | "final"; text: string } | undefined;
|
|
182
|
+
if (options.activityDetail) try { content = await detailForTimeline(ledgerPath, options.activityDetail.taskKey, options.activityDetail.field); } catch (error) { return `PI-DADDY — ACTIVITY DETAIL UNAVAILABLE\n${error instanceof Error ? error.message : String(error)}`; }
|
|
183
|
+
timeline = renderActivityTimeline(activity, { details: options.details, filter: options.filter, content });
|
|
184
|
+
}
|
|
185
|
+
const rendered = timeline ?? renderDashboard(parseDashboardLedger(text, { now: options.now }), {
|
|
161
186
|
color: options.color,
|
|
162
187
|
width: options.width,
|
|
163
188
|
details: options.details,
|
|
@@ -233,7 +258,7 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
|
|
|
233
258
|
}
|
|
234
259
|
const dailyReader = createDailyViewReader();
|
|
235
260
|
const actionMenu=createDashboardMenu();
|
|
236
|
-
const display = createDashboardDisplayControls(cli.details, Boolean(
|
|
261
|
+
const display = createDashboardDisplayControls(cli.details, Boolean(!connected && !debrief && !cli.debriefJson && !dailyView));
|
|
237
262
|
let previous = "";
|
|
238
263
|
let feedback = "";
|
|
239
264
|
let input: ReturnType<typeof createInterface> | null = null;
|
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
/** Local display controls; these never dispatch actions to an owner or change ledger state. */
|
|
2
2
|
export function createDashboardDisplayControls(details: boolean, ledger: boolean) {
|
|
3
|
-
const state = { details, history: false };
|
|
3
|
+
const state: { details: boolean; history: boolean; filter?: "everything" | "agents" | "skills" | "needs-you"; activityDetail?: { taskKey: string; field: "prompt" | "final" } } = { details, history: false, ...(ledger ? { filter: "everything" as const } : {}) };
|
|
4
4
|
return {
|
|
5
5
|
state,
|
|
6
6
|
input(line: string): boolean {
|
|
7
|
-
const
|
|
7
|
+
const input = line.trim(), key = input.toLowerCase();
|
|
8
8
|
if (key === "d") { state.details = !state.details; return true; }
|
|
9
9
|
if (ledger && key === "h") { state.history = !state.history; return true; }
|
|
10
|
+
if (ledger && ["everything", "agents", "skills", "needs-you"].includes(key)) { state.filter = key as NonNullable<typeof state.filter>; return true; }
|
|
11
|
+
const detail = /^(p|f)\s+([^\s]{1,1400})$/i.exec(input);
|
|
12
|
+
if (ledger && detail) { state.activityDetail = { field: detail[1]!.toLowerCase() === "p" ? "prompt" : "final", taskKey: detail[2]! }; return true; }
|
|
10
13
|
return false;
|
|
11
14
|
},
|
|
12
15
|
prompt(): string {
|
|
13
16
|
return ledger
|
|
14
|
-
? `d Details / h ${state.history ? "Collapse" : "Expand"} history
|
|
17
|
+
? `d Details / h ${state.history ? "Collapse" : "Expand"} history / Everything|Agents|Skills|Needs-you / p|f <root-id:task-id>, then Enter: `
|
|
15
18
|
: "Action number / d Details, then Enter: ";
|
|
16
19
|
},
|
|
17
20
|
};
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "./dashboard-herdr.ts";
|
|
14
14
|
import { DASHBOARD_PROTOCOL_VERSION } from "./dashboard-cli.ts";
|
|
15
15
|
import type { HerdrExec } from "./herdr-cli.ts";
|
|
16
|
+
import { defaultActivityTimelinePath, parseActivityTimeline, renderActivityTimeline } from "./activity-timeline.ts";
|
|
16
17
|
|
|
17
18
|
export type DashboardPromptChoice = "not-now" | "never";
|
|
18
19
|
interface DashboardPreference {
|
|
@@ -87,7 +88,13 @@ export async function offerDashboardHandshake(input: DashboardHandshakeInput): P
|
|
|
87
88
|
const preference = await loadPreference(input.preferencePath);
|
|
88
89
|
if (preference) return "suppressed";
|
|
89
90
|
const plugin = await inspectDashboardPlugin(exec, input.pluginRoot);
|
|
90
|
-
|
|
91
|
+
// A compatible bundled panel is local software already chosen by the operator. Present it by default;
|
|
92
|
+
// pane identity makes this one panel per caller tab/session and --no-focus avoids focus theft.
|
|
93
|
+
if (plugin.state === "compatible") {
|
|
94
|
+
const opened = await openOrReuseDashboard({ exec, host: host.host, ledgerPath: input.ledgerPath ?? "", cwd: input.cwd, statePath: input.paneStatePath, pluginRoot: input.pluginRoot, allowInactive: true });
|
|
95
|
+
input.ui.notify(`pi-daddy dashboard ${opened.kind} in pane ${opened.paneId} without changing focus.`, "info");
|
|
96
|
+
return "already-installed";
|
|
97
|
+
}
|
|
91
98
|
const replacingDifferentPackage = plugin.state === "incompatible" && plugin.incompatibility === "package-root";
|
|
92
99
|
if (plugin.state !== "absent" && !replacingDifferentPackage) {
|
|
93
100
|
input.ui.notify(`pi-daddy dashboard: ${plugin.diagnostic}`, "error");
|
|
@@ -157,21 +164,19 @@ export interface DashboardCommandInput {
|
|
|
157
164
|
paneStatePath: string;
|
|
158
165
|
}
|
|
159
166
|
|
|
160
|
-
export
|
|
167
|
+
export type DashboardCommandResult = DashboardOpenResult | { kind: "fallback"; frame: string; visibleBesideCaller: false };
|
|
168
|
+
async function piFallback(cwd: string, reason: string): Promise<Extract<DashboardCommandResult, { kind: "fallback" }>> {
|
|
169
|
+
try { return { kind: "fallback", visibleBesideCaller: false, frame: `${reason}\n\n${renderActivityTimeline(parseActivityTimeline(await readFile(defaultActivityTimelinePath(cwd), "utf8")))}` }; }
|
|
170
|
+
catch { return { kind: "fallback", visibleBesideCaller: false, frame: `${reason}\n\nNo local activity recorded yet.` }; }
|
|
171
|
+
}
|
|
172
|
+
export async function openDashboardCommand(input: DashboardCommandInput): Promise<DashboardCommandResult> {
|
|
161
173
|
const exec = input.exec ?? dashboardHerdrExec;
|
|
162
|
-
//
|
|
174
|
+
// A panel can only target this verified process; otherwise /grants dashboard renders the local Pi fallback.
|
|
163
175
|
const host = await verifyHerdrHost({ env: input.env, pid: input.pid, exec });
|
|
164
|
-
if (!host.ok)
|
|
165
|
-
if (!input.ledgerPath?.trim())
|
|
166
|
-
throw new Error("no pi-daddy ledger is configured — set PI_GRANTS_LEDGER, then retry /grants dashboard");
|
|
167
|
-
}
|
|
176
|
+
if (!host.ok) return piFallback(input.cwd, `Herdr panel unavailable: ${host.diagnostic}`);
|
|
177
|
+
if (!input.ledgerPath?.trim()) return piFallback(input.cwd, "Herdr panel has no governance ledger; showing the local activity timeline.");
|
|
168
178
|
const plugin = await inspectDashboardPlugin(exec, input.pluginRoot);
|
|
169
|
-
if (plugin.state === "absent") {
|
|
170
|
-
throw new Error(
|
|
171
|
-
`the pi-daddy Herdr plugin is not installed. Nothing was installed automatically. Run: ` +
|
|
172
|
-
`herdr plugin link ${quote(resolve(input.pluginRoot))} --enabled`,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
179
|
+
if (plugin.state === "absent") return piFallback(input.cwd, `Herdr panel is not linked. Run: herdr plugin link ${quote(resolve(input.pluginRoot))} --enabled`);
|
|
175
180
|
if (plugin.state === "disabled") {
|
|
176
181
|
throw new Error(`the pi-daddy Herdr plugin is disabled. Run: herdr plugin enable ${DASHBOARD_PLUGIN_ID}`);
|
|
177
182
|
}
|
package/src/delegate-types.ts
CHANGED
|
@@ -61,6 +61,10 @@ export interface DelegationContext {
|
|
|
61
61
|
ledgerPath?: string;
|
|
62
62
|
/** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
|
|
63
63
|
extensionPath?: string;
|
|
64
|
+
/** No-tool activity observer injected only for governed leaf children. */
|
|
65
|
+
observerExtensionPath?: string;
|
|
66
|
+
/** Parent-owned local activity identity for this exact turn. */
|
|
67
|
+
activity?: { rootId: string; path: string; taskId?: string };
|
|
64
68
|
/** Live capability catalog. When supplied, capabilities absent from it are refused as unknown. */
|
|
65
69
|
catalog?: Catalog;
|
|
66
70
|
/**
|
package/src/delegate.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { resolveDelegationApproval } from "./delegation-approval.ts";
|
|
35
35
|
import type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
|
|
36
36
|
import { assertCapabilitiesArePropagatable } from "./capabilities.ts";
|
|
37
|
+
import { ENV_ACTIVITY_PARENT_TASK, ENV_ACTIVITY_PATH, ENV_ACTIVITY_ROOT, ENV_ACTIVITY_TASK } from "./activity-timeline.ts";
|
|
37
38
|
export type { Delegation, DelegationContext, DelegationRequest } from "./delegate-types.ts";
|
|
38
39
|
|
|
39
40
|
export function planDelegation(request: DelegationRequest, ctx: DelegationContext): Delegation {
|
|
@@ -286,13 +287,11 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
286
287
|
}, "DEFINITION_UNREADABLE");
|
|
287
288
|
}
|
|
288
289
|
|
|
289
|
-
//
|
|
290
|
+
// `-e` loads even under `--no-extensions`. A delegate-capable child loads the governance extension;
|
|
291
|
+
// a leaf gets only the no-tool observer. Neither path alters --tools or the computed grant.
|
|
290
292
|
const args = [...plan.args];
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
// extension is added explicitly here and nowhere else.
|
|
294
|
-
args.splice(args.length - 1, 0, "-e", ctx.extensionPath);
|
|
295
|
-
}
|
|
293
|
+
const observer = canSubDelegate ? ctx.extensionPath : ctx.observerExtensionPath;
|
|
294
|
+
if (observer) args.splice(args.length - 1, 0, "-e", observer);
|
|
296
295
|
|
|
297
296
|
// `inheritableGrant`, not `result.effective` directly: this is the path a DELEGATED child's grant
|
|
298
297
|
// actually travels, and the "held but never inherited" rule for `tool:*` and `workspace:*` was enforced
|
|
@@ -322,6 +321,14 @@ export function planDelegation(request: DelegationRequest, ctx: DelegationContex
|
|
|
322
321
|
// reason on the other path.
|
|
323
322
|
env[ENV_APPROVED] = inheritApprovals(ctx.approved ?? [], inheritable).join(",");
|
|
324
323
|
if (ctx.ledgerPath) env[ENV_LEDGER] = ctx.ledgerPath;
|
|
324
|
+
// These local observation values are per-child argv environment, never process-global grant state.
|
|
325
|
+
// A child cannot acquire a tool or a larger grant by receiving them.
|
|
326
|
+
if (ctx.activity?.taskId && ctx.childExecutionId) {
|
|
327
|
+
env[ENV_ACTIVITY_PATH] = ctx.activity.path;
|
|
328
|
+
env[ENV_ACTIVITY_ROOT] = ctx.activity.rootId;
|
|
329
|
+
env[ENV_ACTIVITY_TASK] = ctx.childExecutionId;
|
|
330
|
+
env[ENV_ACTIVITY_PARENT_TASK] = ctx.activity.taskId;
|
|
331
|
+
}
|
|
325
332
|
|
|
326
333
|
return {
|
|
327
334
|
ok: true,
|