@gotgenes/pi-permission-system 10.0.0 → 10.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/agent-prep-session.ts +28 -0
  5. package/src/decision-reporter.ts +41 -0
  6. package/src/denial-messages.ts +11 -0
  7. package/src/forwarded-permissions/permission-forwarder.ts +549 -0
  8. package/src/forwarding-manager.ts +3 -7
  9. package/src/gate-handler-session.ts +13 -0
  10. package/src/gate-prompter.ts +14 -0
  11. package/src/handlers/before-agent-start.ts +2 -3
  12. package/src/handlers/gates/bash-command.ts +4 -18
  13. package/src/handlers/gates/bash-external-directory.ts +3 -15
  14. package/src/handlers/gates/bash-path.ts +3 -16
  15. package/src/handlers/gates/descriptor.ts +0 -28
  16. package/src/handlers/gates/path.ts +3 -15
  17. package/src/handlers/gates/runner.ts +142 -105
  18. package/src/handlers/gates/skill-input-gate-pipeline.ts +104 -0
  19. package/src/handlers/gates/skill-input.ts +44 -0
  20. package/src/handlers/gates/tool-call-gate-pipeline.ts +120 -0
  21. package/src/handlers/lifecycle.ts +9 -9
  22. package/src/handlers/permission-gate-handler.ts +34 -238
  23. package/src/index.ts +49 -69
  24. package/src/mcp-targets.ts +56 -46
  25. package/src/permission-prompter.ts +7 -58
  26. package/src/permission-resolver.ts +17 -0
  27. package/src/permission-session.ts +77 -9
  28. package/src/permissions-service.ts +53 -0
  29. package/src/service-lifecycle.ts +49 -0
  30. package/src/session-approval-recorder.ts +6 -0
  31. package/src/session-lifecycle-session.ts +24 -0
  32. package/src/tool-input-preview.ts +0 -62
  33. package/src/tool-input-prompt-formatters.ts +63 -0
  34. package/src/tool-preview-formatter.ts +6 -4
  35. package/test/decision-reporter.test.ts +112 -0
  36. package/test/denial-messages.test.ts +62 -0
  37. package/test/forwarding-manager.test.ts +26 -44
  38. package/test/handlers/before-agent-start.test.ts +45 -21
  39. package/test/handlers/external-directory-integration.test.ts +86 -22
  40. package/test/handlers/external-directory-session-dedup.test.ts +102 -55
  41. package/test/handlers/gates/bash-command.test.ts +49 -90
  42. package/test/handlers/gates/bash-external-directory.test.ts +54 -95
  43. package/test/handlers/gates/bash-path.test.ts +63 -148
  44. package/test/handlers/gates/path.test.ts +38 -105
  45. package/test/handlers/gates/runner.test.ts +150 -93
  46. package/test/handlers/gates/skill-input-gate-pipeline.test.ts +176 -0
  47. package/test/handlers/gates/skill-input.test.ts +128 -0
  48. package/test/handlers/gates/tool-call-gate-pipeline.test.ts +180 -0
  49. package/test/handlers/input.test.ts +1 -2
  50. package/test/handlers/lifecycle.test.ts +49 -33
  51. package/test/handlers/tool-call-events.test.ts +1 -1
  52. package/test/helpers/gate-fixtures.ts +147 -16
  53. package/test/helpers/handler-fixtures.ts +143 -27
  54. package/test/mcp-targets.test.ts +55 -0
  55. package/test/permission-forwarder.test.ts +295 -0
  56. package/test/permission-forwarding.test.ts +0 -282
  57. package/test/permission-prompter.test.ts +33 -44
  58. package/test/permission-session.test.ts +160 -27
  59. package/test/permissions-service.test.ts +151 -0
  60. package/test/runtime.test.ts +0 -4
  61. package/test/service-lifecycle.test.ts +162 -0
  62. package/test/tool-input-preview.test.ts +0 -111
  63. package/test/tool-input-prompt-formatters.test.ts +115 -0
  64. package/src/forwarded-permissions/polling.ts +0 -411
@@ -1,5 +1,28 @@
1
1
  import { getNonEmptyString, toRecord } from "./common";
2
2
 
