// Task 250 — every `claude --verbose` PTY spawn lives in its own systemd
// transient scope unit under a shared `claude-ptys.slice`, instead of inside
// the session-manager unit's cgroup. The scope cgroup is owned by user
// systemd, not by the manager; stopping the manager unit (installer rerun,
// crash, watchdog, restart) no longer cascades a control-group kill to
// active operator PTYs.
//
// This module holds the pure argv/parser helpers that the spawn adapter,
// stop path, reaper, boot-seed, and the installer share. I/O happens via an
// injected systemctl callable so tests can stub it without invoking real
// systemd. node-pty owns the spawn of `systemd-run` itself — this module
// never touches node-pty.
//
// SIGHUP wrap. Inside the scope, the claude invocation is wrapped with
// `/bin/sh -c 'trap "" HUP; <invoke> "$@"' --`. The wrap exists because
// closing the PTY master fd (which the manager owns) makes the kernel
// deliver SIGHUP to the foreground process group of the slave session —
// and claude's default disposition is exit. With SIGHUP set to ignore,
// claude survives the master closing on manager restart, which is what
// the cgroup separation is there to enable.
import { spawnSync } from 'node:child_process';
/** Single source of truth for the slice unit name. Used by the spawn argv,
 *  the boot-seed list-units filter, and the installer's slice unit
 *  emission. */
export const CLAUDE_PTYS_SLICE = 'claude-ptys.slice';
/** Hard cap on SIGTERM→SIGKILL escalation inside the scope. systemd
 *  delivers SIGTERM on stop, waits TimeoutStopSec, then SIGKILL. 5 s
 *  matches the manager's prior killGraceMs (`config.killGraceMs`); the
 *  manager no longer manages the escalation itself. */
export const SCOPE_TIMEOUT_STOP_SEC = 5;
export function defaultSystemctlRunner(args) {
    const r = spawnSync('systemctl', [...args], {
        encoding: 'utf8',
        timeout: 10_000,
    });
    return {
        exitCode: typeof r.status === 'number' ? r.status : 1,
        stdout: typeof r.stdout === 'string' ? r.stdout : '',
        stderr: typeof r.stderr === 'string' ? r.stderr : '',
    };
}
// systemd unit names accept `[a-zA-Z0-9:_.\-]`. The unit-token is a
// per-spawn stable id the manager passes in: for resume spawns it equals
// Claude Code's intrinsic sessionId (a UUID, which satisfies the char
// set); for fresh spawns it's a manager-minted UUID. Either way the
// helper refuses any token that doesn't match the systemd char set
// rather than silently rewriting — preserving the lookup-by-name contract
// the brief depends on ("no clever uniqueness suffixes").
//
// Why the token isn't always Claude Code's sessionId: at fresh-spawn
// time the manager has no sessionId yet (Claude Code mints it during
// boot and writes it to its PID file). The manager mints a separate
// token for the unit name; boot-seed reconciles unit → claude pid →
// PID-file → claude sessionId after a restart.
const SCOPE_NAME_VALID = /^[a-zA-Z0-9:_.\-]+$/;
export function scopeUnitName(unitToken) {
    if (!unitToken || !SCOPE_NAME_VALID.test(unitToken)) {
        throw new Error(`[systemd-scope] invalid unitToken for unit name: ${JSON.stringify(unitToken)}`);
    }
    return `claude-session-${unitToken}.scope`;
}
/** Inverse of `scopeUnitName` — extracts the unitToken from a unit name
 *  emitted by `systemctl --user list-units --type=scope 'claude-session-*.scope'`.
 *  Returns null when the name doesn't match the prefix/suffix shape. */
export function unitTokenFromScopeUnit(unit) {
    const m = unit.match(/^claude-session-(.+)\.scope$/);
    return m ? m[1] : null;
}
/** Parse the `--no-legend` output of `systemctl --user list-units`.
 *  Tolerant of leading column whitespace and trailing description column.
 *  Rows whose unit name doesn't extract a unitToken are skipped (defensive
 *  against future systemctl-output drift). */
export function parseSurvivingScopes(stdout) {
    const out = [];
    for (const rawLine of stdout.split('\n')) {
        const line = rawLine.trim();
        if (!line)
            continue;
        const cols = line.split(/\s+/);
        if (cols.length < 4)
            continue;
        const unitName = cols[0];
        const loaded = cols[1];
        const active = cols[2];
        const sub = cols[3];
        if (!unitName.startsWith('claude-session-') || !unitName.endsWith('.scope'))
            continue;
        const unitToken = unitTokenFromScopeUnit(unitName);
        if (!unitToken)
            continue;
        out.push({ unitName, unitToken, loaded, active, sub });
    }
    return out;
}
/** Issue `systemctl --user list-units --type=scope --no-legend
 *  'claude-session-*.scope'` and return the parsed rows. The boot-seed
 *  uses this to discover surviving PTYs across a manager restart. */
