@awebai/oats 0.40.2 → 0.41.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/core.mjs CHANGED
@@ -68,6 +68,7 @@ export { fingerprintTree };
68
68
  import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
69
69
  import { readPortableBytes } from "./bounded-read.mjs";
70
70
  import { copyTreeSafe } from "./tree-copy.mjs";
71
+ import { assertSameWorktreeHead, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
71
72
  /** The package and capability id grammar (namespaced, lowercase): an id names a
72
73
  * directory (a home's module copy), so no path spelling fits it. */
73
74
  const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
@@ -206,6 +207,11 @@ function shIn(cwd, cmdline, timeout = 45000) {
206
207
  }
207
208
  function shInTry(cwd, cmdline, timeout) { try { return shIn(cwd, cmdline, timeout); } catch { return undefined; } }
208
209
  export function shq(s) { return `'${String(s).replace(/'/g, `'\\''`)}'`; }
210
+ /** One word of a command line that a person or an agent pastes into a shell: as it is when it holds
211
+ * only letters, digits and `_./:-`, single-quoted otherwise (an empty string too). The safe set is
212
+ * deliberately small: no `~` (an unquoted leading one expands), no `=` (zsh expands a leading
213
+ * `=word`). */
214
+ export function shellWord(s) { const word = String(s); return /^[A-Za-z0-9_.\/:-]+$/.test(word) ? word : shq(word); }
209
215
  export function slug(s) {
210
216
  const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
211
217
  return r || "agent";
@@ -1898,20 +1904,340 @@ export function explicitInstanceName(name) {
1898
1904
  }
1899
1905
 
1900
1906
  function tmuxAlive(session) { return !!shTry(`tmux has-session -t ${shq(session)} 2>/dev/null && echo yes`); }
1901
- function tmuxSocket(session) {
1902
- let socket;
1907
+ /** The window names of `session` on the user's DEFAULT tmux server: only for a home that records no
1908
+ * socket (listInstances). OATS creates nothing there: new sessions live on the OATS server below. */
1909
+ export function tmuxWindows(session = DEFAULT_TMUX_SESSION) {
1910
+ if (!tmuxAlive(session)) return [];
1911
+ return (shTry(`tmux list-windows -t ${shq(session)} -F '#{window_name}'`) || "").split("\n").filter(Boolean);
1912
+ }
1913
+
1914
+ // ---------- the OATS tmux server ----------
1915
+ /** The tmux server OATS creates its sessions on, selected by name (`tmux -L oats`): one per host user,
1916
+ * in tmux's own per-user socket directory, with the user's tmux configuration loaded (no `-f`). A
1917
+ * tool that restyles the default server does not reach it. Not configurable. The name selects the
1918
+ * server only while a session is ensured (ensureOatsTmuxSession); every later step, and every record,
1919
+ * uses the absolute socket that step returned, through tmuxOn. docs/execution-targets.md owns the rule. */
1920
+ const OATS_TMUX_SERVER = "oats";
1921
+ /** What OATS sets, always at WINDOW scope and by window id, on a window it created: nothing is ever
1922
+ * set server-global or on a session, on any server. Sizing goes on every window OATS creates (the
1923
+ * session's `hq` window and each agent window): viewers attach at their own size and depend on both
1924
+ * options, and a viewer links the window itself, so the window's options travel with it. A sizing
1925
+ * command that tmux refuses is ignored, as it always was (tmux 3.0 has no `window-size latest`).
1926
+ * Colours go on the agent window only: it shows the viewer's colours whatever the server's global
1927
+ * styles are, and a refused colour command fails the launch. */
1928
+ const OATS_WINDOW_SIZING = [["window-size", "latest"], ["aggressive-resize", "on"]];
1929
+ const OATS_WINDOW_COLOURS = [["window-style", "default"], ["window-active-style", "default"]];
1930
+ function tmuxOatsServer(args, io, options = {}) {
1931
+ return (io?.exec || execFileSync)("tmux", ["-u", "-L", OATS_TMUX_SERVER, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...options });
1932
+ }
1933
+ const tmuxFailure = (e, fallback) => String(e?.stderr ?? e?.message ?? "").trim() || fallback;
1934
+ /** An error OATS raised itself (a root guard under an injected exec), never one a tolerant read may absorb. */
1935
+ const oatsCoded = (e) => typeof e?.code === "string" && e.code.startsWith("E_");
1936
+ /** A failed call that ran to its own exit: it has a status, no signal ended it, and Node reports no
1937
+ * failure of its own. A timeout and an output overflow carry a code and keep what the call had
1938
+ * printed until then, so that text is not the call's answer. Anything else is not a completed call. */
1939
+ const exitedByItself = (e) => Number.isInteger(e?.status) && e.signal == null && e.code == null;
1940
+ /** What the OATS server holds, as `list-sessions` answers: whether it runs, its socket (any session
1941
+ * names it), and whether a session named exactly `session` is there. No server and no such session
1942
+ * are the only answers that mean absent, and "no server" is read only from a call that exited by
1943
+ * itself: a line a cut-off call left behind is not tmux's answer. Any other failure is not absence. */
1944
+ function oatsTmuxSessionSocket(session, io) {
1945
+ let listed;
1946
+ try { listed = tmuxOatsServer(["list-sessions", "-F", "#{session_name}\t#{socket_path}"], io); }
1947
+ catch (e) {
1948
+ if (oatsCoded(e)) throw e;
1949
+ // A tmux that could not be run at all is the one failure a person can act on: say so.
1950
+ if (e?.code === "ENOENT") throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `could not read the OATS tmux server for session ${session}: tmux was not found through this process's PATH. Run this command with a PATH that holds tmux`);
1951
+ if (!exitedByItself(e)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `could not read the OATS tmux server for session ${session}: tmux list-sessions did not exit by itself (${typeof e?.code === "string" ? e.code : e?.signal || "no exit status"})`);
1952
+ if (tmuxServerLost(e)) return { server: null, present: false };
1953
+ throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `could not read the OATS tmux server for session ${session}: ${tmuxFailure(e, "tmux list-sessions failed")}`);
1954
+ }
1955
+ let server = null, present = false;
1956
+ for (const line of listed.split("\n")) {
1957
+ const tab = line.indexOf("\t");
1958
+ if (tab <= 0) continue;
1959
+ server ??= line.slice(tab + 1).trim() || null;
1960
+ if (line.slice(0, tab) === session) { server = line.slice(tab + 1).trim() || null; present = true; break; }
1961
+ }
1962
+ return { server, present };
1963
+ }
1964
+ /** The names that mark a process as an OATS instance: the launch identity, and what older kernels
1965
+ * called it. One of them in the environment, even empty, is evidence of an instance. */
1966
+ const INSTANCE_IDENTITY_ENV = ["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "PI_AGENT_INSTANCE", "PI_AGENT_HOME"];
1967
+ /** Every name the kernel itself generates into a launch or a hook environment, and the three that
1968
+ * tie a process to its own terminal. None of them is part of an environment OATS starts a tmux
1969
+ * server, a session or a window with. Not the whole OATS_ prefix: operators export configuration
1970
+ * under it (OATS_HOME_DIR, OATS_TMUX_SESSION). The inventory: RESERVED_LAUNCH_ENV; what
1971
+ * runLifecycleHooks adds for a hook (capability, layer, level, meta, the spawn and launch facts in
1972
+ * its extraEnv, the team and workspace names of teamEnv); what operator dispatch, retire and a
1973
+ * trigger add. Launch references (OATS_LAUNCH_REF_<NAME>, and the NAME each stands for) are
1974
+ * removed by prefix in withoutKernelEnvironment. No harness's name is here. */
1975
+ const KERNEL_ENV_NAMES = new Set([...RESERVED_LAUNCH_ENV, "COLORFGBG", "TMUX", "TMUX_PANE",
1976
+ "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_OPERATION",
1977
+ "OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK", "OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME",
1978
+ "OATS_LAUNCH_PREVIEW", "OATS_RETIRE_INTENT", "OATS_TRIGGER_EVENT_FILE",
1979
+ "OATS_TEAM_NAME", "OATS_TEAM_SCOPE", "OATS_TEAM_ID", "OATS_TEAM_LABEL", "OATS_TEAM_LABELS", "OATS_TEAMS", "OATS_TEAMS_SOURCE",
1980
+ "OATS_DEFAULT_TEAM", "OATS_DEFAULT_TEAM_ID", "OATS_DEFAULT_TEAM_FROM", "OATS_WORKSPACE_NAME", "OATS_WORKSPACE_KEY"]);
1981
+ function withoutKernelEnvironment(source) {
1982
+ const env = { ...source };
1983
+ for (const name of Object.keys(env)) {
1984
+ if (name.startsWith(LAUNCH_REF_PREFIX)) { delete env[name]; delete env[name.slice(LAUNCH_REF_PREFIX.length)]; }
1985
+ }
1986
+ for (const name of KERNEL_ENV_NAMES) delete env[name];
1987
+ return env;
1988
+ }
1989
+ /**
1990
+ * What `tmux show-environment -g -s` printed, as bytes, read strictly and never executed: the whole
1991
+ * text or nothing. tmux (cmd-show-environment.c) prints one entry per variable, each ending in a
1992
+ * line feed: `NAME="VALUE"; export NAME;` with `"`, `$`, a backtick and `\` in VALUE each preceded
1993
+ * by a backslash, or `unset NAME;` for a removed one. VALUE is read to its closing unescaped quote
1994
+ * (a backslash escapes the character after it), so a line feed inside a value stays inside it.
1995
+ *
1996
+ * Not every value can be known the same way on every tmux. tmux 3.4 and 3.5 write each printed
1997
+ * line through vis(3) (server-client.c server_client_print): a control character or a byte that is
1998
+ * not UTF-8 arrives as `\a`, `\b`, `\f`, `\r`, `\v` or three octal digits, and tmux 3.4 puts one
1999
+ * more backslash before a `$` that a letter, `_` or `{` follows (utf8.c utf8_strvis). Neither
2000
+ * changes where a value ends. So nothing is decoded: a variable is left out whole when its VALUE
2001
+ * holds a line feed, a `$` (escaped or bare, on every version), or a vis-encoded sequence. A
2002
+ * backtick, a quote and a backslash are written the same by every version read, and are carried.
2003
+ *
2004
+ * Kept: an entry whose NAME is a plain identifier and whose VALUE is none of the above; an `unset`
2005
+ * entry carries nothing. An entry is left out only once it is framed whole (its name, its value to
2006
+ * the closing quote, the exact `; export NAME;` tail); its name is added to `omitted` when the
2007
+ * caller passes one. null for bytes that are not valid UTF-8 or that this grammar does not consume
2008
+ * to the end: a caller refuses, it never uses a partial result.
2009
+ */
2010
+ export function parseTmuxShellEnvironment(bytes, omitted) {
2011
+ let text;
2012
+ try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return null; }
2013
+ const env = {};
2014
+ let i = 0;
2015
+ const take = (word) => { if (!text.startsWith(word, i)) return false; i += word.length; return true; };
2016
+ while (i < text.length) {
2017
+ if (take("unset ")) {
2018
+ const end = text.indexOf(";", i);
2019
+ if (end <= i || /[\s"=]/.test(text.slice(i, end))) return null;
2020
+ i = end + 1;
2021
+ } else {
2022
+ const assign = text.indexOf("=\"", i);
2023
+ if (assign <= i) return null;
2024
+ const name = text.slice(i, assign);
2025
+ i = assign + 2;
2026
+ let value = "", closed = false, carried = true;
2027
+ while (i < text.length) {
2028
+ const c = text[i++];
2029
+ if (c === "\"") { closed = true; break; }
2030
+ if (c === "$") { carried = false; continue; }
2031
+ if (c === "`") return null; // tmux never writes a bare backtick
2032
+ if (c !== "\\") { value += c; continue; }
2033
+ const next = text[i];
2034
+ if (next === "$") { carried = false; i += 1; }
2035
+ else if (next !== undefined && "\"`\\".includes(next)) { value += next; i += 1; }
2036
+ else if (/^(?:[abfrv]|[0-7]{3})/.test(text.slice(i, i + 3))) { carried = false; i += /[0-7]/.test(next) ? 3 : 1; }
2037
+ else return null;
2038
+ }
2039
+ if (!closed || !take(`; export ${name};`)) return null;
2040
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
2041
+ if (carried && !value.includes("\n")) env[name] = value; else omitted?.push(name);
2042
+ }
2043
+ }
2044
+ if (!take("\n")) return null;
2045
+ }
2046
+ return env;
2047
+ }
2048
+ /** Whether this process runs inside an OATS instance, as the CLI resolves an in-home command:
2049
+ * `{ home }` for the home OATS_INSTANCE_HOME names when it is one, else the instance home enclosing
2050
+ * the working directory (a harness may strip the session environment). `{ evidence }` when neither
2051
+ * gives a home and yet the environment carries an instance identity name: an instance's environment
2052
+ * without an identified home is not "outside an instance". undefined otherwise. */
2053
+ function callerInstance() {
2054
+ const named = process.env.OATS_INSTANCE_HOME;
2055
+ if (named && isAbsolute(named)) {
2056
+ try { if (isPlainObject(JSON.parse(readFileSync(join(named, "instance.json"), "utf8")))) return { home: named }; }
2057
+ catch { /* names no home */ }
2058
+ }
2059
+ const enclosing = enclosingInstanceHome(logicalCwd());
2060
+ if (enclosing) return { home: enclosing };
2061
+ const evidence = INSTANCE_IDENTITY_ENV.find((name) => process.env[name] !== undefined);
2062
+ return evidence ? { evidence } : undefined;
2063
+ }
2064
+ /** `tmux` as this process's own PATH finds it, always absolute: the creator chooses the tmux that
2065
+ * runs, whatever environment the server it starts is given. A bare name is never run: an exec
2066
+ * looks one up in the PATH of the environment it is passed, which here is the selected one.
2067
+ * Entries in PATH order; a relative entry, and an empty one (an empty PATH is one empty entry),
2068
+ * are this process's working directory, as an exec of `tmux` reads them. With no PATH, or none
2069
+ * that holds a tmux, the creation of `session` is refused, for every creator: no default search
2070
+ * path is assumed. Of the two, only "no PATH" is met in a run: the lookup before this runs the
2071
+ * bare name with this process's own environment, so a PATH that holds no tmux is refused there
2072
+ * first (oatsTmuxSessionSocket), session or no session. The no-match refusal here is the guard
2073
+ * that the creation never runs a bare name, whatever the lookup did. */
2074
+ function creatorTmux(session) {
2075
+ const refuse = (why) => oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot create tmux session ${session} on the OATS tmux server: ${why}, and the tmux that creates a session is the one this process's own PATH finds; the session was not created. Run this command with a PATH that holds tmux`);
2076
+ if (process.env.PATH === undefined) throw refuse("PATH is not set in this process");
2077
+ for (const dir of process.env.PATH.split(":")) {
2078
+ const candidate = resolve(dir || ".", "tmux");
2079
+ try { accessSync(candidate, fsConstants.X_OK); if (statSync(candidate).isFile()) return candidate; } catch { /* not here */ }
2080
+ }
2081
+ throw refuse("no tmux was found on this process's PATH");
2082
+ }
2083
+ /**
2084
+ * The environment a `new-session` on the OATS server runs with: the only tmux call that can start
2085
+ * a server, so every one of them gets this, whether or not a server was seen a moment earlier. A
2086
+ * tmux server keeps the environment of the client that started it as its global environment, which
2087
+ * every pane created on it later inherits, and a new session takes the client's variables that
2088
+ * `update-environment` names.
2089
+ *
2090
+ * A process inside an OATS instance never passes its own environment (its harness's variables, its
2091
+ * credentials, its identity). It passes the global environment of the tmux server its home
2092
+ * records: an existing baseline, chosen because a window that instance opened on that server got
2093
+ * exactly it; not proof that it holds nothing old. The home's receipt is checked against
2094
+ * instance.json first, as every session verb checks its endpoint, and the text is read with the
2095
+ * strict reader above. When any of that fails, or no home is identified, the session is not
2096
+ * created: no fallback to the caller's environment, to another server or to a built-in list. Any
2097
+ * other creator (an operator's shell, a schedule runner, the Desktop) passes its own environment.
2098
+ * In every case the kernel's names are removed from the final set.
2099
+ *
2100
+ * Environment and endpoint are chosen separately: which server is reached depends only on the
2101
+ * creator (its TMUX_TMPDIR, kept or absent as the creator has it; its tmux); what the server
2102
+ * process gets (HOME and so its configuration file, PATH, everything else) comes from the selected
2103
+ * environment, with no ambient value filling in. Values travel only as the environment of that
2104
+ * tmux process: never in an argument, a message, an event or a file. docs/execution-targets.md
2105
+ * owns the rule.
2106
+ */
2107
+ const TMUX_SERVER_START_ENV = ["HOME", "XDG_CONFIG_HOME", "PATH", "SHELL"];
2108
+ function oatsSessionEnvironment(session, io) {
2109
+ const caller = callerInstance();
2110
+ if (!caller) return withoutKernelEnvironment(process.env);
2111
+ const refuse = (why) => oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot create tmux session ${session} on the OATS tmux server from inside an OATS instance: ${why}. A tmux server and its sessions keep the environment of the process that creates them, and an instance's own environment must not become theirs; the session was not created. Create it from your own shell, outside every instance home (tmux -L ${OATS_TMUX_SERVER} new-session -d -s ${shellWord(session)} -n hq), or run this command there. tmux resolves -L ${OATS_TMUX_SERVER} with TMUX_TMPDIR: use the same TMUX_TMPDIR as this process, if it has one`);
2112
+ if (!caller.home) throw refuse(`this process carries an instance's identity (${caller.evidence}) and neither OATS_INSTANCE_HOME nor the working directory names its home`);
2113
+ let target;
2114
+ try { target = instanceSessionTarget(caller.home).target; }
2115
+ catch (e) { if (!oatsCoded(e)) throw e; throw refuse(`the session receipt of ${basename(caller.home)} could not be used (${e.code})`); }
2116
+ if (!target?.socket) throw refuse(`the home of ${basename(caller.home)} records no tmux server`);
2117
+ // Bytes, not text; and nothing of a failed read (tmux's output, its error) goes into the refusal.
2118
+ let recorded = null;
2119
+ const omitted = [];
1903
2120
  try {
1904
- socket = execFileSync("tmux", ["display-message", "-p", "-t", session, "#{socket_path}"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
1905
- } catch (e) {
1906
- throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `could not identify the tmux endpoint for session ${session}: ${String(e.stderr ?? e.message ?? "").trim() || "tmux display-message failed"}`);
2121
+ const printed = (io?.exec || execFileSync)("tmux", ["-u", "-S", target.socket, "show-environment", "-g", "-s"], { timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
2122
+ recorded = parseTmuxShellEnvironment(Buffer.isBuffer(printed) ? printed : Buffer.from(String(printed)), omitted);
2123
+ } catch (e) { if (oatsCoded(e)) throw e; }
2124
+ if (recorded === null) throw refuse(`the environment of the tmux server that ${basename(caller.home)} is recorded on (${target.socket}) could not be read`);
2125
+ // A variable the reader left out is never filled in from this process, and a server is never
2126
+ // started when the one left out decides which configuration it loads or which programs it runs.
2127
+ // (A recorded server that simply has none of them is copied as it is.)
2128
+ const undecided = TMUX_SERVER_START_ENV.find((name) => omitted.includes(name));
2129
+ if (undecided) throw refuse(`the tmux server that ${basename(caller.home)} is recorded on has a ${undecided} that cannot be carried over (its value holds a $, a line break or a character tmux prints encoded), and a tmux server's configuration and programs are chosen by it`);
2130
+ const env = withoutKernelEnvironment(recorded);
2131
+ if (process.env.TMUX_TMPDIR === undefined) delete env.TMUX_TMPDIR; else env.TMUX_TMPDIR = process.env.TMUX_TMPDIR;
2132
+ return env;
2133
+ }
2134
+ /** The environment a `new-window` client runs with, and the `respawn-pane` client of a restart in
2135
+ * place. tmux gives a pane the PATH of the client that creates its window or respawns it, and
2136
+ * nothing else of that client (spawn.c). A process inside an instance
2137
+ * passes PATH, without any home's kernel shim directory (the launch command puts the new home's
2138
+ * own first), and the three names tmux's client reads its locale from: it does not start without
2139
+ * a UTF-8 locale, and on a host that has neither en_US.UTF-8 nor C.UTF-8 only these name one.
2140
+ * Nothing else of the instance travels by any route. When it has no PATH to give (none is set, or
2141
+ * only shim directories were in it) no PATH is passed: an empty one would be one empty entry, the
2142
+ * working directory, for the lookup of `tmux` and for the pane. A PATH that is set and empty is
2143
+ * passed as it is. Any other creator passes its environment without the kernel's names. */
2144
+ const TMUX_CLIENT_LOCALE_ENV = ["LANG", "LC_ALL", "LC_CTYPE"];
2145
+ function oatsWindowEnvironment() {
2146
+ if (!callerInstance()) return withoutKernelEnvironment(process.env);
2147
+ const env = {};
2148
+ const dirs = process.env.PATH === undefined ? [] : process.env.PATH.split(":").filter((dir) => !/(?:^|\/)instances\/[^/]+\/\.oats\/bin\/?$/.test(dir));
2149
+ if (dirs.length) env.PATH = dirs.join(":");
2150
+ for (const name of TMUX_CLIENT_LOCALE_ENV) if (process.env[name] !== undefined) env[name] = process.env[name];
2151
+ return env;
2152
+ }
2153
+ /**
2154
+ * An optimisation for a spawn or a start, to call BEFORE it creates, stops or writes anything: when
2155
+ * this process is inside an instance and `session` is not on the OATS server, the environment its
2156
+ * creation needs is read now, so that a caller that cannot create it is refused with nothing left
2157
+ * behind; the answer goes to ensureOatsTmuxSession. undefined when there is nothing to read (the
2158
+ * session exists, or the caller is no instance: no tmux is run for it). Not the guarantee:
2159
+ * ensureOatsTmuxSession decides by what it finds when it creates.
2160
+ */
2161
+ function planOatsTmuxSession(session, io) {
2162
+ if (!callerInstance() || oatsTmuxSessionSocket(session, io).present) return undefined;
2163
+ return { env: oatsSessionEnvironment(session, io) };
2164
+ }
2165
+ /**
2166
+ * Make sure `session` exists on the OATS server and return that server's absolute socket: the one
2167
+ * endpoint the caller then creates its window on, records and compensates on. `hq` is the directory
2168
+ * of the session's first window; `io.exec` replaces execFileSync (session start guards its roots there).
2169
+ * This `new-session`, the one that creates an agents' session, runs with oatsSessionEnvironment
2170
+ * (`plan`, when planOatsTmuxSession read it earlier), by the creator's own tmux, on the socket the
2171
+ * lookup named when the server runs. (The viewer's temporary session, lib/session-viewer.mjs, is
2172
+ * created with the attaching process's environment: awebai/oats#623.)
2173
+ * Two creators racing both succeed on one socket: the loser's `duplicate session` is answered by a
2174
+ * second lookup. tmux starts a server once: the first successful creator determines its initial
2175
+ * environment, and nothing here changes a running server's. E_RUNTIME_ENDPOINT_UNKNOWN when the
2176
+ * server cannot be read or names no socket, when this caller may not create the session, or when
2177
+ * its own PATH names no tmux to create it with (creatorTmux).
2178
+ */
2179
+ export function ensureOatsTmuxSession(session, hq, io, plan) {
2180
+ let { server: socket, present } = oatsTmuxSessionSocket(session, io);
2181
+ if (!present) {
2182
+ // The executable is chosen here, where the creation is about to run, and nowhere earlier: a
2183
+ // session that exists needs none, so nothing is refused for it.
2184
+ const tmux = creatorTmux(session);
2185
+ const env = plan?.env ?? oatsSessionEnvironment(session, io);
2186
+ const address = socket ? ["-S", socket] : ["-L", OATS_TMUX_SERVER];
2187
+ let created;
2188
+ try { created = (io?.exec || execFileSync)(tmux, ["-u", ...address, "new-session", "-d", "-s", session, "-n", "hq", "-c", hq, "-P", "-F", "#{socket_path}\t#{window_id}"], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], env }).trim(); }
2189
+ catch (e) {
2190
+ if (oatsCoded(e)) throw e;
2191
+ const again = oatsTmuxSessionSocket(session, io);
2192
+ if (!again.present) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `could not create tmux session ${session} on the OATS tmux server: ${tmuxFailure(e, "tmux new-session failed")}`);
2193
+ socket = again.server;
2194
+ }
2195
+ if (created !== undefined) {
2196
+ const [path, hqWindow] = created.split("\t");
2197
+ if (!path) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `tmux returned no endpoint for session ${session}`);
2198
+ socket = path;
2199
+ // The session's first window, which this call created: sized like every window OATS creates.
2200
+ // A dead server shows in the caller's next step on this socket.
2201
+ sizeWindow(socket, hqWindow, io);
2202
+ }
1907
2203
  }
1908
2204
  if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `tmux returned no endpoint for session ${session}`);
