@gotgenes/pi-permission-system 8.3.1 → 9.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,40 @@ 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
+ ## [9.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.2...pi-permission-system-v9.0.0) (2026-06-01)
9
+
10
+
11
+ ### ⚠ BREAKING CHANGES
12
+
13
+ * unpublishPermissionsService() now requires the service to remove as its sole argument. The package's public export is service.ts, so this changes the published API surface.
14
+
15
+ ### Features
16
+
17
+ * scope service teardown to the publishing instance ([#302](https://github.com/gotgenes/pi-packages/issues/302)) ([72180e9](https://github.com/gotgenes/pi-packages/commit/72180e906f7370c842cd5e31a11726c2971fc988))
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * keep the parent's service published across child shutdown ([#302](https://github.com/gotgenes/pi-packages/issues/302)) ([300214c](https://github.com/gotgenes/pi-packages/commit/300214ca21d985bfba7231f261c022c394d8bf5a))
23
+
24
+
25
+ ### Documentation
26
+
27
+ * document session_start service publication and ready timing ([#302](https://github.com/gotgenes/pi-packages/issues/302)) ([a894fb8](https://github.com/gotgenes/pi-packages/commit/a894fb8d5c2bbc7cd9d33769859d172c5a7dbb73))
28
+
29
+ ## [8.3.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.1...pi-permission-system-v8.3.2) (2026-06-01)
30
+
31
+
32
+ ### Bug Fixes
33
+
34
+ * **pi-permission-system:** key subagent registry by session id and drop vestigial agentName ([d299c54](https://github.com/gotgenes/pi-packages/commit/d299c5421f41ab0829fb83fcf4e030d1c7af6d56))
35
+ * **pi-permission-system:** resolve subagent detection and forwarding target by session id ([0f7e079](https://github.com/gotgenes/pi-packages/commit/0f7e0795b911797e645f4d44c42bb314bf0cb103))
36
+
37
+
38
+ ### Documentation
39
+
40
+ * **retro:** add retro notes for issue [#296](https://github.com/gotgenes/pi-packages/issues/296) ([75743ab](https://github.com/gotgenes/pi-packages/commit/75743abe92604de142ff6e77c9c0fbc44266e12a))
41
+
8
42
  ## [8.3.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.0...pi-permission-system-v8.3.1) (2026-06-01)
9
43
 
10
44
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "8.3.1",
3
+ "version": "9.0.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -105,7 +105,6 @@ export async function waitForForwardedPermissionApproval(
105
105
  deps: PermissionForwardingDeps,
106
106
  ): Promise<PermissionPromptDecision> {
107
107
  const requesterSessionId = getSessionId(ctx);
108
- const sessionDir = ctx.sessionManager.getSessionDir();
109
108
  const targetSessionId = resolvePermissionForwardingTargetSessionId({
110
109
  hasUI: ctx.hasUI,
111
110
  isSubagent: isSubagentExecutionContext(
@@ -115,7 +114,7 @@ export async function waitForForwardedPermissionApproval(
115
114
  ),
116
115
  currentSessionId: requesterSessionId,
117
116
  env: process.env,
118
- sessionDir,
117
+ sessionId: requesterSessionId,
119
118
  registry: deps.registry,
120
119
  });
121
120
 
@@ -18,11 +18,14 @@ interface ResourcesDiscoverPayload {
18
18
  *
19
19
  * Constructor deps:
20
20
  * - `session` — encapsulates all mutable session state
21
+ * - `activateService` — publishes the process-global service for this session
22
+ * (skipped for in-process subagent children) and emits the ready event
21
23
  * - `cleanupRpc` — unsubscribes RPC handlers on shutdown
22
24
  */
23
25
  export class SessionLifecycleHandler {
24
26
  constructor(
25
27
  private readonly session: PermissionSession,
28
+ private readonly activateService: (ctx: ExtensionContext) => void,
26
29
  private readonly cleanupRpc: () => void,
27
30
  ) {}
28
31
 
@@ -47,6 +50,12 @@ export class SessionLifecycleHandler {
47
50
  cwd: ctx.cwd,
48
51
  });
49
52
  }
53
+
54
+ // Publish the process-global service now that a ctx (and therefore the
55
+ // session id) is available, so an in-process subagent child can be
56
+ // identified and excluded. Emitting ready here keeps the
57
+ // service-resolvable-when-ready ordering contract.
58
+ this.activateService(ctx);
50
59
  return Promise.resolve();
51
60
  }
52
61
 
package/src/index.ts CHANGED
@@ -1,4 +1,7 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
2
5
  import { registerBuiltinToolInputFormatters } from "./builtin-tool-input-formatters";
3
6
  import { registerPermissionSystemCommand } from "./config-modal";
4
7
  import { getGlobalConfigPath } from "./config-paths";
@@ -27,7 +30,10 @@ import {
27
30
  unpublishPermissionsService,
28
31
  } from "./service";
29
32
  import { createSessionLogger } from "./session-logger";
30
- import { isSubagentExecutionContext } from "./subagent-context";
33
+ import {
34
+ isRegisteredSubagentChild,
35
+ isSubagentExecutionContext,
36
+ } from "./subagent-context";
31
37
  import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
32
38
  import { getSubagentSessionRegistry } from "./subagent-registry";
33
39
  import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
@@ -129,7 +135,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
129
135
  return formatterRegistry.register(toolName, formatter);
130
136
  },
131
137
  };
132
- publishPermissionsService(permissionsService);
138
+
139
+ // Publish the service to the process-global slot only when this instance is
140
+ // not an in-process subagent child, then emit ready. Deferred to
141
+ // session_start (vs. factory init) because identifying a child requires the
142
+ // session id from ctx, which the factory body does not have. A registered
143
+ // child therefore never clobbers the parent's published service. See #302.
144
+ const activateServiceForSession = (ctx: ExtensionContext): void => {
145
+ if (!isRegisteredSubagentChild(ctx, subagentRegistry)) {
146
+ publishPermissionsService(permissionsService);
147
+ }
148
+ emitReadyEvent(pi.events);
149
+ };
133
150
 
134
151
  // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
135
152
  // sessions register/unregister without the core calling us (ADR 0002).
@@ -138,19 +155,21 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
138
155
  subagentRegistry,
139
156
  );
140
157
 
141
- emitReadyEvent(pi.events);
142
-
143
158
  const toolRegistry = {
144
159
  getAll: () => pi.getAllTools(),
145
160
  setActive: (names: string[]) => pi.setActiveTools(names),
146
161
  };
147
162
 
148
- const lifecycle = new SessionLifecycleHandler(session, () => {
149
- rpcHandles.unsubCheck();
150
- rpcHandles.unsubPrompt();
151
- unsubSubagentLifecycle();
152
- unpublishPermissionsService();
153
- });
163
+ const lifecycle = new SessionLifecycleHandler(
164
+ session,
165
+ activateServiceForSession,
166
+ () => {
167
+ rpcHandles.unsubCheck();
168
+ rpcHandles.unsubPrompt();
169
+ unsubSubagentLifecycle();
170
+ unpublishPermissionsService(permissionsService);
171
+ },
172
+ );
154
173
  const agentPrep = new AgentPrepHandler(session, toolRegistry);
155
174
  const gates = new PermissionGateHandler(
156
175
  session,
@@ -24,7 +24,7 @@ export const PERMISSIONS_PROTOCOL_VERSION = 1;
24
24
 
25
25
  // ── Channel name constants ─────────────────────────────────────────────────
26
26
 
27
- /** Emitted once on extension load. */
27
+ /** Emitted at `session_start`, after the service is published. */
28
28
  export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
29
29
 
30
30
  /** Emitted after every permission gate resolution. */
@@ -160,7 +160,8 @@ export interface PermissionsPromptReplyData {
160
160
 
161
161
  /**
162
162
  * Emit the `permissions:ready` broadcast.
163
- * Call once after the extension has finished setup.
163
+ * Call at `session_start`, after the service is published, so a consumer
164
+ * reacting to ready can immediately resolve `getPermissionsService()`.
164
165
  */
165
166
  export function emitReadyEvent(events: PermissionEventBus): void {
166
167
  const payload: PermissionsReadyEvent = {
@@ -119,8 +119,8 @@ export function resolvePermissionForwardingTargetSessionId(options: {
119
119
  isSubagent: boolean;
120
120
  currentSessionId?: string | null;
121
121
  env?: NodeJS.ProcessEnv;
122
- /** Session directory key for registry lookup. */
123
- sessionDir?: string;
122
+ /** Child session id for registry lookup. */
123
+ sessionId?: string;
124
124
  /** In-process subagent session registry (checked before env vars). */
125
125
  registry?: SubagentSessionRegistry;
126
126
  }): string | null {
@@ -133,8 +133,8 @@ export function resolvePermissionForwardingTargetSessionId(options: {
133
133
  }
134
134
 
135
135
  // 1. Registry — in-process subagents register parentSessionId explicitly.
136
- if (options.registry && options.sessionDir) {
137
- const entry = options.registry.get(options.sessionDir);
136
+ if (options.registry && options.sessionId) {
137
+ const entry = options.registry.get(options.sessionId);
138
138
  const resolved = normalizePermissionForwardingSessionId(
139
139
  entry?.parentSessionId,
140
140
  );
package/src/service.ts CHANGED
@@ -81,7 +81,10 @@ export interface PermissionsService {
81
81
  * Store a `PermissionsService` on `globalThis` so other extensions can
82
82
  * retrieve it via `getPermissionsService()`.
83
83
  *
84
- * Overwrites any previously published service safe for `/reload`.
84
+ * Called at `session_start` by the top-level (parent) instance only — an
85
+ * in-process subagent child skips publishing so it cannot clobber the parent's
86
+ * service. Overwrites any previously published service, which keeps `/reload`
87
+ * working: a reloaded parent re-publishes its fresh service.
85
88
  */
86
89
  export function publishPermissionsService(service: PermissionsService): void {
87
90
  (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
@@ -98,12 +101,22 @@ export function getPermissionsService(): PermissionsService | undefined {
98
101
  }
99
102
 
100
103
  /**
101
- * Remove the service from `globalThis`.
104
+ * Remove `service` from `globalThis`, but only when the current slot still
105
+ * holds it (identity compare-and-delete).
102
106
  *
103
107
  * Called during `session_shutdown` to avoid stale references after the
104
- * extension is torn down.
108
+ * extension is torn down. Scoping the delete to the publishing instance keeps
109
+ * two cases correct:
110
+ *
111
+ * - An in-process subagent child never published the parent's service, so its
112
+ * shutdown is a no-op and the parent's slot survives.
113
+ * - A superseded `/reload` generation no longer owns the slot, so its late
114
+ * shutdown cannot wipe the new generation's freshly published service.
105
115
  */
106
- export function unpublishPermissionsService(): void {
116
+ export function unpublishPermissionsService(service: PermissionsService): void {
117
+ if (getPermissionsService() !== service) {
118
+ return;
119
+ }
107
120
  // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
108
121
  delete (globalThis as Record<symbol, unknown>)[SERVICE_KEY];
109
122
  }
@@ -28,19 +28,47 @@ function isPathWithinDirectoryForSubagent(
28
28
  return pathValue.startsWith(prefix);
29
29
  }
30
30
 
31
+ /**
32
+ * Return `true` when `ctx` belongs to an in-process subagent child registered
33
+ * in `registry` by its session id.
34
+ *
35
+ * This is the only signal that identifies an **in-process** child (one sharing
36
+ * the parent's `globalThis`); env-hint and filesystem heuristics identify
37
+ * **process-based** subagents instead. The composition root uses this to decide
38
+ * whether the instance owns the process-global service slot — a registered
39
+ * child must not publish over its parent.
40
+ */
41
+ export function isRegisteredSubagentChild(
42
+ ctx: ExtensionContext,
43
+ registry: SubagentSessionRegistry,
44
+ ): boolean {
45
+ try {
46
+ const sessionId = ctx.sessionManager.getSessionId();
47
+ if (!sessionId) {
48
+ return false;
49
+ }
50
+ return registry.has(sessionId);
51
+ } catch {
52
+ // getSessionId() unavailable — treat as not-a-registered-child.
53
+ return false;
54
+ }
55
+ }
56
+
31
57
  export function isSubagentExecutionContext(
32
58
  ctx: ExtensionContext,
33
59
  subagentSessionsDir: string,
34
60
  registry?: SubagentSessionRegistry,
35
61
  ): boolean {
36
- const sessionDir = ctx.sessionManager.getSessionDir();
37
-
38
- // 1. Explicit registry in-process subagent extensions register before
39
- // bindExtensions(); checked first so it takes priority over heuristics.
40
- if (registry && sessionDir && registry.has(sessionDir)) {
62
+ // 1. Explicit registry — in-process subagent extensions register by child
63
+ // session id before bindExtensions(); checked first so it takes priority
64
+ // over heuristics. Each concurrent sibling has a unique session id, so
65
+ // one sibling's disposed event cannot affect another's registration.
66
+ if (registry && isRegisteredSubagentChild(ctx, registry)) {
41
67
  return true;
42
68
  }
43
69
 
70
+ const sessionDir = ctx.sessionManager.getSessionDir();
71
+
44
72
  // 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
45
73
  // HazAT/pi-interactive-subagents, pi-agent-router, etc.).
46
74
  for (const key of SUBAGENT_ENV_HINT_KEYS) {
@@ -32,14 +32,15 @@ interface LifecycleEventBus {
32
32
 
33
33
  /** Fields read from the `session-created` payload (ISP). */
34
34
  interface ChildSessionCreatedEvent {
35
- sessionDir: string;
36
- agentName: string;
35
+ /** Child session id — the registry key. Must match the publisher. */
36
+ sessionId: string;
37
37
  parentSessionId?: string;
38
38
  }
39
39
 
40
40
  /** Fields read from the `disposed` payload (ISP). */
41
41
  interface ChildDisposedEvent {
42
- sessionDir: string;
42
+ /** Child session id — the registry key. Must match the publisher. */
43
+ sessionId: string;
43
44
  }
44
45
 
45
46
  /**
@@ -54,15 +55,14 @@ export function subscribeSubagentLifecycle(
54
55
  ): () => void {
55
56
  const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
56
57
  const event = data as ChildSessionCreatedEvent;
57
- registry.register(event.sessionDir, {
58
- agentName: event.agentName,
58
+ registry.register(event.sessionId, {
59
59
  parentSessionId: event.parentSessionId,
60
60
  });
61
61
  });
62
62
 
63
63
  const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
64
64
  const event = data as ChildDisposedEvent;
65
- registry.unregister(event.sessionDir);
65
+ registry.unregister(event.sessionId);
66
66
  });
67
67
 
68
68
  return () => {
@@ -7,15 +7,22 @@
7
7
  * can detect them without relying on environment variables or filesystem
8
8
  * heuristics.
9
9
  *
10
- * The registry is keyed by session directory path, which is unique per
11
- * session and available to both producer and consumer via
12
- * `ctx.sessionManager.getSessionDir()`.
10
+ * The registry is keyed by the child's **session id**, which is unique per
11
+ * child and available to both producer (via `sessionManager.getSessionId()`
12
+ * after `newSession()` in `create-subagent-session.ts`) and consumer (via
13
+ * `ctx.sessionManager.getSessionId()`). Two concurrent siblings of the same
14
+ * parent therefore occupy distinct keys, so one sibling's `disposed` event
15
+ * cannot evict the entry the others depend on.
13
16
  *
14
17
  * The single registry instance is stored on `globalThis` (via `Symbol.for()`)
15
18
  * so that the parent's permission-system instance (which registers children
16
19
  * on the parent's event bus) and each child's separate jiti instance (which
17
20
  * reads the registry to detect itself and resolve its forwarding target) share
18
21
  * one store across per-session event buses. See `getSubagentSessionRegistry()`.
22
+ *
23
+ * When a future code path needs the child's agent name, read it from
24
+ * `tcc.agentName` (resolved from the `<active_agent>` system-prompt tag) —
25
+ * not from this registry.
19
26
  */
20
27
 
21
28
  /** Process-global key for the shared registry slot. */
@@ -53,19 +60,20 @@ export function getSubagentSessionRegistry(): SubagentSessionRegistry {
53
60
  export interface SubagentSessionInfo {
54
61
  /** Parent session ID for permission forwarding. Omit when unknown. */
55
62
  parentSessionId?: string;
56
- /** Agent name for per-agent policy resolution. */
57
- agentName: string;
58
63
  }
59
64
 
60
65
  /**
61
66
  * Registry of active in-process subagent sessions.
62
67
  *
63
- * Owned by `ExtensionRuntime`; written exclusively by `subscribeSubagentLifecycle`
64
- * via the `subagents:child:session-created` / `subagents:child:disposed` event
65
- * subscription (ADR 0002 — the core publishes, consumers observe).
68
+ * A process-global singleton obtain it via `getSubagentSessionRegistry()`,
69
+ * never `new` (see that accessor for why). Written exclusively by
70
+ * `subscribeSubagentLifecycle` via the `subagents:child:session-created` /
71
+ * `subagents:child:disposed` event subscription (ADR 0002 — the core
72
+ * publishes, consumers observe).
66
73
  *
67
- * Concurrent background agents are safe because each session has a unique
68
- * directory path as its key no scalar global flag is needed.
74
+ * Keyed by child session id. Each concurrent child of the same parent receives
75
+ * a unique session id from `sessionManager.newSession()`, so siblings occupy
76
+ * distinct keys and one sibling's `disposed` cannot evict another's entry.
69
77
  */
70
78
  export class SubagentSessionRegistry {
71
79
  private readonly sessions = new Map<string, SubagentSessionInfo>();
@@ -73,25 +81,25 @@ export class SubagentSessionRegistry {
73
81
  /**
74
82
  * Register an in-process subagent session.
75
83
  *
76
- * If a previous entry exists for `sessionKey`, it is overwritten
84
+ * If a previous entry exists for `sessionId`, it is overwritten
77
85
  * (last-write-wins; single-writer expected per key).
78
86
  */
79
- register(sessionKey: string, info: SubagentSessionInfo): void {
80
- this.sessions.set(sessionKey, info);
87
+ register(sessionId: string, info: SubagentSessionInfo): void {
88
+ this.sessions.set(sessionId, info);
81
89
  }
82
90
 
83
91
  /** Remove a previously registered session. No-op if the key is absent. */
84
- unregister(sessionKey: string): void {
85
- this.sessions.delete(sessionKey);
92
+ unregister(sessionId: string): void {
93
+ this.sessions.delete(sessionId);
86
94
  }
87
95
 
88
- /** Return the registered info for `sessionKey`, or `undefined` if absent. */
89
- get(sessionKey: string): SubagentSessionInfo | undefined {
90
- return this.sessions.get(sessionKey);
96
+ /** Return the registered info for `sessionId`, or `undefined` if absent. */
97
+ get(sessionId: string): SubagentSessionInfo | undefined {
98
+ return this.sessions.get(sessionId);
91
99
  }
92
100
 
93
- /** Return `true` when `sessionKey` has a registered entry. */
94
- has(sessionKey: string): boolean {
95
- return this.sessions.has(sessionKey);
101
+ /** Return `true` when `sessionId` has a registered entry. */
102
+ has(sessionId: string): boolean {
103
+ return this.sessions.has(sessionId);
96
104
  }
97
105
  }