@gotgenes/pi-permission-system 7.3.2 → 7.4.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 +26 -0
- package/package.json +1 -1
- package/src/handlers/gates/bash-external-directory.ts +28 -17
- package/src/index.ts +9 -0
- package/src/subagent-lifecycle-events.ts +72 -0
- package/test/handlers/gates/bash-external-directory.test.ts +74 -6
- package/test/subagent-lifecycle-events.test.ts +113 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,32 @@ 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
|
+
## [7.4.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.3...pi-permission-system-v7.4.0) (2026-05-29)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* register subagent child sessions via lifecycle events ([cd324dc](https://github.com/gotgenes/pi-packages/commit/cd324dc5f8b18fe69ba8802eda0b17a6a36ccc58))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* document event-based subagent child lifecycle ([62621fa](https://github.com/gotgenes/pi-packages/commit/62621fa9abd093b5deadb3c15139179ae85ad519))
|
|
19
|
+
|
|
20
|
+
## [7.3.3](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.2...pi-permission-system-v7.3.3) (2026-05-28)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* respect config-level allow/deny in bash external-directory gate ([#249](https://github.com/gotgenes/pi-packages/issues/249)) ([1437ff3](https://github.com/gotgenes/pi-packages/commit/1437ff3e3c0bdde93927ba9fdf9e3cf5b52e7c0c))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
### Documentation
|
|
29
|
+
|
|
30
|
+
* plan fix for bash external-directory config-level allow bypass ([#249](https://github.com/gotgenes/pi-packages/issues/249)) ([9e09f35](https://github.com/gotgenes/pi-packages/commit/9e09f35e6cfad09b53a1b55b54fcd44af4ed6a7b))
|
|
31
|
+
* **retro:** add planning stage notes for issue [#249](https://github.com/gotgenes/pi-packages/issues/249) ([fe13214](https://github.com/gotgenes/pi-packages/commit/fe132144869db93bfc83c4e940abb7d3ce813d46))
|
|
32
|
+
* **retro:** add TDD stage notes for issue [#249](https://github.com/gotgenes/pi-packages/issues/249) ([b5d22f6](https://github.com/gotgenes/pi-packages/commit/b5d22f6d67f7d2c28ed406c99bc0458df9024713))
|
|
33
|
+
|
|
8
34
|
## [7.3.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v7.3.1...pi-permission-system-v7.3.2) (2026-05-27)
|
|
9
35
|
|
|
10
36
|
|
package/package.json
CHANGED
|
@@ -21,7 +21,7 @@ type CheckPermissionFn = (
|
|
|
21
21
|
* Extracts paths from a bash command and checks whether any reference
|
|
22
22
|
* directories outside the working directory. Returns `null` when the gate
|
|
23
23
|
* does not apply (tool is not bash, no CWD, or no external paths found).
|
|
24
|
-
* Returns a `GateBypass` when all paths are session
|
|
24
|
+
* Returns a `GateBypass` when all paths are allowed (by config or session rule).
|
|
25
25
|
* Returns a `GateDescriptor` with multi-pattern sessionApproval for uncovered paths.
|
|
26
26
|
*/
|
|
27
27
|
export async function describeBashExternalDirectoryGate(
|
|
@@ -41,15 +41,26 @@ export async function describeBashExternalDirectoryGate(
|
|
|
41
41
|
if (externalPaths.length === 0) return null;
|
|
42
42
|
|
|
43
43
|
const bashSessionRules = getSessionRuleset();
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
)
|
|
44
|
+
|
|
45
|
+
// Collect paths whose resolved state is not already "allow".
|
|
46
|
+
// Checking state (not source) ensures config-level allow rules (source: "special")
|
|
47
|
+
// suppress the prompt just as session-level allow rules (source: "session") do.
|
|
48
|
+
const uncoveredEntries: Array<{
|
|
49
|
+
path: string;
|
|
50
|
+
check: PermissionCheckResult;
|
|
51
|
+
}> = [];
|
|
52
|
+
for (const p of externalPaths) {
|
|
53
|
+
const check = checkPermission(
|
|
54
|
+
"external_directory",
|
|
55
|
+
{ path: p },
|
|
56
|
+
tcc.agentName ?? undefined,
|
|
57
|
+
bashSessionRules,
|
|
58
|
+
);
|
|
59
|
+
if (check.state !== "allow") {
|
|
60
|
+
uncoveredEntries.push({ path: p, check });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const uncoveredPaths = uncoveredEntries.map(({ path }) => path);
|
|
53
64
|
|
|
54
65
|
if (uncoveredPaths.length === 0) {
|
|
55
66
|
return {
|
|
@@ -69,12 +80,12 @@ export async function describeBashExternalDirectoryGate(
|
|
|
69
80
|
};
|
|
70
81
|
}
|
|
71
82
|
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
// Use the most restrictive check among uncovered paths as the pre-check result.
|
|
84
|
+
// This ensures a config-level "deny" rule is not downgraded to "ask" by the
|
|
85
|
+
// generic "*" catch-all that the old path-less checkPermission call returned.
|
|
86
|
+
const worstCheck =
|
|
87
|
+
uncoveredEntries.find(({ check }) => check.state === "deny")?.check ??
|
|
88
|
+
uncoveredEntries[0].check;
|
|
78
89
|
|
|
79
90
|
const bashExtMessage = formatBashExternalDirectoryAskPrompt(
|
|
80
91
|
command,
|
|
@@ -120,6 +131,6 @@ export async function describeBashExternalDirectoryGate(
|
|
|
120
131
|
surface: "external_directory",
|
|
121
132
|
value: command,
|
|
122
133
|
},
|
|
123
|
-
preCheck:
|
|
134
|
+
preCheck: worstCheck,
|
|
124
135
|
};
|
|
125
136
|
}
|
package/src/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "./service";
|
|
28
28
|
import { createSessionLogger } from "./session-logger";
|
|
29
29
|
import { isSubagentExecutionContext } from "./subagent-context";
|
|
30
|
+
import { subscribeSubagentLifecycle } from "./subagent-lifecycle-events";
|
|
30
31
|
import { SubagentSessionRegistry } from "./subagent-registry";
|
|
31
32
|
import {
|
|
32
33
|
canResolveAskPermissionRequest,
|
|
@@ -129,6 +130,13 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
129
130
|
};
|
|
130
131
|
publishPermissionsService(permissionsService);
|
|
131
132
|
|
|
133
|
+
// Subscribe to @gotgenes/pi-subagents' child lifecycle events so child
|
|
134
|
+
// sessions register/unregister without the core calling us (ADR 0002).
|
|
135
|
+
const unsubSubagentLifecycle = subscribeSubagentLifecycle(
|
|
136
|
+
pi.events,
|
|
137
|
+
subagentRegistry,
|
|
138
|
+
);
|
|
139
|
+
|
|
132
140
|
emitReadyEvent(pi.events);
|
|
133
141
|
|
|
134
142
|
const toolRegistry = {
|
|
@@ -139,6 +147,7 @@ export default function piPermissionSystemExtension(pi: ExtensionAPI): void {
|
|
|
139
147
|
const lifecycle = new SessionLifecycleHandler(session, () => {
|
|
140
148
|
rpcHandles.unsubCheck();
|
|
141
149
|
rpcHandles.unsubPrompt();
|
|
150
|
+
unsubSubagentLifecycle();
|
|
142
151
|
unpublishPermissionsService();
|
|
143
152
|
});
|
|
144
153
|
const agentPrep = new AgentPrepHandler(session, toolRegistry);
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-lifecycle-events.ts — Subscribe to @gotgenes/pi-subagents' child
|
|
3
|
+
* lifecycle events and keep the SubagentSessionRegistry in sync.
|
|
4
|
+
*
|
|
5
|
+
* @gotgenes/pi-subagents publishes its child-execution lifecycle on the Pi
|
|
6
|
+
* event bus (ADR 0002): it no longer calls this package's service directly.
|
|
7
|
+
* We register the child on `session-created` and unregister it on `disposed`.
|
|
8
|
+
*
|
|
9
|
+
* The channel names and payload shapes are declared independently here (the two
|
|
10
|
+
* packages must not depend on each other under jiti) and MUST match the
|
|
11
|
+
* publisher in `@gotgenes/pi-subagents` (`src/lifecycle/child-lifecycle.ts`).
|
|
12
|
+
*
|
|
13
|
+
* The `session-created` handler MUST stay synchronous: the core emits it on the
|
|
14
|
+
* same synchronous call stack immediately before `bindExtensions()`, and the
|
|
15
|
+
* event bus dispatches listeners synchronously, so a synchronous handler lands
|
|
16
|
+
* the registry entry before binding proceeds. Introducing an `await` before
|
|
17
|
+
* `registry.register(...)` would break the pre-bind ordering.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { SubagentSessionRegistry } from "./subagent-registry";
|
|
21
|
+
|
|
22
|
+
/** Emitted by the core after session creation, before `bindExtensions()`. */
|
|
23
|
+
export const SUBAGENT_CHILD_SESSION_CREATED = "subagents:child:session-created";
|
|
24
|
+
|
|
25
|
+
/** Emitted by the core in the run's `finally` (success and error). */
|
|
26
|
+
export const SUBAGENT_CHILD_DISPOSED = "subagents:child:disposed";
|
|
27
|
+
|
|
28
|
+
/** Minimal event-bus surface this module needs (subscribe only). */
|
|
29
|
+
interface LifecycleEventBus {
|
|
30
|
+
on(channel: string, handler: (data: unknown) => void): () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Fields read from the `session-created` payload (ISP). */
|
|
34
|
+
interface ChildSessionCreatedEvent {
|
|
35
|
+
sessionDir: string;
|
|
36
|
+
agentName: string;
|
|
37
|
+
parentSessionId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Fields read from the `disposed` payload (ISP). */
|
|
41
|
+
interface ChildDisposedEvent {
|
|
42
|
+
sessionDir: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Subscribe to the subagent child lifecycle.
|
|
47
|
+
*
|
|
48
|
+
* @returns an unsubscribe that detaches both handlers (call during
|
|
49
|
+
* `session_shutdown`).
|
|
50
|
+
*/
|
|
51
|
+
export function subscribeSubagentLifecycle(
|
|
52
|
+
events: LifecycleEventBus,
|
|
53
|
+
registry: SubagentSessionRegistry,
|
|
54
|
+
): () => void {
|
|
55
|
+
const unsubCreated = events.on(SUBAGENT_CHILD_SESSION_CREATED, (data) => {
|
|
56
|
+
const event = data as ChildSessionCreatedEvent;
|
|
57
|
+
registry.register(event.sessionDir, {
|
|
58
|
+
agentName: event.agentName,
|
|
59
|
+
parentSessionId: event.parentSessionId,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const unsubDisposed = events.on(SUBAGENT_CHILD_DISPOSED, (data) => {
|
|
64
|
+
const event = data as ChildDisposedEvent;
|
|
65
|
+
registry.unregister(event.sessionDir);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
unsubCreated();
|
|
70
|
+
unsubDisposed();
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -98,16 +98,39 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
98
98
|
expect(patterns.length).toBeGreaterThan(0);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
it("
|
|
101
|
+
it("returns GateBypass when all external paths are config-level allowed", async () => {
|
|
102
|
+
// Config-level allow (source: "special") should suppress the prompt,
|
|
103
|
+
// not just session-level allow. This was the bug: source !== "session"
|
|
104
|
+
// kept config-allowed paths in the uncovered set.
|
|
102
105
|
const checkPermission = vi
|
|
103
106
|
.fn()
|
|
104
107
|
.mockImplementation(
|
|
105
108
|
(_surface: string, input: Record<string, unknown>) => {
|
|
106
|
-
// Path-specific check returns session for coverage filtering
|
|
107
109
|
if (input.path)
|
|
108
110
|
return makeCheckResult("allow", { source: "special" });
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
return makeCheckResult("ask");
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
const result = await describeBashExternalDirectoryGate(
|
|
115
|
+
makeTcc(),
|
|
116
|
+
checkPermission,
|
|
117
|
+
vi.fn().mockReturnValue([]),
|
|
118
|
+
);
|
|
119
|
+
expect(result).not.toBeNull();
|
|
120
|
+
expect(isGateBypass(result)).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("uses worst-check state from uncovered paths for preCheck (config deny > catch-all ask)", async () => {
|
|
124
|
+
// The path-less extCheck used to always return the "*" catch-all (ask),
|
|
125
|
+
// silently downgrading a config-level deny to ask. After the fix, the
|
|
126
|
+
// descriptor's preCheck is derived from the actual path check result.
|
|
127
|
+
const checkPermission = vi
|
|
128
|
+
.fn()
|
|
129
|
+
.mockImplementation(
|
|
130
|
+
(_surface: string, input: Record<string, unknown>) => {
|
|
131
|
+
if (input.path) return makeCheckResult("deny", { source: "special" });
|
|
132
|
+
// Path-less catch-all returns ask — should NOT be used as preCheck.
|
|
133
|
+
return makeCheckResult("ask");
|
|
111
134
|
},
|
|
112
135
|
);
|
|
113
136
|
const result = await describeBashExternalDirectoryGate(
|
|
@@ -116,8 +139,6 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
116
139
|
vi.fn().mockReturnValue([]),
|
|
117
140
|
);
|
|
118
141
|
expect(isGateDescriptor(result)).toBe(true);
|
|
119
|
-
// The descriptor should carry the deny state from the config-level check
|
|
120
|
-
// (it will be checked as preCheck by the runner)
|
|
121
142
|
const desc = result as GateDescriptor;
|
|
122
143
|
expect(desc.preCheck?.state).toBe("deny");
|
|
123
144
|
});
|
|
@@ -172,6 +193,53 @@ describe("describeBashExternalDirectoryGate", () => {
|
|
|
172
193
|
});
|
|
173
194
|
});
|
|
174
195
|
|
|
196
|
+
it("config-allowed path is excluded; remaining ask path produces a descriptor", async () => {
|
|
197
|
+
// One path config-allowed, one config-ask → descriptor with only the ask path.
|
|
198
|
+
const checkPermission = vi
|
|
199
|
+
.fn()
|
|
200
|
+
.mockImplementation(
|
|
201
|
+
(_surface: string, input: Record<string, unknown>) => {
|
|
202
|
+
if (input.path === "/outside/a.ts")
|
|
203
|
+
return makeCheckResult("allow", { source: "special" });
|
|
204
|
+
return makeCheckResult("ask");
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
const result = await describeBashExternalDirectoryGate(
|
|
208
|
+
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
209
|
+
checkPermission,
|
|
210
|
+
vi.fn().mockReturnValue([]),
|
|
211
|
+
);
|
|
212
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
213
|
+
const desc = result as GateDescriptor;
|
|
214
|
+
const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
|
|
215
|
+
expect(patterns.length).toBe(1);
|
|
216
|
+
expect(desc.preCheck?.state).toBe("ask");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("config-denied path makes worstCheck deny even when another path is ask", async () => {
|
|
220
|
+
// One path config-denied, one config-ask → descriptor with preCheck.state === "deny".
|
|
221
|
+
const checkPermission = vi
|
|
222
|
+
.fn()
|
|
223
|
+
.mockImplementation(
|
|
224
|
+
(_surface: string, input: Record<string, unknown>) => {
|
|
225
|
+
if (input.path === "/outside/a.ts")
|
|
226
|
+
return makeCheckResult("deny", { source: "special" });
|
|
227
|
+
return makeCheckResult("ask");
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
const result = await describeBashExternalDirectoryGate(
|
|
231
|
+
makeTcc({ input: { command: "diff /outside/a.ts /outside/b.ts" } }),
|
|
232
|
+
checkPermission,
|
|
233
|
+
vi.fn().mockReturnValue([]),
|
|
234
|
+
);
|
|
235
|
+
expect(isGateDescriptor(result)).toBe(true);
|
|
236
|
+
const desc = result as GateDescriptor;
|
|
237
|
+
expect(desc.preCheck?.state).toBe("deny");
|
|
238
|
+
// Both paths are uncovered (neither is allow), so both patterns are included.
|
|
239
|
+
const patterns = (desc.sessionApproval as { patterns: string[] }).patterns;
|
|
240
|
+
expect(patterns.length).toBe(2);
|
|
241
|
+
});
|
|
242
|
+
|
|
175
243
|
it("only includes uncovered paths when some are session-covered", async () => {
|
|
176
244
|
const checkPermission = vi
|
|
177
245
|
.fn()
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { createEventBus } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
SUBAGENT_CHILD_DISPOSED,
|
|
5
|
+
SUBAGENT_CHILD_SESSION_CREATED,
|
|
6
|
+
subscribeSubagentLifecycle,
|
|
7
|
+
} from "#src/subagent-lifecycle-events";
|
|
8
|
+
import { SubagentSessionRegistry } from "#src/subagent-registry";
|
|
9
|
+
|
|
10
|
+
describe("subscribeSubagentLifecycle", () => {
|
|
11
|
+
let registry: SubagentSessionRegistry;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
registry = new SubagentSessionRegistry();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("registers a child session on session-created", () => {
|
|
18
|
+
const bus = createEventBus();
|
|
19
|
+
subscribeSubagentLifecycle(bus, registry);
|
|
20
|
+
|
|
21
|
+
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
22
|
+
sessionDir: "/sessions/child-abc",
|
|
23
|
+
agentName: "Explore",
|
|
24
|
+
parentSessionId: "parent-42",
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(registry.get("/sessions/child-abc")).toEqual({
|
|
28
|
+
agentName: "Explore",
|
|
29
|
+
parentSessionId: "parent-42",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("populates the registry synchronously — before emit() returns", () => {
|
|
34
|
+
// Guards the pre-bindExtensions ordering: the core emits session-created
|
|
35
|
+
// on the same synchronous call stack right before bindExtensions(), so the
|
|
36
|
+
// handler must complete before emit() returns. A real EventEmitter-backed
|
|
37
|
+
// bus dispatches synchronously; this fails loudly if the handler ever
|
|
38
|
+
// becomes async (awaiting before registry.register).
|
|
39
|
+
const bus = createEventBus();
|
|
40
|
+
subscribeSubagentLifecycle(bus, registry);
|
|
41
|
+
|
|
42
|
+
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
43
|
+
sessionDir: "/sessions/child-sync",
|
|
44
|
+
agentName: "Explore",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// No await between emit and this assertion.
|
|
48
|
+
expect(registry.has("/sessions/child-sync")).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("omits parentSessionId when the event does not carry one", () => {
|
|
52
|
+
const bus = createEventBus();
|
|
53
|
+
subscribeSubagentLifecycle(bus, registry);
|
|
54
|
+
|
|
55
|
+
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
56
|
+
sessionDir: "/sessions/child-xyz",
|
|
57
|
+
agentName: "general-purpose",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(registry.get("/sessions/child-xyz")).toEqual({
|
|
61
|
+
agentName: "general-purpose",
|
|
62
|
+
parentSessionId: undefined,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("unregisters a child session on disposed", () => {
|
|
67
|
+
const bus = createEventBus();
|
|
68
|
+
subscribeSubagentLifecycle(bus, registry);
|
|
69
|
+
registry.register("/sessions/child-abc", { agentName: "Explore" });
|
|
70
|
+
|
|
71
|
+
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionDir: "/sessions/child-abc" });
|
|
72
|
+
|
|
73
|
+
expect(registry.has("/sessions/child-abc")).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("detaches both handlers when the returned unsubscribe is called", () => {
|
|
77
|
+
const bus = createEventBus();
|
|
78
|
+
const unsubscribe = subscribeSubagentLifecycle(bus, registry);
|
|
79
|
+
|
|
80
|
+
unsubscribe();
|
|
81
|
+
|
|
82
|
+
bus.emit(SUBAGENT_CHILD_SESSION_CREATED, {
|
|
83
|
+
sessionDir: "/sessions/child-abc",
|
|
84
|
+
agentName: "Explore",
|
|
85
|
+
});
|
|
86
|
+
bus.emit(SUBAGENT_CHILD_DISPOSED, { sessionDir: "/sessions/child-abc" });
|
|
87
|
+
|
|
88
|
+
expect(registry.has("/sessions/child-abc")).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("subscribes to a fake bus on the exact channel names", () => {
|
|
92
|
+
const handlers = new Map<string, (data: unknown) => void>();
|
|
93
|
+
const bus = {
|
|
94
|
+
on: vi.fn((channel: string, handler: (data: unknown) => void) => {
|
|
95
|
+
handlers.set(channel, handler);
|
|
96
|
+
return () => handlers.delete(channel);
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
subscribeSubagentLifecycle(bus, registry);
|
|
101
|
+
|
|
102
|
+
expect(bus.on).toHaveBeenCalledTimes(2);
|
|
103
|
+
expect(handlers.has("subagents:child:session-created")).toBe(true);
|
|
104
|
+
expect(handlers.has("subagents:child:disposed")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("exposes the canonical channel-name strings", () => {
|
|
108
|
+
expect(SUBAGENT_CHILD_SESSION_CREATED).toBe(
|
|
109
|
+
"subagents:child:session-created",
|
|
110
|
+
);
|
|
111
|
+
expect(SUBAGENT_CHILD_DISPOSED).toBe("subagents:child:disposed");
|
|
112
|
+
});
|
|
113
|
+
});
|