3
+ /**
4
+ * An ordered accumulator that owns the uniqueness invariant.
5
+ *
6
+ * `add` ignores null/empty values and silently skips duplicates (first-insertion
7
+ * wins). `toArray` returns the ordered result as an independent copy.
8
+ */
9
+ export class McpTargetList {
10
+ private readonly targets: string[] = [];
11
+
12
+ add(value: string | null): void {
13
+ if (!value) {
14
+ return;
15
+ }
16
+ if (!this.targets.includes(value)) {
17
+ this.targets.push(value);
18
+ }
19
+ }
20
+
21
+ toArray(): string[] {
22
+ return [...this.targets];
23
+ }
24
+ }
25
+
3
26
  /**
4
27
  * Parse a qualified MCP tool name of the form `server:tool`.
5
28
  *
@@ -31,7 +54,7 @@ export function parseQualifiedMcpToolName(
31
54
  function addDerivedMcpServerTargets(
32
55
  toolName: string,
33
56
  configuredServerNames: readonly string[],
34
- pushTarget: (value: string | null) => void,
57
+ targets: McpTargetList,
35
58
  ): void {
36
59
  const trimmedToolName = toolName.trim();
37
60
  if (!trimmedToolName) {
@@ -52,9 +75,9 @@ function addDerivedMcpServerTargets(
52
75
  continue;
53
76
  }
54
77
 
55
- pushTarget(`${trimmedServerName}_${trimmedToolName}`);
56
- pushTarget(`${trimmedServerName}:${trimmedToolName}`);
57
- pushTarget(trimmedServerName);
78
+ targets.add(`${trimmedServerName}_${trimmedToolName}`);
79
+ targets.add(`${trimmedServerName}:${trimmedToolName}`);
80
+ targets.add(trimmedServerName);
58
81
  }
59
82
  }
60
83
 
@@ -62,22 +85,22 @@ function pushMcpToolPermissionTargets(
62
85
  rawReference: string,
63
86
  serverHint: string | null,
64
87
  configuredServerNames: readonly string[],
65
- pushTarget: (value: string | null) => void,
88
+ targets: McpTargetList,
66
89
  ): void {
67
90
  const qualified = parseQualifiedMcpToolName(rawReference);
68
91
  const resolvedServer = serverHint ?? qualified?.server ?? null;
69
92
  const resolvedTool = qualified?.tool ?? rawReference;
70
93
 
71
94
  if (resolvedServer) {
72
- pushTarget(`${resolvedServer}_${resolvedTool}`);
73
- pushTarget(`${resolvedServer}:${resolvedTool}`);
74
- pushTarget(resolvedServer);
95
+ targets.add(`${resolvedServer}_${resolvedTool}`);
96
+ targets.add(`${resolvedServer}:${resolvedTool}`);
97
+ targets.add(resolvedServer);
75
98
  } else {
76
- addDerivedMcpServerTargets(resolvedTool, configuredServerNames, pushTarget);
99
+ addDerivedMcpServerTargets(resolvedTool, configuredServerNames, targets);
77
100
  }
78
101
 
79
- pushTarget(resolvedTool);
80
- pushTarget(rawReference);
102
+ targets.add(resolvedTool);
103
+ targets.add(rawReference);
81
104
  }
82
105
 
83
106
  /**
@@ -98,32 +121,19 @@ export function createMcpPermissionTargets(
98
121
  const describe = getNonEmptyString(record.describe);
99
122
  const search = getNonEmptyString(record.search);
100
123
 
101
- const targets: string[] = [];
102
- const pushTarget = (value: string | null) => {
103
- if (!value) {
104
- return;
105
- }
106
- if (!targets.includes(value)) {
107
- targets.push(value);
108
- }
109
- };
124
+ const targets = new McpTargetList();
110
125
 
111
126
  if (tool) {
112
- pushMcpToolPermissionTargets(
113
- tool,
114
- server,
115
- configuredServerNames,
116
- pushTarget,
117
- );
118
- pushTarget("mcp_call");
119
- return targets;
127
+ pushMcpToolPermissionTargets(tool, server, configuredServerNames, targets);
128
+ targets.add("mcp_call");
129
+ return targets.toArray();
120
130
  }
121
131
 
122
132
  if (connect) {
123
- pushTarget(`mcp_connect_${connect}`);
124
- pushTarget(connect);
125
- pushTarget("mcp_connect");
126
- return targets;
133
+ targets.add(`mcp_connect_${connect}`);
134
+ targets.add(connect);
135
+ targets.add("mcp_connect");
136
+ return targets.toArray();
127
137
  }
128
138
 
129
139
  if (describe) {
@@ -131,30 +141,30 @@ export function createMcpPermissionTargets(
131
141
  describe,
132
142
  server,
133
143
  configuredServerNames,
134
- pushTarget,
144
+ targets,
135
145
  );
136
- pushTarget("mcp_describe");
137
- return targets;
146
+ targets.add("mcp_describe");
147
+ return targets.toArray();
138
148
  }
139
149
 
140
150
  if (search) {
141
151
  if (server) {
142
- pushTarget(`mcp_server_${server}`);
143
- pushTarget(server);
152
+ targets.add(`mcp_server_${server}`);
153
+ targets.add(server);
144
154
  }
145
155
 
146
- pushTarget(search);
147
- pushTarget("mcp_search");
148
- return targets;
156
+ targets.add(search);
157
+ targets.add("mcp_search");
158
+ return targets.toArray();
149
159
  }
150
160
 
151
161
  if (server) {
152
- pushTarget(`mcp_server_${server}`);
153
- pushTarget(server);
154
- pushTarget("mcp_list");
155
- return targets;
162
+ targets.add(`mcp_server_${server}`);
163
+ targets.add(server);
164
+ targets.add("mcp_list");
165
+ return targets.toArray();
156
166
  }
157
167
 
158
- pushTarget("mcp_status");
159
- return targets;
168
+ targets.add("mcp_status");
169
+ return targets.toArray();
160
170
  }
@@ -1,20 +1,12 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import type { PermissionSystemExtensionConfig } from "./extension-config";
3
- import type { ForwardedPermissionLogger } from "./forwarded-permissions/io";
4
- import {
5
- confirmPermission,
6
- type PermissionForwardingDeps,
7
- } from "./forwarded-permissions/polling";
8
- import type {
9
- PermissionPromptDecision,
10
- RequestPermissionOptions,
11
- } from "./permission-dialog";
3
+ import type { ApprovalRequester } from "./forwarded-permissions/permission-forwarder";
4
+ import type { PermissionPromptDecision } from "./permission-dialog";
12
5
  import {
13
6
  emitUiPromptEvent,
14
7
  type PermissionEventBus,
15
8
  } from "./permission-events";
16
9
  import { buildDirectUiPrompt } from "./permission-ui-prompt";
17
- import type { SubagentSessionRegistry } from "./subagent-registry";
18
10
  import { shouldAutoApprovePermissionState } from "./yolo-mode";
19
11
 
20
12
  export type PermissionReviewSource = "tool_call" | "skill_input" | "skill_read";
@@ -48,29 +40,18 @@ export interface PermissionPrompterApi {
48
40
  * Dependencies required by PermissionPrompter.
49
41
  *
50
42
  * Keeps the prompter's external surface narrow: callers provide config
51
- * access, review-log writing, path constants, and the UI dialog function.
52
- * The prompter synthesises the PermissionForwardingDeps it needs internally.
43
+ * access, review-log writing, the UI-prompt event bus, and the forwarder
44
+ * that owns the UI/subagent-forwarding branching logic.
53
45
  */
