@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/src/index.ts CHANGED
@@ -2,7 +2,10 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { getAgentDir, getPackageDir } from "@earendil-works/pi-coding-agent";
3
3
  import { warmBashParser } from "./access-intent/bash/parser";
4
4
  import { buildResolvedIntentFromMatchValues } from "./access-intent/input-normalizer";
5
- import { AuthorizerRegistry } from "./authority/authorizer-registry";
5
+ import {
6
+ AuthorizerRegistry,
7
+ ObservedAuthorizerRegistrar,
8
+ } from "./authority/authorizer-registry";
6
9
  import { AuthorizerSelection } from "./authority/authorizer-selection";
7
10
  import {
8
11
  ForwardedRequestServer,
@@ -35,6 +38,7 @@ import {
35
38
  AgentPrepHandler,
36
39
  PermissionGateHandler,
37
40
  SessionLifecycleHandler,
41
+ SessionTurnPrep,
38
42
  } from "./handlers";
39
43
  import { GateRunner } from "./handlers/gates/runner";
40
44
  import { SkillInputGatePipeline } from "./handlers/gates/skill-input-gate-pipeline";
@@ -46,6 +50,7 @@ import { PermissionResolver } from "./permission-resolver";
46
50
  import { PermissionSession } from "./permission-session";
47
51
  import { LocalPermissionsService } from "./permissions-service";
48
52
  import { resolveRenderBudget } from "./presentation/dialog-renderer";
53
+ import type { PermissionsService } from "./service";
49
54
  import { PermissionServiceLifecycle } from "./service-lifecycle";
50
55
  import { PermissionSessionLogger } from "./session-logger";
51
56
  import { SessionRules } from "./session-rules";
@@ -233,12 +238,24 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
233
238
  ),
234
239
  });
235
240
 
236
- const permissionsService = new LocalPermissionsService(
241
+ // Explicitly annotated to break a type-inference cycle: the selection's
242
+ // `getPermissionQuery` thunk closes over this service, and the service's
243
+ // registrar closes back over the selection. Both are resolved at call time
244
+ // at runtime; `tsc` needs one of the two typed by hand to unwind them.
245
+ const permissionsService: PermissionsService = new LocalPermissionsService(
237
246
  resolver,
238
247
  session,
239
248
  formatterRegistry,
240
249
  accessExtractorRegistry,
241
- authorizerRegistry,
250
+ // Sibling extensions register through the observing decorator, so a link
251
+ // offered to a node whose chain never runs is accepted and recorded rather
252
+ // than vanishing (ADR 0012 decision 4). Chain resolution keeps reading the
253
+ // undecorated registry above.
254
+ new ObservedAuthorizerRegistrar(
255
+ authorizerRegistry,
256
+ authorizerSelection,
257
+ logger,
258
+ ),
242
259
  );
243
260
 
244
261
  // Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
@@ -249,13 +266,16 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
249
266
  );
250
267
 
251
268
  // PermissionServiceLifecycle owns the process-global service publication:
252
- // activate() publishes (skipped for registered subagent children — see #302)
253
- // and emits ready; teardown() unsubscribes all session listeners and
254
- // unpublishes. Deferred to session_start because identifying a child
255
- // requires the session id from ctx, unavailable at factory-init time.
269
+ // activate() publishes this node's service under its own session id (and to
270
+ // the legacy root slot unless this is a registered subagent child — see
271
+ // #302), then announces the node's session id and chain role on the ready
272
+ // channel; teardown() unsubscribes all session listeners and unpublishes.
273
+ // Deferred to session_start because both facts come from ctx, unavailable at
274
+ // factory-init time.
256
275
  const serviceLifecycle = new PermissionServiceLifecycle(
257
276
  permissionsService,
258
277
  subagentDetection,
278
+ authorizerSelection,
259
279
  pi.events,
260
280
  [unsubSubagentLifecycle],
261
281
  );
