@gotgenes/pi-permission-system 8.3.0 → 8.3.2
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 +27 -0
- package/package.json +1 -1
- package/src/forwarded-permissions/polling.ts +1 -2
- package/src/index.ts +2 -2
- package/src/permission-forwarding.ts +4 -4
- package/src/subagent-context.ts +15 -6
- package/src/subagent-lifecycle-events.ts +6 -6
- package/src/subagent-registry.ts +66 -21
- package/test/permission-forwarding.test.ts +12 -15
- package/test/subagent-context.test.ts +39 -17
- package/test/subagent-lifecycle-events.test.ts +37 -18
- package/test/subagent-registry.test.ts +89 -38
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,33 @@ 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.3.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.3.1...pi-permission-system-v8.3.2) (2026-06-01)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **pi-permission-system:** key subagent registry by session id and drop vestigial agentName ([d299c54](https://github.com/gotgenes/pi-packages/commit/d299c5421f41ab0829fb83fcf4e030d1c7af6d56))
|
|
14
|
+
* **pi-permission-system:** resolve subagent detection and forwarding target by session id ([0f7e079](https://github.com/gotgenes/pi-packages/commit/0f7e0795b911797e645f4d44c42bb314bf0cb103))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Documentation
|
|
18
|
+
|
|
19
|
+
* **retro:** add retro notes for issue [#296](https://github.com/gotgenes/pi-packages/issues/296) ([75743ab](https://github.com/gotgenes/pi-packages/commit/75743abe92604de142ff6e77c9c0fbc44266e12a))
|
|
20
|
+
|
|
21
|
+
## [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)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Bug Fixes
|
|
25
|
+
|
|
26
|
+
* add process-global SubagentSessionRegistry accessor ([#296](https://github.com/gotgenes/pi-packages/issues/296)) ([d3fd3b0](https://github.com/gotgenes/pi-packages/commit/d3fd3b04223b2d276873094ad8c14f239654b8c8))
|
|
27
|
+
* share SubagentSessionRegistry across parent and child sessions ([#296](https://github.com/gotgenes/pi-packages/issues/296)) ([fed676a](https://github.com/gotgenes/pi-packages/commit/fed676aaa485abe8db158e522ba898705f3dff94))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
### Documentation
|
|
31
|
+
|
|
32
|
+
* explain process-global subagent registry across session buses ([#296](https://github.com/gotgenes/pi-packages/issues/296)) ([1804dbb](https://github.com/gotgenes/pi-packages/commit/1804dbbb766d7b7fbc0e49da877f3238f5c3e8dc))
|
|
33
|
+
* use ADR-NNNN with links docs-wide ([c6b6431](https://github.com/gotgenes/pi-packages/commit/c6b6431c004f324931f23be46cf2e47e8fdac919))
|
|
34
|
+
|
|
8
35
|
## [8.3.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v8.2.1...pi-permission-system-v8.3.0) (2026-06-01)
|
|
9
36
|
|
|
10
37
|
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
117
|
+
sessionId: requesterSessionId,
|
|
119
118
|
registry: deps.registry,
|
|
120
119
|
});
|
|
121
120
|
|
package/src/index.ts
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import { createSessionLogger } from "./session-logger";
|
|
30
30
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
31
31
|
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
32
|
-
import {
|
|
32
|
+
import { getSubagentSessionRegistry } from "./subagent-registry";
|
|
33
33
|
import { ToolInputFormatterRegistry } from "./tool-input-formatter-registry";
|
|
34
34
|
import {
|
|
35
35
|
canResolveAskPermissionRequest,
|
|
@@ -38,7 +38,7 @@ import {
|
|
|
38
38
|
|
|
39
39
|
export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
40
40
|
const runtime = createExtensionRuntime();
|
|
41
|
-
const subagentRegistry =
|
|
41
|
+
const subagentRegistry = getSubagentSessionRegistry();
|
|
42
42
|
const formatterRegistry = new ToolInputFormatterRegistry();
|
|
43
43
|
registerBuiltinToolInputFormatters(formatterRegistry);
|
|
44
44
|
|
|
@@ -119,8 +119,8 @@ export function resolvePermissionForwardingTargetSessionId(options: {
|
|
|
119
119
|
isSubagent: boolean;
|
|
120
120
|
currentSessionId?: string | null;
|
|
121
121
|
env?: NodeJS.ProcessEnv;
|
|
122
|
-
/**
|
|
123
|
-
|
|
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.
|
|
137
|
-
const entry = options.registry.get(options.
|
|
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/subagent-context.ts
CHANGED
|
@@ -33,14 +33,23 @@ export function isSubagentExecutionContext(
|
|
|
33
33
|
subagentSessionsDir: string,
|
|
34
34
|
registry?: SubagentSessionRegistry,
|
|
35
35
|
): boolean {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
if (registry
|
|
41
|
-
|
|
36
|
+
// 1. Explicit registry — in-process subagent extensions register by child
|
|
37
|
+
// session id before bindExtensions(); checked first so it takes priority
|
|
38
|
+
// over heuristics. Each concurrent sibling has a unique session id, so
|
|
39
|
+
// one sibling's disposed event cannot affect another's registration.
|
|
40
|
+
if (registry) {
|
|
41
|
+
try {
|
|
42
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
43
|
+
if (sessionId && registry.has(sessionId)) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
} catch {
|
|
47
|
+
// getSessionId() unavailable — fall through to env/filesystem detection.
|
|
48
|
+
}
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
52
|
+
|
|
44
53
|
// 2. Env vars — process-based subagent extensions (nicobailon/pi-subagents,
|
|
45
54
|
// HazAT/pi-interactive-subagents, pi-agent-router, etc.).
|
|
46
55
|
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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
65
|
+
registry.unregister(event.sessionId);
|
|
66
66
|
});
|
|
67
67
|
|
|
68
68
|
return () => {
|
package/src/subagent-registry.ts
CHANGED
|
@@ -7,28 +7,73 @@
|
|
|
7
7
|
* can detect them without relying on environment variables or filesystem
|
|
8
8
|
* heuristics.
|
|
9
9
|
*
|
|
10
|
-
* The registry is keyed by session
|
|
11
|
-
*
|
|
12
|
-
* `
|
|
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.
|
|
16
|
+
*
|
|
17
|
+
* The single registry instance is stored on `globalThis` (via `Symbol.for()`)
|
|
18
|
+
* so that the parent's permission-system instance (which registers children
|
|
19
|
+
* on the parent's event bus) and each child's separate jiti instance (which
|
|
20
|
+
* reads the registry to detect itself and resolve its forwarding target) share
|
|
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.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Process-global key for the shared registry slot. */
|
|
29
|
+
const SUBAGENT_SESSION_REGISTRY_KEY = Symbol.for(
|
|
30
|
+
"@gotgenes/pi-permission-system:subagent-registry",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Return the process-global SubagentSessionRegistry, creating it on first call.
|
|
35
|
+
*
|
|
36
|
+
* Backed by `globalThis` + `Symbol.for()` so the parent's permission-system
|
|
37
|
+
* instance (which registers children on the parent event bus) and each child's
|
|
38
|
+
* separate jiti instance (which reads the registry to detect itself and resolve
|
|
39
|
+
* its forwarding target) share one store across per-session event buses.
|
|
40
|
+
*
|
|
41
|
+
* Intentionally has no shutdown/unpublish hook — a child's `session_shutdown`
|
|
42
|
+
* must not be able to wipe the parent's registrations. Entries are added and
|
|
43
|
+
* removed exclusively by the parent's `subagents:child:session-created` /
|
|
44
|
+
* `subagents:child:disposed` subscription.
|
|
13
45
|
*/
|
|
46
|
+
export function getSubagentSessionRegistry(): SubagentSessionRegistry {
|
|
47
|
+
const store = globalThis as Record<symbol, unknown>;
|
|
48
|
+
const existing = store[SUBAGENT_SESSION_REGISTRY_KEY] as
|
|
49
|
+
| SubagentSessionRegistry
|
|
50
|
+
| undefined;
|
|
51
|
+
if (existing) {
|
|
52
|
+
return existing;
|
|
53
|
+
}
|
|
54
|
+
const registry = new SubagentSessionRegistry();
|
|
55
|
+
store[SUBAGENT_SESSION_REGISTRY_KEY] = registry;
|
|
56
|
+
return registry;
|
|
57
|
+
}
|
|
14
58
|
|
|
15
59
|
/** Signal stored per registered in-process subagent session. */
|
|
16
60
|
export interface SubagentSessionInfo {
|
|
17
61
|
/** Parent session ID for permission forwarding. Omit when unknown. */
|
|
18
62
|
parentSessionId?: string;
|
|
19
|
-
/** Agent name for per-agent policy resolution. */
|
|
20
|
-
agentName: string;
|
|
21
63
|
}
|
|
22
64
|
|
|
23
65
|
/**
|
|
24
66
|
* Registry of active in-process subagent sessions.
|
|
25
67
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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).
|
|
29
73
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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.
|
|
32
77
|
*/
|
|
33
78
|
export class SubagentSessionRegistry {
|
|
34
79
|
private readonly sessions = new Map<string, SubagentSessionInfo>();
|
|
@@ -36,25 +81,25 @@ export class SubagentSessionRegistry {
|
|
|
36
81
|
/**
|
|
37
82
|
* Register an in-process subagent session.
|
|
38
83
|
*
|
|
39
|
-
* If a previous entry exists for `
|
|
84
|
+
* If a previous entry exists for `sessionId`, it is overwritten
|
|
40
85
|
* (last-write-wins; single-writer expected per key).
|
|
41
86
|
*/
|
|
42
|
-
register(
|
|
43
|
-
this.sessions.set(
|
|
87
|
+
register(sessionId: string, info: SubagentSessionInfo): void {
|
|
88
|
+
this.sessions.set(sessionId, info);
|
|
44
89
|
}
|
|
45
90
|
|
|
46
91
|
/** Remove a previously registered session. No-op if the key is absent. */
|
|
47
|
-
unregister(
|
|
48
|
-
this.sessions.delete(
|
|
92
|
+
unregister(sessionId: string): void {
|
|
93
|
+
this.sessions.delete(sessionId);
|
|
49
94
|
}
|
|
50
95
|
|
|
51
|
-
/** Return the registered info for `
|
|
52
|
-
get(
|
|
53
|
-
return this.sessions.get(
|
|
96
|
+
/** Return the registered info for `sessionId`, or `undefined` if absent. */
|
|
97
|
+
get(sessionId: string): SubagentSessionInfo | undefined {
|
|
98
|
+
return this.sessions.get(sessionId);
|
|
54
99
|
}
|
|
55
100
|
|
|
56
|
-
/** Return `true` when `
|
|
57
|
-
has(
|
|
58
|
-
return this.sessions.has(
|
|
101
|
+
/** Return `true` when `sessionId` has a registered entry. */
|
|
102
|
+
has(sessionId: string): boolean {
|
|
103
|
+
return this.sessions.has(sessionId);
|
|
59
104
|
}
|
|
60
105
|
}
|
|
@@ -149,13 +149,11 @@ describe("resolvePermissionForwardingTargetSessionId", () => {
|
|
|
149
149
|
});
|
|
150
150
|
|
|
151
151
|
describe("resolvePermissionForwardingTargetSessionId — registry resolution", () => {
|
|
152
|
-
const
|
|
153
|
-
"/home/user/projects/.pi/sessions/parent/tasks/session-abc";
|
|
152
|
+
const childSessionId = "child-session-abc";
|
|
154
153
|
|
|
155
154
|
test("returns parentSessionId from registry when env vars are absent", () => {
|
|
156
155
|
const registry = new SubagentSessionRegistry();
|
|
157
|
-
registry.register(
|
|
158
|
-
agentName: "Explore",
|
|
156
|
+
registry.register(childSessionId, {
|
|
159
157
|
parentSessionId: "parent-from-registry",
|
|
160
158
|
});
|
|
161
159
|
|
|
@@ -163,7 +161,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
|
|
|
163
161
|
resolvePermissionForwardingTargetSessionId({
|
|
164
162
|
hasUI: false,
|
|
165
163
|
isSubagent: true,
|
|
166
|
-
|
|
164
|
+
sessionId: childSessionId,
|
|
167
165
|
registry,
|
|
168
166
|
env: {},
|
|
169
167
|
}),
|
|
@@ -172,8 +170,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
|
|
|
172
170
|
|
|
173
171
|
test("registry takes priority over env vars", () => {
|
|
174
172
|
const registry = new SubagentSessionRegistry();
|
|
175
|
-
registry.register(
|
|
176
|
-
agentName: "Explore",
|
|
173
|
+
registry.register(childSessionId, {
|
|
177
174
|
parentSessionId: "parent-from-registry",
|
|
178
175
|
});
|
|
179
176
|
|
|
@@ -181,7 +178,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
|
|
|
181
178
|
resolvePermissionForwardingTargetSessionId({
|
|
182
179
|
hasUI: false,
|
|
183
180
|
isSubagent: true,
|
|
184
|
-
|
|
181
|
+
sessionId: childSessionId,
|
|
185
182
|
registry,
|
|
186
183
|
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
|
|
187
184
|
}),
|
|
@@ -190,27 +187,27 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
|
|
|
190
187
|
|
|
191
188
|
test("falls through to env vars when registry entry has no parentSessionId", () => {
|
|
192
189
|
const registry = new SubagentSessionRegistry();
|
|
193
|
-
registry.register(
|
|
190
|
+
registry.register(childSessionId, {}); // no parentSessionId
|
|
194
191
|
|
|
195
192
|
expect(
|
|
196
193
|
resolvePermissionForwardingTargetSessionId({
|
|
197
194
|
hasUI: false,
|
|
198
195
|
isSubagent: true,
|
|
199
|
-
|
|
196
|
+
sessionId: childSessionId,
|
|
200
197
|
registry,
|
|
201
198
|
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
|
|
202
199
|
}),
|
|
203
200
|
).toBe("parent-from-env");
|
|
204
201
|
});
|
|
205
202
|
|
|
206
|
-
test("falls through to env vars when
|
|
203
|
+
test("falls through to env vars when sessionId is not in registry", () => {
|
|
207
204
|
const registry = new SubagentSessionRegistry(); // empty
|
|
208
205
|
|
|
209
206
|
expect(
|
|
210
207
|
resolvePermissionForwardingTargetSessionId({
|
|
211
208
|
hasUI: false,
|
|
212
209
|
isSubagent: true,
|
|
213
|
-
|
|
210
|
+
sessionId: childSessionId,
|
|
214
211
|
registry,
|
|
215
212
|
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
|
|
216
213
|
}),
|
|
@@ -219,13 +216,13 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
|
|
|
219
216
|
|
|
220
217
|
test("returns null when registry entry has no parentSessionId and no env vars set", () => {
|
|
221
218
|
const registry = new SubagentSessionRegistry();
|
|
222
|
-
registry.register(
|
|
219
|
+
registry.register(childSessionId, {}); // no parentSessionId
|
|
223
220
|
|
|
224
221
|
expect(
|
|
225
222
|
resolvePermissionForwardingTargetSessionId({
|
|
226
223
|
hasUI: false,
|
|
227
224
|
isSubagent: true,
|
|
228
|
-
|
|
225
|
+
sessionId: childSessionId,
|
|
229
226
|
registry,
|
|
230
227
|
env: {},
|
|
231
228
|
}),
|
|
@@ -237,7 +234,7 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
|
|
|
237
234
|
resolvePermissionForwardingTargetSessionId({
|
|
238
235
|
hasUI: false,
|
|
239
236
|
isSubagent: true,
|
|
240
|
-
|
|
237
|
+
sessionId: childSessionId,
|
|
241
238
|
env: { PI_AGENT_ROUTER_PARENT_SESSION_ID: "parent-from-env" },
|
|
242
239
|
}),
|
|
243
240
|
).toBe("parent-from-env");
|
|
@@ -12,10 +12,14 @@ afterEach(() => {
|
|
|
12
12
|
vi.restoreAllMocks();
|
|
13
13
|
});
|
|
14
14
|
|
|
15
|
-
function makeCtx(
|
|
15
|
+
function makeCtx(
|
|
16
|
+
sessionDir: string | null,
|
|
17
|
+
sessionId: string = "",
|
|
18
|
+
): ExtensionContext {
|
|
16
19
|
return {
|
|
17
20
|
sessionManager: {
|
|
18
21
|
getSessionDir: vi.fn(() => sessionDir),
|
|
22
|
+
getSessionId: vi.fn(() => sessionId),
|
|
19
23
|
},
|
|
20
24
|
} as unknown as ExtensionContext;
|
|
21
25
|
}
|
|
@@ -203,57 +207,75 @@ describe("isSubagentExecutionContext — registry detection", () => {
|
|
|
203
207
|
const subagentRoot = "/home/user/.pi/agent/sessions/subagents";
|
|
204
208
|
const outsideDir =
|
|
205
209
|
"/home/user/projects/my-app/.pi/agent/sessions/parent/tasks";
|
|
210
|
+
const childSessionId = "child-session-abc";
|
|
206
211
|
|
|
207
|
-
test("returns true when session
|
|
212
|
+
test("returns true when session id is registered (no env vars, dir outside filesystem root)", () => {
|
|
208
213
|
const registry = new SubagentSessionRegistry();
|
|
209
|
-
registry.register(
|
|
214
|
+
registry.register(childSessionId, {});
|
|
210
215
|
expect(
|
|
211
|
-
isSubagentExecutionContext(
|
|
216
|
+
isSubagentExecutionContext(
|
|
217
|
+
makeCtx(outsideDir, childSessionId),
|
|
218
|
+
subagentRoot,
|
|
219
|
+
registry,
|
|
220
|
+
),
|
|
212
221
|
).toBe(true);
|
|
213
222
|
});
|
|
214
223
|
|
|
215
224
|
test("returns true when registered session has a parentSessionId", () => {
|
|
216
225
|
const registry = new SubagentSessionRegistry();
|
|
217
|
-
registry.register(
|
|
218
|
-
agentName: "Plan",
|
|
219
|
-
parentSessionId: "parent-123",
|
|
220
|
-
});
|
|
226
|
+
registry.register(childSessionId, { parentSessionId: "parent-123" });
|
|
221
227
|
expect(
|
|
222
|
-
isSubagentExecutionContext(
|
|
228
|
+
isSubagentExecutionContext(
|
|
229
|
+
makeCtx(outsideDir, childSessionId),
|
|
230
|
+
subagentRoot,
|
|
231
|
+
registry,
|
|
232
|
+
),
|
|
223
233
|
).toBe(true);
|
|
224
234
|
});
|
|
225
235
|
|
|
226
|
-
test("returns false when registry is provided but session
|
|
236
|
+
test("returns false when registry is provided but session id is not registered", () => {
|
|
227
237
|
const registry = new SubagentSessionRegistry();
|
|
228
238
|
expect(
|
|
229
|
-
isSubagentExecutionContext(
|
|
239
|
+
isSubagentExecutionContext(
|
|
240
|
+
makeCtx(outsideDir, childSessionId),
|
|
241
|
+
subagentRoot,
|
|
242
|
+
registry,
|
|
243
|
+
),
|
|
230
244
|
).toBe(false);
|
|
231
245
|
});
|
|
232
246
|
|
|
233
|
-
test("returns false when session
|
|
247
|
+
test("returns false when session id is empty and registry has no matching entry", () => {
|
|
234
248
|
const registry = new SubagentSessionRegistry();
|
|
235
249
|
expect(
|
|
236
|
-
isSubagentExecutionContext(makeCtx(null), subagentRoot, registry),
|
|
250
|
+
isSubagentExecutionContext(makeCtx(null, ""), subagentRoot, registry),
|
|
237
251
|
).toBe(false);
|
|
238
252
|
});
|
|
239
253
|
|
|
240
254
|
test("registry check takes priority over env var detection", () => {
|
|
241
255
|
// Registry says registered; env var not set — should still return true.
|
|
242
256
|
const registry = new SubagentSessionRegistry();
|
|
243
|
-
registry.register(
|
|
257
|
+
registry.register(childSessionId, {});
|
|
244
258
|
// Confirm no env var is set
|
|
245
259
|
expect(process.env.PI_IS_SUBAGENT).toBeUndefined();
|
|
246
260
|
expect(
|
|
247
|
-
isSubagentExecutionContext(
|
|
261
|
+
isSubagentExecutionContext(
|
|
262
|
+
makeCtx(outsideDir, childSessionId),
|
|
263
|
+
subagentRoot,
|
|
264
|
+
registry,
|
|
265
|
+
),
|
|
248
266
|
).toBe(true);
|
|
249
267
|
});
|
|
250
268
|
|
|
251
269
|
test("unregistered session falls through to env var detection", () => {
|
|
252
270
|
vi.stubEnv("PI_IS_SUBAGENT", "true");
|
|
253
|
-
const registry = new SubagentSessionRegistry(); // empty —
|
|
271
|
+
const registry = new SubagentSessionRegistry(); // empty — childSessionId not registered
|
|
254
272
|
// Env var present → still true even without registry entry
|
|
255
273
|
expect(
|
|
256
|
-
isSubagentExecutionContext(
|
|
274
|
+
isSubagentExecutionContext(
|
|
275
|
+
makeCtx(outsideDir, childSessionId),
|
|
276
|
+
subagentRoot,
|
|
277
|
+
registry,
|
|
278
|
+
),
|
|
257
279
|
).toBe(true);
|
|
258
280
|
});
|
|
259
281
|
|
|
@@ -19,13 +19,11 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
19
19
|
subscribeSubagentLifecycle(bus, registry);
|
|
20
20
|
|
|
21
21
|
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
22
|
-
|
|
23
|
-
agentName: "Explore",
|
|
22
|
+
sessionId: "child-session-abc",
|
|
24
23
|
parentSessionId: "parent-42",
|
|
25
24
|
});
|
|
26
25
|
|
|
27
|
-
expect(registry.get("
|
|
28
|
-
agentName: "Explore",
|
|
26
|
+
expect(registry.get("child-session-abc")).toEqual({
|
|
29
27
|
parentSessionId: "parent-42",
|
|
30
28
|
});
|
|
31
29
|
});
|
|
@@ -40,12 +38,11 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
40
38
|
subscribeSubagentLifecycle(bus, registry);
|
|
41
39
|
|
|
42
40
|
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
43
|
-
|
|
44
|
-
agentName: "Explore",
|
|
41
|
+
sessionId: "child-session-sync",
|
|
45
42
|
});
|
|
46
43
|
|
|
47
44
|
// No await between emit and this assertion.
|
|
48
|
-
expect(registry.has("
|
|
45
|
+
expect(registry.has("child-session-sync")).toBe(true);
|
|
49
46
|
});
|
|
50
47
|
|
|
51
48
|
it("omits parentSessionId when the event does not carry one", () => {
|
|
@@ -53,12 +50,10 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
53
50
|
subscribeSubagentLifecycle(bus, registry);
|
|
54
51
|
|
|
55
52
|
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
56
|
-
|
|
57
|
-
agentName: "general-purpose",
|
|
53
|
+
sessionId: "child-session-xyz",
|
|
58
54
|
});
|
|
59
55
|
|
|
60
|
-
expect(registry.get("
|
|
61
|
-
agentName: "general-purpose",
|
|
56
|
+
expect(registry.get("child-session-xyz")).toEqual({
|
|
62
57
|
parentSessionId: undefined,
|
|
63
58
|
});
|
|
64
59
|
});
|
|
@@ -66,11 +61,11 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
66
61
|
it("unregisters a child session on disposed", () => {
|
|
67
62
|
const bus = createEventBus();
|
|
68
63
|
subscribeSubagentLifecycle(bus, registry);
|
|
69
|
-
registry.register("
|
|
64
|
+
registry.register("child-session-abc", { parentSessionId: "parent-42" });
|
|
70
65
|
|
|
71
|
-
bus.emit(SUBAGENT_CHILD_DISPOSED, {
|
|
66
|
+
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
|
|
72
67
|
|
|
73
|
-
expect(registry.has("
|
|
68
|
+
expect(registry.has("child-session-abc")).toBe(false);
|
|
74
69
|
});
|
|
75
70
|
|
|
76
71
|
it("detaches both handlers when the returned unsubscribe is called", () => {
|
|
@@ -80,12 +75,11 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
80
75
|
unsubscribe();
|
|
81
76
|
|
|
82
77
|
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
83
|
-
|
|
84
|
-
agentName: "Explore",
|
|
78
|
+
sessionId: "child-session-abc",
|
|
85
79
|
});
|
|
86
|
-
bus.emit(SUBAGENT_CHILD_DISPOSED, {
|
|
80
|
+
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-session-abc" });
|
|
87
81
|
|
|
88
|
-
expect(registry.has("
|
|
82
|
+
expect(registry.has("child-session-abc")).toBe(false);
|
|
89
83
|
});
|
|
90
84
|
|
|
91
85
|
it("subscribes to a fake bus on the exact channel names", () => {
|
|
@@ -110,4 +104,29 @@ describe("subscribeSubagentLifecycle", () => {
|
|
|
110
104
|
);
|
|
111
105
|
expect(SUBAGENT_CHILD_DISPOSED).toBe("subagents:child:disposed");
|
|
112
106
|
});
|
|
107
|
+
|
|
108
|
+
// ── #298 regression: concurrent siblings must be independent ──────────────
|
|
109
|
+
|
|
110
|
+
it("disposing one sibling does not evict the other (collision regression)", () => {
|
|
111
|
+
const bus = createEventBus();
|
|
112
|
+
subscribeSubagentLifecycle(bus, registry);
|
|
113
|
+
|
|
114
|
+
// Two concurrent children of the same parent register under distinct ids.
|
|
115
|
+
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
116
|
+
sessionId: "child-A",
|
|
117
|
+
parentSessionId: "parent-P",
|
|
118
|
+
});
|
|
119
|
+
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
120
|
+
sessionId: "child-B",
|
|
121
|
+
parentSessionId: "parent-P",
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Sibling A finishes first.
|
|
125
|
+
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionId: "child-A" });
|
|
126
|
+
|
|
127
|
+
// B must still be detected as a registered subagent.
|
|
128
|
+
expect(registry.has("child-A")).toBe(false);
|
|
129
|
+
expect(registry.has("child-B")).toBe(true);
|
|
130
|
+
expect(registry.get("child-B")?.parentSessionId).toBe("parent-P");
|
|
131
|
+
});
|
|
113
132
|
});
|
|
@@ -1,94 +1,145 @@
|
|
|
1
|
-
import { describe, expect, test } from "vitest";
|
|
1
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
2
2
|
import {
|
|
3
|
+
getSubagentSessionRegistry,
|
|
3
4
|
type SubagentSessionInfo,
|
|
4
5
|
SubagentSessionRegistry,
|
|
5
6
|
} from "#src/subagent-registry";
|
|
6
7
|
|
|
8
|
+
const REGISTRY_KEY = Symbol.for(
|
|
9
|
+
"@gotgenes/pi-permission-system:subagent-registry",
|
|
10
|
+
);
|
|
11
|
+
|
|
7
12
|
function makeInfo(
|
|
8
13
|
overrides: Partial<SubagentSessionInfo> = {},
|
|
9
14
|
): SubagentSessionInfo {
|
|
10
|
-
return {
|
|
11
|
-
agentName: "Explore",
|
|
12
|
-
...overrides,
|
|
13
|
-
};
|
|
15
|
+
return { ...overrides };
|
|
14
16
|
}
|
|
15
17
|
|
|
16
18
|
describe("SubagentSessionRegistry", () => {
|
|
17
19
|
test("has() returns false for an unregistered key", () => {
|
|
18
20
|
const registry = new SubagentSessionRegistry();
|
|
19
|
-
expect(registry.has("
|
|
21
|
+
expect(registry.has("session-abc")).toBe(false);
|
|
20
22
|
});
|
|
21
23
|
|
|
22
24
|
test("get() returns undefined for an unregistered key", () => {
|
|
23
25
|
const registry = new SubagentSessionRegistry();
|
|
24
|
-
expect(registry.get("
|
|
26
|
+
expect(registry.get("session-abc")).toBeUndefined();
|
|
25
27
|
});
|
|
26
28
|
|
|
27
29
|
test("has() returns true after register()", () => {
|
|
28
30
|
const registry = new SubagentSessionRegistry();
|
|
29
|
-
registry.register("
|
|
30
|
-
expect(registry.has("
|
|
31
|
+
registry.register("session-abc", makeInfo());
|
|
32
|
+
expect(registry.has("session-abc")).toBe(true);
|
|
31
33
|
});
|
|
32
34
|
|
|
33
35
|
test("get() returns the registered info after register()", () => {
|
|
34
36
|
const registry = new SubagentSessionRegistry();
|
|
35
37
|
const info = makeInfo({ parentSessionId: "parent-123" });
|
|
36
|
-
registry.register("
|
|
37
|
-
expect(registry.get("
|
|
38
|
+
registry.register("session-abc", info);
|
|
39
|
+
expect(registry.get("session-abc")).toEqual(info);
|
|
38
40
|
});
|
|
39
41
|
|
|
40
|
-
test("register() stores
|
|
42
|
+
test("register() stores entry without parentSessionId", () => {
|
|
41
43
|
const registry = new SubagentSessionRegistry();
|
|
42
|
-
registry.register("
|
|
43
|
-
expect(registry.get("
|
|
44
|
-
agentName: "Explore",
|
|
45
|
-
});
|
|
44
|
+
registry.register("session-abc", makeInfo());
|
|
45
|
+
expect(registry.get("session-abc")).toEqual({});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
48
|
test("has() returns false after unregister()", () => {
|
|
49
49
|
const registry = new SubagentSessionRegistry();
|
|
50
|
-
registry.register("
|
|
51
|
-
registry.unregister("
|
|
52
|
-
expect(registry.has("
|
|
50
|
+
registry.register("session-abc", makeInfo());
|
|
51
|
+
registry.unregister("session-abc");
|
|
52
|
+
expect(registry.has("session-abc")).toBe(false);
|
|
53
53
|
});
|
|
54
54
|
|
|
55
55
|
test("get() returns undefined after unregister()", () => {
|
|
56
56
|
const registry = new SubagentSessionRegistry();
|
|
57
|
-
registry.register("
|
|
58
|
-
registry.unregister("
|
|
59
|
-
expect(registry.get("
|
|
57
|
+
registry.register("session-abc", makeInfo());
|
|
58
|
+
registry.unregister("session-abc");
|
|
59
|
+
expect(registry.get("session-abc")).toBeUndefined();
|
|
60
60
|
});
|
|
61
61
|
|
|
62
62
|
test("unregister() is a no-op for an unknown key", () => {
|
|
63
63
|
const registry = new SubagentSessionRegistry();
|
|
64
|
-
expect(() => registry.unregister("
|
|
64
|
+
expect(() => registry.unregister("session-nonexistent")).not.toThrow();
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
test("register() overwrites a previous entry for the same key", () => {
|
|
68
|
+
const registry = new SubagentSessionRegistry();
|
|
69
|
+
registry.register("session-abc", makeInfo({ parentSessionId: "parent-1" }));
|
|
70
|
+
registry.register("session-abc", makeInfo({ parentSessionId: "parent-2" }));
|
|
71
|
+
expect(registry.get("session-abc")?.parentSessionId).toBe("parent-2");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── #298 regression: concurrent siblings must be independent ──────────────
|
|
75
|
+
|
|
76
|
+
test("two sibling session ids are registered independently", () => {
|
|
68
77
|
const registry = new SubagentSessionRegistry();
|
|
69
78
|
registry.register(
|
|
70
|
-
"
|
|
71
|
-
makeInfo({ parentSessionId: "parent-
|
|
79
|
+
"child-session-A",
|
|
80
|
+
makeInfo({ parentSessionId: "parent-P" }),
|
|
72
81
|
);
|
|
73
82
|
registry.register(
|
|
74
|
-
"
|
|
75
|
-
makeInfo({ parentSessionId: "parent-
|
|
76
|
-
);
|
|
77
|
-
expect(registry.get("/sessions/task-abc")?.parentSessionId).toBe(
|
|
78
|
-
"parent-2",
|
|
83
|
+
"child-session-B",
|
|
84
|
+
makeInfo({ parentSessionId: "parent-P" }),
|
|
79
85
|
);
|
|
86
|
+
|
|
87
|
+
expect(registry.has("child-session-A")).toBe(true);
|
|
88
|
+
expect(registry.has("child-session-B")).toBe(true);
|
|
80
89
|
});
|
|
81
90
|
|
|
82
|
-
test("
|
|
91
|
+
test("disposing one sibling does not evict the other (collision regression)", () => {
|
|
83
92
|
const registry = new SubagentSessionRegistry();
|
|
84
|
-
registry.register(
|
|
85
|
-
|
|
93
|
+
registry.register(
|
|
94
|
+
"child-session-A",
|
|
95
|
+
makeInfo({ parentSessionId: "parent-P" }),
|
|
96
|
+
);
|
|
97
|
+
registry.register(
|
|
98
|
+
"child-session-B",
|
|
99
|
+
makeInfo({ parentSessionId: "parent-P" }),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Sibling A finishes — should not affect B.
|
|
103
|
+
registry.unregister("child-session-A");
|
|
86
104
|
|
|
87
|
-
expect(registry.
|
|
88
|
-
expect(registry.
|
|
105
|
+
expect(registry.has("child-session-A")).toBe(false);
|
|
106
|
+
expect(registry.has("child-session-B")).toBe(true);
|
|
107
|
+
expect(registry.get("child-session-B")?.parentSessionId).toBe("parent-P");
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ── process-global accessor ────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
describe("getSubagentSessionRegistry (process-global accessor)", () => {
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Symbol-keyed global property; Map.delete() is not applicable
|
|
116
|
+
delete (globalThis as Record<symbol, unknown>)[REGISTRY_KEY];
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("returns a SubagentSessionRegistry instance", () => {
|
|
120
|
+
const registry = getSubagentSessionRegistry();
|
|
121
|
+
expect(registry).toBeInstanceOf(SubagentSessionRegistry);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("returns the same instance on repeated calls", () => {
|
|
125
|
+
const first = getSubagentSessionRegistry();
|
|
126
|
+
const second = getSubagentSessionRegistry();
|
|
127
|
+
expect(first).toBe(second);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("state registered through one call is visible through another call", () => {
|
|
131
|
+
const writer = getSubagentSessionRegistry();
|
|
132
|
+
writer.register("child-session-xyz", {
|
|
133
|
+
parentSessionId: "parent-abc",
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const reader = getSubagentSessionRegistry();
|
|
137
|
+
expect(reader.has("child-session-xyz")).toBe(true);
|
|
138
|
+
expect(reader.get("child-session-xyz")?.parentSessionId).toBe("parent-abc");
|
|
139
|
+
});
|
|
89
140
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
expect(registry.has("
|
|
141
|
+
test("starts empty on first call", () => {
|
|
142
|
+
const registry = getSubagentSessionRegistry();
|
|
143
|
+
expect(registry.has("any-session-id")).toBe(false);
|
|
93
144
|
});
|
|
94
145
|
});
|