54
46
  export interface PermissionPrompterDeps {
55
47
  /** Read current config for yolo-mode check (called at prompt time). */
56
48
  getConfig(): PermissionSystemExtensionConfig;
57
49
  /** Write structured entries to the permission review log. */
58
50
  writeReviewLog(event: string, details: Record<string, unknown>): void;
59
- /** Directory containing subagent session state. */
60
- subagentSessionsDir: string;
61
- /** Directory used for file-based permission forwarding requests/responses. */
62
- forwardingDir: string;
63
- /** In-process subagent session registry for detection and forwarding target resolution. */
64
- registry?: SubagentSessionRegistry;
65
51
  /** Event bus used for UI prompt broadcasts. */
66
52
  events: PermissionEventBus;
67
- /** Show the interactive permission dialog in the UI. */
68
- requestPermissionDecisionFromUi(
69
- ui: ExtensionContext["ui"],
70
- title: string,
71
- message: string,
72
- options?: RequestPermissionOptions,
73
- ): Promise<PermissionPromptDecision>;
53
+ /** Resolves the permission decision: direct UI dialog or forwarded to parent. */
54
+ forwarder: ApprovalRequester;
74
55
  }
75
56
 
76
57
  /**
@@ -107,10 +88,9 @@ export class PermissionPrompter implements PermissionPrompterApi {
107
88
  emitUiPromptEvent(this.deps.events, uiPrompt);
108
89
  }
109
90
 
110
- const decision = await confirmPermission(
91
+ const decision = await this.deps.forwarder.requestApproval(
111
92
  ctx,
112
93
  details.message,
113
- this.buildForwardingDeps(),
114
94
  details.sessionLabel ? { sessionLabel: details.sessionLabel } : undefined,
115
95
  {
116
96
  source: uiPrompt.source,
@@ -158,35 +138,4 @@ export class PermissionPrompter implements PermissionPrompterApi {
158
138
  denialReason: details.denialReason ?? null,
159
139
  });
160
140
  }
161
-
162
- /**
163
- * Build a PermissionForwardingDeps to pass to confirmPermission.
164
- *
165
- * Yolo-mode is already handled at the prompter level, so shouldAutoApprove
166
- * returns false here (confirmPermission does not call it; only
167
- * processForwardedPermissionRequests does, and that has its own deps).
168
- *
169
- * The logger delegates writeReviewLog to deps and uses a no-op writeDebugLog
170
- * (trace-level forwarding debug is deferred — see open question in the plan).
171
- */
172
- private buildForwardingDeps(): PermissionForwardingDeps {
173
- const { deps } = this;
174
- const logger: ForwardedPermissionLogger = {
175
- // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
176
- writeReviewLog: deps.writeReviewLog,
177
- writeDebugLog: () => undefined,
178
- };
179
- return {
180
- forwardingDir: deps.forwardingDir,
181
- subagentSessionsDir: deps.subagentSessionsDir,
182
- registry: deps.registry,
183
- events: deps.events,
184
- logger,
185
- // eslint-disable-next-line @typescript-eslint/unbound-method -- logger methods are plain function closures; no this-binding issue
186
- writeReviewLog: deps.writeReviewLog,
187
- // eslint-disable-next-line @typescript-eslint/unbound-method -- same as above
188
- requestPermissionDecisionFromUi: deps.requestPermissionDecisionFromUi,
189
- shouldAutoApprove: () => false,
190
- };
191
- }
192
141
  }
