@cleocode/adapters 2026.6.14 → 2026.6.15

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.
@@ -5,10 +5,12 @@
5
5
  * Migrated from src/core/spawn/adapters/claude-code-adapter.ts
6
6
  *
7
7
  * Uses the native `claude` CLI to spawn subagent processes with prompts
8
- * written to temporary files. Processes run detached and are tracked
9
- * by PID for listing and termination.
8
+ * written to temporary files. Processes run in per-session containment
9
+ * (systemd transient scope on Linux, or setsid process group as fallback)
10
+ * so that session end reaps the entire MCP suite tree.
10
11
  *
11
12
  * @task T5240
13
+ * @task T11998 — per-session scope/pgid suite containment
12
14
  */
13
15
  import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
14
16
  /**
@@ -57,11 +59,25 @@ export declare class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
57
59
  /**
58
60
  * Terminate a running spawn by instance ID.
59
61
  *
60
- * Sends SIGTERM to the tracked process. If the process is not found
61
- * or has already exited, this is a no-op.
62
+ * Uses the suite-reaper to kill the entire process tree (root claude CLI +
63
+ * all MCP grandchildren) via the containment handle recorded at spawn time.
64
+ * Falls back to a direct SIGTERM on the tracked PID for legacy entries
65
+ * that pre-date T11998 and lack an ownership handle.
66
+ *
67
+ * Idempotent: no-op if the instance is not found or has already exited.
62
68
  *
63
69
  * @param instanceId - ID of the spawn instance to terminate
70
+ * @task T11998
64
71
  */
65
72
  terminate(instanceId: string): Promise<void>;
73
+ /**
74
+ * Terminate all tracked spawn instances on session end.
75
+ *
76
+ * Called by the session lifecycle when a CLEO session ends, ensuring
77
+ * no orphaned agent suites (root process + MCP children) remain.
78
+ *
79
+ * @task T11998
80
+ */
81
+ terminateAll(): Promise<void>;
66
82
  }
67
83
  //# sourceMappingURL=spawn.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,KAAK,EAAE,oBAAoB,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAY3F;;;;;;;;;;;;;;GAcG;AACH,qBAAa,uBAAwB,YAAW,oBAAoB;IAClE,mDAAmD;IACnD,OAAO,CAAC,UAAU,CAAqC;IAEvD;;;;OAIG;IACG,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IASlC;;;;;;;;OAQG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IA8GxD;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAqB3C;;;;;;;OAOG;IACG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;CAWnD"}
