@awebai/oats 0.30.2 → 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;
@@ -1537,7 +1551,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, s
1537
1551
  // Hooks also run through direct core callers (not only bin/oats).
1538
1552
  // Author this from the running kernel, never PATH, ambient env or
1539
1553
  // a caller's extraEnv: those may point at a different executable.
1540
- OATS_CLI_BIN: realpathSync(join(PKG_ROOT, "bin", "oats.mjs")),
1554
+ OATS_CLI_BIN: kernelBin(),
1541
1555
  OATS_SETTINGS: JSON.stringify(cap.settings || {}),
1542
1556
  // Where each leaf of OATS_SETTINGS came from (JSON pointer → { kind, at }; kind is
1543
1557
  // manifest-default | workspace | soul | host | spawn | anchor), so a provider can tell a
@@ -2182,14 +2196,60 @@ 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(""); }
2206
+
2207
+ /** The canonical path of this kernel's CLI: what the home's shim points at, and OATS_CLI_BIN. */
2208
+ export function kernelBin() { return realpathSync(join(PKG_ROOT, "bin", "oats.mjs")); }
2209
+ /** Where a home's `oats` lives: first on its harness's PATH, so plain `oats` inside an instance is
2210
+ * the kernel that launched it, whatever other `oats` the host's PATH finds first. */
2211
+ export const kernelShimDir = (home) => join(home, ".oats", "bin");
2212
+ /** Write <home>/.oats/bin/oats, a symlink to this kernel's CLI, replacing any shim a previous
2213
+ * launch (perhaps by another kernel) left: built aside, then renamed over. `.oats` and `.oats/bin`
2214
+ * must be directories of the home itself: a link there would carry the write outside the home.
2215
+ * The target, or E_LAUNCH_SHIM naming the path and the cause (a launch never runs with the
2216
+ * wrong `oats`), before anything outside the home is touched. */
2217
+ export function writeKernelShim(home) {
2218
+ const dir = kernelShimDir(home), shim = join(dir, "oats");
2219
+ let target, aside;
2220
+ try {
2221
+ target = kernelBin();
2222
+ for (const d of [join(home, ".oats"), dir]) {
2223
+ let st;
2224
+ try { st = lstatSync(d); }
2225
+ catch (e) { if (e.code !== "ENOENT") throw e; mkdirSync(d); st = lstatSync(d); }
2226
+ if (!st.isDirectory()) throw new Error(`${d} is ${st.isSymbolicLink() ? "a symbolic link" : "not a directory"}`);
2227
+ }
2228
+ if (realpathSync(dir) !== join(realpathSync(home), ".oats", "bin")) throw new Error(`${dir} resolves outside the home`);
2229
+ aside = join(dir, `.oats-${randomUUID()}`);
2230
+ symlinkSync(target, aside);
2231
+ renameSync(aside, shim);
2232
+ return target;
2233
+ } catch (e) {
2234
+ if (aside) rmSync(aside, { force: true });
2235
+ throw oatsError("E_LAUNCH_SHIM", `cannot write the kernel shim ${shim}${target ? ` (to ${target})` : ""}: ${e.message}; nothing was started`);
2236
+ }
2237
+ }
2238
+ /** A parsed launch command's environment prefix with the home's shim directory first on PATH: a
2239
+ * configuration's PATH (literal or reference) follows it, else the PATH the command runs under. */
2240
+ function shimPathPrefix(tokens, binary, home) {
2241
+ const dir = shq(kernelShimDir(home));
2242
+ const prefix = tokens.slice(0, binary);
2243
+ const texts = prefix.map((t) => t.name !== "PATH" ? t.text : t.kind === "env" ? `PATH=${dir}:${shq(t.value)}` : `PATH=${dir}:"$${t.source}"`);
2244
+ if (!prefix.some((t) => t.name === "PATH")) texts.push(`PATH=${dir}:"$PATH"`);
2245
+ return texts;
2246
+ }
2247
+ /** The persisted launch command as a shell runs it: the same command, with the home's kernel shim
2248
+ * first on PATH. The persisted bytes never carry it (they stay a shape parseLaunchCommand reads). */
2249
+ export function launchShellCommand(command, home) {
2250
+ const { tokens, binary } = parseLaunchCommand(command);
2251
+ return [...shimPathPrefix(tokens, binary, home), ...tokens.slice(binary).map((t) => t.text)].join(" ");
2252
+ }
2193
2253
 
