1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3
4/**
5 * Isolates long-lived agent and terminal processes from the server on Linux.
6 *
7 * Each wrapped command runs with a high `oom_score_adj`, so the kernel OOM
8 * killer picks it before the server. Under a systemd user manager it also runs
9 * in its own transient scope, so systemd-oomd (which kills whole leaf cgroups)
10 * kills one agent instead of the server and every other agent.
11 *
12 * The server provides the real implementation. The default leaves commands
13 * unchanged, which is also the behavior on macOS, Windows, and in tests.
14 */
15export interface AgentScopeShape {
16 /**
17 * Wraps the command that starts an agent or terminal. `name` labels the
18 * scope unit, for example "claude" or "terminal". `env` is the environment
19 * the command will run with, used to resolve it on PATH. A command that does
20 * not resolve stays unwrapped, so a missing binary still fails at spawn.
21 */
22 readonly wrap: (input: {
23 readonly command: string;
24 readonly args: ReadonlyArray<string>;
25 readonly name: string;
26 readonly threadId?: string | undefined;
27 readonly env?: NodeJS.ProcessEnv | undefined;
28 /**
29 * Set for terminals, so a job the user detached (tmux, nohup) outlives a
30 * server restart. Agents leave it unset: the next server stops agents a
31 * killed server left running before it resumes their sessions.
32 */
33 readonly survivesRestart?: boolean | undefined;
34 }) => Effect.Effect<AgentScopeCommand>;
35 /**
36 * Whether the latest agent scope of this thread was killed because the
37 * machine or the agents slice ran out of memory. The answer holds until the
38 * thread launches its next agent, so a retried failure reads the same.
39 */
40 readonly oomKilled: (threadId: string) => Effect.Effect<boolean>;
41 /**
42 * Forgets the thread's scope. The session manager calls it before it opens
43 * a provider session, so a scope only answers for the session that launched
44 * it, even when the next session runs without one (Cursor, OpenCode).
45 */
46 readonly clear: (threadId: string) => Effect.Effect<void>;
47}
48
49export interface AgentScopeCommand {
50 readonly command: string;
51 readonly args: ReadonlyArray<string>;
52}
53
54export const AgentScope = Context.Reference<AgentScopeShape>("@t3tools/shared/AgentScope", {
55 defaultValue: () => ({
56 wrap: ({ command, args }) => Effect.succeed({ command, args }),
57 oomKilled: () => Effect.succeed(false),
58 clear: () => Effect.void,
59 }),
60});
61
62/**
63 * The thread an ACP runtime is spawned for. ACP runtimes are shared by several
64 * provider flavors and do not take a thread id, so the ACP adapter provides it.
65 */
66export const AgentScopeThreadId = Context.Reference<string | undefined>(
67 "@t3tools/shared/AgentScope/ThreadId",
68 { defaultValue: () => undefined },
69);
70
71/**
72 * Agents get this `oom_score_adj` so the kernel kills them before the server.
73 * An unprivileged process may raise its own score, never lower it.
74 */
75export const AGENT_OOM_SCORE_ADJ = 800;
76
77/** Shell line that raises the score of the current process, ignoring failure. */
78export const RAISE_OOM_SCORE_LINE = `{ echo ${AGENT_OOM_SCORE_ADJ} >/proc/self/oom_score_adj; } 2>/dev/null`;
79
80const LAUNCH_SCRIPT = [RAISE_OOM_SCORE_LINE, 'exec "$@"'].join("\n");
81
82// systemd-run needs XDG_RUNTIME_DIR to reach the user manager. Fill it in when
83// the agent environment does not carry it; it is the same user either way.
84const SCOPED_LAUNCH_SCRIPT = [
85 RAISE_OOM_SCORE_LINE,
86 ': "${XDG_RUNTIME_DIR:=$1}"',
87 "export XDG_RUNTIME_DIR",
88 "shift",
89 'exec "$@"',
90].join("\n");
91
92export interface AgentSystemdScope {
93 readonly systemdRun: string;
94 readonly slice: string;
95 readonly unit: string;
96 readonly runtimeDir: string;
97 /** Extra `systemd-run` options, such as `--property=OOMPolicy=stop`. */
98 readonly options: ReadonlyArray<string>;
99}
100
101/**
102 * Builds the Linux launch for an agent command. Every step `exec`s, so the
103 * spawned PID, stdio, and signals all belong to the agent itself.
104 * `systemd-run --scope` registers its own PID as the scope and then runs the
105 * command, so the agent and all of its children start inside the scope.
106 */
107export function agentScopeCommand(input: {
108 readonly command: string;
109 readonly args: ReadonlyArray<string>;
110 readonly scope?: AgentSystemdScope | undefined;
111}): AgentScopeCommand {
112 const { command, args, scope } = input;
113 if (scope === undefined) {
114 return { command: "/bin/sh", args: ["-c", LAUNCH_SCRIPT, "t3-agent", command, ...args] };
115 }
116 return {
117 command: "/bin/sh",
118 args: [
119 "-c",
120 SCOPED_LAUNCH_SCRIPT,
121 "t3-agent",
122 scope.runtimeDir,
123 scope.systemdRun,
124 "--user",
125 "--scope",
126 "--quiet",
127 `--slice=${scope.slice}`,
128 `--unit=${scope.unit}`,
129 ...scope.options,
130 "--",
131 command,
132 ...args,
133 ],
134 };
135}