@@ -0,0 +1,17 @@
1
+ import type { PermissionCheckResult } from "./types";
2
+
3
+ /**
4
+ * Resolves the effective permission for a surface/input, applying the current
5
+ * session rules internally.
6
+ *
7
+ * Collapses the `checkPermission` + `getSessionRuleset` relay that every gate
8
+ * previously threaded by hand: the ruleset was only ever fetched to be passed
9
+ * straight back into `checkPermission`, so the two are one operation.
10
+ */
11
+ export interface PermissionResolver {
12
+ resolve(
13
+ surface: string,
14
+ input: unknown,
15
+ agentName?: string,
16
+ ): PermissionCheckResult;
17
+ }
@@ -4,18 +4,28 @@ import {
4
4
  getActiveAgentName,
5
5
  getActiveAgentNameFromSystemPrompt,
6
6
  } from "./active-agent";
7
+ import type { AgentPrepSession } from "./agent-prep-session";
7
8
  import type { PermissionSystemExtensionConfig } from "./extension-config";
8
9
  import type { ExtensionPaths } from "./extension-paths";
9
10
  import type { ForwardingController } from "./forwarding-manager";
11
+ import type { GateHandlerSession } from "./gate-handler-session";
12
+ import type { GatePrompter } from "./gate-prompter";
10
13
  import type { PermissionPromptDecision } from "./permission-dialog";
11
14
  import type { PermissionManager } from "./permission-manager";
12
15
  import type { PromptPermissionDetails } from "./permission-prompter";
16
+ import type { PermissionResolver } from "./permission-resolver";
13
17
  import type { Rule } from "./rule";
14
18
  import { createPermissionManagerForCwd } from "./runtime";
15
19
  import type { SessionApproval } from "./session-approval";
20
+ import type { SessionApprovalRecorder } from "./session-approval-recorder";
21
+ import type { SessionLifecycleSession } from "./session-lifecycle-session";
16
22
  import type { SessionLogger } from "./session-logger";
17
23
  import { SessionRules } from "./session-rules";
18
24
  import type { SkillPromptEntry } from "./skill-prompt-sanitizer";
25
+ import {
26
+ resolveToolPreviewLimits,
27
+ type ToolPreviewFormatterOptions,
28
+ } from "./tool-preview-formatter";
19
29
  import type { PermissionCheckResult, PermissionState } from "./types";