2194
2254
  /** The harness command line of a recipe. With no configuration the bytes
2195
2255
  * equal what spawn rendered before recipes: env prefix, the executable, the
@@ -2234,14 +2294,15 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false }) {
2234
2294
 
2235
2295
  /** Execution, not preview: mark pending before dispatch, then resolve native
2236
2296
  * storage inside the backend shell under the actual command environment.
2237
- * The original executable/argv is exec'd unchanged after recording succeeds. */
2297
+ * The original executable/argv is exec'd unchanged after recording succeeds,
2298
+ * with the home's kernel shim first on PATH (every launch runs through here). */
2238
2299
  function nativeRecordCommand(command, home, harness) {
2239
2300
  const { tokens, binary } = parseLaunchCommand(command);
2240
2301
  const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
2241
2302
  const id = prepareNativeStart(home, harness);
2242
2303
  const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
2243
2304
  const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${tokens.slice(binary).map(t => t.text).join(" ")}`;
2244
- return `${tokens.slice(0, binary).map(t => t.text).join(" ")} /bin/sh -c ${shq(inner)}`;
2305
+ return `${shimPathPrefix(tokens, binary, home).join(" ")} /bin/sh -c ${shq(inner)}`;
2245
2306
  }
2246
2307
 
2247
2308
 
@@ -2374,7 +2435,7 @@ export function planLaunch({ home, instance, meta, contextDir, agentLike, select
2374
2435
  version: LAUNCH_RECIPE_VERSION, harness, launchConfig: config?.name || null, launchConfigSource: config?.source || null,
2375
2436
  executable: executable.path || executable.declared || harness, executableDeclared: executable.declared ?? null, executableResolvedFrom: executable.resolvedFrom,
2376
2437
  args: [...(config?.args || [])], env: { ...configEnv }, model: model || null, ...(yolo !== undefined ? { yolo } : {}),
2377
- hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT,
2438
+ hooks: frozen ? { launch: hooks.launch, env: hooks.env, contributions: hooks.contributions } : hooks, prompt: LAUNCH_PROMPT, kernelBin: kernelBin(),
2378
2439
  ...(frozen?.legacy ? { legacy: { ...frozen.legacy, ...(hooks.refreshed?.length ? { replacedBy: hooks.refreshed } : {}) } } : {}),
2379
2440
  };
2380
2441
  const inst = instance || meta?.instance || basename(home);
@@ -2412,11 +2473,18 @@ export function redactLaunchCommand(command) {
2412
2473
  try { return parseLaunchCommand(command).tokens.map((t) => t.kind === "env" && !IDENTITY_LAUNCH_ENV.has(t.name) ? `${t.name}='<redacted>'` : t.text).join(" "); }
2413
2474
  catch { return "<unparseable launch command withheld>"; }
2414
2475
  }
2415
- /** The recipe with every environment value withheld. */
2476
+ /** The recipe as answers carry it: every environment value withheld, and without `kernelBin`
2477
+ * (the home's record keeps it; human `oats status` reads it from there). */
2416
2478
  export function redactLaunchRecipe(recipe) {
2417
2479
  const env = Object.fromEntries(Object.keys(recipe.env || {}).sort().map((n) => [n, typeof recipe.env[n] === "string" ? { redacted: true } : { fromEnv: recipe.env[n].fromEnv }]));
2418
2480
  const hooks = recipe.hooks ? { ...recipe.hooks, env: Object.fromEntries(Object.keys(recipe.hooks.env || {}).sort().map((n) => [n, { redacted: true }])) } : undefined;
2419
- return { ...recipe, env, ...(hooks ? { hooks } : {}) };
2481
+ const { kernelBin: _recorded, ...answered } = recipe;
2482
+ return { ...answered, env, ...(hooks ? { hooks } : {}) };
2483
+ }
2484
+ /** The kernel a home's last launch pointed its `oats` at (instance.json launch.kernelBin), or null. */
2485
+ export function recordedKernelBin(home) {
2486
+ try { const bin = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"))?.launch?.kernelBin; return typeof bin === "string" ? bin : null; }
2487
+ catch { return null; }
2420
2488
  }
2421
2489
  /** Spawn. With `o.prepared` (workspace model: a resolution prepared by
2422
2490
  * instance-resolution.mjs) this is ASYNC — capabilities are fetched and copied
@@ -2455,10 +2523,11 @@ function* spawnBody(root, agent, o = {}) {
2455
2523
  if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
2456
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`);
2457
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");
2458
- const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
2526
+ const { tmuxSession: session } = sessionDefaults(root, { tmuxSession: o.tmuxSession });
2459
2527
  const backend = o.backend || "tmux";
2460
- if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
2461
- 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)`);
2462
2531
  const launch = o.launch !== false;
2463
2532
  // Workspace model (`o.prepared`) + work: workspace: ./work is the deployment
2464
2533
  // boundary — the directory holding oats-local.yaml (`prepared.deployment`), which
@@ -2537,8 +2606,8 @@ function* spawnBody(root, agent, o = {}) {
2537
2606
  }
2538
2607
 
2539
2608
  // An explicit name may not be a soul name (soul and instance references stay
2540
- // unambiguous) and is taken when any soul of this deployment holds it, or — on
2541
- // 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:
2542
2611
  // a typed refusal, never a silent `-2` (the operator typed it). Checked after
2543
2612
  // key recovery, so a retried keyed spawn still reaches its own receipt; the
2544
2613
  // placement below re-checks after its reservation (a concurrent spawn of
@@ -2547,7 +2616,7 @@ function* spawnBody(root, agent, o = {}) {
2547
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`);
2548
2617
  const holder = deploymentInstanceHomes(root).get(instance)?.[0];
2549
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 });
2550
- 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 });
2551
2620
  }
2552
2621
 
2553
2622
  // Forward-only lineage: EXPLICIT only. Relations (child|sibling|parent|unrelated)
@@ -2848,10 +2917,6 @@ function* spawnBody(root, agent, o = {}) {
2848
2917
  // spawn hooks run, which happens after the home exists.
2849
2918
  if ((launchConfig?.args || []).length && applicableRequirements(harness, resolvedCfg.capabilities).length) throw oatsError("E_LAUNCH_PROBE_UNSUPPORTED", `${requirementsWithArgsMessage(harness, resolvedCfg.capabilities, launchConfig)}; nothing was created`);
2850
2919
  const harnessPackages = verifyHarnessPackages(harness, resolvedCfg, repoAbs, { ...(launchConfig?.executable ? { bin } : {}), env: launchEffectiveEnv({ base: process.env, configEnv: launchConfig?.env || {} }) });
2851
- // Backend presence/startup (ensureHerdr) happens AFTER the decision fence
2852
- // and the exclusive placement reservation below — a stale or losing apply
2853
- // must not start a daemon. Preview never starts one either.
2854
- let herdrBase;
2855
2920
  if (o.preview === true) { preflight = { status: previewPreflightBudget?.exhausted ? "timeout" : "complete", budgetMs: preflightBudgetMs, elapsedMs: Date.now() - preflightStarted }; previewPreflightBudget = null; }
2856
2921
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
2857
2922
 
@@ -2936,11 +3001,9 @@ function* spawnBody(root, agent, o = {}) {
2936
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 });
2937
3002
  }
