@awebai/oats 0.30.3 → 0.31.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
@@ -41,9 +41,8 @@ import { attachSessionTarget } from "./session-viewer.mjs";
41
41
  import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
42
42
  import { appendEvent } from "./instance-events.mjs";
43
43
  import { killGroup } from "./process-group.mjs";
44
- import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget, herdrSnapshot, herdrCommand } from "./herdr.mjs";
45
44
 
46
- import { oatsError } from "./errors.mjs";
45
+ import { oatsError, herdrInstanceBusy, herdrInstanceRemoved, herdrSettingRemoved, HERDR_REMOVED } from "./errors.mjs";
47
46
  import { envRows, recordedTeams, teamsEnv } from "./teams.mjs";
48
47
  import { harnessUnavailable, launchConfigUnknown, launchLayers, launchReport, selectionFrom } from "./launch-preference.mjs";
49
48
  async function materializePreparedDefault(prepared, home) { const m = await import("./instance-resolution.mjs"); return m.materializePrepared(prepared, home); }
@@ -126,7 +125,22 @@ export function legacyCapturedHomes(root) {
126
125
  return { code: "legacy-captured-home", instances: homes.map((h) => h.instance), homes: homes.map((h) => h.home),
127
126
  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
127
  }
129
- export const DEFAULT_TMUX_SESSION = process.env.PI_AGENTS_TMUX_SESSION || "pi-agents";
128
+ // The tmux session a new tmux instance opens its window in, when oats-local.yaml session.tmuxSession
129
+ // names none (0.31). PI_AGENTS_TMUX_SESSION is the pre-0.31 variable, still honoured. The Desktop keeps
130
+ // the same default in packages/desktop/server/tmux-status.mjs (it does not import lib/): change both.
131
+ export const DEFAULT_TMUX_SESSION = process.env.OATS_TMUX_SESSION || process.env.PI_AGENTS_TMUX_SESSION || "oats-agents";
132
+ /**
133
+ * The tmux session a NEW tmux launch opens its window in (0.31): the caller's, else the deployment's
134
+ * oats-local.yaml `session.tmuxSession`, else OATS_TMUX_SESSION, else PI_AGENTS_TMUX_SESSION, else
135
+ * oats-agents, read from `env` at call time. A launched home keeps the session it recorded: session
136
+ * start/restart never read this.
137
+ */
138
+ export function sessionDefaults(dir, { tmuxSession } = {}, env = process.env) {
139
+ let local = null;
140
+ try { local = loadLocal(dir); } catch (e) { if (e?.code !== "E_LOCAL_MISSING") throw e; }
141
+ const declared = local?.local?.session ?? {};
142
+ return { tmuxSession: tmuxSession || declared.tmuxSession || env.OATS_TMUX_SESSION || env.PI_AGENTS_TMUX_SESSION || "oats-agents" };
143
+ }
130
144
  /** Package root (this file lives in <pkg>/lib/). */
131
145
  export const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
132
146
  export const OATS_VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
@@ -2182,14 +2196,13 @@ export function launchReportFor({ layers, launchConfigs = {}, contextDir }) {
2182
2196
  * name: the command says NAME="$OATS_LAUNCH_REF_NAME", so no source
2183
2197
  * variable is ever named in the command and no assignment in the same
2184
2198
  * prefix can shadow it (zsh evaluates a prefix's assignments in order).
2185
- * Rendered as tmux `-e` flags or a shell export prefix. */
2199
+ * Rendered as tmux `-e` flags. */
2186
2200
  export function launchEnvRefs(recipe, env = process.env) {
2187
2201
  const out = [];
2188
2202
  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
2203
  return out;
2190
2204
  }
2191
2205
  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
2206
 
2194
2207
  /** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
2195
2208
  export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
@@ -2510,10 +2523,11 @@ function* spawnBody(root, agent, o = {}) {
2510
2523
  if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
2511
2524
  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
2525
  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;
2526
+ const { tmuxSession: session } = sessionDefaults(root, { tmuxSession: o.tmuxSession });
2514
2527
  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");
2528
+ if (backend === "herdr") throw herdrSettingRemoved("backend herdr was given");
2529
+ if (o.herdrSocket !== undefined) throw herdrSettingRemoved("herdrSocket was given");
2530
+ if (backend !== "tmux") throw new Error(`unknown session backend "${backend}" (tmux)`);
2517
2531
  const launch = o.launch !== false;
2518
2532
  // Workspace model (`o.prepared`) + work: workspace: ./work is the deployment
2519
2533
  // boundary — the directory holding oats-local.yaml (`prepared.deployment`), which
@@ -2592,8 +2606,8 @@ function* spawnBody(root, agent, o = {}) {
2592
2606
  }
2593
2607
 
2594
2608
  // 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:
2609
+ // unambiguous) and is taken when any soul of this deployment holds it, or — launched
2610
+ // or not — a live tmux window of the session carries it:
2597
2611
  // a typed refusal, never a silent `-2` (the operator typed it). Checked after
2598
2612
  // key recovery, so a retried keyed spawn still reaches its own receipt; the
2599
2613
  // placement below re-checks after its reservation (a concurrent spawn of
@@ -2602,7 +2616,7 @@ function* spawnBody(root, agent, o = {}) {
2602
2616
  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
2617
  const holder = deploymentInstanceHomes(root).get(instance)?.[0];
2604
2618
  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 });
2619
+ 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
2620
  }
2607
2621
 
2608
2622
  // Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
@@ -2903,10 +2917,6 @@ function* spawnBody(root, agent, o = {}) {
2903
2917
  // spawn hooks run, which happens after the home exists.
2904
2918
  if ((launchConfig?.args || []).length && applicableRequirements(harness, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(harness, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
2905
2919
  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
2920
  if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
2911
2921
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
2912
2922
 
@@ -2991,11 +3001,9 @@ function* spawnBody(root, agent, o = {}) {
2991
3001
  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
3002
  }
2993
3003
  // 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`);
3004
+ // an absent tmux binary must fail with nothing created, never after a
3005
+ // populated home exists.
3006
+ if (launch && !which(backend)) throw new Error(`${backend} not installed (brew install tmux); nothing was created`);
2999
3007
  // Exclusive placement reservation: the parent may be created, the home
3000
3008
  // itself never with `recursive` — EEXIST means another spawn (a concurrent
3001
3009
  // apply of the same decision, or anything else) got here first, and this one
@@ -3101,10 +3109,6 @@ function* spawnBody(root, agent, o = {}) {
3101
3109
  }
3102
3110
  } catch (e) { throw rollbackEmptyOrPreparedHome(e); }
3103
3111
  }
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
3112
  initializeNativeHistory(home);
3109
3113
 
3110
3114
  // Body: instructions are a generated instance-local view; the home carries no soul
@@ -3330,7 +3334,6 @@ function* spawnBody(root, agent, o = {}) {
3330
3334
  const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
3331
3335
  let windowMayExist = false;
3332
3336
  let spawnTmux;
3333
- let spawnHerdr;
3334
3337
  const ancillaryCleanup = [];
3335
3338
  // One compensation owner, from the first hook result through launch and
3336
3339
  // the final lineage write. Preserve the original failure and retain any
@@ -3350,23 +3353,7 @@ function* spawnBody(root, agent, o = {}) {
3350
3353
  const outstandingGit = new Set();
3351
3354
  // A failed new-window command may still have created its window. Verify
3352
3355
  // 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") {
3356
+ if (windowMayExist) {
3370
3357
  shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
3371
3358
  const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
3372
3359
  const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
@@ -3622,7 +3609,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3622
3609
  missingRequires: cap.missingRequires, trust: cap.trust,
3623
3610
  executable: cap.executable,
3624
3611
  })),
3625
- ...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
3612
+ tmux: { session, window: instance },
3626
3613
  launch: recipe, command: cmdline, createdAt: new Date().toISOString(),
3627
3614
  };
3628
3615
  // Workspace model: materialize recorded modules/providers (and the module
@@ -3644,16 +3631,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3644
3631
  spawnTmux = meta.tmux;
3645
3632
  if (work === "directory") assertDirectoryRoots(home, homeReal);
3646
3633
  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) {
3634
+ if (launch) {
3657
3635
  if (!tmuxAlive(session)) {
3658
3636
  const hq = existsSync(root) ? root : workspaceOf(root);
3659
3637
  sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
@@ -3715,7 +3693,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3715
3693
  meta.spawnCompleted = true;
3716
3694
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
3717
3695
  }
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 });
3696
+ 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
3697
  } catch (error) {
3720
3698
  const note = compensateSpawn();
3721
3699
  error.message += note;
@@ -3742,8 +3720,29 @@ export function servedIdentityLine(identity) {
3742
3720
  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
3721
  return `alias ${identity.alias || "?"} on ${identity.team || "?"}`;
3744
3722
  }
3723
+ /** The window names of `session` on the tmux server at `socket`: `{ windows }`, empty when the server
3724
+ * or the session is gone; `{ error }` when the server could not be read. */
3725
+ function recordedTmuxWindows(socket, session) {
3726
+ try { return { windows: tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"]).split("\n").filter(Boolean) }; }
3727
+ catch (e) {
3728
+ if (tmuxServerLost(e) || /can't find session/i.test(String(e.stderr ?? ""))) return { windows: [] };
3729
+ return { error: String(e.stderr ?? e.message ?? "").trim() || "tmux list-windows failed" };
3730
+ }
3731
+ }
3732
+ /** A status row's liveness from its session's windows: unknown (not false) when the server could not be read. */
3733
+ function tmuxLiveness({ windows, error }, window) {
3734
+ return error ? { running: null, runtimeState: "unreachable", runtimeError: error } : { running: windows.includes(window) };
3735
+ }
3745
3736
  export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3746
- const windows = tmuxWindows(tmuxSession);
3737
+ // Liveness reads each home's RECORDED endpoint (a pre-0.31 home records pi-agents): the recorded
3738
+ // socket, never the ambient $TMUX server, once per socket and session per call, so status costs no
3739
+ // more child processes as homes grow. A home without a recorded socket reads the default server.
3740
+ const tmuxByEndpoint = new Map();
3741
+ const windowsOf = (session, socket) => {
3742
+ const key = JSON.stringify([socket ?? null, session]);
3743
+ if (!tmuxByEndpoint.has(key)) tmuxByEndpoint.set(key, socket ? recordedTmuxWindows(socket, session) : { windows: tmuxWindows(session) });
3744
+ return tmuxByEndpoint.get(key);
3745
+ };
3747
3746
  const readInstancesOf = (agentDir) => {
3748
3747
  const instancesDir = join(agentDir, "instances");
3749
3748
  // An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
@@ -3775,11 +3774,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3775
3774
  try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
3776
3775
  catch { retirePending = { reason: "retire pending" }; }
3777
3776
  }
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
- }
3777
+ // A home opened in Herdr (removed in 0.31.0) is listed, never observed: its row says so.
3778
+ const liveness = recordsHerdr(meta, readJsonOrUndefined(retirementBaselinePath(home)))
3779
+ ? { running: null, runtimeState: "unsupported", runtimeError: `E_HERDR_REMOVED: ${herdrInstanceRemoved(e.name).message}` }
3780
+ : tmuxLiveness(windowsOf(meta.tmux?.session || tmuxSession, meta.tmux?.socket), meta.tmux?.window || meta.instance || e.name);
3783
3781
  const identity = servedIdentityOf(meta);
3784
3782
  // Feature desktop-facts: `startedAt` — the last session start or restart (the receipt's restarts[]),
3785
3783
  // else the spawn's own launch (createdAt), else null (never launched); `identityAddress` — the
@@ -3960,7 +3958,7 @@ function sessionDirectoryGuard(home) {
3960
3958
 
3961
3959
  /** Retain the home and its cleanup receipt when spawn compensation or retirement
3962
3960
  * 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) }) {
3961
+ 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
3962
  const marker = {
3965
3963
  // `reason` is optional and DEFAULTS to the spawn wording, so every existing
3966
3964
  // caller is byte-identical; only a caller that supplies one differs. The
@@ -3980,7 +3978,6 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
3980
3978
  // instance.json may be only the pre-hook stub, which records neither.
3981
3979
  ...(typeof soulDir === "string" && soulDir ? { soulDir } : {}),
3982
3980
  ...(typeof soulId === "string" && soulId ? { soulId } : {}),
3983
- ...(sessionTarget ? { sessionTarget } : {}),
3984
3981
  outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
3985
3982
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
3986
3983
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
@@ -4014,7 +4011,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
4014
4011
  if (recordRetirementBaseline) {
4015
4012
  try {
4016
4013
  if (work === "directory") assertDirectoryRoots(home, directoryHome);
4017
- writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
4014
+ writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities || [], { launched: launched === true, tmux });
4018
4015
  } catch (e) {
4019
4016
  incomplete.push(`independent retirement authority: ${e.message}`);
4020
4017
  }
@@ -4243,7 +4240,6 @@ function writeRetirementBaseline(home, work, mode, capabilities, runtime, { excl
4243
4240
  runtime: {
4244
4241
  launched: runtime?.launched === true,
4245
4242
  ...(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
4243
  },
4248
4244
  };
4249
4245
  const path = retirementBaselinePath(home);
@@ -4286,23 +4282,40 @@ function branchOnlyCommits(repo, branch) {
4286
4282
  }
4287
4283
  }
4288
4284
 
4285
+ /** Herdr (removed in 0.31.0) is recognised in a home only to refuse: its instance.json records a
4286
+ * Herdr session target or `backend: "herdr"`, or its independent receipt records a session target
4287
+ * (a tmux receipt never does). Either source is enough. */
4288
+ function recordsHerdr(meta, baseline) {
4289
+ return (isPlainObject(meta) && (meta.sessionTarget !== undefined || meta.backend === "herdr"))
4290
+ || (isPlainObject(baseline?.runtime) && baseline.runtime.sessionTarget !== undefined);
4291
+ }
4292
+ function readJsonOrUndefined(path) { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; } }
4293
+ /** Refuse any session operation on a home opened in Herdr, from its files as they are (either may be
4294
+ * missing or unreadable): before any receipt validation, so the refusal is never an endpoint error. */
4295
+ function refuseHerdrHome(home) {
4296
+ if (recordsHerdr(readJsonOrUndefined(join(home, "instance.json")), readJsonOrUndefined(retirementBaselinePath(home)))) throw herdrInstanceRemoved(basename(home));
4297
+ }
4298
+
4289
4299
  function runtimeAuthorityOf(baseline) {
4290
4300
  const runtime = baseline?.runtime;
4291
4301
  if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
4292
4302
  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
- }
4303
+ if (runtime.sessionTarget !== undefined) return undefined;
4297
4304
  const tmux = runtime.tmux;
4298
4305
  if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
4299
4306
  return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
4300
4307
  }
4301
4308
 
4309
+ /** instance.json's tmux endpoint is the one the independent receipt authorises. */
4310
+ function tmuxEndpointAgrees(meta, authority) {
4311
+ return meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
4312
+ }
4313
+
4302
4314
  /** Session control uses the independent endpoint receipt. */
4303
4315
  function instanceSessionTarget(home) {
4304
4316
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
4305
4317
  home = realPathOrNearest(home);
4318
+ refuseHerdrHome(home);
4306
4319
  let baseline, meta;
4307
4320
  try {
4308
4321
  baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
@@ -4310,11 +4323,8 @@ function instanceSessionTarget(home) {
4310
4323
  } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
4311
4324
  const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
4312
4325
  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 };
4326
+ if (meta.launched !== authority.launched || (authority.launched && !tmuxEndpointAgrees(meta, authority))) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
4327
+ return { home, target: authority.launched ? { backend: "tmux", ...authority.tmux } : undefined };
4318
4328
  }
4319
4329
 
4320
4330
  export function inspectInstanceSession(home) {
@@ -4371,7 +4381,7 @@ export function processesInHome(home, io) {
4371
4381
  * AND no live process on the host works in the home (a harness started by
4372
4382
  * hand on another server is invisible to the recorded endpoint).
4373
4383
  * 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,
4384
+ * the process scan cannot run, a launch without an endpoint,
4375
4385
  * tmux failing otherwise) is `{ absent: false, note }`: live or ambiguous,
4376
4386
  * never killed on mutable metadata.
4377
4387
  */
@@ -4385,7 +4395,6 @@ export function observeSessionWithoutReceipt(home, meta, io) {
4385
4395
  }
4386
4396
  function recordedSessionAbsence(home, meta, io) {
4387
4397
  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
4398
  // A --no-launch home keeps its planned tmux names with `launched: false`: nothing was started there.
4390
4399
  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
4400
  if (!tmux) return { absent: false, backend: null, note: "instance.json records a launch but no session endpoint" };
@@ -4412,7 +4421,7 @@ export function stopInstanceSession(home, o = {}) {
4412
4421
  if (!s.target) return { home: s.home, backend: null, stopped: false, alreadyIdle: true, state: "not-launched", receipt: null };
4413
4422
  let before;
4414
4423
  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}`); }
4424
+ 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
4425
  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
4426
  const receipt = stopHarness(s.target, { graceMs: o.graceMs ?? 20000, kill: o.io?.kill, sleep: o.io?.sleep });
4418
4427
  writeJsonAtomic(join(realHome, ".oats-stop.json"), { instance: basename(realHome), at: new Date().toISOString(), stop: receipt, retained: ["home", "work", "transcript", "launch"] });
@@ -4539,53 +4548,16 @@ function writeJsonAtomic(path, value, mode) {
4539
4548
  }
4540
4549
 
4541
4550
  // ---------- 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
4551
+ /** The harness processes under a tmux session target: EVERY descendant
4544
4552
  * of the pane's launcher process (the launcher shell itself is left so
4545
4553
  * that, once its command ends, it becomes the fallback shell the start
4546
4554
  * 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 }. */
4555
+ * and their children are all included, topmost first. Each row:
4556
+ * { pid, ppid, pgid, comm, depth }. */
4549
4557
  export function harnessProcesses(target, io) {
4550
4558
  const ps = () => ((io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,pgid=,comm="], { encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }))
4551
4559
  .split("\n").map((l) => l.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
4552
4560
  .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
4561
  const row = tmuxOn(target.socket, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_pid}"], io).trim().split("\n")[0];
4590
4562
  if (!/^\d+$/.test(row || "")) throw oatsError("E_SESSION_UNKNOWN", "tmux returned no pane process id");
4591
4563
  const panePid = Number(row);
@@ -4753,6 +4725,10 @@ export function preWorkspaceHome(home, what) {
4753
4725
  }
4754
4726
  export function startInstanceSession(home, o = {}) {
4755
4727
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session start needs an absolute instance home");
4728
+ // A home opened in Herdr, or an earlier start that allocated a Herdr pane, is refused before any
4729
+ // guard or receipt reads it.
4730
+ refuseHerdrHome(realPathOrNearest(home));
4731
+ if (readJsonOrUndefined(join(realPathOrNearest(home), ".oats-start-pending.json"))?.target?.backend === "herdr") throw herdrInstanceRemoved(basename(realPathOrNearest(home)));
4756
4732
  if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
4757
4733
  const directoryGuard = sessionDirectoryGuard(home);
4758
4734
  {
@@ -4777,25 +4753,23 @@ export function startInstanceSession(home, o = {}) {
4777
4753
  // re-resolve it against. Retire still works on it.
4778
4754
  if (!isWorkspaceHome(readMeta())) throw preWorkspaceHome(realHome, "nothing was started");
4779
4755
  const lostTmuxServer = tmuxServerLost;
4780
- const launchFailure = (backend, error) => {
4756
+ const launchFailure = (error) => {
4781
4757
  // execFileSync errors embed argv (including capability environment) in
4782
4758
  // message; backend stderr can echo it too. Neither belongs in the API.
4783
4759
  const reason = error.code === "ENOENT" ? "backend executable unavailable"
4784
4760
  : 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}`);
4761
+ 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
4762
  };
4787
4763
  // The independent receipt first (retire and session consult it), then the
4788
4764
  // mutable metadata; both tmp+rename. A failure between them is what the
4789
4765
  // 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) => {
4766
+ const record = (meta, { id, target, model, command, startedAt, reused, launch, harness: newHarness, yolo: newYolo, stop, nativeRecordId, hookMeta, modelFrom, launchFrom, launchAt, launchDeclared }, clearPending = true) => {
4791
4767
  checkRoots();
4792
4768
  const baselinePath = retirementBaselinePath(realHome);
4793
4769
  let baseline;
4794
4770
  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
4771
  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) } };
4772
+ baseline.runtime = { launched: true, tmux: { session: target.session, window: target.window, socket: resolve(target.socket) } };
4799
4773
  writeJsonAtomic(baselinePath, baseline, 0o600);
4800
4774
  if (o.io?.failBeforeMetadataWrite && reused !== "adopted") throw new Error("injected metadata write failure"); // the write after THIS start's allocation
4801
4775
  const recorded = meta.startId === id;
@@ -4807,11 +4781,10 @@ export function startInstanceSession(home, o = {}) {
4807
4781
  // Launch-hook meta lands per capability over the spawn's record; a hook
4808
4782
  // that answered without meta keeps its previous entry (retire reads it).
4809
4783
  ...(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; }
4784
+ next.tmux = { session: target.session, window: target.window, socket: resolve(target.socket) };
4812
4785
  writeJsonAtomic(metaPath, next);
4813
4786
  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 } : {}) };
