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.
- package/CHANGELOG.md +7 -0
- package/dist/chain.d.ts +2 -0
- package/dist/chain.d.ts.map +1 -1
- package/dist/chain.js.map +1 -1
- package/dist/dashboard-cli.d.ts +2 -0
- package/dist/dashboard-cli.d.ts.map +1 -1
- package/dist/dashboard-cli.js +41 -12
- package/dist/dashboard-cli.js.map +1 -1
- package/dist/init.d.ts.map +1 -1
- package/dist/init.js +20 -1
- package/dist/init.js.map +1 -1
- package/dist/propagation.d.ts.map +1 -1
- package/dist/propagation.js +2 -1
- package/dist/propagation.js.map +1 -1
- package/dist/work-command.d.ts.map +1 -1
- package/dist/work-command.js +13 -3
- package/dist/work-command.js.map +1 -1
- package/extensions/chain-plan.ts +1 -0
- package/extensions/daily-dashboard-session.ts +12 -4
- package/extensions/delegate-chain.ts +6 -3
- package/extensions/delegation-activation.ts +17 -0
- package/extensions/delegation-authority.ts +12 -0
- package/extensions/delegation.ts +9 -8
- package/extensions/grants.ts +16 -21
- package/extensions/reload-environment.ts +63 -0
- package/extensions/session-environment.ts +45 -0
- package/extensions/session.ts +45 -34
- package/package.json +2 -1
- package/src/chain.ts +2 -0
- package/src/dashboard-cli.ts +36 -11
- package/src/init.ts +18 -1
- package/src/propagation.ts +2 -1
- package/src/work-command.ts +10 -3
|
@@ -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
|
+
}
|
package/extensions/delegation.ts
CHANGED
|
@@ -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
|
|
125
|
+
* Register delegation definitions at owner-bound session_start.
|
|
125
126
|
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
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`
|
|
146
|
-
*
|
|
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"));
|
package/extensions/grants.ts
CHANGED
|
@@ -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
|
-
//
|
|
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 () => {
|
|
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
|
+
}
|
package/extensions/session.ts
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
121
|
+
ownSpawnId: string;
|
|
118
122
|
/** Unique identity when this session is itself a governed child; roots have no governed parent. */
|
|
119
|
-
|
|
123
|
+
ownExecutionId?: string;
|
|
120
124
|
/** Descendants this subtree may still create — the cardinality bound ADR-0008 never had. */
|
|
121
|
-
|
|
122
|
-
/** Whether
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
295
|
-
ownExecutionId:
|
|
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(
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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
|
}
|
package/src/dashboard-cli.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
227
|
-
previous
|
|
228
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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);
|