2938
3003
  // Backend PRESENCE is a prerequisite, checked before anything is placed (M1):
2939
- // an absent tmux/herdr binary must fail with nothing created, never after a
2940
- // populated home exists. Backend STARTUP (ensureHerdr) still waits for the
2941
- // bound decision and the exclusive placement below — a stale or losing apply
2942
- // must not start a daemon.
2943
- 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`);
2944
3007
  // Exclusive placement reservation: the parent may be created, the home
2945
3008
  // itself never with `recursive` — EEXIST means another spawn (a concurrent
2946
3009
  // apply of the same decision, or anything else) got here first, and this one
@@ -3046,10 +3109,6 @@ function* spawnBody(root, agent, o = {}) {
3046
3109
  }
3047
3110
  } catch (e) { throw rollbackEmptyOrPreparedHome(e); }
3048
3111
  }
3049
- // Backend startup only now — the decision is bound and the placement is ours.
3050
- try { herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined; }
3051
- catch (e) { throw rollbackEmptyOrPreparedHome(e); }
3052
-
3053
3112
  initializeNativeHistory(home);
3054
3113
 
3055
3114
  // Body: instructions are a generated instance-local view; the home carries no soul
@@ -3275,7 +3334,6 @@ function* spawnBody(root, agent, o = {}) {
3275
3334
  const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
3276
3335
  let windowMayExist = false;
3277
3336
  let spawnTmux;
3278
- let spawnHerdr;
3279
3337
  const ancillaryCleanup = [];
3280
3338
  // One compensation owner, from the first hook result through launch and
3281
3339
  // the final lineage write. Preserve the original failure and retain any
@@ -3295,23 +3353,7 @@ function* spawnBody(root, agent, o = {}) {
3295
3353
  const outstandingGit = new Set();
3296
3354
  // A failed new-window command may still have created its window. Verify
3297
3355
  // quiescence before removing credentials or work that harness may be using.
3298
- if (windowMayExist && spawnHerdr) {
3299
- try { stopHerdr(spawnHerdr); }
3300
- catch (e) {
3301
- incomplete.push(`Herdr session: ${e.message}`);
3302
- for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
3303
- if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
3304
- return quarantineInstanceHome({ home, instance, agent, soulDir: homeSoulTarget, soulId: preparedSoulId, incomplete, failed, outstandingHooks, outstandingGit,
3305
- repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
3306
- launched: true, sessionTarget: spawnHerdr, directoryHome: homeReal, recordRetirementBaseline: true });
3307
- }
3308
- }
3309
- if (windowMayExist && backend === "herdr" && !spawnHerdr) {
3310
- // Allocation starts only an empty shell; the harness command has not run.
3311
- // A lost receipt cannot authorize closing an unidentified terminal.
3312
- incomplete.push(`Herdr workspace allocation may have completed on ${herdrBase.socket} (label ${instance}, cwd ${home}); inspect and remove any empty workspace manually`);
3313
- }
3314
- if (windowMayExist && backend === "tmux") {
3356
+ if (windowMayExist) {
3315
3357
  shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
3316
3358
  const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
3317
3359
  const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
@@ -3477,6 +3519,8 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3477
3519
  // instance.json beside its rendering, which is executed in the instance's
3478
3520
  // tmux window. Capabilities contributed harness-specific arguments and
3479
3521
  // environment through their spawn hook; both are recorded with provenance.
3522
+ // The home's `oats` is this kernel, launched now or later (--no-launch).
3523
+ const shimTarget = writeKernelShim(home);
3480
3524
  const recipe = {
3481
3525
  version: LAUNCH_RECIPE_VERSION, harness,
3482
3526
  launchConfig: launchConfig?.name || null, launchConfigSource: launchConfig?.source || null,
@@ -3484,7 +3528,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3484
3528
  args: [...(launchConfig?.args || [])], env: { ...(launchConfig?.env || {}) },
3485
3529
  model: model || null, ...(yolo !== undefined ? { yolo } : {}),
3486
3530
  hooks: { launch: { ...hookRes.launch }, env: { ...hookRes.env }, contributions: hookRes.contributions || [] },
3487
- prompt: LAUNCH_PROMPT,
3531
+ prompt: LAUNCH_PROMPT, kernelBin: shimTarget,
3488
3532
  };
3489
3533
  if (triggerEventFile) recipe.env.OATS_TRIGGER_EVENT_FILE = triggerEventFile;
3490
3534
  const cmdline = renderLaunchRecipe(recipe, { home, instance });
@@ -3565,7 +3609,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3565
3609
  missingRequires: cap.missingRequires, trust: cap.trust,
3566
3610
  executable: cap.executable,
3567
3611
  })),
3568
- ...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
3612
+ tmux: { session, window: instance },
3569
3613
  launch: recipe, command: cmdline, createdAt: new Date().toISOString(),
3570
3614
  };
3571
3615
  // Workspace model: materialize recorded modules/providers (and the module
@@ -3587,16 +3631,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3587
3631
  spawnTmux = meta.tmux;
3588
3632
  if (work === "directory") assertDirectoryRoots(home, homeReal);
3589
3633
  const executionCommand = launch ? nativeRecordCommand(cmdline, home, harness) : null;
3590
- if (launch && backend === "herdr") {
3591
- windowMayExist = true;
3592
- spawnHerdr = allocateHerdr(herdrBase, { home, instance });
3593
- meta.sessionTarget = spawnHerdr;
3594
- meta.launched = true;
3595
- writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
3596
- writeRetirementBaseline(home, join(home, "work"), work, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
3597
- try { launchHerdr(spawnHerdr, `${launchEnvExports(recipe, process.env)}${executionCommand}`); }
3598
- 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`); }
3599
- } else if (launch) {
3634
+ if (launch) {
3600
3635
  if (!tmuxAlive(session)) {
3601
3636
  const hq = existsSync(root) ? root : workspaceOf(root);
3602
3637
  sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
@@ -3658,7 +3693,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
3658
3693
  meta.spawnCompleted = true;
3659
3694
  writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n"); // a kernel-owned field: the baseline fingerprint ignores it
3660
3695
  }
3661
- 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 });
3662
3697
  } catch (error) {
3663
3698
  const note = compensateSpawn();
3664
3699
  error.message += note;
@@ -3685,8 +3720,29 @@ export function servedIdentityLine(identity) {
3685
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}` : ""}`;
3686
3721
  return `alias ${identity.alias || "?"} on ${identity.team || "?"}`;
3687
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
+ }
3688
3736
  export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3689