4787
+ 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
4788
  };
4816
4789
  try { mkdirSync(lock); }
4817
4790
  catch (e) {
@@ -4829,8 +4802,7 @@ export function startInstanceSession(home, o = {}) {
4829
4802
  let pending;
4830
4803
  try { pending = JSON.parse(readFileSync(pendingPath, "utf8")); } catch { pending = undefined; }
4831
4804
  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);
4805
+ const validTarget = pt?.backend === "tmux" && [pt.session, pt.window, pt.socket].every((v) => typeof v === "string" && v.length > 0) && isAbsolute(pt.socket);
4834
4806
  let validCommand = false;
4835
4807
  try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
4836
4808
  let validLaunch = true;
@@ -4842,13 +4814,12 @@ export function startInstanceSession(home, o = {}) {
4842
4814
  && (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
4843
4815
  && typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
4844
4816
  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
4817
  let st;
4847
4818
  checkRoots();
4848
4819
  try { st = inspectSessionTarget(pending.target, o.io); }
4849
4820
  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`);
4821
+ if (lostTmuxServer(e)) st = { present: false, state: "stopped" };
4822
+ 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
4823
  }
4853
4824
  // A launch may still consist entirely of shells (startup files, a
4854
4825
  // shell-script harness). Only its own completion marker proves this
@@ -4860,7 +4831,7 @@ export function startInstanceSession(home, o = {}) {
4860
4831
  // Reconcile even an exited target: the independent baseline may
4861
4832
  // already name it while metadata still names the old allocation.
4862
4833
  const meta = readMeta();
4863
- const done = record(meta, { ...pending, backend: pbackend, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
4834
+ const done = record(meta, { ...pending, model: pending.model ?? undefined, reused: "adopted" }, !st.present || st.state === "shell");
4864
4835
  if (st.present && st.state !== "shell") {
4865
4836
  if (o.restart) { rmSync(pendingPath, { force: true }); }
4866
4837
  else {
@@ -4878,7 +4849,6 @@ export function startInstanceSession(home, o = {}) {
4878
4849
  const meta = readMeta();
4879
4850
  const harness = meta.harness;
4880
4851
  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
4852
  let command = meta.command;
4883
4853
  let model = meta.model || undefined;
4884
4854
  // What this start launches: the frozen command (optionally with another
@@ -4922,7 +4892,6 @@ export function startInstanceSession(home, o = {}) {
4922
4892
  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
4893
  const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
4924
4894
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
4925
- const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
4926
4895
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
4927
4896
  const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
4928
4897
  ...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
@@ -4933,7 +4902,7 @@ export function startInstanceSession(home, o = {}) {
4933
4902
  if (target) {
4934
4903
  try { state = inspectSessionTarget(target, o.io); }
4935
4904
  catch (e) {
4936
- if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
4905
+ if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
4937
4906
  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
4907
  }
4939
4908
  }
@@ -4947,7 +4916,7 @@ export function startInstanceSession(home, o = {}) {
4947
4916
  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
4917
  appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
4949
4918
  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}`); }
4919
+ 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
4920
  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
4921
  }
