@cleocode/adapters 2026.6.14 → 2026.6.17

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,17 +5,26 @@
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
 
14
16
  import { exec, spawn as nodeSpawn } from 'node:child_process';
15
17
  import { unlink, writeFile } from 'node:fs/promises';
16
18
  import { promisify } from 'node:util';
17
- import type { AdapterSpawnProvider, SpawnContext, SpawnResult } from '@cleocode/contracts';
19
+ import type {
20
+ AdapterSpawnProvider,
21
+ AgentSuiteOwnership,
22
+ SpawnContext,
23
+ SpawnResult,
24
+ } from '@cleocode/contracts';
18
25
  import { getErrorMessage } from '@cleocode/contracts';
26
+ import { buildAgentSpawnArgs } from '../shared/agent-spawn-wrapper.js';
27
+ import { reapAgentSuite } from './suite-reaper.js';
19
28
 
20
29
  const execAsync = promisify(exec);
21
30
 
@@ -24,6 +33,8 @@ interface TrackedProcess {
24
33
  pid: number;
25
34
  taskId: string;
26
35
  startTime: string;
36
+ /** Suite containment handle — used by terminate() and session-end reap (T11998). */
37
+ ownership: AgentSuiteOwnership;
27
38
  }
28
39
 
29
40
  /**
@@ -98,15 +109,27 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
98
109
  // --print: non-interactive batch mode (process prompt, output response, exit)
99
110
  // --dangerously-skip-permissions: allow all tool calls without human approval
100
111
  // --output-format json: structured output for parsing
101
- const args = [
112
+ const claudeArgs = [
102
113
  '--print',
103
114
  '--dangerously-skip-permissions',
104
115
  '--output-format',
105
116
  'json',
106
117
  tmpFile,
107
118
  ];
119
+
120
+ // T11998: Build the argv with per-session containment.
121
+ // On Linux with systemd, this wraps 'claude' inside a transient
122
+ // cleo.slice scope (systemd-run --user --scope ...).
123
+ // On non-Linux or without systemd, falls back to pgid (detached+setsid).
124
+ // The ownership handle records the scope unit name or pgid for reaping.
125
+ const spawnBuild = buildAgentSpawnArgs('claude', claudeArgs, instanceId);
126
+
127
+ // For the systemd path: the child of systemd-run is NOT detached (systemd
128
+ // manages the scope lifecycle). For the pgid path: we use detached:true
129
+ // so Node creates a new session, giving us a fresh pgid to kill the group.
130
+ const isSystemd = spawnBuild.ownership.mode === 'systemd';
108
131
  const spawnOpts: Parameters<typeof nodeSpawn>[2] = {
109
- detached: true,
132
+ detached: !isSystemd,
110
133
  stdio: ['ignore', 'pipe', 'pipe'],
111
134
  };
112
135
 
@@ -126,14 +149,28 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
126
149
  spawnOpts.env = { ...process.env, ...optionsEnv };
127
150
  }
128
151
 
129
- const child = nodeSpawn('claude', args, spawnOpts);
152
+ const child = nodeSpawn(spawnBuild.command, spawnBuild.args, spawnOpts);
153
+ // unref() so the parent process can exit without waiting for the child.
154
+ // The containment scope/pgid ensures the child tree can still be reaped.
130
155
  child.unref();
131
156
 
157
+ // Resolve the ownership handle: for the pgid path the pgid is the same
158
+ // as the pid when detached:true (Node sets the child as the group leader).
159
+ const ownership: AgentSuiteOwnership = {
160
+ ...spawnBuild.ownership,
161
+ pid: child.pid,
162
+ pgid:
163
+ spawnBuild.ownership.mode === 'pgid' && child.pid !== undefined
164
+ ? child.pid
165
+ : spawnBuild.ownership.pgid,
166
+ };
167
+
132
168
  if (child.pid) {
133
169
  this.processMap.set(instanceId, {
134
170
  pid: child.pid,
135
171
  taskId: context.taskId,
136
172
  startTime,
173
+ ownership,
137
174
  });
138
175
  }
139
176
 
@@ -153,6 +190,9 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
153
190
  providerId: 'claude-code',
154
191
  status: 'running',
155
192
  startTime,
193
+ // T11998: surface the ownership handle in the result so callers can
194
+ // persist it or pass it to reapAgentSuite on session end.
195
+ ownership,
156
196
  };
157
197
  } catch (error) {
158
198
  // Log spawn failure for debugging
@@ -198,6 +238,8 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
198
238
  providerId: 'claude-code',
199
239
  status: 'running',
200
240
  startTime: tracked.startTime,
241
+ // T11998: propagate ownership handle so callers can reap the suite.
242
+ ownership: tracked.ownership,
201
243
  });
202
244
  } catch {
203
245
  this.processMap.delete(instanceId);
@@ -210,20 +252,45 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
210
252
  /**
211
253
  * Terminate a running spawn by instance ID.
212
254
  *
213
- * Sends SIGTERM to the tracked process. If the process is not found
214
- * or has already exited, this is a no-op.
255
+ * Uses the suite-reaper to kill the entire process tree (root claude CLI +
256
+ * all MCP grandchildren) via the containment handle recorded at spawn time.
257
+ * Falls back to a direct SIGTERM on the tracked PID for legacy entries
258
+ * that pre-date T11998 and lack an ownership handle.
259
+ *
260
+ * Idempotent: no-op if the instance is not found or has already exited.
215
261
  *
216
262
  * @param instanceId - ID of the spawn instance to terminate
263
+ * @task T11998
217
264
  */
