@awebai/oats 0.22.1 → 0.22.2

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
@@ -23,17 +23,20 @@
23
23
  * soul.yaml (flat key: value):
24
24
  * name, description, kind (persistent|local), type (optional agent-type/family, targeted by config),
25
25
  * repo (path rel. to workspace or absolute),
26
- * work (worktree|checkout|attached), runtime (pi|claude), model (pi model pattern, optional)
26
+ * work (worktree|checkout|attached), runtime (pi|claude|codex), model (pi model pattern, optional)
27
27
  * (attached as soul default is for service agents — spawn must supply workDir)
28
28
  */
29
- import { execFileSync, execSync } from "node:child_process";
29
+ import { execFileSync, execSync, spawn as spawnProcess } from "node:child_process";
30
30
  import {
31
- chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
31
+ chmodSync, closeSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, writeFileSync,
32
32
  } from "node:fs";
33
33
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
34
34
  import { homedir, tmpdir } from "node:os";
35
35
  import { createHash } from "node:crypto";
36
36
  import { fileURLToPath } from "node:url";
37
+ import { attachSessionTarget } from "./session-viewer.mjs";
38
+ import { inspectSessionTarget, inputSessionTarget } from "./session-input.mjs";
39
+ import { ensureHerdr, allocateHerdr, launchHerdr, inspectHerdr, stopHerdr, validHerdrTarget } from "./herdr.mjs";
37
40
 
38
41
  export const RESERVED = new Set(["bin", "local-agents", "tmp-agents"]);
39
42
  /** The work modes spawn accepts — also the enum a quarantine cleanup descriptor
@@ -426,7 +429,7 @@ export const RETIRED_CAPABILITIES = {
426
429
  export function retiredCapabilityReason(id) {
427
430
  return Object.hasOwn(RETIRED_CAPABILITIES, id) ? RETIRED_CAPABILITIES[id] : undefined;
428
431
  }
429
- const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates"]);
432
+ const CONFIG_KEYS = new Set(["name", "team", "agent-types", "capabilities", "skill-overrides", "agents-md-injection", "oats", "work-modes", "templates", "yolo"]);
430
433
  /** Renamed-key tables are read with OWN-property semantics only: a config key
431
434
  * spelled `constructor`/`toString` inherits a value from `Object.prototype`,
432
435
  * and the plain `TABLE[key]` lookup then reported it as the migration hint —
@@ -467,6 +470,7 @@ function loadLevelConfig(dir) {
467
470
  * Shared by the level loader and package profile validation (a profile is
468
471
  * config source material and must pass the same shape checks). */