4953
4922
  // The kernel that launches the harness is the one the agent's plain `oats` runs.
@@ -4959,79 +4928,64 @@ export function startInstanceSession(home, o = {}) {
4959
4928
  const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.harness || harness);
4960
4929
  const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
4961
4930
  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
- }
4931
+ const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
4932
+ const window = target?.window || meta.tmux?.window || meta.instance;
4933
+ let socket = target?.socket || meta.tmux?.socket;
4934
+ const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
4935
+ // A fallback shell (no harness descendant) or a retained dead pane is
4936
+ // the agent's own pane: the command runs there, no other window touched.
4937
+ const inPlace = state.paneId && (state.present || state.state === "stopped");
4938
+ if (inPlace) {
4971
4939
  checkRoots();
4972
4940
  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); }
4941
+ try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
4942
+ catch (e) { throw launchFailure(e); }
4943
+ reused = "pane";
4975
4944
  } 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
- }
4945
+ const instancesRoot = dirname(realHome);
4946
+ const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
4947
+ checkRoots();
4948
+ if (!socket) {
4949
+ // Never launched (--no-launch): the default server, as spawn uses.
4950
+ const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
4951
+ let alive = false;
4952
+ try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
4953
+ if (!alive) {
4954
+ defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
4955
+ for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
4956
+ try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
5003
4957
  }
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
- }
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
4958
  }
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); }
4959
+ socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
4960
+ if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
4961
+ } else if (serverGone) {
4962
+ // The recorded server is gone (a reboot): the same socket path again.
4963
+ mkdirSync(dirname(socket), { recursive: true });
4964
+ tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
4965
+ tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
4966
+ tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
5025
4967
  }