1909
2205
  return resolve(socket);
1910
2206
  }
1911
- export function tmuxWindows(session = DEFAULT_TMUX_SESSION) {
1912
- if (!tmuxAlive(session)) return [];
1913
- return (shTry(`tmux list-windows -t ${shq(session)} -F '#{window_name}'`) || "").split("\n").filter(Boolean);
1914
- }
2207
+ /** The window names of `session` on the OATS server, creating neither a server nor a session: none is
2208
+ * an empty list. An early read only (an explicit name's refusal); the collision check that counts
2209
+ * lists the socket ensureOatsTmuxSession returned. */
2210
+ function oatsTmuxWindows(session) {
2211
+ try { return tmuxOatsServer(["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean); }
2212
+ catch { return []; }
2213
+ }
2214
+ /** The harness's window command: no COLORFGBG in the pane, whatever the server's global environment
2215
+ * holds. tmux runs the command with the server's `default-shell -c` (the user's shell: sh, bash and
2216
+ * zsh all take `unset NAME;`), and the fallback shell it execs inherits the result. */
2217
+ const paneCommand = (command) => `unset COLORFGBG; ${command}; exec "\${SHELL:-/bin/zsh}"`;
2218
+ /** The sizing options on one window, each best effort: this command's failure is ignored, nothing is
2219
+ * retried elsewhere and nothing global is written. */
2220
+ function sizeWindow(socket, windowId, io) {
2221
+ if (!/^@\d+$/.test(windowId || "")) return;
2222
+ for (const [option, value] of OATS_WINDOW_SIZING) {
2223
+ try { tmuxOn(socket, ["set-option", "-w", "-t", windowId, option, value], io); } catch (e) { if (oatsCoded(e)) throw e; }
2224
+ }
2225
+ }
2226
+ /** The agent window OATS just created, by the id new-window returned: showing the viewer's colours and
2227
+ * sized for its viewers. Never a global option, another window or another server. The colour
2228
+ * commands come first and are strict, so a lost server, a permission or a transport failure surfaces
2229
+ * there and the tolerant sizing after them cannot mask it. The palette (`pane-colours`) is left
2230
+ * alone: a local reset did not neutralise inherited entries. */
2231
+ function prepareAgentWindow(socket, windowId, io) {
2232
+ if (!/^@\d+$/.test(windowId || "")) throw new Error("tmux new-window returned no window id");
2233
+ for (const [option, value] of OATS_WINDOW_COLOURS) tmuxOn(socket, ["set-option", "-w", "-t", windowId, option, value], io);
2234
+ // cursor-colour exists from tmux 3.3: -q makes an unknown option exit 0. -q also hides a missing
2235
+ // target, so only this command has it, and only after the two above succeeded on the same window.
2236
+ tmuxOn(socket, ["set-option", "-q", "-w", "-t", windowId, "cursor-colour", "default"], io);
2237
+ sizeWindow(socket, windowId, io);
2238
+ }
2239
+ /** One line for a start that opened its window on another server than the home recorded. */
2240
+ const sessionMovedWarning = (instance, from, to) => `${instance} was recorded on the tmux server ${JSON.stringify(from)}; its window had to be created again, and new windows open on the OATS tmux server: it now runs on ${JSON.stringify(to)}, and that socket is recorded`;
1915
2241
 
1916
2242
  /**
1917
2243
  * Spawn an instance of `agent` (as returned by findAgent/listAgents).
@@ -2297,13 +2623,12 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
2297
2623
  * name: the command says NAME="$OATS_LAUNCH_REF_NAME", so no source
2298
2624
  * variable is ever named in the command and no assignment in the same
2299
2625
  * prefix can shadow it (zsh evaluates a prefix's assignments in order).
2300
- * Rendered as tmux `-e` flags. */
2626
+ * Each goes to the pane as a tmux `-e NAME=value` argument. */
2301
2627
  export function launchEnvRefs(recipe, env = process.env) {
2302
2628
  const out = [];
2303
2629
  for (const [name, v] of Object.entries(recipe.env || {})) if (v && typeof v === "object" && v.fromEnv && env[v.fromEnv] !== undefined) out.push({ name: `${LAUNCH_REF_PREFIX}${name}`, value: env[v.fromEnv], target: name, source: v.fromEnv });
2304
2630
  return out;
2305
2631
  }
2306
- export function launchEnvTmuxFlags(recipe, env) { return launchEnvRefs(recipe, env).map((r) => ` -e ${shq(`${r.name}=${r.value}`)}`).join(""); }
2307
2632
 
2308
2633
  /** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
2309
2634
  export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
@@ -2769,7 +3094,8 @@ function* spawnBody(root, agent, o = {}) {
2769
3094
 
2770
3095
  // An explicit name may not be a soul name (soul and instance references stay
2771
3096
  // unambiguous) and is taken when any soul of this deployment holds it, or — launched
2772
- // or not — a live tmux window of the session carries it:
3097
+ // or not — a live window of the session on the OATS tmux server carries it (an early read that
3098
+ // creates no server; the launch below checks again on the socket it resolved):
2773
3099
  // a typed refusal, never a silent `-2` (the operator typed it). Checked after
2774
3100
  // key recovery, so a retried keyed spawn still reaches its own receipt; the
2775
3101
  // placement below re-checks after its reservation (a concurrent spawn of
@@ -2778,8 +3104,12 @@ function* spawnBody(root, agent, o = {}) {
2778
3104
  if (deploymentSoulNames(root, o.prepared).has(instance)) throw oatsError("E_INSTANCE_NAME_INVALID", `instance name "${instance}" is a soul name in this deployment; an instance may not share a soul's name`);
2779
3105
  const holder = deploymentInstanceHomes(root).get(instance)?.[0];
2780
3106
  if (holder) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken in this deployment (${holder}); instance names are unique across every soul — pick another --name`), { instance, home: holder });
2781
- if (tmuxWindows(session).includes(instance)) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken: a live tmux window of that name exists in session ${session} — pick another --name`), { instance, session });
3107
+ if (oatsTmuxWindows(session).includes(instance)) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken: a live tmux window of that name exists in session ${session} — pick another --name`), { instance, session });
2782
3108
  }
3109
+ // A caller inside an instance that will have to create the tmux session reads the environment
3110
+ // for it here, before any scaffold, work tree, identity or hook, so that one that cannot is
3111
+ // refused with nothing left behind. A caller outside every instance runs no tmux for it.
3112
+ const tmuxSessionPlan = launch && o.preview !== true ? planOatsTmuxSession(session) : undefined;
2783
3113
 
2784
3114
  // Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
2785
3115
  // anchor the new instance to an EXISTING instance (o.relativeTo). o.parent
@@ -3524,9 +3854,11 @@ function* spawnBody(root, agent, o = {}) {
3524
3854
  const outstandingGit = new Set();
3525
3855
  // A failed new-window command may still have created its window. Verify
3526
3856
  // quiescence before removing credentials or work that harness may be using.
3857
+ // On the socket recorded for this spawn: windowMayExist is only set once it is.
3527
3858
  if (windowMayExist) {
3528
- shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
3529
- const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
3859
+ const socket = spawnTmux.socket;
3860
+ try { tmuxOn(socket, ["kill-window", "-t", `=${session}:=${instance}`]); } catch { /* verify the effect below */ }
3861
+ const winProbe = probe(["tmux", "-u", "-S", socket, "list-windows", "-t", `=${session}`, "-F", "#{window_name}"]);
3530
3862
  const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
3531
3863
  if (unresolved) {
3532
3864
  incomplete.push(!winProbe.ok
@@ -3803,14 +4135,14 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3803
4135
  if (work === "directory") assertDirectoryRoots(home, homeReal);
3804
4136
  const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
3805
4137
  if (launch) {
3806
- if (!tmuxAlive(session)) {
3807
- const hq = existsSync(root) ? root : workspaceOf(root);
3808
- sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
3809
- shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
3810
- shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
3811
- }
3812
- if (tmuxWindows(session).includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
3813
- meta.tmux.socket = tmuxSocket(session);
4138
+ // The session on the OATS server, then everything on the one socket that answered: the collision
4139
+ // check, the records, the window and (in compensateSpawn) its removal.
4140
+ const socket = ensureOatsTmuxSession(session, existsSync(root) ? root : workspaceOf(root), undefined, tmuxSessionPlan);
4141
+ let present;
4142
+ try { present = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean); }
4143
+ catch (e) { throw new Error(`could not list the windows of tmux session ${session} on ${socket}: ${tmuxFailure(e, "tmux list-windows failed")}`); }
4144
+ if (present.includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
4145
+ meta.tmux.socket = socket;
3814
4146
  meta.launched = true;
3815
4147
  // Commit the final child metadata and its independent byte authority before
3816
4148
  // the managed harness can write. No child-home transition follows launch.
@@ -3818,10 +4150,15 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3818
4150
  writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
3819
4151
  // Wrap the command so the window drops into an interactive shell when the
3820
4152
  // agent exits (e.g. Ctrl-C) instead of tmux killing the window.
3821
- const windowCmd = `${executionCommand}; exec "\${SHELL:-/bin/zsh}"`;
4153
+ const windowCmd = paneCommand(executionCommand);
4154
+ const paneEnvFlags = launchEnvRefs(recipe, process.env).flatMap((r) => ["-e", `${r.name}=${r.value}`]);
4155
+ const launchFailed = (step, refused, e) => oatsError("E_SPAWN_LAUNCH_FAILED", `tmux ${step} failed for ${instance} (${e.code === "ENOENT" ? "tmux unavailable" : refused}); the command line and tmux's output are withheld from this message because they can carry reference values; run tmux -S ${shellWord(socket)} list-windows -t ${shellWord(session)} to inspect`);
3822
4156
  windowMayExist = true;
3823
- try { sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)}${launchEnvTmuxFlags(recipe, process.env)} ${shq(windowCmd)}`); }
3824
- catch (e) { throw oatsError("E_SPAWN_LAUNCH_FAILED", `tmux new-window failed for ${instance} (${e.code === "ENOENT" ? "tmux unavailable" : "the window command was refused"}); the command line and tmux's output are withheld from this message because they can carry reference values; run tmux list-windows on the session to inspect`); }
4157
+ let windowId;
4158
+ try { windowId = tmuxOn(socket, ["new-window", "-P", "-F", "#{window_id}", "-t", `=${session}:`, "-n", instance, "-c", home, ...paneEnvFlags, windowCmd], undefined, oatsWindowEnvironment()).trim(); }
4159
+ catch (e) { throw launchFailed("new-window", "the window command was refused", e); }
4160
+ try { prepareAgentWindow(socket, windowId); }
4161
+ catch (e) { throw launchFailed("set-option", "the new window's options were refused", e); }
3825
4162
  } else {
3826
4163
  meta.launched = false;
3827
4164
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
@@ -3864,7 +4201,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3864
4201
  meta.spawnCompleted = true;
3865
4202
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
3866
4203
  }
3867
- return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
4204
+ return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: launch ? `tmux -S ${shellWord(meta.tmux.socket)} attach -t ${shellWord(session)}` : `oats session attach --home ${shellWord(home)}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
3868
4205
  } catch (error) {
3869
4206
  try {
3870
4207
  const compensation = compensateSpawn();
@@ -4393,15 +4730,6 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
4393
4730
  return `sha256:${hash.digest("hex")}`;
4394
4731
  }
4395
4732
 
4396
- /** The ref a worktree actually has checked out: `{branch, oid}` with
4397
- * `branch === null` when HEAD is detached. Recovery derives truth from the
4398
- * object, never from spawn-time metadata. */
4399
- function worktreeRef(work) {
4400
- const oid = execFileSync("git", ["-C", work, "rev-parse", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
4401
- let branch = null;
4402
- try { branch = execFileSync("git", ["-C", work, "symbolic-ref", "--quiet", "--short", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim() || null; } catch { branch = null; }
4403
- return { branch, oid };
4404
- }
4405
4733
  /** A worktree directory whose git admin entry is gone: no `.git`, an unreadable one, or a gitfile naming an
4406
4734
  * admin directory that no longer exists. Read from the gitfile, not asked of git, which would walk up into
4407
4735
  * whatever repository encloses the home. A `.git` directory is a repository of its own, not this case. */
@@ -4540,25 +4868,16 @@ function nestedGitRoots(root) {
4540
4868
  return out;
4541
4869
  }
4542
4870
 
4543
- function branchOnlyCommits(repo, branch) {
4544
- if (!repo || !branch) return [];
4545
- const target = `refs/heads/${branch}`;
4546
- try {
4547
- execFileSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", target], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
4548
- } catch (e) {
4549
- const detail = String(e.stderr ?? "").trim();
4550
- // A quarantine retry may follow a successful rollback-owned branch removal.
4551
- // Git's quiet exit 1 is authoritative absence, not an inspection failure.
4552
- if (e.status === 1 && !detail) return null;
4553
- throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${detail || String(e.message ?? "").trim() || "git ref probe failed"}`);
4554
- }
4555
- try {
4556
- const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER })
4557
- .split("\n").filter((ref) => ref && ref !== target);
4558
- return execFileSync("git", ["-C", repo, "rev-list", target, ...(refs.length ? ["--not", ...refs] : [])], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim().split("\n").filter(Boolean);
4559
- } catch (e) {
4560
- throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${String(e.stderr ?? e.message ?? "").trim() || "git ref probe failed"}`);
4561
- }
4871
+ /** Whether the recovery can copy the branch recorded at spawn: only when Git
4872
+ * shows it (exit 0). Asked by a quarantine retry for a worktree that is gone:
4873
+ * a failed spawn's rollback may have deleted the branch. A branch Git cannot
4874
+ * show (absent, or a damaged ref) is not copied; no retire deletes it, so it
4875
+ * stays in the repository, and the quarantine's own check of the branch says
4876
+ * whether it could be shown gone. */
4877
+ function recordedBranchExists(repo, branch) {
4878
+ if (!repo || !branch) return true;
4879
+ const r = spawnSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
4880
+ return !r.error && r.status === 0;
4562
4881
  }
4563
4882
 
4564
4883
  /** Herdr (removed in 0.31.0) is recognised in a home only to refuse: its instance.json records a
@@ -4631,16 +4950,28 @@ function tmuxServerLost(e) { return /no server running on |(?:error connecting t
4631
4950
  * The live processes (other than this one and its children) whose working
4632
4951
  * directory is inside `home`: `lsof -a -d cwd` over the host, one call.
4633
4952
  * → { ok: true, processes: [{pid, command}] } | { ok: false, error } (the scan
4634
- * could not run: a caller must treat that as unknown, never as none).
4953
+ * could not run, did not complete or listed no process: a caller must treat
4954
+ * that as unknown, never as none).
4635
4955
  */
4636
4956
  export function processesInHome(home, io) {
4637
4957
  let out;
4638
4958
  try { out = (io?.exec || execFileSync)("lsof", ["-a", "-d", "cwd", "-F", "pRcn", "-w"], { encoding: "utf8", timeout: 20000, maxBuffer: 16 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }); }
4639
4959
  catch (e) {
4640
- // lsof exits 1 with a full listing when some process could not be read; only no output is a failed scan.
4641
- if (typeof e?.stdout === "string" && /^p\d+$/m.test(e.stdout)) out = e.stdout;
4642
- else return { ok: false, error: e?.code === "ENOENT" ? "lsof is not on PATH" : String(e?.stderr || e?.message || "lsof failed").trim().split("\n")[0] };
4643
- }
4960
+ // lsof exits 1 with a full listing when some process could not be read. That is the one failure
4961
+ // whose output is a scan: lsof ended by itself with status 1 (no signal, and no error code, which
4962
+ // Node sets when the capture itself failed: a timeout, too much output). A scan that was cut off
4963
+ // is a failed scan whatever it printed first. The error says what happened, never a line of the
4964
+ // listing.
4965
+ const completed = e?.status === 1 && e.signal == null && e.code === undefined;
4966
+ if (completed && typeof e.stdout === "string") out = e.stdout;
4967
+ else return { ok: false, error: e?.code === "ENOENT" ? "lsof is not on PATH"
4968
+ : e?.code === "ETIMEDOUT" ? "lsof timed out"
4969
+ : e?.code === "ENOBUFS" ? "lsof printed more output than the scan reads"
4970
+ : e?.signal ? `lsof was ended by ${e.signal}`
4971
+ : String(e?.stderr || e?.message || "lsof failed").trim().split("\n")[0] };
4972
+ }
4973
+ // A listing holds at least one process (lsof and its caller are always in it), whichever exit it came with.
4974
+ if (!/^p\d+$/m.test(String(out))) return { ok: false, error: "lsof listed no process" };
4644
4975
  const realHome = realPathOrNearest(home);
4645
4976
  const processes = [];
4646
4977
  let cur = {};
@@ -4832,8 +5163,8 @@ export function withLaunchModel(command, model) {
4832
5163
  return renderLaunchCommand(tokens);
4833
5164
  }
4834
5165
 
4835
- function tmuxOn(socket, args, io) {
4836
- return (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
5166
+ function tmuxOn(socket, args, io, env) {
5167
+ return (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}) });
4837
5168
  }
4838
5169
 
4839
5170
  function writeJsonAtomic(path, value, mode) {
@@ -5138,6 +5469,9 @@ export function startInstanceSession(home, o = {}) {
5138
5469
  // A self-retirement leaves this marker while its detached teardown runs:
5139
5470
  // the home is about to disappear, so nothing is relaunched into it.
5140
5471
  if (existsSync(retirePendingMarkerPath(realHome))) throw oatsError("E_INSTANCE_RETIRING", `${basename(realHome)} is being retired (${retirePendingMarkerPath(realHome)} is present); nothing was started`);
5472
+ // A start that leaves the server the home recorded says so: one line per move (see the docs'
5473
+ // "Existing instances"), in the answer and as a launch-warning event.
5474
+ const movedWarnings = [];
5141
5475
  // 1. Reconcile a pending receipt before the equality gate: it may be the
5142
5476
  // only record of a session an earlier start allocated.
5143
5477
  if (existsSync(pendingPath)) {
@@ -5177,6 +5511,14 @@ export function startInstanceSession(home, o = {}) {
5177
5511
  // in whichever log the interrupted start did not record it.
5178
5512
  recordStartBoundary(realHome, { startId: pending.id, startedAt: pending.startedAt, harness: pending.harness ?? meta.harness ?? null, backend: "tmux", launchConfig: pending.launch?.launchConfig ?? meta.launch?.launchConfig ?? null, phase: "recovered" });
5179
5513
  const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
5514
+ // The adopted target is on another server than the home recorded (the start that allocated it
5515
+ // could not record it): this adoption records it, and says so once, here.
5516
+ const recordedSocket = typeof meta.tmux?.socket === "string" && meta.tmux.socket ? resolve(meta.tmux.socket) : null;
5517
+ if (recordedSocket && recordedSocket !== resolve(pending.target.socket)) {
5518
+ const message = sessionMovedWarning(meta.instance, recordedSocket, resolve(pending.target.socket));
5519
+ appendEvent(realHome, { kind: "launch-warning", data: { message } });
5520
+ movedWarnings.push(message);
5521
+ }
5180
5522
  if (st.present && st.state !== "shell") {
5181
5523
  if (o.restart) { rmSync(pendingPath, { force: true }); }
5182
5524
  else {
@@ -5185,7 +5527,7 @@ export function startInstanceSession(home, o = {}) {
5185
5527
  if (o.model != null && String(o.model).trim() && resolveModelPreference(String(o.model), done.harness || meta.harness) !== done.model) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running with its previously requested model; its target was recovered, but the new model was not applied`);
5186
5528
  if (o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running (its pending start was recovered); the requested launch configuration, harness or yolo was not applied; stop it, or use session restart`);
5187
5529
  if (meta.startId === pending.id) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is already running; nothing was started`);
5188
- return done;
5530
+ return { ...done, warnings: [...done.warnings, ...movedWarnings] };
5189
5531
  }