export function listSurvivingScopes(run) {
    const result = run([
        '--user',
        'list-units',
        '--type=scope',
        '--no-legend',
        '--all',
        'claude-session-*.scope',
    ]);
    if (result.exitCode !== 0) {
        return { scopes: [], result };
    }
    return { scopes: parseSurvivingScopes(result.stdout), result };
}
/** Task 554 — rc-spawn / rc-daemon scope unit-token prefixes. PTY operator
 *  scopes use the bare `claude-session-<uuid>.scope` shape and are
 *  explicitly excluded: the boot-seed reconciler claims them as live
 *  operator sessions. The reaper only sweeps tokens emitted by the
 *  rc-daemon supervisor and the `/rc-spawn` HTTP route. */
const NON_PTY_TOKEN_PREFIXES = ['rc-daemon-', 'rc-resume-'];
/** Stop any orphan rc scope units left over from prior session-manager
 *  boots. Called from the session-manager startup BEFORE the rc-daemon
 *  supervisor's first spawn and BEFORE the HTTP server begins accepting
 *  /rc-spawn requests, so no in-flight scope can be misidentified as an
 *  orphan. Idempotent and safe to call on every boot — if nothing
 *  matches, no stop is issued. */
export async function reapOrphanNonPtyScopes(run, logger) {
    const { scopes, result } = listSurvivingScopes(run);
    if (result.exitCode !== 0) {
        logger(`[systemd-scope] reaper list-units-failed exitCode=${result.exitCode} stderr=${result.stderr.trim()}`);
        return;
    }
    const orphans = scopes.filter((s) => NON_PTY_TOKEN_PREFIXES.some((p) => s.unitToken.startsWith(p)));
    if (orphans.length === 0)
        return;
    for (const orphan of orphans) {
        const stop = await stopScopeUnit(run, orphan.unitToken, {});
        logger(`[systemd-scope] reaped-orphan unitToken=${orphan.unitToken} active=${orphan.active} sub=${orphan.sub} outcome=${stop.outcome} exitCode=${stop.exitCode}`);
    }
}
export const defaultProcessSignals = {
    kill(pid, signal) {
        process.kill(pid, signal);
    },
    isAlive(pid) {
        try {
            process.kill(pid, 0);
            return true;
        }
        catch (err) {
            const code = err.code;
            if (code === 'ESRCH')
                return false;
            return true;
        }
    },
};
/** Sleep without holding a libuv timer ref open longer than needed. The
 *  default Node `setTimeout(...).unref()` would suffice but we keep the
 *  promise wrapper local so escalation timing is testable via fake timers
 *  in the future without leaking config into call sites. */
