···2929 getSessionDir,
3030 DEFAULT_SESSION_DIR,
3131 type SessionInfo,
3232+ type SessionMetadata,
3233} from "./sessions.ts";
3334import { spawnDaemon, resolveCommand } from "./spawn.ts";
3435import {
···29862987 }
29872988}
2988298929902990+/** Detect a session that should NOT be blindly `pty restart`ed: a stateful
29912991+ * interactive agent. Two signals — a `role=agent` tag, or a `claude --resume`
29922992+ * in the stored command. Returns a short human reason, or null. */
29932993+function statefulAgentReason(meta: SessionMetadata): string | null {
29942994+ if (meta.tags?.role === "agent") return "role=agent tag";
29952995+ const argv = [meta.command, ...(meta.args ?? []), meta.displayCommand].filter(Boolean).join(" ");
29962996+ if (/(^|\s|\/)claude(\s|$)/.test(argv) && /(^|\s)--resume(\s|=|$)/.test(argv)) {
29972997+ return "claude --resume command";
29982998+ }
29992999+ return null;
30003000+}
30013001+29893002async function cmdRestart(
29903003 name: string,
29913004 yes = false,
···30023015 if (!meta) {
30033016 console.error(`Session "${name}" has no metadata — cannot restart.`);
30043017 cleanupAll(name);
30183018+ process.exit(1);
30193019+ }
30203020+30213021+ // Guardrail: `pty restart` blindly re-runs the stored argv — fine for a
30223022+ // stateless daemon, a footgun for a stateful interactive agent. Restarting a
30233023+ // `claude --resume` agent kills its in-progress work AND can wedge the resume
30243024+ // (the re-exec races the old pts teardown; claude freezes on its exit screen
30253025+ // and the daemon orphans). Refuse for agent-shaped sessions unless --force —
30263026+ // the right way to cycle an agent is through its supervisor (e.g. convoy).
30273027+ const agentReason = statefulAgentReason(meta);
30283028+ if (agentReason && !forceNested) {
30293029+ console.error(`Session "${name}" looks like a stateful agent (${agentReason}).`);
30303030+ console.error(
30313031+ "`pty restart` kills its in-progress work and can wedge a `claude --resume`. " +
30323032+ "Cycle it through its supervisor (e.g. `convoy up`) instead — or pass --force to restart anyway."
30333033+ );
30053034 process.exit(1);
30063035 }
30073036
+80
tests/restart-guardrail.test.ts
···11+import { describe, it, expect, afterAll } from "vitest";
22+import * as fs from "node:fs";
33+import * as os from "node:os";
44+import * as path from "node:path";
55+import { fileURLToPath } from "node:url";
66+import { spawnSync } from "node:child_process";
77+88+const __dirname = path.dirname(fileURLToPath(import.meta.url));
99+const nodeBin = process.execPath;
1010+const cliPath = path.join(__dirname, "..", "dist", "cli.js");
1111+const testRoot = fs.mkdtempSync(path.join(os.tmpdir(), "pty-rg-"));
1212+const bgPids: number[] = [];
1313+afterAll(() => {
1414+ for (const pid of bgPids) { try { process.kill(pid, "SIGKILL"); } catch {} }
1515+ fs.rmSync(testRoot, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
1616+});
1717+1818+// PTY_SESSION set => restart takes its "already inside a session, not attaching"
1919+// branch and returns instead of hanging on a non-TTY attach.
2020+function runCli(dir: string, args: string[], timeout = 15000) {
2121+ return spawnSync(nodeBin, [cliPath, ...args], {
2222+ env: { ...process.env, PTY_SESSION_DIR: dir, PTY_ROOT_LEGACY_SILENT: "1", PTY_SESSION: "outer" },
2323+ encoding: "utf8", timeout,
2424+ });
2525+}
2626+2727+function createSession(dir: string, name: string, extra: string[], cmd: string[]): void {
2828+ const r = runCli(dir, ["run", "-d", "--id", name, ...extra, "--", ...cmd]);
2929+ expect(r.status).toBe(0);
3030+ try {
3131+ bgPids.push(Number(fs.readFileSync(path.join(dir, `${name}.pid`), "utf8").trim()));
3232+ } catch {}
3333+}
3434+3535+describe("pty restart guardrail for stateful agent sessions", () => {
3636+ it("refuses to restart a role=agent session (exit nonzero, points at convoy)", () => {
3737+ const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
3838+ createSession(dir, "ag", ["--tag", "role=agent"], ["sleep", "300"]);
3939+4040+ const r = runCli(dir, ["restart", "-y", "ag"]);
4141+ expect(r.status).not.toBe(0);
4242+ expect(r.stderr).toMatch(/stateful agent/);
4343+ expect(r.stderr).toMatch(/role=agent/);
4444+ expect(r.stderr).toMatch(/--force/);
4545+ expect(r.stderr).toMatch(/convoy/);
4646+ }, 20000);
4747+4848+ it("refuses to restart a `claude --resume` command session", () => {
4949+ const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
5050+ // No role tag — detection is purely on the stored argv.
5151+ createSession(dir, "cr", ["--no-display-name"], ["claude", "--resume", "ABC-123"]);
5252+5353+ const r = runCli(dir, ["restart", "-y", "cr"]);
5454+ expect(r.status).not.toBe(0);
5555+ expect(r.stderr).toMatch(/stateful agent/);
5656+ expect(r.stderr).toMatch(/claude --resume/);
5757+ }, 20000);
5858+5959+ it("does NOT block a normal session (no agent tag, no claude --resume)", () => {
6060+ const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
6161+ createSession(dir, "plain", [], ["sleep", "300"]);
6262+6363+ const r = runCli(dir, ["restart", "-y", "plain"]);
6464+ expect(r.status).toBe(0);
6565+ expect(r.stdout).toContain("restarted");
6666+ expect(r.stderr).not.toMatch(/stateful agent/);
6767+ }, 20000);
6868+6969+ it("--force overrides the guardrail (restarts anyway)", () => {
7070+ const dir = fs.mkdtempSync(path.join(testRoot, "d-"));
7171+ createSession(dir, "ag2", ["--tag", "role=agent"], ["sleep", "300"]);
7272+7373+ // --force bypasses the guard AND the nesting guard, so it proceeds to
7474+ // attach and would hang in this non-TTY test — a short timeout is fine; we
7575+ // only assert it got PAST the guard ("restarted" printed, no refusal).
7676+ const r = runCli(dir, ["restart", "-y", "--force", "ag2"], 4000);
7777+ expect(r.stderr).not.toMatch(/stateful agent/);
7878+ expect(r.stdout).toContain("restarted");
7979+ }, 20000);
8080+});