5190
5532
  }
5191
5533
  }
@@ -5246,15 +5588,26 @@ export function startInstanceSession(home, o = {}) {
5246
5588
  checkRoots(); // preparation has run; no backend has been observed
5247
5589
  let target = receipt.target;
5248
5590
  let state = { present: false, state: "not-launched" };
5249
- let serverGone = false;
5250
5591
  if (target) {
5251
5592
  try { state = inspectSessionTarget(target, o.io); }
5252
5593
  catch (e) {
5253
- if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
5594
+ if (lostTmuxServer(e)) state = { present: false, state: "stopped" };
5254
5595
  else throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`);
5255
5596
  }
5256
5597
  }
5257
5598
  if (state.present && state.state !== "shell" && !o.restart) throw oatsError("E_SESSION_RUNNING", `${meta.instance} is running (${state.state}); nothing was started`);
5599
+ // A caller inside an instance that will have to create the tmux session reads the environment
5600
+ // for it here (anyone else runs no tmux here): after the start's preflights and its planning,
5601
+ // so each of their refusals still answers first, and before the real run of preview-aware
5602
+ // launch hooks, a stop and any write of the home's launch state (record, receipt, pending
5603
+ // start). A launch hook that does not declare launchPreview has already run and its warnings
5604
+ // are already events (above), as before any other late refusal of a start (E_SESSION_RUNNING,
5605
+ // E_LAUNCH_ENV_MISSING); nothing undoes what it did, and the hook contract (above
5606
+ // prepareLaunchHooks) allows such a hook idempotent provider registration, which the next
5607
+ // start repeats. Only when this start will have to create a window: a retained pane is reused
5608
+ // where it is. A restart whose window goes away during its stop is decided at the creation.
5609
+ const tmuxSessionPlan = callerInstance() && !(state.paneId && (state.present || state.state === "stopped"))
5610
+ ? planOatsTmuxSession(target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION, o.io) : undefined;
5258
5611
  // Every preflight has passed: the preview-aware launch hooks run for real
5259
5612
  // (they may register the home with their provider), before a restart's
5260
5613
  // stop, and must contribute exactly what they contributed as a preview,
@@ -5293,7 +5646,7 @@ export function startInstanceSession(home, o = {}) {
5293
5646
  writeJsonAtomic(join(realHome, ".oats-restart.json"), { instance: meta.instance, at: new Date().toISOString(), stop: stopReceipt, next: { harness: launchPlan?.harness || harness, launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, model: model ?? null } }, 0o600);
5294
5647
  appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
5295
5648
  if (!stopReceipt.exited) throw oatsError("E_SESSION_STOP_FAILED", `${meta.instance} was asked to stop (${stopReceipt.signal} to ${stopReceipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")} at ${stopReceipt.sentAt}) and was still running after ${stopReceipt.waitedMs} ms (${stopReceipt.state}); nothing was escalated and nothing was started; stop it yourself, or retry with a longer --stop-grace. Receipt: ${join(realHome, ".oats-restart.json")}`);
5296
- try { state = inspectSessionTarget(target, o.io); } catch (e) { if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
5649
+ try { state = inspectSessionTarget(target, o.io); } catch (e) { if (lostTmuxServer(e)) state = { present: false, state: "stopped" }; else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
5297
5650
  if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
5298
5651
  }
5299
5652
  // The kernel that launches the harness is the one the agent's plain `oats` runs.
@@ -5308,52 +5661,39 @@ export function startInstanceSession(home, o = {}) {
5308
5661
  const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
5309
5662
  const window = target?.window || meta.tmux?.window || meta.instance;
5310
5663
  let socket = target?.socket || meta.tmux?.socket;
5311
- const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
5664
+ const windowCmd = paneCommand(completedCommand);
5665
+ let moved = null;
5312
5666
  // A fallback shell (no harness descendant) or a retained dead pane is
5313
- // the agent's own pane: the command runs there, no other window touched.
5667
+ // the agent's own pane: the command runs there, on the server the home
5668
+ // recorded, no other window touched and no option set.
5314
5669
  const inPlace = state.paneId && (state.present || state.state === "stopped");
5315
5670
  if (inPlace) {
5316
5671
  checkRoots();
5317
5672
  writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
5318
- try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
5673
+ try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io, oatsWindowEnvironment()); }
5319
5674
  catch (e) { throw launchFailure(e); }
5320
5675
  reused = "pane";
5321
5676
  } else {
5322
5677
  const instancesRoot = dirname(realHome);
5323
5678
  const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
5324
5679
  checkRoots();
5325
- if (!socket) {
5326
- // Never launched (--no-launch): the default server, as spawn uses.
5327
- const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
5328
- let alive = false;
5329
- try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
5330
- if (!alive) {
5331
- defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
5332
- for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
5333
- try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
5334
- }
5335
- }
5336
- socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
5337
- if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
5338
- } else if (serverGone) {
5339
- // The recorded server is gone (a reboot): the same socket path again.
5340
- mkdirSync(dirname(socket), { recursive: true });
5341
- tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
5342
- tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
5343
- tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
5344
- }
5345
- let names = [];
5680
+ // A replacement window (never launched, recorded window gone, recorded server gone) is created
5681
+ // on the OATS server, wherever the home was recorded: one socket from here to the record.
5682
+ const recordedSocket = socket ? resolve(socket) : null;
5683
+ socket = ensureOatsTmuxSession(session, hq, o.io, tmuxSessionPlan);
5684
+ let names;
5346
5685
  try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
5347
5686
  catch (e) {
5348
- if (!/can't find session/i.test(String(e.stderr ?? e.message ?? "")) && !lostTmuxServer(e)) throw oatsError("E_SESSION_UNKNOWN", `cannot list tmux windows on ${socket}: ${String(e.stderr ?? e.message ?? "").trim()}`);
5349
- tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
5687
+ if (oatsCoded(e)) throw e;
5688
+ throw oatsError("E_SESSION_UNKNOWN", `cannot list tmux windows on ${socket}: ${tmuxFailure(e, "tmux list-windows failed")}`);
5350
5689
  }
5351
5690
  if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
5352
- target = { backend: "tmux", session, window, socket: resolve(socket) };
5691
+ target = { backend: "tmux", session, window, socket };
5353
5692
  checkRoots();
5354
5693
  writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
5355
- try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
5694
+ try { prepareAgentWindow(socket, tmuxOn(socket, ["new-window", "-P", "-F", "#{window_id}", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io, oatsWindowEnvironment()).trim(), o.io); }
5356
5695
  catch (e) { throw launchFailure(e); }
5696
+ if (recordedSocket && recordedSocket !== socket) moved = sessionMovedWarning(meta.instance, recordedSocket, socket);
5357
5697
  }
5358
5698
  target = { backend: "tmux", session, window, socket: resolve(socket) };
5359
5699
  // The session exists: its boundary (lib/instance-events.mjs) is recorded
@@ -5363,11 +5703,15 @@ export function startInstanceSession(home, o = {}) {
5363
5703
  // Keep launch evidence until the command exits or the target disappears.
5364
5704
  // A transient child (for example the native-start recorder) is not proof that startup
5365
5705
  // has finished. A later start reconciles the receipt without a watcher.
5366
- try { return { ...record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
5706
+ let done;
5707
+ try { done = record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false); }
5367
5708
  catch (e) {
5368
5709
  if (e.code && String(e.code).startsWith("E_")) throw e;
5369
5710
  throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (tmux ${target.session}:${target.window} on ${target.socket}) but its metadata could not be recorded: ${e.message}; the actual target is kept in ${pendingPath} and the next start adopts it instead of allocating another`);
5370
5711
  }
5712
+ // Said once the new socket is recorded: a start that could not record leaves it to the adoption.
5713
+ if (moved) { appendEvent(realHome, { kind: "launch-warning", data: { message: moved } }); movedWarnings.push(moved); }
5714
+ return { ...done, warnings: [...warnings, ...movedWarnings] };
5371
5715
  } finally {
5372
5716
  // A hook may have replaced the home itself. Never follow that replacement
5373
5717
  // to remove a target's lock; keep the original retry state with its home.
@@ -5378,7 +5722,7 @@ export function startInstanceSession(home, o = {}) {
5378
5722
  }
5379
5723
  }
5380
5724
 
5381
- function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directory = false, orphanedWork = false } = {}) {
5725
+ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktreeRemoval, directory = false, orphanedWork = false } = {}) {
5382
5726
  if (directory) assertDirectoryRoots(home);
5383
5727
  const classes = [];
5384
5728
  let baseline;
@@ -5405,8 +5749,13 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
5405
5749
  // Never stamp hook-created or authored execution bytes as disposable.
5406
5750
  if (readdirSync(work).length) classes.push("directory work bytes");
5407
5751
  }
5408
- const branchCommits = !directory && branchDeletion?.delete ? branchOnlyCommits(branchDeletion.repo, branchDeletion.branch) : undefined;
5409
- if (branchCommits?.length) classes.push("branch-only local commits");
5752
+ // For a quarantine retry whose worktree is gone, whether the recorded branch
5753
+ // is still there to copy. Every other retire takes it to be there.
5754
+ const branchExists = !directory && recordedBranch && !existsSync(work) ? recordedBranchExists(recordedBranch.repo, recordedBranch.branch) : true;
5755
+ // A retire that will remove the worktree asks whether a ref that outlives it
5756
+ // reaches its commit. `head` is kept for the check before the removal.
5757
+ const unreached = worktreeRemoval?.removes && worktreeRemoval.repo && !directory && isWorktree && existsSync(work) ? worktreeCommitUnreached(worktreeRemoval.repo, work) : undefined;
5758
+ if (unreached?.unreached) classes.push("worktree commits no ref reaches");
5410
5759
  if (isWorktree && existsSync(work)) {
5411
5760
  const status = worktreeStatus(work);
5412
5761
  const rows = status.split("\0").filter(Boolean);
@@ -5415,11 +5764,16 @@ function inspectRetirementWork(home, work, isWorktree, { branchDeletion, directo
5415
5764
  if (!baseline || baseline.generatedWorkFingerprint !== generatedWorkFingerprint(work, status, disposableRoots)) classes.push("untracked or ignored worktree bytes");
5416
5765
  if (nestedGitRoots(work).length) classes.push("nested repository state");
5417
5766
  }
5767
+ // When the worktree will be removed, its HEAD and whether a ref reaches it are part of the state: a hook
5768
+ // that commits on a detached HEAD, or deletes the ref that reached it, changes neither the home nor the
5769
+ // status, and the commit must still be preserved again after it.
5418
5770
  const stateFingerprint = createHash("sha256")
5419
5771
  .update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
5420
5772
  .update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
5773
+ .update("\0").update(unreached ? `${unreached.head.commit}\0${unreached.unreached ? "unreached" : "reached"}\0` : "")
5774
+ .update(unreached?.head.ref ?? "")
5421
5775
  .digest("hex");
5422
- return { classes: [...new Set(classes)], home, work, directory, orphanedWork, directoryFingerprint, stateFingerprint, branchExists: branchCommits !== null, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
5776
+ return { classes: [...new Set(classes)], home, work, directory, orphanedWork, directoryFingerprint, stateFingerprint, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
5423
5777
  }
5424
5778
 
5425
5779
  function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
@@ -5619,6 +5973,7 @@ function preserveRetirementWork(observation, meta, instance) {
5619
5973
  }
5620
5974
  const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
5621
5975
  const recovery = join(recoveryRoot, basename(staging).slice(1));
5976
+ let headUnreadable;
5622
5977
  try {
5623
5978
  // A home-only change (notes, harness files, credentials) needs a home
5624
5979
  // snapshot, not another copy of an otherwise disposable clean worktree.
@@ -5643,14 +5998,18 @@ function preserveRetirementWork(observation, meta, instance) {
5643
5998
  // The branch is derived from the worktree while it exists: an instance
5644
5999
  // that legitimately switched branches during its task must still be
5645
6000
  // recoverable, and the recorded spawn-time branch is only the fallback
5646
- // when the worktree is gone. Detached HEADs recover at their exact OID.
5647
- const ref = existsSync(observation.work) ? worktreeRef(observation.work) : { branch: meta.branch, oid: null };
5648
- if (ref.branch !== meta.branch) branchDrift = { recordedBranch: meta.branch, worktreeBranch: ref.branch, detachedAt: ref.branch === null ? ref.oid : null };
6001
+ // when the worktree is gone. A HEAD that is detached, or on a ref OATS
6002
+ // carries no name for, recovers detached at its exact commit. A HEAD
6003
+ // that cannot be read is an inspection failure, not a failed copy.
6004
+ let ref;
6005
+ try { ref = existsSync(observation.work) ? worktreeHead(observation.work) : { branch: meta.branch, commit: null }; }
6006
+ catch (e) { headUnreadable = e; throw e; }
6007
+ if (ref.branch !== meta.branch) branchDrift = { recordedBranch: meta.branch, worktreeBranch: ref.branch, detachedAt: ref.branch === null ? ref.commit : null };
5649
6008
  if (ref.branch !== null) execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", ref.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
5650
6009
  else {
5651
6010
  execFileSync("git", ["clone", "--no-local", "--quiet", "--no-checkout", meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
5652
- execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.oid], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
5653
- execFileSync("git", ["-C", recoveredRepo, "checkout", "--quiet", "--detach", ref.oid], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
6011
+ execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
6012
+ execFileSync("git", ["-C", recoveredRepo, "checkout", "--quiet", "--detach", ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
5654
6013
  }
5655
6014
  const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
5656
6015
  detachRecoveryClone(sourceGitContext, recoveredRepo);
@@ -5672,10 +6031,15 @@ function preserveRetirementWork(observation, meta, instance) {
5672
6031
  if (fingerprintTree(observation.work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true }) !== fingerprintTree(recoveredRepo, { excludeRoot: new Set([".git"]), excludeGitMetadata: true })) throw new Error("worktree recovery verification disagreed with the source");
5673
6032
  assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
5674
6033
  }
6034
+ // The proof of the copy: its HEAD is the commit the worktree has checked
6035
+ // out. A branch of the repository can be at another commit with the
6036
+ // same files, so the branch's tip there proves nothing about the copy.
6037
+ // With the worktree gone there is no such commit to read, and the copy
6038
+ // is the recorded branch as the repository has it.
5675
6039
  const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
5676
- const sourceHead = ref.branch === null ? ref.oid
5677
- : execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${ref.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
5678
- if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
6040
+ const sourceHead = ref.commit
6041
+ ?? execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${ref.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6042
+ if (recoveredHead !== sourceHead) throw new Error(ref.commit ? "recovery clone is not at the commit the worktree has checked out" : "recovery clone does not retain the instance branch tip");
5679
6043
  }
5680
6044
  if (observation.directory && observation.directoryFingerprint) {
5681
6045
  const recoveredWork = join(staging, "work");
@@ -5695,6 +6059,7 @@ function preserveRetirementWork(observation, meta, instance) {
5695
6059
  return { path: recovery, classes: observation.classes, bytes, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}) };
5696
6060
  } catch (e) {
5697
6061
  rmSync(staging, { recursive: true, force: true });
6062
+ if (e === headUnreadable) throw e;
5698
6063
  const details = e.statusDisagreement ? { home: observation.home, statusDisagreement: e.statusDisagreement } : undefined;
5699
6064
  throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
5700
6065
  }
@@ -5744,7 +6109,7 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
5744
6109
  const intent = {
5745
6110
  instance: name, agent: found.agent.name, root: resolve(root),
5746
6111
  requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
5747
- options: { home: found.home, deleteBranch: !!o.deleteBranch, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
6112
+ options: { home: found.home, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
5748
6113
  };
5749
6114
  const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
5750
6115
  for (const k of CORE_LAUNCH_ENV) delete env[k];
@@ -5786,6 +6151,16 @@ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
5786
6151
  * (and, after hooks ran, the usual quarantine) in place, so `oats status`
5787
6152
  * shows the debt and `oats retire <name>` retries and clears it. Accepts the
5788
6153
  * intent object (the child gets it in its env) or a marker path. */
6154
+ /** The item a quarantine retry reports while the branch its failed spawn created is still there. It
6155
+ * names no branch: the name is the receipt's `retention.recordedBranch`, or the retained home's
6156
+ * instance.json `branch`. */
6157
+ export const FAILED_SPAWN_BRANCH_LEFT = "the branch the failed spawn created is left: OATS does not delete it. Inspect it and delete it with Git if it is not wanted, then retry";
6158
+ const OBSOLETE_DELETE_BRANCH_SENTENCE = "this self-retire was requested with --delete-branch by an older OATS; retirement no longer deletes branches, so the branch and the worktree were left";
6159
+ /** The one internal option that says the retirement completes such an intent.
6160
+ * A symbol, so no caller can set it from parsed arguments or JSON. */
6161
+ const OBSOLETE_DELETE_BRANCH = Symbol("an older self-retire intent with --delete-branch");
6162
+ export const RETIRE_DELETE_BRANCH_REFUSED = "oats retire no longer deletes branches: --delete-branch is not accepted. Retire without it; the branch is left in the repository. Inspect it there and delete it with Git if it is no longer wanted.";
6163
+
5789
6164
  export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
5790
6165
  let intent = intentOrMarkerPath;
5791
6166
  if (typeof intentOrMarkerPath === "string") {
@@ -5802,18 +6177,24 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
5802
6177
  }, null, 2) + "\n");
5803
6178
  } catch (e) { console.error(`deferred retirement: cannot write ${intent.resultPath}: ${e.message}`); }
5804
6179
  };
6180
+ // An intent an older OATS recorded for `--self --delete-branch`. The
6181
+ // retirement it owes is completed; the obsolete option authorizes nothing:
6182
+ // the branch and the worktree are left, and the retire says so.
6183
+ const obsoleteDeleteBranch = intent.options?.deleteBranch === true;
5805
6184
  const delayMs = Math.max(0, Number(opts.delaySec ?? intent.delaySec ?? 8) * 1000);
5806
6185
  if (delayMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
5807
6186
  let result;
5808
6187
  try {
5809
6188
  result = retireInstance(intent.root, intent.instance, {
5810
- home: intent.options?.home, deleteBranch: !!intent.options?.deleteBranch, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
6189
+ home: intent.options?.home, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
6190
+ ...(obsoleteDeleteBranch ? { [OBSOLETE_DELETE_BRANCH]: true } : {}),
5811
6191
  });
5812
6192
  } catch (e) {
5813
- record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
6193
+ record({ ok: false, error: { code: e.code, message: e.message }, ...(obsoleteDeleteBranch ? { warnings: [OBSOLETE_DELETE_BRANCH_SENTENCE] } : {}), retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
5814
6194
  console.error(`deferred retirement of ${intent.instance} failed: ${e.message}`);
5815
6195
  return false;
5816
6196
  }
6197
+ if (obsoleteDeleteBranch) console.log(OBSOLETE_DELETE_BRANCH_SENTENCE);
5817
6198
  if (result.rollbackIncomplete) {
5818
6199
  record({ ok: false, result, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
5819
6200
  console.error(`deferred retirement of ${intent.instance} is INCOMPLETE; the home is retained:\n ${result.rollbackIncomplete.join("\n ")}`);
@@ -5833,6 +6214,7 @@ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
5833
6214
  }
5834
6215
 
5835
6216
  export function retireInstance(root, name, o = {}) {
6217
+ if (o.deleteBranch) throw oatsError("E_BAD_ARGS", RETIRE_DELETE_BRANCH_REFUSED);
5836
6218
  const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
5837
6219
  // self-retire: the caller IS the instance. Without --keep-dir the whole
5838
6220
  // retirement is deferred to a detached external completion (below); with
@@ -5944,8 +6326,10 @@ export function retireInstance(root, name, o = {}) {
5944
6326
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
5945
6327
  // the managed harness; recovery copying never races a live managed Pi.
5946
6328
  const owesWorktree = !!quarantine && (quarantine.cleanup.outstanding?.git || []).includes("worktree");
5947
- const branchDeletion = { delete: !!(o.deleteBranch || quarantine), repo: meta.repo, branch: meta.branch };
5948
- const initialObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { branchDeletion, directory, orphanedWork });
6329
+ const recordedBranch = quarantine ? { repo: meta.repo, branch: meta.branch } : undefined;
6330
+ // Whether this retire removes the worktree, when it gets to that step.
6331
+ const worktreeRemoval = { removes: !!(o.discardWorktree || owesWorktree), repo: meta.repo };
6332
+ const initialObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
5949
6333
  // Harness identity is destructive authority. The mutable child metadata may
5950
6334
  // describe it for humans, but only the independent baseline can authorize the
5951
6335
  // endpoint that proves quiescence.
@@ -5988,10 +6372,20 @@ export function retireInstance(root, name, o = {}) {
5988
6372
  if (windows.includes(runtimeWindow)) throw new Error(`tmux window ${runtimeSession}:${runtimeWindow} is still running`);
5989
6373
  } catch (e) {
5990
6374
  const detail = String(e.stderr ?? e.message ?? "").trim();
5991
- if (!/no server running|failed to connect|can't find session|no sessions/i.test(detail)) throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that ${runtimeSession}:${runtimeWindow} stopped on ${runtimeSocket}: ${detail || "tmux inspection failed"}`);
6375
+ const unestablished = (why) => oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that ${runtimeSession}:${runtimeWindow} stopped on ${runtimeSocket}: ${why}`);
6376
+ if (!/no server running|failed to connect|can't find session|no sessions/i.test(detail)) {
6377
+ if (!tmuxServerLost(e)) throw unestablished(detail || "tmux inspection failed");
6378
+ // A server that cannot be reached, typically because its socket file is missing (a reboot clears
6379
+ // tmux's socket directory). A server outlives its socket file, so that alone does not say the
6380
+ // window is gone: it is taken as gone only when no process works in the home, as for a home
6381
+ // without its receipt.
6382
+ const scan = processesInHome(found.home);
6383
+ if (!scan.ok) throw unestablished(`${detail}; whether a process still works in this home could not be established (${scan.error})`);
6384
+ if (scan.processes.length) throw unestablished(`${detail}; a process still works in this home (${scan.processes.slice(0, 5).map((p) => `pid ${p.pid} ${p.command}`).join(", ")}${scan.processes.length > 5 ? `, and ${scan.processes.length - 5} more` : ""})`);
6385
+ }
5992
6386
  }
5993
6387
  }
5994
- const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { branchDeletion, directory, orphanedWork });
6388
+ const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
5995
6389
  const workRecoveries = [];
5996
6390
  if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
5997
6391
  let workRecovery = workRecoveries.at(-1);
@@ -6070,7 +6464,7 @@ export function retireInstance(root, name, o = {}) {
6070
6464
 
6071
6465
  // Hooks are allowed to mutate the inspected tree, so inspect again after
6072
6466
  // them and preserve a separately verified post-hook snapshot when needed.
6073
- const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { branchDeletion, directory, orphanedWork });
6467
+ const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
6074
6468
  if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
6075
6469
  workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
6076
6470
  workRecovery = workRecoveries.at(-1);
@@ -6132,32 +6526,39 @@ export function retireInstance(root, name, o = {}) {
6132
6526
  // branch and any PR outlive the home. The worktree cannot stay under
6133
6527
  // <home>/work once the home is removed, so it is RE-HOMED with
6134
6528
  // `git worktree move` to the deployment-level worktrees root and the move is
6135
- // recorded in the receipt. `discardWorktree` restores removal; branch
6136
- // deletion uses the VERIFIED current ref of the worktree, never the
6137
- // spawn-time recorded name. A failed move keeps the home (fail closed).
6529
+ // recorded in the receipt. `discardWorktree` restores removal. No retire
6530
+ // deletes a branch. A failed move keeps the home (fail closed).
6138
6531
  // The worktree step runs only once nothing else is outstanding (awebai/oats#444): a hook that did not finish
6139
6532
  // may need the worktree, and its retry needs the home, the worktree and its admin entry exactly as they
6140
6533
  // were. --force removes the home regardless, so the step runs first and no admin entry is left dangling.
6141
6534
  // A failed spawn's quarantine that owes the worktree removes it; any other retire retains it unless
6142
- // --discard-worktree or --delete-branch. An orphaned work directory is never touched.
6535
+ // --discard-worktree. An orphaned work directory is never touched.
6143
6536
  const outstandingBeforeWorktree = quarantine ? retryFailures : ordinaryIncomplete;
6144
6537
  const worktreeStep = isWorktree && !!meta.repo && !orphanedWork;
6145
6538
  const worktreeDeferred = worktreeStep && existsSync(workPath) && outstandingBeforeWorktree.length > 0 && !o.force;
6146
6539
  const keptForRetry = worktreeDeferred ? `git worktree ${workPath}: kept for the retry; outstanding: ${outstandingBeforeWorktree.join("; ")}` : null;
6147
6540
  let retention = null;
6148
6541
  if (worktreeStep && !worktreeDeferred) {
6149
- const ref = existsSync(workPath) ? (() => { try { return worktreeRef(workPath); } catch { return { branch: null, oid: null }; } })() : { branch: meta.branch ?? null, oid: null };
6542
+ // A HEAD that cannot be read here is recorded as no branch and no commit, and the worktree is retained.
6543
+ const ref = existsSync(workPath) ? (() => { try { return worktreeHead(workPath); } catch { return { branch: null, commit: null }; } })() : { branch: meta.branch ?? null, commit: null };
6150
6544
  const verifiedBranch = ref.branch;
6151
- // A branch cannot be deleted while a worktree has it checked out, so
6152
- // --delete-branch implies discarding the worktree (which is what every
6153
- // caller of it meant: clean up everything). Plain retire retains.
6154
- if (o.discardWorktree || o.deleteBranch || owesWorktree) {
6545
+ // --discard-worktree, or a quarantine that owes the worktree, removes it. Plain retire retains.
6546
+ if (worktreeRemoval.removes) {
6547
+ // HEAD as it is now against HEAD as the final inspection read it: a
6548
+ // worktree whose HEAD moved since is not removed, and nothing else is
6549
+ // done. Whatever moved it may have made a commit only it reaches.
6550
+ if (existsSync(workPath)) {
6551
+ let now;
6552
+ try { now = worktreeHead(workPath); }
6553
+ catch (e) { throw oatsError("E_WORK_INSPECTION_FAILED", `${e.message}. The worktree was not removed. The home and the worktree are kept, and so is any recovery the retire wrote; retry the retire.`); }
6554
+ assertSameWorktreeHead(finalObservation.head, now);
6555
+ }
6155
6556
  shTry(`git -C ${shq(meta.repo)} worktree remove --force ${shq(workPath)}`);
6156
6557
  shTry(`git -C ${shq(meta.repo)} worktree prune`);
6157
6558
  retention = { worktree: "removed", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
6158
6559
  } else if (existsSync(workPath)) {
6159
6560
  const repoName = basename(realPathOrNearest(meta.repo)).replace(/\.git$/, "") || "repo";
6160
- const leaf = (verifiedBranch ?? `detached-${(ref.oid || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
6561
+ const leaf = (verifiedBranch ?? `detached-${(ref.commit || "unknown").slice(0, 12)}`).replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "work";
6161
6562
  const retainedRoot = join(workspaceOf(root), ".agents", "worktrees", repoName);
6162
6563
  mkdirSync(retainedRoot, { recursive: true });
6163
6564
  let dest = join(retainedRoot, leaf);
@@ -6167,32 +6568,18 @@ export function retireInstance(root, name, o = {}) {
6167
6568
  } catch (e) {
6168
6569
  throw oatsError("E_WORK_PRESERVATION_FAILED", `${name}: the worktree at ${workPath} could not be re-homed to ${dest} (${String(e.stderr ?? e.message ?? "").trim()}); the home is kept so nothing is lost — resolve and retry, or pass --discard-worktree to remove the worktree instead`);
6169
6570
  }
6170
- retention = { worktree: "retained", movedTo: dest, branch: verifiedBranch, detachedAt: verifiedBranch === null ? ref.oid : null, recordedBranch: meta.branch ?? null };
6571
+ retention = { worktree: "retained", movedTo: dest, branch: verifiedBranch, detachedAt: verifiedBranch === null ? ref.commit : null, recordedBranch: meta.branch ?? null };
6171
6572
  } else retention = { worktree: "absent", branch: verifiedBranch, recordedBranch: meta.branch ?? null };
6172
- if (o.deleteBranch && verifiedBranch) {
6173
- // A plan-driven caller passes the branch it CONFIRMED. Hooks may mutate
6174
- // the tree during retirement, so the branch is re-verified here, at the
6175
- // moment of deletion; a mismatch deletes nothing and says so.
6176
- if (o.expectedBranch !== undefined && o.expectedBranch !== verifiedBranch) {
6177
- retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: verifiedBranch, reason: "the worktree's branch changed between confirmation and deletion; nothing was deleted" };
6178
- } else {
6179
- shTry(`git -C ${shq(meta.repo)} branch -D ${shq(verifiedBranch)}`);
6180
- retention.branchDeleted = verifiedBranch;
6181
- }
6182
- } else if (o.deleteBranch && !verifiedBranch && o.expectedBranch !== undefined) {
6183
- retention.branchDeletionSkipped = { expected: o.expectedBranch, actual: null, reason: "the worktree was detached or absent at deletion time; nothing was deleted" };
6184
- }
6185
6573
  }
6186
6574
  if (retention) {
6187
6575
  if (retention.worktree === "retained") appendEvent(found.home, { kind: "worktree-retained", data: { movedTo: retention.movedTo, branch: retention.branch, recordedBranch: retention.recordedBranch } }, { workspaceOnly: true });
6188
6576
  else if (retention.worktree === "removed") appendEvent(found.home, { kind: "worktree-removed", data: { branch: retention.branch } }, { workspaceOnly: true });
6189
- if (retention.branchDeleted) appendEvent(found.home, { kind: "branch-deleted", data: { branch: retention.branchDeleted } }, { workspaceOnly: true });
6190
6577
  }
6191
6578
  // `hooks`: which retire hooks ran (in order) and how each ended — the same
6192
6579
  // receipt `spawned` carries, so the workspace log shows both halves of a
6193
6580
  // capability's lifecycle after the home is gone.
6194
6581
  const retireHookReceipt = (() => { const res = hookResults || {}; const failedBy = new Map((res.failures || []).map((f) => [f.capability, f])); return (res.order || []).map((id) => ({ capability: id, ok: !failedBy.has(id), meta: Object.hasOwn(res.meta || {}, id) })); })();
6195
- appendEvent(found.home, { kind: "retired", data: { agent: found.agent.name, keepDir: !!o.keepDir, self, quarantine: !!quarantine, workRecovery: workRecovery?.path ?? null, hooks: retireHookReceipt } }, { workspaceOnly: true });
6582
+ appendEvent(found.home, { kind: "retired", data: { agent: found.agent.name, keepDir: !!o.keepDir, self, quarantine: !!quarantine, workRecovery: workRecovery?.path ?? null, hooks: retireHookReceipt, ...(o[OBSOLETE_DELETE_BRANCH] ? { reason: OBSOLETE_DELETE_BRANCH_SENTENCE } : {}) } }, { workspaceOnly: true });
6196
6583
  // Retrying a quarantine only clears it if compensation ACTUALLY completed.
6197
6584
  // Otherwise the home — and the credentials in it — must survive again, or the
6198
6585
  // retry becomes the deletion the quarantine was preventing.
@@ -6224,18 +6611,18 @@ export function retireInstance(root, name, o = {}) {
6224
6611
  const failures = [...retryFailures];
6225
6612
  if (keptForRetry) failures.push(keptForRetry);
6226
6613
  // The quarantine may exist BECAUSE Git cleanup failed, so a retry has to
6227
- // redo those steps and verify them — not just rerun hooks. A branch is never
6228
- // deleted here: only --delete-branch deletes one (the verified branch, above).
6614
+ // redo those steps and verify them — not just rerun hooks. No retire deletes a branch.
6229
6615
  if (meta.work === "worktree" && meta.repo) {
6230
6616
  const gitProbe = (argv) => {
6231
6617
  try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
6232
6618
  catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
6233
6619
  };
6234
6620
  // A worktree this retry removed (the step above ran) must be verified gone; one it retained or kept for
6235
- // the next retry stays registered by design.
6621
+ // the next retry stays registered by design. The removal is not attempted a second time here: only the
6622
+ // worktree step removes, after its check of HEAD, so a worktree it failed to remove is still registered
6623
+ // and the next retry goes through that step again.
6236
6624
  if (retention?.worktree === "removed") {
6237
6625
  const wtCanonical = realPathOrNearest(workPath);
6238
- gitProbe(["git", "-C", meta.repo, "worktree", "remove", "--force", workPath]);
6239
6626
  gitProbe(["git", "-C", meta.repo, "worktree", "prune"]);
6240
6627
  const wtProbe = gitProbe(["git", "-C", meta.repo, "worktree", "list", "--porcelain", "-z"]);
6241
6628
  if (!wtProbe.ok) failures.push(`git worktree ${wtCanonical}: could not verify removal (${wtProbe.err || "worktree list failed"})`);
@@ -6244,25 +6631,16 @@ export function retireInstance(root, name, o = {}) {
6244
6631
  if (registered.includes(wtCanonical)) failures.push(`git worktree ${wtCanonical}: still registered`);
6245
6632
  }
6246
6633
  }
6247
- // The branch is a debt only when the failed spawn's rollback still owes its deletion, or when the
6248
- // operator asked for it (--delete-branch); then it must be verified gone, and a branch kept is said.
6249
- // --delete-branch: the branch the documented path deleted (the verified one) must be gone. A failed
6250
- // spawn's quarantine that owes its recorded branch keeps it unless that branch was the one deleted.
6251
- const verify = (branch) => {
6252
- const br = gitProbe(["git", "-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
6253
- if (br.ok) return "exists";
6254
- if (br.status !== 1 || br.err) { failures.push(`git branch ${branch}: could not verify whether it still exists (${br.err || `rev-parse exit ${br.status}`})`); return "unknown"; }
6255
- return "gone";
6256
- };
6257
- const deleted = retention?.branchDeleted;
6258
- if (deleted && verify(deleted) === "exists") failures.push(`git branch ${deleted}: still exists`);
6634
+ // The branch is a debt only when the failed spawn's rollback still owes its deletion. A retry never
6635
+ // deletes it: the debt is cleared only when the branch is shown to be gone. Git's quiet exit 1 is
6636
+ // "gone"; any other answer, or any text on standard error (a damaged ref file warns there), is
6637
+ // "could not verify", and the debt stays. The text is never matched: this read can only err
6638
+ // toward keeping the debt.
6259
6639
  const owesBranch = (quarantine.cleanup.outstanding?.git || []).includes("branch");
6260
- if (owesBranch && meta.branch && meta.branch !== deleted && verify(meta.branch) === "exists") {
6261
- // Without a home left to retry from (--force), or when --delete-branch verified another branch, the
6262
- // recorded branch is the operator's to delete by hand.
6263
- failures.push(o.force || o.deleteBranch
6264
- ? `git branch ${meta.branch}: kept; the failed spawn created it; delete it with git branch -D ${meta.branch} if unwanted`
6265
- : `git branch ${meta.branch}: kept; the failed spawn created it; pass --delete-branch to delete it`);
6640
+ if (owesBranch && meta.branch) {
6641
+ const br = spawnSync("git", ["-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${meta.branch}`], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
6642
+ if (!br.error && br.status === 0) failures.push(FAILED_SPAWN_BRANCH_LEFT);
6643
+ else if (br.error || br.status !== 1 || br.stderr.length) failures.push(`git branch ${meta.branch}: could not verify whether it still exists (${String(br.stderr ?? "").trim() || br.error?.message || `rev-parse exit ${br.status}`})`);
6266
6644
  }
6267
6645
  }
6268
6646
  // Git debt is proven by the verification block above, which only runs for a
@@ -6295,8 +6673,9 @@ export function retireInstance(root, name, o = {}) {
6295
6673
  rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
6296
6674
  }
6297
6675
 
6298
- const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: !!(retention?.branchDeleted), removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
6676
+ const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && !!retention && retention.worktree !== "retained", branchDeleted: false, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
6299
6677
  const w = [...(hookResults?.warnings || [])];
6678
+ if (o[OBSOLETE_DELETE_BRANCH]) w.push(OBSOLETE_DELETE_BRANCH_SENTENCE);
6300
6679
  if (isCapturedHome(meta) && !quarantine) {
6301
6680
  // A captured home retires through the workspace path; its captured retire hooks do not
6302
6681
  // run (lead decisions on (e), D1), so each capability whose spawn hook ran is named.
@@ -6313,7 +6692,15 @@ export function retireInstance(root, name, o = {}) {
6313
6692
  // The window the home RECORDED (a pre-0.31 home lives in pi-agents), never the default for new ones.
6314
6693
  const killSession = runtimeAuthority?.tmux?.session || meta?.tmux?.session || session;
6315
6694
  const killWindow = runtimeAuthority?.tmux?.window || meta?.tmux?.window || name;
6316
- shTry(`tmux run-shell -b 'sleep ${o.selfKillDelaySec ?? 8}; tmux kill-window -t ${shq(`=${killSession}:=${killWindow}`)} 2>/dev/null || true'`);
6695
+ // On the socket the receipt (else instance.json) records, both the scheduling and the kill; a home
6696
+ // that records none keeps the ambient server. run-shell expands its command as a format: a
6697
+ // literal # in the socket path is written ##.
6698
+ const killSocket = runtimeAuthority?.tmux?.socket || meta?.tmux?.socket;
6699
+ const killTarget = shq(`=${killSession}:=${killWindow}`);
6700
+ if (typeof killSocket === "string" && killSocket) {
6701
+ const kill = `sleep ${o.selfKillDelaySec ?? 8}; tmux -u -S ${shq(killSocket)} kill-window -t ${killTarget} 2>/dev/null || true`;
6702
+ shTry(`tmux -u -S ${shq(killSocket)} run-shell -b ${shq(kill.replace(/#/g, "##"))}`);
6703
+ } else shTry(`tmux run-shell -b 'sleep ${o.selfKillDelaySec ?? 8}; tmux kill-window -t ${killTarget} 2>/dev/null || true'`);
6317
6704
  result.selfKillScheduled = true;
6318
6705
  }
6319
6706
  return result;