function sleep(ms) {
    return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Issue `systemctl --user stop --no-block <unit>` and verify the kill.
 *
 *  Task 451 — when `pid` is supplied, after `systemctl stop` returns we
 *  check `kill(pid, 0)`. If the pid is still alive (the scope was already
 *  collapsed before stop ran, so the pid was reparented outside the cgroup
 *  and stop's `exit=0` was a lie), escalate: SIGTERM → wait `killGraceMs`
 *  → re-check → SIGKILL → wait → re-check. Outcome reflects the path
 *  taken. When `pid` is null (caller cannot supply, e.g. genuine orphan
 *  with no MainPID), the function behaves as pre-451 — exit code is the
 *  only signal.
 *
 *  The --no-block flag returns once the stop request is queued; the
 *  scope's TimeoutStopSec=5 handles in-cgroup SIGTERM→SIGKILL escalation.
 *  Exit code 5 ("not loaded") still means the scope retired naturally —
 *  treated as `already-gone`. */
export async function stopScopeUnit(run, unitToken, opts = {}) {
    const unit = scopeUnitName(unitToken);
    const result = run(['--user', 'stop', '--no-block', unit]);
    if (result.exitCode === 5) {
        return { outcome: 'already-gone', exitCode: 5, stderr: result.stderr };
    }
    if (result.exitCode !== 0) {
        return { outcome: 'error', exitCode: result.exitCode, stderr: result.stderr };
    }
    const pid = opts.pid ?? null;
    // No pid → caller can't verify. Trust systemctl's exit. Pre-451 behaviour.
    if (pid === null) {
        return { outcome: 'ok', exitCode: 0, stderr: result.stderr };
    }
    const signals = opts.signals ?? defaultProcessSignals;
    const graceMs = opts.killGraceMs ?? 5_000;
    // Pid was gone before we even checked — happy path.
    if (!signals.isAlive(pid)) {
        return { outcome: 'ok', exitCode: 0, stderr: result.stderr };
    }
    // Pid survived `systemctl stop` — the scope was already collapsed and
    // the pid is reparented outside its cgroup. Escalate ourselves.
    try {
        signals.kill(pid, 'SIGTERM');
    }
    catch { /* ESRCH = already gone, ESRPM = treat-as-alive */ }
    await sleep(graceMs);
    if (!signals.isAlive(pid)) {
        return { outcome: 'escalated-term', exitCode: 0, stderr: result.stderr };
    }
    try {
        signals.kill(pid, 'SIGKILL');
    }
    catch { /* same */ }
    await sleep(graceMs);
    if (!signals.isAlive(pid)) {
        return { outcome: 'escalated-kill', exitCode: 0, stderr: result.stderr };
    }
    return { outcome: 'failed-pid-survives', exitCode: 0, stderr: result.stderr };
}
/** Build the systemd-run argv that node-pty wraps for one claude spawn.
 *  Inside the scope, `/bin/sh -c 'trap "" HUP; exec "$@"' sh <claudeBin> <claudeArgs...>`
 *  wraps the launch so:
 *
 *  - **claude ignores SIGHUP** when the manager closes the PTY master fd
 *    on restart. Without the trap, master-close → kernel SIGHUP →
 *    claude exits.
 *  - **`exec` keeps the pid stable** through the chain (sh → claude
 *    reuse the original pid via execve), preserving the
 *    `pty.pid == claude.pid` invariant the watcher.waitForPid path
 *    depends on.
 *
 *  Task 573 — node-pty allocates the TTY for the child. The earlier
 *  script(1) wrap (Task 556) plus the non-PTY scope primitive it served
 *  are gone. */
export function buildSystemdRunArgv(p) {
    const unit = scopeUnitName(p.unitToken);
    const args = [
        '--user',
        '--scope',
        '--quiet',
        `--unit=${unit}`,
        `--slice=${CLAUDE_PTYS_SLICE}`,
        '-p',
        `TimeoutStopSec=${SCOPE_TIMEOUT_STOP_SEC}`,
        `--working-directory=${p.cwd}`,
    ];
    // One --setenv per env key. Order is sorted-by-key for deterministic
    // log lines and test fixtures (and to make argv-diff review tractable
    // across spawns).
    const keys = Object.keys(p.env).sort();
    for (const k of keys) {
        args.push(`--setenv=${k}=${p.env[k]}`);
    }
    // POSIX `--` sentinel — ends systemd-run's own flag parsing so the
    // following positional argv (the sh wrapper) doesn't get swallowed
    // by `--setenv=` etc.
    args.push('--');
    // POSIX `sh -c <script> <$0> <$1> <$2>...` — `"$@"` expands to the
    // positionals starting at $1. Passing 'sh' as $0 (the script name)
    // makes claudeBin the first positional and claudeArgs the remainder,
    // so `exec "$@"` runs `<claudeBin> <claudeArgs...>` after the trap.
    args.push('/bin/sh', '-c', 'trap "" HUP; exec "$@"', 'sh', p.claudeBin);
    for (const a of p.claudeArgs)
        args.push(a);
    return { cmd: 'systemd-run', args };
}
/** Query `systemctl --user show -p MainPID claude-session-<token>.scope`
 *  and return the main pid (the claude process). Boot-seed uses this to
 *  bridge from the surviving scope unit to the PID file claude wrote at
 *  `$CLAUDE_CONFIG_DIR/sessions/<pid>.json`, which in turn carries claude's
 *  intrinsic sessionId. Returns null when the unit has no main pid (the
 *  scope is draining) or when the systemctl call fails. */
export function getScopeMainPid(run, unitToken) {
    const unit = scopeUnitName(unitToken);
    const result = run(['--user', 'show', '-p', 'MainPID', '--value', unit]);
    if (result.exitCode !== 0)
        return null;
    const raw = result.stdout.trim();
    if (!raw || raw === '0')
        return null;
    const n = Number.parseInt(raw, 10);
    return Number.isFinite(n) && n > 0 ? n : null;
}
/** Slice unit declared at install time so every claude scope shares one
 *  cohort cgroup. `systemctl --user status claude-ptys.slice` gives the
 *  operator a one-glance view of total live-session memory + CPU. */
export function buildClaudePtysSliceUnitFile() {
    return `[Unit]
Description=Claude Code PTY sessions (Task 250)
Documentation=https://github.com/rubytech/maxy-code/blob/main/.tasks/archive/250-claude-ptys-survive-manager-restart-via-systemd-scope-per-pty.md
Before=shutdown.target

[Slice]

[Install]
WantedBy=default.target
`;
}
//# sourceMappingURL=systemd-scope.js.map