- 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
+ };
3690
3746
  const readInstancesOf = (agentDir) => {
3691
3747
  const instancesDir = join(agentDir, "instances");
3692
3748
  // An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
@@ -3718,11 +3774,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
3718
3774
  try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
3719
3775
  catch { retirePending = { reason: "retire pending" }; }
3720
3776
  }
3721
- let liveness = { running: windows.includes(meta.instance || e.name) };
3722
- if (meta.sessionTarget) {
3723
- try { const state = inspectHerdr({ ...meta.sessionTarget, binary: "herdr" }); liveness = { running: state.present, runtimeState: state.status }; }
3724
- catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
3725
- }
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);
3726
3781
  const identity = servedIdentityOf(meta);
3727
3782
  // Feature desktop-facts: `startedAt` — the last session start or restart (the receipt's restarts[]),
3728
3783
  // else the spawn's own launch (createdAt), else null (never launched); `identityAddress` — the
@@ -3903,7 +3958,7 @@ function sessionDirectoryGuard(home) {
3903
3958
 
3904
3959
  /** Retain the home and its cleanup receipt when spawn compensation or retirement
3905
3960
  * cannot finish. Keeping the original credentials makes cleanup retryable. */
3906
- 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) }) {
3907
3962
  const marker = {
3908
3963
  // `reason` is optional and DEFAULTS to the spawn wording, so every existing
3909
3964
  // caller is byte-identical; only a caller that supplies one differs. The
@@ -3923,7 +3978,6 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
3923
3978
  // instance.json may be only the pre-hook stub, which records neither.
3924
3979
  ...(typeof soulDir === "string" && soulDir ? { soulDir } : {}),
3925
3980
  ...(typeof soulId === "string" && soulId ? { soulId } : {}),
3926
- ...(sessionTarget ? { sessionTarget } : {}),
3927
3981
  outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit], ...(directoryPreservation ? { directory: true } : {}) },
3928
3982
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
3929
3983
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings, settingsOrigins: cap.settingsOrigins ?? {},
@@ -3957,7 +4011,7 @@ function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomp
3957
4011
  if (recordRetirementBaseline) {
3958
4012
  try {
3959
4013
  if (work === "directory") assertDirectoryRoots(home, directoryHome);
3960
- 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 });
3961
4015
  } catch (e) {
3962
4016
  incomplete.push(`independent retirement authority: ${e.message}`);
3963
4017
  }
@@ -4186,7 +4240,6 @@ function writeRetirementBaseline(home, work, mode, capabilities, runtime, { excl
4186
4240
  runtime: {
4187
4241
  launched: runtime?.launched === true,
4188
4242
  ...(runtime?.tmux ? { tmux: { session: runtime.tmux.session, window: runtime.tmux.window, ...(runtime.tmux.socket ? { socket: resolve(runtime.tmux.socket) } : {}) } } : {}),
4189
- ...(runtime?.sessionTarget ? { sessionTarget: runtime.sessionTarget } : {}),
4190
4243
  },
4191
4244
  };
4192
4245
  const path = retirementBaselinePath(home);
@@ -4229,23 +4282,40 @@ function branchOnlyCommits(repo, branch) {
4229
4282
  }
4230
4283
  }
4231
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
+
4232
4299
  function runtimeAuthorityOf(baseline) {
4233
4300
  const runtime = baseline?.runtime;
4234
4301
  if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
4235
4302
  if (!runtime.launched) return { launched: false };
4236
- if (runtime.sessionTarget !== undefined) {
4237
- if (runtime.tmux || !validHerdrTarget(runtime.sessionTarget)) return undefined;
4238
- return { launched: true, sessionTarget: runtime.sessionTarget };
4239
- }
4303
+ if (runtime.sessionTarget !== undefined) return undefined;
4240
4304
  const tmux = runtime.tmux;
4241
4305
  if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
4242
4306
  return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
4243
4307
  }
4244
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
+
4245
4314
  /** Session control uses the independent endpoint receipt. */
4246
4315
  function instanceSessionTarget(home) {
4247
4316
  if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
4248
4317
  home = realPathOrNearest(home);
4318
+ refuseHerdrHome(home);
4249
4319
  let baseline, meta;
4250
4320
  try {
4251
4321
  baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
@@ -4253,11 +4323,8 @@ function instanceSessionTarget(home) {
4253
4323
  } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
4254
4324
  const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
4255
4325
  if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
4256
- const endpointAgrees = authority.sessionTarget
4257
- ? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === authority.sessionTarget[key])
4258
- : !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
4259
- if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
4260
- 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 };
4261
4328
  }
