@gotgenes/pi-permission-system 26.3.0 → 27.0.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 +40 -0
- package/README.md +38 -2
- package/dist/public.d.ts +105 -21
- package/docs/configuration.md +3 -1
- package/docs/cross-extension-api.md +112 -62
- package/docs/guides/permission-frontmatter-for-subagent-extensions.md +7 -2
- package/docs/migration/0794-keyed-service-locator.md +91 -0
- package/docs/subagent-integration.md +98 -23
- package/package.json +1 -1
- package/src/authority/authorizer-registry.ts +50 -0
- package/src/authority/authorizer-selection.ts +33 -1
- package/src/authority/permission-forwarding.ts +25 -9
- package/src/authority/subagent-context.ts +4 -10
- package/src/handlers/before-agent-start.ts +5 -14
- package/src/handlers/index.ts +1 -0
- package/src/handlers/session-turn-prep.ts +58 -0
- package/src/index.ts +35 -10
- package/src/permission-events.ts +44 -11
- package/src/service-lifecycle.ts +76 -9
- package/src/service.ts +188 -16
- package/src/session-identity.ts +33 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Migration guide: the keyed service locator and the ready cadence
|
|
2
|
+
|
|
3
|
+
Starting with the release that closes #794, two things change for an extension that talks to pi-permission-system:
|
|
4
|
+
|
|
5
|
+
1. `getPermissionsService()` requires the session id of the node whose service you want.
|
|
6
|
+
2. `permissions:ready` fires at least once per session and may repeat, so a registration handler must be idempotent.
|
|
7
|
+
|
|
8
|
+
Both are **breaking changes**.
|
|
9
|
+
If your extension neither imports `@gotgenes/pi-permission-system` nor listens on `permissions:ready`, nothing here affects you.
|
|
10
|
+
|
|
11
|
+
## Why the accessor changed
|
|
12
|
+
|
|
13
|
+
One Pi process can host several **nodes** — a root session and each of its in-process subagent children are separate session runtimes, each with its own gates, registries, and authorizer chain.
|
|
14
|
+
A registration is read by the node it was made in, so "the permission service" was never a single thing to ask for.
|
|
15
|
+
The old zero-arg accessor answered with the **process root's** service, which is the wrong node in every node but the root: inside a subagent child it handed back the parent's service, so a chain link landed where the child's gates never read it, and a policy query answered against the parent's config.
|
|
16
|
+
|
|
17
|
+
That is the defect behind the duplicate-registration errors reported in #699, and the contract that replaces it is [ADR 0012](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0012-cross-node-extension-contract.md).
|
|
18
|
+
Each node now publishes its own service under its own session id, and you resolve the one you mean.
|
|
19
|
+
|
|
20
|
+
## What to change
|
|
21
|
+
|
|
22
|
+
| Before | After |
|
|
23
|
+
| -------------------------------------- | ------------------------------------------------------------------ |
|
|
24
|
+
| `getPermissionsService()` | `getPermissionsService(sessionId)` |
|
|
25
|
+
| — (no equivalent) | `getRootPermissionsService()` — the old behavior, still deprecated |
|
|
26
|
+
| `publishPermissionsService(service)` | `publishRootPermissionsService(service)` |
|
|
27
|
+
| `unpublishPermissionsService(service)` | `unpublishRootPermissionsService(service)` |
|
|
28
|
+
|
|
29
|
+
The session id arrives as a field on the `permissions:ready` payload.
|
|
30
|
+
Inside your own session handler, `ctx.sessionManager.getSessionId()` is the same value.
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
let dispose: (() => void) | undefined;
|
|
34
|
+
|
|
35
|
+
pi.events.on(PERMISSIONS_READY_CHANNEL, (event) => {
|
|
36
|
+
const { sessionId } = event as PermissionsReadyEvent;
|
|
37
|
+
// Idempotent: ready may repeat, so a second emission must be a no-op.
|
|
38
|
+
if (dispose || !sessionId) return;
|
|
39
|
+
void (async () => {
|
|
40
|
+
const { getPermissionsService } = await import(
|
|
41
|
+
"@gotgenes/pi-permission-system"
|
|
42
|
+
);
|
|
43
|
+
dispose = getPermissionsService(sessionId)?.registerAuthorizer(
|
|
44
|
+
"my-link",
|
|
45
|
+
authorize,
|
|
46
|
+
);
|
|
47
|
+
})();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
pi.on("session_shutdown", () => {
|
|
51
|
+
dispose?.();
|
|
52
|
+
dispose = undefined;
|
|
53
|
+
});
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That handler is the whole registration.
|
|
57
|
+
A second attempt from your own `session_start` — the dual-path workaround that load-order ambiguity used to require — is no longer needed and should be deleted.
|
|
58
|
+
|
|
59
|
+
## The session id is required, not optional
|
|
60
|
+
|
|
61
|
+
`getPermissionsService` takes `sessionId` as a required argument rather than an optional one, and there is no zero-arg overload.
|
|
62
|
+
`PermissionsReadyEvent.sessionId` is `string | null`, and any shape where a `null` could reach the locator and fall through to the process root's slot would restore exactly the wrong-node bug the contract removes.
|
|
63
|
+
|
|
64
|
+
A TypeScript consumer gets a compile error.
|
|
65
|
+
A JavaScript consumer, or one compiled against an earlier major, still reaches the function with no argument: that call returns `undefined` — never another node's service — and emits a once-guarded Node warning:
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
(node:12345) [PI_PERMISSION_SYSTEM_WARN0001] Warning: getPermissionsService() was called without a session id and answered undefined. …
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Watch for it after upgrading.
|
|
72
|
+
A consumer that guards with `if (!service) return;` treats the `undefined` as "the permission system is not loaded" and silently registers nothing, so the warning is the only evidence that a link stopped being consulted.
|
|
73
|
+
It is deliberately not a `DeprecationWarning`, so `--no-deprecation` does not silence it.
|
|
74
|
+
|
|
75
|
+
## The ready cadence
|
|
76
|
+
|
|
77
|
+
`permissions:ready` used to be described as firing once, at each node's `session_start`.
|
|
78
|
+
It now fires **at least once per session, and may repeat**: each node emits it at `session_start` after publishing, and again at its first `before_agent_start` — which runs after every extension's `session_start` and before any tool call, hence before any ask.
|
|
79
|
+
|
|
80
|
+
The second emission is what makes the ready handler a sufficient registration site regardless of extension load order.
|
|
81
|
+
The cost is that a handler which registers unconditionally now hits `registerAuthorizer`'s duplicate-name throw on every session rather than only on a user-initiated `/reload`.
|
|
82
|
+
Guard it with a stored dispose handle, as shown above, and release the handle on `session_shutdown`.
|
|
83
|
+
|
|
84
|
+
`registerToolInputFormatter` and `registerToolAccessExtractor` throw on a duplicate name for the same reason and want the same guard.
|
|
85
|
+
|
|
86
|
+
## What has not changed
|
|
87
|
+
|
|
88
|
+
- The `PermissionsService` interface — the five methods and their signatures are untouched.
|
|
89
|
+
- The `permissions:ready`, `permissions:ui_prompt`, and `permissions:decision` payload shapes.
|
|
90
|
+
- The process-root slot itself: a node that is not an in-process subagent child still publishes to it, so `getRootPermissionsService()` answers exactly as the old zero-arg accessor did.
|
|
91
|
+
It remains deprecated (`PI_PERMISSION_SYSTEM_DEP0001`), and its removal is deferred to a later major.
|
|
@@ -1,26 +1,94 @@
|
|
|
1
1
|
# Subagent Integration
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## The subagent adapter convention
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
This is the one supported API between a subagent implementation and this package, and this section is its canonical specification ([ADR 0012] decision 5).
|
|
6
|
+
|
|
7
|
+
An implementation's entire obligation is the **announcement**.
|
|
8
|
+
Everything that follows from it is this package's job, on both ends.
|
|
9
|
+
|
|
10
|
+
### In-process implementations
|
|
11
|
+
|
|
12
|
+
An implementation that creates child sessions inside its own process (via `createAgentSession()`) emits two one-way broadcasts on `pi.events`:
|
|
13
|
+
|
|
14
|
+
| Channel | Payload | When |
|
|
15
|
+
| --------------------------------- | --------------------------------- | ------------------------------------------------------------------------- |
|
|
16
|
+
| `subagents:child:session-created` | `{ sessionId, parentSessionId? }` | After the child session is created, immediately before `bindExtensions()` |
|
|
17
|
+
| `subagents:child:disposed` | `{ sessionId }` | In the run's `finally`, on success and on error alike |
|
|
18
|
+
|
|
19
|
+
The pre-bind ordering of `session-created` is **contract, not an implementation detail**.
|
|
20
|
+
Emit it synchronously, on the same call stack, before `bindExtensions()`: this package's subscriber registers the child synchronously, and the registration must land before binding proceeds so the child's own instance can detect itself the moment it loads.
|
|
21
|
+
An implementation that awaits between creating the session and emitting, or that emits after binding, breaks detection for every child it spawns.
|
|
22
|
+
|
|
23
|
+
Both are fire-and-forget broadcasts.
|
|
24
|
+
Nothing is returned, nothing is awaited, and no reply travels back over the bus.
|
|
25
|
+
|
|
26
|
+
### Out-of-process implementations
|
|
27
|
+
|
|
28
|
+
An implementation that spawns a child as its own `pi` process sets one environment variable at spawn:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
PI_SUBAGENT_PARENT_SESSION=<parent-session-id>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
That is the whole obligation.
|
|
35
|
+
The variable identifies the session the child forwards its asks to, and naming a parent session is itself sufficient to mark the process as a child — a separate "I am a subagent" marker is neither required nor expected.
|
|
36
|
+
|
|
37
|
+
Earlier per-extension variables are grandfathered for compatibility: the markers `PI_IS_SUBAGENT`, `PI_SUBAGENT_CHILD`, `PI_SUBAGENT_NAME` and their siblings still register as child hints, and `PI_AGENT_ROUTER_PARENT_SESSION_ID` is still honored as a parent-session source, checked ahead of the convention name.
|
|
38
|
+
New implementations use `PI_SUBAGENT_PARENT_SESSION` only.
|
|
39
|
+
|
|
40
|
+
### What an implementation does not owe
|
|
41
|
+
|
|
42
|
+
None of the following is an implementation's responsibility, on either process shape:
|
|
43
|
+
|
|
44
|
+
- Detecting that a session is a child.
|
|
45
|
+
- Selecting the authority that answers an ask, or deciding whether a node adjudicates locally or relays.
|
|
46
|
+
- Forwarding an ask, fixing its facts, or transporting the answer back.
|
|
47
|
+
- Publishing or reading the serving heartbeat, or fast-failing a request whose target is not draining its inbox.
|
|
48
|
+
- Resolving per-agent `permission:` frontmatter, or choosing a session-approval grant scope.
|
|
49
|
+
|
|
50
|
+
An implementation never imports this package, never resolves its service, and never manages permissions.
|
|
51
|
+
The dependency arrow points one way: this package subscribes to the announcement, and the implementation does not know this package exists ([ADR-0002]).
|
|
52
|
+
|
|
53
|
+
## Loading asymmetry
|
|
54
|
+
|
|
55
|
+
Subagent implementations may load arbitrary extension sets into children — `@gotgenes/pi-subagents` offers `excludedExtensionPackages`, and others differ — so this package assumes no symmetry between a parent's extensions and its children's ([ADR 0012] decision 6).
|
|
56
|
+
|
|
57
|
+
Three statements hold:
|
|
58
|
+
|
|
59
|
+
1. **A permission extension riding into a child is harmless by construction.**
|
|
60
|
+
Access extractors and preview formatters land in the child's own registries, where the child's own gates read them, which is where they are needed.
|
|
61
|
+
A chain link registered on a relaying node is accepted, returns a working disposer, and is recorded as `authorizer_link_vacant` rather than refused.
|
|
62
|
+
Nothing throws and nothing warns per child start.
|
|
63
|
+
2. **Excluding an extension from children is an optimization, never a correctness requirement.**
|
|
64
|
+
Excluding a link-only extension saves load time; the adjudicating node's own instance still judges every descendant ask.
|
|
65
|
+
3. **Excluding a provider of access extractors can weaken a child's own gates.**
|
|
66
|
+
This is the one real hazard, and it is narrower than it sounds: excluding a package also keeps that package's tools out of children, so a package that supplies both a tool and that tool's extractor takes both away and leaves no gap.
|
|
67
|
+
A gap needs the tool and its extractor to come from *different* packages, with only the extractor's package excluded.
|
|
68
|
+
|
|
69
|
+
The full condition, with a worked example, is documented where the setting lives: [Excluding package extensions from children](https://github.com/gotgenes/pi-packages/blob/main/packages/pi-subagents/docs/configuration.md#excluding-package-extensions-from-children).
|
|
70
|
+
|
|
71
|
+
## What this package does on both ends
|
|
72
|
+
|
|
73
|
+
The announcement is all an implementation provides; this section is what it buys.
|
|
74
|
+
|
|
75
|
+
On the announcing side, this package subscribes to the child lifecycle (`src/authority/subagent-lifecycle-events.ts`) and registers every in-process child session in the `SubagentSessionRegistry` on `subagents:child:session-created`, unregistering it on `subagents:child:disposed`.
|
|
76
|
+
Because the event bus dispatches synchronously, that registration completes before `bindExtensions()` proceeds.
|
|
9
77
|
|
|
10
78
|
The `SubagentSessionRegistry` is backed by a process-global singleton (`globalThis` + `Symbol.for()`), accessed via `getSubagentSessionRegistry()` in `src/authority/subagent-registry.ts`.
|
|
11
79
|
This is necessary because each session's `ResourceLoader` creates its own `pi.events` bus: the parent emits `subagents:child:session-created` on the parent's bus, and only the parent's permission-system instance receives it.
|
|
12
80
|
The child's jiti instance runs on a separate bus and never receives the event — but because both instances call `getSubagentSessionRegistry()`, they share the same store, so the parent's registration is visible to the child.
|
|
13
81
|
|
|
14
|
-
|
|
82
|
+
What the announcement enables:
|
|
15
83
|
|
|
16
|
-
1. **Deterministic child detection** — `isSubagentExecutionContext()` hits the process-global registry on the first check,
|
|
84
|
+
1. **Deterministic child detection** — `isSubagentExecutionContext()` hits the process-global registry on the first check for an in-process child, and reads the environment for one spawned as its own process, with a session-directory heuristic behind both.
|
|
17
85
|
2. **Per-agent policy enforcement** - the permission system's `before_agent_start` handler resolves the agent name from the `<active_agent>` system-prompt tag and applies per-agent `permission:` frontmatter overrides.
|
|
18
86
|
3. **`ask`-state forwarding** - when a child triggers an `ask` permission, the request forwards to the parent session's UI through the existing polling mechanism.
|
|
19
87
|
The parent approves or denies, and the child resumes.
|
|
20
88
|
When the parent approves "for this session," it chooses a scope: **this subagent only** (the least-privilege default) records the grant on the requesting child, while **the whole session** records it on the serving parent so the parent and all its subagents resolve it without re-prompting.
|
|
21
89
|
|
|
22
90
|
No configuration is required - the integration is automatic when both extensions are installed.
|
|
23
|
-
When `@gotgenes/pi-permission-system` is not installed,
|
|
91
|
+
When `@gotgenes/pi-permission-system` is not installed, an implementation emits its lifecycle events with no subscriber - a harmless no-op.
|
|
24
92
|
|
|
25
93
|
## Permission Forwarding
|
|
26
94
|
|
|
@@ -92,6 +160,27 @@ Nothing needs to be edited, and in-process children are unaffected: parent and c
|
|
|
92
160
|
|
|
93
161
|
---
|
|
94
162
|
|
|
163
|
+
## Conformance of known implementations
|
|
164
|
+
|
|
165
|
+
Conformance is a property of the announcement alone — whether an implementation emits the in-process events, or sets the out-of-process variable — not of the frontmatter vocabulary it offers for tool visibility.
|
|
166
|
+
|
|
167
|
+
| Extension | Shape | Adopts the convention | Visibility key |
|
|
168
|
+
| ----------------------------------------------------------------------------------- | ---------- | --------------------------------- | ---------------------------------- |
|
|
169
|
+
| [@gotgenes/pi-subagents](https://github.com/gotgenes/pi-subagents) | in-process | ✓ Emits both lifecycle events | `tools:` (allowlist) |
|
|
170
|
+
| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | in-process | ✗ Publishes no lifecycle event | `disallowed_tools:` (CSV denylist) |
|
|
171
|
+
| [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) | subprocess | ✗ Sets no parent-session variable | `tools:` (CSV allowlist) |
|
|
172
|
+
| [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents) | subprocess | ✗ Sets no parent-session variable | `deny-tools:` (CSV denylist) |
|
|
173
|
+
|
|
174
|
+
The two subprocess implementations set their own child-marker variables, so their children are detected, but neither names the parent session.
|
|
175
|
+
Without it there is nowhere to forward to, and an `ask` in one of those children is reported as approval being unavailable.
|
|
176
|
+
Adopting the convention is a one-line change at their spawn site.
|
|
177
|
+
|
|
178
|
+
The upstream `tintinweb/pi-subagents` (which `@gotgenes/pi-subagents` forks) publishes no `subagents:child:session-created` event, so its in-process children have neither deterministic detection nor `ask`-state forwarding.
|
|
179
|
+
|
|
180
|
+
See [guides/permission-frontmatter-for-subagent-extensions.md](guides/permission-frontmatter-for-subagent-extensions.md) for the companion convention on `permission:` frontmatter, which implementations document rather than implement.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
95
184
|
## Coexistence with Other Subagent Extensions
|
|
96
185
|
|
|
97
186
|
Subagent extensions implement their own tool restriction mechanisms.
|
|
@@ -111,21 +200,6 @@ These compose correctly with the permission system because the two operate at di
|
|
|
111
200
|
└─────────────────────────────────────────────────────┘
|
|
112
201
|
```
|
|
113
202
|
|
|
114
|
-
### Known Subagent Extensions
|
|
115
|
-
|
|
116
|
-
| Extension | Type | Permission integration | Frontmatter key |
|
|
117
|
-
| ----------------------------------------------------------------------------------- | ---------- | -------------------------------- | ---------------------------------- |
|
|
118
|
-
| [@gotgenes/pi-subagents](https://github.com/gotgenes/pi-subagents) | in-process | ✓ Native (registry + forwarding) | `disallowed_tools:` (CSV denylist) |
|
|
119
|
-
| [tintinweb/pi-subagents](https://github.com/tintinweb/pi-subagents) | in-process | ✗ No registration | `disallowed_tools:` (CSV denylist) |
|
|
120
|
-
| [nicobailon/pi-subagents](https://github.com/nicobailon/pi-subagents) | subprocess | ✗ Missing env vars | `tools:` (CSV allowlist) |
|
|
121
|
-
| [HazAT/pi-interactive-subagents](https://github.com/HazAT/pi-interactive-subagents) | subprocess | ✗ Missing env vars | `deny-tools:` (CSV denylist) |
|
|
122
|
-
|
|
123
|
-
Process-based subagent extensions (nicobailon, HazAT) spawn child processes but do not set the `PI_SUBAGENT_PARENT_SESSION` env var that the permission system needs for `ask`-state forwarding.
|
|
124
|
-
Without that env var, `ask` permissions in child processes are auto-denied.
|
|
125
|
-
See [guides/permission-frontmatter-for-subagent-extensions.md](guides/permission-frontmatter-for-subagent-extensions.md) for the convention that subagent extension authors should follow.
|
|
126
|
-
|
|
127
|
-
The upstream `tintinweb/pi-subagents` (which `@gotgenes/pi-subagents` forks) does not publish the `subagents:child:session-created` lifecycle event, so it lacks deterministic child detection and `ask`-state forwarding.
|
|
128
|
-
|
|
129
203
|
### Interaction Rules
|
|
130
204
|
|
|
131
205
|
1. **Hidden tool → permission system never sees it.**
|
|
@@ -158,4 +232,5 @@ permission:
|
|
|
158
232
|
|
|
159
233
|
In this example the subagent extension restricts visibility to `bash` and `read`, and the permission system then gates every `bash` call with an `ask` prompt - both rules apply independently.
|
|
160
234
|
|
|
235
|
+
[ADR 0012]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/decisions/0012-cross-node-extension-contract.md
|
|
161
236
|
[ADR-0002]: https://github.com/gotgenes/pi-packages/blob/main/packages/pi-subagents/docs/decisions/0002-extensions-on-a-minimal-core.md
|
package/package.json
CHANGED
|
@@ -10,9 +10,16 @@
|
|
|
10
10
|
* operator names it in the `authorizerChain` config (the opt-in activation
|
|
11
11
|
* model). `AuthorizerSelection` owns that config-order resolution; this registry
|
|
12
12
|
* is storage only.
|
|
13
|
+
*
|
|
14
|
+
* A node that relays its asks runs no chain at all (ADR 0007 §7), so a link
|
|
15
|
+
* registered there is accepted and never consulted — the vacant link cell.
|
|
16
|
+
* {@link ObservedAuthorizerRegistrar} records that fact rather than refusing
|
|
17
|
+
* the registration (ADR 0012 decision 4).
|
|
13
18
|
*/
|
|
14
19
|
|
|
20
|
+
import type { ReviewLogger } from "#src/session-logger";
|
|
15
21
|
import type { Authorizer } from "./authorizer";
|
|
22
|
+
import type { AdjudicationRole } from "./authorizer-selection";
|
|
16
23
|
|
|
17
24
|
/**
|
|
18
25
|
* Read-only lookup used by chain composition (ISP — exposes only the read side,
|
|
@@ -67,3 +74,46 @@ export class AuthorizerRegistry
|
|
|
67
74
|
return this.links.get(name);
|
|
68
75
|
}
|
|
69
76
|
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The registrar a sibling extension reaches through `registerAuthorizer`:
|
|
80
|
+
* accepts every registration, and records the ones this node will never
|
|
81
|
+
* consult (ADR 0012 decision 4 — accept and observe).
|
|
82
|
+
*
|
|
83
|
+
* Registering everywhere is the correct default for a link author: the
|
|
84
|
+
* architecture consults a link where adjudication happens, so a link needs no
|
|
85
|
+
* placement ceremony and cannot be registered "in the wrong node". On a
|
|
86
|
+
* relaying node that makes the registration vacant by the system's own routing
|
|
87
|
+
* decision, not by author error — so it is honored (a working disposer comes
|
|
88
|
+
* back) and written to the review log beside the per-ask
|
|
89
|
+
* `authorizer_chain_delegated`, where an operator already looks to find out
|
|
90
|
+
* where adjudication went.
|
|
91
|
+
*
|
|
92
|
+
* A decorator rather than a branch inside {@link AuthorizerRegistry}: storage
|
|
93
|
+
* stays storage, and the chain's own lookup keeps reading the undecorated
|
|
94
|
+
* registry.
|
|
95
|
+
*/
|
|
96
|
+
export class ObservedAuthorizerRegistrar implements AuthorizerRegistrar {
|
|
97
|
+
constructor(
|
|
98
|
+
private readonly registrar: AuthorizerRegistrar,
|
|
99
|
+
private readonly role: AdjudicationRole,
|
|
100
|
+
private readonly logger: ReviewLogger,
|
|
101
|
+
) {}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Register `name` into the underlying registry and return its disposer.
|
|
105
|
+
*
|
|
106
|
+
* The role is read per registration, not captured at construction: this
|
|
107
|
+
* registrar outlives a session (the composition root owns it) while the
|
|
108
|
+
* node's selected authority is per-activation. A duplicate registration
|
|
109
|
+
* still throws, and records nothing — under the cross-node contract that is
|
|
110
|
+
* a genuine author bug, not a vacancy.
|
|
111
|
+
*/
|
|
112
|
+
register(name: string, authorize: Authorizer["authorize"]): () => void {
|
|
113
|
+
const dispose = this.registrar.register(name, authorize);
|
|
114
|
+
if (!this.role.adjudicatesLocally()) {
|
|
115
|
+
this.logger.review("authorizer_link_vacant", { name });
|
|
116
|
+
}
|
|
117
|
+
return dispose;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -39,6 +39,23 @@ export interface AskEscalator {
|
|
|
39
39
|
escalate(details: PromptPermissionDetails): Promise<PermissionPromptDecision>;
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* The node's chain role, as a fact a collaborator can read: does this node's
|
|
44
|
+
* authorizer chain run, or does it relay its asks to a serving node
|
|
45
|
+
* (ADR 0007 §7)?
|
|
46
|
+
*
|
|
47
|
+
* Consumed by the service lifecycle (which broadcasts it on `permissions:ready`
|
|
48
|
+
* so a sibling extension learns it without knowing what a subagent is) and by
|
|
49
|
+
* the registration observer (which records a link registered where no chain
|
|
50
|
+
* runs). Both depend on this single-method view rather than the selection
|
|
51
|
+
* itself, and neither may re-derive the role from `detection.isSubagent(ctx)`:
|
|
52
|
+
* `selectAuthorizer` tests `hasUI` first, so a subagent with its own UI
|
|
53
|
+
* adjudicates locally.
|
|
54
|
+
*/
|
|
55
|
+
export interface AdjudicationRole {
|
|
56
|
+
adjudicatesLocally(): boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
/**
|
|
43
60
|
* Context-owning selection root for the Authorizer spine.
|
|
44
61
|
*
|
|
@@ -52,7 +69,7 @@ export interface AskEscalator {
|
|
|
52
69
|
* predicate survives (#556 dissolved `canConfirm()`).
|
|
53
70
|
*/
|
|
54
71
|
export class AuthorizerSelection
|
|
55
|
-
implements AskEscalator, AuthorizerSelectionLifecycle
|
|
72
|
+
implements AskEscalator, AuthorizerSelectionLifecycle, AdjudicationRole
|
|
56
73
|
{
|
|
57
74
|
private authority: SelectedAuthority | null = null;
|
|
58
75
|
|
|
@@ -147,6 +164,21 @@ export class AuthorizerSelection
|
|
|
147
164
|
return links;
|
|
148
165
|
}
|
|
149
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Whether this node adjudicates its own asks. Implements
|
|
169
|
+
* {@link AdjudicationRole}.
|
|
170
|
+
*
|
|
171
|
+
* Reports `true` with no selection stored — before activation, or after
|
|
172
|
+
* deactivation. Production never reads it there (`activate` runs inside
|
|
173
|
+
* `PermissionSession.resetForNewSession`, ahead of every consumer), and
|
|
174
|
+
* "this node adjudicates" is the fail-soft answer: it tells a sibling to
|
|
175
|
+
* register, which a relaying node accepts and records rather than refusing
|
|
176
|
+
* (ADR 0012 decision 4).
|
|
177
|
+
*/
|
|
178
|
+
adjudicatesLocally(): boolean {
|
|
179
|
+
return this.authority?.adjudicatesLocally ?? true;
|
|
180
|
+
}
|
|
181
|
+
|
|
150
182
|
/** Clear the stored selection. */
|
|
151
183
|
deactivate(): void {
|
|
152
184
|
this.authority = null;
|
|
@@ -18,7 +18,17 @@ export const PERMISSION_FORWARDING_TIMEOUT_MS = 10 * 60 * 1000;
|
|
|
18
18
|
*/
|
|
19
19
|
export const PERMISSION_FORWARDING_SERVING_GRACE_MS =
|
|
20
20
|
8 * PERMISSION_FORWARDING_POLL_INTERVAL_MS;
|
|
21
|
-
|
|
21
|
+
/** Ordered list of env var names to check for the parent session ID. First match wins. */
|
|
22
|
+
export const SUBAGENT_PARENT_SESSION_ENV_CANDIDATES: readonly string[] = [
|
|
23
|
+
// pi-agent-router (original)
|
|
24
|
+
"PI_AGENT_ROUTER_PARENT_SESSION_ID",
|
|
25
|
+
// Shared convention for CLI-based subagent extensions
|
|
26
|
+
// (nicobailon/pi-subagents, HazAT/pi-interactive-subagents, etc.)
|
|
27
|
+
"PI_SUBAGENT_PARENT_SESSION",
|
|
28
|
+
] as const;
|
|
29
|
+
|
|
30
|
+
/** Per-extension markers set by known process-based subagent extensions. */
|
|
31
|
+
const THIRD_PARTY_SUBAGENT_ENV_HINTS = [
|
|
22
32
|
// pi-agent-router (original)
|
|
23
33
|
"PI_IS_SUBAGENT",
|
|
24
34
|
"PI_SUBAGENT_SESSION_ID",
|
|
@@ -34,14 +44,20 @@ export const SUBAGENT_ENV_HINT_KEYS = [
|
|
|
34
44
|
"PI_SUBAGENT_SESSION",
|
|
35
45
|
"PI_SUBAGENT_ACTIVITY_FILE",
|
|
36
46
|
] as const;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Env vars whose presence marks the current process as a subagent child.
|
|
50
|
+
*
|
|
51
|
+
* A process that names a parent session is a child by definition, so every
|
|
52
|
+
* parent-session candidate is a detection hint too. That is what makes the
|
|
53
|
+
* subagent adapter convention's single out-of-process obligation — set
|
|
54
|
+
* `PI_SUBAGENT_PARENT_SESSION` — sufficient on its own: an implementation owes
|
|
55
|
+
* the announcement and nothing else, and detection is this package's job.
|
|
56
|
+
*/
|
|
57
|
+
export const SUBAGENT_ENV_HINT_KEYS: readonly string[] = [
|
|
58
|
+
...THIRD_PARTY_SUBAGENT_ENV_HINTS,
|
|
59
|
+
...SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
|
|
60
|
+
];
|
|
45
61
|
|
|
46
62
|
/** @deprecated Use SUBAGENT_PARENT_SESSION_ENV_CANDIDATES */
|
|
47
63
|
export const SUBAGENT_PARENT_SESSION_ENV_KEY =
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SUBAGENT_ENV_HINT_KEYS } from "#src/authority/permission-forwarding";
|
|
2
2
|
import type { SubagentSessionRegistry } from "#src/authority/subagent-registry";
|
|
3
3
|
import type { PathFlavor } from "#src/path/path-flavor";
|
|
4
|
+
import { readSessionId } from "#src/session-identity";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Narrow context for subagent detection — the only session-manager readers
|
|
@@ -35,16 +36,9 @@ export function isRegisteredSubagentChild(
|
|
|
35
36
|
ctx: SubagentDetectionContext,
|
|
36
37
|
registry: SubagentSessionRegistry,
|
|
37
38
|
): boolean {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return false;
|
|
42
|
-
}
|
|
43
|
-
return registry.has(sessionId);
|
|
44
|
-
} catch {
|
|
45
|
-
// getSessionId() unavailable — treat as not-a-registered-child.
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
39
|
+
const sessionId = readSessionId(ctx);
|
|
40
|
+
// An unreachable session id names no registration — treat as not-a-child.
|
|
41
|
+
return sessionId !== null && registry.has(sessionId);
|
|
48
42
|
}
|
|
49
43
|
|
|
50
44
|
export function isSubagentExecutionContext(
|
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
BeforeAgentStartEventResult,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { TurnPreparation } from "#src/handlers/session-turn-prep";
|
|
5
6
|
import type { PermissionResolver } from "#src/permission-resolver";
|
|
6
7
|
import type { PermissionSession } from "#src/permission-session";
|
|
7
8
|
import { resolveSkillPromptEntries } from "#src/skill-prompt-sanitizer";
|
|
@@ -37,19 +38,18 @@ export function shouldExposeTool(
|
|
|
37
38
|
* than letting Pi reset to its skill-unfiltered base prompt on a cache hit.
|
|
38
39
|
*
|
|
39
40
|
* Constructor deps:
|
|
41
|
+
* - `turnPrep` — brings the node up to date for the turn before anything reads
|
|
42
|
+
* session state
|
|
40
43
|
* - `session` — encapsulates all mutable session state and lifecycle operations
|
|
41
44
|
* - `resolver` — owns permission-query surface: `getToolPermission`, skill check
|
|
42
45
|
* - `toolRegistry` — Pi tool API subset (getActive + setActive)
|
|
43
|
-
* - `warmParser` — warms the tree-sitter parser so the synchronous advisory
|
|
44
|
-
* bash path can decompose at gate parity; `before_agent_start` precedes any
|
|
45
|
-
* tool call, so triggering it here closes the pre-warm window (#309)
|
|
46
46
|
*/
|
|
47
47
|
export class AgentPrepHandler {
|
|
48
48
|
constructor(
|
|
49
|
+
private readonly turnPrep: TurnPreparation,
|
|
49
50
|
private readonly session: PermissionSession,
|
|
50
51
|
private readonly resolver: PermissionResolver,
|
|
51
52
|
private readonly toolRegistry: ToolRegistry,
|
|
52
|
-
private readonly warmParser: () => void,
|
|
53
53
|
) {}
|
|
54
54
|
|
|
55
55
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
@@ -57,16 +57,7 @@ export class AgentPrepHandler {
|
|
|
57
57
|
event: BeforeAgentStartPayload,
|
|
58
58
|
ctx: ExtensionContext,
|
|
59
59
|
): Promise<BeforeAgentStartEventResult> {
|
|
60
|
-
|
|
61
|
-
// delays agent start. A bash advisory query before it completes falls back
|
|
62
|
-
// to whole-string matching.
|
|
63
|
-
this.warmParser();
|
|
64
|
-
this.session.activate(ctx);
|
|
65
|
-
// Gate the mid-session runtime-config refresh on project trust too, so an
|
|
66
|
-
// untrusted project cannot slip its runtime config (e.g. `yoloMode`) in
|
|
67
|
-
// right before agent start after session_start withheld it (#644). The
|
|
68
|
-
// session_start handler already warned; do not re-warn on every start.
|
|
69
|
-
this.session.refreshConfig(ctx, ctx.isProjectTrusted());
|
|
60
|
+
this.turnPrep.prepare(ctx);
|
|
70
61
|
|
|
71
62
|
const agentName = this.session.resolveAgentName(ctx, event.systemPrompt);
|
|
72
63
|
const activeTools = this.toolRegistry.getActive();
|
package/src/handlers/index.ts
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ReadyAnnouncer } from "#src/service-lifecycle";
|
|
3
|
+
|
|
4
|
+
/** The session surface the turn-prep routine drives. */
|
|
5
|
+
export interface TurnPrepSession {
|
|
6
|
+
activate(ctx: ExtensionContext): void;
|
|
7
|
+
refreshConfig(
|
|
8
|
+
ctx: ExtensionContext | undefined,
|
|
9
|
+
projectTrusted: boolean,
|
|
10
|
+
): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** What a `before_agent_start` handler asks for; `SessionTurnPrep` provides it. */
|
|
14
|
+
export interface TurnPreparation {
|
|
15
|
+
prepare(ctx: ExtensionContext): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Brings this node up to date for the turn about to start.
|
|
20
|
+
*
|
|
21
|
+
* Everything that must be true before the node answers a permission question
|
|
22
|
+
* this turn lives here, so the `before_agent_start` handler is left with the
|
|
23
|
+
* one job its name describes: filtering tools and sanitizing the prompt.
|
|
24
|
+
*
|
|
25
|
+
* Constructor deps:
|
|
26
|
+
* - `session` — activated with the turn's context, then refreshed from disk
|
|
27
|
+
* - `warmParser` — warms the tree-sitter parser so the synchronous advisory
|
|
28
|
+
* bash path can decompose at gate parity; `before_agent_start` precedes any
|
|
29
|
+
* tool call, so triggering it here closes the pre-warm window (#309)
|
|
30
|
+
* - `readyAnnouncer` — re-announces `permissions:ready` once per session
|
|
31
|
+
* (ADR 0012 decision 3); `before_agent_start` runs after every extension's
|
|
32
|
+
* `session_start` and before any ask, so a consumer that registers from the
|
|
33
|
+
* ready handler alone is heard in time
|
|
34
|
+
*/
|
|
35
|
+
export class SessionTurnPrep implements TurnPreparation {
|
|
36
|
+
constructor(
|
|
37
|
+
private readonly session: TurnPrepSession,
|
|
38
|
+
private readonly warmParser: () => void,
|
|
39
|
+
private readonly readyAnnouncer: ReadyAnnouncer,
|
|
40
|
+
) {}
|
|
41
|
+
|
|
42
|
+
prepare(ctx: ExtensionContext): void {
|
|
43
|
+
// Fire-and-forget: warming is idempotent and best-effort, so it never
|
|
44
|
+
// delays agent start. A bash advisory query before it completes falls back
|
|
45
|
+
// to whole-string matching.
|
|
46
|
+
this.warmParser();
|
|
47
|
+
this.session.activate(ctx);
|
|
48
|
+
// Gate the mid-session runtime-config refresh on project trust too, so an
|
|
49
|
+
// untrusted project cannot slip its runtime config (e.g. `yoloMode`) in
|
|
50
|
+
// right before agent start after session_start withheld it (#644). The
|
|
51
|
+
// session_start handler already warned; do not re-warn on every start.
|
|
52
|
+
this.session.refreshConfig(ctx, ctx.isProjectTrusted());
|
|
53
|
+
// Announce last: the node is up to date for the turn, so a consumer that
|
|
54
|
+
// resolves the service in its ready handler queries current policy. The
|
|
55
|
+
// once-per-session guard lives in the announcer, not here.
|
|
56
|
+
this.readyAnnouncer.announceReady(ctx);
|
|
57
|
+
}
|
|
58
|
+
}
|