20
30
 
21
31
  /**
@@ -54,7 +64,15 @@ export interface PermissionSessionRuntimeDeps {
54
64
  * - `ForwardingController` — polling lifecycle
55
65
  * - `PermissionSessionRuntimeDeps` — config refresh + log delegates
56
66
  */
57
- export class PermissionSession {
67
+ export class PermissionSession
68
+ implements
69
+ PermissionResolver,
70
+ SessionApprovalRecorder,
71
+ GatePrompter,
72
+ GateHandlerSession,
73
+ AgentPrepSession,
74
+ SessionLifecycleSession
75
+ {
58
76
  private context: ExtensionContext | null = null;
59
77
  private permissionManager: PermissionManager;
60
78
  private readonly sessionRules = new SessionRules();
@@ -110,6 +128,24 @@ export class PermissionSession {
110
128
  );
111
129
  }
112
130
 
131
+ /**
132
+ * Resolve the effective permission for a surface/input, applying the current
133
+ * session rules. Composes `checkPermission` with `getSessionRuleset` so
134
+ * callers never thread the ruleset by hand.
135
+ */
136
+ resolve(
137
+ surface: string,
138
+ input: unknown,
139
+ agentName?: string,
140
+ ): PermissionCheckResult {
141
+ return this.checkPermission(
142
+ surface,
143
+ input,
144
+ agentName,
145
+ this.getSessionRuleset(),
146
+ );
147
+ }
148
+
113
149
  getToolPermission(toolName: string, agentName?: string): PermissionState {
114
150
  return this.permissionManager.getToolPermission(toolName, agentName);
115
151
  }
@@ -251,13 +287,25 @@ export class PermissionSession {
251
287
 
252
288
  // ── Infrastructure paths ───────────────────────────────────────────────
253
289
 
254
- getInfrastructureDirs(): readonly string[] {
255
- return this.paths.piInfrastructureDirs;
290
+ /**
291
+ * Combined infrastructure read directories: static paths from
292
+ * `ExtensionPaths` plus config-derived paths.
293
+ */
294
+ getInfrastructureReadDirs(): string[] {
295
+ return [
296
+ ...this.paths.piInfrastructureDirs,
297
+ ...(this.config.piInfrastructureReadPaths ?? []),
298
+ ];
256
299
  }
257
300
 
258
- /** Config-derived infrastructure read paths (current at call time). */
259
- getInfrastructureReadPaths(): string[] {
260
- return this.config.piInfrastructureReadPaths ?? [];
301
+ /**
302
+ * Resolved tool-preview formatter options from the current config.
303
+ *
304
+ * Replaces the handler's `resolveToolPreviewLimits(session.config)` reach
305
+ * so the pipeline reads a clean value rather than pulling raw config.
306
+ */
307
+ getToolPreviewLimits(): ToolPreviewFormatterOptions {
308
+ return resolveToolPreviewLimits(this.config);
261
309
  }
262
310
 
263
311
  // ── Prompting ──────────────────────────────────────────────────────────
@@ -275,8 +323,28 @@ export class PermissionSession {
275
323
  return this.runtimeDeps.promptPermission(ctx, details);
276
324
  }
277
325
 
278
- /** Generate a unique ID for a permission request. */
279
- createPermissionRequestId(prefix: string): string {
280
- return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}-${process.pid}`;
326
+ /**
327
+ * Whether an interactive confirmation is possible using the stored context.
328
+ * Returns `false` when no context is active (before `activate` is called).
329
+ * Implements {@link GatePrompter}.
330
+ */
331
+ canConfirm(): boolean {
332
+ return this.context !== null && this.canPrompt(this.context);
333
+ }
334
+
335
+ /**
336
+ * Prompt the user for a permission decision using the stored context.
337
+ * Throws if no context is active — `canConfirm()` guards this in normal use.
338
+ * Implements {@link GatePrompter}.
339
+ */
340
+ promptPermission(
341
+ details: PromptPermissionDetails,
342
+ ): Promise<PermissionPromptDecision> {
343
+ if (this.context === null) {
344
+ return Promise.reject(
345
+ new Error("promptPermission called before the session was activated"),
346
+ );
347
+ }
348
+ return this.prompt(this.context, details);
281
349
  }
282
350
  }
@@ -0,0 +1,53 @@
1
+ import { buildInputForSurface } from "./input-normalizer";
2
+ import type { PermissionManager } from "./permission-manager";
3
+ import type { PermissionsService } from "./service";
4
+ import type { SessionRules } from "./session-rules";
5
+ import type {
6
+ ToolInputFormatter,
7
+ ToolInputFormatterRegistry,
8
+ } from "./tool-input-formatter-registry";
9
+
10
+ /**
11
+ * In-process implementation of the cross-extension {@link PermissionsService}.
12
+ *
13
+ * Constructed once in the composition root and backed by the runtime's
14
+ * permission manager and session rules. Both injected instances are stable
15
+ * for the lifetime of the factory — `runtime.permissionManager` is never
16
+ * reassigned on the runtime object (only `PermissionSession` reassigns its
17
+ * own internal copy), and `runtime.sessionRules` is `readonly`.
18
+ */
19
+ export class LocalPermissionsService implements PermissionsService {
20
+ constructor(
21
+ private readonly permissionManager: PermissionManager,
22
+ private readonly sessionRules: SessionRules,
23
+ private readonly formatterRegistry: ToolInputFormatterRegistry,
24
+ ) {}
25
+
26
+ checkPermission(
27
+ surface: string,
28
+ value?: string,
29
+ agentName?: string,
30
+ ): ReturnType<PermissionsService["checkPermission"]> {
31
+ const input = buildInputForSurface(surface, value);
32
+ return this.permissionManager.checkPermission(
33
+ surface,
34
+ input,
35
+ agentName,
36
+ this.sessionRules.getRuleset(),
37
+ );
38
+ }
39
+
40
+ getToolPermission(
41
+ toolName: string,
42
+ agentName?: string,
43
+ ): ReturnType<PermissionsService["getToolPermission"]> {
44
+ return this.permissionManager.getToolPermission(toolName, agentName);
45
+ }
46
+
47
+ registerToolInputFormatter(
48
+ toolName: string,
49
+ formatter: ToolInputFormatter,
50
+ ): ReturnType<PermissionsService["registerToolInputFormatter"]> {
51
+ return this.formatterRegistry.register(toolName, formatter);
52
+ }
53
+ }
@@ -0,0 +1,49 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { emitReadyEvent, type PermissionEventBus } from "./permission-events";
4
+ import {
5
+ type PermissionsService,
6
+ publishPermissionsService,
7
+ unpublishPermissionsService,
8
+ } from "./service";
9
+ import { isRegisteredSubagentChild } from "./subagent-context";
10
+ import type { SubagentSessionRegistry } from "./subagent-registry";
11
+
12
+ /** The session-scoped service lifecycle that the lifecycle handler drives. */
13
+ export interface ServiceLifecycle {
14
+ activate(ctx: ExtensionContext): void;
15
+ teardown(): void;
16
+ }
17
+
18
+ /**
19
+ * Owns the process-global service publication lifecycle for one extension
20
+ * instance.
21
+ *
22
+ * - `activate` publishes the service (skipped for registered subagent children
23
+ * so they never clobber the parent's slot — see #302), then emits the ready
24
+ * event.
25
+ * - `teardown` runs all session-scoped subscription cleanups in order, then
26
+ * unpublishes the service.
27
+ */
28
+ export class PermissionServiceLifecycle implements ServiceLifecycle {
29
+ constructor(
30
+ private readonly service: PermissionsService,
31
+ private readonly registry: SubagentSessionRegistry,
32
+ private readonly events: PermissionEventBus,
33
+ private readonly subscriptions: readonly (() => void)[],
34
+ ) {}
35
+
36
+ activate(ctx: ExtensionContext): void {
37
+ if (!isRegisteredSubagentChild(ctx, this.registry)) {
38
+ publishPermissionsService(this.service);
39
+ }
40
+ emitReadyEvent(this.events);
41
+ }
42
+
43
+ teardown(): void {
44
+ for (const unsubscribe of this.subscriptions) {
45
+ unsubscribe();
46
+ }
47
+ unpublishPermissionsService(this.service);
48
+ }
49
+ }
@@ -0,0 +1,6 @@
1
+ import type { SessionApproval } from "./session-approval";
2
+
3
+ /** Records a granted session-scoped approval into the session ruleset. */
4
+ export interface SessionApprovalRecorder {
5
+ recordSessionApproval(approval: SessionApproval): void;
6
+ }
@@ -0,0 +1,24 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import type { SessionLogger } from "./session-logger";
4
+
5
+ /**
6
+ * The session surface `SessionLifecycleHandler` invokes across
7
+ * `session_start`, `resources_discover`, and `session_shutdown`: refresh and
8
+ * report config, reset / reload / shut down session state, resolve the agent
9
+ * name, surface config issues, read the runtime context, and log.
10
+ *
11
+ * `activate` is intentionally absent — the lifecycle handler never calls it
12
+ * directly (ISP: do not depend on methods you do not use).
13
+ */
14
+ export interface SessionLifecycleSession {
15
+ refreshConfig(ctx?: ExtensionContext): void;
16
+ resetForNewSession(ctx: ExtensionContext): void;
17
+ logResolvedConfigPaths(): void;
18
+ resolveAgentName(ctx: ExtensionContext, systemPrompt?: string): string | null;
19
+ getConfigIssues(agentName?: string): string[];
20
+ reload(): void;
21
+ getRuntimeContext(): ExtensionContext | null;
22
+ shutdown(): void;
23
+ readonly logger: SessionLogger;
24
+ }
@@ -1,4 +1,3 @@
1
- import { getNonEmptyString, toRecord } from "./common";
2
1
  import { safeJsonStringify } from "./logging";
3
2
 
4
3
  export const TOOL_INPUT_PREVIEW_MAX_LENGTH = 200;
@@ -25,67 +24,6 @@ export function formatCount(
25
24
  return `${value} ${value === 1 ? singular : plural}`;
26
25
  }
27
26
 
28
- export function getPromptPath(input: Record<string, unknown>): string | null {
29
- return getNonEmptyString(input.path) ?? getNonEmptyString(input.file_path);
30
- }
31
-
32
- export function formatEditInputForPrompt(
33
- input: Record<string, unknown>,
34
- ): string {
35
- const path = getPromptPath(input);
36
- const rawEdits = Array.isArray(input.edits)
37
- ? input.edits
38
- : typeof input.oldText === "string" && typeof input.newText === "string"
39
- ? [{ oldText: input.oldText, newText: input.newText }]
40
- : [];
41
-
42
- const edits = rawEdits
43
- .map((edit) => toRecord(edit))
44
- .filter(
45
- (edit) =>
46
- typeof edit.oldText === "string" && typeof edit.newText === "string",
47
- );
48
-
49
- const pathPart = path ? `for '${path}'` : "";
50
- if (edits.length === 0) {
51
- return pathPart ? `${pathPart} with edit input` : "with edit input";
52
- }
53
-
54
- const firstEdit = edits[0];
55
- const oldText = String(firstEdit.oldText);
56
- const newText = String(firstEdit.newText);
57
- const firstEditSummary = `edit #1 replaces ${formatCount(countTextLines(oldText), "line", "lines")} with ${formatCount(countTextLines(newText), "line", "lines")}`;
58
- const extraEdits =
59
- edits.length > 1
60
- ? `, plus ${formatCount(edits.length - 1, "additional edit", "additional edits")}`
61
- : "";
62
- const summary = `(${formatCount(edits.length, "replacement", "replacements")}: ${firstEditSummary}${extraEdits})`;
63
- return pathPart ? `${pathPart} ${summary}` : summary;
64
- }
65
-
66
- export function formatWriteInputForPrompt(
67
- input: Record<string, unknown>,
68
- ): string {
69
- const path = getPromptPath(input);
70
- const content = typeof input.content === "string" ? input.content : "";
71
- const summary = `(${formatCount(countTextLines(content), "line", "lines")}, ${formatCount(content.length, "character", "characters")})`;
72
- return path ? `for '${path}' ${summary}` : summary;
73
- }
74
-
75
- export function formatReadInputForPrompt(
76
- input: Record<string, unknown>,
77
- ): string {
78
- const path = getPromptPath(input);
79
- const parts = path ? [`path '${path}'`] : [];
80
- if (typeof input.offset === "number") {
81
- parts.push(`offset ${input.offset}`);
82
- }
83
- if (typeof input.limit === "number") {
84
- parts.push(`limit ${input.limit}`);
85
- }
86
- return parts.length > 0 ? `for ${parts.join(", ")}` : "";
87
- }
88
-
89
27
  export function serializeToolInputPreview(input: unknown): string {
90
28
  const serialized = safeJsonStringify(input);
91
29
  if (!serialized || serialized === "{}" || serialized === "null") {