4262
4329
 
4263
4330
  export function inspectInstanceSession(home) {
@@ -4314,7 +4381,7 @@ export function processesInHome(home, io) {
4314
4381
  * AND no live process on the host works in the home (a harness started by
4315
4382
  * hand on another server is invisible to the recorded endpoint).
4316
4383
  * Anything else (the window is there, a pane or a process works in the home,
4317
- * the process scan cannot run, a Herdr target, a launch without an endpoint,
4384
+ * the process scan cannot run, a launch without an endpoint,
4318
4385
  * tmux failing otherwise) is `{ absent: false, note }`: live or ambiguous,
4319
4386
  * never killed on mutable metadata.
4320
4387
  */
@@ -4328,7 +4395,6 @@ export function observeSessionWithoutReceipt(home, meta, io) {
4328
4395
  }
4329
4396
  function recordedSessionAbsence(home, meta, io) {
4330
4397
  const tmux = meta?.tmux;
4331
- if (meta?.sessionTarget) return { absent: false, backend: "herdr", note: "instance.json records a Herdr session, which cannot be observed without the receipt" };
4332
4398
  // A --no-launch home keeps its planned tmux names with `launched: false`: nothing was started there.
4333
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" };
4334
4400
  if (!tmux) return { absent: false, backend: null, note: "instance.json records a launch but no session endpoint" };
@@ -4355,7 +4421,7 @@ export function stopInstanceSession(home, o = {}) {
4355
4421
  if (!s.target) return { home: s.home, backend: null, stopped: false, alreadyIdle: true, state: "not-launched", receipt: null };
4356
4422
  let before;
4357
4423
  try { before = inspectSessionTarget(s.target); }
4358
- 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}`); }
4359
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 };
4360
4426
  const receipt = stopHarness(s.target, { graceMs: o.graceMs ?? 20000, kill: o.io?.kill, sleep: o.io?.sleep });
4361
4427
  writeJsonAtomic(join(realHome, ".oats-stop.json"), { instance: basename(realHome), at: new Date().toISOString(), stop: receipt, retained: ["home", "work", "transcript", "launch"] });
@@ -4482,53 +4548,16 @@ function writeJsonAtomic(path, value, mode) {
4482
4548
  }
4483
4549
 
4484
4550
  // ---------- stopping a harness, selecting on an existing home ----------
4485
- const SHELL_NAMES = new Set(["sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "login"]);
4486
- /** The harness processes under a session target: for tmux EVERY descendant
4551
+ /** The harness processes under a tmux session target: EVERY descendant
4487
4552
  * of the pane's launcher process (the launcher shell itself is left so
4488
4553
  * that, once its command ends, it becomes the fallback shell the start
4489
4554
  * path recognizes); a wrapper that does not exec the harness, the harness
4490
- * and their children are all included, topmost first. For Herdr the
4491
- * 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 }. */
4492
4557
  export function harnessProcesses(target, io) {
4493
4558
  const ps = () => ((io?.exec || execFileSync)("ps", ["-axo", "pid=,ppid=,pgid=,comm="], { encoding: "utf8", timeout: 10000, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] }))
4494
4559
  .split("\n").map((l) => l.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+(.+)$/)).filter(Boolean)
4495
4560
  .map(([, pid, ppid, pgid, comm]) => ({ pid: Number(pid), ppid: Number(ppid), pgid: Number(pgid), comm: basename(comm).replace(/^-/, "") }));
4496
- if (target.backend === "herdr") {
4497
- // Herdr's PaneProcessInfo (installed schema: shell_pid, nullable;
4498
- // foreground_process_group_id; foreground_processes) names the pane's
4499
- // shell. That pid, verified against this host, is the root: OATS launched
4500
- // `exec /bin/sh -c <command>` in it, so the root is the launcher shell
4501
- // and the harness its child, exactly as under tmux; a root whose exec
4502
- // replaced it with a non-shell IS the harness and is included. Without a
4503
- // shell_pid, the verified non-shell foreground processes are the roots.
4504
- // Never a fabricated row: an unverifiable pid refuses before any signal.
4505
- const info = herdrCommand(target, ["pane", "process-info", "--pane", target.paneId], io).process_info;
4506
- if (!info || typeof info !== "object") throw oatsError("E_SESSION_UNKNOWN", "Herdr returned no process information for the pane");
4507
- const rows = ps();
4508
- const byParent = new Map();
4509
- for (const r of rows) { if (!byParent.has(r.ppid)) byParent.set(r.ppid, []); byParent.get(r.ppid).push(r); }
4510
- const out = [];
4511
- 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); } } };
4512
- const verified = (pid, what) => {
4513
- if (!Number.isInteger(pid) || pid <= 0) throw oatsError("E_SESSION_UNKNOWN", `Herdr reported ${what} without a verifiable pid; nothing was signalled`);
4514
- const row = rows.find((r) => r.pid === pid);
4515
- if (!row) throw oatsError("E_SESSION_UNKNOWN", `Herdr reports ${what} pid ${pid} but this host has no such process; nothing was signalled`);
4516
- return row;
4517
- };
4518
- if (info.shell_pid !== null && info.shell_pid !== undefined) {
4519
- const root = verified(info.shell_pid, "the pane shell");
4520
- if (!SHELL_NAMES.has(root.comm)) out.push({ ...root, depth: 0 });
4521
- walk(root.pid, 1);
4522
- return out;
4523
- }
4524
- if (!Array.isArray(info.foreground_processes)) throw oatsError("E_SESSION_UNKNOWN", "Herdr returned neither a pane shell pid nor foreground processes");
4525
- for (const p of info.foreground_processes) {
4526
- const row = verified(p.pid, `foreground process ${p.name || "?"}`);
4527
- if (SHELL_NAMES.has(row.comm)) { walk(row.pid, 1); continue; }
4528
- if (!out.some((o) => o.pid === row.pid)) { out.push({ ...row, depth: 0 }); walk(row.pid, 1); }
4529
- }
4530
- return out;
4531
- }
4532
4561
  const row = tmuxOn(target.socket, ["list-panes", "-t", `=${target.session}:=${target.window}`, "-F", "#{pane_pid}"], io).trim().split("\n")[0];
4533
4562
  if (!/^\d+$/.test(row || "")) throw oatsError("E_SESSION_UNKNOWN", "tmux returned no pane process id");
4534
4563
  const panePid = Number(row);
@@ -4696,6 +4725,10 @@ export function preWorkspaceHome(home, what) {
4696
4725
  }
4697
4726
  export function startInstanceSession(home, o = {}) {
4698
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)));
4699
4732
  if (existsSync(directoryRollbackPath(home))) throw oatsError("E_INSTANCE_RETIRING", `${home} has retained directory cleanup; restore and retire it before starting anything there`);
4700
4733
  const directoryGuard = sessionDirectoryGuard(home);
4701
4734
  {
@@ -4720,25 +4753,23 @@ export function startInstanceSession(home, o = {}) {
4720
4753
  // re-resolve it against. Retire still works on it.
4721
4754
  if (!isWorkspaceHome(readMeta())) throw preWorkspaceHome(realHome, "nothing was started");
4722
4755
  const lostTmuxServer = tmuxServerLost;
4723
- const launchFailure = (backend, error) => {
4756
+ const launchFailure = (error) => {
4724
4757
  // execFileSync errors embed argv (including capability environment) in
4725
4758
  // message; backend stderr can echo it too. Neither belongs in the API.
4726
4759
  const reason = error.code === "ENOENT" ? "backend executable unavailable"
4727
4760
  : error.code === "ETIMEDOUT" || error.signal === "SIGTERM" ? "backend command timed out" : "backend command failed";
4728
- 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}`);
4729
4762
  };
4730
4763
  // The independent receipt first (retire and session consult it), then the
4731
4764
  // mutable metadata; both tmp+rename. A failure between them is what the
4732
4765
  // pending receipt exists for.
4733
- 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) => {
4734
4767
  checkRoots();
4735
4768
  const baselinePath = retirementBaselinePath(realHome);
4736
4769
  let baseline;
4737
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}`); }
4738
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}`);
4739
- baseline.runtime = backend === "herdr"
4740
- ? { launched: true, sessionTarget: target }
4741
- : { 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) } };
4742
4773
  writeJsonAtomic(baselinePath, baseline, 0o600);
