@gotgenes/pi-permission-system 26.3.1 → 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 CHANGED
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [27.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.3.1...pi-permission-system-v27.0.0) (2026-08-21)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * **pi-permission-system:** `permissions:ready` now fires at least once per session and may repeat. The latch added in #787 re-emits it at each node's first `before_agent_start`, so a consumer that registers unconditionally on every emission hits a duplicate-registration throw on every session instead of the rare user-initiated `/reload` it previously hit. Guard the ready handler with a stored dispose handle so it registers once per session, and release it on `session_shutdown`.
14
+
15
+ ### Features
16
+
17
+ * **pi-permission-system:** deprecate the zero-arg service accessor ([#699](https://github.com/gotgenes/pi-packages/issues/699)) ([511c87f](https://github.com/gotgenes/pi-packages/commit/511c87f46e1ec85137145442050d53ae41eb8cd1))
18
+ * **pi-permission-system:** publish each node's service under its own session id ([#699](https://github.com/gotgenes/pi-packages/issues/699)) ([d8ce4a8](https://github.com/gotgenes/pi-packages/commit/d8ce4a85ac381b6123b4d1f461d59073953a22b6))
19
+ * **pi-permission-system:** re-emit permissions:ready at the first before_agent_start ([#787](https://github.com/gotgenes/pi-packages/issues/787)) ([405e904](https://github.com/gotgenes/pi-packages/commit/405e904e527645776cab8162293682a71fa5ec65))
20
+ * **pi-permission-system:** reclaim getPermissionsService for the keyed locator ([214b30a](https://github.com/gotgenes/pi-packages/commit/214b30abf3a2ba3dc2f8f9bc3c56932a868c32dd))
21
+ * **pi-permission-system:** record a vacant link cell on a relaying node ([#699](https://github.com/gotgenes/pi-packages/issues/699)) ([f5f08d2](https://github.com/gotgenes/pi-packages/commit/f5f08d2c159504f508b88f9e163be2f9049d558d))
22
+ * **pi-permission-system:** warn when the keyed locator gets no session id ([29824aa](https://github.com/gotgenes/pi-packages/commit/29824aae61b1a4f565b5c456d9f3440d77304be5)), closes [#794](https://github.com/gotgenes/pi-packages/issues/794)
23
+
24
+
25
+ ### Bug Fixes
26
+
27
+ * **pi-permission-system:** detect a subagent from its parent-session env var ([bd698da](https://github.com/gotgenes/pi-packages/commit/bd698da14a7d44dfa8dc06be2342a47b8337324e)), closes [#789](https://github.com/gotgenes/pi-packages/issues/789)
28
+
29
+
30
+ ### Documentation
31
+
32
+ * **pi-permission-system:** cite the adapter convention instead of restating it ([d9ba637](https://github.com/gotgenes/pi-packages/commit/d9ba6375b29e3e25e53d9324676e7c5220c2bad7)), closes [#789](https://github.com/gotgenes/pi-packages/issues/789)
33
+ * **pi-permission-system:** document session-keyed service publication ([#699](https://github.com/gotgenes/pi-packages/issues/699)) ([8ed137c](https://github.com/gotgenes/pi-packages/commit/8ed137c662a22087edc3623151938c7ae0ea9a86))
34
+ * **pi-permission-system:** document the ready latch and its idempotency requirement ([#787](https://github.com/gotgenes/pi-packages/issues/787)) ([bc31193](https://github.com/gotgenes/pi-packages/commit/bc31193aafa8c6ef1356cc2e096e643f6357c912))
35
+ * **pi-permission-system:** document the reclaimed locator and the ready cadence ([ca585b4](https://github.com/gotgenes/pi-packages/commit/ca585b4fba48a95ddd4dbe51106c02486997a400)), closes [#794](https://github.com/gotgenes/pi-packages/issues/794)
36
+ * **pi-permission-system:** make subagent-integration the adapter convention's canonical spec ([07f9d2b](https://github.com/gotgenes/pi-packages/commit/07f9d2beffb66207aa3b01985b1a28312d431da3)), closes [#789](https://github.com/gotgenes/pi-packages/issues/789)
37
+
8
38
  ## [26.3.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v26.3.0...pi-permission-system-v26.3.1) (2026-08-19)
9
39
 
10
40
 
package/README.md CHANGED
@@ -115,7 +115,7 @@ Within a surface map like `bash` or `mcp`, **last matching rule wins** — put b
115
115
  The optional `shellTools` field records which non-`bash` tools carry shell semantics (e.g. an `exec_command` tool that replaces native `bash`), so they are gated at full parity with native `bash` — see [docs/configuration.md](docs/configuration.md#shelltools--gating-aliased-shell-tools).
116
116
 
117
117
  The optional `authorizerChain` field names registered case-by-case decision links (e.g. a light model judge) to consult when a request lands on `ask`, ahead of the interactive prompt.
118
- A downstream extension registers a link via `getPermissionsService().registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
118
+ A downstream extension registers a link via `getPermissionsService(sessionId).registerAuthorizer(name, authorize)`; it decides nothing until you name it here (opt-in), config order fixes the chain order, and the chain owner caps any link's `allow` on `external_directory`/`path` to keep it within your policy — see [docs/configuration.md](docs/configuration.md#authorizer-chain--case-by-case-decision-links).
119
119
  A subagent's ask is reviewed by the chain of the session serving it, one hop up, rather than inside the subagent — see the same section.
120
120
  [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge) is a first-party reference implementation of such a link — a deny-first reviewer that auto-denies mistyped out-of-directory paths.
121
121
 
@@ -179,7 +179,7 @@ Approve-and-steer, edit diffs, and risk explanations → a downstream package ov
179
179
  | [docs/configuration.md](docs/configuration.md) | Full policy reference, runtime knobs, per-agent overrides, recipes |
180
180
  | [docs/session-approvals.md](docs/session-approvals.md) | Session-scoped rules, pattern suggestions, bash arity table |
181
181
  | [docs/cross-extension-api.md](docs/cross-extension-api.md) | Cross-extension service accessor, event bus integration, prompt and decision broadcasts |
182
- | [docs/subagent-integration.md](docs/subagent-integration.md) | Permission forwarding, coexistence with subagent extensions |
182
+ | [docs/subagent-integration.md](docs/subagent-integration.md) | The subagent adapter convention, permission forwarding, coexistence with subagent extensions |
183
183
  | [docs/guides/permission-frontmatter-for-subagent-extensions.md](docs/guides/permission-frontmatter-for-subagent-extensions.md) | Convention guide for subagent extension authors |
184
184
  | [docs/opencode-compatibility.md](docs/opencode-compatibility.md) | OpenCode compatibility — shared concepts, divergences, porting guide |
185
185
  | [docs/troubleshooting.md](docs/troubleshooting.md) | Common issues, diagnostic logging, threat model |
@@ -188,6 +188,7 @@ Approve-and-steer, edit diffs, and risk explanations → a downstream package ov
188
188
  | [docs/migration/0644-project-trust-gating.md](docs/migration/0644-project-trust-gating.md) | Project-trust gating (breaking) — project config loads only after project trust |
189
189
  | [docs/migration/0745-prompt-payload-contracts.md](docs/migration/0745-prompt-payload-contracts.md) | Prompt payload contracts (breaking) — the forwarded wire, the `ui_prompt` broadcast, and the deprecated preview caps |
190
190
  | [docs/migration/0746-review-log-fields.md](docs/migration/0746-review-log-fields.md) | Review-log fields (breaking) — `message` replaced by request facts, and the `reviewLogFieldMaxWidth` bound |
191
+ | [docs/migration/0794-keyed-service-locator.md](docs/migration/0794-keyed-service-locator.md) | Keyed service locator (breaking) — `getPermissionsService(sessionId)`, and the repeating ready event |
191
192
 
192
193
  ## Development
193
194
 
package/dist/public.d.ts CHANGED
@@ -158,20 +158,48 @@ interface PromptAnnotation {
158
158
  * removed or renamed without a semver-major version bump.
159
159
  */
160
160
 
161
- /** Emitted at `session_start`, after the service is published. */
161
+ /**
162
+ * Emitted at `session_start` after the emitting node published its service, and
163
+ * again at that node's first `before_agent_start` (ADR 0012 decision 3).
164
+ *
165
+ * Fires at least once per session and may repeat, so a handler must be
166
+ * idempotent — registering on every emission hits the duplicate-registration
167
+ * throw.
168
+ */
162
169
  declare const PERMISSIONS_READY_CHANNEL = "permissions:ready";
163
170
  /** Emitted when a permission request is committed to the active UI prompt path. */
164
171
  declare const PERMISSIONS_UI_PROMPT_CHANNEL = "permissions:ui_prompt";
165
172
  /** Emitted after every permission gate resolution. */
166
173
  declare const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
167
174
  /**
168
- * Payload emitted on `permissions:ready`.
175
+ * Payload emitted on `permissions:ready`: plain facts about the node that
176
+ * emitted it (ADR 0012 decision 2).
169
177
  *
170
- * Intentionally empty: the channel is a readiness signal. There is no
171
- * `protocolVersion`the published types plus package semver define the
172
- * broadcast contract.
178
+ * The bus announces; the locator provides. The payload carries data a consumer
179
+ * can log, serialize, and replay never a live capability so the service
180
+ * itself is fetched with `getPermissionsService(sessionId)`.
181
+ *
182
+ * There is no `protocolVersion` — the published types plus package semver
183
+ * define the broadcast contract.
173
184
  */
174
- type PermissionsReadyEvent = Record<string, never>;
185
+ interface PermissionsReadyEvent {
186
+ /**
187
+ * The emitting node's session id: the key for
188
+ * `getPermissionsService`. `null` when the host exposed no session
189
+ * id, in which case this node published no keyed service.
190
+ */
191
+ sessionId: string | null;
192
+ /**
193
+ * Whether this node adjudicates its own asks (its authorizer chain runs) or
194
+ * relays them to a serving node, which runs *its* chain over the same facts
195
+ * (ADR 0007 §7).
196
+ *
197
+ * A registration needs no branch on this: extractors and formatters are read
198
+ * by every node's own gates, and a chain link registered where no chain runs
199
+ * is accepted and recorded rather than refused (ADR 0012 decision 4).
200
+ */
201
+ adjudicatesLocally: boolean;
202
+ }
175
203
  /**
176
204
  * Origin of a UI prompt.
177
205
  *
@@ -405,16 +433,26 @@ type ToolAccessExtractor = (input: Record<string, unknown>) => string | undefine
405
433
  type ToolInputFormatter = (input: Record<string, unknown>) => string | undefined;
406
434
 
407
435
  /**
408
- * Cross-extension service accessor backed by `Symbol.for()` on `globalThis`.
436
+ * Cross-extension service accessors backed by `Symbol.for()` on `globalThis`.
409
437
  *
410
438
  * `Symbol.for()` is process-global by spec, so it survives jiti's per-extension
411
439
  * module isolation (`moduleCache: false`). A consumer doing
412
- * `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but
413
- * `getPermissionsService()` reads from the same `globalThis` slot the provider
414
- * wrote to — enabling direct, synchronous, type-safe function calls.
440
+ * `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but the
441
+ * accessors here read from the same `globalThis` slots the provider wrote to —
442
+ * enabling direct, synchronous, type-safe function calls.
443
+ *
444
+ * There are two slots, because one process can host several **nodes** (one Pi
445
+ * session runtime each — a root session and its in-process subagent children
446
+ * all load their own instance of this extension):
447
+ *
448
+ * - A session-keyed map, written by every node under its own session id.
449
+ * `getPermissionsService(sessionId)` resolves the service whose
450
+ * registries that node's own gates and chain read (ADR 0012 decision 2).
451
+ * - A single legacy slot holding the process root's service, read by the
452
+ * deprecated `getRootPermissionsService()`.
415
453
  *
416
- * Best practice: call `getPermissionsService()` per use rather than caching the
417
- * reference — this ensures resilience across `/reload` and load-order edge cases.
454
+ * Best practice: resolve per use rather than caching the reference — this
455
+ * ensures resilience across `/reload` and load-order edge cases.
418
456
  */
419
457
 
420
458
  /**
@@ -466,7 +504,14 @@ interface PermissionQuery {
466
504
  getToolPermission(toolName: string, agentName?: string): PermissionState;
467
505
  }
468
506
  /**
469
- * Public interface exposed to other extensions via `getPermissionsService()`.
507
+ * Public interface exposed to other extensions via
508
+ * {@link getPermissionsService} (or the deprecated
509
+ * {@link getRootPermissionsService}).
510
+ *
511
+ * Each instance belongs to one node, and its three registration surfaces are
512
+ * read by that node alone: extractors and formatters by its own gates, chain
513
+ * links by its own chain. Resolve the service of the node whose behavior you
514
+ * mean to affect.
470
515
  *
471
516
  * `checkPermission` takes a surface + optional value + optional agent name,
472
517
  * and delegates to `PermissionManager.checkPermission()` with current session
@@ -532,20 +577,59 @@ interface PermissionsService extends PermissionQuery {
532
577
  registerAuthorizer(name: string, authorize: Authorizer["authorize"]): () => void;
533
578
  }
534
579
  /**
535
- * Store a `PermissionsService` on `globalThis` so other extensions can
536
- * retrieve it via `getPermissionsService()`.
580
+ * Store a `PermissionsService` in the legacy process-root slot, read by
581
+ * `getRootPermissionsService()`.
537
582
  *
538
583
  * Called at `session_start` by the top-level (parent) instance only — an
539
584
  * in-process subagent child skips publishing so it cannot clobber the parent's
540
585
  * service. Overwrites any previously published service, which keeps `/reload`
541
586
  * working: a reloaded parent re-publishes its fresh service.
542
587
  */
543
- declare function publishPermissionsService(service: PermissionsService): void;
588
+ declare function publishRootPermissionsService(service: PermissionsService): void;
589
+ /**
590
+ * Retrieve the process root's published `PermissionsService`, or `undefined`
591
+ * if the permission-system extension has not loaded (or has been unloaded).
592
+ *
593
+ * @deprecated Use {@link getPermissionsService} with the `sessionId`
594
+ * from the `permissions:ready` payload. This accessor answers "the process
595
+ * root's service", which is the wrong question in every node but the root.
596
+ * Removal is deferred to a future major (ADR 0012 decision 7).
597
+ */
598
+ declare function getRootPermissionsService(): PermissionsService | undefined;
599
+ /**
600
+ * Publish `service` as the service of the node whose session is `sessionId`
601
+ * (ADR 0012 decision 2 — node-locality).
602
+ *
603
+ * Every node publishes under its own key, including an in-process subagent
604
+ * child, so there is nothing to clobber: a child's sibling extension registers
605
+ * an extractor, formatter, or chain link into the registry the child's own
606
+ * gates and chain read.
607
+ */
608
+ declare function publishPermissionsService(sessionId: string, service: PermissionsService): void;
609
+ /**
610
+ * Retrieve the service belonging to the node whose session is `sessionId`, or
611
+ * `undefined` when that node has published none.
612
+ *
613
+ * This is the supported way to obtain a node's service, for registration and
614
+ * for policy queries alike. Take `sessionId` from the `permissions:ready`
615
+ * payload (or from `ctx.sessionManager.getSessionId()` inside your own session
616
+ * handler), and resolve per use rather than caching the reference.
617
+ *
618
+ * A caller the type checker cannot reach — JavaScript, or a consumer compiled
619
+ * against an earlier major — may still call this with no argument. That answers
620
+ * `undefined` rather than another node's service, and warns once so the missing
621
+ * registration is not silent.
622
+ */
623
+ declare function getPermissionsService(sessionId: string): PermissionsService | undefined;
544
624
  /**
545
- * Retrieve the published `PermissionsService`, or `undefined` if the
546
- * permission-system extension has not loaded (or has been unloaded).
625
+ * Remove the `sessionId` entry, but only when it still holds `service`
626
+ * (identity compare-and-delete, like {@link unpublishRootPermissionsService}).
627
+ *
628
+ * Scoping the delete to the publishing instance keeps a superseded `/reload`
629
+ * generation's late shutdown from wiping the new generation's freshly
630
+ * published service.
547
631
  */
548
- declare function getPermissionsService(): PermissionsService | undefined;
632
+ declare function unpublishPermissionsService(sessionId: string, service: PermissionsService): void;
549
633
  /**
550
634
  * Remove `service` from `globalThis`, but only when the current slot still
551
635
  * holds it (identity compare-and-delete).
@@ -559,7 +643,7 @@ declare function getPermissionsService(): PermissionsService | undefined;
559
643
  * - A superseded `/reload` generation no longer owns the slot, so its late
560
644
  * shutdown cannot wipe the new generation's freshly published service.
561
645
  */
562
- declare function unpublishPermissionsService(service: PermissionsService): void;
646
+ declare function unpublishRootPermissionsService(service: PermissionsService): void;
563
647
 
564
- export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService, publishPermissionsService, unpublishPermissionsService };
648
+ export { PERMISSIONS_DECISION_CHANNEL, PERMISSIONS_READY_CHANNEL, PERMISSIONS_UI_PROMPT_CHANNEL, getPermissionsService, getRootPermissionsService, publishPermissionsService, publishRootPermissionsService, unpublishPermissionsService, unpublishRootPermissionsService };
565
649
  export type { Authorizer, AuthorizerLog, AuthorizerVerdict, ForwardedPromptContext, PermissionCheckResult, PermissionDecisionEvent, PermissionQuery, PermissionState, PermissionUiPromptEvent, PermissionUiPromptSource, PermissionsReadyEvent, PermissionsService, PromptAnnotation, PromptEvidence, PromptPayload, PromptPayloadKind, PromptPermissionDetails, PromptRequestFacts, PromptRequester, ToolInputFormatter };
@@ -244,9 +244,11 @@ Three review-log records make the chain observable, all keyed by the ask's `requ
244
244
  | `authorizer_chain_resolved` | the links consulted on this ask, recorded before they run — a link that defers otherwise leaves no trace |
245
245
  | `authorizer_chain_delegated` | the ask came from a relaying subagent node; the named links were deliberately not run here |
246
246
  | `authorizer_chain_unregistered_link` | a configured name had no registered link — a real misconfiguration; the ask still reaches the terminal |
247
+ | `authorizer_link_vacant` | a link was registered on a relaying node, which runs no chain — accepted and recorded, never consulted |
247
248
 
248
- Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService().registerAuthorizer(name, authorize)`; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
249
+ Extension authors: register a link from a `permissions:ready` handler via `getPermissionsService(sessionId).registerAuthorizer(name, authorize)`, taking `sessionId` from that event's payload; the callback receives the ask details and a narrow, session-scoped `PermissionQuery` (`checkPermission` / `getToolPermission`) so it can consult the deterministic engine at gate parity.
249
250
  Registration returns a disposer, and only one link may hold a given name.
251
+ Register in every session without branching: a session that relays its asks accepts the link and records `authorizer_link_vacant` rather than refusing it.
250
252
  For a complete working example, see [`@gotgenes/pi-permission-model-judge`](https://github.com/gotgenes/pi-packages/tree/main/packages/pi-permission-model-judge): it registers a `model-judge` link on `permissions:ready` that reviews `external_directory` asks and auto-denies mistyped paths with a corrective reason.
251
253
 
252
254
  ---
@@ -15,29 +15,45 @@ It provides direct, synchronous, type-safe function calls.
15
15
  ### Quick Start
16
16
 
17
17
  ```typescript
18
- try {
19
- const { getPermissionsService } = await import(
20
- "@gotgenes/pi-permission-system"
21
- );
22
- const permissions = getPermissionsService();
23
- if (permissions) {
24
- const result = permissions.checkPermission("bash", "git push");
25
- console.log(result.state); // "allow" | "deny" | "ask"
26
- }
27
- } catch {
28
- // Not installed graceful degradation
29
- }
18
+ pi.events.on("permissions:ready", (data) => {
19
+ const { sessionId } = data as PermissionsReadyEvent;
20
+ if (!sessionId) return;
21
+ void (async () => {
22
+ try {
23
+ const { getPermissionsService } =
24
+ await import("@gotgenes/pi-permission-system");
25
+ const permissions = getPermissionsService(sessionId);
26
+ if (permissions) {
27
+ const result = permissions.checkPermission("bash", "git push");
28
+ console.log(result.state); // "allow" | "deny" | "ask"
29
+ }
30
+ } catch {
31
+ // Not installed — graceful degradation
32
+ }
33
+ })();
34
+ });
30
35
  ```
31
36
 
37
+ Inside your own `session_start` handler, `ctx.sessionManager.getSessionId()` is the same key.
38
+
32
39
  ### How It Works
33
40
 
34
41
  Pi's extension loader creates a fresh [jiti](https://github.com/nicolo-ribaudo/jiti) instance per extension with `moduleCache: false`, which isolates module-level state.
35
42
  `Symbol.for()` and `globalThis` are process-global by spec, so they survive this isolation.
36
43
 
37
- The permission-system extension publishes a service object on `globalThis` via `Symbol.for("@gotgenes/pi-permission-system:service")` at `session_start`.
38
- Consumers call `getPermissionsService()` to retrieve it even though their `import()` loads a fresh module copy, the accessor reads from the shared `globalThis` slot.
39
- An in-process subagent child does not publish its own service; inside a child, `getPermissionsService()` resolves the parent's service.
40
- A consumer reacting to the `permissions:ready` broadcast (also emitted at `session_start`, after the publish) can resolve the service immediately.
44
+ One process can host several **nodes** one Pi session runtime each, with its own gates and its own registries.
45
+ A root session and each of its in-process subagent children are separate nodes, and each loads its own instance of this extension.
46
+ Registrations never cross a node boundary: a formatter, an access extractor, or an authorizer link is read by the node it was registered in.
47
+
48
+ Each node therefore publishes its own service at `session_start`, into a `globalThis` map keyed by that node's session id (`Symbol.for("@gotgenes/pi-permission-system:session-services")`).
49
+ Consumers call `getPermissionsService(sessionId)` to retrieve it — even though their `import()` loads a fresh module copy, the accessor reads from the shared `globalThis` slot.
50
+ The session id arrives as a field on the `permissions:ready` broadcast, which each node emits at its own `session_start`, right after publishing — and again at that node's first `before_agent_start`, so a consumer whose own `session_start` ran later still hears it.
51
+
52
+ `getRootPermissionsService()` remains, reading a separate legacy slot that holds the **process root's** service, but it is deprecated: in any node but the root it answers the wrong question, handing an in-process child the parent's service.
53
+ Calling it emits a once-guarded Node `DeprecationWarning` (code `PI_PERMISSION_SYSTEM_DEP0001`); run with `--trace-deprecation` to locate your call site, or `--no-deprecation` to silence it.
54
+ Removal is deferred to a future major release.
55
+
56
+ Both accessors were renamed in the major that reclaimed `getPermissionsService` for the keyed locator; if you are upgrading from a release whose `getPermissionsService()` took no argument, see [migration/0794-keyed-service-locator.md](migration/0794-keyed-service-locator.md).
41
57
 
42
58
  All types below are directly importable and type-check with `tsc` out of the box.
43
59
  `@gotgenes/pi-permission-system`'s published `exports` resolve `import type { … }` to a self-contained, bundled declaration file with no internal module references, so a downstream `tsconfig.json` needs no special path configuration.
@@ -184,30 +200,33 @@ Register during your extension's initialization and store the disposer for teard
184
200
  export default function myExtension(pi: ExtensionAPI): void {
185
201
  let disposeFormatter: (() => void) | undefined;
186
202
 
187
- void (async () => {
188
- try {
189
- const { getPermissionsService } = await import(
190
- "@gotgenes/pi-permission-system"
191
- );
192
- const permissions = getPermissionsService();
193
- disposeFormatter = permissions?.registerToolInputFormatter(
194
- "deploy", // a tool THIS extension registers with Pi
195
- (input) => {
196
- const target =
197
- typeof input.target === "string" ? input.target : undefined;
198
- const services = Array.isArray(input.services)
199
- ? input.services.length
200
- : undefined;
201
- if (!target) return undefined; // decline → default preview
202
- return services !== undefined
203
- ? `with target ${target} (${services} services)`
204
- : `with target ${target}`;
205
- },
206
- );
207
- } catch {
208
- // permission-system not installed — nothing to register
209
- }
210
- })();
203
+ pi.events.on("permissions:ready", (data) => {
204
+ const { sessionId } = data as PermissionsReadyEvent;
205
+ if (disposeFormatter || !sessionId) return;
206
+ void (async () => {
207
+ try {
208
+ const { getPermissionsService } =
209
+ await import("@gotgenes/pi-permission-system");
210
+ const permissions = getPermissionsService(sessionId);
211
+ disposeFormatter = permissions?.registerToolInputFormatter(
212
+ "deploy", // a tool THIS extension registers with Pi
213
+ (input) => {
214
+ const target =
215
+ typeof input.target === "string" ? input.target : undefined;
216
+ const services = Array.isArray(input.services)
217
+ ? input.services.length
218
+ : undefined;
219
+ if (!target) return undefined; // decline → default preview
220
+ return services !== undefined
221
+ ? `with target ${target} (${services} services)`
222
+ : `with target ${target}`;
223
+ },
224
+ );
225
+ } catch {
226
+ // permission-system not installed — nothing to register
227
+ }
228
+ })();
229
+ });
211
230
 
212
231
  pi.on("session_shutdown", () => {
213
232
  disposeFormatter?.();
@@ -265,24 +284,27 @@ The extractor must not throw — guard your parsing and return `undefined` on an
265
284
 
266
285
  #### Subagent session registration
267
286
 
268
- In-process subagent registration is event-driven.
269
- `@gotgenes/pi-subagents` emits `subagents:child:session-created` before `bindExtensions()` and `subagents:child:disposed` in the run's `finally`; the permission system subscribes automatically no service call from the spawner is required.
270
- See [Subagent Integration](subagent-integration.md) for details.
287
+ Subagent registration is announcement-driven, and the spawner makes no service call.
288
+ The channel names, payload shapes, pre-bind ordering, and the out-of-process environment variable are specified by the subagent adapter convention in [Subagent Integration](subagent-integration.md#the-subagent-adapter-convention).
271
289
 
272
290
  ### Reload Safety
273
291
 
274
292
  During `/reload`, all extensions re-initialize.
275
- The permission-system re-publishes a fresh service at `session_start`; teardown is identity-scoped, so a superseded generation's shutdown only clears the slot when it still owns it and cannot wipe the new service.
293
+ The permission-system re-publishes a fresh service at `session_start`; teardown is identity-scoped for both slots, so a superseded generation's shutdown only removes an entry it still owns and cannot wipe the new service.
276
294
  Consumers that re-initialize during reload naturally get the new instance.
277
295
 
278
- Best practice: call `getPermissionsService()` per use rather than caching the reference.
296
+ Best practice: resolve the service per use rather than caching the reference.
279
297
 
280
298
  ### Graceful Degradation
281
299
 
282
- `getPermissionsService()` returns `undefined` when the permission-system extension has not loaded (or has been unloaded).
300
+ `getPermissionsService(sessionId)` returns `undefined` when the permission-system extension has not loaded into that node (or has been unloaded).
283
301
  The `import()` throws if the package is not installed.
284
302
  Wrap both in `try/catch` + `if` guard as shown in the Quick Start example.
285
303
 
304
+ It also returns `undefined` when called with no session id at all — a shape TypeScript rejects but JavaScript reaches — rather than falling back to the process root's service, since answering with another node's service is the defect the keyed locator exists to prevent.
305
+ That call emits a once-guarded Node warning (code `PI_PERMISSION_SYSTEM_WARN0001`), because the guard above turns the missing service into a registration that silently never happens.
306
+ It is deliberately not a `DeprecationWarning`: `--no-deprecation` does not silence it.
307
+
286
308
  ---
287
309
 
288
310
  ## Event Bus
@@ -299,11 +321,11 @@ All three broadcasts are best-effort: a throwing listener cannot block permissio
299
321
 
300
322
  ## Channel Reference
301
323
 
302
- | Channel | Direction | When | Payload type |
303
- | ----------------------- | --------- | --------------------------------- | ------------------------- |
304
- | `permissions:ready` | Broadcast | At `session_start`, after publish | `PermissionsReadyEvent` |
305
- | `permissions:ui_prompt` | Broadcast | Before active UI prompt | `PermissionUiPromptEvent` |
306
- | `permissions:decision` | Broadcast | After every gate resolution | `PermissionDecisionEvent` |
324
+ | Channel | Direction | When | Payload type |
325
+ | ----------------------- | --------- | ----------------------------------------------------------------------------------------------------- | ------------------------- |
326
+ | `permissions:ready` | Broadcast | At each node's `session_start` after that node publishes, and again at its first `before_agent_start` | `PermissionsReadyEvent` |
327
+ | `permissions:ui_prompt` | Broadcast | Before active UI prompt | `PermissionUiPromptEvent` |
328
+ | `permissions:decision` | Broadcast | After every gate resolution | `PermissionDecisionEvent` |
307
329
 
308
330
  ---
309
331
 
@@ -401,12 +423,13 @@ A served decision carries a non-null `forwarding` context; the requesting sessio
401
423
 
402
424
  The `requestId` is the same id the request's review-log entries carry, and the same one `permissions:ui_prompt` carried if the request reached a prompt — so a prompt and its outcome are joinable, as are two concurrent prompts for the same command.
403
425
  A request that reaches a prompt is answered by exactly one terminal event on that prompt's own bus, including when the dialog itself fails.
404
- It identifies a permission *request*, not a tool call: one tool call runs several gates and so raises several requests, each with its own id.
426
+ It identifies a permission _request_, not a tool call: one tool call runs several gates and so raises several requests, each with its own id.
405
427
  Use the review log's `toolCallId` to join back to the Pi transcript.
406
428
 
407
429
  ```typescript
408
430
  pi.events.on("permissions:decision", (raw) => {
409
- const event = raw as import("@gotgenes/pi-permission-system").PermissionDecisionEvent;
431
+ const event =
432
+ raw as import("@gotgenes/pi-permission-system").PermissionDecisionEvent;
410
433
  console.log(event.surface, event.result, event.resolution);
411
434
  // e.g. "bash" "allow" "user_approved_for_session"
412
435
  });
@@ -445,20 +468,47 @@ pi.events.on("permissions:decision", (raw) => {
445
468
 
446
469
  ## Ready Event
447
470
 
448
- The extension emits `permissions:ready` at `session_start`, right after the service is published — so a consumer reacting to it can immediately resolve `getPermissionsService()`.
449
- It fires once per `session_start` (including `/reload`).
471
+ Each node emits `permissions:ready` at its own `session_start`, right after publishing its service — so a consumer reacting to it can immediately resolve that node's service.
472
+ It emits again at that node's first `before_agent_start`, which runs after every extension's `session_start` and before any tool call, hence before any permission prompt.
473
+
474
+ So the channel's contract is: **fires at least once per session, and may repeat.**
475
+ A handler must be idempotent.
476
+ That guarantee is what makes the ready event alone a sufficient registration site: a consumer that needs its own config before it can register no longer has to attempt registration from `session_start` as well, hoping one of the two orderings completes the pair.
477
+ A new session generation (`/reload`, `/new`, `/resume`) starts the cycle over: one emission at `session_start`, one at the first turn that follows.
450
478
 
451
- The payload is intentionally empty (`Record<string, never>`): the channel is a pure readiness signal.
479
+ Guard your registration on the disposer you stored, as the example below does.
480
+ An unguarded handler that calls `registerAuthorizer`, `registerToolInputFormatter`, or `registerToolAccessExtractor` on every emission hits the duplicate-registration throw on the second one.
481
+ That throw is caught by Pi's event bus and reported on stderr — your first registration stays live — but the noise is avoidable, and it was already reachable before the latch existed, since `/reload` re-runs `session_start`.
482
+
483
+ The payload carries plain facts about the node that emitted it, never a live capability: the bus announces, the locator provides.
452
484
  It carries no `protocolVersion` — the broadcast contract is defined by the published types plus package semver.
453
485
 
486
+ | Field | Type | Meaning |
487
+ | -------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
488
+ | `sessionId` | `string \| null` | The emitting node's session id — the key for `getPermissionsService`. `null` when the host exposed no session id, in which case that node published no keyed service. |
489
+ | `adjudicatesLocally` | `boolean` | Whether this node's authorizer chain runs, or the node relays its asks to a serving node that runs _its_ chain over the same facts. |
490
+
454
491
  ```typescript
455
- pi.events.on("permissions:ready", () => {
492
+ let dispose: (() => void) | undefined;
493
+
494
+ pi.events.on("permissions:ready", (data) => {
495
+ const { sessionId } = data as PermissionsReadyEvent;
496
+ // Idempotent: ready may repeat, so a second emission must be a no-op.
497
+ if (dispose || !sessionId) return;
456
498
  void (async () => {
457
- const { getPermissionsService } = await import(
458
- "@gotgenes/pi-permission-system"
459
- );
460
- const permissions = getPermissionsService();
461
- // The service is published just before this fires — resolve it now.
499
+ const { getPermissionsService } =
500
+ await import("@gotgenes/pi-permission-system");
501
+ const permissions = getPermissionsService(sessionId);
502
+ // This node published before the event fired — resolve and register now.
503
+ dispose = permissions?.registerAuthorizer("my-link", authorize);
462
504
  })();
463
505
  });
506
+
507
+ pi.on("session_shutdown", () => {
508
+ dispose?.();
509
+ dispose = undefined;
510
+ });
464
511
  ```
512
+
513
+ A registration needs no branch on `adjudicatesLocally`.
514
+ Formatters and access extractors are read by every node's own gates, and a chain link registered on a relaying node is accepted (its disposer works) and recorded in the review log as `authorizer_link_vacant` rather than refused — so registering everywhere is the correct default.
@@ -136,7 +136,8 @@ try {
136
136
  const { getPermissionsService } = await import(
137
137
  "@gotgenes/pi-permission-system"
138
138
  );
139
- const permissions = getPermissionsService();
139
+ // Your own session's id; inside a handler, ctx.sessionManager.getSessionId().
140
+ const permissions = getPermissionsService(sessionId);
140
141
  if (permissions) {
141
142
  const result = permissions.checkPermission("bash", "git push", "Worker");
142
143
  console.log(result.state); // "allow" | "deny" | "ask"
@@ -146,7 +147,11 @@ try {
146
147
  }
147
148
  ```
148
149
 
149
- If `pi-permission-system` is not installed, `import()` throws; if it has not published a service yet (or has been unloaded), `getPermissionsService()` returns `undefined`.
150
+ Query the service belonging to the session whose policy you mean.
151
+ Each session that loads the permission system publishes its own — a subagent child's config (and cwd) may differ from its parent's, so the parent's service is not a stand-in for the child's.
152
+ The deprecated `getRootPermissionsService()` always answers with the process root's service, which is why it is the wrong call inside a child.
153
+
154
+ If `pi-permission-system` is not installed, `import()` throws; if that session has published no service yet (or has been unloaded), `getPermissionsService(sessionId)` returns `undefined`.
150
155
  Guard both cases as shown above.
151
156
 
152
157
  Prompt forwarding for headless child agents is an internal subagent-to-parent mechanism, not a public cross-extension operation — there is no service-accessor equivalent to call directly.