pi-daddy 0.14.0 → 0.16.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 +123 -0
- package/README.md +37 -14
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -4
- package/dist/cli.js.map +1 -1
- package/dist/executor.d.ts +38 -0
- package/dist/executor.d.ts.map +1 -0
- package/dist/executor.js +93 -0
- package/dist/executor.js.map +1 -0
- package/dist/grant-store.d.ts +68 -0
- package/dist/grant-store.d.ts.map +1 -0
- package/dist/grant-store.js +142 -0
- package/dist/grant-store.js.map +1 -0
- package/dist/herdr-cli.d.ts +78 -0
- package/dist/herdr-cli.d.ts.map +1 -0
- package/dist/herdr-cli.js +113 -0
- package/dist/herdr-cli.js.map +1 -0
- package/dist/herdr-name.d.ts +37 -0
- package/dist/herdr-name.d.ts.map +1 -0
- package/dist/herdr-name.js +59 -0
- package/dist/herdr-name.js.map +1 -0
- package/dist/herdr-poll.d.ts +104 -0
- package/dist/herdr-poll.d.ts.map +1 -0
- package/dist/herdr-poll.js +150 -0
- package/dist/herdr-poll.js.map +1 -0
- package/dist/herdr-stage.d.ts +40 -0
- package/dist/herdr-stage.d.ts.map +1 -0
- package/dist/herdr-stage.js +54 -0
- package/dist/herdr-stage.js.map +1 -0
- package/dist/ledger-report.d.ts +18 -0
- package/dist/ledger-report.d.ts.map +1 -1
- package/dist/ledger-report.js +10 -0
- package/dist/ledger-report.js.map +1 -1
- package/dist/ledger.d.ts +17 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +1 -0
- package/dist/ledger.js.map +1 -1
- package/dist/pane-reaper.d.ts +66 -4
- package/dist/pane-reaper.d.ts.map +1 -1
- package/dist/pane-reaper.js +131 -9
- package/dist/pane-reaper.js.map +1 -1
- package/dist/progress.d.ts +96 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +167 -0
- package/dist/progress.js.map +1 -0
- package/dist/run-child.d.ts +27 -0
- package/dist/run-child.d.ts.map +1 -1
- package/dist/run-child.js +84 -7
- package/dist/run-child.js.map +1 -1
- package/dist/run-herdr.d.ts +41 -28
- package/dist/run-herdr.d.ts.map +1 -1
- package/dist/run-herdr.js +150 -167
- package/dist/run-herdr.js.map +1 -1
- package/dist/skill-packages.d.ts +16 -0
- package/dist/skill-packages.d.ts.map +1 -1
- package/dist/skill-packages.js +39 -10
- package/dist/skill-packages.js.map +1 -1
- package/extensions/delegation.ts +94 -2
- package/extensions/grants-command.ts +57 -1
- package/extensions/grants.ts +109 -165
- package/extensions/init-command.ts +132 -0
- package/extensions/run-delegation.ts +70 -8
- package/extensions/session-report.ts +231 -0
- package/extensions/session.ts +138 -17
- package/extensions/tripwire.ts +44 -0
- package/package.json +17 -1
- package/src/cli.ts +10 -4
- package/src/executor.ts +122 -0
- package/src/grant-store.ts +151 -0
- package/src/herdr-cli.ts +125 -0
- package/src/herdr-name.ts +61 -0
- package/src/herdr-poll.ts +185 -0
- package/src/herdr-stage.ts +55 -0
- package/src/ledger-report.ts +21 -0
- package/src/ledger.ts +18 -0
- package/src/pane-reaper.ts +147 -9
- package/src/progress.ts +206 -0
- package/src/run-child.ts +96 -7
- package/src/run-herdr.ts +170 -174
- package/src/skill-packages.ts +41 -10
package/extensions/grants.ts
CHANGED
|
@@ -21,24 +21,22 @@
|
|
|
21
21
|
* child derives its own grant from the tool array of its first provider request.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { existsSync } from "node:fs";
|
|
25
24
|
import { fileURLToPath } from "node:url";
|
|
26
25
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
27
26
|
import { WILDCARD } from "../src/pi-tools.ts";
|
|
28
|
-
import { AGENT_WILDCARD } from "../src/resolve.ts";
|
|
29
|
-
import { legacyApprovalsPath, sharedApprovalsPath } from "../src/approval-store.ts";
|
|
30
27
|
import { buildCatalog } from "../src/catalog.ts";
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
28
|
+
import { appendRecord, buildRecord } from "../src/ledger.ts";
|
|
29
|
+
import { openPaneCount, reapOpenPanesAsync } from "../src/pane-reaper.ts";
|
|
30
|
+
import {
|
|
31
|
+
ENV_GRANT, deriveOwnGrant, observeToolNames } from "../src/propagation.ts";
|
|
34
32
|
import { snapshotOf } from "./approvals.ts";
|
|
35
33
|
import { registerDelegationTools } from "./delegation.ts";
|
|
36
34
|
import { grantsCommand } from "./grants-command.ts";
|
|
35
|
+
import { runInit } from "./init-command.ts";
|
|
37
36
|
import { planWithApprovals } from "./run-delegation.ts";
|
|
38
|
-
import { createGrantsSession } from "./session.ts";
|
|
39
|
-
import {
|
|
40
|
-
|
|
41
|
-
const SPAWN_TOOLS = new Set(["Agent", "subagent", "spawn_agent"]);
|
|
37
|
+
import { createGrantsSession, loadProjectDefinitions, resolveExecutor, type GrantsSession } from "./session.ts";
|
|
38
|
+
import { reportSessionStart } from "./session-report.ts";
|
|
39
|
+
import { SPAWN_TOOLS, tripwireReason } from "./tripwire.ts";
|
|
42
40
|
|
|
43
41
|
export default function (pi: ExtensionAPI) {
|
|
44
42
|
// The path pi loads as the extension, so a child granted `tool:delegate` can be started with `-e <this>`.
|
|
@@ -60,6 +58,24 @@ export default function (pi: ExtensionAPI) {
|
|
|
60
58
|
|
|
61
59
|
pi.on("session_start", async (_event, ctx) => {
|
|
62
60
|
session.cwd = ctx.cwd;
|
|
61
|
+
// The one case the stored-grant lookup can get wrong (ADR-0030). The factory reads the store keyed by
|
|
62
|
+
// `process.cwd()` because it runs before any hook and therefore before `ctx` exists — and S-5 forces
|
|
63
|
+
// that ordering, since whether `delegate` is registered is decided there. Almost always the two agree.
|
|
64
|
+
// When they do not, the session is governed by a different directory's decision than the one it is
|
|
65
|
+
// working in, which is exactly the confusion a grant must never cause, so it is said out loud rather
|
|
66
|
+
// than left to be inferred from a surprising refusal. Sync, so it is not the R-60 shape.
|
|
67
|
+
// Inside no try of its own, and that is deliberate: it is synchronous, so it cannot be the R-60 shape. A
|
|
68
|
+
// reviewer noted it sits before the try that contains `resolveExecutor` — which is the right order, because
|
|
69
|
+
// this warning is about WHICH directory's grant was read and must reach the operator even if everything
|
|
70
|
+
// after it fails.
|
|
71
|
+
if (session.storeCwd !== ctx.cwd && process.env[ENV_GRANT] === undefined) {
|
|
72
|
+
ctx.ui.notify(
|
|
73
|
+
`grants: this session's stored grant was read for ${session.storeCwd}, but pi is working in ` +
|
|
74
|
+
`${ctx.cwd}. A grant belongs to a directory, so run /grants init here, or set PI_GRANTS_GRANT ` +
|
|
75
|
+
`explicitly — the environment always wins.`,
|
|
76
|
+
"warning",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
63
79
|
try {
|
|
64
80
|
// Guarded together, and guarded at all because of R-60 rather than because either one throws today:
|
|
65
81
|
// both loaders swallow their own filesystem errors, so this catch is currently unreachable. The point
|
|
@@ -69,9 +85,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
69
85
|
// delegate by `tools:`, and an operator whose `agent:` spawns have all started failing deserves to
|
|
70
86
|
// know it was the *scan* that broke rather than the grant.
|
|
71
87
|
try {
|
|
72
|
-
|
|
73
|
-
session.catalogReady = buildCatalog({ cwd: ctx.cwd, observedTools: session.observedTools });
|
|
74
|
-
session.catalog = await session.catalogReady;
|
|
88
|
+
await loadProjectDefinitions(session, ctx.cwd);
|
|
75
89
|
} catch (error) {
|
|
76
90
|
ctx.ui.notify(
|
|
77
91
|
`grants: could not read this project's definitions or capability catalog ` +
|
|
@@ -81,163 +95,62 @@ export default function (pi: ExtensionAPI) {
|
|
|
81
95
|
"error",
|
|
82
96
|
);
|
|
83
97
|
}
|
|
98
|
+
// ADR-0031: probe once, HERE, before anything reports — so the disclosure line can name the executor,
|
|
99
|
+
// and so a demanded-but-unreachable herdr is reported before the operator's first prompt rather than at
|
|
100
|
+
// their first delegation. Its own try, because a failure here must not cancel the controls after it
|
|
101
|
+
// (R-60), and `probeHerdr` is documented as never throwing precisely so this is belt-and-braces.
|
|
102
|
+
try {
|
|
103
|
+
await resolveExecutor(session);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// Says what is actually true of the state left behind, which depends on the variable: with
|
|
106
|
+
// `PI_GRANTS_HERDR=1` the session holds a REFUSAL and every delegation fails, so telling the operator
|
|
107
|
+
// "using the captured subprocess" would be the opposite of what happens. The old wording asserted the
|
|
108
|
+
// fallback unconditionally.
|
|
109
|
+
ctx.ui.notify(
|
|
110
|
+
`grants: could not settle which executor to use ` +
|
|
111
|
+
`(${error instanceof Error ? error.message : String(error)}) — ` +
|
|
112
|
+
(session.executor.refusal
|
|
113
|
+
? `PI_GRANTS_HERDR=1 still demands herdr, so every delegation in this session will refuse. ` +
|
|
114
|
+
`Unset it to let this session probe, or set 0 to choose subprocesses.`
|
|
115
|
+
: `using the captured subprocess, which needs nothing installed. Set PI_GRANTS_HERDR=0 to make ` +
|
|
116
|
+
`that explicit, or 1 to demand herdr panes.`),
|
|
117
|
+
"warning",
|
|
118
|
+
);
|
|
119
|
+
}
|
|
84
120
|
session.publishChildEnv();
|
|
85
121
|
// The definitions now exist, so the `delegate` schema can finally name them (R-39). pi serialises a
|
|
86
122
|
// tool's schema at REQUEST time, not at registration — measured — which is what makes this reach the
|
|
87
123
|
// model at all.
|
|
88
124
|
delegation.refreshSpawnable();
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
ctx.ui.notify(
|
|
94
|
-
`grants: ${session.malformedBounds.join(" and ")} could not be read as a non-negative integer — ` +
|
|
95
|
-
`spawning is disabled for this session (failing closed)`,
|
|
96
|
-
"warning",
|
|
97
|
-
);
|
|
98
|
-
}
|
|
99
|
-
// ADR-0014: a pre-0.6 in-workspace approvals file is IGNORED, not migrated — importing it would
|
|
100
|
-
// import exactly the entries whose trustworthiness the move exists to remove. Say so, because an
|
|
101
|
-
// operator whose approvals silently stopped applying deserves to know why.
|
|
102
|
-
// ADR-0020: the pre-0.11 single shared store is ignored, not migrated. Same reasoning shape as the
|
|
103
|
-
// legacy file below — an operator whose approvals silently stopped applying must be told why — but a
|
|
104
|
-
// different reason for not migrating: splitting it by `cwd` would be lossless, and it is still declined
|
|
105
|
-
// because one-shot migration code in the layer with nine defects buys less than one re-approval costs.
|
|
106
|
-
try {
|
|
107
|
-
if (existsSync(sharedApprovalsPath())) {
|
|
108
|
-
ctx.ui.notify(
|
|
109
|
-
`grants: ignoring ${sharedApprovalsPath()} — approvals are now stored one file per governed ` +
|
|
110
|
-
`directory (ADR-0020), because a single shared file could not hold two projects' approvals ` +
|
|
111
|
-
`for a same-named definition. Re-approve when next asked. **Deleting the old file is ` +
|
|
112
|
-
`recommended, not merely safe**: entries written by 0.10.x may contain the task text a model ` +
|
|
113
|
-
`composed at approval time, which this version no longer stores anywhere (ADR-0021).`,
|
|
114
|
-
"warning",
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
/* never throw into the agent loop */
|
|
119
|
-
}
|
|
120
|
-
try {
|
|
121
|
-
if (existsSync(legacyApprovalsPath(ctx.cwd))) {
|
|
122
|
-
ctx.ui.notify(
|
|
123
|
-
`grants: ignoring ${legacyApprovalsPath(ctx.cwd)} — approvals now live outside the workspace ` +
|
|
124
|
-
`(it was writable by the very agents it gated). Re-approve when next asked; the old file is ` +
|
|
125
|
-
`safe to delete.`,
|
|
126
|
-
"warning",
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
} catch {
|
|
130
|
-
/* never throw into the agent loop */
|
|
131
|
-
}
|
|
132
|
-
// R-47. `gatedBlocked` filters `requested`, and for a definition spawn `requested` is that
|
|
133
|
-
// definition's CEILING — which never contains `agent:<name>`, because the authorisation check
|
|
134
|
-
// (ADR-0017) is a separate, ungated branch. So `PI_GRANTS_GATED=agent:deploy`, written by an operator
|
|
135
|
-
// who read "it attenuates like any other capability" and meant "ask me before deploy runs", produces
|
|
136
|
-
// no dialog and no warning. It DOES bite when a definition passes the id down in its own
|
|
137
|
-
// `allowed-tools`, so the flag half-works — which is worse than not working, and is R-25's shape in
|
|
138
|
-
// the namespace ADR-0017 just promoted out of exactly that state.
|
|
125
|
+
// Everything an operator is TOLD at session start now lives in `./session-report.ts`. Lifted because
|
|
126
|
+
// this file had reached 398 of the 400-line ceiling and ADR-0032 adds a control to it; the split is the
|
|
127
|
+
// same move `session.ts` and `grants-command.ts` were extracted under, and the guard is obeyed rather
|
|
128
|
+
// than raised.
|
|
139
129
|
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
`grants: PI_GRANTS_GRANT pairs agent:* with tool:bash and gates nothing — every SKILL.md in ` +
|
|
150
|
-
`this project AND in ~/.pi/agent/skills (which other tools install into) may run with a shell. ` +
|
|
151
|
-
`Enumerate the agent: ids you mean, or leave PI_GRANTS_GATED at its default so bash is asked for.`,
|
|
152
|
-
"warning",
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
const inertGates = session.gated.filter((c) => c.startsWith("agent:"));
|
|
156
|
-
if (inertGates.length > 0) {
|
|
130
|
+
// Its own try/catch, required by `test/session-start-guard.test.ts` and right on the merits: every
|
|
131
|
+
// control inside `reportSessionStart` already has one, but a throw from the reporter ITSELF would
|
|
132
|
+
// otherwise reach the blanket catch below and be reported as "session start did not complete" — which
|
|
133
|
+
// would be true and useless, because nothing about the grant or its enforcement depends on any of it.
|
|
134
|
+
// Naming that distinction is the difference between an operator checking their configuration and an
|
|
135
|
+
// operator distrusting their governance.
|
|
136
|
+
try {
|
|
137
|
+
await reportSessionStart(session, ctx);
|
|
138
|
+
} catch (error) {
|
|
157
139
|
ctx.ui.notify(
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
140
|
+
// Names the CHECKS as well as the display lines. The first version listed only "the grant, the
|
|
141
|
+
// executor and the spawnable definitions" — which understated it: a throw partway through the reporter
|
|
142
|
+
// also skips the legacy/shared approval-store notices and the **ledger-corruption check**, which is an
|
|
143
|
+
// error rather than an FYI. Telling an operator that three lines are missing, when a control also did
|
|
144
|
+
// not run, is the reassuring half of the truth.
|
|
145
|
+
`grants: the session-start report could not be produced ` +
|
|
146
|
+
`(${error instanceof Error ? error.message : String(error)}) — the grant, the executor and the ` +
|
|
147
|
+
`spawnable definitions were not printed, AND the checks that run alongside them did not complete: ` +
|
|
148
|
+
`ledger integrity was not verified and any ignored-approvals notices were not shown. Run /grants and ` +
|
|
149
|
+
`/grants ledger for all of it. Governance itself is unaffected: it is enforced by --tools when a ` +
|
|
150
|
+
`child is spawned.`,
|
|
162
151
|
"warning",
|
|
163
152
|
);
|
|
164
153
|
}
|
|
165
|
-
// R-34. `verifyLedger` existed and nothing ran it, so a torn line was detectable and undetected —
|
|
166
|
-
// and a check an operator has to know to run is not a control, it is a feature. Setting
|
|
167
|
-
// `PI_GRANTS_LEDGER` already means "I want an audit trail"; noticing that the trail is damaged is
|
|
168
|
-
// part of keeping one.
|
|
169
|
-
//
|
|
170
|
-
// Corruption only, deliberately. The escalation count is a *query* — `/grants ledger` answers it —
|
|
171
|
-
// and reporting historical attempts unprompted at every start is the fatigue shape R-25 names, which
|
|
172
|
-
// ends with the operator ignoring the line that matters.
|
|
173
|
-
//
|
|
174
|
-
// Awaited rather than fired and forgotten: it is one read, on a path that already awaits two
|
|
175
|
-
// directory scans, and awaiting is what guarantees the warning reaches a live `ctx.ui`.
|
|
176
|
-
//
|
|
177
|
-
// R-60. `verifyLedger` RETHROWS every read error that is not ENOENT — right for `/grants ledger`,
|
|
178
|
-
// where an operator asked a direct question and deserves the failure — and this call is the only one
|
|
179
|
-
// that makes it inside the blanket catch below. So an unreadable ledger threw here and cancelled every
|
|
180
|
-
// remaining control **in silence**: no alarm, and not even the `holding [...]` line that is the one
|
|
181
|
-
// sign governance is on. Confirmed by execution — `PI_GRANTS_LEDGER` naming a directory produced ZERO
|
|
182
|
-
// notifications from a governed session. A trail that cannot be read at all is a worse failure than a
|
|
183
|
-
// torn line, and it was the one case this control said nothing about.
|
|
184
|
-
if (session.ledgerPath) {
|
|
185
|
-
try {
|
|
186
|
-
const report = await verifyLedger(session.ledgerPath);
|
|
187
|
-
if (report.exists && !report.ok) {
|
|
188
|
-
ctx.ui.notify(
|
|
189
|
-
`grants: ledger ${session.ledgerPath} has ${report.corrupt.length} unparseable line(s) — ` +
|
|
190
|
-
`first at line ${report.corrupt[0]?.line}. A torn line is indistinguishable from a spawn that ` +
|
|
191
|
-
`never happened, so this audit trail is incomplete. Run /grants ledger for detail; the file is ` +
|
|
192
|
-
`left alone because a corrupt line is evidence.`,
|
|
193
|
-
"error",
|
|
194
|
-
);
|
|
195
|
-
}
|
|
196
|
-
} catch (error) {
|
|
197
|
-
ctx.ui.notify(
|
|
198
|
-
`grants: ledger ${session.ledgerPath} could not be read ` +
|
|
199
|
-
`(${(error as { code?: string }).code ?? String(error)}) — nothing can be verified about this ` +
|
|
200
|
-
`audit trail, and the first spawn will refuse rather than proceed unrecorded. Check that ` +
|
|
201
|
-
`PI_GRANTS_LEDGER names a writable FILE.`,
|
|
202
|
-
"error",
|
|
203
|
-
);
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
if (session.governed) {
|
|
207
|
-
ctx.ui.notify(
|
|
208
|
-
`grants: depth ${session.depth}/${session.maxDepth}, holding [${session.ownGrant.join(", ") || "nothing"}]`,
|
|
209
|
-
"info",
|
|
210
|
-
);
|
|
211
|
-
// B1 / P4. The grant alone never named the definitions, never said where they came from, and never
|
|
212
|
-
// said which ones were being WITHHELD — so an operator who had just installed a package of
|
|
213
|
-
// `SKILL.md` files could not tell governance-is-working from did-the-install-fail. Classified by the
|
|
214
|
-
// real planner (see `./spawn-summary.ts`), never by a second reading of the rules.
|
|
215
|
-
//
|
|
216
|
-
// Its own try/catch, and not because `summariseSpawnable` throws today: this is the R-60 shape
|
|
217
|
-
// exactly — one added `await` inside the blanket catch cancelling every control below it in
|
|
218
|
-
// silence. There is nothing below it now; there will be.
|
|
219
|
-
try {
|
|
220
|
-
const line = renderSpawnableSummary(
|
|
221
|
-
await summariseSpawnable(
|
|
222
|
-
session.definitions,
|
|
223
|
-
(name) => planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
|
|
224
|
-
// The session facts that make every per-definition verdict identical. `mayDelegate` in
|
|
225
|
-
// particular: without `tool:delegate` there is no delegate tool at all, and the line used to
|
|
226
|
-
// report definitions as spawnable in the one session where nothing can ever be spawned.
|
|
227
|
-
{ mayDelegate: session.mayDelegate, depth: session.depth, maxDepth: session.maxDepth },
|
|
228
|
-
),
|
|
229
|
-
session.definitions.size,
|
|
230
|
-
);
|
|
231
|
-
if (line) ctx.ui.notify(line, "info");
|
|
232
|
-
} catch (error) {
|
|
233
|
-
ctx.ui.notify(
|
|
234
|
-
`grants: could not work out which definitions are spawnable ` +
|
|
235
|
-
`(${error instanceof Error ? error.message : String(error)}) — run /grants for the per-definition ` +
|
|
236
|
-
`verdict. Nothing about the grant or its enforcement depends on this line.`,
|
|
237
|
-
"warning",
|
|
238
|
-
);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
154
|
} catch (error) {
|
|
242
155
|
// Rule 8 — fail closed, and be LOUD about it. Swallowing is still right: a startup fault must not
|
|
243
156
|
// reach the agent loop. Swallowing SILENTLY is what let R-60 exist, and would let the next one exist
|
|
@@ -257,6 +170,33 @@ export default function (pi: ExtensionAPI) {
|
|
|
257
170
|
return undefined;
|
|
258
171
|
});
|
|
259
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Reap the herdr panes this agent run opened — ADR-0032.
|
|
175
|
+
*
|
|
176
|
+
* **`agent_settled`, not `turn_end`, and that is the whole finding.** `turn_end` fires at the end of each
|
|
177
|
+
* provider round-trip, i.e. no later than the `finally` that used to close the pane — so building pane
|
|
178
|
+
* lifetime on it would have shipped a no-op that read like a feature. `agent_settled` is documented as firing
|
|
179
|
+
* once the run has fully settled with no retry, compaction or queued continuation: the moment the operator
|
|
180
|
+
* gets their prompt back. Independent corroboration that this is the right boundary: herdr's own pi
|
|
181
|
+
* integration drives its busy/idle display from `agent_start`/`agent_settled`.
|
|
182
|
+
*
|
|
183
|
+
* The sweep is ASYNC (`reapOpenPanesAsync`) rather than the `exit` handler's sync one, which is `execFileSync`
|
|
184
|
+
* with a six-second budget by necessity. Running that here would freeze pi for up to six seconds every time
|
|
185
|
+
* the operator gets their prompt back — a feature built to make work visible, stalling the thing it serves.
|
|
186
|
+
*
|
|
187
|
+
* `exit` remains the backstop. SIGKILL still orphans panes, exactly as R-62 records, and no signal handler is
|
|
188
|
+
* installed here for the reason R-62 gives: it would turn pi's "interrupt this turn" into "exit pi".
|
|
189
|
+
*/
|
|
190
|
+
pi.on("agent_settled", async () => {
|
|
191
|
+
try {
|
|
192
|
+
if (session.executor.kind !== "herdr" || openPaneCount() === 0) return undefined;
|
|
193
|
+
await reapOpenPanesAsync();
|
|
194
|
+
} catch {
|
|
195
|
+
/* never throw into the agent loop; the exit handler is still the backstop */
|
|
196
|
+
}
|
|
197
|
+
return undefined;
|
|
198
|
+
});
|
|
199
|
+
|
|
260
200
|
// Observe this session's real tool surface once, and tighten the grant to it. Authoritative because
|
|
261
201
|
// it is exactly what pi sent the model.
|
|
262
202
|
pi.on("before_provider_request", (event) => {
|
|
@@ -305,11 +245,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
305
245
|
pi.on("tool_call", async (event) => {
|
|
306
246
|
if (!session.governed || !SPAWN_TOOLS.has(event.toolName)) return undefined;
|
|
307
247
|
|
|
308
|
-
const reason =
|
|
309
|
-
`grants: "${event.toolName}" spawns sub-agents outside this session's governance — refused. ` +
|
|
310
|
-
`This session grants capabilities by spawning them itself (\`delegate\`), so a child created by ` +
|
|
311
|
-
`another extension would hold whatever that extension decided, with no grant, no depth bound and ` +
|
|
312
|
-
`no ledger entry. Use \`delegate\` instead. If you meant to run ungoverned, unset PI_GRANTS_GRANT.`;
|
|
248
|
+
const reason = tripwireReason(event.toolName);
|
|
313
249
|
|
|
314
250
|
if (session.ledgerPath) {
|
|
315
251
|
// Recorded like any other refusal: an audit that omits the spawns we turned away cannot answer
|
|
@@ -323,6 +259,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
323
259
|
agentType: event.toolName,
|
|
324
260
|
// The wildcard is the honest record: an unknown spawner was going to hand this child whatever
|
|
325
261
|
// IT decided, and we have no way to know what that would have been.
|
|
262
|
+
executor: session.executor.kind,
|
|
326
263
|
requested: [WILDCARD],
|
|
327
264
|
parentGrant: session.ownGrant,
|
|
328
265
|
result: { effective: [], denied: [WILDCARD], clipped: [], gatedBlocked: [], universal: [], subsumedBy: [] },
|
|
@@ -355,6 +292,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
355
292
|
cwd: session.cwd,
|
|
356
293
|
governed: session.governed,
|
|
357
294
|
ownGrant: session.ownGrant,
|
|
295
|
+
executor: session.executor,
|
|
358
296
|
observed: session.observed,
|
|
359
297
|
depth: session.depth,
|
|
360
298
|
maxDepth: session.maxDepth,
|
|
@@ -368,9 +306,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
368
306
|
// says so: stored approvals count exactly as they would for a spawn, and no human is asked
|
|
369
307
|
// (R-38). Passing `ctx` here would let `/grants` raise a dialog, and passing `hasUI: false` would
|
|
370
308
|
// make every gated definition report "no interactive user" instead of what actually blocks it.
|
|
309
|
+
runInit: () =>
|
|
310
|
+
runInit(session, ctx, async () => {
|
|
311
|
+
await loadProjectDefinitions(session, ctx.cwd);
|
|
312
|
+
delegation.refreshSpawnable();
|
|
313
|
+
}),
|
|
371
314
|
previewDelegation: (name: string) =>
|
|
372
315
|
planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
|
|
373
316
|
},
|
|
374
317
|
}),
|
|
375
318
|
});
|
|
376
319
|
}
|
|
320
|
+
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/grants init` — the one command in this package that writes.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `extensions/grants.ts` when the file-size guard refused it, and the seam is the right one:
|
|
5
|
+
* `grants.ts` is wiring, and this is a decision procedure that asks a human questions and stores the
|
|
6
|
+
* answer. Keeping it here means the file that registers hooks stays readable, which is the property the
|
|
7
|
+
* guard exists to defend — every wiring bug this package has had lived in that file.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { discoverSkillPackages } from "../src/skill-packages.ts";
|
|
11
|
+
import { applyInit, planInit } from "../src/init.ts";
|
|
12
|
+
import { saveGrant, grantStorePath } from "../src/grant-store.ts";
|
|
13
|
+
import { expandSubsumed, SUBSUMPTION, type Capability } from "../src/resolve.ts";
|
|
14
|
+
import type { GrantsSession } from "./session.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `/grants init` — scaffold, ask about what is withheld, store it outside the workspace, apply it now.
|
|
18
|
+
*
|
|
19
|
+
* **The dialog covers the withheld capabilities and nothing else** (ADR-0030). Asking about all of them
|
|
20
|
+
* would be a dozen questions for a first run, and this project has a name for what that produces: R-25,
|
|
21
|
+
* where the operator learns to click through and the control becomes decorative. The read-only capabilities
|
|
22
|
+
* a skill declares are already bounded by the ceiling its author wrote and by pi's `--tools`; the ones that
|
|
23
|
+
* can change the machine are the decision, so they are the question.
|
|
24
|
+
*
|
|
25
|
+
* A refusal is not a failure. Answering *no* to `tool:bash` leaves four definitions unspawnable and says so
|
|
26
|
+
* — that is the same outcome `pi-daddy init` writes by default, reached deliberately rather than by
|
|
27
|
+
* omission.
|
|
28
|
+
*/
|
|
29
|
+
export async function runInit(
|
|
30
|
+
session: GrantsSession,
|
|
31
|
+
ctx: any,
|
|
32
|
+
/**
|
|
33
|
+
* Reload definitions and the catalog, and re-describe the delegation tools.
|
|
34
|
+
*
|
|
35
|
+
* **Without this the grant goes live and the definitions do not** — `session.definitions` and the catalog
|
|
36
|
+
* are read at `session_start`, which is before `init` wrote a single file, so a session would hold
|
|
37
|
+
* `agent:review` while believing no definition of that name exists. `/grants` showed `0 skill,
|
|
38
|
+
* 0 agent-type` and no verdicts, and the model would have been told `Available: none` — R-39 exactly,
|
|
39
|
+
* reintroduced by a feature whose whole selling point is "no restart". Found by running it.
|
|
40
|
+
*/
|
|
41
|
+
refresh: () => Promise<void>,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
const packages = await discoverSkillPackages(ctx.cwd);
|
|
44
|
+
if (packages.length === 0) {
|
|
45
|
+
ctx.ui.notify(
|
|
46
|
+
"grants: no packages declaring skills found in node_modules. Install one — e.g. " +
|
|
47
|
+
"`npm i principal-pi-skills` — then run /grants init again.",
|
|
48
|
+
"warning",
|
|
49
|
+
);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const plan = planInit(packages, ctx.cwd);
|
|
54
|
+
const outcome = await applyInit(plan);
|
|
55
|
+
const lines = [
|
|
56
|
+
`grants: ${plan.skills.length} definition(s) from ${packages.map((p) => `${p.name}@${p.version}`).join(", ")}`,
|
|
57
|
+
` wrote ${outcome.written.length}, kept ${outcome.kept.length} already present` +
|
|
58
|
+
`${outcome.failed.length > 0 ? `, ${outcome.failed.length} FAILED` : ""}`,
|
|
59
|
+
];
|
|
60
|
+
for (const f of outcome.failed) lines.push(` ${f.path}: ${f.error}`);
|
|
61
|
+
|
|
62
|
+
// The grant `init` would have written to `.pi/grants.env`: read-only, nothing that can change the
|
|
63
|
+
// machine. Everything below is added to it only by an explicit yes.
|
|
64
|
+
const grant = new Set<Capability>(plan.grant);
|
|
65
|
+
const granted: string[] = [];
|
|
66
|
+
const declined: string[] = [];
|
|
67
|
+
/** Withheld capabilities a previous *yes* already conferred, so no question was asked about them. */
|
|
68
|
+
const alreadyConferred: string[] = [];
|
|
69
|
+
|
|
70
|
+
for (const [capability, neededBy] of plan.withheldCapabilities) {
|
|
71
|
+
// **Do not ask a question whose answer cannot matter.** `tool:bash` subsumes `write`, `edit` and
|
|
72
|
+
// `edit-diff` (`SUBSUMPTION`, `src/resolve.ts`), so once bash is granted those are already conferred.
|
|
73
|
+
// The first version asked anyway: an operator could answer *no* to `tool:write`, watch `/grants` allow
|
|
74
|
+
// `build` with `tool:write`, and reasonably conclude the dialog was decorative. It was — that is R-47's
|
|
75
|
+
// shape, a control that appears to do something and does not, inside a control built to prevent it.
|
|
76
|
+
//
|
|
77
|
+
// Reported rather than silently skipped, because "you already granted this" is the useful sentence and
|
|
78
|
+
// silence is what made it confusing.
|
|
79
|
+
if (expandSubsumed([...grant]).includes(capability)) {
|
|
80
|
+
alreadyConferred.push(`${capability} (via ${subsumedBy([...grant], capability) ?? "a granted capability"})`);
|
|
81
|
+
for (const name of neededBy) grant.add(`agent:${name}` as Capability);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const answer = await ctx.ui.select(
|
|
85
|
+
`grants: grant ${capability} to sub-agents?\n needed by: ${neededBy.join(", ")}\n` +
|
|
86
|
+
` this can change your machine — ${capability === "tool:bash" ? "and bash also confers write and edit" : "it is not gated, so no dialog at spawn time"}`,
|
|
87
|
+
["No", "Yes"],
|
|
88
|
+
);
|
|
89
|
+
if (answer === "Yes") {
|
|
90
|
+
grant.add(capability);
|
|
91
|
+
granted.push(capability);
|
|
92
|
+
// The `agent:` ids too: a definition authorised but unable to receive what it declares would be
|
|
93
|
+
// allowed to run and then refused, which is a worse answer than not being authorised (ADR-0029).
|
|
94
|
+
for (const name of neededBy) grant.add(`agent:${name}` as Capability);
|
|
95
|
+
} else {
|
|
96
|
+
declined.push(`${capability} (${neededBy.join(", ")})`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const finalGrant = [...grant].sort();
|
|
101
|
+
const saved = await saveGrant(ctx.cwd, finalGrant);
|
|
102
|
+
if (saved !== "saved") {
|
|
103
|
+
lines.push(
|
|
104
|
+
` NOT STORED — ${saved === "busy" ? "another session holds the grant store" : "the store could not be written"}. ` +
|
|
105
|
+
`Nothing was changed; this session's grant is unchanged. Retry, or export PI_GRANTS_GRANT yourself.`,
|
|
106
|
+
);
|
|
107
|
+
ctx.ui.notify(lines.join("\n"), "error");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Live, without a restart — the whole point of the store. Only after a successful write, so what runs and
|
|
112
|
+
// what is recorded cannot disagree.
|
|
113
|
+
session.adoptGrant(finalGrant);
|
|
114
|
+
// Order matters: the grant first, then the reload, because `refreshSpawnable` filters the definitions it
|
|
115
|
+
// advertises through `maySpawnDefinition` against the grant the session now holds.
|
|
116
|
+
await refresh();
|
|
117
|
+
|
|
118
|
+
lines.push(` stored at ${grantStorePath(ctx.cwd)} — outside this project, so no child can rewrite it`);
|
|
119
|
+
if (granted.length > 0) lines.push(` GRANTED: ${granted.join(", ")}`);
|
|
120
|
+
if (alreadyConferred.length > 0) {
|
|
121
|
+
lines.push(` ALREADY CONFERRED, not asked about: ${alreadyConferred.join(", ")}`);
|
|
122
|
+
}
|
|
123
|
+
if (declined.length > 0) lines.push(` withheld: ${declined.join("; ")}`);
|
|
124
|
+
lines.push(` live now (${finalGrant.length} capabilities) — no restart. /grants shows the verdicts.`);
|
|
125
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Which held capability confers `capability`, for a message that names the cause rather than the effect. */
|
|
129
|
+
function subsumedBy(held: Capability[], capability: Capability): Capability | null {
|
|
130
|
+
for (const h of held) if ((SUBSUMPTION[h] ?? []).includes(capability)) return h;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
@@ -22,7 +22,8 @@ import type { Capability } from "../src/resolve.ts";
|
|
|
22
22
|
import { ENV_CHILD_TIMEOUT, runChild, timeoutFromEnv } from "../src/run-child.ts";
|
|
23
23
|
import { runHerdrPane } from "../src/run-herdr.ts";
|
|
24
24
|
import { obtainApprovals, republishable, snapshotOf, type ApprovalOutcome, type ApprovalUIContext } from "./approvals.ts";
|
|
25
|
-
import {
|
|
25
|
+
import { resolveWorkspace } from "../src/herdr-cli.ts";
|
|
26
|
+
import { ENV_HERDR_KEEP_PANE, type GrantsSession } from "./session.ts";
|
|
26
27
|
|
|
27
28
|
/** What one child was asked to do. The shape both tools accept, per child. */
|
|
28
29
|
interface ChildSpec {
|
|
@@ -156,6 +157,22 @@ export async function runOneDelegation(
|
|
|
156
157
|
budget: number | undefined,
|
|
157
158
|
ctx: DelegationToolContext,
|
|
158
159
|
signal: AbortSignal | undefined,
|
|
160
|
+
/**
|
|
161
|
+
* Progress for the parent's status block (ADR-0032). Optional, so nothing here depends on being watched.
|
|
162
|
+
*
|
|
163
|
+
* One sink for both executors: the herdr path additionally reports a pane id, and the process path never
|
|
164
|
+
* has one. Every field is display-only — the child's answer is still the returned outcome.
|
|
165
|
+
*/
|
|
166
|
+
onProgress?: (update: {
|
|
167
|
+
/** Appended (process executor: a genuine byte stream). */
|
|
168
|
+
chunk?: string;
|
|
169
|
+
/** Replaces (herdr executor: a snapshot of a bounded terminal). The two are NOT interchangeable. */
|
|
170
|
+
snapshot?: string[];
|
|
171
|
+
paneId?: string;
|
|
172
|
+
/** The name herdr actually knows this child by — minted in `runHerdrPane`, so it cannot be derived. */
|
|
173
|
+
agentName?: string;
|
|
174
|
+
state?: "running" | "completed" | "failed";
|
|
175
|
+
}) => void,
|
|
159
176
|
): Promise<DelegationOutcome> {
|
|
160
177
|
// pi resolves a BARE model id to an unauthenticated provider and the child dies at startup — the id
|
|
161
178
|
// alone is not enough, it must be qualified with its provider (`Model<Api>` carries both).
|
|
@@ -163,9 +180,32 @@ export async function runOneDelegation(
|
|
|
163
180
|
const request = { task: spec.task, agent: spec.agent, tools: spec.tools, model: spec.model ?? defaultModel };
|
|
164
181
|
const extra = { fanoutBudget: budget, spawnId: ids.parentId, childSpawnId: ids.childId };
|
|
165
182
|
|
|
183
|
+
// ADR-0031: herdr was DEMANDED (`PI_GRANTS_HERDR=1`) and is not answering. Refused rather than relocated —
|
|
184
|
+
// the operator chose that over falling back, so the ledger can never name a child that ran somewhere nobody
|
|
185
|
+
// chose.
|
|
186
|
+
//
|
|
187
|
+
// **Decided BEFORE the gate, and the ordering is a fix.** This sat after `planWithApprovals`, which opens the
|
|
188
|
+
// approval dialog — so with herdr down a human was asked to approve `tool:bash`, answered *Always*, and was
|
|
189
|
+
// then refused anyway. Measured: the answer still reached `process.env.PI_GRANTS_APPROVED`, still wrote a
|
|
190
|
+
// **30-day project-wide** entry to the persisted store, and still produced a ledger line asserting a human
|
|
191
|
+
// approved `bash` for a child that never existed. A refused operation must not leave authority behind, and
|
|
192
|
+
// asking for permission that cannot be used is R-25's fatigue shape with nothing bought.
|
|
193
|
+
//
|
|
194
|
+
// `ctx: null` rather than skipping the plan entirely: the ledger still gets a full, honest record of what was
|
|
195
|
+
// requested and refused, and stored approvals still count toward it — nothing is *hidden*, only nobody is
|
|
196
|
+
// *asked*. It is the same argument `/grants` uses for its preview.
|
|
197
|
+
const refusal = session.executor.refusal;
|
|
166
198
|
// Planning and the gate live in `planWithApprovals`, shared with the `/grants` preview so the two cannot
|
|
167
|
-
// disagree (R-38). This call is the enforcing one
|
|
168
|
-
|
|
199
|
+
// disagree (R-38). This call is the enforcing one when a human may be asked: `ctx` is passed unless the
|
|
200
|
+
// executor has already made the outcome certain.
|
|
201
|
+
let { plan, approval: approvalOutcome } = await planWithApprovals(session, request, extra, refusal ? null : ctx, signal);
|
|
202
|
+
|
|
203
|
+
// Applied in front of the ledger write below, so the record describes a refusal rather than a spawn. Turning
|
|
204
|
+
// `plan.ok` off reuses the existing blocked-record path, so this adds a reason rather than a second refusal
|
|
205
|
+
// mechanism.
|
|
206
|
+
if (refusal) {
|
|
207
|
+
plan = { ...plan, ok: false, reason: `grants: ${refusal}` };
|
|
208
|
+
}
|
|
169
209
|
|
|
170
210
|
// G6 / B-I3: no `&& plan.result` guard — `planDelegation` always carries one now.
|
|
171
211
|
if (session.ledgerPath) {
|
|
@@ -179,6 +219,9 @@ export async function runOneDelegation(
|
|
|
179
219
|
childId: ids.childId,
|
|
180
220
|
depth: plan.childDepth,
|
|
181
221
|
agentType: spec.agent ?? "delegate",
|
|
222
|
+
// ADR-0031: where this child actually ran. Read off the live session, which the probe has settled
|
|
223
|
+
// by now, so the record and the executor cannot disagree.
|
|
224
|
+
executor: session.executor.kind,
|
|
182
225
|
requested: plan.requested,
|
|
183
226
|
parentGrant: session.ownGrant,
|
|
184
227
|
result: plan.result,
|
|
@@ -208,10 +251,14 @@ export async function runOneDelegation(
|
|
|
208
251
|
// G8: bounded output, a wall-clock timeout with SIGTERM->SIGKILL escalation, and an abort observed
|
|
209
252
|
// even if it happened before we got here. See src/run-child.ts for why each one exists.
|
|
210
253
|
//
|
|
211
|
-
// ADR-0016 point 6: two executors, one plan. `runChild`
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
|
|
254
|
+
// ADR-0016 point 6: two executors, one plan. `runChild` needs nothing installed; herdr gives the same
|
|
255
|
+
// governed argv a VISIBLE, attachable pane.
|
|
256
|
+
//
|
|
257
|
+
// **Which one is chosen was reversed by ADR-0031**: the session probes for a reachable herdr server at
|
|
258
|
+
// startup rather than waiting to be told. Still never detected from a binary on `PATH` — only from a server
|
|
259
|
+
// that answered — and the choice is disclosed at session start, in `/grants`, and per child in the ledger.
|
|
260
|
+
// Read live off `session.executor`, because the probe finishes after this module is loaded.
|
|
261
|
+
const output = session.executor.kind === "herdr"
|
|
215
262
|
? await runHerdrPane({
|
|
216
263
|
args: plan.args.slice(0, -1),
|
|
217
264
|
// The task is delivered as a prompt, so it never reaches argv at all. `plan.args` still ends
|
|
@@ -223,10 +270,22 @@ export async function runOneDelegation(
|
|
|
223
270
|
env: plan.env,
|
|
224
271
|
cwd: ctx.cwd,
|
|
225
272
|
name: `${spec.agent ?? "delegate"}-${ids.childId}`,
|
|
226
|
-
|
|
273
|
+
// Was `process.env[ENV_HERDR_WORKSPACE]`, i.e. "omitted lets herdr choose" — which put children in a
|
|
274
|
+
// different workspace from the pi session that spawned them, so switching to one meant hopping
|
|
275
|
+
// workspaces rather than tabs. `resolveWorkspace` prefers the operator's explicit answer and otherwise
|
|
276
|
+
// inherits the parent's own `HERDR_WORKSPACE_ID` (measured; herdr sets it in every pane it creates).
|
|
277
|
+
workspace: resolveWorkspace(process.env),
|
|
227
278
|
signal,
|
|
228
279
|
timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
|
|
229
280
|
keepPane: process.env[ENV_HERDR_KEEP_PANE] === "1",
|
|
281
|
+
// ADR-0032. The pane id arrives first and is what a human switches to; the pane's tail follows as the
|
|
282
|
+
// child works. Both are display only.
|
|
283
|
+
//
|
|
284
|
+
// `snapshot`, not `chunk`: `agent read` returns a snapshot of a bounded terminal, and the sink must
|
|
285
|
+
// REPLACE what it holds. Treating it as a stream produced an 89,000× amplification and fabricated lines
|
|
286
|
+
// the child never printed — see `tailLines` in `src/herdr-poll.ts`.
|
|
287
|
+
onPane: onProgress ? (paneId, agentName) => onProgress({ paneId, agentName, state: "running" }) : undefined,
|
|
288
|
+
onSnapshot: onProgress ? (snapshot) => onProgress({ snapshot }) : undefined,
|
|
230
289
|
})
|
|
231
290
|
: await runChild({
|
|
232
291
|
command: "pi",
|
|
@@ -238,6 +297,9 @@ export async function runOneDelegation(
|
|
|
238
297
|
cwd: ctx.cwd,
|
|
239
298
|
signal,
|
|
240
299
|
timeoutMs: timeoutFromEnv(process.env[ENV_CHILD_TIMEOUT]),
|
|
300
|
+
// No pane on this path, so streaming is the ONLY observability a subprocess child can have — which is
|
|
301
|
+
// why ADR-0032 chose the streaming option over status lines alone.
|
|
302
|
+
onOutput: onProgress ? (chunk) => onProgress({ chunk }) : undefined,
|
|
241
303
|
});
|
|
242
304
|
|
|
243
305
|
// G8: a child that failed is reported as a failure. A non-zero exit, a timeout and a truncated flood
|