4743
4774
  if (o.io?.failBeforeMetadataWrite && reused !== "adopted") throw new Error("injected metadata write failure"); // the write after THIS start's allocation
4744
4775
  const recorded = meta.startId === id;
@@ -4750,11 +4781,10 @@ export function startInstanceSession(home, o = {}) {
4750
4781
  // Launch-hook meta lands per capability over the spawn's record; a hook
4751
4782
  // that answered without meta keeps its previous entry (retire reads it).
4752
4783
  ...(hookMeta ? { capabilityMeta: { ...(meta.capabilityMeta || {}), ...hookMeta } } : {}) };
4753
- if (backend === "herdr") { next.sessionTarget = target; delete next.tmux; }
4754
- 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) };
4755
4785
  writeJsonAtomic(metaPath, next);
4756
4786
  if (clearPending) { checkRoots(); rmSync(pendingPath, { force: true }); }
4757
- 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 } : {}) };
4758
4788
  };
4759
4789
  try { mkdirSync(lock); }
4760
4790
  catch (e) {
@@ -4772,8 +4802,7 @@ export function startInstanceSession(home, o = {}) {
4772
4802
  let pending;
4773
4803
  try { pending = JSON.parse(readFileSync(pendingPath, "utf8")); } catch { pending = undefined; }
4774
4804
  const pt = pending?.target;
4775
- const validTarget = pt?.backend === "herdr" ? validHerdrTarget(pt)
4776
- : 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);
4777
4806
  let validCommand = false;
4778
4807
  try { parseLaunchCommand(pending?.command); validCommand = true; } catch { /* preserve invalid receipt below */ }
4779
4808
  let validLaunch = true;
@@ -4785,13 +4814,12 @@ export function startInstanceSession(home, o = {}) {
4785
4814
  && (pending.model === null || (typeof pending.model === "string" && !!pending.model.trim() && !pending.model.includes("\0")))
4786
4815
  && typeof pending.startedAt === "string" && Number.isFinite(Date.parse(pending.startedAt));
4787
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`);
4788
- const pbackend = pending.target.backend === "herdr" ? "herdr" : "tmux";
4789
4817
  let st;
4790
4818
  checkRoots();
4791
4819
  try { st = inspectSessionTarget(pending.target, o.io); }
4792
4820
  catch (e) {
4793
- if (pbackend === "tmux" && lostTmuxServer(e)) st = { present: false, state: "stopped" };
4794
- 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`);
4795
4823
  }
4796
4824
  // A launch may still consist entirely of shells (startup files, a
4797
4825
  // shell-script harness). Only its own completion marker proves this