1
+ {"version":3,"file":"spawn.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/spawn.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAKH,OAAO,KAAK,EACV,oBAAoB,EAEpB,YAAY,EACZ,WAAW,EACZ,MAAM,qBAAqB,CAAC;AAgB7B;;;;;;;;;;;;;;GAcG;AACH,qBAAa,uBAAwB,YAAW,oBAAoB;IAClE,mDAAmD;IACnD,OAAO,CAAC,UAAU,CAAqC;IAEvD;;;;OAIG;IACG,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IASlC;;;;;;;;OAQG;IACG,KAAK,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;IA2IxD;;;;;;;OAOG;IACG,WAAW,IAAI,OAAO,CAAC,WAAW,EAAE,CAAC;IAuB3C;;;;;;;;;;;;OAYG;IACG,SAAS,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAmBlD;;;;;;;OAOG;IACG,YAAY,IAAI,OAAO,CAAC,IAAI,CAAC;CAIpC"}
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Agent suite reaper — session-end cleanup for the per-session containment epic (T11998).
3
+ *
4
+ * Provides a single {@link reapAgentSuite} function that kills an agent's
5
+ * entire process tree (including indirectly-spawned MCP grandchildren) using
6
+ * whatever containment handle was recorded at spawn time.
7
+ *
8
+ * ## Reap strategy
9
+ *
10
+ * | mode | primary kill | fallback |
11
+ * |----------|---------------------------------------|---------------------------|
12
+ * | systemd | `systemctl --user stop <unitName>` | negative-pgid SIGKILL |
13
+ * | pgid | `kill(-pgid, SIGTERM)` → grace → KILL | none (best-effort) |
14
+ * | none | no-op (janitor T11995 is backstop) | — |
15
+ *
16
+ * All operations are idempotent: ESRCH, no-such-unit, and already-stopped
17
+ * conditions are treated as success (no-op).
18
+ *
19
+ * @module suite-reaper
20
+ * @task T11998
21
+ * @epic T11992
22
+ */
23
+ import type { AgentSuiteOwnership } from '@cleocode/contracts';
24
+ /**
25
+ * Reap an agent process suite.
26
+ *
27
+ * Stops the entire tree of processes associated with a spawned agent session:
28
+ * the root claude CLI process AND all MCP children that reparented under it.
29
+ *
30
+ * This function is called on:
31
+ * - Normal session end (user runs `cleo session end`)
32
+ * - `terminate(instanceId)` on the spawn provider
33
+ * - Watchdog-declared death (future T11995 janitor call-back)
34
+ *
35
+ * @param ownership - The containment handle recorded at spawn time.
36
+ * @returns A promise that resolves when the reap attempt is complete.
37
+ *
38
+ * @task T11998
39
+ */
40
+ export declare function reapAgentSuite(ownership: AgentSuiteOwnership): Promise<void>;
41
+ //# sourceMappingURL=suite-reaper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"suite-reaper.d.ts","sourceRoot":"","sources":["../../../src/providers/claude-code/suite-reaper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAGH,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAU/D;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,cAAc,CAAC,SAAS,EAAE,mBAAmB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsBlF"}
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Adapter-local spawn-args builder for per-session suite containment (T11998).
3
+ *
4
+ * This module provides a self-contained copy of the systemd-run argv builder
5
+ * for use inside `packages/adapters/`, which cannot import from
6
+ * `packages/core/` (that would create a circular dependency since core depends
7
+ * on adapters).
8
+ *
9
+ * ## Relationship to `packages/core/src/resources/spawn-wrapper.ts`
10
+ *
11
+ * The canonical SSoT for systemd-run argv construction is in core (T11993).
12
+ * This module is a DELIBERATE LOCAL COPY scoped to the adapter layer, kept
13
+ * in sync by the skill-drift gate. It follows the same design decisions:
14
+ * - `cleo.slice` placement
15
+ * - `MemoryMax=32G` hard cap (P1 staged value)
16
+ * - `MemorySwapMax=0`
17
+ * - Selective `ManagedOOMPreference=avoid` (daemon/db only)
18
+ * - Core suppression via `ulimit -c 0` (NOT LimitCORE=0 — invalid on scopes)
19
+ * - `_forceSystemdRunAvailable()` test hook
20
+ *
21
+ * When the core SSoT changes, update this file in the same PR.
22
+ *
23
+ * ## Why a copy?
24
+ *
25
+ * The dependency direction is `core → adapters`. Adding `core` to adapters'
26
+ * deps would create a cycle. Extracting to a third package is a separate
27
+ * refactor task; for P1 the local copy is the right trade-off.
28
+ *
29
+ * @module agent-spawn-wrapper
30
+ * @task T11998
31
+ * @epic T11992
32
+ * @see packages/core/src/resources/spawn-wrapper.ts (canonical SSoT)
33
+ */
34
+ import type { AgentSuiteOwnership } from '@cleocode/contracts';
35
+ /** The systemd user slice that all cleo agent scope sessions are placed under. */
36
+ export declare const CLEO_SLICE: "cleo.slice";
37
+ /**
38
+ * Force the cached systemd-run availability for tests.
39
+ *
40
+ * @param available - `true` = systemd path, `false` = pgid fallback.
41
+ */
42
+ export declare function _forceSystemdRunAvailable(available: boolean): void;
43
+ /**
44
+ * Result of building the spawn argv for an agent session.
45
+ *
46
+ * Carries both the launch command/args AND the ownership handle that must
47
+ * be persisted in the tracking record for use by {@link reapAgentSuite}.
48
+ */
49
+ export interface AgentSpawnArgs {
50
+ /** Command to execute (e.g. `'systemd-run'` or `'sh'` or the real binary). */
51
+ command: string;
52
+ /** Full argument list. */
53
+ args: string[];
54
+ /**
55
+ * Ownership handle to persist in the session tracking record.
56
+ *
57
+ * Pass this to {@link reapAgentSuite} on session end.
58
+ */
59
+ ownership: AgentSuiteOwnership;
60
+ }
61
+ /**
62
+ * Build the argv for spawning a claude agent session with containment.
63
+ *
64
+ * When systemd is available the agent is placed in a transient scope under
65
+ * `cleo.slice`. Otherwise it falls back to a setsid process-group spawn
66
+ * (the caller is responsible for passing `{ detached: true }` to
67
+ * `child_process.spawn` so a new pgid is created).
68
+ *
69
+ * Core suppression is applied via `ulimit -c 0` (NOT `LimitCORE=0` — that is
70
+ * a service-unit EXEC property and is rejected by `systemd-run --scope`).
71
+ *
72
+ * @param command - The executable to run (e.g. `'claude'`).
73
+ * @param args - Arguments for the executable.
74
+ * @param scopeId - Optional discriminator appended to the unit name (e.g. a
75
+ * task ID or instance ID) so concurrent sessions are addressable.
76
+ * @returns Build result with spawn command/args and the ownership handle.
77
+ */
78
+ export declare function buildAgentSpawnArgs(command: string, args: readonly string[], scopeId?: string): AgentSpawnArgs;
79
+ //# sourceMappingURL=agent-spawn-wrapper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"agent-spawn-wrapper.d.ts","sourceRoot":"","sources":["../../../src/providers/shared/agent-spawn-wrapper.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgCG;AAIH,OAAO,KAAK,EAAwB,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAMrF,kFAAkF;AAClF,eAAO,MAAM,UAAU,EAAG,YAAqB,CAAC;AAmChD;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,SAAS,EAAE,OAAO,GAAG,IAAI,CAElE;AAyBD;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,8EAA8E;IAC9E,OAAO,EAAE,MAAM,CAAC;IAChB,0BAA0B;IAC1B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf;;;;OAIG;IACH,SAAS,EAAE,mBAAmB,CAAC;CAChC;AAMD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,OAAO,CAAC,EAAE,MAAM,GACf,cAAc,CAmEhB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cleocode/adapters",
3
- "version": "2026.6.14",
3
+ "version": "2026.6.15",
4
4
  "description": "Unified provider adapters for CLEO (Claude Code, OpenCode, Cursor)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -15,9 +15,9 @@
