pi-daddy 0.25.2 → 0.26.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.
@@ -0,0 +1,12 @@
1
+ import { GovernanceRefusal, refusal } from "../src/refusals.ts";
2
+ import type { GrantsSession } from "./session.ts";
3
+
4
+ /** Tool definitions may be inspected directly; execution still requires an owner-bound delegation grant. */
5
+ export function assertDelegationAuthority(session: GrantsSession): void {
6
+ if (!session.ownerBound) {
7
+ throw new GovernanceRefusal(refusal("CAPABILITY_ESCALATION", "delegation refused: session ownership is not bound yet"));
8
+ }
9
+ if (!session.mayDelegate) {
10
+ throw new GovernanceRefusal(refusal("CAPABILITY_ESCALATION", "delegation refused: this session does not hold tool:delegate"));
11
+ }
12
+ }
@@ -30,6 +30,7 @@ import { type GrantsSession } from "./session.ts";
30
30
  import { newDelegationOccurrence } from "./execution-occurrence.ts";
31
31
  import { correlationShape as buildCorrelationShape } from "./correlation-shape.ts";
32
32
  import { completePrimary } from "./primary-shadow.ts";
33
+ import { assertDelegationAuthority } from "./delegation-authority.ts";
33
34
 
34
35
  /**
35
36
  * Wire a set of children to pi's partial-result channel — ADR-0032.
@@ -121,14 +122,13 @@ export interface DelegationRegistration {
121
122
 
122
123
 
123
124
  /**
124
- * Register `delegate` and `delegate_all` — but only if this session may delegate.
125
+ * Register delegation definitions at owner-bound session_start.
125
126
  *
126
- * The conditional is the whole of S-5: an unconditionally-registered `delegate` appears in every child's
127
- * ceiling, so a delegator without it was told every single agent type "requires tool:delegate".
127
+ * Pi exposes factory registrations before `bindExtensions()`, so this timing keeps the pre-start surface
128
+ * empty. Owner reconciliation precedes registration and activation, so a holder without `tool:delegate`
129
+ * remains a leaf at every dispatch boundary.
128
130
  */