469
472
  export function validateConfigShape(cfg, file) {
473
+ if (cfg.yolo !== undefined && typeof cfg.yolo !== "boolean") throw new Error(`yolo in ${file} must be true or false`);
470
474
  for (const key of Object.keys(cfg)) {
471
475
  if (Object.hasOwn(RENAMED_CONFIG_KEYS, key)) throw new Error(`unsupported oats-config key "${key}" in ${file} — ${RENAMED_CONFIG_KEYS[key]}`);
472
476
  if (!CONFIG_KEYS.has(key)) throw new Error(`unsupported oats-config key in ${file}: ${key}`);
@@ -725,6 +729,8 @@ export function resolveCapabilities(contextDir, soulName) {
725
729
  export function resolveOatsConfig(contextDir, soulName) {
726
730
  const chain = configChain(contextDir);
727
731
  const out = { layers: {}, provenance: {}, layerDisabled: {}, injects: [], capabilities: [], name: chain[0]?.name, chain };
732
+ const yoloCfg = chain.find((c) => c.yolo !== undefined);
733
+ if (yoloCfg) out.yolo = yoloCfg.yolo;
728
734
  // Closest team: declaration wins; the declaring scope is the deployment/team boundary.
729
735
  const teamCfg = chain.find((c) => c.team);
730
736
  if (teamCfg) out.team = { ...teamCfg.team, scope: teamCfg._level };
@@ -4716,13 +4722,22 @@ function runSoulScaffoldHooks(args) {
4716
4722
  if (Object.keys(owners).length) writeFileSync(ownersFile, JSON.stringify(owners, null, 2) + "\n");
4717
4723
  }
4718
4724
 
4719
- export function writeSoul(root, { name, kind, repo, work, runtime, model, description, type, instructions }) {
4725
+ /** Flat soul YAML uses strings; never treat the string "false" as truthy. */
4726
+ export function resolveYolo(value) {
4727
+ if (value === undefined) return undefined;
4728
+ if (value === true || value === "true") return true;
4729
+ if (value === false || value === "false") return false;
4730
+ throw new Error("yolo must be true or false");
4731
+ }
4732
+
4733
+ export function writeSoul(root, { name, kind, repo, work, runtime, model, yolo, description, type, instructions }) {
4734
+ yolo = resolveYolo(yolo);
4720
4735
  const agentDir = agentDirOf(root, name, kind);
4721
4736
  const soulDir = soulOf(agentDir);
4722
4737
  mkdirSync(soulDir, { recursive: true });
4723
4738
  mkdirSync(join(agentDir, "instances"), { recursive: true });
4724
4739
  writeFileSync(join(soulDir, "soul.yaml"), yamlFlat({
4725
- name, kind, description, type, repo, work: work || "checkout", runtime: runtime || "pi", model,
4740
+ name, kind, description, type, repo, work: work || "checkout", runtime: runtime || "pi", model, yolo,
4726
4741
  }));
4727
4742
  const agentsMd = join(soulDir, "AGENTS.md");
4728
4743
  if (instructions !== undefined || !existsSync(agentsMd)) {
@@ -4767,7 +4782,7 @@ export function createAgent(root, o) {
4767
4782
  * Local souls are full souls — same scaffold and memory as persistent ones —
4768
4783
  * that live in the scope's uncommitted local-agents/. */
4769
4784
  export function upsertLocalAgent(root, o) {
4770
- let { name, instructions, description, model, repo, work, runtime } = o;
4785
+ let { name, instructions, description, model, repo, work, runtime, yolo } = o;
4771
4786
  if (o.file) {
4772
4787
  const f = resolve(o.file);
4773
4788
  if (!existsSync(f)) throw new Error(`file not found: ${f}`);
@@ -4778,6 +4793,7 @@ export function upsertLocalAgent(root, o) {
4778
4793
  repo = repo ?? meta.repo;
4779
4794
  work = work ?? meta.work;
4780
4795
  runtime = runtime ?? meta.runtime;
4796
+ yolo = yolo ?? meta.yolo;
4781
4797
  instructions = body;
4782
4798
  }
4783
4799
  if (!name) throw new Error("local agent requires a name");
@@ -4789,7 +4805,7 @@ export function upsertLocalAgent(root, o) {
4789
4805
  writeSoul(root, {
4790
4806
  name, kind: "local",
4791
4807
  repo: repo ?? existing?.repo, work: work ?? existing?.work,
4792
- runtime: runtime ?? existing?.runtime, model: model ?? existing?.model,
4808
+ runtime: runtime ?? existing?.runtime, model: model ?? existing?.model, yolo: yolo ?? existing?.yolo,
4793
4809
  description: description ?? existing?.description, instructions,
4794
4810
  });
4795
4811
  return findAgent(root, name);
@@ -4909,9 +4925,19 @@ export function resolveClaudeBinary(contextDir) {
4909
4925
  * pi: checked against `pi --list-models <pattern>` (authenticated providers).
4910
4926
  * claude: pi-style patterns are translated (anthropic/<id> → <id>) or dropped —
4911
4927
  * claude takes aliases/bare claude-* ids only; nothing usable → "" (claude default).
4912
- * Unknown runtimes or probe failures: first entry wins (pi errors loudly at launch). */
4928
+ * codex: translate openai/openai-codex entries to native ids, otherwise use its default.
4929
+ * Probe failures: first entry wins (pi errors loudly at launch). */
4913
4930
  export function resolveModelPreference(model, runtime = "pi") {
4914
4931
  const prefs = String(model || "").split(",").map((s) => s.trim()).filter(Boolean);
4932
+ if (runtime === "codex") {
4933
+ for (const pref of prefs) {
4934
+ const bare = pref.replace(/:[a-z]+$/i, "");
4935
+ if (!bare.includes("/")) return bare;
4936
+ const [provider, ...rest] = bare.split("/");
4937
+ if (["openai", "openai-codex"].includes(provider) && rest.length) return rest.join("/");
4938
+ }
4939
+ return ""; // let Codex use its configured model rather than another provider's id
4940
+ }
4915
4941
  if (runtime === "claude") {
4916
4942
  // Claude accepts its aliases and bare claude-* ids — NOT pi-style
4917
4943
  // "provider/model[:thinking]" patterns. Agents whose soul default is a
@@ -5017,8 +5043,12 @@ export function spawnInstance(root, agent, o = {}) {
5017
5043
  if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
5018
5044
  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`);
5019
5045
  const runtime = o.runtime || agent.runtime || "pi";
5046
+ if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
5020
5047
  const model = resolveModelPreference(o.model || agent.model || "", runtime);
5021
5048
  const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
5049
+ const backend = o.backend || agent.backend || "tmux";
5050
+ if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
5051
+ if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
5022
5052
  const launch = o.launch !== false;
5023
5053
  const repoAbs = resolveRepo(root, o.repo || agent.repo);
5024
5054
  if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
@@ -5324,6 +5354,7 @@ export function spawnInstance(root, agent, o = {}) {
5324
5354
  const soulDir = agent._soulDir || soulOf(agent._dir);
5325
5355
  const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind);
5326
5356
  const resolvedCfg = composition.resolved;
5357
+ const yolo = resolveYolo(o.yolo ?? agent.yolo ?? resolvedCfg.yolo);
5327
5358
  const expectedResources = planInstanceResources({ resolved: resolvedCfg, soulDir, agent, contextDir: repoAbs, composition });
5328
5359
  // Runtime extensions selected by ACTIVE capabilities for THIS instance's
5329
5360
  // runtime. Strict launch disables ambient extension discovery, so each one has
@@ -5335,9 +5366,10 @@ export function spawnInstance(root, agent, o = {}) {
5335
5366
 
5336
5367
  // Prerequisites must fail before creating a home, worktree, or identity.
5337
5368
  const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
5338
- const bin = which(runtime === "claude" ? claudeBin : "pi");
5369
+ const bin = which(runtime === "claude" ? claudeBin : runtime);
5339
5370
  if (!bin) throw new Error(`${runtime === "claude" ? claudeBin : runtime} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}`);
5340
- if (launch && !which("tmux")) throw new Error("tmux not installed (brew install tmux)");
5371
+ if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
5372
+ const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
5341
5373
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
5342
5374
 
5343
5375
  mkdirSync(home, { recursive: true });
@@ -5524,6 +5556,7 @@ export function spawnInstance(root, agent, o = {}) {
5524
5556
  const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
5525
5557
  let windowMayExist = false;
5526
5558
  let spawnTmux;
5559
+ let spawnHerdr;
5527
5560
  const ancillaryCleanup = [];
5528
5561
  // One compensation owner, from the first hook result through launch and
5529
5562
  // the final lineage write. Preserve the original failure and retain any
@@ -5543,7 +5576,23 @@ export function spawnInstance(root, agent, o = {}) {
5543
5576
  const outstandingGit = new Set();
5544
5577
  // A failed new-window command may still have created its window. Verify
5545
5578
  // quiescence before removing credentials or work that runtime may be using.
5546
- if (windowMayExist) {
5579
+ if (windowMayExist && spawnHerdr) {
5580
+ try { stopHerdr(spawnHerdr); }
5581
+ catch (e) {
5582
+ incomplete.push(`Herdr session: ${e.message}`);
5583
+ for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5584
+ if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
5585
+ return quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
5586
+ repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
5587
+ launched: true, sessionTarget: spawnHerdr, recordRetirementBaseline: true });
5588
+ }
5589
+ }
5590
+ if (windowMayExist && backend === "herdr" && !spawnHerdr) {
5591
+ // Allocation starts only an empty shell; the harness command has not run.
5592
+ // A lost receipt cannot authorize closing an unidentified terminal.
5593
+ incomplete.push(`Herdr workspace allocation may have completed on ${herdrBase.socket} (label ${instance}, cwd ${home}); inspect and remove any empty workspace manually`);
5594
+ }
5595
+ if (windowMayExist && backend === "tmux") {
5547
5596
  shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
5548
5597
  const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
5549
5598
  const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
@@ -5655,7 +5704,7 @@ export function spawnInstance(root, agent, o = {}) {
5655
5704
  You are instance "${instance}" of agent "${agent.name}".
5656
5705
  - Home: ${home}${resolvedCfg.team ? `\n- Team: ${resolvedCfg.team.name}${resolvedCfg.team.id ? ` (${resolvedCfg.team.id})` : ""} — see teammates with \`oats status --team\`` : ""}
5657
5706
  - Work tree: ./work — ${workDesc}
5658
- - Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}
5707
+ - Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}${runtime === "codex" ? "\n## Runtime notification delivery\n\nNative Codex has no built-in messaging channel. Follow the explicit delivery briefing for this instance from your messaging capability, if present; it may arrange notification through this terminal. Shared channel instructions alone do not establish that delivery is configured. Without an instance delivery briefing, check your messaging capability's inbox and pending commands at task boundaries or when the operator asks; do not assume messages will wake this session.\n" : ""}
5659
5708
  ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
5660
5709
 
5661
5710
  // Launch command. Spawn IS session start: this command is persisted in
@@ -5672,7 +5721,18 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5672
5721
  // the TASK.md text is swallowed as that flag's next value — claude
5673
5722
  // errors out ("entries must be tagged: <task text>") and the window
5674
5723
  // drops to the fallback shell, which reads as a silently stuck spawn.
5675
- cmdline = `${shq(bin)}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5724
+ cmdline = `${shq(bin)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5725
+ } else if (runtime === "codex") {
5726
+ // Codex discovers this home's AGENTS.md and .agents/skills natively.
5727
+ // Keep the operator's native permission policy. --add-dir is not valid
5728
+ // under every policy (including untrusted/read-only startup). Worktrees
5729
+ // already live below home; external paths use native approval handling.
5730
+ // --yolo bypasses execution approvals but Codex still asks to trust a new
5731
+ // project. The operator's explicit yolo choice also trusts this generated
5732
+ // home for this launch, without editing the shared user config.
5733
+ const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
5734
+ cmdline = `${shq(bin)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}`
5735
+ + `${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5676
5736
  } else {
5677
5737
  // STRICT CURRICULUM (pi): the OATS-composed set — no user, ancestor, project
5678
5738
  // or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
@@ -5729,6 +5789,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5729
5789
  const meta = {
5730
5790
  agent: agent.name, kind: agent.kind || "persistent", instance, home,
5731
5791
  repo: repoAbs, work, branch, runtime, model: model || undefined,
5792
+ ...(yolo !== undefined ? { yolo } : {}),
5732
5793
  team: resolvedCfg.team || undefined,
5733
5794
  parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
5734
5795
  siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
@@ -5768,7 +5829,11 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5768
5829
  ambient: ["user skills", "project and ancestor skills to the repository root", "user and project plugins", "user and project settings", "user and ancestor CLAUDE.md"],
5769
5830
  why: "founder ruling: Claude Code's own global and per-repo configuration stays enabled — it is powerful, and the operator decides. An all-OATS setup is the way to opt out.",
5770
5831
  }
5771
- : {
5832
+ : runtime === "codex" ? {
5833
+ oatsComposed: "skills via .agents/skills; instructions via AGENTS.md; task via initial prompt",
5834
+ ambient: ["user and ancestor instructions", "user, project, admin and system skills", "user and project configuration and MCP servers"],
5835
+ why: yolo ? "Codex keeps native configuration with approval prompts and sandbox disabled by the OATS yolo setting." : "Codex keeps the operator's native configuration and approval policy, including approval handling for work paths outside the home.",
5836
+ } : {
5772
5837
  oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
5773
5838
  curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
5774
5839
  ambient: ["globally configured pi extensions, and any resources they contribute"],
@@ -5784,13 +5849,21 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5784
5849
  missingRequires: cap.missingRequires, trust: cap.trust,
5785
5850
  executable: cap.executable,
5786
5851
  })),
5787
- tmux: { session, window: instance },
5852
+ ...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
5788
5853
  command: cmdline, createdAt: new Date().toISOString(),
5789
5854
  };
5790
5855
  const spawnWarnings = warnings;
5791
5856
 
5792
5857
  spawnTmux = meta.tmux;
5793
- if (launch) {
5858
+ if (launch && backend === "herdr") {
5859
+ windowMayExist = true;
5860
+ spawnHerdr = allocateHerdr(herdrBase, { home, instance });
5861
+ meta.sessionTarget = spawnHerdr;
5862
+ meta.launched = true;
5863
+ writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5864
+ writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
5865
+ launchHerdr(spawnHerdr, cmdline);
5866
+ } else if (launch) {
5794
5867
  if (!tmuxAlive(session)) {
5795
5868
  const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
5796
5869
  sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
@@ -5844,7 +5917,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5844
5917
  }
5845
5918
  }
5846
5919
 
5847
- return { ...meta, attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
5920
+ return { ...meta, 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 };
5848
5921
  } catch (error) {
5849
5922
  const note = compensateSpawn();
5850
5923
  error.message += note;
@@ -5856,8 +5929,11 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
5856
5929
  const windows = tmuxWindows(tmuxSession);
5857
5930
  const readInstancesOf = (agentDir) => {
5858
5931
  const instancesDir = join(agentDir, "instances");
5932
+ // An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
5933
+ // dot-directory under instances/ is kernel bookkeeping (.oats-retirement
5934
+ // holds baselines and recoveries), never a home (oats-5xl).
5859
5935
  return (existsSync(instancesDir) ? readdirSync(instancesDir, { withFileTypes: true }) : [])
5860
- .filter((e) => e.isDirectory())
5936
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
5861
5937
  .map((e) => {
5862
5938
  const metaPath = join(instancesDir, e.name, "instance.json");
5863
5939
  const home = join(instancesDir, e.name);
@@ -5872,12 +5948,47 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
5872
5948
  try { rollbackIncomplete = JSON.parse(readFileSync(quarantine, "utf8")); }
5873
5949
  catch { rollbackIncomplete = { reason: "rollback incomplete" }; }
5874
5950
  }
5875
- return { ...meta, running: windows.includes(meta.instance || e.name), ...(rollbackIncomplete ? { rollbackIncomplete } : {}) };
5951
+ // A self-retire that has been requested but not completed: the home is
5952
+ // owed a retirement, not live work. Read-only here — completion is the
5953
+ // detached child's or an operator's `oats retire`, never status.
5954
+ const pending = retirePendingMarkerPath(home);
5955
+ let retirePending;
5956
+ if (existsSync(pending)) {
5957
+ try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
5958
+ catch { retirePending = { reason: "retire pending" }; }
5959
+ }
5960
+ let liveness = { running: windows.includes(meta.instance || e.name) };
5961
+ if (meta.sessionTarget) {
5962
+ try { const state = inspectHerdr({ ...meta.sessionTarget, binary: "herdr" }); liveness = { running: state.present, runtimeState: state.status }; }
5963
+ catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
5964
+ }
5965
+ return { ...meta, ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
5966
+
5876
5967
  });
5877
5968
  };
5969
+ // Failed deferred self-retirements leave their outcome beside the home; a
5970
+ // successful one is evidence only and is not a problem to surface.
5971
+ const readRetireFailuresOf = (agentDir) => {
5972
+ const instancesDir = join(agentDir, "instances");
5973
+ if (!existsSync(instancesDir)) return [];
5974
+ const out = [];
5975
+ for (const e of readdirSync(instancesDir, { withFileTypes: true })) {
5976
+ const m = e.isFile() && /^\.oats-retired-(.+)\.json$/.exec(e.name);
5977
+ if (!m) continue;
5978
+ try {
5979
+ const r = JSON.parse(readFileSync(join(instancesDir, e.name), "utf8"));
5980
+ if (r.ok === false) out.push({ instance: m[1], completedAt: r.completedAt, error: r.error?.message, incomplete: r.result?.rollbackIncomplete, retry: r.retry, resultPath: join(instancesDir, e.name) });
5981
+ } catch { out.push({ instance: m[1], error: "unreadable result file", resultPath: join(instancesDir, e.name) }); }
5982
+ }
5983
+ return out;
5984
+ };
5985
+ const withFailures = (entry, agentDir) => {
5986
+ const retireFailures = readRetireFailuresOf(agentDir);
5987
+ return retireFailures.length ? { ...entry, retireFailures } : entry;
5988
+ };
5878
5989
  const out = listAgents(root).map((a) => {
5879
5990
  const { _dir, ...soul } = a;
5880
- return { ...soul, dir: _dir, instances: readInstancesOf(a._dir) };
5991
+ return withFailures({ ...soul, dir: _dir, instances: readInstancesOf(a._dir) }, a._dir);
5881
5992
  });
5882
5993
  // Capability-defined agents home under local-agents/<name>/ WITHOUT a local
5883
5994
  // soul (it lives read-only in the package) — surface their instances too.
@@ -5887,9 +5998,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
5887
5998
  for (const e of readdirSync(dir, { withFileTypes: true })) {
5888
5999
  if (!e.isDirectory() || seen.has(e.name)) continue;
5889
6000
  const instances = readInstancesOf(join(dir, e.name));
5890
- if (!instances.length) continue;
6001
+ const retireFailures = readRetireFailuresOf(join(dir, e.name));
6002
+ if (!instances.length && !retireFailures.length) continue;
5891
6003
  const cap = instances.find((i) => i.capability)?.capability;
5892
- out.push({ name: e.name, kind: "capability", capability: cap, description: cap ? `capability agent (${cap})` : "capability agent", dir: join(dir, e.name), instances });
6004
+ out.push({ name: e.name, kind: "capability", capability: cap, description: cap ? `capability agent (${cap})` : "capability agent", dir: join(dir, e.name), instances, ...(retireFailures.length ? { retireFailures } : {}) });
5893
6005
  seen.add(e.name);
5894
6006
  }
5895
6007
  }
@@ -5967,7 +6079,7 @@ export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
5967
6079
 
5968
6080
  /** Retain the home and its cleanup receipt when spawn compensation or retirement
5969
6081
  * cannot finish. Keeping the original credentials makes cleanup retryable. */
5970
- function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason }) {
6082
+ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason }) {
5971
6083
  try {
5972
6084
  writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
5973
6085
  // `reason` is optional and DEFAULTS to the spawn wording, so every existing
@@ -5983,6 +6095,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
5983
6095
  cleanup: {
5984
6096
  version: QUARANTINE_CLEANUP_VERSION,
5985
6097
  repo: repoAbs, work, branch, launched, tmux,
6098
+ ...(sessionTarget ? { sessionTarget } : {}),
5986
6099
  outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
5987
6100
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
5988
6101
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
@@ -5997,7 +6110,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
5997
6110
  } catch { /* the quarantine still stands without its marker */ }
5998
6111
  if (recordRetirementBaseline) {
5999
6112
  try {
6000
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux });
6113
+ writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
6001
6114
  } catch (e) {
6002
6115
  incomplete.push(`independent retirement authority: ${e.message}`);
6003
6116
  }
@@ -6071,6 +6184,15 @@ function retirementBaselinePath(home) {
6071
6184
  return join(retirementStateRoot(home), "baselines", `${retirementKey(home)}.json`);
6072
6185
  }
6073
6186
 
6187
+ /** git's output for a large tree (status with untracked and ignored files,
6188
+ * the index listing, a large blob) easily exceeds Node's 1 MiB default child
6189
+ * buffer; the spawn then dies with ENOBUFS and retirement reports the
6190
+ * recovery as unverifiable (cjr, ~9500 tracked long paths). Every git call
6191
+ * on the retirement path gets this bound instead. It raises the practical
6192
+ * limit, it does not remove it: a single blob over 512 MiB would still fail,
6193
+ * loudly, and streaming is deliberately not attempted in this change. */
6194
+ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
6195
+
6074
6196
  function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
6075
6197
  const hash = createHash("sha256");
6076
6198
  const rootStat = lstatSync(root);
@@ -6100,7 +6222,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
6100
6222
 
6101
6223
  function worktreeStatus(repo) {
6102
6224
  try {
6103
- return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
6225
+ return execFileSync("git", ["-C", repo, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6104
6226
  } catch (e) {
6105
6227
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect instance worktree: ${String(e.stderr ?? e.message ?? "").trim() || "git status failed"}`);
6106
6228
  }
@@ -6151,6 +6273,7 @@ function writeRetirementBaseline(home, work, isWorktree, workMode, capabilities,
6151
6273
  runtime: {
6152
6274
  launched: runtime?.launched === true,
6153
6275
  ...(runtime?.tmux ? { tmux: { session: runtime.tmux.session, window: runtime.tmux.window, ...(runtime.tmux.socket ? { socket: resolve(runtime.tmux.socket) } : {}) } } : {}),
6276
+ ...(runtime?.sessionTarget ? { sessionTarget: runtime.sessionTarget } : {}),
6154
6277
  },
6155
6278
  };
6156
6279
  const path = retirementBaselinePath(home);
@@ -6176,7 +6299,7 @@ function branchOnlyCommits(repo, branch) {
6176
6299
  if (!repo || !branch) return [];
6177
6300
  const target = `refs/heads/${branch}`;
6178
6301
  try {
6179
- execFileSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", target], { stdio: ["ignore", "pipe", "pipe"] });
6302
+ execFileSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", target], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6180
6303
  } catch (e) {
6181
6304
  const detail = String(e.stderr ?? "").trim();
6182
6305
  // A quarantine retry may follow a successful rollback-owned branch removal.
@@ -6185,9 +6308,9 @@ function branchOnlyCommits(repo, branch) {
6185
6308
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${detail || String(e.message ?? "").trim() || "git ref probe failed"}`);
6186
6309
  }
6187
6310
  try {
6188
- const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
6311
+ const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER })
6189
6312
  .split("\n").filter((ref) => ref && ref !== target);
6190
- return execFileSync("git", ["-C", repo, "rev-list", target, ...(refs.length ? ["--not", ...refs] : [])], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim().split("\n").filter(Boolean);
6313
+ return execFileSync("git", ["-C", repo, "rev-list", target, ...(refs.length ? ["--not", ...refs] : [])], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], maxBuffer: GIT_MAX_BUFFER }).trim().split("\n").filter(Boolean);
6191
6314
  } catch (e) {
6192
6315
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${String(e.stderr ?? e.message ?? "").trim() || "git ref probe failed"}`);
6193
6316
  }
@@ -6197,11 +6320,55 @@ function runtimeAuthorityOf(baseline) {
6197
6320
  const runtime = baseline?.runtime;
6198
6321
  if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
6199
6322
  if (!runtime.launched) return { launched: false };
6323
+ if (runtime.sessionTarget !== undefined) {
6324
+ if (runtime.tmux || !validHerdrTarget(runtime.sessionTarget)) return undefined;
6325
+ return { launched: true, sessionTarget: runtime.sessionTarget };
6326
+ }
6200
6327
  const tmux = runtime.tmux;
6201
6328
  if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
6202
6329
  return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
6203
6330
  }
6204
6331
 
6332
+ /** Session control uses the same independent endpoint receipt as retirement. */
6333
+ function instanceSessionTarget(home) {
6334
+ if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
6335
+ home = realPathOrNearest(home);
6336
+ let baseline, meta;
6337
+ try {
6338
+ baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
6339
+ meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"));
6340
+ } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
6341
+ const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
6342
+ if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
6343
+ const endpointAgrees = authority.sessionTarget
6344
+ ? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === authority.sessionTarget[key])
6345
+ : !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
6346
+ if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
6347
+ return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined };
6348
+ }
6349
+
6350
+ export function inspectInstanceSession(home) {
6351
+ if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
6352
+ const s = instanceSessionTarget(home);
6353
+ if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
6354
+ try { return { home: s.home, ...inspectSessionTarget(s.target) }; }
6355
+ catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
6356
+ }
6357
+
6358
+ export async function attachInstanceSession(home) {
6359
+ const s = instanceSessionTarget(home);
6360
+ if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
6361
+ return attachSessionTarget(s.target);
6362
+ }
6363
+
6364
+ export function inputInstanceSession(home, text) {
6365
+ if (typeof text !== "string" || !text.trim() || text.includes("\0") || Buffer.byteLength(text) > 256 * 1024) throw oatsError("E_BAD_ARGS", "session input must be nonempty text without NUL, at most 256 KiB");
6366
+ const s = instanceSessionTarget(home);
6367
+ if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
6368
+ try { return { home: s.home, ...inputSessionTarget(s.target, text) }; }
6369
+ catch (e) { throw oatsError("E_SESSION_INPUT_FAILED", `cannot submit session input: ${e.message}`); }
6370
+ }
6371
+
6205
6372
  function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
6206
6373
  const classes = [];
6207
6374
  let baseline;
@@ -6248,14 +6415,14 @@ const RECOVERABLE_GIT_ADMIN = [
6248
6415
  ];
6249
6416
 
6250
6417
  function restoreStandaloneGitState(sourceWork, recoveredRepo) {
6251
- const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" }).trim();
6418
+ const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6252
6419
  const recoveredGit = join(recoveredRepo, ".git");
6253
- const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"]);
6420
+ const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"], { maxBuffer: GIT_MAX_BUFFER });
6254
6421
  for (const row of indexRows.toString("utf8").split("\0").filter(Boolean)) {
6255
6422
  const match = row.match(/^\d+ ([0-9a-f]+) \d+\t/);
6256
6423
  if (!match) continue;
6257
- const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", match[1]]);
6258
- const restored = execFileSync("git", ["-C", recoveredRepo, "hash-object", "-w", "--stdin"], { input: blob, encoding: "utf8" }).trim();
6424
+ const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", match[1]], { maxBuffer: GIT_MAX_BUFFER });
6425
+ const restored = execFileSync("git", ["-C", recoveredRepo, "hash-object", "-w", "--stdin"], { input: blob, encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6259
6426
  if (restored !== match[1]) throw new Error(`recovered Git object ${restored} did not match source ${match[1]}`);
6260
6427
  }
6261
6428
  copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
@@ -6270,11 +6437,11 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
6270
6437
 
6271
6438
  function detachRecoveryClone(source, recovered) {
6272
6439
  let stash;
6273
- try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
6440
+ try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER }).trim(); }
6274
6441
  catch { stash = undefined; }
6275
6442
  if (stash) {
6276
- execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"]);
6277
- const sourceCommon = execFileSync("git", ["-C", source, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" }).trim();
6443
+ execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"], { maxBuffer: GIT_MAX_BUFFER });
6444
+ const sourceCommon = execFileSync("git", ["-C", source, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6278
6445
  const stashLog = join(sourceCommon, "logs", "refs", "stash");
6279
6446
  if (existsSync(stashLog)) {
6280
6447
  const recoveredLog = join(recovered, ".git", "logs", "refs", "stash");
@@ -6282,17 +6449,17 @@ function detachRecoveryClone(source, recovered) {
6282
6449
  copyFileSync(stashLog, recoveredLog);
6283
6450
  }
6284
6451
  }
6285
- try { execFileSync("git", ["-C", recovered, "remote", "remove", "origin"], { stdio: "ignore" }); } catch { /* no remote is already independent */ }
6452
+ try { execFileSync("git", ["-C", recovered, "remote", "remove", "origin"], { stdio: "ignore" , maxBuffer: GIT_MAX_BUFFER }); } catch { /* no remote is already independent */ }
6286
6453
  }
6287
6454
 
6288
6455
  function materializeNestedRepositories(sourceWork, recoveredRepo) {
6289
6456
  for (const source of nestedGitRoots(sourceWork)) {
6290
6457
  const rel = relative(sourceWork, source);
6291
6458
  const dest = join(recoveredRepo, rel);
6292
- const head = execFileSync("git", ["-C", source, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
6459
+ const head = execFileSync("git", ["-C", source, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6293
6460
  rmSync(dest, { recursive: true, force: true });
6294
- execFileSync("git", ["clone", "--no-local", "--quiet", source, dest], { stdio: ["ignore", "pipe", "pipe"] });
6295
- execFileSync("git", ["-C", dest, "checkout", "--quiet", head]);
6461
+ execFileSync("git", ["clone", "--no-local", "--quiet", source, dest], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6462
+ execFileSync("git", ["-C", dest, "checkout", "--quiet", head], { maxBuffer: GIT_MAX_BUFFER });
6296
6463
  detachRecoveryClone(source, dest);
6297
6464
  restoreStandaloneGitState(source, dest);
6298
6465
  for (const e of readdirSync(source, { withFileTypes: true })) {
@@ -6319,7 +6486,7 @@ function preserveRetirementWork(observation, meta, instance) {
6319
6486
  }
6320
6487
  if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
6321
6488
  const recoveredRepo = join(staging, "repo");
6322
- execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] });
6489
+ execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6323
6490
  const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
6324
6491
  detachRecoveryClone(sourceGitContext, recoveredRepo);
6325
6492
  if (existsSync(observation.work)) {
@@ -6337,8 +6504,8 @@ function preserveRetirementWork(observation, meta, instance) {
6337
6504
  if (fingerprintTree(observation.work, { excludeRoot: new Set([".git"]), excludeGitMetadata: true }) !== fingerprintTree(recoveredRepo, { excludeRoot: new Set([".git"]), excludeGitMetadata: true })) throw new Error("worktree recovery verification disagreed with the source");
6338
6505
  if (worktreeStatus(observation.work) !== worktreeStatus(recoveredRepo)) throw new Error("recovered Git index/status disagreed with the source");
6339
6506
  }
6340
- const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
6341
- const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" }).trim();
6507
+ const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6508
+ const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6342
6509
  if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
6343
6510
  }
6344
6511
  writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString() }, null, 2) + "\n", { mode: 0o600 });
@@ -6351,9 +6518,143 @@ function preserveRetirementWork(observation, meta, instance) {
6351
6518
  }
6352
6519
  }
6353
6520
 
6521
+ /** Marker a self-retiring instance leaves BESIDE its home: the retirement is
6522
+ * requested and owed, and a detached completion is on its way. It is not
6523
+ * written into the home, so the caller changes no instance bytes and the
6524
+ * completion's work inspection sees exactly what the instance left. */
6525
+ export function retirePendingMarkerPath(home) {
6526
+ return join(dirname(home), `.oats-retire-pending-${basename(home)}.json`);
6527
+ }
6528
+
6529
+ /** Where a deferred self-retirement writes its explicit outcome: a FILE beside
6530
+ * the (former) home, so it survives the home's removal and never reads as an
6531
+ * instance directory. */
6532
+ export function deferredRetireResultPath(home) {
6533
+ return join(dirname(home), `.oats-retired-${basename(home)}.json`);
6534
+ }
6535
+
6536
+ const DEFERRED_RETIRE_SCRIPT = `import { completeDeferredRetirement } from ${JSON.stringify(import.meta.url)};
6537
+ process.exitCode = completeDeferredRetirement(JSON.parse(process.env.OATS_RETIRE_INTENT)) ? 0 : 1;`;
6538
+
6539
+ /** Self-retire (aweb-abep): persist intent, then hand the retirement to a
6540
+ * detached process that runs it as an ORDINARY external retirement after the
6541
+ * caller's window has died. Nothing destructive happens in the caller: no
6542
+ * inspection, no hooks, no removal — the runtime is still alive, and the
6543
+ * quiesce rule stays intact. The child owns its own process group so the
6544
+ * tmux window kill (SIGHUP to the pane's group) cannot take it down, and the
6545
+ * caller's instance env is stripped so the child is an external operator,
6546
+ * not another self-retire. The intent travels to the child in its env; the
6547
+ * marker beside the home is the operator-visible promise and is written only
6548
+ * once a completion process exists (reviewer D2). */
6549
+ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
6550
+ const delaySec = o.selfKillDelaySec ?? 8;
6551
+ const marker = retirePendingMarkerPath(found.home);
6552
+ const resultPath = deferredRetireResultPath(found.home);
6553
+ const logPath = resultPath.replace(/\.json$/, ".log");
6554
+ // A second `--self` while the first completion is still on its way must not
6555
+ // start a second, racing retirement: report the one already owed.
6556
+ if (existsSync(marker) && !existsSync(resultPath)) {
6557
+ let prior; try { prior = JSON.parse(readFileSync(marker, "utf8")); } catch { prior = undefined; }
6558
+ if (prior?.resultPath) {
6559
+ return { retired: name, agent: found.agent.name, deferred: true, alreadyScheduled: true, pendingMarker: marker, resultPath: prior.resultPath, logPath, completesInSec: prior.delaySec ?? delaySec, requestedAt: prior.requestedAt };
6560
+ }
6561
+ }
6562
+ const intent = {
6563
+ instance: name, agent: found.agent.name, root: resolve(root),
6564
+ requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
6565
+ options: { deleteBranch: !!o.deleteBranch, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
6566
+ };
6567
+ const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
6568
+ for (const k of CORE_LAUNCH_ENV) delete env[k];
6569
+ rmSync(resultPath, { force: true });
6570
+ const scheduleFailed = (why) => oatsError("E_SELF_RETIRE_SCHEDULE_FAILED", `could not start the deferred retirement of ${name}: ${why}. Nothing was inspected, run, or removed; the instance is still live and can be retired externally with \`oats retire ${name}\``);
6571
+ let fd, child;
6572
+ try {
6573
+ fd = openSync(logPath, "w");
6574
+ child = spawnProcess(process.execPath, ["--input-type=module", "-e", DEFERRED_RETIRE_SCRIPT], {
6575
+ detached: true, stdio: ["ignore", fd, fd], env, cwd: dirname(found.home),
6576
+ });
6577
+ } catch (e) {
6578
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
6579
+ rmSync(logPath, { force: true });
6580
+ throw scheduleFailed(e.message);
6581
+ }
6582
+ closeSync(fd);
6583
+ if (!child.pid) { rmSync(logPath, { force: true }); throw scheduleFailed("no process was created"); }
6584
+ // A spawn failure Node reports asynchronously (EAGAIN, EMFILE) would arrive
6585
+ // after this returns; record it as a failed outcome so status shows the
6586
+ // debt instead of an uncaught exception behind a success message.
6587
+ child.on("error", (e) => {
6588
+ try {
6589
+ writeFileSync(resultPath, JSON.stringify({ instance: name, agent: found.agent.name, requestedAt: intent.requestedAt, completedAt: new Date().toISOString(), ok: false, error: { code: e.code, message: `the deferred retirement could not start: ${e.message}` }, retry: `oats retire ${name}` }, null, 2) + "\n");
6590
+ } catch { /* the marker alone then shows RETIRING; status names its age */ }
6591
+ });
6592
+ child.unref();
6593
+ writeFileSync(marker, JSON.stringify(intent, null, 2) + "\n");
6594
+ return {
6595
+ retired: name, agent: found.agent.name, deferred: true, pendingMarker: marker,
6596
+ resultPath, logPath, completesInSec: delaySec, completionPid: child.pid,
6597
+ };
6598
+ }
6599
+
6600
+ /** Run by the detached child: wait for the caller's window to be gone, then
6601
+ * retire the instance as an ordinary external operator. Returns true only
6602
+ * when the home is actually gone, and then leaves no file behind. A failure
6603
+ * writes an explicit outcome beside the home and leaves the pending marker
6604
+ * (and, after hooks ran, the usual quarantine) in place, so `oats status`
6605
+ * shows the debt and `oats retire <name>` retries and clears it. Accepts the
6606
+ * intent object (the child gets it in its env) or a marker path. */
6607
+ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
6608
+ let intent = intentOrMarkerPath;
6609
+ if (typeof intentOrMarkerPath === "string") {
6610
+ try { intent = JSON.parse(readFileSync(intentOrMarkerPath, "utf8")); }
6611
+ catch (e) { console.error(`deferred retirement: cannot read ${intentOrMarkerPath}: ${e.message}`); return false; }
6612
+ }
6613
+ if (!isPlainObject(intent) || !intent.instance || !intent.root) { console.error("deferred retirement: intent is not usable"); return false; }
6614
+ const record = (payload) => {
6615
+ if (!intent.resultPath) return;
6616
+ try {
6617
+ writeFileSync(intent.resultPath, JSON.stringify({
6618
+ instance: intent.instance, agent: intent.agent, requestedAt: intent.requestedAt,
6619
+ completedAt: new Date().toISOString(), ...payload,
6620
+ }, null, 2) + "\n");
6621
+ } catch (e) { console.error(`deferred retirement: cannot write ${intent.resultPath}: ${e.message}`); }
6622
+ };
6623
+ const delayMs = Math.max(0, Number(opts.delaySec ?? intent.delaySec ?? 8) * 1000);
6624
+ if (delayMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
6625
+ let result;
6626
+ try {
6627
+ result = retireInstance(intent.root, intent.instance, {
6628
+ deleteBranch: !!intent.options?.deleteBranch, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
6629
+ });
6630
+ } catch (e) {
6631
+ record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}` });
6632
+ console.error(`deferred retirement of ${intent.instance} failed: ${e.message}`);
6633
+ return false;
6634
+ }
6635
+ if (result.rollbackIncomplete) {
6636
+ record({ ok: false, result, retry: `oats retire ${intent.instance}` });
6637
+ console.error(`deferred retirement of ${intent.instance} is INCOMPLETE; the home is retained:\n ${result.rollbackIncomplete.join("\n ")}`);
6638
+ return false;
6639
+ }
6640
+ if (intent.options?.keepDir) {
6641
+ const retained = findInstanceHome(intent.root, intent.instance);
6642
+ if (retained) rmSync(retirePendingMarkerPath(retained.home), { force: true });
6643
+ }
6644
+ // Success leaves nothing beside the home: the home is gone, the marker went
6645
+ // with it, and a kept success record per retired reviewer or harvester would
6646
+ // accumulate forever (reviewer D4). Only failures leave files, and they are
6647
+ // the ones status surfaces and a retry clears.
6648
+ try { rmSync(intent.resultPath.replace(/\.json$/, ".log"), { force: true }); } catch { /* nothing to keep */ }
6649
+ return true;
6650
+ }
6651
+
6354
6652
  export function retireInstance(root, name, o = {}) {
6355
6653
  const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
6356
- const self = o.self === true; // self-retire: the caller IS the instance — kill the window LAST
6654
+ // self-retire: the caller IS the instance. Without --keep-dir the whole
6655
+ // retirement is deferred to a detached external completion (below); with
6656
+ // --keep-dir the old in-process path runs and kills the window LAST.
6657
+ const self = o.self === true;
6357
6658
  const found = findInstanceHome(root, name);
6358
6659
  if (!found) throw new Error(`no instance named "${name}"`);
6359
6660
  const metaPath = join(found.home, "instance.json");
@@ -6390,6 +6691,7 @@ export function retireInstance(root, name, o = {}) {
6390
6691
  if (quarantine && typeof quarantine.cleanup.launched === "boolean") {
6391
6692
  meta.launched = quarantine.cleanup.launched;
6392
6693
  meta.tmux = quarantine.cleanup.tmux;
6694
+ meta.sessionTarget = quarantine.cleanup.sessionTarget;
6393
6695
  }
6394
6696
  // A home with NEITHER instance.json NOR a usable cleanup descriptor cannot be
6395
6697
  // retired safely: hooks would be skipped and the directory removed, which is
@@ -6409,8 +6711,13 @@ export function retireInstance(root, name, o = {}) {
6409
6711
  const workPath = join(found.home, "work");
6410
6712
  const isWorktree = meta.work === "worktree" ||
6411
6713
  (existsSync(workPath) && !lstatSync(workPath).isSymbolicLink());
6412
- if (self && !o.keepDir) {
6413
- throw oatsError("E_SELF_RETIRE_NOT_QUIESCED", `self-retire cannot establish a stable final work inspection while the calling runtime is active; exit the runtime, then have an external operator retire ${name}`);
6714
+ // A live runtime cannot establish a stable final work inspection of itself,
6715
+ // so self-retire never inspects, runs hooks, or removes anything here. It
6716
+ // persists the intent and hands the whole retirement to a detached process
6717
+ // that runs it as an ordinary EXTERNAL retirement once the runtime is gone
6718
+ // (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
6719
+ if (self && (!o.keepDir || meta.sessionTarget)) {
6720
+ return scheduleDeferredSelfRetirement(root, found, name, o, session);
6414
6721
  }
6415
6722
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
6416
6723
  // the managed runtime; recovery copying never races a live managed Pi.
@@ -6425,11 +6732,12 @@ export function retireInstance(root, name, o = {}) {
6425
6732
  if (!runtimeAuthority) {
6426
6733
  throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot quiesce ${name}: independent runtime endpoint authority is missing or invalid`);
6427
6734
  }
6428
- const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || (
6429
- meta.tmux?.session === runtimeAuthority.tmux.session &&
6430
- meta.tmux?.window === runtimeAuthority.tmux.window &&
6431
- resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux.socket
6432
- ));
6735
+ const endpointAgrees = runtimeAuthority.sessionTarget
6736
+ ? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === runtimeAuthority.sessionTarget[key])
6737
+ : !meta.sessionTarget && meta.tmux?.session === runtimeAuthority.tmux?.session
6738
+ && meta.tmux?.window === runtimeAuthority.tmux?.window
6739
+ && resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux?.socket;
6740
+ const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || endpointAgrees);
6433
6741
  if (!metaAgrees) {
6434
6742
  throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
6435
6743
  }
@@ -6437,7 +6745,11 @@ export function retireInstance(root, name, o = {}) {
6437
6745
  // `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
6438
6746
  // A no-launch instance is already quiesced. A launched one must have exact
6439
6747
  // window absence established before recovery copying begins.
6440
- if (!self && runtimeAuthority?.launched) {
6748
+ if (!self && runtimeAuthority?.launched && runtimeAuthority.sessionTarget) {
6749
+ try { stopHerdr(runtimeAuthority.sessionTarget); }
6750
+ catch (e) { throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that Herdr session for ${name} stopped: ${e.message}`); }
6751
+ }
6752
+ if (!self && runtimeAuthority?.launched && !runtimeAuthority.sessionTarget) {
6441
6753
  const runtimeSession = runtimeAuthority.tmux.session;
6442
6754
  const runtimeWindow = runtimeAuthority.tmux.window;
6443
6755
  const runtimeSocket = runtimeAuthority.tmux.socket;
@@ -6480,13 +6792,12 @@ export function retireInstance(root, name, o = {}) {
6480
6792
  // describes. A hook that reports nothing, or reports retired:true, is
6481
6793
  // unaffected; only an explicit "I did not finish" changes the outcome.
6482
6794
  let ordinaryIncomplete = [];
6483
- // SELF-RETIRE IS DELIBERATELY EXCLUDED. A self-retiring instance is the caller;
6484
- // it cannot hold the authority to complete owner cleanup, and turning its exit
6485
- // into a retained owner quarantine would change a path this change is not
6486
- // scoped to touch (reviewer-5c8b724: control deletes and schedules teardown,
6487
- // the first draft retained and quarantined). Self-retire keeps tearing down
6488
- // local state and leaving the incomplete operation for the recorded cleanup
6489
- // owner to reconcile.
6795
+ // The IN-PROCESS self path (--self --keep-dir only, since aweb-abep) is
6796
+ // excluded: that caller is the instance and cannot hold the authority to
6797
+ // complete owner cleanup (reviewer-5c8b724). A plain --self never reaches
6798
+ // here as `self`: its deferred completion calls retireInstance as an
6799
+ // external operator, so a self-retiring reviewer or harvester whose hook
6800
+ // reports incomplete cleanup IS quarantined like any other instance.
6490
6801
  if (!quarantine && !self) {
6491
6802
  for (const f of hookResults?.failures || []) ordinaryIncomplete.push(`retire hook ${f.capability}: ${f.message}`);
6492
6803
  // A hook may exit 0 and still report it did not finish. Only an explicit
@@ -6669,7 +6980,15 @@ export function retireInstance(root, name, o = {}) {
6669
6980
  // unreachable remote) would be unremovable through OATS forever — the same
6670
6981
  // dead end the unusable-marker fixes closed, just reached from a valid one.
6671
6982
  const forced = !!(stillIncomplete && o.force);
6672
- if (!o.keepDir && (!stillIncomplete || forced)) rmSync(found.home, { recursive: true, force: true });
6983
+ if (!o.keepDir && (!stillIncomplete || forced)) {
6984
+ rmSync(found.home, { recursive: true, force: true });
6985
+ // The owed retirement is paid: clear the pending marker, and the failed
6986
+ // outcome an earlier deferred attempt may have left beside the home; a
6987
+ // deferred completion writes its own outcome after this returns.
6988
+ rmSync(retirePendingMarkerPath(found.home), { force: true });
6989
+ rmSync(deferredRetireResultPath(found.home), { force: true });
6990
+ rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
6991
+ }
6673
6992
 
6674
6993
 
6675
6994
  const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, worktreeRemoved: isWorktree, branchDeleted: !!(o.deleteBranch && meta.branch) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, harvested, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: hookResults?.warnings?.length ? hookResults.warnings : undefined };