pi-daddy 0.15.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 +91 -0
- package/README.md +27 -12
- package/dist/cli.js +0 -0
- 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/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/extensions/delegation.ts +94 -2
- package/extensions/grants-command.ts +26 -1
- package/extensions/grants.ts +85 -163
- package/extensions/run-delegation.ts +70 -8
- package/extensions/session-report.ts +231 -0
- package/extensions/session.ts +63 -11
- package/extensions/tripwire.ts +44 -0
- package/package.json +17 -1
- package/src/executor.ts +122 -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
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything this extension says at session start.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `extensions/grants.ts` for the reason `grants-command.ts` and `session.ts` were: that file is
|
|
5
|
+
* where every wiring bug in this package has lived, and it had reached **398 of the 400-line ceiling**
|
|
6
|
+
* `test/file-size.test.ts` enforces. ADR-0032 adds a control there, so the file had to be split before it
|
|
7
|
+
* could be added — the alternative was raising the cap, which is how a guard stops guarding. This project
|
|
8
|
+
* split `delegate.ts` at 413 rather than raise it, and that precedent is the whole argument.
|
|
9
|
+
*
|
|
10
|
+
* The seam is the same one twice over: `grants.ts` keeps the HOOKS and the wiring; this module decides what an
|
|
11
|
+
* operator is **told**. Nothing here returns a value or mutates the session, which is what makes it safe to
|
|
12
|
+
* lift — a reporter cannot become a governance path by accident.
|
|
13
|
+
*
|
|
14
|
+
* **Each control keeps its own `try`.** That is R-60's lesson rather than tidiness: one added `await` inside a
|
|
15
|
+
* shared `catch` cancels every control below it with no trace, and that is exactly how an unreadable ledger
|
|
16
|
+
* came to silence the `holding [...]` line too.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { existsSync } from "node:fs";
|
|
20
|
+
import { legacyApprovalsPath, sharedApprovalsPath } from "../src/approval-store.ts";
|
|
21
|
+
import { verifyLedger } from "../src/ledger.ts";
|
|
22
|
+
import { AGENT_WILDCARD } from "../src/resolve.ts";
|
|
23
|
+
import { planWithApprovals } from "./run-delegation.ts";
|
|
24
|
+
import type { GrantsSession } from "./session.ts";
|
|
25
|
+
import { renderSpawnableSummary, summariseSpawnable } from "./spawn-summary.ts";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The slice of pi's context this module needs: a working directory and somewhere to speak.
|
|
29
|
+
*
|
|
30
|
+
* Named explicitly rather than taking `ExtensionContext` whole, for `grants-command.ts`'s reason — what a
|
|
31
|
+
* read-only reporter may see is a decision, and it belongs in a type rather than in whatever happened to be
|
|
32
|
+
* in scope.
|
|
33
|
+
*/
|
|
34
|
+
export interface SessionReportContext {
|
|
35
|
+
cwd: string;
|
|
36
|
+
ui: { notify(message: string, level: "info" | "warning" | "error"): void };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function reportSessionStart(session: GrantsSession, ctx: SessionReportContext): Promise<void> {
|
|
40
|
+
// A malformed bound is now loud as well as safe. Silently disabling spawning would be just as
|
|
41
|
+
// confusing as silently disabling the limit was dangerous — the operator set the variable, so
|
|
42
|
+
// they need to know it did not take effect (G7 / A-S4).
|
|
43
|
+
if (session.malformedBounds.length > 0) {
|
|
44
|
+
ctx.ui.notify(
|
|
45
|
+
`grants: ${session.malformedBounds.join(" and ")} could not be read as a non-negative integer — ` +
|
|
46
|
+
`spawning is disabled for this session (failing closed)`,
|
|
47
|
+
"warning",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
// ADR-0014: a pre-0.6 in-workspace approvals file is IGNORED, not migrated — importing it would
|
|
51
|
+
// import exactly the entries whose trustworthiness the move exists to remove. Say so, because an
|
|
52
|
+
// operator whose approvals silently stopped applying deserves to know why.
|
|
53
|
+
// ADR-0020: the pre-0.11 single shared store is ignored, not migrated. Same reasoning shape as the
|
|
54
|
+
// legacy file below — an operator whose approvals silently stopped applying must be told why — but a
|
|
55
|
+
// different reason for not migrating: splitting it by `cwd` would be lossless, and it is still declined
|
|
56
|
+
// because one-shot migration code in the layer with nine defects buys less than one re-approval costs.
|
|
57
|
+
try {
|
|
58
|
+
if (existsSync(sharedApprovalsPath())) {
|
|
59
|
+
ctx.ui.notify(
|
|
60
|
+
`grants: ignoring ${sharedApprovalsPath()} — approvals are now stored one file per governed ` +
|
|
61
|
+
`directory (ADR-0020), because a single shared file could not hold two projects' approvals ` +
|
|
62
|
+
`for a same-named definition. Re-approve when next asked. **Deleting the old file is ` +
|
|
63
|
+
`recommended, not merely safe**: entries written by 0.10.x may contain the task text a model ` +
|
|
64
|
+
`composed at approval time, which this version no longer stores anywhere (ADR-0021).`,
|
|
65
|
+
"warning",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
/* never throw into the agent loop */
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
if (existsSync(legacyApprovalsPath(ctx.cwd))) {
|
|
73
|
+
ctx.ui.notify(
|
|
74
|
+
`grants: ignoring ${legacyApprovalsPath(ctx.cwd)} — approvals now live outside the workspace ` +
|
|
75
|
+
`(it was writable by the very agents it gated). Re-approve when next asked; the old file is ` +
|
|
76
|
+
`safe to delete.`,
|
|
77
|
+
"warning",
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* never throw into the agent loop */
|
|
82
|
+
}
|
|
83
|
+
// R-47. `gatedBlocked` filters `requested`, and for a definition spawn `requested` is that
|
|
84
|
+
// definition's CEILING — which never contains `agent:<name>`, because the authorisation check
|
|
85
|
+
// (ADR-0017) is a separate, ungated branch. So `PI_GRANTS_GATED=agent:deploy`, written by an operator
|
|
86
|
+
// who read "it attenuates like any other capability" and meant "ask me before deploy runs", produces
|
|
87
|
+
// no dialog and no warning. It DOES bite when a definition passes the id down in its own
|
|
88
|
+
// `allowed-tools`, so the flag half-works — which is worse than not working, and is R-25's shape in
|
|
89
|
+
// the namespace ADR-0017 just promoted out of exactly that state.
|
|
90
|
+
//
|
|
91
|
+
// Warned rather than enforced: making it gate the spawn is a behaviour change and wants a decision.
|
|
92
|
+
// Silence is the part that is indefensible either way.
|
|
93
|
+
// `agent:*` grants no tools, but it authorises every definition in BOTH skill roots — including
|
|
94
|
+
// `~/.pi/agent/skills/`, which other software installs into, so ADR-0017's "an operator-authored
|
|
95
|
+
// file" is not true of everything it covers. Paired with a shell that is every body on disk running
|
|
96
|
+
// with `bash`. `docs/SPEC.md` calls the combination poor and nothing detected it, which is R-47's
|
|
97
|
+
// shape in a control shipped one day later.
|
|
98
|
+
if (session.ownGrant.includes(AGENT_WILDCARD) && session.gated.length === 0 && session.ownGrant.includes("tool:bash")) {
|
|
99
|
+
ctx.ui.notify(
|
|
100
|
+
`grants: PI_GRANTS_GRANT pairs agent:* with tool:bash and gates nothing — every SKILL.md in ` +
|
|
101
|
+
`this project AND in ~/.pi/agent/skills (which other tools install into) may run with a shell. ` +
|
|
102
|
+
`Enumerate the agent: ids you mean, or leave PI_GRANTS_GATED at its default so bash is asked for.`,
|
|
103
|
+
"warning",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const inertGates = session.gated.filter((c) => c.startsWith("agent:"));
|
|
107
|
+
if (inertGates.length > 0) {
|
|
108
|
+
ctx.ui.notify(
|
|
109
|
+
`grants: ${inertGates.join(", ")} in PI_GRANTS_GATED does NOT gate spawning that definition — ` +
|
|
110
|
+
`the authorisation check for a definition is separate and ungated, so a human is never asked. ` +
|
|
111
|
+
`It applies only where a definition passes the id down in its own allowed-tools. To control ` +
|
|
112
|
+
`which definitions may run, withhold the agent: capability from PI_GRANTS_GRANT instead.`,
|
|
113
|
+
"warning",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
// R-34. `verifyLedger` existed and nothing ran it, so a torn line was detectable and undetected —
|
|
117
|
+
// and a check an operator has to know to run is not a control, it is a feature. Setting
|
|
118
|
+
// `PI_GRANTS_LEDGER` already means "I want an audit trail"; noticing that the trail is damaged is
|
|
119
|
+
// part of keeping one.
|
|
120
|
+
//
|
|
121
|
+
// Corruption only, deliberately. The escalation count is a *query* — `/grants ledger` answers it —
|
|
122
|
+
// and reporting historical attempts unprompted at every start is the fatigue shape R-25 names, which
|
|
123
|
+
// ends with the operator ignoring the line that matters.
|
|
124
|
+
//
|
|
125
|
+
// Awaited rather than fired and forgotten: it is one read, on a path that already awaits two
|
|
126
|
+
// directory scans, and awaiting is what guarantees the warning reaches a live `ctx.ui`.
|
|
127
|
+
//
|
|
128
|
+
// R-60. `verifyLedger` RETHROWS every read error that is not ENOENT — right for `/grants ledger`,
|
|
129
|
+
// where an operator asked a direct question and deserves the failure — and this call is the only one
|
|
130
|
+
// that makes it inside the blanket catch below. So an unreadable ledger threw here and cancelled every
|
|
131
|
+
// remaining control **in silence**: no alarm, and not even the `holding [...]` line that is the one
|
|
132
|
+
// sign governance is on. Confirmed by execution — `PI_GRANTS_LEDGER` naming a directory produced ZERO
|
|
133
|
+
// notifications from a governed session. A trail that cannot be read at all is a worse failure than a
|
|
134
|
+
// torn line, and it was the one case this control said nothing about.
|
|
135
|
+
if (session.ledgerPath) {
|
|
136
|
+
try {
|
|
137
|
+
const report = await verifyLedger(session.ledgerPath);
|
|
138
|
+
if (report.exists && !report.ok) {
|
|
139
|
+
ctx.ui.notify(
|
|
140
|
+
`grants: ledger ${session.ledgerPath} has ${report.corrupt.length} unparseable line(s) — ` +
|
|
141
|
+
`first at line ${report.corrupt[0]?.line}. A torn line is indistinguishable from a spawn that ` +
|
|
142
|
+
`never happened, so this audit trail is incomplete. Run /grants ledger for detail; the file is ` +
|
|
143
|
+
`left alone because a corrupt line is evidence.`,
|
|
144
|
+
"error",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
ctx.ui.notify(
|
|
149
|
+
`grants: ledger ${session.ledgerPath} could not be read ` +
|
|
150
|
+
`(${(error as { code?: string }).code ?? String(error)}) — nothing can be verified about this ` +
|
|
151
|
+
`audit trail, and the first spawn will refuse rather than proceed unrecorded. Check that ` +
|
|
152
|
+
`PI_GRANTS_LEDGER names a writable FILE.`,
|
|
153
|
+
"error",
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// ADR-0031 rests on this line existing: an executor chosen by a probe is only defensible if it is announced.
|
|
158
|
+
//
|
|
159
|
+
// **`mayDelegate`, not `governed`** — and that distinction is a defect caught in review before it shipped.
|
|
160
|
+
// An UNGOVERNED session still registers `delegate` and still spawns (`mayDelegate` is true when
|
|
161
|
+
// `!governed`), so gating this on `governed` would have relocated an ungoverned session's children into
|
|
162
|
+
// herdr panes and said nothing about it. That is precisely the "silently" objection ADR-0031 claims to have
|
|
163
|
+
// discharged, reappearing inside the fix for it — R-28's shape, in the one configuration nobody tests.
|
|
164
|
+
//
|
|
165
|
+
// The guard is not simply dropped because a session that cannot spawn at all has no executor worth naming.
|
|
166
|
+
// **Every `info` line is joined into ONE notify, and that is a fix rather than formatting.**
|
|
167
|
+
//
|
|
168
|
+
// Measured against real pi 0.84.2 in a pty: `notify(…, "info")` maps to `showStatus`, which **replaces the
|
|
169
|
+
// previous status text in place** when the last two transcript children are the pair it created — which is
|
|
170
|
+
// exactly the case for back-to-back notifies. So consecutive `info` calls overwrite each other, and only the
|
|
171
|
+
// last survives. In a governed session with definitions that meant the executor line AND the
|
|
172
|
+
// `holding [...]` line were both gone, leaving only the spawnable summary — and `grants.ts` calls
|
|
173
|
+
// `holding [...]` "the one sign governance is on". **That half is a pre-existing defect**, true since the
|
|
174
|
+
// spawnable summary was added; ADR-0031's disclosure merely became its third victim.
|
|
175
|
+
//
|
|
176
|
+
// Six tests asserted these lines were *composed*. None asserted they were *delivered*: the unit harness
|
|
177
|
+
// pushes to an array and the integration harness runs `--mode rpc`, where each notify is its own JSON line.
|
|
178
|
+
// Both are replace-free, so neither could see this.
|
|
179
|
+
//
|
|
180
|
+
// Warnings and errors are NOT folded in — they go to different components (`showError`), survive on their
|
|
181
|
+
// own, and each says something an operator may need to act on separately.
|
|
182
|
+
const info: string[] = [];
|
|
183
|
+
|
|
184
|
+
if (session.mayDelegate && !session.executor.refusal) {
|
|
185
|
+
info.push(`grants: executor — ${session.executor.disclosure}`);
|
|
186
|
+
}
|
|
187
|
+
if (session.mayDelegate && session.executor.refusal) {
|
|
188
|
+
// An error, not an FYI: every delegation in this session will refuse. Emitted separately because `error`
|
|
189
|
+
// routes elsewhere and therefore is not at risk of being overwritten.
|
|
190
|
+
ctx.ui.notify(`grants: executor — ${session.executor.disclosure}`, "error");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (session.governed) {
|
|
194
|
+
info.push(
|
|
195
|
+
`grants: depth ${session.depth}/${session.maxDepth}, holding [${session.ownGrant.join(", ") || "nothing"}]`,
|
|
196
|
+
);
|
|
197
|
+
// B1 / P4. The grant alone never named the definitions, never said where they came from, and never
|
|
198
|
+
// said which ones were being WITHHELD — so an operator who had just installed a package of
|
|
199
|
+
// `SKILL.md` files could not tell governance-is-working from did-the-install-fail. Classified by the
|
|
200
|
+
// real planner (see `./spawn-summary.ts`), never by a second reading of the rules.
|
|
201
|
+
//
|
|
202
|
+
// Its own try/catch, and not because `summariseSpawnable` throws today: this is the R-60 shape
|
|
203
|
+
// exactly — one added `await` inside the blanket catch cancelling every control below it in
|
|
204
|
+
// silence.
|
|
205
|
+
try {
|
|
206
|
+
const line = renderSpawnableSummary(
|
|
207
|
+
await summariseSpawnable(
|
|
208
|
+
session.definitions,
|
|
209
|
+
(name) => planWithApprovals(session, { task: "(preview)", agent: name }, {}, null),
|
|
210
|
+
// The session facts that make every per-definition verdict identical. `mayDelegate` in
|
|
211
|
+
// particular: without `tool:delegate` there is no delegate tool at all, and the line used to
|
|
212
|
+
// report definitions as spawnable in the one session where nothing can ever be spawned.
|
|
213
|
+
{ mayDelegate: session.mayDelegate, depth: session.depth, maxDepth: session.maxDepth },
|
|
214
|
+
),
|
|
215
|
+
session.definitions.size,
|
|
216
|
+
);
|
|
217
|
+
if (line) info.push(line);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
ctx.ui.notify(
|
|
220
|
+
`grants: could not work out which definitions are spawnable ` +
|
|
221
|
+
`(${error instanceof Error ? error.message : String(error)}) — run /grants for the per-definition ` +
|
|
222
|
+
`verdict. Nothing about the grant or its enforcement depends on this line.`,
|
|
223
|
+
"warning",
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// One call, so nothing can overwrite anything else. `/grants` already worked this way, which is why its
|
|
229
|
+
// multi-line status screen has always survived while these separate lines did not.
|
|
230
|
+
if (info.length > 0) ctx.ui.notify(info.join("\n"), "info");
|
|
231
|
+
}
|
package/extensions/session.ts
CHANGED
|
@@ -20,6 +20,8 @@ import { makeCatalog, skillPathsFromCatalog, type Catalog } from "../src/catalog
|
|
|
20
20
|
import type { SkillDefinition } from "../src/definitions.ts";
|
|
21
21
|
import { DELEGATE_CAPABILITY, type DelegationContext } from "../src/delegate.ts";
|
|
22
22
|
import { budgetFromEnv } from "../src/fanout.ts";
|
|
23
|
+
import { chooseExecutor, needsProbe, ENV_HERDR, type ExecutorChoice } from "../src/executor.ts";
|
|
24
|
+
import { probeHerdr } from "../src/herdr-cli.ts";
|
|
23
25
|
import { WILDCARD } from "../src/pi-tools.ts";
|
|
24
26
|
import {
|
|
25
27
|
childEnv,
|
|
@@ -43,16 +45,32 @@ import { loadGrantSync, grantStorePath } from "../src/grant-store.ts";
|
|
|
43
45
|
import { republishable } from "./approvals.ts";
|
|
44
46
|
|
|
45
47
|
/**
|
|
46
|
-
* Run governed children in herdr panes instead of captured child processes
|
|
48
|
+
* Run governed children in herdr panes instead of captured child processes.
|
|
47
49
|
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
50
|
+
* **Three-state as of ADR-0031, and absent means PROBE.** It was opt-in under ADR-0016 point 6, on the
|
|
51
|
+
* reasoning that *"a run that silently relocates because a binary appeared is exactly the kind of invisible
|
|
52
|
+
* change this package exists to prevent"* — and that sentence is still honoured, because nothing is detected
|
|
53
|
+
* from `herdr` being on `PATH`. What changed is that a **server which answers** is a different and stronger
|
|
54
|
+
* test, and the "silently" half is discharged by the disclosure line ADR-0032 adds at session start and in
|
|
55
|
+
* `/grants`. Both executors still enforce the identical grant: the plan is the same, only the place it runs
|
|
56
|
+
* differs.
|
|
57
|
+
*
|
|
58
|
+
* The table itself is a pure function in `../src/executor.ts`; re-exported here because this is where every
|
|
59
|
+
* other `PI_GRANTS_*` name lives and a reader looking for it will look here.
|
|
60
|
+
*/
|
|
61
|
+
export { ENV_HERDR } from "../src/executor.ts";
|
|
62
|
+
/**
|
|
63
|
+
* herdr workspace for spawned panes — re-exported from where it is actually READ.
|
|
64
|
+
*
|
|
65
|
+
* It was declared here and read nowhere: `resolveWorkspace` reads the string literal, so the constant and the
|
|
66
|
+
* literal could drift with nothing binding them. Re-exporting the single definition keeps this the place a reader
|
|
67
|
+
* looks for a `PI_GRANTS_*` name without letting two spellings exist.
|
|
68
|
+
*
|
|
69
|
+
* Omitting the variable no longer means "let herdr choose": it falls back to the parent's own
|
|
70
|
+
* `HERDR_WORKSPACE_ID`, because a child in a different workspace from the session that spawned it makes switching
|
|
71
|
+
* to it a workspace hop (ADR-0032). This name is the operator's explicit override.
|
|
52
72
|
*/
|
|
53
|
-
export
|
|
54
|
-
/** herdr workspace for spawned panes. Omitted lets herdr choose. */
|
|
55
|
-
export const ENV_HERDR_WORKSPACE = "PI_GRANTS_HERDR_WORKSPACE";
|
|
73
|
+
export { ENV_HERDR_WORKSPACE } from "../src/herdr-cli.ts";
|
|
56
74
|
/** Keep each child's pane after it finishes, for inspection. Off by default: fan-out would flood it. */
|
|
57
75
|
export const ENV_HERDR_KEEP_PANE = "PI_GRANTS_HERDR_KEEP_PANE";
|
|
58
76
|
|
|
@@ -76,7 +94,18 @@ export interface GrantsSession {
|
|
|
76
94
|
readonly malformedBounds: string[];
|
|
77
95
|
readonly gated: Capability[];
|
|
78
96
|
readonly ledgerPath?: string;
|
|
79
|
-
|
|
97
|
+
/**
|
|
98
|
+
* Which executor runs this session's children — ADR-0031.
|
|
99
|
+
*
|
|
100
|
+
* **Mutable, and for ADR-0030's reason exactly.** Settling it needs a probe, the probe is async, and this
|
|
101
|
+
* object is built *synchronously* in the extension factory — an ordering S-5 forces, since whether
|
|
102
|
+
* `delegate` is registered at all is decided there. So it starts as the un-probed reading and is replaced by
|
|
103
|
+
* `resolveExecutor` once `session_start` has probed.
|
|
104
|
+
*
|
|
105
|
+
* Nothing may capture a copy: read it through the session, live. A copy taken in the factory is a copy taken
|
|
106
|
+
* before the probe, which is the same hazard as capturing `ownGrant` before the tool surface is observed.
|
|
107
|
+
*/
|
|
108
|
+
executor: ExecutorChoice;
|
|
80
109
|
/** This session's ledger identity; children descend from it (F8). */
|
|
81
110
|
readonly ownSpawnId: string;
|
|
82
111
|
/** Descendants this subtree may still create — the cardinality bound ADR-0008 never had. */
|
|
@@ -182,6 +211,22 @@ export async function loadProjectDefinitions(session: GrantsSession, cwd: string
|
|
|
182
211
|
session.catalog = await session.catalogReady;
|
|
183
212
|
}
|
|
184
213
|
|
|
214
|
+
/**
|
|
215
|
+
* Probe for herdr and settle this session's executor — ADR-0031.
|
|
216
|
+
*
|
|
217
|
+
* **Once, at session start, and never per spawn.** A fan-out whose children ran under two executors would put
|
|
218
|
+
* two different things under one call in the ledger, and the two plans differ (`--print` is withheld on the
|
|
219
|
+
* herdr path). A herdr server that dies mid-session therefore surfaces as a failed `tab create`, reported as
|
|
220
|
+
* the spawn error it is, rather than as a silent relocation of the remaining children.
|
|
221
|
+
*
|
|
222
|
+
* `probeHerdr` never throws, so this cannot either — which matters because it runs *before* the line that
|
|
223
|
+
* discloses what it decided (R-60: a throw here would cancel that line and every control after it).
|
|
224
|
+
*/
|
|
225
|
+
export async function resolveExecutor(session: GrantsSession): Promise<void> {
|
|
226
|
+
const raw = process.env[ENV_HERDR];
|
|
227
|
+
session.executor = chooseExecutor(raw, needsProbe(raw) ? await probeHerdr() : null);
|
|
228
|
+
}
|
|
229
|
+
|
|
185
230
|
export function createGrantsSession(extensionPath: string | undefined): GrantsSession {
|
|
186
231
|
// Governance is opt-in: with PI_GRANTS_GRANT unset AND no stored grant for this directory, the session
|
|
187
232
|
// holds the wildcard and nothing is blocked. This extension must never silently tighten a normal
|
|
@@ -221,7 +266,10 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
|
|
|
221
266
|
// `PI_GRANTS_GATED=""` turns the default off; absent and empty are deliberately distinguishable.
|
|
222
267
|
gated: governed ? gatedFromEnv(process.env[ENV_GATED]) : parseList(process.env[ENV_GATED]),
|
|
223
268
|
ledgerPath: process.env[ENV_LEDGER],
|
|
224
|
-
|
|
269
|
+
// The un-probed reading. `resolveExecutor` replaces it at session start; until then a `1` already reads as
|
|
270
|
+
// a refusal, which is the safe direction — a delegation that somehow ran before the probe would refuse
|
|
271
|
+
// rather than quietly use the wrong executor.
|
|
272
|
+
executor: chooseExecutor(process.env[ENV_HERDR], null),
|
|
225
273
|
// `ownSpawnId` comes from the parent (F8), so ids form one tree across process boundaries instead of
|
|
226
274
|
// every level restarting at `d0` and the ledger becoming unjoinable.
|
|
227
275
|
ownSpawnId: process.env[ENV_PARENT_ID]?.trim() || `d${depth}`,
|
|
@@ -267,7 +315,11 @@ export function createGrantsSession(extensionPath: string | undefined): GrantsSe
|
|
|
267
315
|
// The herdr executor drives the child after starting it, so its plan must NOT carry `--print`.
|
|
268
316
|
// Threaded through the plan rather than patched afterwards: the argv is what the ledger records, and
|
|
269
317
|
// an executor quietly rewriting it would make the record describe a spawn that did not happen.
|
|
270
|
-
|
|
318
|
+
//
|
|
319
|
+
// Read live off `session.executor` (ADR-0031) rather than a boolean captured in the factory: the probe
|
|
320
|
+
// has not run when this session object is built, so a captured value would plan `--print` for a session
|
|
321
|
+
// that turns out to use panes — and `runHerdrPane` refuses a plan containing `--print` by design.
|
|
322
|
+
interactive: session.executor.kind === "herdr",
|
|
271
323
|
...(approved ? { approved } : {}),
|
|
272
324
|
}),
|
|
273
325
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tripwire's vocabulary — which tool names count as a foreign spawner, and what to say when one appears.
|
|
3
|
+
*
|
|
4
|
+
* Lifted out of `extensions/grants.ts` so the *message* can be tested without loading pi. That is not
|
|
5
|
+
* fastidiousness: the message is the whole product of this control. A refusal nothing verifies is a refusal
|
|
6
|
+
* whose wording drifts, and the wording is what a model acts on.
|
|
7
|
+
*
|
|
8
|
+
* The hook that uses these stays in `grants.ts`, because it also writes a ledger record and that needs the
|
|
9
|
+
* session.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Tool names that create sub-agents this package did not provision.
|
|
14
|
+
*
|
|
15
|
+
* `subagent` is the one seen in the wild — a directory drop-in at `~/.pi/agent/extensions/subagent/`, which pi
|
|
16
|
+
* auto-loads in **every** session on a machine regardless of `settings.json`. `Agent` and `spawn_agent` are the
|
|
17
|
+
* other plausible names. **Deliberately a name check and nothing more**: `subagents:rpc:spawn` reaches a
|
|
18
|
+
* manager over the event bus and never produces a `tool_call` at all (ADR-0013 Finding 6), so this catches the
|
|
19
|
+
* ordinary case loudly and is not a boundary.
|
|
20
|
+
*/
|
|
21
|
+
export const SPAWN_TOOLS: ReadonlySet<string> = new Set(["Agent", "subagent", "spawn_agent"]);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Why a foreign spawn tool is refused, and what to use instead.
|
|
25
|
+
*
|
|
26
|
+
* **Both governed tools are named, and this is the fix rather than a flourish.** The text said only *"Use
|
|
27
|
+
* `delegate` instead"*. On 2026-08-17 an operator asked for parallel work, `subagent` was refused, and the model
|
|
28
|
+
* then planned a single sequential `delegate` — a reasonable reading of the only instruction it was given, and
|
|
29
|
+
* the wrong shape for the request. `delegate_all` existed the whole time.
|
|
30
|
+
*
|
|
31
|
+
* A refusal that points at the wrong replacement is a refusal that gets obeyed badly. And it names what is
|
|
32
|
+
* *lost* rather than only what is forbidden, because a control an operator cannot evaluate is one they route
|
|
33
|
+
* around — the escape hatch is one unset variable away, so it should be an informed choice.
|
|
34
|
+
*/
|
|
35
|
+
export function tripwireReason(toolName: string): string {
|
|
36
|
+
return (
|
|
37
|
+
`grants: "${toolName}" spawns sub-agents outside this session's governance — refused. ` +
|
|
38
|
+
`This session grants capabilities by spawning them itself, so a child created by another extension would ` +
|
|
39
|
+
`hold whatever that extension decided, with no grant, no depth bound and no ledger entry. ` +
|
|
40
|
+
`Use \`delegate\` for a single sub-agent, or \`delegate_all\` to run several CONCURRENTLY — that is the ` +
|
|
41
|
+
`governed equivalent of a parallel or chained spawn, and it is what to reach for when independent tasks ` +
|
|
42
|
+
`can proceed at the same time. If you meant to run ungoverned, unset PI_GRANTS_GRANT.`
|
|
43
|
+
);
|
|
44
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-daddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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",
|
|
@@ -69,6 +69,22 @@
|
|
|
69
69
|
"types": "./dist/run-herdr.d.ts",
|
|
70
70
|
"default": "./dist/run-herdr.js"
|
|
71
71
|
},
|
|
72
|
+
"./herdr-poll": {
|
|
73
|
+
"types": "./dist/herdr-poll.d.ts",
|
|
74
|
+
"default": "./dist/herdr-poll.js"
|
|
75
|
+
},
|
|
76
|
+
"./herdr-cli": {
|
|
77
|
+
"types": "./dist/herdr-cli.d.ts",
|
|
78
|
+
"default": "./dist/herdr-cli.js"
|
|
79
|
+
},
|
|
80
|
+
"./executor": {
|
|
81
|
+
"types": "./dist/executor.d.ts",
|
|
82
|
+
"default": "./dist/executor.js"
|
|
83
|
+
},
|
|
84
|
+
"./progress": {
|
|
85
|
+
"types": "./dist/progress.d.ts",
|
|
86
|
+
"default": "./dist/progress.js"
|
|
87
|
+
},
|
|
72
88
|
"./run-child": {
|
|
73
89
|
"types": "./dist/run-child.d.ts",
|
|
74
90
|
"default": "./dist/run-child.js"
|
package/src/executor.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which executor runs a governed child — ADR-0031.
|
|
3
|
+
*
|
|
4
|
+
* `PI_GRANTS_HERDR` is three-state, and **absent means probe**. That reverses ADR-0016 point 6's opt-in, and
|
|
5
|
+
* the reversal is narrower than it sounds: nothing is detected from `herdr` being on `PATH` (option C, rejected
|
|
6
|
+
* by name), only from a server that *answered*. The grant, the depth bound, the gate and `--tools` enforcement
|
|
7
|
+
* are identical either way — `planSpawn` produces one plan and both executors enforce it.
|
|
8
|
+
*
|
|
9
|
+
* **Pure on purpose.** The probe is I/O and lives in `herdr-cli.ts`; the DECISION is a table, and a table that
|
|
10
|
+
* fits on one screen is the only reason a reversal like this is reviewable at all.
|
|
11
|
+
*
|
|
12
|
+
* The disclosure string is part of the return value rather than composed at the call site, because ADR-0031's
|
|
13
|
+
* defence against "this relocates silently" is that every outcome says what it chose AND what to set instead.
|
|
14
|
+
* Two call sites composing that separately is how one of them comes to omit it (R-28).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { HerdrProbe } from "./herdr-cli.ts";
|
|
18
|
+
|
|
19
|
+
export const ENV_HERDR = "PI_GRANTS_HERDR";
|
|
20
|
+
|
|
21
|
+
export type ExecutorKind = "herdr" | "process";
|
|
22
|
+
|
|
23
|
+
export interface ExecutorChoice {
|
|
24
|
+
kind: ExecutorKind;
|
|
25
|
+
/** The operator named it: `PI_GRANTS_HERDR` was exactly `0` or `1`. */
|
|
26
|
+
forced: boolean;
|
|
27
|
+
/** Whether a probe was needed at all — false only for `0`. */
|
|
28
|
+
probed: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Set ONLY when herdr was demanded and is unreachable. Every delegation must refuse with this.
|
|
31
|
+
*
|
|
32
|
+
* Note that `kind` stays `"herdr"` in that case, deliberately: nothing downstream may mistake a refusing
|
|
33
|
+
* session for a working process-executor one.
|
|
34
|
+
*/
|
|
35
|
+
refusal?: string;
|
|
36
|
+
/** One line for the session banner and `/grants`. Always present. */
|
|
37
|
+
disclosure: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `0` is the one value that needs no probe: the operator ruled herdr out, so asking is pure cost. */
|
|
41
|
+
export function needsProbe(raw: string | undefined): boolean {
|
|
42
|
+
return raw === undefined || raw === "1";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function chooseExecutor(raw: string | undefined, probe: HerdrProbe | null): ExecutorChoice {
|
|
46
|
+
if (raw === "0") {
|
|
47
|
+
return {
|
|
48
|
+
kind: "process",
|
|
49
|
+
forced: true,
|
|
50
|
+
probed: false,
|
|
51
|
+
disclosure: "captured subprocess (PI_GRANTS_HERDR=0) — children have no terminal",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (raw === "1") {
|
|
56
|
+
if (probe?.ok) {
|
|
57
|
+
return { kind: "herdr", forced: true, probed: true, disclosure: "herdr panes (PI_GRANTS_HERDR=1)" };
|
|
58
|
+
}
|
|
59
|
+
// Refusal, not fallback — the operator's decision of 2026-08-17, against the alternative of falling back
|
|
60
|
+
// loudly. A fallback nobody reads is R-25's shape; refusing keeps the ledger unable to name a child that
|
|
61
|
+
// ran somewhere nobody chose. A `null` probe lands here too: if the probe failed so badly it produced no
|
|
62
|
+
// result, herdr was still demanded, and failing closed means refusing.
|
|
63
|
+
const why = probe?.error ?? "the herdr probe did not succeed";
|
|
64
|
+
return {
|
|
65
|
+
kind: "herdr",
|
|
66
|
+
forced: true,
|
|
67
|
+
probed: true,
|
|
68
|
+
refusal:
|
|
69
|
+
`PI_GRANTS_HERDR=1 demands the herdr executor and herdr is not answering (${why}). ` +
|
|
70
|
+
`Delegation is refused rather than quietly relocated to a captured subprocess, so this session's ` +
|
|
71
|
+
`ledger can never contain a child that ran somewhere nobody chose. Start herdr, or unset ` +
|
|
72
|
+
`PI_GRANTS_HERDR to let this session probe, or set PI_GRANTS_HERDR=0 to choose subprocesses.`,
|
|
73
|
+
disclosure: `herdr panes DEMANDED but unreachable (${why}) — every delegation will refuse`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (raw !== undefined) {
|
|
78
|
+
// Rule 8: fail closed and be loud. An unrecognised value must not relocate a run, and must not break
|
|
79
|
+
// delegation either — the operator meant *something*, and the dependency-free executor is the safe read.
|
|
80
|
+
// The empty string lands here rather than with `undefined`, which keeps absent-versus-empty
|
|
81
|
+
// distinguishable exactly as `PI_GRANTS_GATED` does.
|
|
82
|
+
return {
|
|
83
|
+
kind: "process",
|
|
84
|
+
forced: false,
|
|
85
|
+
probed: probe !== null,
|
|
86
|
+
disclosure:
|
|
87
|
+
`captured subprocess — PI_GRANTS_HERDR is set to an unrecognised value and was ignored. ` +
|
|
88
|
+
`Use 1 (demand herdr panes), 0 (demand subprocesses), or unset it to probe.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (probe?.ok) {
|
|
93
|
+
return { kind: "herdr", forced: false, probed: true, disclosure: "herdr panes (probed — herdr is answering)" };
|
|
94
|
+
}
|
|
95
|
+
if (probe === null) {
|
|
96
|
+
// **The pre-probe seed, and it must not claim a probe happened.** `createGrantsSession` builds a choice
|
|
97
|
+
// synchronously with `probe: null` because S-5 forces the factory to run before any hook; `resolveExecutor`
|
|
98
|
+
// replaces it during `session_start`. This branch previously fell through to the one below and reported
|
|
99
|
+
// `probed: true` with the word "(probed)" in its disclosure — a fabricated observation, indistinguishable
|
|
100
|
+
// from a real negative probe.
|
|
101
|
+
//
|
|
102
|
+
// Unreachable in practice today (pi awaits the `session_start` emit before the first prompt, verified by a
|
|
103
|
+
// reviewer against real pi), which is exactly why it is worth making honest rather than leaving to be
|
|
104
|
+
// discovered: if a throw ever strands a session on this reading, the disclosure should say so.
|
|
105
|
+
return {
|
|
106
|
+
kind: "process",
|
|
107
|
+
forced: false,
|
|
108
|
+
probed: false,
|
|
109
|
+
disclosure: "not yet probed — settling at session start",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
kind: "process",
|
|
114
|
+
forced: false,
|
|
115
|
+
probed: true,
|
|
116
|
+
// Names the remedy on the same line as the state. The gap that produced ADR-0031 was not that the
|
|
117
|
+
// operator could not see which executor ran — it was that seeing it would not have told them what to do.
|
|
118
|
+
disclosure:
|
|
119
|
+
`captured subprocess (probed — no herdr answering${probe?.error ? `: ${probe.error}` : ""}). ` +
|
|
120
|
+
`Set PI_GRANTS_HERDR=1 to demand panes.`,
|
|
121
|
+
};
|
|
122
|
+
}
|