15
15
  "@ai-sdk/anthropic": "^3.0.69",
16
16
  "@ai-sdk/openai": "^2.0.53",
17
17
  "ai": "^6.0.168",
18
- "@cleocode/contracts": "2026.6.14",
19
- "@cleocode/caamp": "2026.6.14",
20
- "@cleocode/paths": "2026.6.14"
18
+ "@cleocode/caamp": "2026.6.15",
19
+ "@cleocode/contracts": "2026.6.15",
20
+ "@cleocode/paths": "2026.6.15"
21
21
  },
22
22
  "license": "MIT",
23
23
  "engines": {
@@ -30,7 +30,7 @@
30
30
  "devDependencies": {
31
31
  "@types/node": "^22.19.15",
32
32
  "vitest": "^4.1.4",
33
- "@cleocode/playbooks": "2026.6.14"
33
+ "@cleocode/playbooks": "2026.6.15"
34
34
  },
35
35
  "repository": {
36
36
  "type": "git",
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Integration test: per-session suite containment (T11998).
3
+ *
4
+ * ## Acceptance criterion
5
+ *
6
+ * > An integration test spawns a stand-in agent that forks grandchildren,
7
+ * > ends the session, and asserts zero surviving processes.
8
+ *
9
+ * ## CI-runnable design
10
+ *
11
+ * The test uses `_forceSystemdRunAvailable(false)` to run in the **pgid path**
12
+ * so it does not require a user systemd bus. A separate `describe.skipIf`
13
+ * block exercises the systemd path when `systemd-run --user` is actually
14
+ * available on the test host.
15
+ *
16
+ * ### Stand-in agent script
17
+ *
18
+ * A small shell script is launched that immediately forks two `sleep 60`
19
+ * grandchildren, simulating an MCP server suite:
20
+ *
21
+ * ```sh
22
+ * #!/bin/sh
23
+ * sleep 60 &
24
+ * sleep 60 &
25
+ * wait
26
+ * ```
27
+ *
28
+ * After the reaper is called, we assert that neither the root process nor
29
+ * any `sleep 60` grandchild is still alive.
30
+ *
31
+ * @task T11998
32
+ * @epic T11992
33
+ */
34
+
35
+ import { spawn, spawnSync } from 'node:child_process';
36
+ import { existsSync, unlinkSync, writeFileSync } from 'node:fs';
37
+ import { tmpdir } from 'node:os';
38
+ import { join } from 'node:path';
39
+ import type { AgentSuiteOwnership } from '@cleocode/contracts';
40
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
41
+ import { reapAgentSuite } from '../providers/claude-code/suite-reaper.js';
42
+ import {
43
+ _forceSystemdRunAvailable,
44
+ buildAgentSpawnArgs,
45
+ } from '../providers/shared/agent-spawn-wrapper.js';
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Write a stand-in agent script to a temp file.
53
+ * The script spawns two sleep grandchildren then waits.
54
+ */
55
+ function writeStandInScript(): string {
56
+ const scriptPath = join(tmpdir(), `cleo-test-agent-${process.pid}-${Date.now()}.sh`);
57
+ writeFileSync(scriptPath, '#!/bin/sh\nsleep 60 &\nsleep 60 &\nwait\n', { mode: 0o755 });
58
+ return scriptPath;
59
+ }
60
+
61
+ /**
62
+ * Check whether a PID is still alive via kill(pid, 0).
63
+ */
64
+ function isAlive(pid: number): boolean {
65
+ try {
66
+ process.kill(pid, 0);
67
+ return true;
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ /**
74
+ * Wait up to `timeoutMs` for a condition to become true.
75
+ */
76
+ async function waitForCondition(
77
+ condition: () => boolean,
78
+ timeoutMs = 5_000,
79
+ pollMs = 100,
80
+ ): Promise<boolean> {
81
+ const deadline = Date.now() + timeoutMs;
82
+ while (Date.now() < deadline) {
83
+ if (condition()) return true;
84
+ await new Promise((r) => setTimeout(r, pollMs));
85
+ }
86
+ return condition();
87
+ }
88
+
89
+ /**
90
+ * Find child PIDs of a given parent PID using /proc (Linux only).
91
+ * Returns empty array on non-Linux or when /proc is unavailable.
92
+ */
93
+ function getChildPids(parentPid: number): number[] {
94
+ if (process.platform !== 'linux') return [];
95
+ try {
96
+ const result = spawnSync('pgrep', ['-P', String(parentPid)], { encoding: 'utf8' });
97
+ if (result.status !== 0 || !result.stdout) return [];
98
+ return result.stdout
99
+ .trim()
100
+ .split('\n')
101
+ .filter(Boolean)
102
+ .map(Number)
103
+ .filter((n) => !Number.isNaN(n));
104
+ } catch {
105
+ return [];
106
+ }
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Reset forced availability after each test
111
+ // ---------------------------------------------------------------------------
112
+
113
+ afterEach(() => {
114
+ _forceSystemdRunAvailable(false);
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // pgid path — CI-runnable (forced: no systemd)
119
+ // ---------------------------------------------------------------------------
120
+
121
+ describe('suite-containment — pgid path (forced, no systemd required)', () => {
122
+ let scriptPath: string | undefined;
123
+
124
+ beforeEach(() => {
125
+ scriptPath = writeStandInScript();
126
+ });
127
+
128
+ afterEach(() => {
129
+ if (scriptPath && existsSync(scriptPath)) {
130
+ try {
131
+ unlinkSync(scriptPath);
132
+ } catch {
133
+ // ignore
134
+ }
135
+ }
136
+ });
137
+
138
+ it('buildAgentSpawnArgs returns mode=pgid when systemd-run is forced unavailable', () => {
139
+ _forceSystemdRunAvailable(false);
140
+ const result = buildAgentSpawnArgs('sh', ['-c', 'echo hi'], 'T11998-test');
141
+ expect(result.ownership.mode).toBe('pgid');
142
+ expect(result.ownership.unitName).toBeUndefined();
143
+ // Command is 'sh' wrapping ulimit
144
+ expect(result.command).toBe('sh');
145
+ expect(result.args).toContain('ulimit -c 0; exec "$@"');
146
+ });
147
+
148
+ it('reapAgentSuite (pgid): zero surviving processes after reap', async () => {
149
+ _forceSystemdRunAvailable(false);
150
+
151
+ if (process.platform !== 'linux') {
152
+ // pgid kill requires POSIX — skip on non-Linux CI
153
+ return;
154
+ }
155
+
156
+ if (!scriptPath) throw new Error('scriptPath not set');
157
+
158
+ // Spawn the stand-in agent with detached:true so it gets its own pgid.
159
+ // The agent script itself spawns two sleep grandchildren.
160
+ const child = spawn('sh', [scriptPath], {
161
+ detached: true,
162
+ stdio: 'ignore',
163
+ });
164
+ child.unref();
165
+
166
+ const rootPid = child.pid;
167
+ if (!rootPid) throw new Error('Failed to spawn stand-in agent');
168
+
169
+ // Give the stand-in time to fork its grandchildren.
170
+ await new Promise((r) => setTimeout(r, 300));
171
+
172
+ // Collect grandchild PIDs before reap.
173
+ const grandchildren = getChildPids(rootPid);
174
+ // We should have at least the root alive.
175
+ expect(isAlive(rootPid)).toBe(true);
176
+
177
+ // Build an ownership handle with the root's PID as pgid leader.
178
+ const ownership: AgentSuiteOwnership = {
179
+ mode: 'pgid',
180
+ pgid: rootPid,
181
+ pid: rootPid,
182
+ };
183
+
184
+ // Reap the suite.
185
+ await reapAgentSuite(ownership);
186
+
187
+ // Wait for all processes to die (up to 5s).
188
+ const allGone = await waitForCondition(() => {
189
+ if (isAlive(rootPid)) return false;
190
+ return grandchildren.every((pid) => !isAlive(pid));
191
+ });
192
+
193
+ expect(allGone).toBe(true);
194
+
195
+ // Verify individually for a clear failure message.
196
+ expect(isAlive(rootPid)).toBe(false);
197
+ for (const gPid of grandchildren) {
198
+ expect(isAlive(gPid)).toBe(false);
199
+ }
200
+ }, 10_000); // 10s timeout (SIGTERM grace = 3s + buffer)
201
+
202
+ it('reapAgentSuite (pgid, none): is a no-op for mode=none', async () => {
203
+ const ownership: AgentSuiteOwnership = { mode: 'none' };
204
+ // Should not throw.
205
+ await expect(reapAgentSuite(ownership)).resolves.toBeUndefined();
206
+ });
207
+
208
+ it('reapAgentSuite (pgid): ESRCH is a no-op (already-gone group)', async () => {
209
+ // Use a known-dead PID (extremely high number, likely not in use).
210
+ const ownership: AgentSuiteOwnership = {
211
+ mode: 'pgid',
212
+ pgid: 2_000_000_000,
213
+ pid: 2_000_000_000,
214
+ };
215
+ // Should resolve cleanly (ESRCH is treated as success).
216
+ await expect(reapAgentSuite(ownership)).resolves.toBeUndefined();
217
+ });
218
+
219
+ it('abnormal agent exit leaves no processes reparented after reap', async () => {
220
+ if (process.platform !== 'linux') return;
221
+ if (!scriptPath) throw new Error('scriptPath not set');
222
+
223
+ // Spawn stand-in, kill the root immediately (simulating abnormal exit),
224
+ // then assert the reaper still handles the orphaned grandchildren.
225
+ const child = spawn('sh', [scriptPath], {
226
+ detached: true,
227
+ stdio: 'ignore',
228
+ });
229
+ child.unref();
230
+
231
+ const rootPid = child.pid;
232
+ if (!rootPid) throw new Error('Failed to spawn stand-in agent');
233
+
234
+ // Give grandchildren time to start.
235
+ await new Promise((r) => setTimeout(r, 300));
236
+ const grandchildren = getChildPids(rootPid);
237
+
238
+ // Kill the root abnormally (simulate crash).
239
+ try {
240
+ process.kill(rootPid, 'SIGKILL');
241
+ } catch {
242
+ // Already dead — fine.
243
+ }
244
+
245
+ // Wait a tick for grandchildren to potentially reparent.
246
+ await new Promise((r) => setTimeout(r, 100));
247
+
248
+ // Now reap whatever is left via pgid.
249
+ const ownership: AgentSuiteOwnership = {
250
+ mode: 'pgid',
251
+ pgid: rootPid,
252
+ pid: rootPid,
253
+ };
254
+ await reapAgentSuite(ownership);
255
+
256
+ // After reap, all grandchildren must be gone.
257
+ const allGone = await waitForCondition(() => grandchildren.every((pid) => !isAlive(pid)));
258
+ expect(allGone).toBe(true);
259
+ }, 10_000);
260
+ });
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // systemd path — only when systemd-run is actually available
264
+ // ---------------------------------------------------------------------------
265
+
266
+ const systemdAvailable =
267
+ process.platform === 'linux' &&
268
+ spawnSync('systemd-run', ['--version'], { stdio: 'ignore' }).status === 0 &&
269
+ // Also check for a live user bus (DBUS_SESSION_BUS_ADDRESS or XDG_RUNTIME_DIR).
270
+ (!!process.env['DBUS_SESSION_BUS_ADDRESS'] || !!process.env['XDG_RUNTIME_DIR']);
271
+
272
+ describe.skipIf(!systemdAvailable)(
273
+ 'suite-containment — systemd path (requires live user bus)',
274
+ () => {
275
+ let scriptPath: string | undefined;
276
+
277
+ beforeEach(() => {
278
+ scriptPath = writeStandInScript();
279
+ // Do NOT force availability — use the real probe.
280
+ _forceSystemdRunAvailable(true);
281
+ });
282
+
283
+ afterEach(() => {
284
+ _forceSystemdRunAvailable(false);
285
+ if (scriptPath && existsSync(scriptPath)) {
286
+ try {
287
+ unlinkSync(scriptPath);
288
+ } catch {
289
+ // ignore
290
+ }
291
+ }
292
+ });
293
+
294
+ it('buildAgentSpawnArgs returns mode=systemd with a unit name', () => {
295
+ const result = buildAgentSpawnArgs('sh', ['-c', 'echo hi'], 'T11998-systemd');
296
+ expect(result.ownership.mode).toBe('systemd');
297
+ expect(result.ownership.unitName).toMatch(/^cleo-agent-session-T11998-systemd\.scope$/);
298
+ expect(result.command).toBe('systemd-run');
299
+ expect(result.args).toContain('--scope');
300
+ expect(result.args).toContain(`--slice=cleo.slice`);
301
+ });
302
+
303
+ it('reapAgentSuite (systemd): stops the scope and all grandchildren', async () => {
304
+ if (!scriptPath) throw new Error('scriptPath not set');
305
+
306
+ const spawnBuild = buildAgentSpawnArgs('sh', [scriptPath], 'T11998-reap-test');
307
+ expect(spawnBuild.ownership.mode).toBe('systemd');
308
+ const { unitName } = spawnBuild.ownership;
309
+ if (!unitName) throw new Error('unitName must be set in systemd mode');
310
+
311
+ // Launch via systemd-run.
312
+ const child = spawn(spawnBuild.command, spawnBuild.args, {
313
+ stdio: 'ignore',
314
+ });
315
+
316
+ const rootPid = child.pid;
317
+ if (!rootPid) throw new Error('Failed to spawn stand-in agent via systemd-run');
318
+
319
+ // Give systemd-run time to activate the scope and let grandchildren start.
320
+ await new Promise((r) => setTimeout(r, 600));
321
+
322
+ const ownership: AgentSuiteOwnership = {
323
+ ...spawnBuild.ownership,
324
+ pid: rootPid,
325
+ };
326
+
327
+ // Reap the scope.
328
+ await reapAgentSuite(ownership);
329
+
330
+ // The scope should be gone — verify via systemctl show (exit non-zero = not found).
331
+ await new Promise((r) => setTimeout(r, 200));
332
+ const showResult = spawnSync(
333
+ 'systemctl',
334
+ ['--user', 'show', '--property=ActiveState', unitName],
335
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
336
+ );
337
+ // Either the unit is not found (exit 4) or its state is inactive/dead.
338
+ const isInactiveOrGone =
339
+ showResult.status !== 0 ||
340
+ showResult.stdout.includes('ActiveState=inactive') ||
341
+ showResult.stdout.includes('ActiveState=failed') ||
342
+ showResult.stdout.includes('ActiveState=dead') ||
343
+ !showResult.stdout.includes('ActiveState=active');
344
+ expect(isInactiveOrGone).toBe(true);
345
+ }, 15_000);
346
+ },
347
+ );