218
265
  async terminate(instanceId: string): Promise<void> {
219
266
  const tracked = this.processMap.get(instanceId);
220
267
  if (!tracked) return;
221
268
 
269
+ this.processMap.delete(instanceId);
270
+
222
271
  try {
223
- process.kill(tracked.pid, 'SIGTERM');
272
+ // T11998: reap the entire suite tree via the containment handle.
273
+ await reapAgentSuite(tracked.ownership);
224
274
  } catch {
225
- // Process may have already exited
275
+ // Best-effort: fall back to direct SIGTERM on the root pid.
276
+ try {
277
+ process.kill(tracked.pid, 'SIGTERM');
278
+ } catch {
279
+ // Process may have already exited — no-op.
280
+ }
226
281
  }
227
- this.processMap.delete(instanceId);
282
+ }
283
+
284
+ /**
285
+ * Terminate all tracked spawn instances on session end.
286
+ *
287
+ * Called by the session lifecycle when a CLEO session ends, ensuring
288
+ * no orphaned agent suites (root process + MCP children) remain.
289
+ *
290
+ * @task T11998
291
+ */
292
+ async terminateAll(): Promise<void> {
293
+ const instanceIds = [...this.processMap.keys()];
294
+ await Promise.allSettled(instanceIds.map((id) => this.terminate(id)));
228
295
  }
229
296
  }
