Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/operations/config-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
11 changes: 10 additions & 1 deletion packages/terminal/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand Down
2 changes: 2 additions & 0 deletions packages/terminal/src/pane-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]),
Expand Down
48 changes: 48 additions & 0 deletions packages/terminal/src/resource-priority.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
52 changes: 52 additions & 0 deletions packages/terminal/src/resource-priority.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean>();

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;
}
}
Loading