@@ -274,13 +294,18 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
274
294
  logger,
275
295
  audit,
276
296
  );
277
- const agentPrep = new AgentPrepHandler(
297
+ const turnPrep = new SessionTurnPrep(
278
298
  session,
279
- resolver,
280
- toolRegistry,
281
299
  () => {
282
300
  void warmBashParser();
283
301
  },
302
+ serviceLifecycle,
303
+ );
304
+ const agentPrep = new AgentPrepHandler(
305
+ turnPrep,
306
+ session,
307
+ resolver,
308
+ toolRegistry,
284
309
  );
285
310
 
286
311
  const gateRunner = new GateRunner(
@@ -18,7 +18,14 @@ export interface PermissionEventBus {
18
18
 
19
19
  // ── Channel name constants ─────────────────────────────────────────────────
20
20
 
21
- /** Emitted at `session_start`, after the service is published. */
21
+ /**
22
+ * Emitted at `session_start` after the emitting node published its service, and
23
+ * again at that node's first `before_agent_start` (ADR 0012 decision 3).
24
+ *
25
+ * Fires at least once per session and may repeat, so a handler must be
26
+ * idempotent — registering on every emission hits the duplicate-registration
27
+ * throw.
28
+ */
22
29
  export const PERMISSIONS_READY_CHANNEL = "permissions:ready";
23
30
 
24
31
  /** Emitted when a permission request is committed to the active UI prompt path. */
@@ -30,13 +37,34 @@ export const PERMISSIONS_DECISION_CHANNEL = "permissions:decision";
30
37
  // ── permissions:ready ──────────────────────────────────────────────────────
31
38
 
32
39
  /**
33
- * Payload emitted on `permissions:ready`.
40
+ * Payload emitted on `permissions:ready`: plain facts about the node that
41
+ * emitted it (ADR 0012 decision 2).
34
42
  *
35
- * Intentionally empty: the channel is a readiness signal. There is no
36
- * `protocolVersion` — the published types plus package semver define the
37
- * broadcast contract.
43
+ * The bus announces; the locator provides. The payload carries data a consumer
44
+ * can log, serialize, and replay — never a live capability — so the service
45
+ * itself is fetched with `getPermissionsService(sessionId)`.
46
+ *
47
+ * There is no `protocolVersion` — the published types plus package semver
48
+ * define the broadcast contract.
38
49
  */
39
- export type PermissionsReadyEvent = Record<string, never>;
50
+ export interface PermissionsReadyEvent {
51
+ /**
52
+ * The emitting node's session id: the key for
53
+ * `getPermissionsService`. `null` when the host exposed no session
54
+ * id, in which case this node published no keyed service.
55
+ */
56
+ sessionId: string | null;
57
+ /**
58
+ * Whether this node adjudicates its own asks (its authorizer chain runs) or
59
+ * relays them to a serving node, which runs *its* chain over the same facts
60
+ * (ADR 0007 §7).
61
+ *
62
+ * A registration needs no branch on this: extractors and formatters are read
63
+ * by every node's own gates, and a chain link registered where no chain runs
64
+ * is accepted and recorded rather than refused (ADR 0012 decision 4).
65
+ */
66
+ adjudicatesLocally: boolean;
67
+ }
40
68
 
41
69
  // ── permissions:ui_prompt ──────────────────────────────────────────────────
42
70
 
@@ -152,13 +180,18 @@ export interface PermissionDecisionEvent {
152
180
 
153
181
  /**
154
182
  * Emit the `permissions:ready` broadcast.
155
- * Call at `session_start`, after the service is published, so a consumer
156
- * reacting to ready can immediately resolve `getPermissionsService()`.
183
+ * Call after the node published its service, so a consumer reacting to ready
184
+ * can immediately resolve `getPermissionsService(event.sessionId)`.
185
+ * Called twice per session: at `session_start`, and at the first
186
+ * `before_agent_start` so a consumer whose own `session_start` ran later still
187
+ * hears it (ADR 0012 decision 3).
157
188
  */
158
- export function emitReadyEvent(events: PermissionEventBus): void {
159
- const payload: PermissionsReadyEvent = {};
189
+ export function emitReadyEvent(
190
+ events: PermissionEventBus,
191
+ event: PermissionsReadyEvent,
192
+ ): void {
160
193
  try {
161
- events.emit(PERMISSIONS_READY_CHANNEL, payload);
194
+ events.emit(PERMISSIONS_READY_CHANNEL, event);
162
195
  } catch {
163
196
  // Broadcasts are best-effort. A throwing listener must not block the
164
197
  // permission system from completing session startup.
@@ -1,11 +1,15 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AdjudicationRole } from "./authority/authorizer-selection";
2
3
  import type { RegisteredChildDetector } from "./authority/subagent-detection";
3
4
  import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
4
5
  import {
5
6
  type PermissionsService,
6
7
  publishPermissionsService,
8
+ publishRootPermissionsService,
7
9
  unpublishPermissionsService,
10
+ unpublishRootPermissionsService,
8
11
  } from "./service";
12
+ import { readSessionId } from "./session-identity";
9
13
 
10
14
  /** The session-scoped service lifecycle that the lifecycle handler drives. */
11
15
  export interface ServiceLifecycle {
@@ -13,35 +17,98 @@ export interface ServiceLifecycle {
13
17
  teardown(): void;
14
18
  }
15
19
 
20
+ /**
21
+ * Announces this node's ready facts at most once per session, whatever the
22
+ * caller does (ADR 0012 decision 3, the ready latch).
23
+ *
24
+ * Kept separate from `ServiceLifecycle` because the two roles have different
25
+ * callers: the session lifecycle starts and tears the node down, while the
26
+ * latch fires from the turn about to start.
27
+ */
28
+ export interface ReadyAnnouncer {
29
+ announceReady(ctx: ExtensionContext): void;
30
+ }
31
+
16
32
  /**
17
33
  * Owns the process-global service publication lifecycle for one extension
18
- * instance.
34
+ * instance — that is, for one node (ADR 0012).
19
35
  *
20
- * - `activate` publishes the service (skipped for registered subagent children
21
- * so they never clobber the parent's slot — see #302), then emits the ready
22
- * event.
36
+ * - `activate` publishes the service under this node's own session id, so a
37
+ * sibling extension loaded into this node registers into the registries this
38
+ * node's gates and chain read. It additionally publishes to the legacy
39
+ * process-root slot unless this is a registered subagent child, which must
40
+ * not clobber its parent's slot (#302). Then it announces both facts a
41
+ * consumer needs — the session id and the chain role — on the ready channel,
42
+ * and re-arms the latch so the turn about to start announces once more.
43
+ * - `announceReady` is that second announcement: it fires at most once per
44
+ * activation cycle, so `permissions:ready` reaches a consumer whose own
45
+ * `session_start` ran after this node's (ADR 0012 decision 3). The channel's
46
+ * contract is therefore "at least once per session, and may repeat" —
47
+ * handlers must be idempotent.
23
48
  * - `teardown` runs all session-scoped subscription cleanups in order, then
24
- * unpublishes the service.
49
+ * unpublishes from both slots. Each unpublish is identity-scoped, so a
50
+ * superseded `/reload` generation cannot evict the fresh one.
25
51
  */
26
- export class PermissionServiceLifecycle implements ServiceLifecycle {
52
+ export class PermissionServiceLifecycle
53
+ implements ServiceLifecycle, ReadyAnnouncer
54
+ {
55
+ /** The key this instance last published under; `null` until it publishes. */
56
+ private publishedSessionId: string | null = null;
57
+
58
+ /** Whether the latch has already announced for the current session. */
59
+ private announced = false;
60
+
27
61
  constructor(
28
62
  private readonly service: PermissionsService,
29
63
  private readonly detection: RegisteredChildDetector,
64
+ private readonly role: AdjudicationRole,
30
65
  private readonly events: PermissionEventBus,
31
66
  private readonly subscriptions: readonly (() => void)[],
32
67
  ) {}
33
68
 
34
69
  activate(ctx: ExtensionContext): void {
70
+ // Re-arm: a new session generation gets its own post-session_start
71
+ // announcement, so a consumer that loaded after this node still hears one.
72
+ this.announced = false;
73
+ const sessionId = readSessionId(ctx);
74
+ if (sessionId !== null) {
75
+ publishPermissionsService(sessionId, this.service);
76
+ this.publishedSessionId = sessionId;
77
+ }
35
78
  if (!this.detection.isRegisteredChild(ctx)) {
36
- publishPermissionsService(this.service);
79
+ publishRootPermissionsService(this.service);
80
+ }
81
+ this.emitReady(ctx);
82
+ }
83
+
84
+ announceReady(ctx: ExtensionContext): void {
85
+ if (this.announced) {
86
+ return;
37
87
  }
38
- emitReadyEvent(this.events);
88
+ this.announced = true;
89
+ this.emitReady(ctx);
39
90
  }
40
91
 
41
92
  teardown(): void {
42
93
  for (const unsubscribe of this.subscriptions) {
43
94
  unsubscribe();
44
95
  }
45
- unpublishPermissionsService(this.service);
96
+ if (this.publishedSessionId !== null) {
97
+ unpublishPermissionsService(this.publishedSessionId, this.service);
98
+ this.publishedSessionId = null;
99
+ }
100
+ unpublishRootPermissionsService(this.service);
101
+ }
102
+
103
+ /**
104
+ * The one place a ready payload is built, so the `session_start` emission and
105
+ * the latch emission cannot drift in shape: both read the node's facts from
106
+ * the context they are handed.
107
+ */
108
+ private emitReady(ctx: ExtensionContext): void {
109
+ emitReadyEvent(this.events, {
110
+ sessionId: readSessionId(ctx),
111
+ adjudicatesLocally: this.role.adjudicatesLocally(),
112
+ });
46
113
  }
47
114
  }
package/src/service.ts CHANGED
@@ -1,14 +1,24 @@
1
1
  /**
2
- * Cross-extension service accessor backed by `Symbol.for()` on `globalThis`.
2
+ * Cross-extension service accessors backed by `Symbol.for()` on `globalThis`.
3
3
  *
4
4
  * `Symbol.for()` is process-global by spec, so it survives jiti's per-extension
5
5
  * module isolation (`moduleCache: false`). A consumer doing
6
- * `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but
7
- * `getPermissionsService()` reads from the same `globalThis` slot the provider
8
- * wrote to — enabling direct, synchronous, type-safe function calls.
6
+ * `import("@gotgenes/pi-permission-system")` gets a fresh module copy, but the
7
+ * accessors here read from the same `globalThis` slots the provider wrote to —
8
+ * enabling direct, synchronous, type-safe function calls.
9
9
  *
10
- * Best practice: call `getPermissionsService()` per use rather than caching the
11
- * reference — this ensures resilience across `/reload` and load-order edge cases.
10
+ * There are two slots, because one process can host several **nodes** (one Pi
11
+ * session runtime each — a root session and its in-process subagent children
12
+ * all load their own instance of this extension):
13
+ *
14
+ * - A session-keyed map, written by every node under its own session id.
15
+ * `getPermissionsService(sessionId)` resolves the service whose
16
+ * registries that node's own gates and chain read (ADR 0012 decision 2).
17
+ * - A single legacy slot holding the process root's service, read by the
18
+ * deprecated `getRootPermissionsService()`.
19
+ *
20
+ * Best practice: resolve per use rather than caching the reference — this
21
+ * ensures resilience across `/reload` and load-order edge cases.
12
22
  */
13
23
 
14
24
  import type { Authorizer } from "./authority/authorizer";
@@ -62,9 +72,14 @@ export type {
62
72
  } from "./presentation/prompt-payload";
63
73
  export type { PermissionCheckResult, PermissionState, ToolInputFormatter };
64
74
 
65
- /** Process-global key for the service slot. */
75
+ /** Process-global key for the legacy (process-root) service slot. */
66
76
  const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
67
77
 
78
+ /** Process-global key for the session-keyed service map (ADR 0012 decision 2). */
79
+ const SESSION_SERVICES_KEY = Symbol.for(
80
+ "@gotgenes/pi-permission-system:session-services",
81
+ );
82
+
68
83
  /**
69
84
  * The narrow, read-only projection of {@link PermissionsService}: answer a
70
85
  * policy query for a surface, and report a tool-level state. This is the
@@ -104,7 +119,14 @@ export interface PermissionQuery {
104
119
  }
105
120
 
106
121
  /**
107
- * Public interface exposed to other extensions via `getPermissionsService()`.
122
+ * Public interface exposed to other extensions via
123
+ * {@link getPermissionsService} (or the deprecated
124
+ * {@link getRootPermissionsService}).
125
+ *
126
+ * Each instance belongs to one node, and its three registration surfaces are
127
+ * read by that node alone: extractors and formatters by its own gates, chain
128
+ * links by its own chain. Resolve the service of the node whose behavior you
129
+ * mean to affect.
108
130
  *
109
131
  * `checkPermission` takes a surface + optional value + optional agent name,
110
132
  * and delegates to `PermissionManager.checkPermission()` with current session
@@ -182,28 +204,176 @@ export interface PermissionsService extends PermissionQuery {
182
204
  }
183
205
 
184
206
  /**
185
- * Store a `PermissionsService` on `globalThis` so other extensions can
186
- * retrieve it via `getPermissionsService()`.
207
+ * Store a `PermissionsService` in the legacy process-root slot, read by
208
+ * `getRootPermissionsService()`.
187
209
  *
188
210
  * Called at `session_start` by the top-level (parent) instance only — an
189
211
  * in-process subagent child skips publishing so it cannot clobber the parent's
190
212
  * service. Overwrites any previously published service, which keeps `/reload`
191
213
  * working: a reloaded parent re-publishes its fresh service.
192
214
  */
193
- export function publishPermissionsService(service: PermissionsService): void {
215
+ export function publishRootPermissionsService(
216
+ service: PermissionsService,
217
+ ): void {
194
218
  (globalThis as Record<symbol, unknown>)[SERVICE_KEY] = service;
195
219
  }
196
220
 
197
221
  /**
198
- * Retrieve the published `PermissionsService`, or `undefined` if the
199
- * permission-system extension has not loaded (or has been unloaded).
222
+ * Warned at most once per module copy, so a consumer hears it on its first
223
+ * deprecated call and never again. Under jiti isolation each consumer
224
+ * extension holds its own copy, so each hears it for its own call site.
200
225
  */
201
- export function getPermissionsService(): PermissionsService | undefined {
226
+ let warnedDeprecatedAccessor = false;
227
+
228
+ const DEPRECATED_ACCESSOR_WARNING =
229
+ "getRootPermissionsService() is deprecated: it answers with the process root's " +
230
+ "service, which is the wrong node in every node but the root — inside an " +
231
+ "in-process subagent child it hands back the parent's service, so a " +
232
+ "registration lands where the child's gates never read it and a policy " +
233
+ "query answers against the parent's config. Use " +
234
+ "getPermissionsService(sessionId) with the sessionId from the " +
235
+ "permissions:ready payload. See " +
236
+ "https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/cross-extension-api.md";
237
+
238
+ /**
239
+ * Retrieve the process root's published `PermissionsService`, or `undefined`
240
+ * if the permission-system extension has not loaded (or has been unloaded).
241
+ *
242
+ * @deprecated Use {@link getPermissionsService} with the `sessionId`
243
+ * from the `permissions:ready` payload. This accessor answers "the process
244
+ * root's service", which is the wrong question in every node but the root.
245
+ * Removal is deferred to a future major (ADR 0012 decision 7).
246
+ */
247
+ export function getRootPermissionsService(): PermissionsService | undefined {
248
+ if (!warnedDeprecatedAccessor) {
249
+ warnedDeprecatedAccessor = true;
250
+ process.emitWarning(DEPRECATED_ACCESSOR_WARNING, {
251
+ type: "DeprecationWarning",
252
+ code: "PI_PERMISSION_SYSTEM_DEP0001",
253
+ });
254
+ }
255
+ return readRootService();
256
+ }
257
+
258
+ /**
259
+ * The undeprecated read of the root slot, for this package's own lifecycle.
260
+ *
261
+ * `unpublishRootPermissionsService` must compare identities without warning the
262
+ * host about a call the host did not make.
263
+ */
264
+ function readRootService(): PermissionsService | undefined {
202
265
  return (globalThis as Record<symbol, unknown>)[SERVICE_KEY] as
203
266
  | PermissionsService
204
267
  | undefined;
205
268
  }
206
269
 
270
+ /**
271
+ * The process-global map of session id → that node's service, created on first
272
+ * use.
273
+ *
274
+ * Backed by `globalThis` + `Symbol.for()` for the same reason the single slot
275
+ * above is: each session's `ResourceLoader` builds its own jiti instance, so a
276
+ * parent and its in-process child share no module state — only process globals.
277
+ */
278
+ function sessionServices(): Map<string, PermissionsService> {
279
+ const store = globalThis as Record<symbol, unknown>;
280
+ const existing = store[SESSION_SERVICES_KEY] as
281
+ | Map<string, PermissionsService>
282
+ | undefined;
283
+ if (existing) {
284
+ return existing;
285
+ }
286
+ const services = new Map<string, PermissionsService>();
287
+ store[SESSION_SERVICES_KEY] = services;
288
+ return services;
289
+ }
290
+
291
+ /**
292
+ * Publish `service` as the service of the node whose session is `sessionId`
293
+ * (ADR 0012 decision 2 — node-locality).
294
+ *
295
+ * Every node publishes under its own key, including an in-process subagent
296
+ * child, so there is nothing to clobber: a child's sibling extension registers
297
+ * an extractor, formatter, or chain link into the registry the child's own
298
+ * gates and chain read.
299
+ */
300
+ export function publishPermissionsService(
301
+ sessionId: string,
302
+ service: PermissionsService,
303
+ ): void {
304
+ sessionServices().set(sessionId, service);
305
+ }
306
+
307
+ /**
308
+ * Retrieve the service belonging to the node whose session is `sessionId`, or
309
+ * `undefined` when that node has published none.
310
+ *
311
+ * This is the supported way to obtain a node's service, for registration and
312
+ * for policy queries alike. Take `sessionId` from the `permissions:ready`
313
+ * payload (or from `ctx.sessionManager.getSessionId()` inside your own session
314
+ * handler), and resolve per use rather than caching the reference.
315
+ *
316
+ * A caller the type checker cannot reach — JavaScript, or a consumer compiled
317
+ * against an earlier major — may still call this with no argument. That answers
318
+ * `undefined` rather than another node's service, and warns once so the missing
319
+ * registration is not silent.
320
+ */
321
+ export function getPermissionsService(
322
+ sessionId: string,
323
+ ): PermissionsService | undefined {
324
+ if (typeof sessionId !== "string") {
325
+ warnMissingSessionId();
326
+ return undefined;
327
+ }
328
+ return sessionServices().get(sessionId);
329
+ }
330
+
331
+ const MISSING_SESSION_ID_WARNING =
332
+ "getPermissionsService() was called without a session id and answered " +
333
+ "undefined. It resolves the service of one node, so it needs the sessionId " +
334
+ "from the permissions:ready payload: getPermissionsService(sessionId). To " +
335
+ "read the process root's service — what the zero-arg call used to do — use " +
336
+ "the deprecated getRootPermissionsService(). See " +
337
+ "https://github.com/gotgenes/pi-packages/blob/main/packages/pi-permission-system/docs/cross-extension-api.md";
338
+
339
+ /**
340
+ * Warned at most once per module copy, like the deprecation guard above, so a
341
+ * consumer polling the locator every turn reports the defect once.
342
+ *
343
+ * Deliberately not a `DeprecationWarning`: an operator who silences those with
344
+ * `--no-deprecation` still needs to hear that a registration never landed.
345
+ */
346
+ let warnedMissingSessionId = false;
347
+
348
+ function warnMissingSessionId(): void {
349
+ if (warnedMissingSessionId) {
350
+ return;
351
+ }
352
+ warnedMissingSessionId = true;
353
+ process.emitWarning(MISSING_SESSION_ID_WARNING, {
354
+ type: "Warning",
355
+ code: "PI_PERMISSION_SYSTEM_WARN0001",
356
+ });
357
+ }
358
+
359
+ /**
360
+ * Remove the `sessionId` entry, but only when it still holds `service`
361
+ * (identity compare-and-delete, like {@link unpublishRootPermissionsService}).
362
+ *
363
+ * Scoping the delete to the publishing instance keeps a superseded `/reload`
364
+ * generation's late shutdown from wiping the new generation's freshly
365
+ * published service.
366
+ */
367
+ export function unpublishPermissionsService(
368
+ sessionId: string,
369
+ service: PermissionsService,
370
+ ): void {
371
+ const services = sessionServices();
372
+ if (services.get(sessionId) === service) {
373
+ services.delete(sessionId);
374
+ }
375
+ }
376
+
207
377
  /**
208
378
  * Remove `service` from `globalThis`, but only when the current slot still
209
379
  * holds it (identity compare-and-delete).
@@ -217,8 +387,10 @@ export function getPermissionsService(): PermissionsService | undefined {
217
387
  * - A superseded `/reload` generation no longer owns the slot, so its late
218
388
  * shutdown cannot wipe the new generation's freshly published service.
219
389
  */
220
- export function unpublishPermissionsService(service: PermissionsService): void {
221
- if (getPermissionsService() !== service) {
390
+ export function unpublishRootPermissionsService(
391
+ service: PermissionsService,
392
+ ): void {
393
+ if (readRootService() !== service) {
222
394
  return;
223
395
  }
224
396
  // eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
@@ -0,0 +1,33 @@
1
+ /**
2
+ * session-identity.ts — This node's own session id.
3
+ *
4
+ * One Pi session runtime is a **node** (ADR 0012): its own `ExtensionContext`,
5
+ * event bus, gates, and `PermissionSession`. The session id is how a node names
6
+ * itself to the rest of the process — it keys the subagent-child registry, the
7
+ * serving-heartbeat records, and the session-keyed service publication.
8
+ *
9
+ * The read is defensive because the id is not guaranteed to be reachable: a
10
+ * host may expose a session manager without one. An unavailable id is `null`
11
+ * rather than a throw, so a caller decides what to do without an unreachable
12
+ * id (skip a keyed publication, report "not a registered child") instead of
13
+ * failing session startup.
14
+ */
15
+
16
+ /** Narrow context: the only session-manager reader {@link readSessionId} consumes. */
17
+ export interface SessionIdentityContext {
18
+ sessionManager: {
19
+ getSessionId(): string;
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Return the session id `ctx` belongs to, or `null` when the host does not
25
+ * expose one (absent, empty, or throwing).
26
+ */
27
+ export function readSessionId(ctx: SessionIdentityContext): string | null {
28
+ try {
29
+ return ctx.sessionManager.getSessionId() || null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }