@awebai/oats 0.30.3 → 0.32.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
@@ -33,6 +33,7 @@ import {
33
33
  } from "node:fs";
34
34
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
35
35
  import { accessSync, constants as fsConstants } from "node:fs";
36
+ import { recordLocalInput } from "./local-inputs.mjs";
36
37
  import { createHash, randomUUID } from "node:crypto";
37
38
  import { fileURLToPath } from "node:url";
38
39
  import { initializeNativeHistory, prepareNativeStart } from "../packages/record/lib/native-history.mjs";
@@ -41,9 +42,8 @@ import { attachSessionTarget } from "./session-viewer.mjs";
41
42
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
42
43
  import { appendEvent } from "./instance-events.mjs";
43
44
  import { killGroup } from "./process-group.mjs";
44
- import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
45
45
 
46
- import { oatsError } from "./errors.mjs";
46
+ import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
47
47
  import { envRows, recordedTeams, teamsEnv } from "./teams.mjs";
48
48
  import { harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
49
49
  async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
@@ -126,7 +126,22 @@ export function legacyCapturedHomes(root) {
126
126
  return { code: "legacy-captured-home", instances: homes.map((h) => h.instance), homes: homes.map((h) => h.home),
127
127
  message: `${homes.length} captured home${one ? "" : "s"} (0.24–0.25; the captured/portable path was removed in 0.26) ${one ? "has" : "have"} no 0.26 runtime: start, inspect and in-home commands refuse ${one ? "it" : "them"}; retire ${one ? "it" : "them"} (\`oats retire\` still works) and re-spawn from the deployment (${homes.map((h) => h.instance).join(", ")})` };
128
128
  }
129
- export const DEFAULT_TMUX_SESSION = process.env.PI_AGENTS_TMUX_SESSION || "pi-agents";
129
+ // The tmux session a new tmux instance opens its window in, when oats-local.yaml session.tmuxSession
130
+ // names none (0.31). PI_AGENTS_TMUX_SESSION is the pre-0.31 variable, still honoured. The Desktop keeps
131
+ // the same default in packages/desktop/server/tmux-status.mjs (it does not import lib/): change both.
132
+ export const DEFAULT_TMUX_SESSION = process.env.OATS_TMUX_SESSION || process.env.PI_AGENTS_TMUX_SESSION || "oats-agents";
133
+ /**
134
+ * The tmux session a NEW tmux launch opens its window in (0.31): the caller's, else the deployment's
135
+ * oats-local.yaml `session.tmuxSession`, else OATS_TMUX_SESSION, else PI_AGENTS_TMUX_SESSION, else
136
+ * oats-agents, read from `env` at call time. A launched home keeps the session it recorded: session
137
+ * start/restart never read this.
138
+ */
139
+ export function sessionDefaults(dir, { tmuxSession } = {}, env = process.env) {
140
+ let local = null;
141
+ try { local = loadLocal(dir); } catch (e) { if (e?.code !== "E_LOCAL_MISSING") throw e; }
142
+ const declared = local?.local?.session ?? {};
143
+ return { tmuxSession: tmuxSession || declared.tmuxSession || env.OATS_TMUX_SESSION || env.PI_AGENTS_TMUX_SESSION || "oats-agents" };
144
+ }
130
145
  /** Package root (this file lives in <pkg>/lib/). */
131
146
  export const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
132
147
  export const OATS_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
@@ -169,7 +184,13 @@ export function slug(s) {
169
184
  const r = String(s).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
170
185
  return r || "agent";
171
186
  }
172
- function which(bin) { return shTry(`command -v ${shq(bin)}`); }
187
+ /** `command -v <bin>`, once per (bin, PATH) in this process: the answer only changes with PATH. */
188
+ const whichMemo = new Map();
189
+ function which(bin) {
190
+ const key = `${bin}\0${process.env.PATH ?? ""}`;
191
+ if (!whichMemo.has(key)) whichMemo.set(key, shTry(`command -v ${shq(bin)}`));
192
+ return whichMemo.get(key);
193
+ }
173
194
 
174
195
  // ---------- yaml-ish ----------
175
196
  /** `__proto__` is never data in a plain-object mapping: assigning it REWRITES
@@ -511,7 +532,7 @@ export const LAYERS = ["knowledge", "messaging", "tasks"];
511
532
  // deployment's oats-local.yaml (lead decision 2: a spawn-time HOST choice,
512
533
  // never a soul field). Selected at spawn or session start/restart by name.
513
534
  export const LAUNCH_HARNESSES = ["pi", "claude", "codex"];
514
- export const LAUNCH_CONFIG_KEYS = new Set(["harness", "runtime", "executable", "args", "env", "model", "yolo"]);
535
+ export const LAUNCH_CONFIG_KEYS = new Set(["harness", "runtime", "executable", "args", "env", "model", "yolo", "default"]);
515
536
  /** A launch configuration's harness: `harness`, or `runtime`, its pre-0.27 name (0.26.0
516
537
  * deployments wrote it) — read either (lead call 6). */
517
538
  export const launchConfigHarness = (entry) => (Object.hasOwn(entry, "harness") ? entry.harness : entry.runtime);
@@ -527,7 +548,7 @@ export function validateLaunchConfig(name, entry, where) {
527
548
  if (typeof name !== "string" || !LAUNCH_CONFIG_NAME.test(name)) bad("has an invalid name (letters, digits, dot, underscore, dash; up to 64 characters)");
528
549
  if (name === "none") bad("cannot be named none: that word selects no configuration");
529
550
  if (!entry || typeof entry !== "object" || Array.isArray(entry)) bad("must be a map");
530
- for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (harness, executable, args, env, model, yolo)`);
551
+ for (const key of Object.keys(entry)) if (!LAUNCH_CONFIG_KEYS.has(key)) bad(`has an unsupported key ${JSON.stringify(key)} (harness, executable, args, env, model, yolo, default)`);
531
552
  if (Object.hasOwn(entry, "harness") && Object.hasOwn(entry, "runtime") && entry.harness !== entry.runtime) bad(`names harness ${JSON.stringify(entry.harness)} and runtime ${JSON.stringify(entry.runtime)}: \`runtime\` is the pre-0.27 name of \`harness\` — keep one`);
532
553
  if (!LAUNCH_HARNESSES.includes(launchConfigHarness(entry))) bad(`needs harness: one of ${LAUNCH_HARNESSES.join(", ")}`);
533
554
  const text = (v, what) => { if (typeof v !== "string" || !v.trim() || v.includes("\0")) bad(`${what} must be non-empty text`); };
@@ -548,12 +569,25 @@ export function validateLaunchConfig(name, entry, where) {
548
569
  }
549
570
  if (entry.model !== undefined) text(entry.model, "model");
550
571
  if (entry.yolo !== undefined && typeof entry.yolo !== "boolean") bad("yolo must be true or false");
572
+ if (entry.default !== undefined && typeof entry.default !== "boolean") bad("default must be true or false");
551
573
  return entry;
552
574
  }
575
+ /** At most one `default: true` per harness (0.32, feature launch-config-default): the host's baseline for
576
+ * that harness has one source. `map` is name → entry; entries are validated first. */
577
+ export function validateLaunchConfigDefaults(map, where) {
578
+ const byHarness = new Map();
579
+ for (const [name, entry] of Object.entries(map || {})) {
580
+ if (entry?.default !== true) continue;
581
+ const harness = launchConfigHarness(entry);
582
+ if (byHarness.has(harness)) throw Object.assign(oatsError("E_LAUNCH_CONFIG_INVALID", `launch configurations ${JSON.stringify(byHarness.get(harness))} and ${JSON.stringify(name)}${where ? ` in ${where}` : ""} are both default: true for ${harness}; keep one default per harness`), { details: { harness, configurations: [byHarness.get(harness), name] } });
583
+ byHarness.set(harness, name);
584
+ }
585
+ }
553
586
  function validateLaunchConfigs(map, file) {
554
587
  if (map === undefined) return;
555
588
  if (!map || typeof map !== "object" || Array.isArray(map)) throw oatsError("E_LAUNCH_CONFIG_INVALID", `launch-configs in ${file} must be a map of name to configuration`);
556
589
  for (const [name, entry] of Object.entries(map)) validateLaunchConfig(name, entry, file);
590
+ validateLaunchConfigDefaults(map, file);
557
591
  }
558
592
  /** The launch configurations effective at `dir`: the `launch-configs:` of the
559
593
  * oats-local.yaml found walking up from it (the deployment), validated. None
@@ -571,7 +605,7 @@ export function launchConfigsAt(dir) {
571
605
  const source = dirname(found.path);
572
606
  for (const [name, entry] of Object.entries(map || {})) {
573
607
  if (!Object.hasOwn(entry, "harness")) noteRuntimeName(`launch-configs.${name}.runtime in ${found.path}`);
574
- out[name] = { name, harness: launchConfigHarness(entry), ...(entry.executable !== undefined ? { executable: entry.executable } : {}), args: [...(entry.args || [])], env: { ...(entry.env || {}) }, ...(entry.model !== undefined ? { model: entry.model } : {}), ...(entry.yolo !== undefined ? { yolo: entry.yolo } : {}), source, shadows: [] };
608
+ out[name] = { name, harness: launchConfigHarness(entry), ...(entry.executable !== undefined ? { executable: entry.executable } : {}), args: [...(entry.args || [])], env: { ...(entry.env || {}) }, ...(entry.model !== undefined ? { model: entry.model } : {}), ...(entry.yolo !== undefined ? { yolo: entry.yolo } : {}), ...(entry.default === true ? { default: true } : {}), source, shadows: [] };
575
609
  }
576
610
  return out;
577
611
  }
@@ -844,9 +878,14 @@ export function officialPackageCatalog() {
844
878
  function readCatalogFile() {
845
879
  const file = officialCatalogFile();
846
880
  const empty = { packages: Object.create(null), capabilities: Object.create(null), file };
847
- if (!existsSync(file)) return empty;
881
+ // An OATS_PACKAGE_CATALOG override is local configuration (observation.localRevision); the bundled
882
+ // catalog belongs to the kernel install.
883
+ const override = !!process.env.OATS_PACKAGE_CATALOG;
884
+ if (!existsSync(file)) { if (override) recordLocalInput(file, null); return empty; }
848
885
  let doc;
849
- try { doc = JSON.parse(readFileSync(file, "utf8")); }
886
+ const text = readFileSync(file, "utf8");
887
+ if (override) recordLocalInput(file, text);
888
+ try { doc = JSON.parse(text); }
850
889
  catch (e) { throw oatsError("invalid-source", `broken package catalog ${file}: ${e.message}`); }
851
890
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw oatsError("invalid-source", `broken package catalog ${file}: root must be a JSON object`);
852
891
  const out = { packages: Object.create(null), capabilities: Object.create(null), file };
@@ -1233,7 +1272,7 @@ export const HARNESS_PACKAGE_MANAGERS = {
1233
1272
  * different marketplaces into one identity. */
1234
1273
  identity: (spec) => String(spec || "").trim(),
1235
1274
  safeSpec: (spec) => typeof spec === "string" && /^[a-z0-9][\w.-]*@[a-z0-9][\w.-]*$/i.test(spec),
1236
- /** The executable is CONTEXT-SELECTED (oats-claude-config may name a wrapper
1275
+ /** The executable is SELECTED by the launch (a configuration may name a wrapper
1237
1276
  * such as `claude-personal`). Probing and installing through the literal
1238
1277
  * `claude` would inspect a DIFFERENT account's plugins than the session
1239
1278
  * actually launches with — passing preflight while the real harness lacks
@@ -1834,20 +1873,21 @@ export function tmuxWindows(session = DEFAULT_TMUX_SESSION) {
1834
1873
  * Spawn an instance of `agent` (as returned by findAgent/listAgents).
1835
1874
  * o: { instance?, purpose?, name?, repo?, work?, harness?, model?, task?, taskFile?, branch?, launch?, tmuxSession? }
1836
1875
  */
