@awebai/oats 0.41.1 → 0.42.0

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
@@ -43,10 +43,11 @@ import { attachSessionTarget } from "./session-viewer.mjs";
43
43
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
44
44
  import { appendEvent, liveWaiting, recordStartBoundary } from "./instance-events.mjs";
45
45
  import { killGroup } from "./process-group.mjs";
46
+ import { captureLoginEnvironment } from "./login-environment.mjs";
46
47
 
47
48
  import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
48
49
  import { envRows, recordedTeams, teamsEnv } from "./teams.mjs";
49
- import { harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
50
+ import { PANE_PATH_FIX, harnessNotOnPanePath, harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
50
51
  import { claudeTrusts, codexTrustsRoot, harnessTrustWarning } from "./harness-trust.mjs";
51
52
  async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
52
53
  // Capability rows for a PREPARED spawn (workspace model): the one function that
@@ -60,15 +61,17 @@ import { GIT_FETCH_TIMEOUT_MS, GIT_TIMEOUT_MS, gitEnv } from "./remote.mjs";
60
61
  import { loadLocal } from "./workspace.mjs";
61
62
  import { parseConfigData } from "./config-data.mjs";
62
63
  import { renderInstructionText } from "./instruction-composition.mjs";
63
- import { APPROVED_HOOKS, PORTABLE_ENV_NAME_RE, CORE_LAUNCH_ENV, PROCESS_BOOTSTRAP_ENV, PROCESS_BOOTSTRAP_PREFIXES, manifestContractProblems } from "./capability-contract.mjs";
64
+ import { APPROVED_HOOKS, PORTABLE_ENV_NAME_RE, CORE_LAUNCH_ENV, PROCESS_BOOTSTRAP_ENV, PROCESS_BOOTSTRAP_PREFIXES, manifestContractProblems, disposableHomeRootProblem, disposableHomeRootMatches } from "./capability-contract.mjs";
64
65
  import { validateBindingInterface } from "./provider-binding.mjs";
65
- /** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home). */
66
- export { fingerprintTree };
66
+ /** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home),
67
+ * the exact digest a copied path is compared with (exported so a test holds it to the copier),
68
+ * and the receipts the stored digest passes over in a home (exported so a test holds the manifest grammar to refusing each of them). */
69
+ export { exactTreeDigest, fingerprintTree, KERNEL_HOME_RECEIPTS };
67
70
 
68
71
  import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
69
72
  import { readPortableBytes } from "./bounded-read.mjs";
70
73
  import { copyTreeSafe } from "./tree-copy.mjs";
71
- import { assertSameWorktreeHead, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
74
+ import { assertSameWorktreeHead, headName, worktreeCommitUnreached, worktreeHead } from "./instance-git.mjs";
72
75
  /** The package and capability id grammar (namespaced, lowercase): an id names a
73
76
  * directory (a home's module copy), so no path spelling fits it. */
74
77
  const PACKAGE_ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
@@ -572,7 +575,9 @@ const LAUNCH_CONFIG_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
572
575
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
573
576
  /** Environment the kernel sets for every launch (identity, home, roots) and
574
577
  * its reference aliases: a configuration may not name them. */
575
- export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_SETTINGS_ORIGINS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT"]);
578
+ export const RESERVED_LAUNCH_ENV = new Set(["OATS_INSTANCE", "OATS_INSTANCE_HOME", "OATS_HOME", "OATS_AGENT", "OATS_SOUL", "OATS_SOUL_ID", "OATS_ROOT", "OATS_CONTEXT", "OATS_WORKSPACE", "OATS_EVENT", "OATS_SETTINGS", "OATS_SETTINGS_ORIGINS", "OATS_CLI_BIN", "PI_AGENT_INSTANCE", "PI_AGENT_HOME", "PI_AGENTS_ROOT",
579
+ // The test seam that replaces the login shell (lib/login-environment.mjs): never a launch's to set.
580
+ "OATS_TEST_LOGIN_SHELL"]);
576
581
  export const LAUNCH_REF_PREFIX = "OATS_LAUNCH_REF_";
577
582
  const reservedLaunchEnv = (n) => RESERVED_LAUNCH_ENV.has(n) || n.startsWith(LAUNCH_REF_PREFIX);
578
583
  export function validateLaunchConfig(name, entry, where) {
@@ -681,19 +686,10 @@ function validateCapabilityManifest(m, mf) {
681
686
  // has one flat name space per organisation instead (duplicate → E_CAPABILITY_AMBIGUOUS
682
687
  // at resolution), so the grammar is the only requirement.
683
688
  if (!/^[a-z0-9][a-z0-9._-]*$/.test(id)) throw new Error(`capability ID must match ^[a-z0-9][a-z0-9._-]*$: "${id}" (${mf})`);
684
- if (m.retirement !== undefined) {
685
- if (!isPlainObject(m.retirement) || !isPlainObject(m.retirement.disposable)) throw new Error(`capability ${id} manifest retirement must contain a disposable map`);
686
- const unknown = Object.keys(m.retirement).filter((key) => key !== "disposable");
687
- const scopes = Object.keys(m.retirement.disposable).filter((key) => !["home", "work"].includes(key));
688
- if (unknown.length || scopes.length) throw new Error(`capability ${id} manifest retirement has unsupported keys: ${[...unknown, ...scopes].join(", ")}`);
689
- for (const scope of ["home", "work"]) {
690
- const roots = m.retirement.disposable[scope];
691
- if (roots !== undefined && (!Array.isArray(roots) || roots.some((root) => typeof root !== "string"))) throw new Error(`capability ${id} manifest retirement.disposable.${scope} must be an array of relative roots`);
692
- }
693
- }
694
- // Launch environment and hooks: the shared manifest contract (lib/capability-contract.mjs).
689
+ // Launch environment, hooks and retirement: the shared manifest contract
690
+ // (lib/capability-contract.mjs). The error carries the problem's JSON pointer.
695
691
  const problem = manifestContractProblems(m)[0];
696
- if (problem) throw new Error(problem.message);
692
+ if (problem) throw Object.assign(new Error(problem.message), { pointer: problem.pointer });
697
693
  return id;
698
694
  }
699
695
 
@@ -2002,12 +1998,15 @@ function withoutKernelEnvironment(source) {
2002
1998
  * backtick, a quote and a backslash are written the same by every version read, and are carried.
2003
1999
  *
2004
2000
  * 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.
2001
+ * entry carries nothing, and its name is added to `cleared` when the caller passes one (tmux keeps
2002
+ * such an entry, and it masks the variable: a session's `unset PATH;` gives its panes no PATH). An
2003
+ * entry is left out only once it is framed whole (its name, its value to the closing quote, the
2004
+ * exact `; export NAME;` tail); its name is added to `omitted` when the caller passes one. So a
2005
+ * name is in the result, in `cleared`, in `omitted`, or absent from the text. null for bytes that
2006
+ * are not valid UTF-8 or that this grammar does not consume to the end: a caller refuses, it never
2007
+ * uses a partial result.
2009
2008
  */
2010
- export function parseTmuxShellEnvironment(bytes, omitted) {
2009
+ export function parseTmuxShellEnvironment(bytes, omitted, cleared) {
2011
2010
  let text;
2012
2011
  try { text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return null; }
2013
2012
  const env = {};
@@ -2017,6 +2016,7 @@ export function parseTmuxShellEnvironment(bytes, omitted) {
2017
2016
  if (take("unset ")) {
2018
2017
  const end = text.indexOf(";", i);
2019
2018
  if (end <= i || /[\s"=]/.test(text.slice(i, end))) return null;
2019
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(text.slice(i, end))) cleared?.push(text.slice(i, end));
2020
2020
  i = end + 1;
2021
2021
  } else {
2022
2022
  const assign = text.indexOf("=\"", i);
@@ -2074,11 +2074,19 @@ function callerInstance() {
2074
2074
  function creatorTmux(session) {
2075
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
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");
2077
+ const found = lookupOnPath("tmux", process.env.PATH);
2078
+ if (found) return found;
2079
+ throw refuse("no tmux was found on this process's PATH");
2080
+ }
2081
+ /** `name` as an exec looks it up on `path`: the first entry, in order, that holds a regular
2082
+ * executable file of that name, as an absolute path; null when none does. A relative entry, and
2083
+ * an empty one (an empty PATH is one empty entry), are `cwd`. Nothing is run. */
2084
+ function lookupOnPath(name, path, cwd = process.cwd()) {
2085
+ for (const dir of path.split(":")) {
2086
+ const candidate = resolve(cwd, dir || ".", name);
2079
2087
  try { accessSync(candidate, fsConstants.X_OK); if (statSync(candidate).isFile()) return candidate; } catch { /* not here */ }
2080
2088
  }
2081
- throw refuse("no tmux was found on this process's PATH");
2089
+ return null;
2082
2090
  }
2083
2091
  /**
2084
2092
  * The environment a `new-session` on the OATS server runs with: the only tmux call that can start
@@ -2087,80 +2095,188 @@ function creatorTmux(session) {
2087
2095
  * every pane created on it later inherits, and a new session takes the client's variables that
2088
2096
  * `update-environment` names.
2089
2097
  *
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.
2098
+ * When no server answered the lookup (`startsServer`), so that this new-session starts it, the
2099
+ * environment is the user's login environment (lib/login-environment.mjs), for every creator: what
2100
+ * the login shell from the password database sets up, from a seed that holds nothing of the creator
2101
+ * but its locale and, when it is no instance, its session variables (an instance's come from the
2102
+ * server its home records, then from the platform's user session). The kernel's names and the
2103
+ * instance-identity names are removed from what it answers, and the operator's OATS configuration
2104
+ * (OATS_ and PI_AGENTS_ names that are not the kernel's) is the creator's (an instance's: its recorded
2105
+ * server's). When it cannot be read (a creator whose HOME is not the user's home directory, a
2106
+ * timeout, a non-zero exit, a partial or malformed answer, no HOME or PATH, a shell that is not bash,
2107
+ * zsh or fish), one line on stderr says so and names the fallback, never a value, and the creator falls
2108
+ * back to what follows. That fallback is degraded: neither a login environment nor a working agent
2109
+ * socket is assured.
2110
+ *
2111
+ * Otherwise (the server runs, or the login environment could not be read): a process inside an OATS
2112
+ * instance never passes its own environment (its harness's variables, its credentials, its
2113
+ * identity). It passes the global environment of the tmux server its home records: an existing
2114
+ * baseline, chosen because a window that instance opened on that server got exactly it; not proof
2115
+ * that it holds nothing old. The home's receipt is checked against instance.json first, as every
2116
+ * session verb checks its endpoint, and the text is read with the strict reader above, which can
2117
+ * leave values out. When any of that fails, or no home is identified, the session is not created:
2118
+ * no fallback to the caller's environment, to another server or to a built-in list. Any other
2119
+ * creator (an operator's shell, a schedule runner, the Desktop) passes its own environment. In
2120
+ * every case the kernel's names are removed from the final set.
2099
2121
  *
2100
2122
  * 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.
2123
+ * creator (its TMUX_TMPDIR, kept or absent as the creator has it, whatever the selected environment
2124
+ * says; its tmux); what the server process gets (HOME and so its configuration file, PATH,
2125
+ * everything else) comes from the selected environment, with no ambient value filling in. Values
2126
+ * travel only as the environment of that tmux process: never in an argument, a message, an event or
2127
+ * a file. docs/execution-targets.md owns the rule.
2106
2128
  */
2107
2129
  const TMUX_SERVER_START_ENV = ["HOME", "XDG_CONFIG_HOME", "PATH", "SHELL"];
2108
- function oatsSessionEnvironment(session, io) {
2130
+ /** The operator's configuration names, carried into a server started with the login environment
2131
+ * (the kernel's own names among them are removed first). */
2132
+ const OPERATOR_CONFIG_ENV = /^(OATS_|PI_AGENTS_)/;
2133
+ function oatsSessionEnvironment(session, io, { startsServer = false } = {}) {
2109
2134
  const caller = callerInstance();
2135
+ let recorded;
2136
+ const recordedServer = () => (recorded ??= recordedServerEnvironment(caller, session, io));
2137
+ const creatorsTmpdir = (env) => { if (process.env.TMUX_TMPDIR === undefined) delete env.TMUX_TMPDIR; else env.TMUX_TMPDIR = process.env.TMUX_TMPDIR; return env; };
2138
+ if (startsServer) {
2139
+ const login = captureLoginEnvironment({ creatorEnv: process.env, instance: !!caller, recorded: caller ? recordedServer().read : null, shell: process.env.OATS_TEST_LOGIN_SHELL || undefined });
2140
+ if (login.env) {
2141
+ for (const note of login.notes) process.stderr.write(`oats: warning: ${note}\n`);
2142
+ const env = withoutKernelEnvironment(login.env);
2143
+ for (const name of INSTANCE_IDENTITY_ENV) delete env[name];
2144
+ // The operator's own OATS configuration (OATS_HOME_DIR, OATS_TMUX_SESSION, OATS_PACKAGE_CATALOG,
2145
+ // any other OATS_ or PI_AGENTS_ name that is not the kernel's) stays the creator's, over what
2146
+ // the login shell set: a creator outside every instance gives its own; an instance, what the
2147
+ // server its home records has, never its own.
2148
+ const configured = caller ? recordedServer().read : process.env;
2149
+ for (const [name, value] of Object.entries(withoutKernelEnvironment(configured ?? {}))) if (OPERATOR_CONFIG_ENV.test(name)) env[name] = value;
2150
+ return creatorsTmpdir(env);
2151
+ }
2152
+ const fallback = !caller ? "this process's own environment" : recordedServer().error ? null : "a copy of the environment of the tmux server this instance's home records";
2153
+ process.stderr.write(`oats: warning: could not read your login environment (${login.failure}); ${fallback ? `the OATS tmux server is started with ${fallback} instead: a fallback, neither a login environment nor a working agent socket is assured` : "and there is no fallback from inside an instance"}\n`);
2154
+ }
2110
2155
  if (!caller) return withoutKernelEnvironment(process.env);
2156
+ const { env, error } = recordedServer();
2157
+ if (error) throw error;
2158
+ return creatorsTmpdir(env);
2159
+ }
2160
+ /** The global environment of the tmux server an instance's home records, read strictly: `read` (what
2161
+ * the reader kept, or null), and `env` (a copy without the kernel's names) or `error` (the refusal
2162
+ * a creation from inside an instance gets when it cannot use it). */
2163
+ function recordedServerEnvironment(caller, session, io) {
2111
2164
  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`);
2165
+ if (!caller.home) return { read: null, error: refuse(`this process carries an instance's identity (${caller.evidence}) and neither OATS_INSTANCE_HOME nor the working directory names its home`) };
2113
2166
  let target;
2114
2167
  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`);
2168
+ catch (e) { if (!oatsCoded(e)) throw e; return { read: null, error: refuse(`the session receipt of ${basename(caller.home)} could not be used (${e.code})`) }; }
2169
+ if (!target?.socket) return { read: null, error: refuse(`the home of ${basename(caller.home)} records no tmux server`) };
2117
2170
  // Bytes, not text; and nothing of a failed read (tmux's output, its error) goes into the refusal.
2118
- let recorded = null;
2171
+ let read = null;
2119
2172
  const omitted = [];
2120
2173
  try {
2121
2174
  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);
2175
+ read = parseTmuxShellEnvironment(Buffer.isBuffer(printed) ? printed : Buffer.from(String(printed)), omitted);
2123
2176
  } 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`);
2177
+ if (read === null) return { read, error: refuse(`the environment of the tmux server that ${basename(caller.home)} is recorded on (${target.socket}) could not be read`) };
2125
2178
  // A variable the reader left out is never filled in from this process, and a server is never
2126
2179
  // started when the one left out decides which configuration it loads or which programs it runs.
2127
2180
  // (A recorded server that simply has none of them is copied as it is.)
2128
2181
  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;
2182
+ if (undecided) return { read, error: 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`) };
2183
+ return { read, env: withoutKernelEnvironment(read) };
2133
2184
  }
2134
2185
  /** 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. */
2186
+ * place, for every creator (an instance, the Desktop, an operator's shell): the three names tmux's
2187
+ * client reads its locale from, and nothing else. tmux's client does not start without a UTF-8
2188
+ * locale, and on a host that has neither en_US.UTF-8 nor C.UTF-8 only these name one; tmux gives
2189
+ * the pane none of them. A pane's environment is its session's over the server's global one
2190
+ * (spawn.c environ_for_session), PATH included: tmux puts the client's PATH in its place only when
2191
+ * the client has one, and this client has none. So a PATH and the state that interprets it (a
2192
+ * version manager's bookkeeping, `__MISE_DIFF`, nvm's) come from one environment, the server's or
2193
+ * the session's; OATS no longer overlays a creator's PATH onto it. That does not make an existing
2194
+ * or external server's environment, a session override or a launch configuration's PATH coherent:
2195
+ * OATS uses them as they are. The client is run by an absolute tmux (tmuxOn), since it has no PATH
2196
+ * to look one up with. docs/execution-targets.md owns the rule. */
2144
2197
  const TMUX_CLIENT_LOCALE_ENV = ["LANG", "LC_ALL", "LC_CTYPE"];
2145
2198
  function oatsWindowEnvironment() {
2146
- if (!callerInstance()) return withoutKernelEnvironment(process.env);
2147
2199
  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
2200
  for (const name of TMUX_CLIENT_LOCALE_ENV) if (process.env[name] !== undefined) env[name] = process.env[name];
2151
2201
  return env;
2152
2202
  }
2153
2203
  /**
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.
2204
+ * What a spawn or a start knows, BEFORE it creates, stops or writes anything, about the session it
2205
+ * will open a window in: `{ server, present }` from the lookup and, when `session` is not on the
2206
+ * OATS server and this is no preview, `env`: the environment its creation needs, read now, so that a
2207
+ * caller that cannot create it is refused with nothing left behind; the answer goes to
2208
+ * ensureOatsTmuxSession. It is also where the harness is first looked up (expectedPanePath). Not the
2209
+ * guarantee: ensureOatsTmuxSession decides by what it finds when it creates, and the harness is
2210
+ * looked up again on the session it returns; and an environment read for a running server is read
2211
+ * again there when that server has gone. undefined when this process has no tmux to run: the
2212
+ * caller's backend check refuses that.
2213
+ */
2214
+ function planOatsTmuxSession(session, io, { preview = false } = {}) {
2215
+ if (!io?.exec && !which("tmux")) return undefined;
2216
+ let server, present;
2217
+ try { ({ server, present } = oatsTmuxSessionSocket(session, io)); }
2218
+ catch (e) { if (preview) return undefined; throw e; } // a preview reports, it does not refuse on a read
2219
+ if (present || preview) return { server, present };
2220
+ return { server, present, env: oatsSessionEnvironment(session, io, { startsServer: !server }), startsServer: !server };
2221
+ }
2222
+ /**
2223
+ * The PATH a pane created in `session` on `socket` starts with, read as tmux decides it (spawn.c,
2224
+ * environ.c), never computed from this process: the session's own entry when it has one, otherwise
2225
+ * the server's global PATH. `session` null reads the global one only (a session about to be created
2226
+ * on a running server). Both are read with the strict reader, as bytes.
2227
+ * → `{ path, source }`, or `{ source, problem }` when the pane's PATH cannot be established: the
2228
+ * session entry is an explicit clear (`unset PATH;`: the pane has no PATH at all; never substituted
2229
+ * by the global one or this process's), a value the strict reader leaves out (unknown), a read that
2230
+ * failed, or a server with no PATH (tmux would fall back to a compiled-in default, which OATS does not
2231
+ * assume). `source` names where the PATH comes from; no value is ever in it or in `problem`.
2160
2232
  */
2161
- function planOatsTmuxSession(session, io) {
2162
- if (!callerInstance() || oatsTmuxSessionSocket(session, io).present) return undefined;
2163
- return { env: oatsSessionEnvironment(session, io) };
2233
+ function panePath(socket, session, io, cwd) {
2234
+ const read = (scope) => {
2235
+ const omitted = [], cleared = [];
2236
+ let env = null;
2237
+ try {
2238
+ const printed = (io?.exec || execFileSync)("tmux", ["-u", "-S", socket, "show-environment", ...scope, "-s"], { timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
2239
+ env = parseTmuxShellEnvironment(Buffer.isBuffer(printed) ? printed : Buffer.from(String(printed)), omitted, cleared);
2240
+ } catch (e) { if (oatsCoded(e)) throw e; }
2241
+ return { env, omitted, cleared };
2242
+ };
2243
+ const decide = ({ env, omitted, cleared }, source, unset) => {
2244
+ if (env === null) return { source, problem: `${source} could not be read` };
2245
+ if (Object.hasOwn(env, "PATH")) return { path: env.PATH, source, cwd };
2246
+ if (cleared.includes("PATH")) return { source, problem: unset };
2247
+ if (omitted.includes("PATH")) return { source, problem: `${source} cannot be read as data (its value holds a $, a line break or a character tmux prints encoded)` };
2248
+ return null;
2249
+ };
2250
+ if (session) {
2251
+ const own = decide(read(["-t", `=${session}`]), `the PATH of tmux session ${session}`, `tmux session ${session} clears PATH (unset PATH), so its panes start with no PATH at all`);
2252
+ if (own) return own;
2253
+ }
2254
+ const source = `the global PATH of the OATS tmux server (${socket})`;
2255
+ return decide(read(["-g"]), source, `the OATS tmux server (${socket}) clears PATH (unset PATH), so its panes start with no PATH at all`)
2256
+ ?? { source, problem: `the OATS tmux server (${socket}) has no PATH, and tmux then gives a pane a compiled-in default that OATS does not assume` };
2257
+ }
2258
+ /** Where a launch's pane is expected to look its harness up, from a plan (planOatsTmuxSession): the
2259
+ * session's or the server's PATH when a server runs, else the PATH of the environment the server
2260
+ * will be created with. undefined when that is not known (no plan, or a preview with no server: a
2261
+ * preview reads no creation environment); the lookup then uses this process's PATH, and the launch
2262
+ * looks again on the session it actually gets. */
2263
+ function expectedPanePath(plan, session, io, cwd) {
2264
+ if (!plan) return undefined;
2265
+ if (plan.server) return panePath(plan.server, plan.present ? session : null, io, cwd);
2266
+ if (!plan.env) return undefined;
2267
+ const source = "the PATH the OATS tmux server is created with";
2268
+ return plan.env.PATH === undefined ? { source, problem: `${source} is not set, and tmux then gives a pane a compiled-in default that OATS does not assume` } : { path: plan.env.PATH, source, cwd };
2269
+ }
2270
+ /** The PATH a launch's harness is looked up on, by precedence: a PATH set in the launch
2271
+ * configuration (a literal, or a reference resolved from `env`) is what the pane's command line
2272
+ * applies, so the lookup uses it; otherwise the pane's own PATH (`pane`, from panePath or
2273
+ * expectedPanePath; a function is called only when it is needed). A declared absolute or relative
2274
+ * executable is never looked up (resolveLaunchExecutable). undefined: look up on this process's PATH. */
2275
+ function launchLookup(configEnv, env, pane, configName, cwd) {
2276
+ const own = configEnv?.PATH;
2277
+ if (typeof own === "string") return { path: own, source: `the PATH of launch configuration ${configName}`, cwd };
2278
+ if (own && typeof own === "object" && own.fromEnv) return env[own.fromEnv] === undefined ? undefined : { path: env[own.fromEnv], source: `the PATH of launch configuration ${configName} (from ${own.fromEnv})`, cwd };
2279
+ return typeof pane === "function" ? pane() : pane;
2164
2280
  }
2165
2281
  /**
2166
2282
  * Make sure `session` exists on the OATS server and return that server's absolute socket: the one
@@ -2182,7 +2298,9 @@ export function ensureOatsTmuxSession(session, hq, io, plan) {
2182
2298
  // The executable is chosen here, where the creation is about to run, and nowhere earlier: a
2183
2299
  // session that exists needs none, so nothing is refused for it.
2184
2300
  const tmux = creatorTmux(session);
2185
- const env = plan?.env ?? oatsSessionEnvironment(session, io);
2301
+ // The plan's environment, unless it was read for a running server that has gone since: a server
2302
+ // this call starts gets what a server start gets.
2303
+ const env = plan?.env && (plan.startsServer || socket) ? plan.env : oatsSessionEnvironment(session, io, { startsServer: !socket });
2186
2304
  const address = socket ? ["-S", socket] : ["-L", OATS_TMUX_SERVER];
2187
2305
  let created;
2188
2306
  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(); }
@@ -2469,19 +2587,31 @@ const TASK_PROMPT = Object.freeze({ claude: "@TASK.md", codex: CODEX_TASK_PROMPT
2469
2587
  /** The executable a launch uses: a configuration's declared one (a bare name
2470
2588
  * on PATH; a path against the deployment directory when relative) or the
2471
2589
  * harness's name on PATH. Never executed. A new claude launch with a legacy `oats-claude-config` in reach
2472
- * of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares. */
2473
- export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir }) {
2590
+ * of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares.
2591
+ * `lookup` (launchLookup: `{ path, source, cwd }` or `{ source, problem }`) is the PATH a bare name is
2592
+ * looked up on, the one the pane runs it with; without one, this process's PATH. A result looked up
2593
+ * there carries `lookup`, so a refusal can name where it looked. */
2594
+ export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir, lookup }) {
2474
2595
  if (harness === "claude" && contextDir) { const refused = legacyClaudeConfigRefusal(contextDir); if (refused) throw refused; }
2475
- if (declared) {
2476
- if (declared.includes("/")) {
2477
- const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
2478
- return { path, declared, resolvedFrom: isAbsolute(declared) ? "absolute" : `relative to ${declaringDir || contextDir}`, missing: existsSync(path) ? undefined : `${declared} (${path}) does not exist` };
2479
- }
2480
- const found = which(declared);
2481
- return { path: found || null, declared, resolvedFrom: "PATH", missing: found ? undefined : `${declared} binary not found on PATH` };
2482
- }
2483
- const found = which(harness);
2484
- return { path: found || null, declared: null, resolvedFrom: "PATH", missing: found ? undefined : `${harness} binary not found on PATH` };
2596
+ if (declared?.includes("/")) {
2597
+ const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
2598
+ return { path, declared, resolvedFrom: isAbsolute(declared) ? "absolute" : `relative to ${declaringDir || contextDir}`, missing: existsSync(path) ? undefined : `${declared} (${path}) does not exist` };
2599
+ }
2600
+ const name = declared || harness;
2601
+ if (lookup) {
2602
+ const found = lookup.problem ? null : lookupOnPath(name, lookup.path, lookup.cwd);
2603
+ return { path: found, declared: declared || null, resolvedFrom: "PATH", lookup, missing: found ? undefined : lookup.problem ?? `${name} was not found on ${lookup.source}` };
2604
+ }
2605
+ const found = which(name);
2606
+ return { path: found || null, declared: declared || null, resolvedFrom: "PATH", missing: found ? undefined : `${name} binary not found on PATH` };
2607
+ }
2608
+ /** The refusal for an executable a launch looked up where its pane looks it up (`executable.lookup`)
2609
+ * and did not find, or could not look up: E_HARNESS_UNAVAILABLE for the harness's own name,
2610
+ * E_LAUNCH_EXECUTABLE for a configuration's declared name. Names the source and the remedies, never
2611
+ * the PATH's contents. */
2612
+ function executableNotOnPanePath(executable, { harness, config, from, at }) {
2613
+ if (!executable.declared) return harnessNotOnPanePath({ harness, from, at, why: executable.missing });
2614
+ return oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name}: ${executable.missing}; ${PANE_PATH_FIX}`);
2485
2615
  }
2486
2616
  /** null when `path` is a regular executable file; otherwise why not. */
2487
2617
  export function checkLaunchExecutable(path) {
@@ -2816,7 +2946,7 @@ function requirementsWithArgsMessage(harness, providers, config) {
2816
2946
  * and not at all for a preview. A home
2817
2947
  * that records no launch recipe (an earlier kernel spawned it) is not planned:
2818
2948
  * E_LAUNCH_LEGACY, re-spawn it from the deployment. */
2819
- export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots, reselect = null }) {
2949
+ export function planLaunch({ home, instance, meta, contextDir, agentLike, selection = {}, launchConfigs, resolvedCfg, env = process.env, preview = false, assertRoots, reselect = null, panePath: expectedPane }) {
2820
2950
  assertRoots?.();
2821
2951
  const problems = [];
2822
2952
  const fail = (check, code, detail) => { if (!preview) throw oatsError(code, detail); problems.push({ check, ok: false, detail, code }); };
@@ -2842,16 +2972,23 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2842
2972
  // the default no longer decides (a bare --launch-config none, another harness).
2843
2973
  const frozenYolo = frozen && !(frozen.launchConfigDefault === true && !config?.frozen) ? frozen.yolo : undefined;
2844
2974
  const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozenYolo : agentLike?.yolo ?? resolvedCfg?.yolo));
2975
+ // A frozen recipe keeps its recorded path. Otherwise a bare name is looked up where the pane will
2976
+ // look it up (launchLookup: the configuration's PATH, else `expectedPane`, the start's expected
2977
+ // session or server PATH; a start looks again on the session it gets).
2845
2978
  const executable = config?.frozen
2846
2979
  ? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
2847
- : resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
2848
- if (!executable.path && !config?.executable && launchChoice) {
2980
+ : resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir, lookup: config?.executable?.includes("/") ? undefined : launchLookup(config?.env, env, expectedPane, config?.name, home) });
2981
+ const notOnPane = !executable.path && executable.lookup ? executableNotOnPanePath(executable, { harness, config, from: launchChoice?.from, at: launchChoice?.at }) : null;
2982
+ if (notOnPane) {
2983
+ if (!preview) throw notOnPane;
2984
+ problems.push({ check: "executable", ok: false, detail: notOnPane.message, code: notOnPane.code });
2985
+ } else if (!executable.path && !config?.executable && launchChoice) {
2849
2986
  const e = harnessUnavailable({ harness, from: launchChoice.from, at: launchChoice.at, why: executable.missing });
2850
2987
  if (!preview) throw e;
2851
2988
  problems.push({ check: "executable", ok: false, detail: e.message, code: e.code });
2852
2989
  }
2853
- const exeProblem = executable.path ? checkLaunchExecutable(executable.path) : !config?.executable && launchChoice ? null : executable.missing;
2854
- if (exeProblem) fail("executable", "E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${exeProblem}`); else problems.push({ check: "executable", ok: true, detail: `${executable.path} (${executable.resolvedFrom})` });
2990
+ const exeProblem = executable.path ? checkLaunchExecutable(executable.path) : notOnPane || (!config?.executable && launchChoice) ? null : executable.missing;
2991
+ if (exeProblem) fail("executable", "E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${exeProblem}`); else if (!notOnPane) problems.push({ check: "executable", ok: true, detail: `${executable.path} (${executable.resolvedFrom})` });
2855
2992
  // Capability contributions: recorded at spawn with provenance; a harness
2856
2993
  // switch needs the new harness's launch args from the same capabilities.
2857
2994
  let hooks = { launch: {}, env: {}, contributions: [], pending: true };
@@ -3106,10 +3243,11 @@ function* spawnBody(root, agent, o = {}) {
3106
3243
  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 });
3107
3244
  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 });
3108
3245
  }
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;
3246
+ // A caller that will have to create the tmux session reads the environment for it here, before
3247
+ // any scaffold, work tree, identity or hook, so that an instance that cannot is refused with
3248
+ // nothing left behind; the harness is first looked up on the PATH this plan expects (below). A
3249
+ // preview reads only what exists.
3250
+ const tmuxSessionPlan = launch ? planOatsTmuxSession(session, undefined, { preview: o.preview === true }) : undefined;
3113
3251
 
3114
3252
  // Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
3115
3253
  // anchor the new instance to an EXISTING instance (o.relativeTo). o.parent
@@ -3393,7 +3531,12 @@ function* spawnBody(root, agent, o = {}) {
3393
3531
  // reconciliation, so this spawn-time check is the authoritative one.
3394
3532
 
3395
3533
  // Prerequisites must fail before creating a home, worktree, or identity.
3396
- const executable = resolveLaunchExecutable({ harness, declared: launchConfig?.executable, declaringDir: launchConfig?.source, contextDir: repoAbs });
3534
+ // A launched harness is looked up where its pane will look it up (launchLookup): this is the
3535
+ // preflight against the expected PATH; the launch looks again on the session it gets. Without a
3536
+ // launch (or a known destination) the lookup is this process's PATH, as before.
3537
+ const lookupFor = (pane) => launch && !launchConfig?.executable?.includes("/") ? launchLookup(launchConfig?.env, process.env, pane, launchConfig?.name, home) : undefined;
3538
+ const executable = resolveLaunchExecutable({ harness, declared: launchConfig?.executable, declaringDir: launchConfig?.source, contextDir: repoAbs, lookup: lookupFor(() => expectedPanePath(tmuxSessionPlan, session, undefined, home)) });
3539
+ if (!executable.path && executable.lookup) { previewPreflightBudget = null; throw executableNotOnPanePath(executable, { harness, config: launchConfig, from: launchChoice.from, at: launchChoice.at }); }
3397
3540
  // No executable for the chosen harness (none declared by a configuration): E_HARNESS_UNAVAILABLE names the
3398
3541
  // layer that chose it and the fix. Never a fallback to another harness.
3399
3542
  if (!executable.path && !launchConfig?.executable) { previewPreflightBudget = null; throw harnessUnavailable({ harness, from: launchChoice.from, at: launchChoice.at, why: executable.missing }); }
@@ -3902,7 +4045,7 @@ function* spawnBody(root, agent, o = {}) {
3902
4045
  assertDirectoryRoots(home, homeReal);
3903
4046
  const path = join(home, "work");
3904
4047
  if (!readdirSync(path).length) return;
3905
- const directoryFingerprint = fingerprintTree(path);
4048
+ const directoryFingerprint = directoryBytes(path);
3906
4049
  if (lastDirectoryFingerprint === directoryFingerprint) return;
3907
4050
  const receipt = preserveRetirementWork({ home, work: path, directory: true, directoryFingerprint, classes: ["directory work bytes"] }, { work, repo: repoAbs }, instance);
3908
4051
  directoryRecoveries.push(receipt.path);
@@ -4034,7 +4177,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
4034
4177
  if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
4035
4178
  const folderTrust = launchFolderTrust({ harness, home, meta: { workspace: { deployment: o.prepared?.deployment } }, yolo });
4036
4179
  if (folderTrust.warning) warnings.push(folderTrust.warning);
4037
- const cmdline = renderLaunchRecipe(recipe, { home, instance, trustHome: folderTrust.trustHome });
4180
+ let cmdline = renderLaunchRecipe(recipe, { home, instance, trustHome: folderTrust.trustHome });
4038
4181
 
4039
4182
  // Module skills as materialize landed them (flat, .agents/skills/<skill>/),
4040
4183
  // beside the soul's own: per-skill provenance `module:<cap>` (lead decision c3),
@@ -4133,11 +4276,26 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
4133
4276
 
4134
4277
  spawnTmux = meta.tmux;
4135
4278
  if (work === "directory") assertDirectoryRoots(home, homeReal);
4136
- const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
4279
+ let executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
4137
4280
  if (launch) {
4138
4281
  // The session on the OATS server, then everything on the one socket that answered: the collision
4139
4282
  // check, the records, the window and (in compensateSpawn) its removal.
4140
4283
  const socket = ensureOatsTmuxSession(session, existsSync(root) ? root : workspaceOf(root), undefined, tmuxSessionPlan);
4284
+ // The harness looked up again on the PATH this window's pane will actually start with, whoever
4285
+ // created the session and with whatever environment (a race lost to another creator, a session
4286
+ // that overrides PATH). Not there: refused, and the spawn is compensated. Found elsewhere than
4287
+ // the preflight found it: the launch runs that one.
4288
+ if (executable.lookup) {
4289
+ const actual = resolveLaunchExecutable({ harness, declared: launchConfig?.executable, declaringDir: launchConfig?.source, lookup: lookupFor(() => panePath(socket, session, undefined, home)) });
4290
+ if (!actual.path) throw executableNotOnPanePath(actual, { harness, config: launchConfig, from: launchChoice.from, at: launchChoice.at });
4291
+ const bad = checkLaunchExecutable(actual.path);
4292
+ if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${launchConfig?.name || "(harness default)"}: ${bad}`);
4293
+ if (actual.path !== recipe.executable) {
4294
+ recipe.executable = actual.path;
4295
+ meta.command = cmdline = renderLaunchRecipe(recipe, { home, instance, trustHome: folderTrust.trustHome });
4296
+ executionCommand = nativeRecordCommand(cmdline, home, harness);
4297
+ }
4298
+ }
4141
4299
  let present;
4142
4300
  try { present = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean); }
4143
4301
  catch (e) { throw new Error(`could not list the windows of tmux session ${session} on ${socket}: ${tmuxFailure(e, "tmux list-windows failed")}`); }
@@ -4692,43 +4850,147 @@ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
4692
4850
 
4693
4851
  /** Kernel-owned receipts the kernel itself appends to a home after the spawn
4694
4852
  * baseline (events log, stop/restart receipts). They are evidence about the
4695
- * instance, not the instance's work, so a retirement fingerprint ignores them —
4696
- * otherwise every stop or event write would read as "changed home bytes". */
4853
+ * instance, not the instance's work, so the stored digest a spawn baseline is
4854
+ * compared with passes over them — otherwise every stop or event write would
4855
+ * read as "changed home bytes". The home copy carries them, so the exact
4856
+ * digest, which says whether the retire hooks moved the home, holds them.
4857
+ * A new kernel-written top-level home entry, whether or not it belongs here,
4858
+ * MUST be added in the same change to the names a capability may not declare
4859
+ * as its own (`retirement.disposable.home`: KERNEL_HOME_NAMES and
4860
+ * KERNEL_HOME_NAME_PREFIXES in lib/capability-contract.mjs, and the schema). */
4697
4861
  const KERNEL_HOME_RECEIPTS = new Set([".oats-events.jsonl", ".oats-stop.json", ".oats-stop-receipt.json", ".oats-restart.json"]);
4698
4862
  const KERNEL_HOME_RECEIPT_PATTERNS = [/^\.oats-stop-receipt\..+\.json$/, /^\.oats-agents-md\..+\.previous$/];
4699
4863
  /** Harness project settings in the home are configuration, not work: the
4700
4864
  * home's `.claude/` is harness layout the kernel already shapes (the skills
4701
4865
  * alias), and capabilities keep their own entries current in its
4702
- * settings.json at every launch. A retirement fingerprint ignores exactly
4703
- * that path, home-relative. */
4866
+ * settings.json at every launch. The stored digest passes over exactly that
4867
+ * path, home-relative; the exact digest holds it, as the home copy does. */
4704
4868
  const HARNESS_HOME_SETTINGS = new Set([join(".claude", "settings.json")]);
4705
- function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false } = {}) {
4706
- const hash = createHash("sha256");
4869
+ /** The refusal for an entry that is not a file, a directory or a symbolic link
4870
+ * (a socket, a FIFO, a device): it has no bytes to read or to copy. One
4871
+ * sentence for every caller of fingerprintTree, at spawn and at retire, so it
4872
+ * names neither. It does not say "remove": the entry may be a live endpoint. */
4873
+ const unsupportedEntry = (path) => oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type: it is not a file, a directory or a symbolic link, so it cannot be read or copied. Safely stop the process or resource that owns it, or move the entry elsewhere, before retrying`);
4874
+ const PATH_SEPARATOR_BYTES = Buffer.from(sep);
4875
+ /** The digests of a tree, from one walk that reads each file once → { stored,
4876
+ * exact }; each only when asked for (`stored` by default).
4877
+ *
4878
+ * Both hash, for each entry in order, its path under the root, its
4879
+ * permission bits, its kind, and a file's bytes or a link's target. Entry
4880
+ * names and link targets are read and hashed as bytes, never as decoded
4881
+ * text: two trees that differ only in bytes that are not UTF-8 have two
4882
+ * digests, and an entry with such a name is read, not missed. This is about
4883
+ * what a digest tells apart. It does not make such a tree copyable:
4884
+ * copyTreeSafe reads entry names as text and cannot carry a file name that
4885
+ * is not UTF-8.
4886
+ *
4887
+ * A name's text (its bytes decoded as UTF-8, the way readdirSync decodes
4888
+ * them: replacement characters for bytes that are not UTF-8, a byte order
4889
+ * mark kept) is used only to order the entries and to test membership: the
4890
+ * names a digest passes over and `instance.json`, as they have always been
4891
+ * tested. Both digests pass over what a copy does not carry: `excludeRoot`
4892
+ * and, with `excludeGitMetadata`, `.git`. Only the stored digest also passes
4893
+ * over the kernel's receipts and the harness settings of a home.
4894
+ *
4895
+ * `stored` is the digest a spawn baseline holds, written by this kernel or
4896
+ * by an older one, so it never changes: its framing (each field followed by
4897
+ * NUL, no lengths), its order, and a home's `instance.json` without the
4898
+ * fields the kernel writes after spawn (kernelNeutralInstanceJson). For a
4899
+ * tree whose names and link targets are all valid UTF-8 it is what it was
4900
+ * when they were read as text: hashing a text hashes its UTF-8, which is
4901
+ * those bytes. It is what is compared with a baseline. Without lengths it
4902
+ * reads alike a file whose bytes spell the entry that follows it and those
4903
+ * two entries, and the neutral form reads alike two `instance.json` files
4904
+ * that parse to the same value.
4905
+ *
4906
+ * `exact` is for every comparison of a tree with itself later, or with its
4907
+ * copy: whether the retire hooks left it as it was. It is kept nowhere. It
4908
+ * holds every entry the copy carries: of a home, the kernel's receipts and
4909
+ * the harness settings too, which the copy carries and a retire hook can
4910
+ * write. Two different trees cannot give it the same stream: an entry is its
4911
+ * path, its mode, its kind and its content, where the path and the content
4912
+ * carry their length and the mode and the kind end in NUL, so the stream can
4913
+ * be read back into its entries in one way only, and a tree is the set of
4914
+ * its entries. A home's `instance.json` goes in as the bytes it has.
4915
+ *
4916
+ * The root's own permission bits, which are no entry of the walk, are in
4917
+ * the exact digest: a path the recovery copier copies is compared with its
4918
+ * own bits, because copyTreeSafe ends with a chmod of what it made.
4919
+ * `children` is for a root the copier creates itself (a home, a worktree):
4920
+ * its copy is made inside a directory the copier creates, so the root's bits
4921
+ * are no part of what it carries, and the root is compared through its
4922
+ * children alone. The stored digest never holds the root's bits. */
4923
+ function fingerprintTrees(root, { excludeRoot = new Set(), excludeGitMetadata = false, instanceHome = false, stored = true, exact = false, children = false } = {}) {
4924
+ const s = stored ? createHash("sha256") : null, x = exact ? createHash("sha256") : null;
4925
+ const digests = () => ({ stored: s ? `sha256:${s.digest("hex")}` : undefined, exact: x ? `sha256:${x.digest("hex")}` : undefined });
4926
+ /** A field of the exact digest whose length is not fixed: its length, NUL, its bytes. */
4927
+ const sized = (bytes) => { x.update(String(bytes.length)); x.update("\0"); x.update(bytes); };
4707
4928
  const rootStat = lstatSync(root);
4708
4929
  if (!rootStat.isDirectory()) {
4709
- hash.update(String(rootStat.mode & 0o7777)); hash.update("\0");
4710
- if (rootStat.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(root)); }
4711
- else if (rootStat.isFile()) { hash.update("file\0"); hash.update(readFileSync(root)); }
4712
- else throw oatsError("E_WORK_INSPECTION_FAILED", `${root} has an unsupported filesystem type`);
4713
- return `sha256:${hash.digest("hex")}`;
4714
- }
4715
- const walk = (dir, rel = "") => {
4716
- for (const e of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
4717
- if ((!rel && excludeRoot.has(e.name)) || (instanceHome && !rel && (KERNEL_HOME_RECEIPTS.has(e.name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(e.name)))) || (excludeGitMetadata && e.name === ".git")) continue;
4718
- const childRel = rel ? join(rel, e.name) : e.name;
4719
- if (instanceHome && HARNESS_HOME_SETTINGS.has(childRel)) continue;
4720
- const path = join(dir, e.name);
4930
+ const mode = String(rootStat.mode & 0o7777);
4931
+ s?.update(mode); s?.update("\0");
4932
+ x?.update("entry\0"); x?.update(mode); x?.update("\0");
4933
+ if (rootStat.isSymbolicLink()) { const target = readlinkSync(root, "buffer"); s?.update("link\0"); s?.update(target); if (x) { x.update("link\0"); sized(target); } }
4934
+ else if (rootStat.isFile()) { const bytes = readFileSync(root); s?.update("file\0"); s?.update(bytes); if (x) { x.update("file\0"); sized(bytes); } }
4935
+ else throw unsupportedEntry(root);
4936
+ return digests();
4937
+ }
4938
+ x?.update(children ? "tree\0" : `tree with its mode\0${rootStat.mode & 0o7777}\0`);
4939
+ // `dir` and `rel` are bytes: the directory, and its path under the root
4940
+ // (empty at the root). `relText` is `rel` as text, for the name tests.
4941
+ // `outer` is the stored hash, or null inside an entry it passes over.
4942
+ const walk = (dir, rel, relText, outer) => {
4943
+ // The order is the one it has always been: the names as text, compared
4944
+ // by localeCompare, on the listing as readdirSync returns it. The sort is
4945
+ // stable and the listing is the same whether its names come as text or
4946
+ // as bytes, so a tree whose names are all valid UTF-8 is walked in the
4947
+ // order it always was. Names that decode alike (two that are not UTF-8,
4948
+ // or one and a name that really holds the replacement character) keep
4949
+ // the listing's order. Where that order is not fixed, two reads of one
4950
+ // directory can differ, which adds a copy attempt; two different trees
4951
+ // cannot read alike for it, because the bytes of every name are hashed.
4952
+ const entries = readdirSync(dir, { encoding: "buffer" }).map((bytes) => ({ bytes, name: bytes.toString("utf8") }));
4953
+ entries.sort((a, b) => a.name.localeCompare(b.name));
4954
+ for (const { bytes: nameBytes, name } of entries) {
4955
+ if ((!relText && excludeRoot.has(name)) || (excludeGitMetadata && name === ".git")) continue;
4956
+ const childRelText = relText ? join(relText, name) : name;
4957
+ const storedPassesOver = instanceHome && ((!relText && (KERNEL_HOME_RECEIPTS.has(name) || KERNEL_HOME_RECEIPT_PATTERNS.some((p) => p.test(name)))) || HARNESS_HOME_SETTINGS.has(childRelText));
4958
+ if (storedPassesOver && !x) continue;
4959
+ const s = storedPassesOver ? null : outer;
4960
+ const childRel = rel.length ? Buffer.concat([rel, PATH_SEPARATOR_BYTES, nameBytes]) : nameBytes;
4961
+ const path = Buffer.concat([dir, PATH_SEPARATOR_BYTES, nameBytes]);
4721
4962
  const st = lstatSync(path);
4722
- hash.update(childRel); hash.update("\0"); hash.update(String(st.mode & 0o7777)); hash.update("\0");
4723
- if (st.isSymbolicLink()) { hash.update("link\0"); hash.update(readlinkSync(path)); hash.update("\0"); }
4724
- else if (st.isFile()) { hash.update("file\0"); hash.update(instanceHome && !rel && e.name === "instance.json" ? kernelNeutralInstanceJson(readFileSync(path)) : readFileSync(path)); /* kernel-field neutrality applies ONLY when the tree IS an instance home; a work tree's instance.json is the agent's bytes */ hash.update("\0"); }
4725
- else if (st.isDirectory()) { hash.update("dir\0"); walk(path, childRel); }
4726
- else throw oatsError("E_WORK_INSPECTION_FAILED", `${path} has an unsupported filesystem type`);
4963
+ const mode = String(st.mode & 0o7777);
4964
+ s?.update(childRel); s?.update("\0"); s?.update(mode); s?.update("\0");
4965
+ if (x) { sized(childRel); x.update(mode); x.update("\0"); }
4966
+ if (st.isSymbolicLink()) {
4967
+ const target = readlinkSync(path, "buffer");
4968
+ s?.update("link\0"); s?.update(target); s?.update("\0");
4969
+ if (x) { x.update("link\0"); sized(target); }
4970
+ } else if (st.isFile()) {
4971
+ const bytes = readFileSync(path);
4972
+ // Kernel-field neutrality applies ONLY when the tree IS an instance home, and only to the
4973
+ // stored digest; a work tree's instance.json is the agent's bytes.
4974
+ s?.update("file\0"); s?.update(instanceHome && !relText && name === "instance.json" ? kernelNeutralInstanceJson(bytes) : bytes); s?.update("\0");
4975
+ if (x) { x.update("file\0"); sized(bytes); }
4976
+ } else if (st.isDirectory()) { s?.update("dir\0"); x?.update("dir\0"); walk(path, childRel, childRelText, s); }
4977
+ else throw unsupportedEntry(join(root, childRelText));
4727
4978
  }
4728
4979
  };
4729
- walk(root);
4730
- return `sha256:${hash.digest("hex")}`;
4980
+ walk(Buffer.from(root), Buffer.alloc(0), "", s);
4981
+ return digests();
4982
+ }
4983
+ /** The stored digest of a tree (fingerprintTrees): what a spawn baseline holds and is compared with. */
4984
+ function fingerprintTree(root, options = {}) {
4985
+ return fingerprintTrees(root, { ...options, stored: true, exact: false }).stored;
4731
4986
  }
4987
+ /** The exact digest of a tree (fingerprintTrees): what a tree is compared with itself later, or with its copy. */
4988
+ function exactTreeDigest(root, options = {}) {
4989
+ return fingerprintTrees(root, { ...options, stored: false, exact: true }).exact;
4990
+ }
4991
+ /** The bytes of a directory instance's work/, as their exact digest, with the
4992
+ * permission bits of work/ itself: what a copy of it carries. Kept nowhere. */
4993
+ const directoryBytes = (work) => exactTreeDigest(work);
4732
4994
 
4733
4995
  /** A worktree directory whose git admin entry is gone: no `.git`, an unreadable one, or a gitfile naming an
4734
4996
  * admin directory that no longer exists. Read from the gitfile, not asked of git, which would walk up into
@@ -4745,13 +5007,24 @@ function worktreeAdminMissing(work) {
4745
5007
  if (!m) return true;
4746
5008
  return !existsSync(join(resolve(work, m[1]), "HEAD"));
4747
5009
  }
4748
- function worktreeStatus(repo) {
5010
+ /** A repository's status as Git printed it, as bytes. A path that is not
5011
+ * UTF-8 is in there as it is: the work state compares these bytes
5012
+ * (repositoryGitState). Output that does not decode is not a failure; only
5013
+ * a `git status` that fails is. */
5014
+ function worktreeStatusBytes(repo) {
4749
5015
  try {
4750
- return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
5016
+ return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
4751
5017
  } catch (e) {
4752
5018
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect instance worktree: ${String(e.stderr ?? e.message ?? "").trim() || "git status failed"}`);
4753
5019
  }
4754
5020
  }
5021
+ /** The same status as text, for what reads its rows: the classes, the rows a
5022
+ * spawn baseline was taken from, the comparison of a copy with its source.
5023
+ * Bytes that are not UTF-8 read as replacement characters here, so this
5024
+ * text is never what proves a worktree unchanged. */
5025
+ function worktreeStatus(repo) {
5026
+ return worktreeStatusBytes(repo).toString("utf8");
5027
+ }
4755
5028
 
4756
5029
  /** `git status --porcelain=v1 -z` as Map<path, XY>; a rename/copy row names its source (`R ← old`). */
4757
5030
  function statusRows(z) {
@@ -4825,24 +5098,90 @@ function retirementDisposableRoots(work, capabilities) {
4825
5098
  const KERNEL_POST_SPAWN_FIELDS = ["spawnCompleted", "wake"];
4826
5099
  function kernelNeutralInstanceJson(bytes) {
4827
5100
  try {
4828
- const m = JSON.parse(String(bytes));
5101
+ // Bytes that are not UTF-8 would each read as the replacement character,
5102
+ // and two such files would then read alike: a file that does not decode
5103
+ // and encode back to the same bytes is hashed as it is, like one that is
5104
+ // not JSON.
5105
+ const text = String(bytes);
5106
+ if (!Buffer.from(text).equals(bytes)) return bytes;
5107
+ const m = JSON.parse(text);
4829
5108
  if (!m || typeof m !== "object" || Array.isArray(m)) return bytes;
4830
5109
  for (const k of KERNEL_POST_SPAWN_FIELDS) delete m[k];
4831
5110
  return Buffer.from(JSON.stringify(m));
4832
5111
  } catch { return bytes; }
4833
5112
  }
5113
+ const byCodeUnit = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
5114
+ /** The provider-owned home entries the active capabilities declare
5115
+ * (`retirement.disposable.home`, lib/capability-contract.mjs) → [{ owner, root }],
5116
+ * sorted by owner then root. Recorded in the spawn baseline: retirement reads
5117
+ * them from there and from nowhere else. */
5118
+ function retirementDisposableHome(capabilities) {
5119
+ const rows = new Map();
5120
+ for (const cap of capabilities || []) {
5121
+ for (const root of cap?.retirement?.disposable?.home || []) {
5122
+ if (disposableHomeRootProblem(root)) throw new Error(`retirement disposable home entry from ${cap.id} is not declarable: ${JSON.stringify(root)}`);
5123
+ rows.set(`${cap.id}\0${root}`, { owner: cap.id, root });
5124
+ }
5125
+ }
5126
+ return [...rows.values()].sort((a, b) => byCodeUnit(a.owner, b.owner) || byCodeUnit(a.root, b.root));
5127
+ }
5128
+ /** A baseline's `disposableHome`, or no exclusions at all: it must be an array
5129
+ * of { owner, root } whose every root passes the manifest grammar. Anything
5130
+ * else (absent, an older baseline, a malformed or widened row) excludes
5131
+ * nothing, so the home is copied whole. Never the manifest in the home's
5132
+ * module copy, the capability as it is today, or instance.json. */
5133
+ function baselineDisposableHome(baseline) {
5134
+ const rows = baseline?.disposableHome;
5135
+ if (!Array.isArray(rows) || !rows.every((r) => isPlainObject(r) && nonEmptyString(r.owner) && !disposableHomeRootProblem(r.root))) return [];
5136
+ return rows.map((r) => ({ owner: r.owner, root: r.root })).sort((a, b) => byCodeUnit(a.owner, b.owner) || byCodeUnit(a.root, b.root));
5137
+ }
5138
+ /** One pass's resolved exclusion set: the home's top-level entries that
5139
+ * `disposableHome` rows cover, matched by name (a symlink is its name; it is
5140
+ * never followed). The fingerprint, the copy and the copy's verification of
5141
+ * that pass all use this one set. → { excludeRoot: Set (work/ and the covered
5142
+ * names), notCopied: [{ scope: "home", path, owner }] sorted by path }: names
5143
+ * and owners only. Two owners of one entry: the first in owner order. */
5144
+ function resolveHomeExclusions(home, disposableHome = []) {
5145
+ const excludeRoot = new Set(["work"]);
5146
+ const notCopied = [];
5147
+ if (!disposableHome.length) return { excludeRoot, notCopied };
5148
+ for (const name of readdirSync(home).sort(byCodeUnit)) {
5149
+ const row = name === "work" ? undefined : disposableHome.find((r) => disposableHomeRootMatches(r.root, name));
5150
+ if (!row) continue;
5151
+ excludeRoot.add(name);
5152
+ notCopied.push({ scope: "home", path: name, owner: row.owner });
5153
+ }
5154
+ return { excludeRoot, notCopied };
5155
+ }
5156
+ function unionNotCopied(...lists) {
5157
+ const byPath = new Map();
5158
+ for (const row of lists.flatMap((list) => list || [])) if (!byPath.has(row.path)) byPath.set(row.path, row);
5159
+ return [...byPath.values()].sort((a, b) => byCodeUnit(a.path, b.path));
5160
+ }
5161
+ function retirementBaselineValid(baseline, home) {
5162
+ return baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
5163
+ }
5164
+ /** What a retire plan may say about recovery without hashing anything: where a
5165
+ * recovery would be written, and the home entries the spawn baseline declares
5166
+ * as not copied (none without a valid baseline). */
5167
+ export function retirementRecoveryFacts(home) {
5168
+ const baseline = readJsonOrUndefined(retirementBaselinePath(home));
5169
+ return { recoveryRoot: join(retirementStateRoot(home), "recovery"), disposableHome: retirementBaselineValid(baseline, home) ? baselineDisposableHome(baseline) : [] };
5170
+ }
4834
5171
  function writeRetirementBaseline(home, work, mode, capabilities, runtime, { exclusive = false, incarnationId, executionBinding } = {}) {
4835
5172
  if (mode === "directory") assertDirectoryRoots(home);
4836
5173
  const isWorktree = mode === "worktree";
4837
5174
  const status = isWorktree && existsSync(work) ? worktreeStatus(work) : "";
4838
5175
  const disposableReceipts = isWorktree ? retirementDisposableRoots(work, capabilities) : [];
5176
+ const disposableHome = retirementDisposableHome(capabilities);
4839
5177
  const baseline = {
4840
5178
  version: RETIRE_BASELINE_VERSION,
4841
5179
  ...(incarnationId ? { incarnationId, executionBinding } : {}),
4842
5180
  ...(mode === "directory" ? { directoryWork: true, directoryRoots: { home: directoryIdentity(home), work: directoryIdentity(work) } } : {}),
4843
5181
  home: realPathOrNearest(home),
4844
- homeFingerprint: fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }),
5182
+ homeFingerprint: fingerprintTree(home, { excludeRoot: resolveHomeExclusions(home, disposableHome).excludeRoot, instanceHome: true }),
4845
5183
  disposableReceipts,
5184
+ disposableHome,
4846
5185
  generatedWorkFingerprint: isWorktree ? generatedWorkFingerprint(work, status, disposableReceipts.map((r) => r.root)) : undefined,
4847
5186
  runtime: {
4848
5187
  launched: runtime?.launched === true,
@@ -5163,8 +5502,16 @@ export function withLaunchModel(command, model) {
5163
5502
  return renderLaunchCommand(tokens);
5164
5503
  }
5165
5504
 
5505
+ /** One tmux command on `socket`. With `env` (a window client: oatsWindowEnvironment, which has no
5506
+ * PATH) the tmux is the absolute one this process's own PATH finds, so its lookup never depends on
5507
+ * the environment it is passed; none found is reported as tmux being unavailable (ENOENT). */
5166
5508
  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 } : {}) });
5509
+ let tmux = "tmux";
5510
+ if (env) {
5511
+ tmux = process.env.PATH === undefined ? null : lookupOnPath("tmux", process.env.PATH);
5512
+ if (!tmux) throw Object.assign(new Error("no tmux was found on this process's PATH"), { code: "ENOENT" });
5513
+ }
5514
+ return (io?.exec || execFileSync)(tmux, ["-u", "-S", socket, ...args], { encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], ...(env ? { env } : {}) });
5168
5515
  }
5169
5516
 
5170
5517
  function writeJsonAtomic(path, value, mode) {
@@ -5545,6 +5892,28 @@ export function startInstanceSession(home, o = {}) {
5545
5892
  // --reselect-launch (feature launch-preference): apply the home's launch layers now (souls.launch,
5546
5893
  // the recorded soul's launch, the host default) instead of the frozen recipe.
5547
5894
  const reselect = o.reselectLaunch === true ? homeLaunchLayers(realHome, meta) : null;
5895
+ // The session this start opens its window in when it has to create one, and what its creation
5896
+ // needs (planOatsTmuxSession), read at most once: by the plan's lookup or by the creation's preflight.
5897
+ const planSession = receipt.target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
5898
+ let sessionPlanRead;
5899
+ const sessionPlan = () => {
5900
+ if (!sessionPlanRead) { try { sessionPlanRead = { plan: planOatsTmuxSession(planSession, o.io) }; } catch (e) { sessionPlanRead = { error: e }; } }
5901
+ if (sessionPlanRead.error) throw sessionPlanRead.error;
5902
+ return sessionPlanRead.plan;
5903
+ };
5904
+ // The recorded target, observed once: by the plan's lookup, which must know whether the pane will
5905
+ // be reused before it chooses where to look, or by the gate below. A lost server is a stopped one;
5906
+ // any other failure is the gate's refusal (E_SESSION_UNKNOWN), and the lookup does not decide it.
5907
+ let recordedRead;
5908
+ const recordedState = () => {
5909
+ if (!recordedRead) {
5910
+ try { recordedRead = { state: receipt.target ? inspectSessionTarget(receipt.target, o.io) : { present: false, state: "not-launched" } }; }
5911
+ catch (e) { recordedRead = lostTmuxServer(e) ? { state: { present: false, state: "stopped" } } : { error: e }; }
5912
+ }
5913
+ return recordedRead;
5914
+ };
5915
+ // A pane the start reuses where it is: one the recorded target shows, running or left as a shell.
5916
+ const reusable = (state) => !!(state?.paneId && (state.present || state.state === "stopped"));
5548
5917
  const selected = reselect !== null || o.launchConfig !== undefined || o.harness !== undefined || o.yolo !== undefined;
5549
5918
  const hasRecipe = meta.launch && typeof meta.launch === "object";
5550
5919
  let launchPlan = null, launchHooksPass = null, hookMeta, explicitModelFrom = null, warnings = [];
@@ -5559,8 +5928,23 @@ export function startInstanceSession(home, o = {}) {
5559
5928
  // the launch hook re-checks joined memberships against them.
5560
5929
  const resolvedCfg = resolvedFromHome(realHome, meta, { teams: o.teams, defaultTeam: o.defaultTeam, teamsSource: o.teamsSource });
5561
5930
  let agent; try { agent = findAgent(dirname(dirname(dirname(realHome))), meta.agent); } catch { agent = undefined; }
5562
- const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model, yolo: o.yolo }, reselect, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots });
5563
- launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }),
5931
+ // Where the plan looks a harness up by name: a pane the start will reuse, on its own recorded
5932
+ // session, whatever the OATS server holds; otherwise the session's or the server's PATH when a
5933
+ // server runs; when none does, the PATH of the environment this start will create the server
5934
+ // with (read once, here, for the plan and for the creation below). For a creator that may not
5935
+ // create the server the plan looks on this process's PATH, and its refusal comes at the
5936
+ // creation's own place, after the start's other preflights.
5937
+ const pane = () => {
5938
+ const recorded = recordedState();
5939
+ if (reusable(recorded.state)) return panePath(receipt.target.socket, receipt.target.session, o.io, realHome);
5940
+ if (recorded.error) return undefined; // the gate refuses below; the lookup does not decide it
5941
+ const preview = planOatsTmuxSession(planSession, o.io, { preview: true });
5942
+ if (!preview || preview.server) return expectedPanePath(preview, planSession, o.io, realHome);
5943
+ try { return expectedPanePath(sessionPlan(), planSession, o.io, realHome); } catch (e) { if (oatsCoded(e)) return undefined; throw e; }
5944
+ };
5945
+ const plan = planLaunch({ home: realHome, instance: meta.instance, meta, contextDir: context, agentLike: agent || { harness: meta.harness, model: meta.model, yolo: meta.yolo }, selection: { launchConfig: o.launchConfig, harness: o.harness, model: o.model, yolo: o.yolo }, reselect, resolvedCfg, env: o.env || process.env, assertRoots: checkRoots,
5946
+ panePath: pane });
5947
+ launchPlan = { recipe: plan.recipe, command: plan.command, harness: plan.harness, model: plan.model, yolo: plan.yolo, executable: plan.executable, config: plan.config, launchChoice: plan.launchChoice, trustHome: plan.trustHome, modelFrom: modelFromOf(plan.modelSource, { at: "start", prior: meta.modelFrom ?? null }),
5564
5948
  ...(plan.launchChoice ? { launchFrom: plan.launchChoice.from, launchAt: plan.launchChoice.at, launchDeclared: plan.launchChoice.declared } : {}) };
5565
5949
  // The plan ran preview-aware launch hooks as a preview: their real run, over the planned
5566
5950
  // contributions, waits for the rest of preflight.
@@ -5585,19 +5969,15 @@ export function startInstanceSession(home, o = {}) {
5585
5969
  if (recipeForEnv) { const missing = missingLaunchEnvRefs(recipeForEnv.env, o.env || process.env); if (missing.length) throw oatsError("E_LAUNCH_ENV_MISSING", `this home's launch references ${missing.join(", ")}, not set on this host; nothing was started`); }
5586
5970
  const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
5587
5971
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
5588
- checkRoots(); // preparation has run; no backend has been observed
5972
+ checkRoots(); // preparation has run; no backend has been changed
5589
5973
  let target = receipt.target;
5590
- let state = { present: false, state: "not-launched" };
5591
- if (target) {
5592
- try { state = inspectSessionTarget(target, o.io); }
5593
- catch (e) {
5594
- if (lostTmuxServer(e)) state = { present: false, state: "stopped" };
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}`);
5596
- }
5597
- }
5974
+ const recorded = recordedState();
5975
+ if (recorded.error) { const e = recorded.error; throw oatsError("E_SESSION_UNKNOWN", `cannot establish whether ${meta.instance} is running, so nothing was started: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
5976
+ let state = recorded.state;
5598
5977
  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,
5978
+ // A caller that will have to create the tmux session reads the environment for it here, unless
5979
+ // the plan's lookup read it already with no server running (an instance that cannot is refused
5980
+ // here either way): after the start's preflights and its planning,
5601
5981
  // so each of their refusals still answers first, and before the real run of preview-aware
5602
5982
  // launch hooks, a stop and any write of the home's launch state (record, receipt, pending
5603
5983
  // start). A launch hook that does not declare launchPreview has already run and its warnings
@@ -5606,8 +5986,27 @@ export function startInstanceSession(home, o = {}) {
5606
5986
  // prepareLaunchHooks) allows such a hook idempotent provider registration, which the next
5607
5987
  // start repeats. Only when this start will have to create a window: a retained pane is reused
5608
5988
  // 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;
5989
+ const retained = reusable(state);
5990
+ const tmuxSessionPlan = retained ? undefined : sessionPlan();
5991
+ // A harness the plan looked up by name is looked up again where its pane will look it up, before
5992
+ // anything is stopped: the retained pane's own session, or the session this start will create
5993
+ // its window in (as planned). Once more on the session ensureOatsTmuxSession returns (below).
5994
+ // Not there: refused. Found elsewhere: the launch runs that one, and records it. Every executable
5995
+ // named bare (the harness's name, a declared name) is looked up again, also one the plan could only
5996
+ // look up on this process's PATH; a recorded (frozen) or a declared path never is.
5997
+ const lookAgain = (pane) => {
5998
+ if (!launchPlan || launchPlan.config?.frozen || launchPlan.config?.executable?.includes("/")) return false;
5999
+ const { config, launchChoice } = launchPlan;
6000
+ const actual = resolveLaunchExecutable({ harness: launchPlan.harness, declared: config?.executable, declaringDir: config?.source, lookup: launchLookup(config?.env, o.env || process.env, pane, config?.name, realHome) });
6001
+ if (!actual.path) throw executableNotOnPanePath(actual, { harness: launchPlan.harness, config, from: launchChoice?.from, at: launchChoice?.at });
6002
+ const bad = checkLaunchExecutable(actual.path);
6003
+ if (bad) throw oatsError("E_LAUNCH_EXECUTABLE", `launch configuration ${config?.name || "(harness default)"}: ${bad}; nothing was started`);
6004
+ if (actual.path === launchPlan.recipe.executable) return false;
6005
+ launchPlan.recipe = { ...launchPlan.recipe, executable: actual.path };
6006
+ command = launchPlan.command = renderLaunchRecipe(launchPlan.recipe, { home: realHome, instance: meta.instance, trustHome: launchPlan.trustHome });
6007
+ return true;
6008
+ };
6009
+ lookAgain(retained ? () => panePath(target.socket, target.session, o.io, realHome) : () => expectedPanePath(tmuxSessionPlan, planSession, o.io, realHome));
5611
6010
  // Every preflight has passed: the preview-aware launch hooks run for real
5612
6011
  // (they may register the home with their provider), before a restart's
5613
6012
  // stop, and must contribute exactly what they contributed as a preview,
@@ -5655,13 +6054,12 @@ export function startInstanceSession(home, o = {}) {
5655
6054
  const startedAt = new Date().toISOString();
5656
6055
  const id = randomUUID();
5657
6056
  checkRoots();
5658
- const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.harness || harness);
5659
- const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
6057
+ const windowCommand = () => paneCommand(`${nativeRecordCommand(command, realHome, launchPlan?.harness || harness)}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`);
5660
6058
  let reused = "new";
5661
6059
  const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
5662
6060
  const window = target?.window || meta.tmux?.window || meta.instance;
5663
6061
  let socket = target?.socket || meta.tmux?.socket;
5664
- const windowCmd = paneCommand(completedCommand);
6062
+ let windowCmd = windowCommand();
5665
6063
  let moved = null;
5666
6064
  // A fallback shell (no harness descendant) or a retained dead pane is
5667
6065
  // the agent's own pane: the command runs there, on the server the home
@@ -5681,6 +6079,7 @@ export function startInstanceSession(home, o = {}) {
5681
6079
  // on the OATS server, wherever the home was recorded: one socket from here to the record.
5682
6080
  const recordedSocket = socket ? resolve(socket) : null;
5683
6081
  socket = ensureOatsTmuxSession(session, hq, o.io, tmuxSessionPlan);
6082
+ if (lookAgain(() => panePath(socket, session, o.io, realHome))) { planExtra.launch = launchPlan.recipe; windowCmd = windowCommand(); }
5684
6083
  let names;
5685
6084
  try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
5686
6085
  catch (e) {
@@ -5722,6 +6121,287 @@ export function startInstanceSession(home, o = {}) {
5722
6121
  }
5723
6122
  }
5724
6123
 
6124
+ /** One Git call of a retirement inspection → its stdout, as bytes: a text
6125
+ * with one code unit per byte (latin1), so that no byte is replaced. Git
6126
+ * prints ref names and paths as the bytes they are, and decoded as UTF-8 two
6127
+ * names that differ only in bytes that are not UTF-8 would read alike: a
6128
+ * state a hook changed would compare equal, and a copy would be skipped.
6129
+ * What is compared is this text, never a decoded one. A value that is also
6130
+ * used as a path is decoded where it is used (repositoryGitState).
6131
+ * With `absent`, Git's quiet exit 1 ("no such ref", nothing on stderr)
6132
+ * gives null. Any other failure throws E_WORK_INSPECTION_FAILED: a state
6133
+ * that could not be read is never taken for an unchanged one. What a failed
6134
+ * read of the state means for the retire is inspectRetirementWork's
6135
+ * decision: not provable. */
6136
+ function inspectGit(repo, args, { absent = false, env } = {}) {
6137
+ try {
6138
+ return execFileSync("git", ["-C", repo, ...args], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER, ...(env ? { env: { ...process.env, ...env } } : {}) }).toString("latin1");
6139
+ } catch (e) {
6140
+ const detail = String(e.stderr ?? "").trim();
6141
+ if (absent && e.status === 1 && !detail) return null;
6142
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect the Git state of ${repo}: ${detail || String(e.message ?? "").trim() || `git ${args[0]} failed`}`);
6143
+ }
6144
+ }
6145
+ /** Whether an entry is there, for a read of the Git state. Only "not there"
6146
+ * is absence: a test that fails for any other reason (no permission on a
6147
+ * directory above it, for example) throws, so an entry the kernel cannot see
6148
+ * is never taken for one that does not exist. A link is followed, as
6149
+ * existsSync follows it. */
6150
+ function inspectedEntryExists(path) {
6151
+ try { statSync(path); return true; }
6152
+ catch (e) {
6153
+ if (e.code === "ENOENT") return false;
6154
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect ${path}: ${e.message}`);
6155
+ }
6156
+ }
6157
+ /** How the recovery copier resolves what it reads from a repository, read by
6158
+ * the copier and by the comparison alike (repositoryGitState), so that the
6159
+ * comparison holds the files the copy carries, found where the copy finds
6160
+ * them. Git's answer decoded as UTF-8 and trimmed of white space at both
6161
+ * ends: a path that ends in white space is read without it, which Git itself
6162
+ * does not do. That is the copier's own reading, kept as it is; a copy of
6163
+ * the file Git reads would be a change to the copier.
6164
+ * A worktree whose `core.worktree` (per-worktree configuration) names a
6165
+ * directory other than `<home>/work` is outside what the retire is
6166
+ * specified for: Git's status then describes that other directory while
6167
+ * the retire reads, copies and removes `<home>/work`. copierExcludesFile
6168
+ * resolves the excludes base the copier's way for it all the same; no test
6169
+ * pins it. */
6170
+ const copierGitText = (repo, args) => execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
6171
+ /** The Git directory the copier copies the index and the operation entries from. */
6172
+ const copierGitDir = (repo) => copierGitText(repo, ["rev-parse", "--absolute-git-dir"]);
6173
+ /** The common directory the copier reads info/exclude, info/attributes and the stash's log from. */
6174
+ const copierCommonDir = (repo) => copierGitText(repo, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
6175
+ /** The file the copier reads for core.excludesFile; null when it reads none:
6176
+ * the value is unset, cannot be read, or is only white space. */
6177
+ function copierExcludesFile(repo) {
6178
+ let value = "";
6179
+ try { value = copierGitText(repo, ["config", "--path", "--get", "core.excludesFile"]); } catch { /* unset: Git's default applies to both repositories alike */ }
6180
+ return value ? resolve(copierGitText(repo, ["rev-parse", "--show-toplevel"]), value) : null;
6181
+ }
6182
+ /** What a recovery's work copy holds of the worktree's repository beside the
6183
+ * bytes of its files: everything copyRecoveryWork carries or restores, read
6184
+ * the way it reads it, without reading the files. The rule: the work is
6185
+ * unchanged only if everything its copy would carry is byte-for-byte equal,
6186
+ * and anything that cannot be compared exactly counts as changed.
6187
+ * - its status text (the copy is verified against it);
6188
+ * - its HEAD as the copier reads it (headName): the whole ref, as its
6189
+ * bytes, which decides whether the copy is cloned on a branch or
6190
+ * detached; and the commit;
6191
+ * - the index the copier copies, by what it holds and not by its bytes:
6192
+ * its entries as `git ls-files -s -v` lists them (mode, object, stage and path, with the
6193
+ * skip-worktree and assume-unchanged marks), its resolve-undo records
6194
+ * (`git ls-files --resolve-undo`: resolving a conflict can add them while
6195
+ * every entry, status row and byte ends up as it was), and the file's
6196
+ * mode, which copyFileSync gives the copy. Its bytes also hold a stat
6197
+ * cache that a read-only Git command rewrites, and extensions derived
6198
+ * from the entries: those are a deliberate semantic exception, never
6199
+ * compared;
6200
+ * - each path the copier copies from the Git directories (COPIED_GIT_PATHS:
6201
+ * an operation in progress, info/attributes, the stash's log), by what its
6202
+ * copy carries, the permission bits included (copiedGitPathDigest);
6203
+ * - its tags: a clone brings them, and removing the remote leaves them;
6204
+ * - the stash ref (detachRecoveryClone fetches it);
6205
+ * - its exclude rules as carryExcludes reads them: the core.excludesFile
6206
+ * value, the file carryExcludes reads for it (copierExcludesFile), and
6207
+ * info/exclude. They are written into the copy as new text, so their
6208
+ * bytes are what it carries;
6209
+ * - its status settings as carryStatusConfig reads them: the effective
6210
+ * value of each STATUS_CONFIG key. The keys, not the configuration file:
6211
+ * Git rewrites that file for an upstream or a remote, which no copy
6212
+ * carries;
6213
+ * - `cloneHead`, for a worktree whose HEAD the copier copies detached (no
6214
+ * branch, a ref that is not a branch, a name that is not UTF-8): that copy
6215
+ * is cloned without --branch, so it keeps a local branch for the HEAD of
6216
+ * the repository it is cloned from, and none when that HEAD is not on a
6217
+ * branch. That HEAD is read as its whole ref.
6218
+ * The repository's other branches are not here: they outlive the worktree,
6219
+ * and the copy does not hold them. Its tags, stash, rules and settings are
6220
+ * the shared repository's: one made there by anyone while the retire hooks
6221
+ * run adds a copy attempt. The repository's objects and the settings a clone
6222
+ * of it is served under (its shallow boundary, grafts, hidden refs) stay in
6223
+ * it and are not compared: a custody boundary, which holds because no
6224
+ * retire removes that repository or deletes a branch.
6225
+ * Every read is inspectGit's, a file read or an existence test
6226
+ * (inspectedEntryExists): only Git's quiet "not set" and an entry that is
6227
+ * not there are absence; any other failure throws, and the worktree is then
6228
+ * not provable (inspectRetirementWork).
6229
+ * Everything in the list is compared as its bytes; nothing of it goes
6230
+ * through a decoding that replaces a byte. Git's output is inspectGit's
6231
+ * text of bytes, the status is the status bytes as such a text, a file is
6232
+ * the digest of its bytes, and a copied path is its exact digest
6233
+ * (fingerprintTrees), which reads names and link targets as bytes. The
6234
+ * three `rev-parse` paths and the `core.excludesFile` value are in the state
6235
+ * as Git printed them (`paths`, and the first of `excludesFile`), compared
6236
+ * as their bytes; a `rev-parse` path that does not decode to an existing one
6237
+ * throws below, and the worktree is not provable. Every file and directory
6238
+ * the state reads is found where the copier finds it, by the copier's own
6239
+ * functions (copierGitDir, copierCommonDir, copierExcludesFile, headName):
6240
+ * the state is what the copy carries, read the way the copy reads it. A
6241
+ * file the copier does not reach is not there for the state either.
6242
+ * `status`: the repository's worktreeStatusBytes, when the caller has it.
6243
+ * `cloneSource`: the repository the copy is cloned from (meta.repo). */
6244
+ function repositoryGitState(repo, { status, cloneSource } = {}) {
6245
+ /** One of the copier's reads; any failure makes the worktree not provable. */
6246
+ const asCopier = (read) => {
6247
+ try { return read(repo); }
6248
+ catch (e) {
6249
+ if (e?.code === "E_WORK_INSPECTION_FAILED") throw e;
6250
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect the Git state of ${repo}: ${String(e?.stderr ?? "").trim() || String(e?.message ?? "").trim()}`);
6251
+ }
6252
+ };
6253
+ // Three paths, one per line. A path with a line feed in it prints more
6254
+ // lines, and taken apart wrongly every test below would read "not there",
6255
+ // before the hooks and after them alike. So: exactly three, absolute, there.
6256
+ /** A value Git printed, as the path the kernel's text paths name with it. */
6257
+ const pathOf = (value) => Buffer.from(value, "latin1").toString("utf8");
6258
+ const printed = inspectGit(repo, ["rev-parse", "--path-format=absolute", "--absolute-git-dir", "--git-common-dir", "--show-toplevel"]).split("\n");
6259
+ if (printed.at(-1) === "") printed.pop();
6260
+ const paths = printed.map(pathOf);
6261
+ if (paths.length !== 3 || !paths.every((path) => isAbsolute(path) && inspectedEntryExists(path))) {
6262
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect the Git state of ${repo}: git rev-parse did not give its Git directory, its common directory and its top level as three existing absolute paths`);
6263
+ }
6264
+ const [gitDir, commonDir] = paths;
6265
+ const bytesOf = (path) => {
6266
+ try { return createHash("sha256").update(readFileSync(path)).digest("hex"); }
6267
+ catch (e) {
6268
+ if (e.code === "ENOENT") return null;
6269
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read ${path}: ${e.message}`);
6270
+ }
6271
+ };
6272
+ /** The permission bits a copyFileSync gives the copy of `path`; null when it is not there. */
6273
+ const modeOf = (path) => {
6274
+ try { return statSync(path).mode & 0o7777; }
6275
+ catch (e) {
6276
+ if (e.code === "ENOENT") return null;
6277
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read ${path}: ${e.message}`);
6278
+ }
6279
+ };
6280
+ /** One value Git may not have: null when it is not set. */
6281
+ const valueOf = (...args) => inspectGit(repo, args, { absent: true })?.replace(/\n$/, "") ?? null;
6282
+ const sourceValueOf = (...args) => inspectGit(cloneSource, args, { absent: true })?.replace(/\n$/, "") ?? null;
6283
+ const head = asCopier(headName);
6284
+ const copierGit = asCopier(copierGitDir), copierCommon = asCopier(copierCommonDir);
6285
+ const excludesFile = valueOf("config", "--path", "--get", "core.excludesFile");
6286
+ const excludesRead = asCopier(copierExcludesFile);
6287
+ return {
6288
+ paths: printed,
6289
+ status: (status ?? worktreeStatusBytes(repo)).toString("latin1"),
6290
+ head: head.ref === null ? null : head.ref.toString("latin1"),
6291
+ commit: valueOf("rev-parse", "--verify", "--quiet", "HEAD"),
6292
+ // The index the copier copies (copierGitDir), read as an index: Git's
6293
+ // own, at a path that differs from it only in white space, is not what
6294
+ // the copy carries. One that is not there lists nothing.
6295
+ index: inspectGit(repo, ["ls-files", "-s", "-v", "-z"], { env: { GIT_INDEX_FILE: join(copierGit, "index") } }),
6296
+ resolveUndo: inspectGit(repo, ["ls-files", "--resolve-undo", "-z"], { env: { GIT_INDEX_FILE: join(copierGit, "index") } }),
6297
+ indexMode: modeOf(join(copierGit, "index")),
6298
+ copied: COPIED_GIT_PATHS.map((entry) => copiedGitPathDigest(entry, copiedGitPath(entry, copierGit, copierCommon))),
6299
+ tags: inspectGit(repo, ["for-each-ref", "--format=%(objectname) %(refname)", "refs/tags"]),
6300
+ stash: valueOf("rev-parse", "--verify", "--quiet", "refs/stash"),
6301
+ excludesFile: excludesFile === null && excludesRead === null ? null : [excludesFile, excludesRead, excludesRead && bytesOf(excludesRead)],
6302
+ exclude: bytesOf(join(copierCommon, "info", "exclude")),
6303
+ statusConfig: STATUS_CONFIG.map((key) => valueOf("config", "--get", key)),
6304
+ // The copy is cloned from the repository the instance was spawned from
6305
+ // (`cloneSource`, meta.repo), which can be a linked worktree of it: that
6306
+ // repository's HEAD is read there, as its whole ref, not the common
6307
+ // directory's.
6308
+ cloneHead: head.branch === null && gitDir !== commonDir && cloneSource
6309
+ ? [sourceValueOf("symbolic-ref", "--quiet", "HEAD"), sourceValueOf("rev-parse", "--verify", "--quiet", "HEAD")]
6310
+ : null,
6311
+ };
6312
+ }
6313
+ /** Every repository under a worktree, at any depth → [{ path, inner }]:
6314
+ * nestedGitRoots, and (`inner`) the repositories inside those. A recovery
6315
+ * rebuilds a nested repository from a clone; one inside it comes along as
6316
+ * plain files, its Git directory included, which the copy's verification
6317
+ * leaves out and which no read-only Git command can describe whole.
6318
+ * A directory whose `.git` cannot be tested (inspectedEntryExists throws),
6319
+ * or is a dangling symbolic link (a link `stat` cannot follow, which the
6320
+ * copy carries as a link and the digests pass over by its name), is listed
6321
+ * as { path, unknown: true }: it is not taken for a directory without a
6322
+ * repository, and not for a repository either. So every directory with a
6323
+ * `.git` entry of any kind is listed: a directory, a file, a link to
6324
+ * anything, a dangling link, and a `.git` that cannot be tested. */
6325
+ function repositoriesUnder(work) {
6326
+ const out = [];
6327
+ // Asked only where `stat` found no `.git`: whether there is an entry all the same.
6328
+ const entryIsThere = (path) => {
6329
+ try { lstatSync(path); return true; }
6330
+ catch (e) {
6331
+ if (e.code === "ENOENT") return false;
6332
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect ${path}: ${e.message}`);
6333
+ }
6334
+ };
6335
+ const walk = (dir, inner) => {
6336
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
6337
+ if (!e.isDirectory() || e.isSymbolicLink?.() || e.name === ".git") continue;
6338
+ const path = join(dir, e.name);
6339
+ let repository = false;
6340
+ try {
6341
+ repository = inspectedEntryExists(join(path, ".git"));
6342
+ if (!repository && entryIsThere(join(path, ".git"))) out.push({ path, unknown: true });
6343
+ } catch (e) {
6344
+ if (e?.code !== "E_WORK_INSPECTION_FAILED") throw e;
6345
+ out.push({ path, unknown: true });
6346
+ }
6347
+ if (repository) out.push({ path, inner });
6348
+ walk(path, inner || repository);
6349
+ }
6350
+ };
6351
+ walk(work, false);
6352
+ return out;
6353
+ }
6354
+ /** The work state of a worktree instance, without the bytes of its files: the
6355
+ * worktree's Git state (repositoryGitState), as one text. Two inspections of
6356
+ * an untouched tree give the same text. The bytes are the other part, read
6357
+ * only when needed: observedWorkBytes. A repository under the worktree is not
6358
+ * part of it: such a worktree is not provable (inspectRetirementWork). */
6359
+ function worktreeGitState(work, status, cloneSource) { // `status`: the worktree's status bytes
6360
+ return JSON.stringify({ worktree: repositoryGitState(work, { status, cloneSource }) });
6361
+ }
6362
+ /** The bytes of a worktree, Git metadata left out, as their exact digest: what a work copy is verified
6363
+ * with, and what proves that the hooks left the files as they were. One full read. Kept nowhere. */
6364
+ const worktreeBytes = (work) => exactTreeDigest(work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true, children: true });
6365
+ /** The byte part of an observed worktree's work state, read at most once per
6366
+ * observation and kept on it; undefined where the observation saw no
6367
+ * worktree. An inspection never reads it. The retire reads it before it
6368
+ * writes the pre-hook recovery, the copy's verification uses it (so a pass
6369
+ * that copies the work costs no extra read), and so does the proof that the
6370
+ * retire hooks left the work as it was (movedByHooks). */
6371
+ function observedWorkBytes(observation) {
6372
+ if (observation.worktree && observation.workBytes === undefined) {
6373
+ // A read that fails is an inspection that failed, with its code: where the
6374
+ // retire reads before it writes, nothing else gives the failure one.
6375
+ try { observation.workBytes = worktreeBytes(observation.work); }
6376
+ catch (e) {
6377
+ if (e?.code === "E_WORK_INSPECTION_FAILED") throw e;
6378
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read the worktree at ${observation.work}: ${e.message}`);
6379
+ }
6380
+ }
6381
+ return observation.workBytes;
6382
+ }
6383
+ /** What the retire hooks moved, between the observation before them and the
6384
+ * one after → { home, work }. Nothing when the later observation has nothing
6385
+ * to preserve. The home moved when its exact digest did: that is its bytes,
6386
+ * `instance.json` as it is. (The stored digest, which a baseline is compared
6387
+ * with, leaves the kernel's own fields of that file out and frames entries
6388
+ * without lengths; it reads alike some homes that differ.)
6389
+ * The work moved unless it is proven unchanged: a directory by its bytes
6390
+ * (its exact digest); a worktree by its Git state and, only when that is
6391
+ * equal, by its bytes. A status text or a class list alone proves nothing: a
6392
+ * hook can rewrite a file that was already modified. `before` must hold its
6393
+ * bytes already (the retire reads them before it writes the pre-hook
6394
+ * recovery); without them the work counts as moved. So does a worktree that
6395
+ * cannot be proven unchanged at all (`workProvable` false: a worktree that
6396
+ * holds a repository, or a read of the Git state that failed). */
6397
+ function movedByHooks(before, after) {
6398
+ if (!after.classes.length) return { home: false, work: false };
6399
+ const home = after.homeBytes !== before.homeBytes;
6400
+ let work = after.workFingerprint !== before.workFingerprint;
6401
+ if (!work && after.worktree) work = !before.workProvable || !after.workProvable || before.workBytes === undefined || observedWorkBytes(after) !== before.workBytes;
6402
+ return { home, work };
6403
+ }
6404
+
5725
6405
  function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktreeRemoval, directory = false, orphanedWork = false } = {}) {
5726
6406
  if (directory) assertDirectoryRoots(home);
5727
6407
  const classes = [];
@@ -5732,10 +6412,20 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
5732
6412
  } catch (e) {
5733
6413
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not read the independent retirement baseline: ${e.message}`);
5734
6414
  }
5735
- const baselineValid = baseline?.version === RETIRE_BASELINE_VERSION && baseline.home === realPathOrNearest(home);
6415
+ const baselineValid = retirementBaselineValid(baseline, home);
6416
+ // This pass's one resolved exclusion set: provider-owned entries the spawn
6417
+ // baseline declared. Every home fingerprint here and the copy made from this
6418
+ // observation use it; without a valid baseline there is none.
6419
+ const homeExclusions = resolveHomeExclusions(home, baselineValid ? baselineDisposableHome(baseline) : []);
6420
+ // One walk of the home, two digests: the stored one for the comparison with
6421
+ // the baseline, which must not see the kernel's own fields of instance.json,
6422
+ // and the exact one for the comparison after the hooks, which must see
6423
+ // every byte.
6424
+ let homeDigests;
6425
+ const fingerprintHome = () => (homeDigests ??= fingerprintTrees(home, { excludeRoot: homeExclusions.excludeRoot, instanceHome: true, exact: true, children: true })).stored;
5736
6426
  if (!baselineValid) {
5737
6427
  classes.push("unknown instance-home provenance");
5738
- } else if (baseline.homeFingerprint !== fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true })) {
6428
+ } else if (baseline.homeFingerprint !== fingerprintHome()) {
5739
6429
  classes.push("changed instance-home bytes");
5740
6430
  }
5741
6431
  // A mutable mode must not turn owned directory bytes into an excluded shared
@@ -5745,7 +6435,7 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
5745
6435
  }
5746
6436
  let directoryFingerprint;
5747
6437
  if (directory) {
5748
- directoryFingerprint = fingerprintTree(work);
6438
+ directoryFingerprint = directoryBytes(work);
5749
6439
  // Never stamp hook-created or authored execution bytes as disposable.
5750
6440
  if (readdirSync(work).length) classes.push("directory work bytes");
5751
6441
  }
@@ -5756,24 +6446,65 @@ function inspectRetirementWork(home, work, isWorktree, { recordedBranch, worktre
5756
6446
  // reaches its commit. `head` is kept for the check before the removal.
5757
6447
  const unreached = worktreeRemoval?.removes && worktreeRemoval.repo && !directory && isWorktree && existsSync(work) ? worktreeCommitUnreached(worktreeRemoval.repo, work) : undefined;
5758
6448
  if (unreached?.unreached) classes.push("worktree commits no ref reaches");
5759
- if (isWorktree && existsSync(work)) {
5760
- const status = worktreeStatus(work);
6449
+ const worktree = isWorktree && existsSync(work);
6450
+ let gitState = "", workProvable = true;
6451
+ if (worktree) {
6452
+ // Read once, as bytes. The bytes go into the Git state; the rows, the
6453
+ // classes and the comparison with the spawn baseline read them as text.
6454
+ const statusBytes = worktreeStatusBytes(work);
6455
+ const status = statusBytes.toString("utf8");
5761
6456
  const rows = status.split("\0").filter(Boolean);
5762
6457
  if (rows.some((row) => !row.startsWith("?? ") && !row.startsWith("!! "))) classes.push("tracked or index worktree state");
5763
6458
  const disposableRoots = baseline?.disposableReceipts?.map((r) => r.root) || [];
5764
6459
  if (!baseline || baseline.generatedWorkFingerprint !== generatedWorkFingerprint(work, status, disposableRoots)) classes.push("untracked or ignored worktree bytes");
5765
- if (nestedGitRoots(work).length) classes.push("nested repository state");
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.
5770
- const stateFingerprint = createHash("sha256")
5771
- .update(fingerprintTree(home, { excludeRoot: new Set(["work"]), instanceHome: true }))
5772
- .update("\0").update(directory ? (directoryFingerprint || "missing") : isWorktree && existsSync(work) ? worktreeStatus(work) : "")
6460
+ const found = repositoriesUnder(work);
6461
+ if (found.some((r) => !r.unknown)) classes.push("nested repository state");
6462
+ // The one place where a read of the Git state that fails is decided. It
6463
+ // is never taken for "not set" or for "unchanged", and it does not refuse
6464
+ // either: main's inspection read none of this and let such a retire go
6465
+ // on. The worktree is then not provable, and the copy decides, as it did
6466
+ // on main: nothing to preserve retires, anything else is copied before
6467
+ // the hooks (homeOnlyRecovery) and again after them (movedByHooks).
6468
+ // `git status` is not part of this: it failed the inspection on main too.
6469
+ try { gitState = worktreeGitState(work, statusBytes, worktreeRemoval?.repo); }
6470
+ catch (e) {
6471
+ if (!e?.code) throw e;
6472
+ workProvable = false;
6473
+ }
6474
+ // A worktree that holds a repository is not provable either: a directory
6475
+ // under it with a `.git` entry of any kind (repositoriesUnder, the
6476
+ // predicate the class reads): a directory, a file, a link to anything, a
6477
+ // dangling link, and a `.git` that cannot be tested. Only a repository
6478
+ // the predicate can see adds the class; the last two make the work
6479
+ // unprovable without it, and so never home-only (homeOnlyRecovery): a
6480
+ // change to the home alone can then cause a work-copy attempt before
6481
+ // the hooks that main did not make, and any failure of that attempt can
6482
+ // refuse the retirement. The retire hooks run between the two copies and
6483
+ // can change such a repository in ways no read of its state covers (its
6484
+ // configuration, its objects, what a clone of it is shown), so its work
6485
+ // is copied again after them, whatever they did. What stays in the repository the
6486
+ // worktree belongs to needs no copy: no retire removes it or deletes a
6487
+ // branch, and a worktree commit no ref reaches is preserved (the class
6488
+ // above, `unreached`).
6489
+ if (found.length) workProvable = false;
6490
+ }
6491
+ // Two values, so the post-hook pass can tell which part a hook moved: the
6492
+ // home's bytes (its exact digest), and the work state's fingerprint. A
6493
+ // directory's work state is its bytes (`directoryFingerprint`, an exact
6494
+ // digest too). A worktree's is its Git state here, and its bytes, which an
6495
+ // inspection does not read (observedWorkBytes). `workProvable`: whether
6496
+ // that state could be read and covers everything a copy of the work would
6497
+ // carry. None of these is kept in a baseline, a receipt or recovery.json.
6498
+ // When the worktree will be removed, its HEAD (the commit and the ref's
6499
+ // bytes) and whether a ref reaches that commit are part of the work state: a
6500
+ // hook that deletes the ref that reached the commit changes nothing else
6501
+ // the state holds, and the commit must still be preserved again after it.
6502
+ fingerprintHome();
6503
+ const workFingerprint = createHash("sha256").update(directory ? (directoryFingerprint || "missing") : gitState)
5773
6504
  .update("\0").update(unreached ? `${unreached.head.commit}\0${unreached.unreached ? "unreached" : "reached"}\0` : "")
5774
6505
  .update(unreached?.head.ref ?? "")
5775
6506
  .digest("hex");
5776
- return { classes: [...new Set(classes)], home, work, directory, orphanedWork, directoryFingerprint, stateFingerprint, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
6507
+ return { classes: [...new Set(classes)], home, work, directory, orphanedWork, worktree, workProvable, directoryFingerprint, homeBytes: homeDigests.exact, workFingerprint, homeExclude: homeExclusions.excludeRoot, notCopied: homeExclusions.notCopied, branchExists, head: unreached?.head, runtimeAuthority: baselineValid ? runtimeAuthorityOf(baseline) : undefined };
5777
6508
  }
5778
6509
 
5779
6510
  function copyRecoveryTree(src, dest, { excludeRoot = new Set() } = {}) {
@@ -5789,6 +6520,44 @@ const RECOVERABLE_GIT_ADMIN = [
5789
6520
  "BISECT_LOG", "BISECT_START", "BISECT_NAMES", "rebase-apply", "rebase-merge", "sequencer",
5790
6521
  ];
5791
6522
 
6523
+ /** The paths of a repository's Git directories that a recovery's work copy
6524
+ * takes by copying them, beside the clone, the files and the index: one
6525
+ * selection, read by the copier (restoreStandaloneGitState,
6526
+ * carryStatusConfig, detachRecoveryClone) and by the comparison
6527
+ * (repositoryGitState), so that each copied path is compared by what its
6528
+ * copy carries (copiedGitPathDigest). `dir`: the repository's own Git
6529
+ * directory ("git") or its common directory ("common"). `copy`: copyTreeSafe
6530
+ * ("tree") or copyFileSync ("file").
6531
+ * The index is copied whole and is not in the selection: it is compared by
6532
+ * its entries, its resolve-undo records and its mode, never by its bytes,
6533
+ * which hold a cache that a read-only Git command rewrites. That is a
6534
+ * deliberate semantic exception (repositoryGitState). */
6535
+ const COPIED_GIT_PATHS = [
6536
+ ...RECOVERABLE_GIT_ADMIN.map((name) => ({ dir: "git", path: [name], copy: "tree" })),
6537
+ { dir: "common", path: ["info", "attributes"], copy: "file" },
6538
+ { dir: "common", path: ["logs", "refs", "stash"], copy: "file" },
6539
+ ];
6540
+ const [GIT_ATTRIBUTES, GIT_STASH_LOG] = COPIED_GIT_PATHS.slice(-2);
6541
+ const copiedGitPath = ({ dir, path }, gitDir, commonDir) => join(dir === "git" ? gitDir : commonDir, ...path);
6542
+ /** What the copy of one selected path carries, as a digest; null when the
6543
+ * path is not there. copyTreeSafe gives a copy the entry's kind, bytes or
6544
+ * target, and the bits of the entry and of everything under it: its exact
6545
+ * digest. copyFileSync follows a link and gives the copy the bytes and the
6546
+ * mode of what it reaches: those. Any failure but "not there" throws, as a
6547
+ * read of the Git state does, and so does a file path that is not a file. */
6548
+ function copiedGitPathDigest(entry, at) {
6549
+ if (!inspectedEntryExists(at)) return null;
6550
+ try {
6551
+ if (entry.copy === "tree") return exactTreeDigest(at);
6552
+ const st = statSync(at);
6553
+ if (!st.isFile()) throw new Error("it is not a file");
6554
+ return createHash("sha256").update(`${st.mode & 0o7777}\0`).update(readFileSync(at)).digest("hex");
6555
+ } catch (e) {
6556
+ if (e?.code === "E_WORK_INSPECTION_FAILED") throw e;
6557
+ throw oatsError("E_WORK_INSPECTION_FAILED", `could not read ${at}: ${e.message}`);
6558
+ }
6559
+ }
6560
+
5792
6561
  /** Object ids among `oids` that `repo` does not have, from one
5793
6562
  * `cat-file --batch-check` process. */
5794
6563
  export function missingGitObjects(repo, oids) {
@@ -5803,7 +6572,7 @@ export function missingGitObjects(repo, oids) {
5803
6572
  }
5804
6573
 
5805
6574
  function restoreStandaloneGitState(sourceWork, recoveredRepo) {
5806
- const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6575
+ const sourceGit = copierGitDir(sourceWork);
5807
6576
  const recoveredGit = join(recoveredRepo, ".git");
5808
6577
  const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"], { maxBuffer: GIT_MAX_BUFFER });
5809
6578
  // The staged blobs the recovered index will point at. The clone already
@@ -5827,11 +6596,13 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
5827
6596
  }
5828
6597
  const stillMissing = missingGitObjects(recoveredRepo, [...staged]);
5829
6598
  if (stillMissing.length) throw new Error(`recovered repository lacks ${stillMissing.length} staged object(s) after restore: ${stillMissing.slice(0, 3).join(", ")}`);
6599
+ // The index is the selection's one exception (COPIED_GIT_PATHS): copied
6600
+ // whole, compared by its entries, resolve-undo records and mode.
5830
6601
  copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
5831
- for (const name of RECOVERABLE_GIT_ADMIN) {
5832
- const source = join(sourceGit, name);
6602
+ for (const entry of COPIED_GIT_PATHS.filter((e) => e.dir === "git")) {
6603
+ const source = copiedGitPath(entry, sourceGit);
5833
6604
  if (!existsSync(source)) continue;
5834
- const dest = join(recoveredGit, name);
6605
+ const dest = copiedGitPath(entry, recoveredGit);
5835
6606
  rmSync(dest, { recursive: true, force: true });
5836
6607
  copyTreeSafe(source, dest);
5837
6608
  }
@@ -5844,7 +6615,6 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
5844
6615
  * info/exclude, which keeps Git's precedence (a later pattern wins, and info/exclude outranks
5845
6616
  * core.excludesFile). → the sources carried, [{ kind, path }]. */
5846
6617
  function carryExcludes(sourceWork, recoveredRepo) {
5847
- const git = (...args) => execFileSync("git", ["-C", sourceWork, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
5848
6618
  const carried = [], parts = [];
5849
6619
  // A file with no pattern line (Git's template info/exclude is comments only) changes nothing: not carried.
5850
6620
  const carry = (kind, file) => {
@@ -5853,12 +6623,9 @@ function carryExcludes(sourceWork, recoveredRepo) {
5853
6623
  if (!text.split("\n").some((line) => line.trim() && !line.startsWith("#"))) return;
5854
6624
  parts.push(text); carried.push({ kind, path: file });
5855
6625
  };
5856
- let excludesFile = "";
5857
- try { excludesFile = git("config", "--path", "--get", "core.excludesFile"); } catch { /* unset: Git's default applies to both repositories alike */ }
5858
- if (excludesFile) {
5859
- carry("core.excludesFile", resolve(git("rev-parse", "--show-toplevel"), excludesFile));
5860
- }
5861
- carry("info/exclude", join(git("rev-parse", "--path-format=absolute", "--git-common-dir"), "info", "exclude"));
6626
+ const excludesFile = copierExcludesFile(sourceWork);
6627
+ if (excludesFile) carry("core.excludesFile", excludesFile);
6628
+ carry("info/exclude", join(copierCommonDir(sourceWork), "info", "exclude"));
5862
6629
  if (!carried.length) return carried;
5863
6630
  mkdirSync(join(recoveredRepo, ".git", "info"), { recursive: true });
5864
6631
  writeFileSync(join(recoveredRepo, ".git", "info", "exclude"), parts.map((t) => (t.endsWith("\n") ? t : `${t}\n`)).join(""));
@@ -5885,11 +6652,12 @@ function carryStatusConfig(sourceWork, recoveredRepo) {
5885
6652
  execFileSync("git", ["-C", recoveredRepo, "config", "--local", ...(value === null ? ["--unset-all", key] : [key, value])], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
5886
6653
  carried.push({ kind: "config", key, value });
5887
6654
  }
5888
- const common = execFileSync("git", ["-C", sourceWork, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim();
5889
- const attributes = join(common, "info", "attributes");
6655
+ const common = copierCommonDir(sourceWork);
6656
+ const attributes = copiedGitPath(GIT_ATTRIBUTES, null, common);
5890
6657
  if (existsSync(attributes)) {
5891
- mkdirSync(join(recoveredRepo, ".git", "info"), { recursive: true });
5892
- copyFileSync(attributes, join(recoveredRepo, ".git", "info", "attributes"));
6658
+ const dest = copiedGitPath(GIT_ATTRIBUTES, null, join(recoveredRepo, ".git"));
6659
+ mkdirSync(dirname(dest), { recursive: true });
6660
+ copyFileSync(attributes, dest);
5893
6661
  carried.push({ kind: "info/attributes", path: attributes });
5894
6662
  }
5895
6663
  return carried;
@@ -5901,10 +6669,10 @@ function detachRecoveryClone(source, recovered) {
5901
6669
  catch { stash = undefined; }
5902
6670
  if (stash) {
5903
6671
  execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"], { maxBuffer: GIT_MAX_BUFFER });
5904
- const sourceCommon = execFileSync("git", ["-C", source, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
5905
- const stashLog = join(sourceCommon, "logs", "refs", "stash");
6672
+ const sourceCommon = copierCommonDir(source);
6673
+ const stashLog = copiedGitPath(GIT_STASH_LOG, null, sourceCommon);
5906
6674
  if (existsSync(stashLog)) {
5907
- const recoveredLog = join(recovered, ".git", "logs", "refs", "stash");
6675
+ const recoveredLog = copiedGitPath(GIT_STASH_LOG, null, join(recovered, ".git"));
5908
6676
  mkdirSync(dirname(recoveredLog), { recursive: true });
5909
6677
  copyFileSync(stashLog, recoveredLog);
5910
6678
  }
@@ -5965,7 +6733,133 @@ function preservedOutputs(work, directory) {
5965
6733
  return { paths, bytes: paths.reduce((n, p) => n + p.bytes, 0) };
5966
6734
  }
5967
6735
 
5968
- function preserveRetirementWork(observation, meta, instance) {
6736
+ /** The home part of a recovery at `dest`: everything but work/ and this pass's
6737
+ * excluded provider-owned entries (observation.homeExclude), verified against
6738
+ * the source with that same set. The copy is hashed whole, so an excluded
6739
+ * entry that reached it fails the verification. The stored digest is what
6740
+ * is compared: it leaves the kernel's own fields of instance.json out, so a
6741
+ * kernel write there between the copy and this check is not a disagreement.
6742
+ * That is an inherited limit, and this is not an exact verification of the
6743
+ * whole home: the stored digest also passes over the kernel's receipts and
6744
+ * the harness settings, reads instance.json through a parse, and frames its
6745
+ * entries without lengths. */
6746
+ function copyRecoveryHome(observation, dest) {
6747
+ const excludeRoot = observation.homeExclude || new Set(["work"]);
6748
+ copyRecoveryTree(observation.home, dest, { excludeRoot });
6749
+ if (fingerprintTree(observation.home, { excludeRoot, instanceHome: true }) !== fingerprintTree(dest, { instanceHome: true })) {
6750
+ throw new Error("home recovery verification disagreed with the source");
6751
+ }
6752
+ }
6753
+
6754
+ /** The work part of a recovery under `parent`, copied and verified: `repo/`, a
6755
+ * standalone clone of a worktree instance's repository carrying its
6756
+ * uncommitted state, or `work/`, a directory instance's bytes. A worktree's
6757
+ * source bytes are the observation's (observedWorkBytes), so the verified
6758
+ * value is also its byte state. → { copied, branchDrift?, excludes?,
6759
+ * statusConfig? }; `copied` is false where neither applies (another work
6760
+ * mode, or a worktree home with no recorded repo or branch). A HEAD that
6761
+ * cannot be read is an inspection failure, not a failed copy: its error is
6762
+ * put in `unreadableHeads`, and the callers throw it as it is. */
6763
+ const unreadableHeads = new WeakSet();
6764
+ function copyRecoveryWork(observation, meta, parent) {
6765
+ let branchDrift, excludes, statusConfig, copied = false;
6766
+ if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
6767
+ const recoveredRepo = join(parent, "repo");
6768
+ // The branch is derived from the worktree while it exists: an instance
6769
+ // that legitimately switched branches during its task must still be
6770
+ // recoverable, and the recorded spawn-time branch is only the fallback
6771
+ // when the worktree is gone. A HEAD that is detached, or on a ref OATS
6772
+ // carries no name for, recovers detached at its exact commit.
6773
+ let ref;
6774
+ try { ref = existsSync(observation.work) ? worktreeHead(observation.work) : { branch: meta.branch, commit: null }; }
6775
+ catch (e) { unreadableHeads.add(e); throw e; }
6776
+ if (ref.branch !== meta.branch) branchDrift = { recordedBranch: meta.branch, worktreeBranch: ref.branch, detachedAt: ref.branch === null ? ref.commit : null };
6777
+ if (ref.branch !== null) execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", ref.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6778
+ else {
6779
+ execFileSync("git", ["clone", "--no-local", "--quiet", "--no-checkout", meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6780
+ execFileSync("git", ["-C", recoveredRepo, "fetch", "--quiet", observation.work, ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
6781
+ execFileSync("git", ["-C", recoveredRepo, "checkout", "--quiet", "--detach", ref.commit], { stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER });
6782
+ }
6783
+ const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
6784
+ detachRecoveryClone(sourceGitContext, recoveredRepo);
6785
+ if (existsSync(observation.work)) {
6786
+ restoreStandaloneGitState(observation.work, recoveredRepo);
6787
+ excludes = carryExcludes(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
6788
+ statusConfig = carryStatusConfig(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
6789
+ for (const e of readdirSync(observation.work, { withFileTypes: true })) {
6790
+ if (e.name === ".git") continue;
6791
+ const dest = join(recoveredRepo, e.name);
6792
+ rmSync(dest, { recursive: true, force: true });
6793
+ copyTreeSafe(join(observation.work, e.name), dest);
6794
+ }
6795
+ const nested = materializeNestedRepositories(observation.work, recoveredRepo);
6796
+ excludes.push(...nested.excludes); statusConfig.push(...nested.statusConfig);
6797
+ }
6798
+ if (existsSync(join(recoveredRepo, ".git", "objects", "info", "alternates"))) throw new Error("recovery clone depends on object alternates");
6799
+ if (existsSync(observation.work)) {
6800
+ // The source's bytes as this observation holds them: read before the
6801
+ // pre-hook recovery was written, or by the proof that the hooks moved
6802
+ // nothing, or else here. Either way they are kept as the observation's
6803
+ // byte state.
6804
+ if ((observation.worktree ? observedWorkBytes(observation) : worktreeBytes(observation.work)) !== worktreeBytes(recoveredRepo)) throw new Error("worktree recovery verification disagreed with the source");
6805
+ assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
6806
+ }
6807
+ // The proof of the copy: its HEAD is the commit the worktree has checked
6808
+ // out. A branch of the repository can be at another commit with the
6809
+ // same files, so the branch's tip there proves nothing about the copy.
6810
+ // With the worktree gone there is no such commit to read, and the copy
6811
+ // is the recorded branch as the repository has it.
6812
+ const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6813
+ const sourceHead = ref.commit
6814
+ ?? execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${ref.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6815
+ 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");
6816
+ copied = true;
6817
+ }
6818
+ if (observation.directory && observation.directoryFingerprint) {
6819
+ const recoveredWork = join(parent, "work");
6820
+ copyTreeSafe(observation.work, recoveredWork);
6821
+ if (directoryBytes(observation.work) !== observation.directoryFingerprint || directoryBytes(recoveredWork) !== observation.directoryFingerprint) {
6822
+ throw new Error("directory recovery verification disagreed with the inspected source");
6823
+ }
6824
+ copied = true;
6825
+ }
6826
+ return { copied, branchDrift, excludes, statusConfig };
6827
+ }
6828
+
6829
+ /** The top-level entries of a recovery's home snapshot with their bytes,
6830
+ * largest first; a directory ends in `/`. What the retire summary names as
6831
+ * copied from the home. */
6832
+ function preservedHome(recoveredHome) {
6833
+ const paths = readdirSync(recoveredHome, { withFileTypes: true })
6834
+ .map((e) => ({ path: e.isDirectory() ? `${e.name}/` : e.name, bytes: treeBytes(join(recoveredHome, e.name)) }))
6835
+ .sort((a, b) => b.bytes - a.bytes || a.path.localeCompare(b.path));
6836
+ return { paths, bytes: paths.reduce((n, p) => n + p.bytes, 0) };
6837
+ }
6838
+
6839
+ /** Whether a recovery of this observation holds the home only. A home-only
6840
+ * change (notes, harness files, credentials) needs a home snapshot, not
6841
+ * another copy of an otherwise disposable clean worktree. In-progress Git
6842
+ * operations retain the full standalone recovery even when porcelain status
6843
+ * has no changed paths. An orphaned work directory (no git admin entry) stays
6844
+ * where it is, never moved or removed, and git cannot read it: only the home
6845
+ * is snapshotted. A worktree that cannot be proven unchanged (workProvable
6846
+ * false) is never home-only: after the hooks it could be neither proven nor
6847
+ * taken as copied, so its work goes into the snapshot before them. */
6848
+ function homeOnlyRecovery(observation, meta) {
6849
+ if (observation.orphanedWork === true) return true;
6850
+ if (observation.workProvable === false) return false;
6851
+ if (!(observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes" && meta.work === "worktree" && existsSync(observation.work))) return false;
6852
+ const gitDir = copierGitDir(observation.work);
6853
+ return !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
6854
+ }
6855
+
6856
+ /** Write one verified recovery of an observed home and work: staged beside its
6857
+ * final name and renamed into place. `phase` goes into recovery.json:
6858
+ * "before-hooks" for the snapshot a retirement takes before its retire hooks
6859
+ * (completeRetirementRecovery concludes it), "complete" for a recovery nothing
6860
+ * will add to. → { receipt, manifest }: the receipt retire reports, and
6861
+ * recovery.json as written. */
6862
+ function writeRetirementRecovery(observation, meta, instance, phase) {
5969
6863
  const recoveryRoot = join(retirementStateRoot(observation.home), "recovery");
5970
6864
  mkdirSync(recoveryRoot, { recursive: true });
5971
6865
  if (observation.directory && realpathSync(recoveryRoot) !== join(realpathSync(dirname(observation.home)), ".oats-retirement", "recovery")) {
@@ -5973,98 +6867,115 @@ function preserveRetirementWork(observation, meta, instance) {
5973
6867
  }
5974
6868
  const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
5975
6869
  const recovery = join(recoveryRoot, basename(staging).slice(1));
5976
- let headUnreadable;
5977
6870
  try {
5978
- // A home-only change (notes, harness files, credentials) needs a home
5979
- // snapshot, not another copy of an otherwise disposable clean worktree.
5980
- // In-progress Git operations retain the full standalone recovery even
5981
- // when porcelain status has no changed paths.
5982
- // An orphaned work directory (no git admin entry) stays where it is, never moved or removed, and git
5983
- // cannot read it: only the home is snapshotted.
5984
- let homeOnly = observation.orphanedWork === true || (observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes"
5985
- && meta.work === "worktree" && existsSync(observation.work));
5986
- if (homeOnly && !observation.orphanedWork) {
5987
- const gitDir = execFileSync("git", ["-C", observation.work, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }).trim();
5988
- homeOnly = !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
5989
- }
6871
+ const homeOnly = homeOnlyRecovery(observation, meta);
5990
6872
  const recoveredHome = join(staging, "home");
5991
- copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
5992
- if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]), instanceHome: true }) !== fingerprintTree(recoveredHome, { instanceHome: true })) {
5993
- throw new Error("home recovery verification disagreed with the source");
5994
- }
5995
- let branchDrift, excludes, statusConfig;
5996
- if (!homeOnly && meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
5997
- const recoveredRepo = join(staging, "repo");
5998
- // The branch is derived from the worktree while it exists: an instance
5999
- // that legitimately switched branches during its task must still be
6000
- // recoverable, and the recorded spawn-time branch is only the fallback
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 };
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 });
6009
- else {
6010
- execFileSync("git", ["clone", "--no-local", "--quiet", "--no-checkout", meta.repo, recoveredRepo], { 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 });
6013
- }
6014
- const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
6015
- detachRecoveryClone(sourceGitContext, recoveredRepo);
6016
- if (existsSync(observation.work)) {
6017
- restoreStandaloneGitState(observation.work, recoveredRepo);
6018
- excludes = carryExcludes(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
6019
- statusConfig = carryStatusConfig(observation.work, recoveredRepo).map((c) => ({ repo: ".", ...c }));
6020
- for (const e of readdirSync(observation.work, { withFileTypes: true })) {
6021
- if (e.name === ".git") continue;
6022
- const dest = join(recoveredRepo, e.name);
6023
- rmSync(dest, { recursive: true, force: true });
6024
- copyTreeSafe(join(observation.work, e.name), dest);
6025
- }
6026
- const nested = materializeNestedRepositories(observation.work, recoveredRepo);
6027
- excludes.push(...nested.excludes); statusConfig.push(...nested.statusConfig);
6028
- }
6029
- if (existsSync(join(recoveredRepo, ".git", "objects", "info", "alternates"))) throw new Error("recovery clone depends on object alternates");
6030
- if (existsSync(observation.work)) {
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");
6032
- assertStatusAgrees(observation.work, recoveredRepo, "recovered Git index/status disagreed with the source", ".");
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.
6039
- const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
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");
6043
- }
6044
- if (observation.directory && observation.directoryFingerprint) {
6045
- const recoveredWork = join(staging, "work");
6046
- copyTreeSafe(observation.work, recoveredWork);
6047
- if (fingerprintTree(observation.work) !== observation.directoryFingerprint || fingerprintTree(recoveredWork) !== observation.directoryFingerprint) {
6048
- throw new Error("directory recovery verification disagreed with the inspected source");
6049
- }
6050
- }
6873
+ copyRecoveryHome(observation, recoveredHome);
6874
+ const { branchDrift, excludes, statusConfig } = homeOnly ? {} : copyRecoveryWork(observation, meta, staging);
6051
6875
  const repoCopy = homeOnly ? { copied: false, reason: "Only instance-home bytes changed; no work state requires a repository copy", source: meta.repo, branch: meta.branch } : undefined;
6052
- // What the copy cost: the untracked/ignored (or directory) outputs it carries, named.
6876
+ // What the copy cost: the home entries it carries and the untracked/ignored (or directory) outputs, named.
6877
+ const home = preservedHome(recoveredHome);
6053
6878
  const workCopied = observation.directory ? !!observation.directoryFingerprint : !homeOnly && meta.work === "worktree" && existsSync(observation.work);
6054
6879
  const outputs = workCopied ? preservedOutputs(observation.work, observation.directory) : undefined;
6055
- writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}), ...(branchDrift ? { branchDrift } : {}), ...(excludes?.length ? { excludes } : {}), ...(statusConfig?.length ? { statusConfig } : {}), ...(outputs ? { outputs } : {}) }, null, 2) + "\n", { mode: 0o600 });
6880
+ const notCopied = observation.notCopied?.length ? observation.notCopied : undefined;
6881
+ const manifest = { version: 1, phase, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}), ...(branchDrift ? { branchDrift } : {}), ...(excludes?.length ? { excludes } : {}), ...(statusConfig?.length ? { statusConfig } : {}), home, ...(outputs ? { outputs } : {}), ...(notCopied ? { notCopied } : {}) };
6882
+ writeFileSync(join(staging, "recovery.json"), JSON.stringify(manifest, null, 2) + "\n", { mode: 0o600 });
6056
6883
  const bytes = treeBytes(staging);
6057
6884
  mkdirSync(dirname(recovery), { recursive: true });
6058
6885
  renameSync(staging, recovery);
6059
- return { path: recovery, classes: observation.classes, bytes, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}) };
6886
+ return { receipt: { path: recovery, classes: observation.classes, bytes, home, ...(outputs ? { outputs } : {}), ...(repoCopy ? { repoCopy } : {}), ...(notCopied ? { notCopied } : {}) }, manifest };
6060
6887
  } catch (e) {
6061
6888
  rmSync(staging, { recursive: true, force: true });
6062
- if (e === headUnreadable) throw e;
6889
+ if (unreadableHeads.has(e)) throw e;
6063
6890
  const details = e.statusDisagreement ? { home: observation.home, statusDisagreement: e.statusDisagreement } : undefined;
6064
6891
  throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
6065
6892
  }
6066
6893
  }
6067
6894
 
6895
+ /** One complete recovery, for a caller with no retire hooks still to run
6896
+ * between the snapshot and the removal. → its receipt. */
6897
+ function preserveRetirementWork(observation, meta, instance) {
6898
+ return writeRetirementRecovery(observation, meta, instance, "complete").receipt;
6899
+ }
6900
+
6901
+ /** The post-hook check on the recovery a retirement wrote before its retire
6902
+ * hooks. Each part a hook moved (movedByHooks: the home by its bytes, the work
6903
+ * unless it is proven unchanged) is copied again, whole and verified, under
6904
+ * `after-hooks/`; nothing under the pre-hook `home/`, `repo/` or `work/` is
6905
+ * written, moved or removed. The home is not copied again merely because the
6906
+ * work is: a home whose every byte the hooks left as it was (its exact
6907
+ * digest) is in the pre-hook snapshot as it still is.
6908
+ * `after-hooks/` is staged inside the recovery under a dot-name and renamed
6909
+ * into place; an `after-hooks` entry that already exists is a failure, never
6910
+ * replaced. Then ONE atomic rewrite of recovery.json records `afterHooks` and
6911
+ * moves `phase` to "complete" (the same rewrite, without `afterHooks`, when
6912
+ * the hooks changed nothing). Until that rewrite the manifest says
6913
+ * "before-hooks", so an interrupted pass under-reports and can never read as
6914
+ * complete. A failure removes the staging, leaves the pre-hook snapshot and
6915
+ * its manifest as they were, and refuses: the caller keeps the home.
6916
+ * Directory mode: the retire hooks ran since the pre-hook pass verified that
6917
+ * the recovery sits in the owned storage beside the home, so that is verified
6918
+ * again before anything is written through its path.
6919
+ * → the receipt, updated: `classes` and `notCopied` are the union of both
6920
+ * passes, `bytes` covers the whole directory. */
6921
+ function completeRetirementRecovery({ receipt, manifest }, before, after, meta) {
6922
+ const recovery = receipt.path;
6923
+ const moved = movedByHooks(before, after);
6924
+ const wantHome = moved.home;
6925
+ const final = join(recovery, "after-hooks");
6926
+ const manifestPath = join(recovery, "recovery.json");
6927
+ const manifestTmp = join(recovery, `.recovery.json.${process.pid}.tmp`);
6928
+ const occupied = () => { try { lstatSync(final); return true; } catch { return false; } };
6929
+ const assertOwnedStorage = () => {
6930
+ if (!after.directory) return;
6931
+ let owned = false;
6932
+ try { owned = realpathSync(recovery) === join(realpathSync(dirname(after.home)), ".oats-retirement", "recovery", basename(recovery)); } catch { /* gone: not the owned storage */ }
6933
+ if (!owned) throw new Error("directory recovery storage was redirected");
6934
+ };
6935
+ let staging, manifestStarted = false;
6936
+ try {
6937
+ assertOwnedStorage();
6938
+ // Work that is not proven unchanged is copied. So is work this recovery does
6939
+ // not hold yet: a pre-hook snapshot that was home-only stands for no work,
6940
+ // so "unchanged" proves nothing about it. When the observation after the
6941
+ // hooks is not home-only by that pass's own rule, the work is copied, moved
6942
+ // or not: a class can appear from outside the work state (the baseline is
6943
+ // gone). That rule only ever adds a copy attempt here; it never skips one.
6944
+ // It asks Git, so it is decided in here: a failure refuses like any other
6945
+ // in this pass.
6946
+ const firstWorkCopy = receipt.repoCopy?.copied === false && after.classes.length > 0 && !homeOnlyRecovery(after, meta);
6947
+ const wantWork = !after.orphanedWork && (moved.work || firstWorkCopy);
6948
+ let afterHooks;
6949
+ if (wantHome || wantWork) {
6950
+ if (occupied()) throw new Error(`${final} already exists`);
6951
+ staging = mkdtempSync(join(recovery, ".after-hooks-"));
6952
+ if (wantHome) copyRecoveryHome(after, join(staging, "home"));
6953
+ const workCopied = wantWork && copyRecoveryWork(after, meta, staging).copied;
6954
+ if (wantHome || workCopied) {
6955
+ if (occupied()) throw new Error(`${final} already exists`);
6956
+ renameSync(staging, final);
6957
+ afterHooks = { home: wantHome, work: workCopied };
6958
+ } else rmSync(staging, { recursive: true, force: true });
6959
+ staging = undefined;
6960
+ }
6961
+ const classes = [...new Set([...receipt.classes, ...after.classes])];
6962
+ const notCopied = unionNotCopied(receipt.notCopied, after.notCopied);
6963
+ const { notCopied: _before, ...rest } = manifest;
6964
+ assertOwnedStorage();
6965
+ manifestStarted = true;
6966
+ writeFileSync(manifestTmp, JSON.stringify({ ...rest, phase: "complete", classes, ...(notCopied.length ? { notCopied } : {}), ...(afterHooks ? { afterHooks } : {}) }, null, 2) + "\n", { mode: 0o600 });
6967
+ renameSync(manifestTmp, manifestPath);
6968
+ const { notCopied: _was, ...kept } = receipt;
6969
+ return { ...kept, classes, bytes: treeBytes(recovery), ...(notCopied.length ? { notCopied } : {}), ...(afterHooks ? { afterHooks } : {}) };
6970
+ } catch (e) {
6971
+ if (staging) rmSync(staging, { recursive: true, force: true });
6972
+ if (manifestStarted) rmSync(manifestTmp, { force: true });
6973
+ if (unreadableHeads.has(e)) throw e;
6974
+ const details = e.statusDisagreement ? { home: after.home, statusDisagreement: e.statusDisagreement } : undefined;
6975
+ throw Object.assign(oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${after.home}; recovery could not be verified: ${e.message}`, details), details ? { details } : {});
6976
+ }
6977
+ }
6978
+
6068
6979
  /** Marker a self-retiring instance leaves BESIDE its home: the retirement is
6069
6980
  * requested and owed, and a detached completion is on its way. It is not
6070
6981
  * written into the home, so the caller changes no instance bytes and the
@@ -6361,6 +7272,7 @@ export function retireInstance(root, name, o = {}) {
6361
7272
  // `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
6362
7273
  // A no-launch instance is already quiesced. A launched one must have exact
6363
7274
  // window absence established before recovery copying begins.
7275
+ let sessionStopped = false;
6364
7276
  if (!self && runtimeAuthority?.launched) {
6365
7277
  const runtimeSession = runtimeAuthority.tmux.session;
6366
7278
  const runtimeWindow = runtimeAuthority.tmux.window;
@@ -6384,11 +7296,41 @@ export function retireInstance(root, name, o = {}) {
6384
7296
  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
7297
  }
6386
7298
  }
7299
+ sessionStopped = true;
6387
7300
  }
6388
7301
  const stableObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
6389
- const workRecoveries = [];
6390
- if (stableObservation.classes.length) workRecoveries.push(preserveRetirementWork(stableObservation, meta, name));
6391
- let workRecovery = workRecoveries.at(-1);
7302
+ // The bytes of the worktree as they are before the hooks: after the hooks,
7303
+ // "the work did not move" is then proven, never taken from a status text.
7304
+ // Read before the recovery is written, so that a worktree that cannot be
7305
+ // read (an entry that is not a file, a directory or a symbolic link)
7306
+ // refuses here and leaves no recovery behind, however often it is retried.
7307
+ // The refusal says what this retire has and has not done by now: a launched
7308
+ // instance's session was stopped above. A pre-hook copy of the work
7309
+ // verifies itself against this same read. A worktree that cannot be proven
7310
+ // unchanged is copied again in any case, and with nothing to preserve
7311
+ // before the hooks there is nothing to prove: any class after them is
7312
+ // preserved.
7313
+ // What a refusal before the hooks tells the operator about the retire so far.
7314
+ const soFar = (kept) => `${name} is not retired and ${kept}; ${sessionStopped ? "its session has been stopped" : "this retire stopped no session"}`;
7315
+ if (stableObservation.classes.length && stableObservation.workProvable) {
7316
+ try { observedWorkBytes(stableObservation); }
7317
+ catch (e) {
7318
+ throw oatsError(e.code, `${e.message}. No recovery was written and nothing was deleted: ${soFar("its home is kept")}`);
7319
+ }
7320
+ }
7321
+ // One recovery per retirement. The snapshot taken here, before the retire
7322
+ // hooks, is the gate: it is verified before any hook runs and is never
7323
+ // rewritten. What the hooks change is added to it afterwards. A copy that
7324
+ // cannot be made or verified refuses here, with the same disclosure.
7325
+ let preHookRecovery;
7326
+ if (stableObservation.classes.length) {
7327
+ try { preHookRecovery = writeRetirementRecovery(stableObservation, meta, name, "before-hooks"); }
7328
+ catch (e) {
7329
+ if (e?.code !== "E_WORK_PRESERVATION_FAILED") throw e;
7330
+ throw Object.assign(oatsError(e.code, `${e.message}. No retire hook has run, no recovery was written and nothing was deleted: ${soFar("its home and work are kept")}`, e.provenance), e.details ? { details: e.details } : {});
7331
+ }
7332
+ }
7333
+ let workRecovery = preHookRecovery?.receipt;
6392
7334
 
6393
7335
  // Capability lifecycle hooks (retire) — run BEFORE the dir (and any package state in it,
6394
7336
  // e.g. aweb signing keys) is removed. The knowledge integration harvests notes/ here;
@@ -6463,11 +7405,19 @@ export function retireInstance(root, name, o = {}) {
6463
7405
  }
6464
7406
 
6465
7407
  // Hooks are allowed to mutate the inspected tree, so inspect again after
6466
- // them and preserve a separately verified post-hook snapshot when needed.
7408
+ // them. A recovery written before the hooks gains a separately verified
7409
+ // snapshot of each part they moved, under its after-hooks/, and its manifest
7410
+ // is concluded; a home that only now has something to preserve gets its one
7411
+ // recovery here. Nothing was preserved before the hooks only when there was
7412
+ // no class then, so a class now is something new to preserve, whether it
7413
+ // came from the home, from the work or from outside both (another ref, the
7414
+ // baseline): it does not have to be a move of the observed state. Either
7415
+ // way this finishes, or refuses and keeps the home, before the worktree
7416
+ // step and before the home is removed.
6467
7417
  const finalObservation = inspectRetirementWork(found.home, workPath, inspectableWorktree, { recordedBranch, worktreeRemoval, directory, orphanedWork });
6468
- if (finalObservation.classes.length && finalObservation.stateFingerprint !== stableObservation.stateFingerprint) {
6469
- workRecoveries.push(preserveRetirementWork(finalObservation, meta, name));
6470
- workRecovery = workRecoveries.at(-1);
7418
+ if (preHookRecovery) workRecovery = completeRetirementRecovery(preHookRecovery, stableObservation, finalObservation, meta);
7419
+ else if (finalObservation.classes.length) {
7420
+ workRecovery = preserveRetirementWork({ ...finalObservation, notCopied: unionNotCopied(stableObservation.notCopied, finalObservation.notCopied) }, meta, name);
6471
7421
  }
6472
7422
 
6473
7423
  // Lineage repair: any instance pointing at the retiree (parentInstance from a
@@ -6673,7 +7623,7 @@ export function retireInstance(root, name, o = {}) {
6673
7623
  rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
6674
7624
  }
6675
7625
 
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: (() => {
7626
+ const result = { retired: name, agent: found.agent.name, workRecovery, 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: (() => {
6677
7627
  const w = [...(hookResults?.warnings || [])];
6678
7628
  if (o[OBSOLETE_DELETE_BRANCH]) w.push(OBSOLETE_DELETE_BRANCH_SENTENCE);
6679
7629
  if (isCapturedHome(meta) && !quarantine) {