@gotgenes/pi-permission-system 7.4.1 → 8.1.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,35 @@ 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
+ ## [8.1.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.0.0...pi-permission-system-v8.1.0) (2026-05-31)
9
+
10
+
11
+ ### Features
12
+
13
+ * add toolInputPreviewMaxLength and toolTextSummaryMaxLength config fields ([#266](https://github.com/gotgenes/pi-packages/issues/266)) ([3a7dafb](https://github.com/gotgenes/pi-packages/commit/3a7dafbb0bb8534dabda7eeba6c4d35ba2e8708b))
14
+ * use configured preview limits in permission prompts ([#266](https://github.com/gotgenes/pi-packages/issues/266)) ([83e2829](https://github.com/gotgenes/pi-packages/commit/83e2829175a55f2f0436c742e19e3753ee171e47))
15
+
16
+
17
+ ### Documentation
18
+
19
+ * document configurable tool-preview length knobs ([#266](https://github.com/gotgenes/pi-packages/issues/266)) ([6d0b134](https://github.com/gotgenes/pi-packages/commit/6d0b134be4ef4c90ddf582b32058c3ec9d2eb13f))
20
+
21
+ ## [8.0.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.4.1...pi-permission-system-v8.0.0) (2026-05-30)
22
+
23
+
24
+ ### ⚠ BREAKING CHANGES
25
+
26
+ * `registerSubagentSession` and `unregisterSubagentSession` are removed from the `PermissionsService` interface and its implementation. The `SubagentSessionInfo` type is no longer re-exported from the public service module.
27
+
28
+ ### Features
29
+
30
+ * remove inbound subagent-registration methods from PermissionsService ([#267](https://github.com/gotgenes/pi-packages/issues/267)) ([552735a](https://github.com/gotgenes/pi-packages/commit/552735a97eec939fc06130bce059c78f03eb8e58))
31
+
32
+
33
+ ### Documentation
34
+
35
+ * **pi-permission-system:** describe event-driven subagent registration ([#267](https://github.com/gotgenes/pi-packages/issues/267)) ([8c39b87](https://github.com/gotgenes/pi-packages/commit/8c39b8785aa389d96b5f38996711d8aa3dbeb284))
36
+
8
37
  ## [7.4.1](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.4.0...pi-permission-system-v7.4.1) (2026-05-30)
9
38
 
10
39
 
@@ -5,6 +5,9 @@
5
5
  "permissionReviewLog": true,
6
6
  "yoloMode": false,
7
7
 
8
+ "toolInputPreviewMaxLength": 400,
9
+ "toolTextSummaryMaxLength": 120,
10
+
8
11
  "piInfrastructureReadPaths": [],
9
12
 
10
13
  "permission": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "7.4.1",
3
+ "version": "8.1.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,6 +29,18 @@
29
29
  "type": "boolean",
30
30
  "default": false
31
31
  },
32
+ "toolInputPreviewMaxLength": {
33
+ "description": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts. Omit to use the default (200). Set to a large value to disable truncation.",
34
+ "markdownDescription": "Maximum character length of the inline-JSON tool-input preview shown in permission prompts.\n\nOmit to use the default (200). Set to a large value (e.g. `10000`) to effectively disable truncation and see the full input.",
35
+ "type": "integer",
36
+ "minimum": 1
37
+ },
38
+ "toolTextSummaryMaxLength": {
39
+ "description": "Maximum character length of inline pattern/path summaries (e.g. grep patterns, find globs, ls paths) in permission prompts. Omit to use the default (80).",
40
+ "markdownDescription": "Maximum character length of inline pattern/path summaries (e.g. grep patterns, find globs, ls paths) shown in permission prompts.\n\nOmit to use the default (80). Increase this when working with long regexes or deep paths that are being cut off.",
41
+ "type": "integer",
42
+ "minimum": 1
43
+ },
32
44
  "piInfrastructureReadPaths": {
33
45
  "description": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the external_directory gate. Supports ~ expansion and wildcard patterns (* and ?).",
34
46
  "markdownDescription": "Additional directories to auto-allow for reads as Pi infrastructure, bypassing the `external_directory` gate.\n\nThe extension auto-discovers the global node_modules root (walks up from the extension's install path; falls back to `npm root -g` from a dev checkout), `agentDir`, `agentDir/git`, and project-local `.pi/npm/` and `.pi/git/`. Add entries here for edge cases where auto-discovery is insufficient (e.g. custom `npmCommand` pointing to pnpm).\n\nSupports `~`/`$HOME` expansion. Entries may be plain directory prefixes or wildcard patterns using `*` (matches any characters, including `/`) and `?` (matches exactly one character). `**` and `*` are equivalent — both cross directory boundaries.",
@@ -12,6 +12,10 @@ export interface PermissionSystemExtensionConfig {
12
12
  yoloMode: boolean;
13
13
  /** Additional directories to auto-allow for reads as Pi infrastructure. */
14
14
  piInfrastructureReadPaths?: string[];
15
+ /** Max length of the inline-JSON input preview shown in permission prompts. Defaults to 200. */
16
+ toolInputPreviewMaxLength?: number;
17
+ /** Max length of inline pattern/path summaries (grep/find/ls) in permission prompts. Defaults to 80. */
18
+ toolTextSummaryMaxLength?: number;
15
19
  }
16
20
 
17
21
  export const DEFAULT_EXTENSION_CONFIG: PermissionSystemExtensionConfig = {
@@ -42,6 +46,13 @@ export function detectMisplacedPermissionKeys(
42
46
  return Object.keys(raw).filter((key) => PERMISSION_POLICY_KEYS.has(key));
43
47
  }
44
48
 
49
+ /** Returns `raw` if it is a positive integer; otherwise `undefined`. */
50
+ export function normalizeOptionalPositiveInt(raw: unknown): number | undefined {
51
+ return typeof raw === "number" && Number.isInteger(raw) && raw > 0
52
+ ? raw
53
+ : undefined;
54
+ }
55
+
45
56
  export function normalizePermissionSystemConfig(
46
57
  raw: unknown,
47
58
  ): PermissionSystemExtensionConfig {
@@ -60,6 +71,18 @@ export function normalizePermissionSystemConfig(
60
71
  if (piInfrastructureReadPaths !== undefined) {
61
72
  result.piInfrastructureReadPaths = piInfrastructureReadPaths;
62
73
  }
74
+ const toolInputPreviewMaxLength = normalizeOptionalPositiveInt(
75
+ record.toolInputPreviewMaxLength,
76
+ );
77
+ if (toolInputPreviewMaxLength !== undefined) {
78
+ result.toolInputPreviewMaxLength = toolInputPreviewMaxLength;
79
+ }
80
+ const toolTextSummaryMaxLength = normalizeOptionalPositiveInt(
81
+ record.toolTextSummaryMaxLength,
82
+ );
83
+ if (toolTextSummaryMaxLength !== undefined) {
84
+ result.toolTextSummaryMaxLength = toolTextSummaryMaxLength;
85
+ }
63
86
  return result;
64
87
  }
65
88
 
@@ -1,7 +1,7 @@
1
1
  import { getPathBearingToolPath, PATH_BEARING_TOOLS } from "#src/path-utils";
2
2
  import { suggestSessionPattern } from "#src/pattern-suggest";
3
3
  import { formatAskPrompt } from "#src/permission-prompts";
4
- import { getPermissionLogContext } from "#src/tool-input-preview";
4
+ import type { ToolPreviewFormatter } from "#src/tool-preview-formatter";
5
5
  import type { PermissionCheckResult } from "#src/types";
6
6
  import type { GateDescriptor } from "./descriptor";
7
7
  import { deriveDecisionValue } from "./helpers";
@@ -31,8 +31,9 @@ function deriveSuggestionValue(
31
31
  export function describeToolGate(
32
32
  tcc: ToolCallContext,
33
33
  check: PermissionCheckResult,
34
+ formatter: ToolPreviewFormatter,
34
35
  ): GateDescriptor {
35
- const permissionLogContext = getPermissionLogContext(
36
+ const permissionLogContext = formatter.getPermissionLogContext(
36
37
  check,
37
38
  tcc.input,
38
39
  PATH_BEARING_TOOLS,
@@ -48,6 +49,7 @@ export function describeToolGate(
48
49
  check,
49
50
  tcc.agentName ?? undefined,
50
51
  tcc.input,
52
+ formatter,
51
53
  );
52
54
 
53
55
  return {
@@ -16,6 +16,10 @@ import {
16
16
  formatUnknownToolReason,
17
17
  } from "#src/permission-prompts";
18
18
  import type { PermissionSession } from "#src/permission-session";
19
+ import {
20
+ resolveToolPreviewLimits,
21
+ ToolPreviewFormatter,
22
+ } from "#src/tool-preview-formatter";
19
23
  import {
20
24
  checkRequestedToolRegistration,
21
25
  getToolNameFromValue,
@@ -23,7 +27,7 @@ import {
23
27
  } from "#src/tool-registry";
24
28
  import { describeBashExternalDirectoryGate } from "./gates/bash-external-directory";
25
29
  import { describeBashPathGate } from "./gates/bash-path";
26
- import type { GateRunnerDeps } from "./gates/descriptor";
30
+ import type { GateResult, GateRunnerDeps } from "./gates/descriptor";
27
31
  import { isGateBypass } from "./gates/descriptor";
28
32
  import { describeExternalDirectoryGate } from "./gates/external-directory";
29
33
  import { describePathGate } from "./gates/path";
@@ -58,30 +62,13 @@ export class PermissionGateHandler {
58
62
  ): Promise<{ block?: true; reason?: string }> {
59
63
  this.session.activate(ctx);
60
64
 
61
- const agentName = this.session.resolveAgentName(ctx);
62
- const toolName = getToolNameFromValue(event);
63
-
64
- if (!toolName) {
65
- return { block: true, reason: formatMissingToolNameReason() };
66
- }
67
-
68
- const registrationCheck = checkRequestedToolRegistration(
69
- toolName,
70
- this.toolRegistry.getAll(),
71
- );
72
- if (registrationCheck.status === "missing-tool-name") {
73
- return { block: true, reason: formatMissingToolNameReason() };
65
+ const validation = validateRequestedTool(event, this.toolRegistry.getAll());
66
+ if (validation.status === "block") {
67
+ return { block: true, reason: validation.reason };
74
68
  }
69
+ const toolName = validation.toolName;
75
70
 
76
- if (registrationCheck.status === "unregistered") {
77
- return {
78
- block: true,
79
- reason: formatUnknownToolReason(
80
- registrationCheck.requestedToolName,
81
- registrationCheck.availableToolNames,
82
- ),
83
- };
84
- }
71
+ const agentName = this.session.resolveAgentName(ctx);
85
72
 
86
73
  const input = getEventInput(event);
87
74
  const toolCallId =
@@ -126,136 +113,78 @@ export class PermissionGateHandler {
126
113
  promptPermission,
127
114
  };
128
115
 
129
- // ── Skill-read gate (descriptor + runner) ───────────────────────────────
130
- const skillDescriptor = describeSkillReadGate(tcc, () =>
131
- this.session.getActiveSkillEntries(),
132
- );
133
- if (skillDescriptor) {
134
- const skillResult = await runGateCheck(
135
- skillDescriptor,
116
+ // ── Unified gate executor ─────────────────────────────────────────────
117
+ // Handles the bypass log/emit branch, calls runGateCheck for descriptors,
118
+ // and returns a block result or undefined (allow / no-op).
119
+ const runGate = async (
120
+ gate: GateResult,
121
+ ): Promise<{ block: true; reason: string } | undefined> => {
122
+ if (!gate) {
123
+ return undefined;
124
+ }
125
+ if (isGateBypass(gate)) {
126
+ if (gate.log) {
127
+ writeReviewLog(gate.log.event, gate.log.details);
128
+ }
129
+ if (gate.decision) {
130
+ emitDecision(gate.decision);
131
+ }
132
+ return undefined;
133
+ }
134
+ const result = await runGateCheck(
135
+ gate,
136
136
  tcc.agentName,
137
137
  tcc.toolCallId,
138
138
  runnerDeps,
139
139
  );
140
- if (skillResult.action === "block") {
141
- return { block: true, reason: skillResult.reason };
142
- }
143
- }
140
+ return result.action === "block"
141
+ ? { block: true, reason: result.reason }
142
+ : undefined;
143
+ };
144
144
 
145
- // ── Path gate for tools (descriptor + runner) ────────────────────────────
146
- const pathDesc = describePathGate(tcc, checkPermission, getSessionRuleset);
147
- if (pathDesc) {
148
- if (isGateBypass(pathDesc)) {
149
- if (pathDesc.log) {
150
- writeReviewLog(pathDesc.log.event, pathDesc.log.details);
151
- }
152
- } else {
153
- const pathResult = await runGateCheck(
154
- pathDesc,
155
- tcc.agentName,
156
- tcc.toolCallId,
157
- runnerDeps,
158
- );
159
- if (pathResult.action === "block") {
160
- return { block: true, reason: pathResult.reason };
161
- }
162
- }
163
- }
145
+ const formatter = new ToolPreviewFormatter(
146
+ resolveToolPreviewLimits(this.session.config),
147
+ );
164
148
 
165
- // ── External-directory gate (descriptor + runner) ────────────────────────
149
+ // ── Ordered gate pipeline ─────────────────────────────────────────────
150
+ // infraDirs is computed once, outside the pipeline, exactly as before.
166
151
  const infraDirs = [
167
152
  ...this.session.getInfrastructureDirs(),
168
153
  ...this.session.getInfrastructureReadPaths(),
169
154
  ];
170
- const extDirDesc = describeExternalDirectoryGate(tcc, infraDirs);
171
- if (extDirDesc) {
172
- if (isGateBypass(extDirDesc)) {
173
- if (extDirDesc.log) {
174
- writeReviewLog(extDirDesc.log.event, extDirDesc.log.details);
175
- }
176
- if (extDirDesc.decision) {
177
- emitDecision(extDirDesc.decision);
178
- }
179
- } else {
180
- const extDirResult = await runGateCheck(
181
- extDirDesc,
182
- tcc.agentName,
183
- tcc.toolCallId,
184
- runnerDeps,
185
- );
186
- if (extDirResult.action === "block") {
187
- return { block: true, reason: extDirResult.reason };
188
- }
189
- }
190
- }
191
155
 
192
- // ── Bash external-directory gate (descriptor + runner) ───────────────────
193
- const bashExtDesc = await describeBashExternalDirectoryGate(
194
- tcc,
195
- checkPermission,
196
- getSessionRuleset,
197
- );
198
- if (bashExtDesc) {
199
- if (isGateBypass(bashExtDesc)) {
200
- if (bashExtDesc.log) {
201
- writeReviewLog(bashExtDesc.log.event, bashExtDesc.log.details);
202
- }
203
- } else {
204
- const bashExtResult = await runGateCheck(
205
- bashExtDesc,
206
- tcc.agentName,
207
- tcc.toolCallId,
208
- runnerDeps,
156
+ const gateProducers: Array<() => GateResult | Promise<GateResult>> = [
157
+ () =>
158
+ describeSkillReadGate(tcc, () => this.session.getActiveSkillEntries()),
159
+ () => describePathGate(tcc, checkPermission, getSessionRuleset),
160
+ () => describeExternalDirectoryGate(tcc, infraDirs),
161
+ () =>
162
+ describeBashExternalDirectoryGate(
163
+ tcc,
164
+ checkPermission,
165
+ getSessionRuleset,
166
+ ),
167
+ () => describeBashPathGate(tcc, checkPermission, getSessionRuleset),
168
+ () => {
169
+ const toolCheck = checkPermission(
170
+ tcc.toolName,
171
+ tcc.input,
172
+ tcc.agentName ?? undefined,
173
+ getSessionRuleset(),
209
174
  );
210
- if (bashExtResult.action === "block") {
211
- return { block: true, reason: bashExtResult.reason };
212
- }
213
- }
214
- }
175
+ const toolDescriptor = describeToolGate(tcc, toolCheck, formatter);
176
+ toolDescriptor.preCheck = toolCheck;
177
+ return toolDescriptor;
178
+ },
179
+ ];
215
180
 
216
- // ── Bash path gate (descriptor + runner) ────────────────────────────────
217
- const bashPathDesc = await describeBashPathGate(
218
- tcc,
219
- checkPermission,
220
- getSessionRuleset,
221
- );
222
- if (bashPathDesc) {
223
- if (isGateBypass(bashPathDesc)) {
224
- if (bashPathDesc.log) {
225
- writeReviewLog(bashPathDesc.log.event, bashPathDesc.log.details);
226
- }
227
- } else {
228
- const bashPathResult = await runGateCheck(
229
- bashPathDesc,
230
- tcc.agentName,
231
- tcc.toolCallId,
232
- runnerDeps,
233
- );
234
- if (bashPathResult.action === "block") {
235
- return { block: true, reason: bashPathResult.reason };
236
- }
181
+ for (const produce of gateProducers) {
182
+ const blocked = await runGate(await produce());
183
+ if (blocked) {
184
+ return blocked;
237
185
  }
238
186
  }
239
187
 
240
- // ── Normal tool permission gate (descriptor + runner) ────────────────────
241
- const toolCheck = checkPermission(
242
- tcc.toolName,
243
- tcc.input,
244
- tcc.agentName ?? undefined,
245
- getSessionRuleset(),
246
- );
247
- const toolDescriptor = describeToolGate(tcc, toolCheck);
248
- toolDescriptor.preCheck = toolCheck;
249
- const toolResult = await runGateCheck(
250
- toolDescriptor,
251
- tcc.agentName,
252
- tcc.toolCallId,
253
- runnerDeps,
254
- );
255
- if (toolResult.action === "block") {
256
- return { block: true, reason: toolResult.reason };
257
- }
258
-
259
188
  return {};
260
189
  }
261
190
 
@@ -352,7 +281,46 @@ export class PermissionGateHandler {
352
281
  }
353
282
  }
354
283
 
355
- // ── Pure helpers (re-exported from original modules) ──────────────────────
284
+ // ── Pure helpers ─────────────────────────────────────────────────────────
285
+
286
+ /** Discriminated result of validating a tool-call event's name and registration. */
287
+ export type RequestedToolValidation =
288
+ | { status: "ok"; toolName: string }
289
+ | { status: "block"; reason: string };
290
+
291
+ /**
292
+ * Validate the tool name from a raw event against the registered tool list.
293
+ *
294
+ * Composes `getToolNameFromValue` + `checkRequestedToolRegistration` + the
295
+ * two reason formatters and returns a discriminated result so `handleToolCall`
296
+ * reads as a straight validate → proceed path without nested early-returns.
297
+ *
298
+ * Returns the **raw** tool name (not the normalised form) so that
299
+ * `ToolCallContext.toolName` stays identical to the pre-extraction behaviour.
300
+ */
301
+ export function validateRequestedTool(
302
+ event: unknown,
303
+ availableTools: readonly unknown[],
304
+ ): RequestedToolValidation {
305
+ const toolName = getToolNameFromValue(event);
306
+ if (!toolName) {
307
+ return { status: "block", reason: formatMissingToolNameReason() };
308
+ }
309
+ const check = checkRequestedToolRegistration(toolName, availableTools);
310
+ if (check.status === "missing-tool-name") {
311
+ return { status: "block", reason: formatMissingToolNameReason() };
312
+ }
313
+ if (check.status === "unregistered") {
314
+ return {
315
+ status: "block",
316
+ reason: formatUnknownToolReason(
317
+ check.requestedToolName,
318
+ check.availableToolNames,
319
+ ),
320
+ };
321
+ }
322
+ return { status: "ok", toolName };
323
+ }
356
324
 
357
325
  /**
358
326
  * Extract the tool input from an event, checking both `input` and `arguments`
package/src/index.ts CHANGED
@@ -118,12 +118,6 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
118
118
  sessionRules,
119
119
  );
120
120
  },
121
- registerSubagentSession(sessionKey, info) {
122
- subagentRegistry.register(sessionKey, info);
123
- },
124
- unregisterSubagentSession(sessionKey) {
125
- subagentRegistry.unregister(sessionKey);
126
- },
127
121
  getToolPermission(toolName, agentName) {
128
122
  return runtime.permissionManager.getToolPermission(toolName, agentName);
129
123
  },
@@ -1,5 +1,5 @@
1
1
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
2
- import { formatToolInputForPrompt } from "./tool-input-preview";
2
+ import type { ToolPreviewFormatter } from "./tool-preview-formatter";
3
3
  import type { PermissionCheckResult } from "./types";
4
4
 
5
5
  // NOTE: formatDenyReason, formatUserDeniedReason, and
@@ -31,6 +31,7 @@ export function formatAskPrompt(
31
31
  result: PermissionCheckResult,
32
32
  agentName?: string,
33
33
  input?: unknown,
34
+ formatter?: ToolPreviewFormatter,
34
35
  ): string {
35
36
  const subject = agentName ? `Agent '${agentName}'` : "Current agent";
36
37
 
@@ -51,7 +52,9 @@ export function formatAskPrompt(
51
52
  const patternInfo = result.matchedPattern
52
53
  ? ` (matched '${result.matchedPattern}')`
53
54
  : "";
54
- const inputPreview = formatToolInputForPrompt(result.toolName, input);
55
+ const inputPreview = formatter
56
+ ? formatter.formatToolInputForPrompt(result.toolName, input)
57
+ : "";
55
58
  const inputSuffix = inputPreview ? ` ${inputPreview}` : "";
56
59
  return `${subject} requested tool '${result.toolName}'${patternInfo}${inputSuffix}. Allow this call?`;
57
60
  }
package/src/service.ts CHANGED
@@ -11,10 +11,9 @@
11
11
  * reference — this ensures resilience across `/reload` and load-order edge cases.
12
12
  */
13
13
 
14
- import type { SubagentSessionInfo } from "./subagent-registry";
15
14
  import type { PermissionCheckResult, PermissionState } from "./types";
16
15
 
17
- export type { PermissionCheckResult, PermissionState, SubagentSessionInfo };
16
+ export type { PermissionCheckResult, PermissionState };
18
17
 
19
18
  /** Process-global key for the service slot. */
20
19
  const SERVICE_KEY = Symbol.for("@gotgenes/pi-permission-system:service");
@@ -44,27 +43,6 @@ export interface PermissionsService {
44
43
  agentName?: string,
45
44
  ): PermissionCheckResult;
46
45
 
47
- /**
48
- * Register an in-process subagent session.
49
- *
50
- * Call this before `bindExtensions()` so that `isSubagentExecutionContext()`
51
- * and permission-forwarding target resolution can detect the child session.
52
- * Always pair with `unregisterSubagentSession()` in a `finally` block.
53
- *
54
- * @param sessionKey - Unique session identifier (use the session directory path).
55
- * @param info - Agent name and optional parent session ID.
56
- */
57
- registerSubagentSession(sessionKey: string, info: SubagentSessionInfo): void;
58
-
59
- /**
60
- * Remove a previously registered in-process subagent session.
61
- *
62
- * Safe to call even if `registerSubagentSession` was never called for this key.
63
- *
64
- * @param sessionKey - The same key passed to `registerSubagentSession`.
65
- */
66
- unregisterSubagentSession(sessionKey: string): void;
67
-
68
46
  /**
69
47
  * Query the tool-level permission state for pre-filtering tools before
70
48
  * creating a child session.
@@ -23,9 +23,9 @@ export interface SubagentSessionInfo {
23
23
  /**
24
24
  * Registry of active in-process subagent sessions.
25
25
  *
26
- * Owned by `ExtensionRuntime`; exposed to external callers through the
27
- * `PermissionsService` interface (`registerSubagentSession` /
28
- * `unregisterSubagentSession`).
26
+ * Owned by `ExtensionRuntime`; written exclusively by `subscribeSubagentLifecycle`
27
+ * via the `subagents:child:session-created` / `subagents:child:disposed` event
28
+ * subscription (ADR 0002 — the core publishes, consumers observe).
29
29
  *
30
30
  * Concurrent background agents are safe because each session has a unique
31
31
  * directory path as its key — no scalar global flag is needed.