1837
- /** The claude binary for a context: closest `oats-claude-config` (a one-line file
1838
- * naming the binary, e.g. "claude-personal") walking up from contextDir wins; no
1839
- * file → "claude". Local-only by design — a personal machine preference (account
1840
- * selection), never committed config; keep it out of version control. */
1841
- export function resolveClaudeBinary(contextDir) {
1876
+ /** `oats-claude-config` (a one-line file naming the claude binary, found walking up from the context) is no
1877
+ * longer read (0.32): a host names its claude executable in a default launch configuration. A file still in
1878
+ * reach of a new claude launch refuses it, naming the file and the configuration to declare instead. */
1879
+ export function legacyClaudeConfigRefusal(contextDir) {
1842
1880
  let d = resolve(contextDir);
1843
1881
  while (true) {
1844
1882
  const f = join(d, "oats-claude-config");
1845
1883
  if (existsSync(f)) {
1846
- const name = readFileSync(f, "utf8").split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#"));
1847
- if (name) return name;
1884
+ let name = null;
1885
+ try { name = readFileSync(f, "utf8").split("\n").map((l) => l.trim()).find((l) => l && !l.startsWith("#")) || null; } catch {}
1886
+ const exe = name ? `executable: ${name}, ` : "";
1887
+ return Object.assign(oatsError("E_CLAUDE_CONFIG_REMOVED", `${f} is no longer read (OATS 0.32): declare this machine's claude default in oats-local.yaml instead — launch-configs: { <name>: { harness: claude, ${exe}default: true } } (oats launch-config set <name> --file <json>) — then delete ${f}`), { details: { file: f, executable: name, fix: `declare a claude default launch configuration (${exe}default: true), then delete ${f}` } });
1848
1888
  }
1849
1889
  const parent = dirname(d);
1850
- if (parent === d) return "claude";
1890
+ if (parent === d) return null;
1851
1891
  d = parent;
1852
1892
  }
1853
1893
  }
@@ -1947,11 +1987,10 @@ function verifyHarnessPackages(harness, resolved, contextDir, { bin, env } = {})
1947
1987
  const found = [];
1948
1988
  const problems = [];
1949
1989
  // The session launches with the SELECTED executable (a configuration's,
1950
- // or the context-selected one: oats-claude-config may name
1951
- // `claude-personal`), so probing another binary would inspect a different
1990
+ // the host's harness default included), so probing another binary would inspect a different
1952
1991
  // account's packages than the instance will actually use. The probe is the
1953
1992
  // manager's controlled list subcommand; no launch argument is added to it.
1954
- const probeOpts = { context: contextDir, ...(bin ? { bin } : harness === "claude" ? { bin: resolveClaudeBinary(contextDir) } : {}) };
1993
+ const probeOpts = { context: contextDir, ...(bin ? { bin } : {}) };
1955
1994
  for (const cap of resolved.capabilities || []) {
1956
1995
  for (const raw of cap.manifest?.requires || []) {
1957
1996
  if (!raw || typeof raw !== "object") continue;
@@ -2045,8 +2084,10 @@ const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
2045
2084
 
2046
2085
  /** The executable a launch uses: a configuration's declared one (a bare name
2047
2086
  * on PATH; a path against the deployment directory when relative) or the
2048
- * harness's default (claude through oats-claude-config). Never executed. */
2087
+ * harness's name on PATH. Never executed. A new claude launch with a legacy `oats-claude-config` in reach
2088
+ * of its context is refused (E_CLAUDE_CONFIG_REMOVED, 0.32), whatever it declares. */
2049
2089
  export function resolveLaunchExecutable({ harness, declared, declaringDir, contextDir }) {
2090
+ if (harness === "claude" && contextDir) { const refused = legacyClaudeConfigRefusal(contextDir); if (refused) throw refused; }
2050
2091
  if (declared) {
2051
2092
  if (declared.includes("/")) {
2052
2093
  const path = isAbsolute(declared) ? declared : resolve(declaringDir || contextDir, declared);
@@ -2055,10 +2096,8 @@ export function resolveLaunchExecutable({ harness, declared, declaringDir, conte
2055
2096
  const found = which(declared);
2056
2097
  return { path: found || null, declared, resolvedFrom: "PATH", missing: found ? undefined : `${declared} binary not found on PATH` };
2057
2098
  }
2058
- const claudeBin = harness === "claude" ? resolveClaudeBinary(contextDir) : undefined;
2059
- const name = harness === "claude" ? claudeBin : harness;
2060
- const found = which(name);
2061
- return { path: found || null, declared: null, resolvedFrom: harness === "claude" && claudeBin !== "claude" ? "oats-claude-config" : "PATH", missing: found ? undefined : `${name} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}` };
2099
+ const found = which(harness);
2100
+ return { path: found || null, declared: null, resolvedFrom: "PATH", missing: found ? undefined : `${harness} binary not found on PATH` };
2062
2101
  }
2063
2102
  /** null when `path` is a regular executable file; otherwise why not. */
2064
2103
  export function checkLaunchExecutable(path) {
@@ -2093,7 +2132,7 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
2093
2132
  // applies the current definition.
2094
2133
  // Named or not: the recorded executable (a saved wrapper, say), args and
2095
2134
  // env are what runs; later edits of the scope never change it.
2096
- config = { name: frozen.launchConfig || null, harness: frozen.harness, ...(frozen.executableDeclared ? { executable: frozen.executableDeclared } : {}), executablePath: frozen.executable, args: [...(frozen.args || [])], env: { ...(frozen.env || {}) }, ...(frozen.model ? { model: frozen.model } : {}), ...(frozen.yolo !== undefined ? { yolo: frozen.yolo } : {}), source: frozen.launchConfigSource || null, frozen: true };
2135
+ config = { name: frozen.launchConfig || null, harness: frozen.harness, ...(frozen.executableDeclared ? { executable: frozen.executableDeclared } : {}), executablePath: frozen.executable, args: [...(frozen.args || [])], env: { ...(frozen.env || {}) }, ...(frozen.model ? { model: frozen.model } : {}), ...(frozen.yolo !== undefined ? { yolo: frozen.yolo } : {}), source: frozen.launchConfigSource || null, ...(frozen.launchConfigDefault === true ? { harnessDefault: true } : {}), frozen: true };
2097
2136
  wanted = "none";
2098
2137
  }
2099
2138
  if (wanted === undefined) wanted = frozen ? "none" : (agent?.["launch-config"] || "none");
@@ -2106,6 +2145,9 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
2106
2145
  // host default would: below the flags and a selected configuration.
2107
2146
  const harness = config?.harness || selection.harness || (frozen ? frozen.harness : preference?.harness || agent?.harness || "pi");
2108
2147
  if (!LAUNCH_HARNESSES.includes(harness)) bad("E_UNSUPPORTED_HARNESS", `unknown harness "${harness}" (pi|claude|codex)`);
2148
+ // 0.32 (feature launch-config-default): a launch that selected no configuration runs this host's default
2149
+ // for its harness, if one is declared. An explicit `--launch-config none` asks for the bare harness.
2150
+ const harnessDefault = !config && !(selection.launchConfig === "none" && !preference) ? harnessDefaultConfig(launchConfigs, harness) : null;
2109
2151
  let model, modelSource;
2110
2152
  // K6: an EXPLICIT "use the harness's native default" is distinct from an
2111
2153
  // omitted model (which inherits the configuration's or soul's preference).
@@ -2131,8 +2173,19 @@ export function resolveLaunchSelection({ launchConfigs = {}, agent, frozen, sele
2131
2173
  // is no proof it fits another one. Nothing is passed across.
2132
2174
  model = ""; modelSource = "native default (harness differs from the soul's)";
2133
2175
  } else { model = resolveModelPreference(agent?.model || "", harness); modelSource = model ? "soul default" : "native default"; }
2176
+ // The harness default's model is the last fallback: below every layer that names one, above the harness's own.
2177
+ if (harnessDefault?.model && !model && !nativeDefault) {
2178
+ model = resolveModelPreference(harnessDefault.model, harness); modelSource = `launch-config ${harnessDefault.name}`;
2179
+ if (!model) bad("E_MODEL_UNKNOWN", `launch configuration ${harnessDefault.name} (the ${harness} default) names model ${JSON.stringify(harnessDefault.model)}, which has no entry usable by harness ${harness}`);
2180
+ }
2181
+ if (harnessDefault) config = harnessDefault;
2134
2182
  return { config, harness, model, modelSource, configuredYolo: config?.yolo };
2135
2183
  }
2184
+ /** This host's default launch configuration for `harness` (`default: true`), marked `harnessDefault`, or null. */
2185
+ export function harnessDefaultConfig(launchConfigs, harness) {
2186
+ const found = Object.values(launchConfigs || {}).find((c) => c?.default === true && c.harness === harness);
2187
+ return found ? { ...found, harnessDefault: true } : null;
2188
+ }
2136
2189
  /** A home's launch layers now (feature launch-preference; --reselect-launch and inspect's launchCurrent): its
2137
2190
  * RECORDED soul's launch (the soul copy it runs) and its deployment's oats-local.yaml souls.launch. */
2138
2191
  export function homeLaunchLayers(realHome, meta) {
@@ -2172,7 +2225,11 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
2172
2225
  if (!Object.hasOwn(launchConfigs, name)) { const e = launchConfigUnknown({ name, from: choice.from, at: choice.at }); return report("pi", null, null, { code: e.code, message: e.message, fix: `declare launch configuration ${name} (oats launch-config set), or change oats-local.yaml souls.launch` }); }
2173
2226
  config = launchConfigs[name]; harness = config.harness; model = typeof config.model === "string" && config.model ? config.model : null;
2174
2227
  } else if (choice.preference) { harness = choice.preference.harness; model = choice.preference.model ?? null; }
2175
- const exe = resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
2228
+ // As a spawn: a layer naming "none" asks for the bare harness; otherwise no configuration is the harness default.
2229
+ if (!config && !(choice.selection.launchConfig === "none" && !choice.preference)) { config = harnessDefaultConfig(launchConfigs, harness); if (config && !model) model = typeof config.model === "string" && config.model ? config.model : null; }
2230
+ let exe;
2231
+ try { exe = resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir }); }
2232
+ catch (e) { if (e?.code !== "E_CLAUDE_CONFIG_REMOVED") throw e; return report(harness, model, config?.name, { code: e.code, message: e.message, fix: `declare a claude default launch configuration (oats launch-config set), then delete ${e.details.file}` }); }
2176
2233
  if (!exe.path && !config?.executable) { const e = harnessUnavailable({ harness, from: choice.from, at: choice.at, why: exe.missing }); return report(harness, model, config?.name, { code: e.code, message: e.message, fix: e.details.fix }); }
2177
2234
  if (!exe.path) return report(harness, model, config.name, { code: "E_LAUNCH_EXECUTABLE", message: `launch configuration ${config.name}: ${exe.missing}`, fix: `fix the executable of launch configuration ${config.name} (oats launch-config set)` });
2178
2235
  return report(harness, model, config?.name);
@@ -2182,14 +2239,13 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
2182
2239
  * name: the command says NAME="$OATS_LAUNCH_REF_NAME", so no source
2183
2240
  * variable is ever named in the command and no assignment in the same
2184
2241
  * prefix can shadow it (zsh evaluates a prefix's assignments in order).
2185
- * Rendered as tmux `-e` flags or a shell export prefix. */
2242
+ * Rendered as tmux `-e` flags. */
2186
2243
  export function launchEnvRefs(recipe, env = process.env) {
2187
2244
  const out = [];
2188
2245
  for (const [name, v] of Object.entries(recipe.env || {})) if (v && typeof v === "object" && v.fromEnv && env[v.fromEnv] !== undefined) out.push({ name: `${LAUNCH_REF_PREFIX}${name}`, value: env[v.fromEnv], target: name, source: v.fromEnv });
2189
2246
  return out;
2190
2247
  }
2191
2248
  export function launchEnvTmuxFlags(recipe, env) { return launchEnvRefs(recipe, env).map((r) => ` -e ${shq(`${r.name}=${r.value}`)}`).join(""); }
2192
- export function launchEnvExports(recipe, env) { return launchEnvRefs(recipe, env).map((r) => `export ${r.name}=${shq(r.value)}; `).join(""); }
2193
2249
 
2194
2250
  /** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
2195
2251
  export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
@@ -2351,7 +2407,10 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2351
2407
  if (selection.launchConfig !== undefined || selection.harness !== undefined) launchChoice = { from: "flag", at: null, declared: meta?.launchDeclared ?? null };
2352
2408
  }
2353
2409
  const { config, harness, model, modelSource } = chosen;
2354
- const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozen.yolo : agentLike?.yolo ?? resolvedCfg?.yolo));
2410
+ // A recorded yolo that came from a harness default belongs to that default: it never carries into a launch
2411
+ // the default no longer decides (a bare --launch-config none, another harness).
2412
+ const frozenYolo = frozen && !(frozen.launchConfigDefault === true && !config?.frozen) ? frozen.yolo : undefined;
2413
+ const yolo = resolveYolo(selection.yolo ?? chosen.configuredYolo ?? (frozen ? frozenYolo : agentLike?.yolo ?? resolvedCfg?.yolo));
2355
2414
  const executable = config?.frozen
2356
2415
  ? { path: config.executablePath, declared: frozen.executableDeclared ?? null, resolvedFrom: frozen.executableResolvedFrom || "recorded", missing: existsSync(config.executablePath) ? undefined : `${config.executablePath} (recorded) does not exist` }
2357
2416
  : resolveLaunchExecutable({ harness, declared: config?.executable, declaringDir: config?.source, contextDir });
@@ -2419,7 +2478,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2419
2478
  } else problems.push({ check: "harness-packages", ok: true, detail: "no harness package requirement declared for this harness; nothing probed" });
2420
2479
  }
2421
2480
  const recipe = {
2422
- version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
2481
+ version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null, ...(config?.harnessDefault ? { launchConfigDefault: true } : {}),
2423
2482
  executable: executable.path || executable.declared || harness, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
2424
2483
  args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
2425
2484
  hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT, kernelBin: kernelBin(),
@@ -2510,10 +2569,11 @@ function* spawnBody(root, agent, o = {}) {
2510
2569
  if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
2511
2570
  if (o.task !== undefined && typeof o.task !== "string") throw new Error(`task must be a string (got ${typeof o.task}) — a flag parser handing --task's next flag through shows up here`);
2512
2571
  if (o.launchConfig !== undefined && (typeof o.launchConfig !== "string" || !o.launchConfig.trim())) throw oatsError("E_BAD_ARGS", "launchConfig must be a configuration name or none");
2513
- const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
2572
+ const { tmuxSession: session } = sessionDefaults(root, { tmuxSession: o.tmuxSession });
2514
2573
  const backend = o.backend || "tmux";
2515
- if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
2516
- if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
2574
+ if (backend === "herdr") throw herdrSettingRemoved("backend herdr was given");
2575
+ if (o.herdrSocket !== undefined) throw herdrSettingRemoved("herdrSocket was given");
2576
+ if (backend !== "tmux") throw new Error(`unknown session backend "${backend}" (tmux)`);
2517
2577
  const launch = o.launch !== false;
2518
2578
  // Workspace model (`o.prepared`) + work: workspace: ./work is the deployment
2519
2579
  // boundary — the directory holding oats-local.yaml (`prepared.deployment`), which
@@ -2592,8 +2652,8 @@ function* spawnBody(root, agent, o = {}) {
2592
2652
  }
2593
2653
 
2594
2654
  // An explicit name may not be a soul name (soul and instance references stay
2595
- // unambiguous) and is taken when any soul of this deployment holds it, or — on
2596
- // the tmux backend, launched or not — a live window of the session carries it:
2655
+ // unambiguous) and is taken when any soul of this deployment holds it, or — launched
2656
+ // or not — a live tmux window of the session carries it:
2597
2657
  // a typed refusal, never a silent `-2` (the operator typed it). Checked after
2598
2658
  // key recovery, so a retried keyed spawn still reaches its own receipt; the
2599
2659
  // placement below re-checks after its reservation (a concurrent spawn of
@@ -2602,7 +2662,7 @@ function* spawnBody(root, agent, o = {}) {
2602
2662
  if (deploymentSoulNames(root, o.prepared).has(instance)) throw oatsError("E_INSTANCE_NAME_INVALID", `instance name "${instance}" is a soul name in this deployment; an instance may not share a soul's name`);
2603
2663
  const holder = deploymentInstanceHomes(root).get(instance)?.[0];
2604
2664
  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 });
2605
- if (backend === "tmux" && tmuxWindows(session).includes(instance)) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken: a live tmux window of that name exists in session ${session} — pick another --name`), { instance, session });
2665
+ if (tmuxWindows(session).includes(instance)) throw Object.assign(oatsError("E_INSTANCE_NAME_TAKEN", `instance name "${instance}" is taken: a live tmux window of that name exists in session ${session} — pick another --name`), { instance, session });
2606
2666
  }
2607
2667
 
2608
2668
  // Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
@@ -2903,10 +2963,6 @@ function* spawnBody(root, agent, o = {}) {
2903
2963
  // spawn hooks run, which happens after the home exists.
2904
2964
  if ((launchConfig?.args || []).length && applicableRequirements(harness, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(harness, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
2905
2965
  const harnessPackages = verifyHarnessPackages(harness, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
2906
- // Backend presence/startup (ensureHerdr) happens AFTER the decision fence
2907
- // and the exclusive placement reservation below — a stale or losing apply
2908
- // must not start a daemon. Preview never starts one either.
2909
- let herdrBase;
2910
2966
  if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
2911
2967
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
2912
2968
 
@@ -2974,7 +3030,7 @@ function* spawnBody(root, agent, o = {}) {
2974
3030
  subject: o.subject ?? { soul: agent.name, agentsRoot: root, context: null },
2975
3031
  decision, preflight,
2976
3032
  backendStatus: launch ? { name: backend, installed: !!which(backend), started: false } : null,
2977
- harness, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, yolo, backend,
3033
+ harness, model: model || null, modelSource: launchSelection.modelSource ?? null, launchConfig: launchConfig?.name ?? null, launchConfigDefault: launchConfig?.harnessDefault === true, yolo, backend,
2978
3034
  launch: launchReport({ declared: launchChoice.declared, harness, model, launchConfig: launchConfig?.name, from: launchChoice.from, at: launchChoice.at }),
2979
3035
  branch: plannedBranch, base: plannedBase, worktree: work === "worktree" ? join(home, "work") : null,
2980
3036
  relation: relation || null, parentInstance: parentInstance && parentInstance !== instance ? parentInstance : null,
@@ -2991,11 +3047,9 @@ function* spawnBody(root, agent, o = {}) {
2991
3047
  if (fresh.revision !== o.expectDecision) throw Object.assign(oatsError("E_DECISION_STALE", `the previewed decision changed (${o.expectDecision} → ${fresh.revision}): ${fresh.instance}${plannedBase ? ` from ${plannedBase.ref}@${plannedBase.oid.slice(0, 12)}` : ""}; preview again`), { decision: fresh });
2992
3048
  }
2993
3049
  // Backend PRESENCE is a prerequisite, checked before anything is placed (M1):
2994
- // an absent tmux/herdr binary must fail with nothing created, never after a
2995
- // populated home exists. Backend STARTUP (ensureHerdr) still waits for the
2996
- // bound decision and the exclusive placement below — a stale or losing apply
2997
- // must not start a daemon.
2998
- if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}; nothing was created`);
3050
+ // an absent tmux binary must fail with nothing created, never after a
3051
+ // populated home exists.
3052
+ if (launch && !which(backend)) throw new Error(`${backend} not installed (brew install tmux); nothing was created`);
2999
3053
  // Exclusive placement reservation: the parent may be created, the home
3000
3054
  // itself never with `recursive` — EEXIST means another spawn (a concurrent
3001
3055
  // apply of the same decision, or anything else) got here first, and this one
@@ -3101,10 +3155,6 @@ function* spawnBody(root, agent, o = {}) {
3101
3155
  }
3102
3156
  } catch (e) { throw rollbackEmptyOrPreparedHome(e); }
3103
3157
  }
3104
- // Backend startup only now — the decision is bound and the placement is ours.
3105
- try { herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined; }
3106
- catch (e) { throw rollbackEmptyOrPreparedHome(e); }
3107
-
3108
3158
  initializeNativeHistory(home);
3109
3159
 
3110
3160
  // Body: instructions are a generated instance-local view; the home carries no soul
@@ -3330,7 +3380,6 @@ function* spawnBody(root, agent, o = {}) {
3330
3380
  const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
3331
3381
  let windowMayExist = false;
3332
3382
  let spawnTmux;
3333
- let spawnHerdr;
3334
3383
  const ancillaryCleanup = [];
3335
3384
  // One compensation owner, from the first hook result through launch and
3336
3385
  // the final lineage write. Preserve the original failure and retain any
@@ -3350,23 +3399,7 @@ function* spawnBody(root, agent, o = {}) {
3350
3399
  const outstandingGit = new Set();
3351
3400
  // A failed new-window command may still have created its window. Verify
3352
3401
  // quiescence before removing credentials or work that harness may be using.
3353
- if (windowMayExist && spawnHerdr) {
3354
- try { stopHerdr(spawnHerdr); }
3355
- catch (e) {
3356
- incomplete.push(`Herdr session: ${e.message}`);
3357
- for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
3358
- if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
3359
- return quarantineInstanceHome({ home, instance, agent, soulDir: homeSoulTarget, soulId: preparedSoulId, incomplete, failed, outstandingHooks, outstandingGit,
3360
- repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
3361
- launched: true, sessionTarget: spawnHerdr, directoryHome: homeReal, recordRetirementBaseline: true });
3362
- }
3363
- }
3364
- if (windowMayExist && backend === "herdr" && !spawnHerdr) {
3365
- // Allocation starts only an empty shell; the harness command has not run.
3366
- // A lost receipt cannot authorize closing an unidentified terminal.
3367
- incomplete.push(`Herdr workspace allocation may have completed on ${herdrBase.socket} (label ${instance}, cwd ${home}); inspect and remove any empty workspace manually`);
3368
- }
3369
- if (windowMayExist && backend === "tmux") {
3402
+ if (windowMayExist) {
3370
3403
  shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
3371
3404
  const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
3372
3405
  const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
@@ -3536,7 +3569,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3536
3569
  const shimTarget = writeKernelShim(home);
3537
3570
  const recipe = {
3538
3571
  version: LAUNCH_RECIPE_VERSION, harness,
3539
- launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
3572
+ launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null, ...(launchConfig?.harnessDefault ? { launchConfigDefault: true } : {}),
3540
3573
  executable: bin, executableDeclared: executable.declared, executableResolvedFrom: executable.resolvedFrom,
3541
3574
  args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
3542
3575
  model: model || null, ...(yolo !== undefined ? { yolo } : {}),
@@ -3622,7 +3655,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3622
3655
  missingRequires: cap.missingRequires, trust: cap.trust,
3623
3656
  executable: cap.executable,
3624
3657
  })),
3625
- ...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
3658
+ tmux: { session, window: instance },
3626
3659
  launch: recipe, command: cmdline, createdAt: new Date().toISOString(),
3627
3660
  };
3628
3661
  // Workspace model: materialize recorded modules/providers (and the module
@@ -3644,16 +3677,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3644
3677
  spawnTmux = meta.tmux;
3645
3678
  if (work === "directory") assertDirectoryRoots(home, homeReal);
3646
3679
  const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
3647
- if (launch && backend === "herdr") {
3648
- windowMayExist = true;
3649
- spawnHerdr = allocateHerdr(herdrBase, { home, instance });
3650
- meta.sessionTarget = spawnHerdr;
3651
- meta.launched = true;
3652
- writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
3653
- writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
3654
- try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${executionCommand}`); }
3655
- catch (e) { throw oatsError("E_SPAWN_LAUNCH_FAILED", `Herdr could not run the launch command for ${instance} (${e.code === "ENOENT" ? "herdr unavailable" : "pane run failed"}); the command line is withheld from this message`); }
3656
- } else if (launch) {
3680
+ if (launch) {
3657
3681
  if (!tmuxAlive(session)) {
3658
3682
  const hq = existsSync(root) ? root : workspaceOf(root);
3659
3683
  sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
@@ -3715,7 +3739,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3715
3739
  meta.spawnCompleted = true;
3716
3740
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
3717
3741
  }
3718
- return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: spawnHerdr ? `HERDR_SOCKET_PATH=${shq(spawnHerdr.socket)} ${shq(spawnHerdr.binary)} terminal attach ${shq(spawnHerdr.terminalId)}` : backend === "herdr" ? "not launched" : `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
3742
+ return deliver({ ...meta, ...(o.expectDecision !== undefined ? { replayed: false } : {}), launch: redactLaunchRecipe(recipe), command: redactLaunchCommand(cmdline), attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined });
3719
3743
  } catch (error) {
3720
3744
  const note = compensateSpawn();
3721
3745
  error.message += note;
@@ -3742,8 +3766,29 @@ export function servedIdentityLine(identity) {
3742
3766
  if (identity.mode === "global" && identity.grant) return `acts as ${identity.address || identity.alias || identity.resident || "?"} via grant${identity.grant.expiresAt ? `, expires ${identity.grant.expiresAt}` : ""}`;
3743
3767
  return `alias ${identity.alias || "?"} on ${identity.team || "?"}`;
3744
3768
  }
3769
+ /** The window names of `session` on the tmux server at `socket`: `{ windows }`, empty when the server
3770
+ * or the session is gone; `{ error }` when the server could not be read. */
3771
+ function recordedTmuxWindows(socket, session) {
3772
+ try { return { windows: tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean) }; }
3773
+ catch (e) {
3774
+ if (tmuxServerLost(e) || /can't find session/i.test(String(e.stderr ?? ""))) return { windows: [] };
3775
+ return { error: String(e.stderr ?? e.message ?? "").trim() || "tmux list-windows failed" };
3776
+ }
3777
+ }
3778
+ /** A status row's liveness from its session's windows: unknown (not false) when the server could not be read. */
3779
+ function tmuxLiveness({ windows, error }, window) {
3780
+ return error ? { running: null, runtimeState: "unreachable", runtimeError: error } : { running: windows.includes(window) };
3781
+ }
3745
3782
  export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3746
- const windows = tmuxWindows(tmuxSession);
3783
+ // Liveness reads each home's RECORDED endpoint (a pre-0.31 home records pi-agents): the recorded
3784
+ // socket, never the ambient $TMUX server, once per socket and session per call, so status costs no
3785
+ // more child processes as homes grow. A home without a recorded socket reads the default server.
3786
+ const tmuxByEndpoint = new Map();
3787
+ const windowsOf = (session, socket) => {
3788
+ const key = JSON.stringify([socket ?? null, session]);
3789
+ if (!tmuxByEndpoint.has(key)) tmuxByEndpoint.set(key, socket ? recordedTmuxWindows(socket, session) : { windows: tmuxWindows(session) });
3790
+ return tmuxByEndpoint.get(key);
3791
+ };
3747
3792
  const readInstancesOf = (agentDir) => {
3748
3793
  const instancesDir = join(agentDir, "instances");
3749
3794
  // An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
@@ -3775,11 +3820,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3775
3820
  try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
3776
3821
  catch { retirePending = { reason: "retire pending" }; }
3777
3822
  }
3778
- let liveness = { running: windows.includes(meta.instance || e.name) };
3779
- if (meta.sessionTarget) {
3780
- try { const state = inspectHerdr({ ...meta.sessionTarget, binary: "herdr" }); liveness = { running: state.present, runtimeState: state.status }; }
3781
- catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
3782
- }
3823
+ // A home opened in Herdr (removed in 0.31.0) is listed, never observed: its row says so.
3824
+ const liveness = recordsHerdr(meta, readJsonOrUndefined(retirementBaselinePath(home)))
3825
+ ? { running: null, runtimeState: "unsupported", runtimeError: `E_HERDR_REMOVED: ${herdrInstanceRemoved(e.name).message}` }
3826
+ : tmuxLiveness(windowsOf(meta.tmux?.session || tmuxSession, meta.tmux?.socket), meta.tmux?.window || meta.instance || e.name);
3783
3827
  const identity = servedIdentityOf(meta);
3784
3828
  // Feature desktop-facts: `startedAt` — the last session start or restart (the receipt's restarts[]),
3785
3829
  // else the spawn's own launch (createdAt), else null (never launched); `identityAddress` — the
@@ -3960,7 +4004,7 @@ function sessionDirectoryGuard(home) {
3960
4004
 
3961
4005
  /** Retain the home and its cleanup receipt when spawn compensation or retirement
3962
4006
  * cannot finish. Keeping the original credentials makes cleanup retryable. */
3963
- function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason, directoryPreservation = false, directoryHome = realPathOrNearest(home) }) {
4007
+ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason, directoryPreservation = false, directoryHome = realPathOrNearest(home) }) {
3964
4008
  const marker = {
3965
4009
  // `reason` is optional and DEFAULTS to the spawn wording, so every existing
3966
4010
  // caller is byte-identical; only a caller that supplies one differs. The
@@ -3980,7 +4024,6 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
3980
4024
  // instance.json may be only the pre-hook stub, which records neither.
3981
4025
  ...(typeof soulDir === "string" && soulDir ? { soulDir } : {}),
3982
4026
  ...(typeof soulId === "string" && soulId ? { soulId } : {}),
3983
- ...(sessionTarget ? { sessionTarget } : {}),
3984
4027
  outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
3985
4028
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
3986
4029
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
@@ -4014,7 +4057,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
4014
4057
  if (recordRetirementBaseline) {
4015
4058
  try {
4016
4059
  if (work === "directory") assertDirectoryRoots(home, directoryHome);
4017
- writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
4060
+ writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities || [], { launched: launched === true, tmux });
4018
4061
  } catch (e) {
4019
4062
  incomplete.push(`independent retirement authority: ${e.message}`);
4020
4063
  }
@@ -4243,7 +4286,6 @@ function writeRetirementBaseline(home, work, mode, capabilities, runtime, { excl
4243
4286
  runtime: {
4244
4287
  launched: runtime?.launched === true,
4245
4288
  ...(runtime?.tmux ? { tmux: { session: runtime.tmux.session, window: runtime.tmux.window, ...(runtime.tmux.socket ? { socket: resolve(runtime.tmux.socket) } : {}) } } : {}),
4246
- ...(runtime?.sessionTarget ? { sessionTarget: runtime.sessionTarget } : {}),
4247
4289
  },
4248
4290
  };
4249
4291
  const path = retirementBaselinePath(home);
@@ -4286,23 +4328,40 @@ function branchOnlyCommits(repo, branch) {
4286
4328
  }
4287
4329
  }
4288
4330
 
4331
+ /** Herdr (removed in 0.31.0) is recognised in a home only to refuse: its instance.json records a
4332
+ * Herdr session target or `backend: "herdr"`, or its independent receipt records a session target
4333
+ * (a tmux receipt never does). Either source is enough. */
4334
+ function recordsHerdr(meta, baseline) {
4335
+ return (isPlainObject(meta) && (meta.sessionTarget !== undefined || meta.backend === "herdr"))
4336
+ || (isPlainObject(baseline?.runtime) && baseline.runtime.sessionTarget !== undefined);
4337
+ }
4338
+ function readJsonOrUndefined(path) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; } }
4339
+ /** Refuse any session operation on a home opened in Herdr, from its files as they are (either may be
4340
+ * missing or unreadable): before any receipt validation, so the refusal is never an endpoint error. */
4341
+ function refuseHerdrHome(home) {
4342
+ if (recordsHerdr(readJsonOrUndefined(join(home, "instance.json")), readJsonOrUndefined(retirementBaselinePath(home)))) throw herdrInstanceRemoved(basename(home));
4343
+ }
4344
+
4289
4345
  function runtimeAuthorityOf(baseline) {
4290
4346
  const runtime = baseline?.runtime;
4291
4347
  if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
4292
4348
  if (!runtime.launched) return { launched: false };
4293
- if (runtime.sessionTarget !== undefined) {
4294
- if (runtime.tmux || !validHerdrTarget(runtime.sessionTarget)) return undefined;
4295
- return { launched: true, sessionTarget: runtime.sessionTarget };
4296
- }
4349
+ if (runtime.sessionTarget !== undefined) return undefined;
4297
4350
  const tmux = runtime.tmux;
4298
4351
  if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
4299
4352
  return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
4300
4353
  }
4301
4354
 
4355
+ /** instance.json's tmux endpoint is the one the independent receipt authorises. */
4356
+ function tmuxEndpointAgrees(meta, authority) {
4357
+ return meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
4358
+ }
4359
+
4302
4360
  /** Session control uses the independent endpoint receipt. */
4303
4361
  function instanceSessionTarget(home) {
4304
4362
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
4305
4363
  home = realPathOrNearest(home);
4364
+ refuseHerdrHome(home);
4306
4365
  let baseline, meta;
4307
4366
  try {
4308
4367
  baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
@@ -4310,11 +4369,8 @@ function instanceSessionTarget(home) {
4310
4369
  } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
4311
4370
  const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
4312
4371
  if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
4313
- const endpointAgrees = authority.sessionTarget
4314
- ? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === authority.sessionTarget[key])
4315
- : !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
4316
- if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
4317
- return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined };
4372
+ if (meta.launched !== authority.launched || (authority.launched && !tmuxEndpointAgrees(meta, authority))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
4373
+ return { home, target: authority.launched ? { backend: "tmux", ...authority.tmux } : undefined };
4318
4374
  }
4319
4375
 
4320
4376
  export function inspectInstanceSession(home) {
@@ -4371,7 +4427,7 @@ export function processesInHome(home, io) {
4371
4427
  * AND no live process on the host works in the home (a harness started by
4372
4428
  * hand on another server is invisible to the recorded endpoint).
4373
4429
  * Anything else (the window is there, a pane or a process works in the home,
4374
- * the process scan cannot run, a Herdr target, a launch without an endpoint,
4430
+ * the process scan cannot run, a launch without an endpoint,
4375
4431
  * tmux failing otherwise) is `{ absent: false, note }`: live or ambiguous,
4376
4432
  * never killed on mutable metadata.
4377
4433
  */
@@ -4385,7 +4441,6 @@ export function observeSessionWithoutReceipt(home, meta, io) {
4385
4441
  }
4386
4442
  function recordedSessionAbsence(home, meta, io) {
4387
4443
  const tmux = meta?.tmux;
4388
- if (meta?.sessionTarget) return { absent: false, backend: "herdr", note: "instance.json records a Herdr session, which cannot be observed without the receipt" };
4389
4444
  // A --no-launch home keeps its planned tmux names with `launched: false`: nothing was started there.
4390
4445
  if (meta?.launched !== true) return { absent: true, backend: null, note: "no session receipt (a home from before 0.25.9); instance.json records no launch" };
4391
4446
  if (!tmux) return { absent: false, backend: null, note: "instance.json records a launch but no session endpoint" };
@@ -4412,7 +4467,7 @@ export function stopInstanceSession(home, o = {}) {
4412
4467
  if (!s.target) return { home: s.home, backend: null, stopped: false, alreadyIdle: true, state: "not-launched", receipt: null };
4413
4468
  let before;
4414
4469
  try { before = inspectSessionTarget(s.target); }
4415
- catch (e) { if (s.target.backend === "tmux" && tmuxServerLost(e)) before = { present: false, state: "stopped" }; else throw oatsError("E_SESSION_UNAVAILABLE", `cannot establish whether ${basename(realHome)} is running, so nothing was stopped: ${e.message}`); }
4470
+ catch (e) { if (tmuxServerLost(e)) before = { present: false, state: "stopped" }; else throw oatsError("E_SESSION_UNAVAILABLE", `cannot establish whether ${basename(realHome)} is running, so nothing was stopped: ${e.message}`); }
4416
4471
  if (!before.present || before.state === "shell" || before.state === "stopped") return { home: s.home, backend: s.target.backend, stopped: false, alreadyIdle: true, state: before.state, receipt: null };
4417
4472
  const receipt = stopHarness(s.target, { graceMs: o.graceMs ?? 20000, kill: o.io?.kill, sleep: o.io?.sleep });
4418
4473
  writeJsonAtomic(join(realHome, ".oats-stop.json"), { instance: basename(realHome), at: new Date().toISOString(), stop: receipt, retained: ["home", "work", "transcript", "launch"] });
@@ -4539,53 +4594,16 @@ function writeJsonAtomic(path, value, mode) {
4539
4594
  }
4540
4595
 
4541
4596
  // ---------- stopping a harness, selecting on an existing home ----------
4542
- const SHELL_NAMES = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "login"]);
4543
- /** The harness processes under a session target: for tmux EVERY descendant
4597
+ /** The harness processes under a tmux session target: EVERY descendant
4544
4598
  * of the pane's launcher process (the launcher shell itself is left so
4545
4599
  * that, once its command ends, it becomes the fallback shell the start
4546
4600
  * path recognizes); a wrapper that does not exec the harness, the harness
4547
- * and their children are all included, topmost first. For Herdr the
4548
- * pane's foreground processes. Each row: { pid, ppid, pgid, comm, depth }. */
4601
+ * and their children are all included, topmost first. Each row:
4602
+ * { pid, ppid, pgid, comm, depth }. */
4549
4603
  export function harnessProcesses(target, io) {
4550
4604
  const ps = () => ((io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,pgid=,comm="], { encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }))
4551
4605
  .split("\n").map((l) => l.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
4552
4606
  .map(([, pid, ppid, pgid, comm]) => ({ pid: Number(pid), ppid: Number(ppid), pgid: Number(pgid), comm: basename(comm).replace(/^-/, "") }));
4553
- if (target.backend === "herdr") {
4554
- // Herdr's PaneProcessInfo (installed schema: shell_pid, nullable;
4555
- // foreground_process_group_id; foreground_processes) names the pane's
4556
- // shell. That pid, verified against this host, is the root: OATS launched
4557
- // `exec /bin/sh -c <command>` in it, so the root is the launcher shell
4558
- // and the harness its child, exactly as under tmux; a root whose exec
4559
- // replaced it with a non-shell IS the harness and is included. Without a
4560
- // shell_pid, the verified non-shell foreground processes are the roots.
4561
- // Never a fabricated row: an unverifiable pid refuses before any signal.
4562
- const info = herdrCommand(target, ["pane", "process-info", "--pane", target.paneId], io).process_info;
4563
- if (!info || typeof info !== "object") throw oatsError("E_SESSION_UNKNOWN", "Herdr returned no process information for the pane");
4564
- const rows = ps();
4565
- const byParent = new Map();
4566
- for (const r of rows) { if (!byParent.has(r.ppid)) byParent.set(r.ppid, []); byParent.get(r.ppid).push(r); }
4567
- const out = [];
4568
- const walk = (pid, depth) => { for (const child of byParent.get(pid) || []) { if (!out.some((o) => o.pid === child.pid)) { out.push({ ...child, depth }); walk(child.pid, depth + 1); } } };
4569
- const verified = (pid, what) => {
4570
- if (!Number.isInteger(pid) || pid <= 0) throw oatsError("E_SESSION_UNKNOWN", `Herdr reported ${what} without a verifiable pid; nothing was signalled`);
4571
- const row = rows.find((r) => r.pid === pid);
4572
- if (!row) throw oatsError("E_SESSION_UNKNOWN", `Herdr reports ${what} pid ${pid} but this host has no such process; nothing was signalled`);
4573
- return row;
4574
- };
4575
- if (info.shell_pid !== null && info.shell_pid !== undefined) {
4576
- const root = verified(info.shell_pid, "the pane shell");
4577
- if (!SHELL_NAMES.has(root.comm)) out.push({ ...root, depth: 0 });
4578
- walk(root.pid, 1);
4579
- return out;
4580
- }
4581
- if (!Array.isArray(info.foreground_processes)) throw oatsError("E_SESSION_UNKNOWN", "Herdr returned neither a pane shell pid nor foreground processes");
4582
- for (const p of info.foreground_processes) {
4583
- const row = verified(p.pid, `foreground process ${p.name || "?"}`);
4584
- if (SHELL_NAMES.has(row.comm)) { walk(row.pid, 1); continue; }
4585
- if (!out.some((o) => o.pid === row.pid)) { out.push({ ...row, depth: 0 }); walk(row.pid, 1); }
4586
- }
4587
- return out;
4588
- }
4589
4607
  const row = tmuxOn(target.socket, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_pid}"], io).trim().split("\n")[0];
4590
4608
  if (!/^\d+$/.test(row || "")) throw oatsError("E_SESSION_UNKNOWN", "tmux returned no pane process id");
4591
4609
  const panePid = Number(row);
@@ -4753,6 +4771,10 @@ export function preWorkspaceHome(home, what) {
4753
4771
  }
4754
4772
  export function startInstanceSession(home, o = {}) {
4755
4773
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
4774
+ // A home opened in Herdr, or an earlier start that allocated a Herdr pane, is refused before any
4775
+ // guard or receipt reads it.
4776
+ refuseHerdrHome(realPathOrNearest(home));
4777
+ if (readJsonOrUndefined(join(realPathOrNearest(home), ".oats-start-pending.json"))?.target?.backend === "herdr") throw herdrInstanceRemoved(basename(realPathOrNearest(home)));
4756
4778
  if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
4757
4779
  const directoryGuard = sessionDirectoryGuard(home);
4758
4780
  {
@@ -4777,25 +4799,23 @@ export function startInstanceSession(home, o = {}) {
4777
4799
  // re-resolve it against. Retire still works on it.
4778
4800
  if (!isWorkspaceHome(readMeta())) throw preWorkspaceHome(realHome, "nothing was started");
4779
4801
  const lostTmuxServer = tmuxServerLost;
4780
- const launchFailure = (backend, error) => {
4802
+ const launchFailure = (error) => {
4781
4803
  // execFileSync errors embed argv (including capability environment) in
4782
4804
  // message; backend stderr can echo it too. Neither belongs in the API.
4783
4805
  const reason = error.code === "ENOENT" ? "backend executable unavailable"
4784
4806
  : error.code === "ETIMEDOUT" || error.signal === "SIGTERM" ? "backend command timed out" : "backend command failed";
4785
- return oatsError("E_SESSION_START_FAILED", `${backend} start of ${basename(realHome)} could not be confirmed (${reason}); inspect the recorded session before retrying. Launch evidence is retained in ${pendingPath}`);
4807
+ return oatsError("E_SESSION_START_FAILED", `tmux start of ${basename(realHome)} could not be confirmed (${reason}); inspect the recorded session before retrying. Launch evidence is retained in ${pendingPath}`);
4786
4808
  };
4787
4809
  // The independent receipt first (retire and session consult it), then the
4788
4810
  // mutable metadata; both tmp+rename. A failure between them is what the
4789
4811
  // pending receipt exists for.
4790
- const record = (meta, { id, backend, target, model, command, startedAt, reused, launch, harness: newHarness, yolo: newYolo, stop, nativeRecordId, hookMeta, modelFrom, launchFrom, launchAt, launchDeclared }, clearPending = true) => {
4812
+ const record = (meta, { id, target, model, command, startedAt, reused, launch, harness: newHarness, yolo: newYolo, stop, nativeRecordId, hookMeta, modelFrom, launchFrom, launchAt, launchDeclared }, clearPending = true) => {
4791
4813
  checkRoots();
4792
4814
  const baselinePath = retirementBaselinePath(realHome);
4793
4815
  let baseline;
4794
4816
  try { baseline = JSON.parse(readFileSync(baselinePath, "utf8")); } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or unreadable for ${realHome}: ${e.message}`); }
4795
4817
  if (baseline.version !== RETIRE_BASELINE_VERSION || baseline.home !== realHome || !runtimeAuthorityOf(baseline)) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is invalid for ${realHome}`);
4796
- baseline.runtime = backend === "herdr"
4797
- ? { launched: true, sessionTarget: target }
4798
- : { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
4818
+ baseline.runtime = { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
4799
4819
  writeJsonAtomic(baselinePath, baseline, 0o600);
4800
4820
  if (o.io?.failBeforeMetadataWrite && reused !== "adopted") throw new Error("injected metadata write failure"); // the write after THIS start's allocation
4801
4821
  const recorded = meta.startId === id;
@@ -4807,11 +4827,10 @@ export function startInstanceSession(home, o = {}) {
4807
4827
  // Launch-hook meta lands per capability over the spawn's record; a hook
4808
4828
  // that answered without meta keeps its previous entry (retire reads it).
4809
4829
  ...(hookMeta ? { capabilityMeta: { ...(meta.capabilityMeta || {}), ...hookMeta } } : {}) };
4810
- if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
4811
- else { next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) }; delete next.sessionTarget; }
4830
+ next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) };
4812
4831
  writeJsonAtomic(metaPath, next);
4813
4832
  if (clearPending) { checkRoots(); rmSync(pendingPath, { force: true }); }
4814
- return { instance: meta.instance, agent: meta.agent, home: realHome, harness: next.harness, backend, model: model ?? null, launchConfig: next.launch?.launchConfig ?? null, yolo: next.yolo ?? null, target, startedAt, restartCount: next.restartCount, reused, warnings: [], ...(nativeRecordId ? { nativeRecordId } : {}), ...(stop ? { stop } : {}) };
4833
+ return { instance: meta.instance, agent: meta.agent, home: realHome, harness: next.harness, backend: "tmux", model: model ?? null, launchConfig: next.launch?.launchConfig ?? null, yolo: next.yolo ?? null, target, startedAt, restartCount: next.restartCount, reused, warnings: [], ...(nativeRecordId ? { nativeRecordId } : {}), ...(stop ? { stop } : {}) };
4815
4834
  };
4816
4835
  try { mkdirSync(lock); }
4817
4836
  catch (e) {
@@ -4829,8 +4848,7 @@ export function startInstanceSession(home, o = {}) {
4829
4848
  let pending;
4830
4849
  try { pending = JSON.parse(readFileSync(pendingPath, "utf8")); } catch { pending = undefined; }
4831
4850
  const pt = pending?.target;
4832
- const validTarget = pt?.backend === "herdr" ? validHerdrTarget(pt)
4833
- : pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
4851
+ const validTarget = pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
4834
4852
  let validCommand = false;
4835
4853
  try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
4836
4854
  let validLaunch = true;
@@ -4842,13 +4860,12 @@ export function startInstanceSession(home, o = {}) {
4842
4860
  && (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
4843
4861
  && typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
4844
4862
  if (!validReceipt) throw oatsError("E_SESSION_UNKNOWN", `an earlier start left an unreadable or invalid receipt at ${pendingPath}; inspect it before retrying; nothing was started`);
4845
- const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
4846
4863
  let st;
4847
4864
  checkRoots();
4848
4865
  try { st = inspectSessionTarget(pending.target, o.io); }
4849
4866
  catch (e) {
4850
- if (pbackend === "tmux" && lostTmuxServer(e)) st = { present: false, state: "stopped" };
4851
- else throw oatsError("E_SESSION_UNKNOWN", `an earlier start of ${basename(realHome)} recorded a session (${pbackend === "herdr" ? `Herdr pane ${pending.target.paneId}` : `tmux ${pending.target.session}:${pending.target.window} on ${pending.target.socket}`}) that cannot be observed now: ${String(e.stderr ?? e.message ?? "").trim() || e.message}; the receipt ${pendingPath} is kept and nothing was started`);
4867
+ if (lostTmuxServer(e)) st = { present: false, state: "stopped" };
4868
+ else throw oatsError("E_SESSION_UNKNOWN", `an earlier start of ${basename(realHome)} recorded a session (tmux ${pending.target.session}:${pending.target.window} on ${pending.target.socket}) that cannot be observed now: ${String(e.stderr ?? e.message ?? "").trim() || e.message}; the receipt ${pendingPath} is kept and nothing was started`);
4852
4869
  }
4853
4870
  // A launch may still consist entirely of shells (startup files, a
4854
4871
  // shell-script harness). Only its own completion marker proves this
@@ -4860,7 +4877,7 @@ export function startInstanceSession(home, o = {}) {
4860
4877
  // Reconcile even an exited target: the independent baseline may
4861
4878
  // already name it while metadata still names the old allocation.
4862
4879
  const meta = readMeta();
4863
- const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
4880
+ const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
4864
4881
  if (st.present && st.state !== "shell") {
4865
4882
  if (o.restart) { rmSync(pendingPath, { force: true }); }
4866
4883
  else {
@@ -4878,7 +4895,6 @@ export function startInstanceSession(home, o = {}) {
4878
4895
  const meta = readMeta();
4879
4896
  const harness = meta.harness;
4880
4897
  if (!["pi", "claude", "codex"].includes(harness)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `instance ${meta.instance || realHome} records harness ${JSON.stringify(harness)}, which this kernel cannot relaunch`);
4881
- const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
4882
4898
  let command = meta.command;
4883
4899
  let model = meta.model || undefined;
4884
4900
  // What this start launches: the frozen command (optionally with another
@@ -4922,7 +4938,6 @@ export function startInstanceSession(home, o = {}) {
4922
4938
  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`); }
4923
4939
  const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
4924
4940
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
4925
- const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
4926
4941
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
4927
4942
  const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
4928
4943
  ...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
@@ -4933,7 +4948,7 @@ export function startInstanceSession(home, o = {}) {
4933
4948
  if (target) {
4934
4949
  try { state = inspectSessionTarget(target, o.io); }
4935
4950
  catch (e) {
4936
- if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
4951
+ if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
4937
4952
  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}`);
4938
4953
  }
4939
4954
  }
@@ -4947,7 +4962,7 @@ export function startInstanceSession(home, o = {}) {
4947
4962
  writeJsonAtomic(join(realHome, ".oats-restart.json"), { instance: meta.instance, at: new Date().toISOString(), stop: stopReceipt, next: { harness: launchPlan?.harness || harness, launchConfig: launchPlan?.recipe?.launchConfig ?? meta.launch?.launchConfig ?? null, model: model ?? null } }, 0o600);
4948
4963
  appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
4949
4964
  if (!stopReceipt.exited) throw oatsError("E_SESSION_STOP_FAILED", `${meta.instance} was asked to stop (${stopReceipt.signal} to ${stopReceipt.requested.map((r) => `${r.comm} pid ${r.pid}`).join(", ")} at ${stopReceipt.sentAt}) and was still running after ${stopReceipt.waitedMs} ms (${stopReceipt.state}); nothing was escalated and nothing was started; stop it yourself, or retry with a longer --stop-grace. Receipt: ${join(realHome, ".oats-restart.json")}`);
4950
- try { state = inspectSessionTarget(target, o.io); } catch (e) { if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
4965
+ try { state = inspectSessionTarget(target, o.io); } catch (e) { if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; } else throw oatsError("E_SESSION_UNKNOWN", `after the stop, cannot establish the state of ${meta.instance}: ${String(e.stderr ?? e.message ?? "").trim() || e.message}`); }
4951
4966
  if (state.present && state.state !== "shell") throw oatsError("E_SESSION_UNKNOWN", `${meta.instance} read as stopped and then as ${state.state} again; nothing was started`);
4952
4967
  }
4953
4968
  // The kernel that launches the harness is the one the agent's plain `oats` runs.
@@ -4959,79 +4974,64 @@ export function startInstanceSession(home, o = {}) {
4959
4974
  const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.harness || harness);
4960
4975
  const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
4961
4976
  let reused = "new";
4962
- if (backend === "herdr") {
4963
- if (!target) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
4964
- const base = { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
4965
- if (state.present) { reused = "pane"; }
4966
- else {
4967
- try { herdrSnapshot(base, o.io); }
4968
- catch (e) { throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`); }
4969
- target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io);
4970
- }
4977
+ const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
4978
+ const window = target?.window || meta.tmux?.window || meta.instance;
4979
+ let socket = target?.socket || meta.tmux?.socket;
4980
+ const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
4981
+ // A fallback shell (no harness descendant) or a retained dead pane is
4982
+ // the agent's own pane: the command runs there, no other window touched.
4983
+ const inPlace = state.paneId && (state.present || state.state === "stopped");
4984
+ if (inPlace) {
4971
4985
  checkRoots();
4972
4986
  writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
4973
- try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
4974
- catch (e) { throw launchFailure("Herdr", e); }
4987
+ try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
4988
+ catch (e) { throw launchFailure(e); }
4989
+ reused = "pane";
4975
4990
  } else {
4976
- const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
4977
- const window = target?.window || meta.tmux?.window || meta.instance;
4978
- let socket = target?.socket || meta.tmux?.socket;
4979
- const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
4980
- // A fallback shell (no harness descendant) or a retained dead pane is
4981
- // the agent's own pane: the command runs there, no other window touched.
4982
- const inPlace = state.paneId && (state.present || state.state === "stopped");
4983
- if (inPlace) {
4984
- checkRoots();
4985
- writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
4986
- try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
4987
- catch (e) { throw launchFailure("tmux", e); }
4988
- reused = "pane";
4989
- } else {
4990
- const instancesRoot = dirname(realHome);
4991
- const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
4992
- checkRoots();
4993
- if (!socket) {
4994
- // Never launched (--no-launch): the default server, as spawn uses.
4995
- const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
4996
- let alive = false;
4997
- try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
4998
- if (!alive) {
4999
- defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
5000
- for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
5001
- try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
5002
- }
4991
+ const instancesRoot = dirname(realHome);
4992
+ const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
4993
+ checkRoots();
4994
+ if (!socket) {
4995
+ // Never launched (--no-launch): the default server, as spawn uses.
4996
+ const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
4997
+ let alive = false;
4998
+ try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
4999
+ if (!alive) {
5000
+ defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
5001
+ for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
5002
+ try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
5003
5003
  }
5004
- socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
5005
- if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
5006
- } else if (serverGone) {
5007
- // The recorded server is gone (a reboot): the same socket path again.
5008
- mkdirSync(dirname(socket), { recursive: true });
5009
- tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
5010
- tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
5011
- tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
5012
5004
  }
5013
- let names = [];
5014
- try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
5015
- catch (e) {
5016
- if (!/can't find session/i.test(String(e.stderr ?? e.message ?? "")) && !lostTmuxServer(e)) throw oatsError("E_SESSION_UNKNOWN", `cannot list tmux windows on ${socket}: ${String(e.stderr ?? e.message ?? "").trim()}`);
5017
- tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
5018
- }
5019
- if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
5020
- target = { backend: "tmux", session, window, socket: resolve(socket) };
5021
- checkRoots();
5022
- writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
5023
- try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
5024
- catch (e) { throw launchFailure("tmux", e); }
5005
+ socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
5006
+ if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
5007
+ } else if (serverGone) {
5008
+ // The recorded server is gone (a reboot): the same socket path again.
5009
+ mkdirSync(dirname(socket), { recursive: true });
5010
+ tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
5011
+ tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
5012
+ tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
5013
+ }
5014
+ let names = [];
5015
+ try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
5016
+ catch (e) {
5017
+ if (!/can't find session/i.test(String(e.stderr ?? e.message ?? "")) && !lostTmuxServer(e)) throw oatsError("E_SESSION_UNKNOWN", `cannot list tmux windows on ${socket}: ${String(e.stderr ?? e.message ?? "").trim()}`);
5018
+ tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
5025
5019
  }
5020
+ if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
5026
5021
  target = { backend: "tmux", session, window, socket: resolve(socket) };
5022
+ checkRoots();
5023
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
5024
+ try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
5025
+ catch (e) { throw launchFailure(e); }
5027
5026
  }
5027
+ target = { backend: "tmux", session, window, socket: resolve(socket) };
5028
5028
  // Keep launch evidence until the command exits or the target disappears.
5029
5029
  // A transient child (for example cat TASK.md) is not proof that startup
5030
5030
  // has finished. A later start reconciles the receipt without a watcher.
5031
- try { return { ...record(meta, { id, backend, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
5031
+ try { return { ...record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
5032
5032
  catch (e) {
5033
5033
  if (e.code && String(e.code).startsWith("E_")) throw e;
5034
- throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (${backend === "herdr" ? `Herdr pane ${target.paneId}` : `tmux ${target.session}:${target.window} on ${target.socket}`}) but its metadata could not be recorded: ${e.message}; the actual target is kept in ${pendingPath} and the next start adopts it instead of allocating another`);
5034
+ throw oatsError("E_SESSION_START_INCOMPLETE", `${meta.instance} was started (tmux ${target.session}:${target.window} on ${target.socket}) but its metadata could not be recorded: ${e.message}; the actual target is kept in ${pendingPath} and the next start adopts it instead of allocating another`);
5035
5035
  }
5036
5036
  } finally {
5037
5037
  // A hook may have replaced the home itself. Never follow that replacement
@@ -5589,7 +5589,10 @@ export function retireInstance(root, name, o = {}) {
5589
5589
  // persists the intent and hands the whole retirement to a detached process
5590
5590
  // that runs it as an ordinary EXTERNAL retirement once the harness is gone
5591
5591
  // (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
5592
- if (self && (!o.keepDir || meta.sessionTarget)) {
5592
+ // A home opened in Herdr (removed in 0.31.0) has no endpoint OATS can quiesce in-process. A
5593
+ // quarantined home's cleanup descriptor overrides the live record's endpoint: either may name it.
5594
+ const herdrHome = recordsHerdr(meta, readJsonOrUndefined(retirementBaselinePath(found.home))) || recordsHerdr(liveMeta);
5595
+ if (self && (!o.keepDir || herdrHome)) {
5593
5596
  return scheduleDeferredSelfRetirement(root, found, name, o, session);
5594
5597
  }
5595
5598
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
@@ -5600,7 +5603,13 @@ export function retireInstance(root, name, o = {}) {
5600
5603
  // describe it for humans, but only the independent baseline can authorize the
5601
5604
  // endpoint that proves quiescence.
5602
5605
  let runtimeAuthority;
5603
- if (liveMeta || quarantine) {
5606
+ if (herdrHome) {
5607
+ // Nothing to quiesce without Herdr: the retirement goes on only when no process works in the
5608
+ // home, exactly as for a session observed absent.
5609
+ const scan = processesInHome(found.home);
5610
+ if (!scan.ok) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot retire ${name}: it was opened in Herdr (${HERDR_REMOVED}), and whether a process still works in its home could not be established (${scan.error}); nothing was removed`);
5611
+ if (scan.processes.length) throw herdrInstanceBusy(name, scan.processes.map((p) => p.pid));
5612
+ } else if (liveMeta || quarantine) {
5604
5613
  runtimeAuthority = initialObservation.runtimeAuthority;
5605
5614
  // A home without its receipt (spawned before 0.25.9) has nothing OATS may
5606
5615
  // quiesce; when its session is observably absent, quiescing is vacuous and
@@ -5613,12 +5622,7 @@ export function retireInstance(root, name, o = {}) {
5613
5622
  }
5614
5623
  }
5615
5624
  if (runtimeAuthority) {
5616
- const endpointAgrees = runtimeAuthority.sessionTarget
5617
- ? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === runtimeAuthority.sessionTarget[key])
5618
- : !meta.sessionTarget && meta.tmux?.session === runtimeAuthority.tmux?.session
5619
- && meta.tmux?.window === runtimeAuthority.tmux?.window
5620
- && resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux?.socket;
5621
- const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || endpointAgrees);
5625
+ const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || tmuxEndpointAgrees(meta, runtimeAuthority));
5622
5626
  if (!metaAgrees) {
5623
5627
  throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
5624
5628
  }
@@ -5626,11 +5630,7 @@ export function retireInstance(root, name, o = {}) {
5626
5630
  // `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
5627
5631
  // A no-launch instance is already quiesced. A launched one must have exact
5628
5632
  // window absence established before recovery copying begins.
5629
- if (!self && runtimeAuthority?.launched && runtimeAuthority.sessionTarget) {
5630
- try { stopHerdr(runtimeAuthority.sessionTarget); }
5631
- catch (e) { throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that Herdr session for ${name} stopped: ${e.message}`); }
5632
- }
5633
- if (!self && runtimeAuthority?.launched && !runtimeAuthority.sessionTarget) {
5633
+ if (!self && runtimeAuthority?.launched) {
5634
5634
  const runtimeSession = runtimeAuthority.tmux.session;
5635
5635
  const runtimeWindow = runtimeAuthority.tmux.window;
5636
5636
  const runtimeSocket = runtimeAuthority.tmux.socket;
@@ -5930,7 +5930,10 @@ export function retireInstance(root, name, o = {}) {
5930
5930
  // The caller is the instance: its process lives in the window we are about to
5931
5931
  // kill. Detach the kill so this function can return and the caller can report
5932
5932
  // before dying. The delay is the caller's window to print its last words.
5933
- shTry(`tmux run-shell -b 'sleep ${o.selfKillDelaySec ?? 8}; tmux kill-window -t ${shq(`=${session}:=${name}`)} 2>/dev/null || true'`);
5933
+ // The window the home RECORDED (a pre-0.31 home lives in pi-agents), never the default for new ones.
5934
+ const killSession = runtimeAuthority?.tmux?.session || meta?.tmux?.session || session;
5935
+ const killWindow = runtimeAuthority?.tmux?.window || meta?.tmux?.window || name;
5936
+ shTry(`tmux run-shell -b 'sleep ${o.selfKillDelaySec ?? 8}; tmux kill-window -t ${shq(`=${killSession}:=${killWindow}`)} 2>/dev/null || true'`);
5934
5937
  result.selfKillScheduled = true;
5935
5938
  }
5936
5939
  return result;