@@ -4803,7 +4831,7 @@ export function startInstanceSession(home, o = {}) {
4803
4831
  // Reconcile even an exited target: the independent baseline may
4804
4832
  // already name it while metadata still names the old allocation.
4805
4833
  const meta = readMeta();
4806
- 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");
4807
4835
  if (st.present && st.state !== "shell") {
4808
4836
  if (o.restart) { rmSync(pendingPath, { force: true }); }
4809
4837
  else {
@@ -4821,7 +4849,6 @@ export function startInstanceSession(home, o = {}) {
4821
4849
  const meta = readMeta();
4822
4850
  const harness = meta.harness;
4823
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`);
4824
- const backend = meta.sessionTarget || meta.backend === "herdr" ? "herdr" : "tmux";
4825
4852
  let command = meta.command;
4826
4853
  let model = meta.model || undefined;
4827
4854
  // What this start launches: the frozen command (optionally with another
@@ -4865,7 +4892,6 @@ export function startInstanceSession(home, o = {}) {
4865
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`); }
4866
4893
  const paneEnv = recipeForEnv ? launchEnvRefs(recipeForEnv, o.env || process.env) : [];
4867
4894
  const paneEnvFlags = paneEnv.flatMap((r) => ["-e", `${r.name}=${r.value}`]);
4868
- const paneEnvExports = paneEnv.map((r) => `export ${r.name}=${shq(r.value)}; `).join("");
4869
4895
  checkRoots(); // launch hooks/preparation have run; no backend has been observed
4870
4896
  const planExtra = launchPlan ? { launch: launchPlan.recipe, harness: launchPlan.harness, yolo: launchPlan.yolo, ...(launchPlan.modelFrom ? { modelFrom: launchPlan.modelFrom } : {}),
4871
4897
  ...(launchPlan.launchFrom ? { launchFrom: launchPlan.launchFrom, launchAt: launchPlan.launchAt, launchDeclared: launchPlan.launchDeclared } : {}),
@@ -4876,7 +4902,7 @@ export function startInstanceSession(home, o = {}) {
4876
4902
  if (target) {
4877
4903
  try { state = inspectSessionTarget(target, o.io); }
4878
4904
  catch (e) {
4879
- if (backend === "tmux" && lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
4905
+ if (lostTmuxServer(e)) { serverGone = true; state = { present: false, state: "stopped" }; }
4880
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}`);
4881
4907
  }
4882
4908
  }
@@ -4890,88 +4916,76 @@ export function startInstanceSession(home, o = {}) {
4890
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);
4891
4917
  appendEvent(realHome, { kind: stopReceipt.exited ? "restarted" : "stop-refused", data: { phase: "restart-stop", signal: stopReceipt.signal, waitedMs: stopReceipt.waitedMs, stillRunning: stopReceipt.stillRunning ?? [] } });
4892
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")}`);
4893
- 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}`); }
4894
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`);
4895
4921
  }
4922
+ // The kernel that launches the harness is the one the agent's plain `oats` runs.
4923
+ checkRoots();
4924
+ writeKernelShim(realHome);
4896
4925
  const startedAt = new Date().toISOString();
4897
4926
  const id = randomUUID();
4898
4927
  checkRoots();
4899
4928
  const executionCommand = nativeRecordCommand(command, realHome, launchPlan?.harness || harness);
4900
4929
  const completedCommand = `${executionCommand}; oats_start_status=$?; printf '%s\\n' ${shq(id)} > ${shq(exitedPath)}`;
4901
4930
  let reused = "new";
4902
- if (backend === "herdr") {
4903
- if (!target) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `this never-launched Herdr home has no saved server endpoint; no tmux fallback was started`);
4904
- const base = { backend: "herdr", binary: target.binary, socket: target.socket, protocol: target.protocol };
4905
- if (state.present) { reused = "pane"; }
4906
- else {
4907
- try { herdrSnapshot(base, o.io); }
4908
- catch (e) { throw oatsError("E_SESSION_UNKNOWN", `Herdr server on ${base.socket} is not reachable, so nothing was started: ${e.message}`); }
4909
- target = allocateHerdr(base, { home: realHome, instance: meta.instance }, o.io);
4910
- }
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) {
4911
4939
  checkRoots();
4912
4940
  writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
4913
- try { launchHerdr(target, `${paneEnvExports}cd ${shq(realHome)} && ${completedCommand}; exit "$oats_start_status"`, o.io); }
4914
- 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";
4915
4944
  } else {
4916
- const session = target?.session || meta.tmux?.session || DEFAULT_TMUX_SESSION;
4917
- const window = target?.window || meta.tmux?.window || meta.instance;
4918
- let socket = target?.socket || meta.tmux?.socket;
4919
- const windowCmd = `${completedCommand}; exec "\${SHELL:-/bin/zsh}"`;
4920
- // A fallback shell (no harness descendant) or a retained dead pane is
4921
- // the agent's own pane: the command runs there, no other window touched.
4922
- const inPlace = state.paneId && (state.present || state.state === "stopped");
4923
- if (inPlace) {
4924
- checkRoots();
4925
- writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
4926
- try { tmuxOn(socket, ["respawn-pane", "-k", "-t", state.paneId, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
4927
- catch (e) { throw launchFailure("tmux", e); }
4928
- reused = "pane";
4929
- } else {
4930
- const instancesRoot = dirname(realHome);
4931
- const hq = existsSync(dirname(dirname(instancesRoot))) ? dirname(dirname(instancesRoot)) : realHome;
4932
- checkRoots();
4933
- if (!socket) {
4934
- // Never launched (--no-launch): the default server, as spawn uses.
4935
- const defaultTmux = args => guardedExec("tmux", args, { encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }).trim();
4936
- let alive = false;
4937
- try { defaultTmux(["has-session", "-t", session]); alive = true; } catch (e) { checkRoots(); }
4938
- if (!alive) {
4939
- defaultTmux(["new-session", "-d", "-s", session, "-n", "hq", "-c", hq]);
4940
- for (const option of [["window-size", "latest"], ["aggressive-resize", "on"]]) {
4941
- try { defaultTmux(["set-option", "-t", session, "-g", ...option]); } catch (e) { checkRoots(); }
4942
- }
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(); }
4943
4957
  }
4944
- socket = defaultTmux(["display-message", "-p", "-t", session, "#{socket_path}"]);
4945
- if (!socket) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", "tmux did not report its socket");
4946
- } else if (serverGone) {
4947
- // The recorded server is gone (a reboot): the same socket path again.
4948
- mkdirSync(dirname(socket), { recursive: true });
4949
- tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
4950
- tmuxOn(socket, ["set-option", "-t", session, "-g", "window-size", "latest"], o.io);
4951
- tmuxOn(socket, ["set-option", "-t", session, "-g", "aggressive-resize", "on"], o.io);
4952
4958
  }
4953
- let names = [];
4954
- try { names = tmuxOn(socket, ["list-windows", "-t", `=${session}`, "-F", "#{window_name}"], o.io).split("\n").filter(Boolean); }
4955
- catch (e) {
4956
- 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()}`);
4957
- tmuxOn(socket, ["new-session", "-d", "-s", session, "-n", "hq", "-c", hq], o.io);
4958
- }
4959
- if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
4960
- target = { backend: "tmux", session, window, socket: resolve(socket) };
4961
- checkRoots();
4962
- writeJsonAtomic(pendingPath, { id, target, command, model: model ?? null, startedAt, ...planExtra }, 0o600);
4963
- try { tmuxOn(socket, ["new-window", "-t", `=${session}:`, "-n", window, "-c", realHome, ...paneEnvFlags, windowCmd], o.io); }
4964
- 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);
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);
4965
4973
  }
4974
+ if (names.includes(window)) throw oatsError("E_SESSION_RUNNING", `tmux window ${session}:${window} appeared on ${socket} during the start; nothing was started`);
4966
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); }
4967
4980
  }
4981
+ target = { backend: "tmux", session, window, socket: resolve(socket) };
4968
4982
  // Keep launch evidence until the command exits or the target disappears.
4969
4983
  // A transient child (for example cat TASK.md) is not proof that startup
4970
4984
  // has finished. A later start reconciles the receipt without a watcher.
4971
- 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 }; }
4972
4986
  catch (e) {
4973
4987
  if (e.code && String(e.code).startsWith("E_")) throw e;
4974
- 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`);
4975
4989
  }
4976
4990
  } finally {
4977
4991
  // A hook may have replaced the home itself. Never follow that replacement
@@ -5529,7 +5543,10 @@ export function retireInstance(root, name, o = {}) {
5529
5543
  // persists the intent and hands the whole retirement to a detached process
5530
5544
  // that runs it as an ordinary EXTERNAL retirement once the harness is gone
5531
5545
  // (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
5532
- 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)) {
5533
5550
  return scheduleDeferredSelfRetirement(root, found, name, o, session);
5534
5551
  }
5535
5552
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
@@ -5540,7 +5557,13 @@ export function retireInstance(root, name, o = {}) {
5540
5557
  // describe it for humans, but only the independent baseline can authorize the
5541
5558
  // endpoint that proves quiescence.
5542
5559
  let runtimeAuthority;
5543
- 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) {
5544
5567
  runtimeAuthority = initialObservation.runtimeAuthority;
5545
5568
  // A home without its receipt (spawned before 0.25.9) has nothing OATS may
5546
5569
  // quiesce; when its session is observably absent, quiescing is vacuous and
@@ -5553,12 +5576,7 @@ export function retireInstance(root, name, o = {}) {
5553
5576
  }
5554
5577
  }
5555
5578
  if (runtimeAuthority) {
5556
- const endpointAgrees = runtimeAuthority.sessionTarget
5557
- ? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === runtimeAuthority.sessionTarget[key])
5558
- : !meta.sessionTarget && meta.tmux?.session === runtimeAuthority.tmux?.session
5559
- && meta.tmux?.window === runtimeAuthority.tmux?.window
5560
- && resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux?.socket;
5561
- const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || endpointAgrees);
5579
+ const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || tmuxEndpointAgrees(meta, runtimeAuthority));
5562
5580
  if (!metaAgrees) {
5563
5581
  throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
5564
5582
  }