@@ -0,0 +1,210 @@
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
+
24
+ import { spawnSync } from 'node:child_process';
25
+ import type { AgentSuiteOwnership } from '@cleocode/contracts';
26
+
27
+ /**
28
+ * Grace period in milliseconds between SIGTERM and SIGKILL in the pgid path.
29
+ *
30
+ * 3 s is sufficient for graceful MCP server shutdown and avoids the risk
31
+ * of SIGKILL mid-write.
32
+ */
33
+ const SIGTERM_GRACE_MS = 3_000;
34
+
35
+ /**
36
+ * Reap an agent process suite.
37
+ *
38
+ * Stops the entire tree of processes associated with a spawned agent session:
39
+ * the root claude CLI process AND all MCP children that reparented under it.
40
+ *
41
+ * This function is called on:
42
+ * - Normal session end (user runs `cleo session end`)
43
+ * - `terminate(instanceId)` on the spawn provider
44
+ * - Watchdog-declared death (future T11995 janitor call-back)
45
+ *
46
+ * @param ownership - The containment handle recorded at spawn time.
47
+ * @returns A promise that resolves when the reap attempt is complete.
48
+ *
49
+ * @task T11998
50
+ */
51
+ export async function reapAgentSuite(ownership: AgentSuiteOwnership): Promise<void> {
52
+ switch (ownership.mode) {
53
+ case 'systemd':
54
+ await reapSystemdScope(ownership);
55
+ break;
56
+ case 'pgid':
57
+ await reapPgidGroup(ownership);
58
+ break;
59
+ case 'none':
60
+ // No containment recorded — the janitor (T11995) is the sole backstop.
61
+ // Log at debug level for observability but do not throw.
62
+ process.stderr.write(
63
+ '[cleo:suite-reaper] containment mode=none — no reap performed; ' +
64
+ 'janitor (T11995) is the backstop\n',
65
+ );
66
+ break;
67
+ default: {
68
+ // Exhaustiveness guard — should never be reached.
69
+ const _exhaustive: never = ownership.mode;
70
+ void _exhaustive;
71
+ }
72
+ }
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Systemd path
77
+ // ---------------------------------------------------------------------------
78
+
79
+ /**
80
+ * Stop a transient systemd scope and reset its failed-unit state.
81
+ *
82
+ * 1. `systemctl --user stop <unitName>` — graceful scope stop.
83
+ * 2. `systemctl --user reset-failed <unitName>` — clear the failed ledger
84
+ * if the scope exited non-zero (idempotent, best-effort).
85
+ * 3. pgid fallback if systemctl is unavailable or the stop failed.
86
+ *
87
+ * @internal
88
+ */
89
+ async function reapSystemdScope(ownership: AgentSuiteOwnership): Promise<void> {
90
+ const { unitName, pgid } = ownership;
91
+
92
+ if (unitName) {
93
+ const stopResult = spawnSync('systemctl', ['--user', 'stop', unitName], {
94
+ stdio: 'ignore',
95
+ timeout: 10_000,
96
+ });
97
+
98
+ if (stopResult.error) {
99
+ // systemctl not available — fall through to pgid.
100
+ process.stderr.write(
101
+ `[cleo:suite-reaper] systemctl stop failed (${stopResult.error.message}); ` +
102
+ 'falling back to pgid kill\n',
103
+ );
104
+ } else if (stopResult.status !== 0) {
105
+ // Non-zero exit may mean "unit not found" (already gone) or "not started".
106
+ // Both are acceptable outcomes — unit is gone either way.
107
+ // Attempt reset-failed to clear the ledger, then check pgid.
108
+ spawnSync('systemctl', ['--user', 'reset-failed', unitName], {
109
+ stdio: 'ignore',
110
+ timeout: 5_000,
111
+ });
112
+ // If there is a pgid handle, kill any survivors the scope may have missed.
113
+ if (pgid !== undefined) {
114
+ await killPgidGracefully(pgid);
115
+ }
116
+ return;
117
+ } else {
118
+ // Successful stop — also try reset-failed to keep the ledger clean.
119
+ spawnSync('systemctl', ['--user', 'reset-failed', unitName], {
120
+ stdio: 'ignore',
121
+ timeout: 5_000,
122
+ });
123
+ // A successful scope stop reaps all cgroup members; pgid kill is not needed.
124
+ return;
125
+ }
126
+ }
127
+
128
+ // Fallback: pgid kill when unit name is absent or systemctl failed.
129
+ if (pgid !== undefined) {
130
+ await killPgidGracefully(pgid);
131
+ }
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Pgid path
136
+ // ---------------------------------------------------------------------------
137
+
138
+ /**
139
+ * Kill a POSIX process group: SIGTERM → grace period → SIGKILL.
140
+ *
141
+ * Sends SIGTERM to the whole group (negative PID = group leader + all
142
+ * members). After {@link SIGTERM_GRACE_MS}, checks for survivors and
143
+ * sends SIGKILL if any remain.
144
+ *
145
+ * ESRCH errors (no such process / already gone) are treated as success.
146
+ *
147
+ * @internal
148
+ */
149
+ async function reapPgidGroup(ownership: AgentSuiteOwnership): Promise<void> {
150
+ const { pgid } = ownership;
151
+ if (pgid === undefined) return;
152
+ await killPgidGracefully(pgid);
153
+ }
154
+
155
+ /**
156
+ * Send SIGTERM to a process group, wait for grace period, then SIGKILL survivors.
157
+ *
158
+ * @param pgid - Positive process-group ID.
159
+ * @internal
160
+ */
161
+ async function killPgidGracefully(pgid: number): Promise<void> {
162
+ // Step 1: SIGTERM
163
+ try {
164
+ process.kill(-pgid, 'SIGTERM');
165
+ } catch (err) {
166
+ // ESRCH = group already gone — treat as success.
167
+ if (isEsrch(err)) return;
168
+ // Other errors: log and proceed to SIGKILL attempt.
169
+ process.stderr.write(`[cleo:suite-reaper] SIGTERM to pgid=${pgid} failed: ${String(err)}\n`);
170
+ }
171
+
172
+ // Step 2: Grace period — give MCP servers time to flush and exit.
173
+ await sleep(SIGTERM_GRACE_MS);
174
+
175
+ // Step 3: SIGKILL survivors.
176
+ try {
177
+ process.kill(-pgid, 'SIGKILL');
178
+ } catch (err) {
179
+ // ESRCH after grace = all processes exited cleanly — ideal outcome.
180
+ if (isEsrch(err)) return;
181
+ process.stderr.write(`[cleo:suite-reaper] SIGKILL to pgid=${pgid} failed: ${String(err)}\n`);
182
+ }
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Helpers
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * Returns true if an error is ESRCH (no such process).
191
+ *
192
+ * @internal
193
+ */
194
+ function isEsrch(err: unknown): boolean {
195
+ if (typeof err === 'object' && err !== null) {
196
+ const code = (err as Record<string, unknown>)['code'];
197
+ return code === 'ESRCH';
198
+ }
199
+ return false;
200
+ }
201
+
202
+ /**
203
+ * Promise-based sleep.
204
+ *
205
+ * @param ms - Duration in milliseconds.
206
+ * @internal
207
+ */
208
+ function sleep(ms: number): Promise<void> {
209
+ return new Promise((resolve) => setTimeout(resolve, ms));
210
+ }
@@ -0,0 +1,222 @@
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
+
35
+ import { spawnSync } from 'node:child_process';
36
+ import { readFileSync } from 'node:fs';
37
+ import type { AgentContainmentMode, AgentSuiteOwnership } from '@cleocode/contracts';
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Constants
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** The systemd user slice that all cleo agent scope sessions are placed under. */
44
+ export const CLEO_SLICE = 'cleo.slice' as const;
45
+
46
+ /** Default MemoryMax for agent scopes (P1 staged value). */
47
+ const DEFAULT_MEMORY_MAX = '32G' as const;
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Availability probe
51
+ // ---------------------------------------------------------------------------
52
+
53
+ /** Cached result of the systemd-run probe. */
54
+ let _systemdRunAvailable: boolean | undefined;
55
+
56
+ /** Whether the pgid-demotion log line has been emitted for this process. */
57
+ let _demotionLogged = false;
58
+
59
+ /** Counter for unique scope discriminators within this process. */
60
+ let _scopeCounter = 0;
61
+
62
+ /**
63
+ * Check whether `systemd-run --user` is usable on this host.
64
+ *
65
+ * Returns `false` on non-Linux, when systemd-run is absent, or when
66
+ * DBUS_SESSION_BUS_ADDRESS / XDG_RUNTIME_DIR are absent.
67
+ */
68
+ function hasSystemdRun(): boolean {
69
+ if (_systemdRunAvailable !== undefined) return _systemdRunAvailable;
70
+ if (process.platform !== 'linux') {
71
+ _systemdRunAvailable = false;
72
+ return false;
73
+ }
74
+ const probe = spawnSync('systemd-run', ['--version'], { stdio: 'ignore' });
75
+ _systemdRunAvailable = probe.status === 0;
76
+ return _systemdRunAvailable;
77
+ }
78
+
79
+ /**
80
+ * Force the cached systemd-run availability for tests.
81
+ *
82
+ * @param available - `true` = systemd path, `false` = pgid fallback.
83
+ */
84
+ export function _forceSystemdRunAvailable(available: boolean): void {
85
+ _systemdRunAvailable = available;
86
+ }
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Memory helpers
90
+ // ---------------------------------------------------------------------------
91
+
92
+ /**
93
+ * Read /proc/meminfo MemTotal in bytes. Returns 32 GiB as a safe fallback.
94
+ */
95
+ function readMemTotalBytes(): number {
96
+ if (process.platform !== 'linux') return 32 * 1024 * 1024 * 1024;
97
+ try {
98
+ const raw = readFileSync('/proc/meminfo', 'utf8');
99
+ const m = raw.match(/^MemTotal:\s+(\d+)\s+kB/m);
100
+ if (m?.[1]) return parseInt(m[1], 10) * 1024;
101
+ } catch {
102
+ // ignore
103
+ }
104
+ return 32 * 1024 * 1024 * 1024;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // Build result
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Result of building the spawn argv for an agent session.
113
+ *
114
+ * Carries both the launch command/args AND the ownership handle that must
115
+ * be persisted in the tracking record for use by {@link reapAgentSuite}.
116
+ */
117
+ export interface AgentSpawnArgs {
118
+ /** Command to execute (e.g. `'systemd-run'` or `'sh'` or the real binary). */
119
+ command: string;
120
+ /** Full argument list. */
121
+ args: string[];
122
+ /**
123
+ * Ownership handle to persist in the session tracking record.
124
+ *
125
+ * Pass this to {@link reapAgentSuite} on session end.
126
+ */
127
+ ownership: AgentSuiteOwnership;
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Main API
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Build the argv for spawning a claude agent session with containment.
136
+ *
137
+ * When systemd is available the agent is placed in a transient scope under
138
+ * `cleo.slice`. Otherwise it falls back to a setsid process-group spawn
139
+ * (the caller is responsible for passing `{ detached: true }` to
140
+ * `child_process.spawn` so a new pgid is created).
141
+ *
142
+ * Core suppression is applied via `ulimit -c 0` (NOT `LimitCORE=0` — that is
143
+ * a service-unit EXEC property and is rejected by `systemd-run --scope`).
144
+ *
145
+ * @param command - The executable to run (e.g. `'claude'`).
146
+ * @param args - Arguments for the executable.
147
+ * @param scopeId - Optional discriminator appended to the unit name (e.g. a
148
+ * task ID or instance ID) so concurrent sessions are addressable.
149
+ * @returns Build result with spawn command/args and the ownership handle.
150
+ */
151
+ export function buildAgentSpawnArgs(
152
+ command: string,
153
+ args: readonly string[],
154
+ scopeId?: string,
155
+ ): AgentSpawnArgs {
156
+ if (!hasSystemdRun()) {
157
+ if (!_demotionLogged) {
158
+ _demotionLogged = true;
159
+ process.stderr.write(
160
+ '[cleo:agent-spawn-wrapper] systemd-run unavailable — ' +
161
+ 'falling back to plain pgid (detached) spawn; no cgroup containment\n',
162
+ );
163
+ }
164
+
165
+ // pgid fallback: wrap with ulimit -c 0 for core suppression.
166
+ // The caller MUST pass { detached: true } to spawn() so that Node creates
167
+ // a new session+pgid for this child.
168
+ return {
169
+ command: 'sh',
170
+ args: ['-c', 'ulimit -c 0; exec "$@"', 'sh', command, ...args],
171
+ ownership: {
172
+ mode: 'pgid' as AgentContainmentMode,
173
+ // pgid is populated after spawn; the caller patches it via the
174
+ // returned child.pid (which is the pgid leader when detached: true).
175
+ },
176
+ };
177
+ }
178
+
179
+ // Systemd path: build a transient scope under cleo.slice.
180
+ const totalBytes = readMemTotalBytes();
181
+ const maxStr = DEFAULT_MEMORY_MAX; // P1: absolute string, no fraction logic needed
182
+
183
+ const counter = ++_scopeCounter;
184
+ const discriminator = scopeId
185
+ ? scopeId.replace(/[^a-zA-Z0-9-]/g, '-').slice(0, 40)
186
+ : String(counter);
187
+ const unitName = `cleo-agent-session-${discriminator}.scope`;
188
+
189
+ // Resolve MemoryMax fraction if needed (P1 uses absolute string, so passthrough).
190
+ void totalBytes; // suppress unused-var for future fraction support
191
+
192
+ const wrapArgs: string[] = [
193
+ '--user',
194
+ '--scope',
195
+ `--slice=${CLEO_SLICE}`,
196
+ `--unit=${unitName}`,
197
+ '-p',
198
+ `MemoryMax=${maxStr}`,
199
+ '-p',
200
+ 'MemorySwapMax=0',
201
+ // NOTE: ManagedOOMPreference=avoid is NOT set for agent sessions —
202
+ // only daemon/db scope classes (write-txn holders) get 'avoid'.
203
+ // See spawn-wrapper.ts OOM_AVOID_CLASSES and the module TSDoc for rationale.
204
+ '--',
205
+ 'sh',
206
+ '-c',
207
+ 'ulimit -c 0; exec "$@"',
208
+ 'sh',
209
+ command,
210
+ ...args,
211
+ ];
212
+
213
+ return {
214
+ command: 'systemd-run',
215
+ args: wrapArgs,
216
+ ownership: {
217
+ mode: 'systemd' as AgentContainmentMode,
218
+ unitName,
219
+ // pgid is populated after spawn; caller patches via child.pid.
220
+ },
221
+ };
222
+ }