4968
+ let names = [];
4969
+ try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
4970
+ catch (e) {
4971
+ 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()}`);
4972
+ tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
4973
+ }
4974
+ if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
5026
4975
  target = { backend: "tmux", session, window, socket: resolve(socket) };
4976
+ checkRoots();
4977
+ writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
4978
+ try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
4979
+ catch (e) { throw launchFailure(e); }
5027
4980
  }
4981
+ target = { backend: "tmux", session, window, socket: resolve(socket) };
5028
4982
  // Keep launch evidence until the command exits or the target disappears.
5029
4983
  // A transient child (for example cat TASK.md) is not proof that startup
5030
4984
  // 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 }; }
4985
+ try { return { ...record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
5032
4986
  catch (e) {
5033
4987
  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`);
4988
+ 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
4989
  }
5036
4990
  } finally {
5037
4991
  // A hook may have replaced the home itself. Never follow that replacement
@@ -5589,7 +5543,10 @@ export function retireInstance(root, name, o = {}) {
5589
5543
  // persists the intent and hands the whole retirement to a detached process
5590
5544
  // that runs it as an ordinary EXTERNAL retirement once the harness is gone
5591
5545
  // (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
5592
- if (self && (!o.keepDir || meta.sessionTarget)) {
5546
+ // A home opened in Herdr (removed in 0.31.0) has no endpoint OATS can quiesce in-process. A
5547
+ // quarantined home's cleanup descriptor overrides the live record's endpoint: either may name it.
5548
+ const herdrHome = recordsHerdr(meta, readJsonOrUndefined(retirementBaselinePath(found.home))) || recordsHerdr(liveMeta);
5549
+ if (self && (!o.keepDir || herdrHome)) {
5593
5550
  return scheduleDeferredSelfRetirement(root, found, name, o, session);
5594
5551
  }
5595
5552
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
@@ -5600,7 +5557,13 @@ export function retireInstance(root, name, o = {}) {
5600
5557
  // describe it for humans, but only the independent baseline can authorize the
5601
5558
  // endpoint that proves quiescence.
5602
5559
  let runtimeAuthority;
5603
- if (liveMeta || quarantine) {
5560
+ if (herdrHome) {
5561
+ // Nothing to quiesce without Herdr: the retirement goes on only when no process works in the
5562
+ // home, exactly as for a session observed absent.
5563
+ const scan = processesInHome(found.home);
5564
+ 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`);
5565
+ if (scan.processes.length) throw herdrInstanceBusy(name, scan.processes.map((p) => p.pid));
5566
+ } else if (liveMeta || quarantine) {
5604
5567
  runtimeAuthority = initialObservation.runtimeAuthority;
5605
5568
  // A home without its receipt (spawned before 0.25.9) has nothing OATS may
5606
5569
  // quiesce; when its session is observably absent, quiescing is vacuous and
@@ -5613,12 +5576,7 @@ export function retireInstance(root, name, o = {}) {
5613
5576
  }
5614
5577
  }
5615
5578
  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);
5579
+ const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || tmuxEndpointAgrees(meta, runtimeAuthority));
5622
5580
  if (!metaAgrees) {
5623
5581
  throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
5624
5582
  }
@@ -5626,11 +5584,7 @@ export function retireInstance(root, name, o = {}) {
5626
5584
  // `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
5627
5585
  // A no-launch instance is already quiesced. A launched one must have exact
5628
5586
  // 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) {
5587
+ if (!self && runtimeAuthority?.launched) {
5634
5588
  const runtimeSession = runtimeAuthority.tmux.session;
5635
5589
  const runtimeWindow = runtimeAuthority.tmux.window;
5636
5590
  const runtimeSocket = runtimeAuthority.tmux.socket;
@@ -5930,7 +5884,10 @@ export function retireInstance(root, name, o = {}) {
5930
5884
  // The caller is the instance: its process lives in the window we are about to
5931
5885
  // kill. Detach the kill so this function can return and the caller can report
5932
5886
  // 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'`);
5887
+ // The window the home RECORDED (a pre-0.31 home lives in pi-agents), never the default for new ones.
5888
+ const killSession = runtimeAuthority?.tmux?.session || meta?.tmux?.session || session;
5889
+ const killWindow = runtimeAuthority?.tmux?.window || meta?.tmux?.window || name;
5890
+ shTry(`tmux run-shell -b 'sleep ${o.selfKillDelaySec ?? 8}; tmux kill-window -t ${shq(`=${killSession}:=${killWindow}`)} 2>/dev/null || true'`);
5934
5891
  result.selfKillScheduled = true;
5935
5892
  }
5936
5893
  return result;