@@ -5566,11 +5584,7 @@ export function retireInstance(root, name, o = {}) {
5566
5584
  // `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
5567
5585
  // A no-launch instance is already quiesced. A launched one must have exact
5568
5586
  // window absence established before recovery copying begins.
5569
- if (!self && runtimeAuthority?.launched && runtimeAuthority.sessionTarget) {
5570
- try { stopHerdr(runtimeAuthority.sessionTarget); }
5571
- catch (e) { throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that Herdr session for ${name} stopped: ${e.message}`); }
5572
- }
5573
- if (!self && runtimeAuthority?.launched && !runtimeAuthority.sessionTarget) {
5587
+ if (!self && runtimeAuthority?.launched) {
5574
5588
  const runtimeSession = runtimeAuthority.tmux.session;
5575
5589
  const runtimeWindow = runtimeAuthority.tmux.window;
5576
5590
  const runtimeSocket = runtimeAuthority.tmux.socket;
@@ -5870,7 +5884,10 @@ export function retireInstance(root, name, o = {}) {
5870
5884
  // The caller is the instance: its process lives in the window we are about to
5871
5885
  // kill. Detach the kill so this function can return and the caller can report
5872
5886
  // before dying. The delay is the caller's window to print its last words.
5873
- 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'`);
5874
5891
  result.selfKillScheduled = true;
5875
5892
  }
5876
5893
  return result;