diff --git a/docs/operations/config-reference.md b/docs/operations/config-reference.md index 4c598aab..38827d1b 100644 --- a/docs/operations/config-reference.md +++ b/docs/operations/config-reference.md @@ -253,6 +253,9 @@ Environment variables: - `CITADEL_TMUX_SOCKET` — tmux socket name used by the daemon and terminal bridge. - `CITADEL_TMUX_HISTORY_LIMIT` — tmux scrollback lines per pane (default `20000`, clamped to `1000`-`100000`). - `CITADEL_SHELL_BIN` — shell used when Citadel creates shell-first tmux sessions (default `$SHELL` then `/bin/bash`). +- `CITADEL_AGENT_LOW_PRIORITY` — set to `0`, `false`, `no`, or `off` to disable the default low-priority wrapper for agent runtimes. +- `CITADEL_AGENT_IONICE` — set to `off` to skip `ionice -c3`; by default Citadel uses it when available so agent disk IO yields to interactive work. +- `CITADEL_AGENT_NICE` — agent niceness value, default `10`, clamped to `0`-`19`. Lifecycle: diff --git a/packages/terminal/src/index.ts b/packages/terminal/src/index.ts index 261dffe5..cf7366fd 100644 --- a/packages/terminal/src/index.ts +++ b/packages/terminal/src/index.ts @@ -21,6 +21,7 @@ export { keyForControlCharacter, keyForEscapeSequence, tokenizeTerminalInput } f export type { InputToken } from "./input-tokens.js"; import { captureTmuxSnapshot, waitForTerminalIdle } from "./capture.js"; +import { agentResourcePrefixArgs } from "./resource-priority.js"; export { attachTerminalWebSocket, attachTmuxPty, @@ -151,6 +152,7 @@ export { sweepLegacyAgentSentinels, } from "./pane-lifecycle.js"; export type { AgentExitHint, PanePidProcess, SweepLegacySentinelsResult } from "./pane-lifecycle.js"; +export { agentNiceValue, agentResourcePrefixArgs } from "./resource-priority.js"; // Path of the "agent still running" sentinel file for a given tmux session. // The wrapper script touches this before exec'ing the agent and removes it @@ -255,7 +257,14 @@ export async function ensureTmuxSessionRaw(input: RawTerminalSessionRequest) { // tmux new-session takes a single `shell-command` string and hands it to // /bin/sh -c. Build it ourselves with shellQuote so an arg with spaces is // preserved correctly. - const shellCommand = [...commandEnvironmentPrefix(input.env), input.command, ...input.args].map(shellQuote).join(" "); + const shellCommand = [ + ...commandEnvironmentPrefix(input.env), + ...agentResourcePrefixArgs(), + input.command, + ...input.args, + ] + .map(shellQuote) + .join(" "); await execFileAsync( "tmux", [...tmuxPrefix(input.socketName), "new-session", "-d", "-s", input.sessionName, "-c", input.cwd, shellCommand], diff --git a/packages/terminal/src/pane-lifecycle.ts b/packages/terminal/src/pane-lifecycle.ts index c7f837cf..96c69db4 100644 --- a/packages/terminal/src/pane-lifecycle.ts +++ b/packages/terminal/src/pane-lifecycle.ts @@ -3,6 +3,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { shellQuote, tmuxPrefix, waitForPaneCommand, waitForTerminalIdle } from "./index.js"; +import { agentResourcePrefixArgs } from "./resource-priority.js"; // Shell-first pane lifecycle helpers. The tmux pane PID is `bash -l`; the // agent runs as a child of the shell. Killing the agent (Ctrl+C, /quit, @@ -94,6 +95,7 @@ export async function launchAgentInSession( if (!runtimeBinary) throw new Error("launchAgentInSession requires a runtimeBinary"); await waitForTerminalIdle(sessionName, { timeoutMs: 1500, idleMs: 200, socketName: options.socketName ?? null }); const launchCmd = [ + ...agentResourcePrefixArgs(), "env", ...envUnsetArgs(options.env), ...COLOR_ENV_UNSETS.flatMap((key) => ["-u", key]), diff --git a/packages/terminal/src/resource-priority.test.ts b/packages/terminal/src/resource-priority.test.ts new file mode 100644 index 00000000..6ca13409 --- /dev/null +++ b/packages/terminal/src/resource-priority.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { agentNiceValue, agentResourcePrefixArgs } from "./resource-priority.js"; + +describe("agent resource priority", () => { + it("wraps agent launches with idle IO and lower CPU priority by default", () => { + expect(agentResourcePrefixArgs({ commandExists: () => true, env: {} })).toEqual([ + "ionice", + "-c3", + "nice", + "-n", + "10", + ]); + }); + + it("can be disabled for environments that need unmodified agent launches", () => { + expect( + agentResourcePrefixArgs({ + commandExists: () => true, + env: { CITADEL_AGENT_LOW_PRIORITY: "false" }, + }), + ).toEqual([]); + }); + + it("omits unavailable wrappers instead of failing the launch", () => { + expect( + agentResourcePrefixArgs({ + commandExists: (command) => command === "nice", + env: {}, + }), + ).toEqual(["nice", "-n", "10"]); + }); + + it("supports disabling ionice while keeping nice", () => { + expect( + agentResourcePrefixArgs({ + commandExists: () => true, + env: { CITADEL_AGENT_IONICE: "off", CITADEL_AGENT_NICE: "7" }, + }), + ).toEqual(["nice", "-n", "7"]); + }); + + it("clamps nice values to the unprivileged priority-lowering range", () => { + expect(agentNiceValue("-5")).toBe(0); + expect(agentNiceValue("12")).toBe(12); + expect(agentNiceValue("99")).toBe(19); + expect(agentNiceValue("invalid")).toBe(10); + }); +}); diff --git a/packages/terminal/src/resource-priority.ts b/packages/terminal/src/resource-priority.ts new file mode 100644 index 00000000..6f9051fe --- /dev/null +++ b/packages/terminal/src/resource-priority.ts @@ -0,0 +1,52 @@ +import { execFileSync } from "node:child_process"; + +const DEFAULT_AGENT_NICE = 10; +const MAX_NICE = 19; +const DISABLED_VALUES = new Set(["0", "false", "no", "off", "disabled", "none"]); +const commandCache = new Map(); + +type AgentResourcePrefixOptions = { + env?: NodeJS.ProcessEnv; + commandExists?: (command: string) => boolean; +}; + +export function agentResourcePrefixArgs(options: AgentResourcePrefixOptions = {}): string[] { + const env = options.env ?? process.env; + if (isDisabled(env.CITADEL_AGENT_LOW_PRIORITY)) return []; + + const exists = options.commandExists ?? commandAvailable; + const args: string[] = []; + if (!isDisabled(env.CITADEL_AGENT_IONICE) && exists("ionice")) args.push("ionice", "-c3"); + + const niceValue = agentNiceValue(env.CITADEL_AGENT_NICE); + if (niceValue > 0 && exists("nice")) args.push("nice", "-n", String(niceValue)); + return args; +} + +export function agentNiceValue(raw: string | undefined): number { + if (raw === undefined || raw.trim() === "") return DEFAULT_AGENT_NICE; + const parsed = Number.parseInt(raw.trim(), 10); + if (!Number.isFinite(parsed)) return DEFAULT_AGENT_NICE; + return Math.min(MAX_NICE, Math.max(0, parsed)); +} + +function isDisabled(raw: string | undefined): boolean { + return raw !== undefined && DISABLED_VALUES.has(raw.trim().toLowerCase()); +} + +function commandAvailable(command: string): boolean { + if (!/^[A-Za-z0-9_.-]+$/.test(command)) return false; + const cached = commandCache.get(command); + if (cached !== undefined) return cached; + try { + execFileSync("sh", ["-lc", `command -v ${command}`], { + stdio: "ignore", + timeout: 1000, + }); + commandCache.set(command, true); + return true; + } catch { + commandCache.set(command, false); + return false; + } +}