129
131
  export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession): DelegationRegistration {
130
- if (!session.mayDelegate) return { refreshSpawnable: () => {} };
131
-
132
132
  /**
133
133
  * Definitions this session is actually authorised to spawn (ADR-0017), for the tool description.
134
134
  *
@@ -142,9 +142,8 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
142
142
  /**
143
143
  * **R-39.** This used to be computed once, right here, and it was always `[]`.
144
144
  *
145
- * `registerDelegationTools` is called synchronously from the extension factory, while
146
- * `session.definitions` is only populated in the `session_start` hook — which fires afterwards. So every
147
- * model in every governed session was told `Available: none.` and did the reasonable thing: it used
145
+ * `registerDelegationTools` runs at `session_start`, before that hook loads definitions. So every model
146
+ * in every governed session was told `Available: none.` and did the reasonable thing: it used
148
147
  * `delegate({tools})`, the path with no operator-authored instructions, no `agent:` prerequisite, no body
149
148
  * digest on the record, and no `always` approval available. **ADR-0017 and ADR-0019 bought expressiveness
150
149
  * the model was structurally prevented from using**, and every dialog was a `<delegate>` dialog again.
@@ -230,6 +229,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
230
229
  "sub-agent must itself delegate further; withhold it to make the sub-agent a leaf.",
231
230
  parameters: delegateParams,
232
231
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
232
+ assertDelegationAuthority(session);
233
233
  // ADR-0032: one child, same block. `_onUpdate` was discarded here, so a delegation showed the bare word
234
234
  // `delegate` for up to DEFAULT_TIMEOUT_MS — twenty minutes by default.
235
235
  const progress = progressReporter(session, [params.agent ?? "delegate"], onUpdate as never);
@@ -302,6 +302,7 @@ export function registerDelegationTools(pi: ExtensionAPI, session: GrantsSession
302
302
  "completion=primary with a 1-based primary returns that result while shadows remain owned/accounted.",
303
303
  parameters: delegateAllParams,
304
304
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
305
+ assertDelegationAuthority(session);
305
306
  const children = params.children ?? [],primaryMode=params.completion==="primary";
306
307
  if(primaryMode!==Number.isInteger(params.primary)||primaryMode&&(params.primary!<1||params.primary!>children.length))throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED","primary fan-out requires one in-range 1-based primary"));
307
308
  if(primaryMode&&session.variantRuns.size>=128)throw new GovernanceRefusal(refusal("FANOUT_EXCEEDED","primary/shadow accounting capacity exhausted"));
@@ -38,16 +38,13 @@ import { grantsCommand } from "./grants-command.ts";
38
38
  import { runInit } from "./init-command.ts";
39
39
  import { planWithApprovals } from "./run-delegation.ts";
40
40
  import { createGrantsSession, loadProjectDefinitions, type GrantsSession } from "./session.ts";
41
+ import { bindReloadLifecycle } from "./reload-environment.ts";
42
+ import { reconcileActiveDelegationTools } from "./delegation-activation.ts";
41
43
  import { resolveExecutor } from "./executor-session.ts";
42
44
  import { reportSessionStart } from "./session-report.ts";
43
45
  import { SPAWN_TOOLS, tripwireReason } from "./tripwire.ts";
44
46
  import { reportGrantStoreRefusal } from "./grant-store-refusal.ts";
45
- import {
46
- defaultDashboardPaths,
47
- offerDashboardHandshake,
48
- openDashboardCommand,
49
- } from "../src/dashboard-handshake.ts";
50
-
47
+ import { defaultDashboardPaths, offerDashboardHandshake, openDashboardCommand } from "../src/dashboard-handshake.ts";
51
48
  import { associateOrdinaryHost, ordinaryChildrenFor } from "../src/ordinary-children.ts";
52
49
  import { loadDeclaredWork } from "../src/work-command.ts";
53
50
  import { createDailyDashboardSession } from "./daily-dashboard-session.ts";
@@ -62,7 +59,6 @@ export default function (pi: ExtensionAPI) {
62
59
  return undefined;
63
60
  }
64
61
  })();
65
-
66
62
  const session = createGrantsSession(extensionPath);
67
63
  associateOrdinaryHost(pi,session);
68
64
  const dailyHost=createDailyDashboardSession({ordinary:()=>ordinaryChildrenFor(pi),declared:()=>session.declaredWork,rebind:state=>{session.declaredWork=state;},cwd:()=>session.cwd,env:process.env,author:"local-operator"});
@@ -70,14 +66,20 @@ export default function (pi: ExtensionAPI) {
70
66
  const dashboardPaths = defaultDashboardPaths(
71
67
  process.env.PI_CODING_AGENT_DIR?.trim() || join(homedir(), ".pi", "agent"),
72
68
  );
73
-
74
- // Filled in by `registerDelegationTools` at the bottom of this function. A holder rather than a reordering,
75
- // because the hooks below have to be registered before the tools and both need to call it — the tool
76
- // schemas describe which definitions are spawnable, and nothing knows that until a hook has run (R-39).
69
+ // Definitions are registered only after owner-bound session_start. Until then there is no delegation
70
+ // dispatch surface; afterwards this callback refreshes their model-facing spawnable-definition text.
77
71
  const delegation = { refreshSpawnable: () => {} };
78
72
  const publishedDailyWork: PublishedDailyWork = {};
79
73
 
80
74
  pi.on("session_start", async (_event, ctx) => {
75
+ // Real SDK contexts always supply SessionManager. The fallback keeps lightweight wiring fixtures
76
+ // isolated; it cannot carry state into another extension instance.
77
+ const owner = (ctx as { sessionManager?: object }).sessionManager ?? session;
78
+ const reload = bindReloadLifecycle(owner, session.reloadLifecycle);
79
+ session.reconcileEnvironment(reload.environment, reload.lifecycle);
80
+ session.ownerBound = true;
81
+ delegation.refreshSpawnable = registerDelegationTools(pi, session).refreshSpawnable;
82
+ reconcileActiveDelegationTools(pi, session);
81
83
  session.cwd = ctx.cwd;
82
84
  // A routed child changes cwd. Publishing a relative ledger string unchanged therefore fragments one
83
85
  // execution tree across workspaces. Resolve it once at the root's actual pi cwd; descendants inherit
@@ -250,7 +252,9 @@ export default function (pi: ExtensionAPI) {
250
252
  * `exit` remains the backstop. SIGKILL still orphans panes, exactly as R-62 records, and no signal handler is
251
253
  * installed here for the reason R-62 gives: it would turn pi's "interrupt this turn" into "exit pi".
252
254
  */
253
- pi.on("session_shutdown", async () => { await dailyHost.close().catch(() => undefined); });
255
+ pi.on("session_shutdown", async () => {
256
+ await dailyHost.close().catch(() => undefined);
257
+ });
254
258
 
255
259
  pi.on("agent_settled", async () => {
256
260
  try {
@@ -341,15 +345,6 @@ export default function (pi: ExtensionAPI) {
341
345
  return { block: true, reason };
342
346
  });
343
347
 
344
- // Governed delegation. Unlike the tripwire above this PROVISIONS: the grant is an argument, so the
345
- // orchestrator hands each child exactly the capabilities it should have. Registered only when this
346
- // session may delegate, so withholding `tool:delegate` genuinely makes a session a leaf.
347
- //
348
- // Registration necessarily happens HERE, before any hook has run, so the tools cannot yet know which
349
- // definitions exist — hence `refreshSpawnable`, called from both hooks above once they do. R-39 is what
350
- // happens without it: every model in every governed session is told `Available: none`.
351
- delegation.refreshSpawnable = registerDelegationTools(pi, session).refreshSpawnable;
352
-
353
348
  pi.registerCommand("grants", {
354
349
  ...grantsCommand,
355
350
  // Built per invocation and spelled out field by field, rather than passing the session whole: what a
@@ -0,0 +1,63 @@
1
+ import { GRANT_ENV_KEYS } from "../src/propagation.ts";
2
+
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> }
5
+ type SessionOwner = object;
6
+
7
+ interface ReloadState {
8
+ owners: WeakMap<SessionOwner, ReloadLifecycle>;
9
+ latestChildPublication?: { lifecycle: ReloadLifecycle; environment: Record<string, string | undefined> };
10
+ }
11
+ const RELOAD_STATE = Symbol.for("pi-daddy.reload-environment.v1");
12
+ function state(): ReloadState {
13
+ const global = globalThis as typeof globalThis & { [key: symbol]: ReloadState | undefined };
14
+ return global[RELOAD_STATE] ?? (global[RELOAD_STATE] = { owners: new WeakMap() });
15
+ }
16
+
17
+ function snapshot(): Record<string, string | undefined> {
18
+ return Object.fromEntries(GRANT_ENV_KEYS.map(key => [key, process.env[key]]));
19
+ }
20
+ function same(left: Record<string, string | undefined>, right: Record<string, string | undefined>): boolean {
21
+ return GRANT_ENV_KEYS.every(key => left[key] === right[key]);
22
+ }
23
+ function withRoot(root: Record<string, string | undefined>): NodeJS.ProcessEnv {
24
+ return { ...process.env, ...root };
25
+ }
26
+
27
+ /** Factory-time state is provisional: ExtensionAPI is recreated before the stable session owner is known. */
28
+ export function beginExtensionLifecycle(): { environment: NodeJS.ProcessEnv; lifecycle: ReloadLifecycle } {
29
+ return { environment: process.env, lifecycle: { root: snapshot() } };
30
+ }
31
+
32
+ /**
33
+ * Bind an extension lifecycle to Pi's stable owner at session_start.
34
+ *
35
+ * A matching latest publication is child state from a known owner, not an explicit replacement for this
36
+ * owner. An unmatched environment change is an explicit root replacement for THIS owner only.
37
+ */
38
+ export function bindReloadLifecycle(owner: SessionOwner, provisional: ReloadLifecycle): {
39
+ lifecycle: ReloadLifecycle;
40
+ environment: NodeJS.ProcessEnv;
41
+ } {
42
+ const holder = state();
43
+ const existing = holder.owners.get(owner);
44
+ if (!existing) {
45
+ holder.owners.set(owner, provisional);
46
+ return { lifecycle: provisional, environment: withRoot(provisional.root) };
47
+ }
48
+
49
+ const current = snapshot();
50
+ if (!holder.latestChildPublication || !same(current, holder.latestChildPublication.environment)) {
51
+ // No pi-daddy lifecycle published what is currently in process.env, so this is an explicit change to
52
+ // this owner's root rather than another bound session's child state.
53
+ existing.root = current;
54
+ }
55
+ return { lifecycle: existing, environment: withRoot(existing.root) };
56
+ }
57
+
58
+ /** Publish only parent-level child state, and retain which owner made the process-global publication. */
59
+ export function rememberChildPublication(lifecycle: ReloadLifecycle): void {
60
+ const environment = snapshot();
61
+ lifecycle.published = environment;
62
+ state().latestChildPublication = { lifecycle, environment };
63
+ }
@@ -0,0 +1,45 @@
1
+ import { parseInherited } from "../src/approval.ts";
2
+ import { DELEGATE_CAPABILITY } from "../src/delegate.ts";
3
+ import { budgetFromEnv } from "../src/fanout.ts";
4
+ import { WILDCARD } from "../src/pi-tools.ts";
5
+ import {
6
+ depthConfig, deriveOwnGrant, gatedFromEnv, ENV_APPROVED, ENV_DEPTH, ENV_EXECUTION_ID, ENV_FANOUT,
7
+ ENV_GATED, ENV_GRANT, ENV_LEDGER, ENV_MAX_DEPTH, ENV_PARENT_ID, parseList,
8
+ } from "../src/propagation.ts";
9
+ import { storedGrantSessionState } from "./stored-grant-session.ts";
10
+ import { chooseExecutor, ENV_HERDR } from "../src/executor.ts";
11
+ import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/model-preflight.ts";
12
+ import type { ReloadLifecycle } from "./reload-environment.ts";
13
+ import type { GrantsSession } from "./session.ts";
14
+
15
+ /** Rebuild every authority-bearing factory input once session_start identifies its real SDK owner. */
16
+ export function reconcileSessionEnvironment(
17
+ session: GrantsSession, environment: NodeJS.ProcessEnv, lifecycle: ReloadLifecycle,
18
+ ): void {
19
+ const grantRaw = environment[ENV_GRANT];
20
+ const stored = storedGrantSessionState(grantRaw, session.storeCwd);
21
+ const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]);
22
+ const ledgerRaw = environment[ENV_LEDGER];
23
+ session.ledgerFromEnvironment = ledgerRaw !== undefined;
24
+ session.governed = stored.governed;
25
+ session.inherited = stored.inherited;
26
+ session.depth = bounds.depth;
27
+ session.maxDepth = bounds.maxDepth;
28
+ session.malformedBounds = bounds.malformed;
29
+ session.gated = session.governed ? gatedFromEnv(environment[ENV_GATED]) : parseList(environment[ENV_GATED]);
30
+ session.ledgerPath = ledgerRaw !== undefined ? ledgerRaw : stored.defaultLedger;
31
+ session.executor = chooseExecutor(environment[ENV_HERDR], null);
32
+ session.ownSpawnId = environment[ENV_PARENT_ID]?.trim() || `d${session.depth}`;
33
+ session.ownExecutionId = environment[ENV_EXECUTION_ID]?.trim() || undefined;
34
+ session.fanoutBudget = budgetFromEnv(environment[ENV_FANOUT]);
35
+ session.mayDelegate = !session.governed || session.inherited.includes(DELEGATE_CAPABILITY) || session.inherited.includes(WILDCARD);
36
+ session.allowUnresolvedModels = environment[ENV_ALLOW_UNRESOLVED_MODELS] === "1";
37
+ session.reloadLifecycle = lifecycle;
38
+ session.inheritedApprovals = parseInherited(environment[ENV_APPROVED]);
39
+ session.ownGrant = deriveOwnGrant(session.inherited, null);
40
+ session.observed = false;
41
+ session.observedTools = null;
42
+ session.modelResolutionCache.clear();
43
+ if (stored.refusal) session.grantStoreRefusal = stored.refusal;
44
+ else delete session.grantStoreRefusal;
45
+ }
@@ -37,6 +37,7 @@ import {
37
37
  ENV_LEDGER,
38
38
  ENV_MAX_DEPTH,
39
39
  ENV_PARENT_ID,
40
+ GRANT_ENV_KEYS,
40
41
  parseList,
41
42
  } from "../src/propagation.ts";
42
43
  import type { Capability } from "../src/resolve.ts";
@@ -49,6 +50,8 @@ import { storedGrantSessionState } from "./stored-grant-session.ts";
49
50
  import { nativeSessionRootFromEnv, type NativeSessionHost } from "../src/native-session-target.ts";
50
51
  import { ENV_ALLOW_UNRESOLVED_MODELS } from "../src/model-preflight.ts";
51
52
  import type { DeclaredWorkState } from "../src/work-command.ts";
53
+ import { beginExtensionLifecycle, rememberChildPublication, type ReloadLifecycle } from "./reload-environment.ts";
54
+ import { reconcileSessionEnvironment } from "./session-environment.ts";
52
55
  /**
53
56
  * Run governed children in herdr panes instead of captured child processes.
54
57
  *
@@ -93,14 +96,15 @@ export interface GrantsSession extends NativeSessionHost {
93
96
  */
94
97
  governed: boolean;
95
98
  /** The upper bound handed down by the delegator, before this session's own tools are observed. */
96
- readonly inherited: Capability[];
97
- readonly depth: number;
98
- readonly maxDepth: number;
99
+ inherited: Capability[];
100
+ depth: number;
101
+ maxDepth: number;
99
102
  /** Bound variables that could not be read as non-negative integers — spawning is disabled, loudly. */
100
- readonly malformedBounds: string[];
101
- readonly gated: Capability[];
103
+ malformedBounds: string[];
104
+ gated: Capability[];
102
105
  /** Resolved against the actual pi cwd at session start, then inherited verbatim by every descendant. */
103
106
  ledgerPath?: string;
107
+ ledgerFromEnvironment: boolean;
104
108
  /**
105
109
  * Which executor runs this session's children — ADR-0031.
106
110
  *
@@ -114,15 +118,17 @@ export interface GrantsSession extends NativeSessionHost {
114
118
  */
115
119
  executor: ExecutorChoice;
116
120
  /** This session's readable logical ledger identity; children descend from it (F8). */
117
- readonly ownSpawnId: string;
121
+ ownSpawnId: string;
118
122
  /** Unique identity when this session is itself a governed child; roots have no governed parent. */
119
- readonly ownExecutionId?: string;
123
+ ownExecutionId?: string;
120
124
  /** Descendants this subtree may still create — the cardinality bound ADR-0008 never had. */
121
- readonly fanoutBudget: number;
122
- /** Whether `delegate` / `delegate_all` are registered at all (S-5). Decided on the INHERITED grant. */
123
- readonly mayDelegate: boolean;
125
+ fanoutBudget: number;
126
+ /** Whether delegation tools are active. Reconciled against the owner-bound root at session_start. */
127
+ mayDelegate: boolean;
128
+ /** True only after session_start binds this instance to ctx.sessionManager. */
129
+ ownerBound: boolean;
124
130
  /** Operator escape hatch for custom model resolution. Exact `1`, read once for the session. */
125
- readonly allowUnresolvedModels: boolean;
131
+ allowUnresolvedModels: boolean;
126
132
  /** Results from pi's synchronous model catalogue, shared by every delegation in this session. */
127
133
  readonly modelResolutionCache: Map<string, boolean>;
128
134
  /** Path to this extension, so a child granted `tool:delegate` can delegate in turn. */
@@ -130,7 +136,8 @@ export interface GrantsSession extends NativeSessionHost {
130
136
  declaredWork?: DeclaredWorkState; // Explicit operator selection; absence leaves execution visibly unbound.
131
137
  /** Primary-return fan-outs retained by this original session; bounded and human-readable via /grants variants. */
132
138
  readonly variantRuns: Map<string, VariantRunAccounting>;
133
-
139
+ /** Root identity keyed to ctx.sessionManager once session_start supplies it. */
140
+ reloadLifecycle: ReloadLifecycle;
134
141
  /** Approval keys approved for this session. In memory only — this dies with the process. */
135
142
  readonly sessionApprovals: Set<string>;
136
143
  /** Exact bindings for correlated approvals; these never inherit across a delegation boundary. */
@@ -143,10 +150,9 @@ export interface GrantsSession extends NativeSessionHost {
143
150
  * needs `session.definitions`, which does not exist until `session_start` — and this object is built
144
151
  * before any hook has run.
145
152
  */
146
- readonly inheritedApprovals: Map<string, string | undefined>;
153
+ inheritedApprovals: Map<string, string | undefined>;
147
154
  /** ONE single-flight queue for the whole session — see `obtainApprovals` for why it lives here. */
148
155
  readonly approvalGateFor: ReturnType<typeof createApprovalGateProvider>;
149
-
150
156
  /** Set at `session_start`; `process.cwd()` until then. */
151
157
  cwd: string;
152
158
  /** This session's own grant. Starts as the inherited upper bound, tightened once tools are observed. */
@@ -194,7 +200,7 @@ export interface GrantsSession extends NativeSessionHost {
194
200
  */
195
201
  readonly storeCwd: string;
196
202
  /** Invalid stored state fails closed and is reported/ledgered during session_start. */
197
- readonly grantStoreRefusal?: { reason: GrantStoreRefusalReason; path: string };
203
+ grantStoreRefusal?: { reason: GrantStoreRefusalReason; path: string };
198
204
  /**
199
205
  * Adopt the project choice made DURING the session — grant plus optional default ledger — without restart.
200
206
  *
@@ -207,6 +213,7 @@ export interface GrantsSession extends NativeSessionHost {
207
213
  * widen its own session's ceiling by calling something.
208
214
  */
209
215
  adoptGrant(grant: Capability[], projectLedger?: string): void;
216
+ reconcileEnvironment(environment: NodeJS.ProcessEnv, lifecycle: ReloadLifecycle): void;
210
217
  }
211
218
 
212
219
  /**
@@ -238,7 +245,10 @@ export async function loadProjectDefinitions(session: GrantsSession, cwd: string
238
245
  session.catalog = await session.catalogReady;
239
246
  }
240
247
 
241
- export function createGrantsSession(extensionPath: string | undefined): GrantsSession {
248
+ export function createGrantsSession(extensionPath: string | undefined, lifecycle?: ReloadLifecycle): GrantsSession {
249
+ const started = lifecycle ? undefined : beginExtensionLifecycle();
250
+ const activeLifecycle = lifecycle ?? started!.lifecycle;
251
+ const environment = lifecycle ? process.env : started!.environment;
242
252
  // Governance is opt-in: with PI_GRANTS_GRANT unset AND no stored grant for this directory, the session
243
253
  // holds the wildcard and nothing is blocked. This extension must never silently tighten a normal
244
254
  // workflow.
@@ -252,22 +262,21 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
252
262
  // and therefore before `ctx` exists. That ordering is forced by S-5: whether `delegate` is registered at
253
263
  // all is decided here, and a grant arriving later could not inform it. `session_start` re-checks the two
254
264
  // against each other and says so if they differ, which is the only case this can get wrong.
255
- const grantRaw = process.env[ENV_GRANT];
265
+ const grantRaw = environment[ENV_GRANT];
256
266
  const storeCwd = process.cwd();
257
267
  // One root-only store read supplies both decisions made by `/grants init`. A child always has ENV_GRANT,
258
268
  // so it cannot activate a ledger merely because its routed cwd happens to have a v2 store (ADR-0037).
259
269
  const storedState = storedGrantSessionState(grantRaw, storeCwd);
260
270
  const { governed, inherited, refusal: grantStoreRefusal } = storedState;
261
- const ledgerRaw = process.env[ENV_LEDGER];
271
+ const ledgerRaw = environment[ENV_LEDGER];
262
272
  // Capture provenance before publishChildEnv writes this session's derived default into process.env. A later
263
273
  // `/grants init` for ctx.cwd must not mistake our own publication for an operator override.
264
274
  const ledgerFromEnvironment = ledgerRaw !== undefined;
265
275
  const storedLedger = storedState.defaultLedger;
266
276
  // G7 / A-S4 + B-I4: strict, three-way parsing that fails CLOSED. A malformed bound used to yield
267
277
  // `NaN`, and every comparison against `NaN` is false, so depth limiting switched itself off.
268
- const bounds = depthConfig(process.env[ENV_DEPTH], process.env[ENV_MAX_DEPTH]);
278
+ const bounds = depthConfig(environment[ENV_DEPTH], environment[ENV_MAX_DEPTH]);
269
279
  const { depth, maxDepth } = bounds;
270
-
271
280
  const emptyCatalog = makeCatalog([]);
272
281
 
273
282
  const session: GrantsSession = {
@@ -281,41 +290,41 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
281
290
  // did. Inside a session the operator already chose to govern, handing a child `bash` hands it an
282
291
  // ungoverned-descendant escape hatch, and doing that silently is what changes here.
283
292
  // `PI_GRANTS_GATED=""` turns the default off; absent and empty are deliberately distinguishable.
284
- gated: governed ? gatedFromEnv(process.env[ENV_GATED]) : parseList(process.env[ENV_GATED]),
293
+ gated: governed ? gatedFromEnv(environment[ENV_GATED]) : parseList(environment[ENV_GATED]),
285
294
  // Presence wins, including an explicitly empty value for a one-run opt-out. The store is eligible only
286
295
  // when ENV_GRANT was absent above, preserving the environment as the child's single authority channel.
287
296
  ledgerPath: ledgerRaw !== undefined ? ledgerRaw : storedLedger,
297
+ ledgerFromEnvironment,
288
298
  // The un-probed reading. `resolveExecutor` replaces it at session start; until then a `1` already reads as
289
299
  // a refusal, which is the safe direction — a delegation that somehow ran before the probe would refuse
290
300
  // rather than quietly use the wrong executor.
291
- executor: chooseExecutor(process.env[ENV_HERDR], null),
301
+ executor: chooseExecutor(environment[ENV_HERDR], null),
292
302
  // `ownSpawnId` comes from the parent (F8), so ids form one tree across process boundaries instead of
293
303
  // every level restarting at `d0` and the ledger becoming unjoinable.
294
- ownSpawnId: process.env[ENV_PARENT_ID]?.trim() || `d${depth}`,
295
- ownExecutionId: process.env[ENV_EXECUTION_ID]?.trim() || undefined,
304
+ ownSpawnId: environment[ENV_PARENT_ID]?.trim() || `d${depth}`,
305
+ ownExecutionId: environment[ENV_EXECUTION_ID]?.trim() || undefined,
296
306
  // The cardinality bound ADR-0008 never had: it attenuates downward like depth, so a subtree can never
297
307
  // create more descendants than its root was given — with no shared state, no lock and no counter file.
298
- fanoutBudget: budgetFromEnv(process.env[ENV_FANOUT]),
308
+ fanoutBudget: budgetFromEnv(environment[ENV_FANOUT]),
299
309
  /**
300
310
  * Review finding S-5, fixed. The comment on the tools has always claimed conditional registration; the
301
311
  * call was unconditional, `DELEGATE_CAPABILITY` was imported and never used, and "withhold it and the
302
312
  * child is a leaf" was simply untrue on this path.
303
313
  *
304
- * Decided on the INHERITED grant rather than `ownGrant`, because registration happens at load time,
305
- * before any tools are observed. An ungoverned session registers it as before.
314
+ * Provisional before owner binding; session_start recomputes it from that owner's root before activation.
306
315
  */
307
316
  mayDelegate: !governed || inherited.includes(DELEGATE_CAPABILITY) || inherited.includes(WILDCARD),
308
- allowUnresolvedModels: process.env[ENV_ALLOW_UNRESOLVED_MODELS] === "1",
317
+ ownerBound: false,
318
+ allowUnresolvedModels: environment[ENV_ALLOW_UNRESOLVED_MODELS] === "1",
309
319
  nativeSessionRoot: nativeSessionRootFromEnv(process.env),
310
320
  modelResolutionCache: new Map<string, boolean>(),
311
321
  extensionPath,
312
322
  variantRuns: new Map(),
313
-
323
+ reloadLifecycle: activeLifecycle,
314
324
  sessionApprovals: new Set<string>(),
315
325
  sessionApprovalBindings: new Map<string, ApprovalBinding>(),
316
- inheritedApprovals: parseInherited(process.env[ENV_APPROVED]),
326
+ inheritedApprovals: parseInherited(environment[ENV_APPROVED]),
317
327
  approvalGateFor: createApprovalGateProvider(),
318
-
319
328
  cwd: process.cwd(),
320
329
  ownGrant: deriveOwnGrant(inherited, null),
321
330
  observed: false,
@@ -323,7 +332,6 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
323
332
  definitions: new Map<string, SkillDefinition>(),
324
333
  catalog: emptyCatalog,
325
334
  catalogReady: Promise.resolve(emptyCatalog),
326
-
327
335
  delegationContext: async (approved?: InheritableApproval[]) => ({
328
336
  ownGrant: session.ownGrant,
329
337
  depth: session.depth,
@@ -360,12 +368,13 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
360
368
  session.ownGrant = grant;
361
369
  // An environment ledger remains the explicit answer. Otherwise init's v2 choice becomes live now,
362
370
  // before publishChildEnv gives the same absolute path to descendants.
363
- if (!ledgerFromEnvironment && projectLedger !== undefined) {
371
+ if (!session.ledgerFromEnvironment && projectLedger !== undefined) {
364
372
  session.ledgerPath = projectLedger;
365
373
  }
366
374
  session.publishChildEnv();
367
375
  },
368
376
 
377
+ reconcileEnvironment: (environment, lifecycle) => reconcileSessionEnvironment(session, environment, lifecycle),
369
378
  publishChildEnv: () => {
370
379
  const env = childEnv({
371
380
  ownGrant: session.ownGrant,
@@ -378,9 +387,11 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
378
387
  // descendants too. Previously it exported its own observed tool surface as their grant.
379
388
  governed: session.governed,
380
389
  });
390
+ // Clear omitted fields: another owner's provenance must never reach this session's children.
391
+ for (const key of GRANT_ENV_KEYS) delete process.env[key];
381
392
  for (const [key, value] of Object.entries(env)) process.env[key] = value;
393
+ rememberChildPublication(session.reloadLifecycle);
382
394
  },
383
395
  };
384
-
385
396
  return session;
386
397
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-daddy",
3
- "version": "0.25.2",
3
+ "version": "0.26.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",
@@ -248,6 +248,7 @@
248
248
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json",
249
249
  "typecheck": "tsc -p tsconfig.check.json",
250
250
  "test": "node scripts/unit-tests.ts",
251
+ "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",
251
252
  "test:integration": "node --test test-integration/*.it.ts",
252
253
  "test:integration:ci": "node --test test-integration/approval.it.ts test-integration/governance.it.ts",
253
254
  "contracts:generate": "node scripts/generate-ledger-v3-contract.ts",
package/src/chain.ts CHANGED
@@ -156,6 +156,8 @@ export interface ChainStep {
156
156
  agent?: string;
157
157
  tools?: string[];
158
158
  model?: string;
159
+ /** Requested Pi thinking level; validated by the same schema as delegate/delegate_all. */
160
+ thinking?: string;
159
161
  correlation?: import("./correlation.ts").CorrelationMetadata;
160
162
  workspace?: { workspace_id: string; access: import("./workspace.ts").WorkspaceAccess };
161
163
  }
@@ -58,6 +58,20 @@ export async function dashboardHostAction(host:DashboardConnection,line:string){
58
58
  return host.humanAction(command);
59
59
  }
60
60
 
61
+ /** A fulfilled host call is not necessarily an applied native effect; keep those facts distinct in the UI. */
62
+ export function dashboardActionFeedback(command:string,outcome:unknown):string{
63
+ const value=outcome&&typeof outcome==="object"?outcome as {state?:unknown;result?:unknown}:{};
64
+ const state=typeof value.state==="string"?value.state:"unrecognised host response";
65
+ if(state==="failed-or-unknown"||state==="readback-only")return `NO ACTION CLAIM: ${command} returned ${state}; inspect/reconcile explicitly.`;
66
+ if(state!=="acknowledged")return `NOT APPLIED: ${command} returned ${state}.`;
67
+ const result=value.result&&typeof value.result==="object"?value.result as {application?:unknown}:null;
68
+ // A retained records[] snapshot has no outer dashboard request correlation. Never select an old receipt.
69
+ const nativeApplication=typeof result?.application==="string"?result.application:undefined;
70
+ if(nativeApplication==="applied")return `ACKNOWLEDGED: ${command}; native application applied.`;
71
+ if(nativeApplication)return `ACKNOWLEDGED: ${command}; native application ${nativeApplication}, so no applied effect is claimed.`;
72
+ return `ACKNOWLEDGED: ${command}; refreshed state is shown below (no native application claim).`;
73
+ }
74
+
61
75
  function shellQuote(value: string): string {
62
76
  return `'${value.replaceAll("'", `'"'"'`)}'`;
63
77
  }
@@ -96,14 +110,15 @@ export async function dashboardFrame(options: DashboardFrameOptions): Promise<st
96
110
  if(options.connected){
97
111
  if(!isDashboardConnection(options.connected))throw Error("original dashboard connection required");
98
112
  let view:Awaited<ReturnType<DashboardConnection["frame"]>>;
99
- try{view=await options.connected.frame();}catch{return "PI-DADDY — CONNECTED HOST UNAVAILABLE\nNo prior acceptance, presentation or continuity substituted. No action retried.";}
113
+ try{view=await options.connected.frame();}catch(error){return `PI-DADDY — CONNECTED HOST ERROR\n${error instanceof Error ? error.message : String(error)}\nNo prior acceptance, presentation or continuity substituted. No action retried.`;}
100
114
  if(options.dailyJson||options.debriefJson)return JSON.stringify(view);
101
115
  const source=view.source as {daily?:Parameters<typeof renderDailyView>[0]}|null;
102
116
  const actions=(view.actions as {key:string;label:string}[]|undefined)??[];
103
- return ["PI-DADDY — CONNECTED HOST (snapshot; identity/acceptance not authenticated)",source?.daily?renderDailyView(source.daily,options.width):"Source unavailable; no previous acceptance substituted.",
117
+ const hostError=typeof view.error==="string"&&view.error?`HOST ERROR — ${view.error}`:null;
118
+ return ["PI-DADDY — CONNECTED HOST (snapshot; identity/acceptance not authenticated)",hostError,source?.daily?renderDailyView(source.daily,options.width):"Source unavailable; no previous acceptance substituted.",
104
119
  view.debrief?renderDebrief(view.debrief as Parameters<typeof renderDebrief>[0],options.width):"Debrief deferred/closed. No implicit presentation or steering.",
105
- `Attention reserved ${view.attention.attentionUsed}/5 (not proof of delivery). Tip ${view.tip}.`,
106
- actions.length?`ACTIONS — type one command and Enter\n${actions.map(action=>` ${action.key} — ${action.label}`).join("\n")}`:"ACTIONS — none currently authorized; refresh never acts."].join("\n\n");
120
+ `Host control ${view.control}; acknowledgement ${view.acknowledgement}. Attention reserved ${view.attention.attentionUsed}/5 (not proof of delivery). Tip ${view.tip}.`,
121
+ actions.length?`ACTIONS — type one command and Enter\n${actions.map(action=>` ${action.key} — ${action.label}`).join("\n")}`:"ACTIONS — none currently authorized; refresh never acts."].filter((line):line is string=>Boolean(line)).join("\n\n");
107
122
  }
108
123
  if (options.debrief || options.debriefJson) {
109
124
  if (!isDebriefPresenter(options.debrief)) return "PI-DADDY — DEBRIEF UNAVAILABLE: genuine presenter/host missing";
@@ -213,6 +228,8 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
213
228
  }
214
229
  const dailyReader = createDailyViewReader();
215
230
  let previous = "";
231
+ let feedback = "";
232
+ let input: ReturnType<typeof createInterface> | null = null;
216
233
  const draw = async (clear: boolean): Promise<void> => {
217
234
  const frame = await dashboardFrame({
218
235
  cwd,
@@ -223,15 +240,20 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
223
240
  details: cli.details,
224
241
  dailyView, dailyReader, dailyJson: cli.dailyJson, debrief, debriefJson: cli.debriefJson, connected,
225
242
  });
226
- if (frame === previous && !clear) return;
227
- previous = frame;
228
- process.stdout.write(clear ? `\u001b]0;PI-DADDY\u0007\u001b[2J\u001b[H${frame}` : `${frame}\n`);
243
+ const rendered = feedback ? `${frame}\n\n${feedback}` : frame;
244
+ if (rendered === previous && !clear) return;
245
+ previous = rendered;
246
+ const prompt = "COMMAND — type an exact listed key, then Enter (refresh never acts): ";
247
+ const draft = input?.line ?? "";
248
+ process.stdout.write(clear ? `\u001b]0;PI-DADDY\u0007\u001b[2J\u001b[H${rendered}\n\n${input ? prompt + draft : ""}` : `${rendered}\n`);
249
+ if (input) input.setPrompt(prompt);
229
250
  };
230
251
 
231
252
  if (cli.once) {
232
253
  await draw(false);
233
254
  return;
234
255
  }
256
+ input = debrief || connected ? createInterface({ input: process.stdin, output: process.stdout, terminal: true }) : null;
235
257
  await draw(true);
236
258
  let drawing = false;
237
259
  const redraw = (): void => {
@@ -239,13 +261,16 @@ export async function runDashboard(argv = process.argv.slice(2), env: NodeJS.Pro
239
261
  drawing = true;
240
262
  void draw(true).finally(() => { drawing = false; });
241
263
  };
242
- const input = debrief || connected ? createInterface({ input: process.stdin, terminal: false }) : null;
243
264
  let acting = false;
244
265
  input?.on("line", line => {
245
- if (acting) return;
266
+ if (acting) return; // never replay a line typed while the prior exact command is still settling.
246
267
  acting = true;
247
- void (connected?dashboardHostAction(connected,line):debriefAction(debrief!, line)).catch(() => { process.stderr.write("Debrief action refused or acknowledgement unknown; inspect/reconcile explicitly.\n"); })
248
- .finally(() => { acting = false; redraw(); });
268
+ feedback = `COMMAND ${line.trim() || "(empty)"} — applying the displayed exact request…`;
269
+ void (connected?dashboardHostAction(connected,line):debriefAction(debrief!, line)).then(outcome => {
270
+ feedback = connected ? dashboardActionFeedback(line.trim() || "command", outcome) : `ACKNOWLEDGED: ${line.trim() || "command"}. Refreshed state is shown below.`;
271
+ }).catch(error => {
272
+ feedback = `REJECTED: ${error instanceof Error ? error.message : String(error)}. No command was retried; inspect the refreshed offered keys.`;
273
+ }).finally(() => { acting = false; redraw(); });
249
274
  });
250
275
  const timer = setInterval(redraw, DASHBOARD_REFRESH_MS);
251
276
  process.on("SIGWINCH", redraw);
package/src/init.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * for, rather than a grant nobody decided.
23
23
  */
24
24
 
25
- import { mkdir, open, rm, writeFile } from "node:fs/promises";
25
+ import { lstat, mkdir, open, rm, writeFile } from "node:fs/promises";
26
26
  import { join } from "node:path";
27
27
  import { agentCapability, workspaceCapability } from "./capabilities.ts";
28
28
  import { ceilingForDefinition } from "./definitions.ts";
@@ -372,6 +372,23 @@ async function replace(path: string, content: string, outcome: InitOutcome): Pro
372
372
  */
373
373
  export async function applyInit(plan: InitPlan, options: { force?: boolean } = {}): Promise<InitOutcome> {
374
374
  const outcome: InitOutcome = { written: [], kept: [], failed: [] };
375
+ const projectPi = join(plan.grantEnvPath, "..");
376
+ try {
377
+ // A new project gets a private control directory. Existing operator/Pi state is never chmodded or reused.
378
+ await mkdir(projectPi, { mode: 0o700 });
379
+ const created = await lstat(projectPi);
380
+ if (!created.isDirectory() || created.isSymbolicLink()) throw Error("private .pi directory creation failed");
381
+ } catch (error) {
382
+ if ((error as { code?: string }).code !== "EEXIST") {
383
+ outcome.failed.push({ path: projectPi, error: error instanceof Error ? error.message : String(error) });
384
+ return outcome;
385
+ }
386
+ const existing = await lstat(projectPi);
387
+ if (existing.isSymbolicLink() || !existing.isDirectory()) {
388
+ outcome.failed.push({ path: projectPi, error: "existing .pi state is not a directory; it was not followed or changed" });
389
+ return outcome;
390
+ }
391
+ }
375
392
  const force = options.force === true;
376
393
  for (const skill of plan.skills) {
377
394
  if (force) await replace(skill.targetPath, skill.content, outcome);