@awebai/oats 0.22.1 → 0.22.3

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}`);
@@ -667,7 +671,7 @@ export function resolveCapabilities(contextDir, soulName) {
667
671
  }
668
672
  }
669
673
  const ranked = [...list].sort((a, b) => a.specificity - b.specificity || b.scope - a.scope || a.target.localeCompare(b.target));
670
- const settings = {};
674
+ const settings = Object.create(null); // manifest-declared names such as "constructor" must read as unset
671
675
  // Setting names come from parsed config, so the RANK table — a key→info
672
676
  // lookup, unlike `settings` itself, which is data handed to consumers — is
673
677
  // null-prototype: an inherited name must not read as an already-taken rank.
@@ -681,12 +685,28 @@ export function resolveCapabilities(contextDir, soulName) {
681
685
  settings[key] = value; settingRank[key] = rank;
682
686
  }
683
687
  }
688
+ // Declared defaults (manifest `settings: { <name>: { default } }`) are part
689
+ // of the effective settings: a requirement conditional on a setting must
690
+ // see the default for a deployment that never set it, or the default path
691
+ // silently loses its requirements (aweb review of oats.aweb 1.10.0).
692
+ const declaredSettings = manifest.settings && typeof manifest.settings === "object" && !Array.isArray(manifest.settings) ? manifest.settings : {};
693
+ for (const [key, decl] of Object.entries(declaredSettings)) {
694
+ if (!decl || typeof decl !== "object") continue;
695
+ if (settings[key] === undefined && decl.default !== undefined) settings[key] = decl.default;
696
+ }
684
697
  const strongest = [...list].sort((a, b) => b.specificity - a.specificity || a.scope - b.scope || a.target.localeCompare(b.target));
685
698
  const top = strongest[0];
686
699
  const tied = strongest.filter((c) => c.specificity === top.specificity && c.scope === top.scope);
687
700
  const enabledValues = new Set(tied.map((c) => c.binding.enabled === undefined ? true : !!c.binding.enabled));
688
701
  if (enabledValues.size > 1) throw new Error(`ambiguous enabled/excluded bindings for ${id} at equal specificity (${tied.map((c) => c.target).join(", ")})`);
689
702
  if (![...enabledValues][0]) continue;
703
+ // A declared value set is enforced for an ACTIVE capability: a misspelling
704
+ // must not switch a conditional requirement off by matching nothing.
705
+ for (const [key, decl] of Object.entries(declaredSettings)) {
706
+ if (decl && typeof decl === "object" && Array.isArray(decl.values) && settings[key] !== undefined && !decl.values.some((v) => String(v) === String(settings[key]))) {
707
+ throw new Error(`capability setting ${id}.${key} is ${JSON.stringify(settings[key])}, not one of ${decl.values.map((v) => JSON.stringify(v)).join(", ")}`);
708
+ }
709
+ }
690
710
  const compatibility = capabilityCompatibility(manifest);
691
711
  if (!compatibility.compatible) throw new Error(`capability "${id}" requires OATS ${compatibility.range}; running ${compatibility.version}`);
692
712
  const trust = capabilityTrust(manifest, contextDir);
@@ -699,7 +719,7 @@ export function resolveCapabilities(contextDir, soulName) {
699
719
  active.push({
700
720
  id, capability: id, manifest, layer: manifest.layer, command: manifest.command,
701
721
  level: top.level, origin: manifest._origin, provenance: list.map((c) => `${c.target} @ ${c.level}`),
702
- settings, skills: capabilitySkillDirs(id, contextDir), inject,
722
+ settings: { ...settings }, skills: capabilitySkillDirs(id, contextDir), inject,
703
723
  // What the manifest PROMISES, so preflight can tell "declared nothing"
704
724
  // from "declared and missing" — the two are indistinguishable in the
705
725
  // resolved lists above.
@@ -713,6 +733,7 @@ export function resolveCapabilities(contextDir, soulName) {
713
733
  // what required:true claims to prevent (aggregate review at 798b156).
714
734
  requiredHooks: manifestRequiredHooks(manifest),
715
735
  environment: trust.trusted ? [...(manifest.environment || [])] : [],
736
+ environmentNamespaces: trust.trusted ? [...(manifest.environmentNamespaces || [])] : [],
716
737
  missingRequires: capabilityMissingRequires(id, contextDir), compatibility, trust, executable,
717
738
  retirement: trust.trusted ? manifest.retirement : undefined,
718
739
  _scope: top.scope,
@@ -725,6 +746,8 @@ export function resolveCapabilities(contextDir, soulName) {
725
746
  export function resolveOatsConfig(contextDir, soulName) {
726
747
  const chain = configChain(contextDir);
727
748
  const out = { layers: {}, provenance: {}, layerDisabled: {}, injects: [], capabilities: [], name: chain[0]?.name, chain };
749
+ const yoloCfg = chain.find((c) => c.yolo !== undefined);
750
+ if (yoloCfg) out.yolo = yoloCfg.yolo;
728
751
  // Closest team: declaration wins; the declaring scope is the deployment/team boundary.
729
752
  const teamCfg = chain.find((c) => c.team);
730
753
  if (teamCfg) out.team = { ...teamCfg.team, scope: teamCfg._level };
@@ -883,6 +906,18 @@ function validateCapabilityManifest(m, mf) {
883
906
  const vendor = id.match(/^([a-z][a-z0-9]*)\./)?.[1];
884
907
  if (!vendor) throw new Error(`capability ${id} must use a lowercase dotted vendor ID to declare launch environment`);
885
908
  const prefix = `${vendor.toUpperCase()}_`;
909
+ // A capability may declare additional namespaces it speaks for, e.g.
910
+ // the official oats.aweb integration setting the aweb extensions'
911
+ // AWEB_ variables. Declared, never implied: the names are disclosed at
912
+ // trust time with the rest of the launch environment, and the reserved
913
+ // core and bootstrap namespaces can never be claimed this way.
914
+ const extraNamespaces = m.environmentNamespaces === undefined ? [] : m.environmentNamespaces;
915
+ if (!Array.isArray(extraNamespaces) || extraNamespaces.some((ns) => typeof ns !== "string")) throw new Error(`capability ${id} manifest environmentNamespaces must be an array of prefixes`);
916
+ for (const ns of extraNamespaces) {
917
+ if (!/^[A-Z][A-Z0-9]*_$/.test(ns)) throw new Error(`capability ${id} manifest environmentNamespaces entry ${JSON.stringify(ns)} must be an uppercase prefix ending in an underscore`);
918
+ if (ns === "OATS_" || ns === "PI_AGENT_" || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => ns.startsWith(reserved) || reserved.startsWith(ns))) throw new Error(`capability ${id} manifest environmentNamespaces entry ${ns} is a reserved namespace`);
919
+ }
920
+ const allowed = [prefix, ...extraNamespaces];
886
921
  for (const name of m.environment) {
887
922
  if (!PORTABLE_ENV_NAME_RE.test(name)) throw new Error(`capability ${id} manifest environment name ${JSON.stringify(name)} is invalid`);
888
923
  if (CORE_LAUNCH_ENV.has(name) || name.startsWith("OATS_") || name.startsWith("PI_AGENT_")) {
@@ -891,7 +926,7 @@ function validateCapabilityManifest(m, mf) {
891
926
  if (PROCESS_BOOTSTRAP_ENV.has(name) || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => name.startsWith(reserved))) {
892
927
  throw new Error(`capability ${id} manifest environment name ${name} collides with a reserved process bootstrap variable`);
893
928
  }
894
- if (!name.startsWith(prefix)) throw new Error(`capability ${id} manifest environment name ${name} is outside its ${prefix} namespace`);
929
+ if (!allowed.some((ns) => name.startsWith(ns))) throw new Error(`capability ${id} manifest environment name ${name} is outside its ${allowed.join(", ")} namespace${allowed.length > 1 ? "s" : ""} (declare another in environmentNamespaces)`);
895
930
  }
896
931
  }
897
932
  }
@@ -1303,6 +1338,7 @@ export function capabilityTrust(a, b) {
1303
1338
  commands: Object.keys(manifest?.commands || {}),
1304
1339
  hooks: Object.keys(manifest?.hooks || {}),
1305
1340
  environment: [...(manifest?.environment || [])],
1341
+ ...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
1306
1342
  };
1307
1343
  return { ...t, package: t.package || manifest?._package, executableSurface: surface };
1308
1344
  }
@@ -2585,6 +2621,7 @@ function executableSurfaceOf(manifest) {
2585
2621
  commands: Object.keys(manifest?.commands || {}),
2586
2622
  hooks: Object.keys(manifest?.hooks || {}),
2587
2623
  environment: [...(manifest?.environment || [])],
2624
+ ...(manifest?.environmentNamespaces?.length ? { environmentNamespaces: [...manifest.environmentNamespaces] } : {}),
2588
2625
  };
2589
2626
  }
2590
2627
  function hasExecutableSurface(manifest) {
@@ -4248,6 +4285,27 @@ export function packageSpecIdentity(spec) {
4248
4285
  /** What the runtime reports about a required package: is it there, where did it
4249
4286
  * land, and does the user's entry filter its resources? A settings row alone is
4250
4287
  * NOT proof the capability's extension will load (reviewer-8518c49). */
4288
+ /** The installed version of a runtime package: neither pi's nor Claude's
4289
+ * listing reports one, so it is read from the package.json under the
4290
+ * install directory the listing names; undefined when there is none. */
4291
+ export function installedRuntimePackageVersion(runtime, spec, status) {
4292
+ const dir = status?.dir;
4293
+ if (dir && existsSync(join(dir, "package.json"))) {
4294
+ try {
4295
+ const v = JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
4296
+ if (typeof v === "string" && /^\d+\.\d+\.\d+/.test(v)) return v.match(/^\d+\.\d+\.\d+/)[0];
4297
+ } catch { /* unreadable manifest: unknowable */ }
4298
+ }
4299
+ return undefined;
4300
+ }
4301
+
4302
+ export function compareVersionTriples(a, b) {
4303
+ const pa = String(a).split(".").map((x) => Number.parseInt(x, 10) || 0);
4304
+ const pb = String(b).split(".").map((x) => Number.parseInt(x, 10) || 0);
4305
+ for (let i = 0; i < 3; i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d; }
4306
+ return 0;
4307
+ }
4308
+
4251
4309
  export function runtimePackageStatus(runtime, spec, env = process.env, opts = {}) {
4252
4310
  const mgr = RUNTIME_PACKAGE_MANAGERS[runtime];
4253
4311
  if (!mgr) return { installed: false };
@@ -4336,6 +4394,12 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4336
4394
  throw new HookEnvironmentContractError(`${capabilityID} hook env requires a dotted lowercase alphanumeric vendor component; hyphen, @, and / forms cannot claim an environment namespace`);
4337
4395
  }
4338
4396
  const prefix = `${vendorComponent.toUpperCase()}_`;
4397
+ // The runtime half of the same contract the manifest validator enforces:
4398
+ // a hook may set names under its vendor prefix or under a namespace its
4399
+ // trusted manifest declared (environmentNamespaces), and only names the
4400
+ // manifest listed. Manifest and runtime must permit exactly the same set.
4401
+ const declared = declarations.get(capabilityID) || { names: new Set(), namespaces: [] };
4402
+ const allowed = [prefix, ...(declared.namespaces || [])];
4339
4403
  const accepted = {};
4340
4404
  for (const name of Object.keys(value).sort()) {
4341
4405
  const envValue = value[name];
@@ -4348,10 +4412,10 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4348
4412
  if (PROCESS_BOOTSTRAP_ENV.has(name) || PROCESS_BOOTSTRAP_PREFIXES.some((reserved) => name.startsWith(reserved))) {
4349
4413
  throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} collides with a reserved process bootstrap variable`);
4350
4414
  }
4351
- if (!name.startsWith(prefix)) {
4352
- throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is outside its ${prefix} namespace`);
4415
+ if (!allowed.some((ns) => name.startsWith(ns))) {
4416
+ throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is outside its ${allowed.join(", ")} namespace${allowed.length > 1 ? "s" : ""}`);
4353
4417
  }
4354
- if (!(declarations.get(capabilityID) || new Set()).has(name)) {
4418
+ if (!declared.names.has(name)) {
4355
4419
  throw new HookEnvironmentContractError(`${capabilityID} hook env name ${name} is not declared in its trusted manifest environment`);
4356
4420
  }
4357
4421
  if (typeof envValue !== "string") {
@@ -4376,7 +4440,7 @@ function validateHookEnvironment(capabilityID, value, owners, declarations) {
4376
4440
  export function runLifecycleHooks(event, { home, instance, agentName, soulDir, contextDir, workspaceDir, rootDir, resolved, priorMeta = {}, extraEnv = {} }) {
4377
4441
  const results = { meta: {}, briefs: [], warnings: [], order: [], launch: {}, env: {}, failures: [] };
4378
4442
  const envOwners = new Map();
4379
- const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, new Set(cap.environment || [])]));
4443
+ const envDeclarations = new Map((resolved.capabilities || []).map((cap) => [cap.id, { names: new Set(cap.environment || []), namespaces: [...(cap.environmentNamespaces || [])] }]));
4380
4444
  const caps = [...(resolved.capabilities || [])];
4381
4445
  if (event === "retire") caps.reverse();
4382
4446
  for (const cap of caps) {
@@ -4716,13 +4780,22 @@ function runSoulScaffoldHooks(args) {
4716
4780
  if (Object.keys(owners).length) writeFileSync(ownersFile, JSON.stringify(owners, null, 2) + "\n");
4717
4781
  }
4718
4782
 
4719
- export function writeSoul(root, { name, kind, repo, work, runtime, model, description, type, instructions }) {
4783
+ /** Flat soul YAML uses strings; never treat the string "false" as truthy. */
4784
+ export function resolveYolo(value) {
4785
+ if (value === undefined) return undefined;
4786
+ if (value === true || value === "true") return true;
4787
+ if (value === false || value === "false") return false;
4788
+ throw new Error("yolo must be true or false");
4789
+ }
4790
+
4791
+ export function writeSoul(root, { name, kind, repo, work, runtime, model, yolo, description, type, instructions }) {
4792
+ yolo = resolveYolo(yolo);
4720
4793
  const agentDir = agentDirOf(root, name, kind);
4721
4794
  const soulDir = soulOf(agentDir);
4722
4795
  mkdirSync(soulDir, { recursive: true });
4723
4796
  mkdirSync(join(agentDir, "instances"), { recursive: true });
4724
4797
  writeFileSync(join(soulDir, "soul.yaml"), yamlFlat({
4725
- name, kind, description, type, repo, work: work || "checkout", runtime: runtime || "pi", model,
4798
+ name, kind, description, type, repo, work: work || "checkout", runtime: runtime || "pi", model, yolo,
4726
4799
  }));
4727
4800
  const agentsMd = join(soulDir, "AGENTS.md");
4728
4801
  if (instructions !== undefined || !existsSync(agentsMd)) {
@@ -4767,7 +4840,7 @@ export function createAgent(root, o) {
4767
4840
  * Local souls are full souls — same scaffold and memory as persistent ones —
4768
4841
  * that live in the scope's uncommitted local-agents/. */
4769
4842
  export function upsertLocalAgent(root, o) {
4770
- let { name, instructions, description, model, repo, work, runtime } = o;
4843
+ let { name, instructions, description, model, repo, work, runtime, yolo } = o;
4771
4844
  if (o.file) {
4772
4845
  const f = resolve(o.file);
4773
4846
  if (!existsSync(f)) throw new Error(`file not found: ${f}`);
@@ -4778,6 +4851,7 @@ export function upsertLocalAgent(root, o) {
4778
4851
  repo = repo ?? meta.repo;
4779
4852
  work = work ?? meta.work;
4780
4853
  runtime = runtime ?? meta.runtime;
4854
+ yolo = yolo ?? meta.yolo;
4781
4855
  instructions = body;
4782
4856
  }
4783
4857
  if (!name) throw new Error("local agent requires a name");
@@ -4789,7 +4863,7 @@ export function upsertLocalAgent(root, o) {
4789
4863
  writeSoul(root, {
4790
4864
  name, kind: "local",
4791
4865
  repo: repo ?? existing?.repo, work: work ?? existing?.work,
4792
- runtime: runtime ?? existing?.runtime, model: model ?? existing?.model,
4866
+ runtime: runtime ?? existing?.runtime, model: model ?? existing?.model, yolo: yolo ?? existing?.yolo,
4793
4867
  description: description ?? existing?.description, instructions,
4794
4868
  });
4795
4869
  return findAgent(root, name);
@@ -4909,9 +4983,19 @@ export function resolveClaudeBinary(contextDir) {
4909
4983
  * pi: checked against `pi --list-models <pattern>` (authenticated providers).
4910
4984
  * claude: pi-style patterns are translated (anthropic/<id> → <id>) or dropped —
4911
4985
  * 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). */
4986
+ * codex: translate openai/openai-codex entries to native ids, otherwise use its default.
4987
+ * Probe failures: first entry wins (pi errors loudly at launch). */
4913
4988
  export function resolveModelPreference(model, runtime = "pi") {
4914
4989
  const prefs = String(model || "").split(",").map((s) => s.trim()).filter(Boolean);
4990
+ if (runtime === "codex") {
4991
+ for (const pref of prefs) {
4992
+ const bare = pref.replace(/:[a-z]+$/i, "");
4993
+ if (!bare.includes("/")) return bare;
4994
+ const [provider, ...rest] = bare.split("/");
4995
+ if (["openai", "openai-codex"].includes(provider) && rest.length) return rest.join("/");
4996
+ }
4997
+ return ""; // let Codex use its configured model rather than another provider's id
4998
+ }
4915
4999
  if (runtime === "claude") {
4916
5000
  // Claude accepts its aliases and bare claude-* ids — NOT pi-style
4917
5001
  // "provider/model[:thinking]" patterns. Agents whose soul default is a
@@ -4973,6 +5057,11 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
4973
5057
  for (const cap of resolved.capabilities || []) {
4974
5058
  for (const raw of cap.manifest?.requires || []) {
4975
5059
  if (!raw || typeof raw !== "object" || raw.runtime !== runtime) continue;
5060
+ // A requirement may be conditional on the capability's effective
5061
+ // settings (`when: { delivery: "channel" }`): rows whose condition does
5062
+ // not hold are not requirements of this spawn at all.
5063
+ if (raw.when !== undefined && (!raw.when || typeof raw.when !== "object" || Array.isArray(raw.when))) { problems.push(`${cap.id}: a requirement's \`when\` must be an object of setting names to values`); continue; }
5064
+ if (raw.when && !Object.entries(raw.when).every(([k, v]) => String(cap.settings?.[k] ?? "") === String(v))) continue;
4976
5065
  const spec = raw.package;
4977
5066
  if (!safeRuntimePackageSpec(spec, runtime)) { problems.push(`${cap.id}: ${runtime} package spec is not a plain source token (${JSON.stringify(spec)})`); continue; }
4978
5067
  if (raw.marketplace !== undefined && !safeRuntimeSourceRef(raw.marketplace)) { problems.push(`${cap.id}: marketplace is not a plain source reference (${JSON.stringify(raw.marketplace)})`); continue; }
@@ -4981,7 +5070,10 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
4981
5070
  const stepList = mgr?.steps ? mgr.steps(spec, raw, probeOpts) : [mgr?.argv(spec, raw, probeOpts) || []];
4982
5071
  const direct = stepList.filter((a) => a.length).map((a) => a.join(" ")).join(" && ");
4983
5072
  const remedy = `run \`oats install --accept-requirement ${runtime}:${runtimePackageIdentity(runtime, spec)} --dir ${contextDir}\`${direct ? ` (or \`${direct}\` directly)` : ""}`;
4984
- if (!status.installed) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is not installed — ${remedy}`); continue; }
5073
+ // `ifInstalled: true`: the row constrains a package that may be absent
5074
+ // (an ambient extension must honour a contract IF it is there); absence
5075
+ // satisfies it. Without the flag, absence fails as before.
5076
+ if (!status.installed) { if (raw.ifInstalled === true) continue; problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is not installed — ${remedy}`); continue; }
4985
5077
  // A settings row is not proof the extension loads. Both of these leave the
4986
5078
  // capability silently absent, which is the loss this gate exists to stop.
4987
5079
  if (status.unverified) { problems.push(`${cap.id} requires the ${runtime} package ${spec}: it is configured, but OATS could not verify it is installed (${status.unverified}) — a config entry is not an installation; ${remedy}`); continue; }
@@ -4993,6 +5085,16 @@ function verifyRuntimePackages(runtime, resolved, contextDir) {
4993
5085
  }
4994
5086
  if (status.disabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, which is installed but DISABLED, so it will not load — enable it (\`${probeOpts.bin || runtime} plugin enable ${spec}\` for Claude), or drop the capability for this soul`); continue; }
4995
5087
  if (status.extensionsDisabled) { problems.push(`${cap.id} requires the ${runtime} package ${spec}, but your ${runtime} settings entry sets "extensions": [], which loads none of them — remove that filter, or drop the capability for this soul`); continue; }
5088
+ // A floor on the installed version (`minVersion`), checked once presence
5089
+ // and loadability are settled: a package whose manifest states an older
5090
+ // version, or none, fails closed with the same remedy, since an old
5091
+ // extension that ignores a newer contract (AWEB_DELIVERY) is exactly
5092
+ // what the floor guards.
5093
+ if (raw.minVersion) {
5094
+ const have = installedRuntimePackageVersion(runtime, spec, status);
5095
+ if (!have) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later, but its installed version cannot be established (no package manifest under its install directory) — ${remedy}`); continue; }
5096
+ if (compareVersionTriples(have, raw.minVersion) < 0) { problems.push(`${cap.id} requires the ${runtime} package ${spec} at ${raw.minVersion} or later; ${have} is installed — ${remedy}`); continue; }
5097
+ }
4996
5098
  if (status.extensionsFilter?.length) {
4997
5099
  // Unverifiable, not merely auditable: proving the filter selects this
4998
5100
  // capability's extension means implementing pi's glob semantics, and
@@ -5017,8 +5119,12 @@ export function spawnInstance(root, agent, o = {}) {
5017
5119
  if (work === "attached" && !o.workDir) throw new Error(`attached mode needs workDir — the owning instance's work tree (its <home>/work)`);
5018
5120
  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
5121
  const runtime = o.runtime || agent.runtime || "pi";
5122
+ if (!["pi", "claude", "codex"].includes(runtime)) throw oatsError("E_UNSUPPORTED_RUNTIME", `unknown runtime "${runtime}" (pi|claude|codex)`);
5020
5123
  const model = resolveModelPreference(o.model || agent.model || "", runtime);
5021
5124
  const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
5125
+ const backend = o.backend || agent.backend || "tmux";
5126
+ if (!["tmux", "herdr"].includes(backend)) throw new Error(`unknown session backend "${backend}" (tmux|herdr)`);
5127
+ if (o.herdrSocket !== undefined && (typeof o.herdrSocket !== "string" || !o.herdrSocket)) throw oatsError("E_BAD_ARGS", "herdrSocket must be a socket path");
5022
5128
  const launch = o.launch !== false;
5023
5129
  const repoAbs = resolveRepo(root, o.repo || agent.repo);
5024
5130
  if (!repoAbs) throw new Error(`agent "${agent.name}" has no repo configured — pass one`);
@@ -5324,6 +5430,7 @@ export function spawnInstance(root, agent, o = {}) {
5324
5430
  const soulDir = agent._soulDir || soulOf(agent._dir);
5325
5431
  const composition = composeInstanceAgentsMd(soulDir, repoAbs, agent.name, work, agent.kind);
5326
5432
  const resolvedCfg = composition.resolved;
5433
+ const yolo = resolveYolo(o.yolo ?? agent.yolo ?? resolvedCfg.yolo);
5327
5434
  const expectedResources = planInstanceResources({ resolved: resolvedCfg, soulDir, agent, contextDir: repoAbs, composition });
5328
5435
  // Runtime extensions selected by ACTIVE capabilities for THIS instance's
5329
5436
  // runtime. Strict launch disables ambient extension discovery, so each one has
@@ -5335,9 +5442,10 @@ export function spawnInstance(root, agent, o = {}) {
5335
5442
 
5336
5443
  // Prerequisites must fail before creating a home, worktree, or identity.
5337
5444
  const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
5338
- const bin = which(runtime === "claude" ? claudeBin : "pi");
5445
+ const bin = which(runtime === "claude" ? claudeBin : runtime);
5339
5446
  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)");
5447
+ if (launch && !which(backend)) throw new Error(`${backend} not installed${backend === "tmux" ? " (brew install tmux)" : " (https://herdr.dev)"}`);
5448
+ const herdrBase = launch && backend === "herdr" ? ensureHerdr({ binary: which("herdr"), socket: o.herdrSocket }) : undefined;
5341
5449
  const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
5342
5450
 
5343
5451
  mkdirSync(home, { recursive: true });
@@ -5524,6 +5632,7 @@ export function spawnInstance(root, agent, o = {}) {
5524
5632
  const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
5525
5633
  let windowMayExist = false;
5526
5634
  let spawnTmux;
5635
+ let spawnHerdr;
5527
5636
  const ancillaryCleanup = [];
5528
5637
  // One compensation owner, from the first hook result through launch and
5529
5638
  // the final lineage write. Preserve the original failure and retain any
@@ -5543,7 +5652,23 @@ export function spawnInstance(root, agent, o = {}) {
5543
5652
  const outstandingGit = new Set();
5544
5653
  // A failed new-window command may still have created its window. Verify
5545
5654
  // quiescence before removing credentials or work that runtime may be using.
5546
- if (windowMayExist) {
5655
+ if (windowMayExist && spawnHerdr) {
5656
+ try { stopHerdr(spawnHerdr); }
5657
+ catch (e) {
5658
+ incomplete.push(`Herdr session: ${e.message}`);
5659
+ for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5660
+ if (work === "worktree") { outstandingGit.add("worktree"); if (branch) outstandingGit.add("branch"); }
5661
+ return quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
5662
+ repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
5663
+ launched: true, sessionTarget: spawnHerdr, recordRetirementBaseline: true });
5664
+ }
5665
+ }
5666
+ if (windowMayExist && backend === "herdr" && !spawnHerdr) {
5667
+ // Allocation starts only an empty shell; the harness command has not run.
5668
+ // A lost receipt cannot authorize closing an unidentified terminal.
5669
+ incomplete.push(`Herdr workspace allocation may have completed on ${herdrBase.socket} (label ${instance}, cwd ${home}); inspect and remove any empty workspace manually`);
5670
+ }
5671
+ if (windowMayExist && backend === "tmux") {
5547
5672
  shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
5548
5673
  const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
5549
5674
  const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
@@ -5655,7 +5780,7 @@ export function spawnInstance(root, agent, o = {}) {
5655
5780
  You are instance "${instance}" of agent "${agent.name}".
5656
5781
  - Home: ${home}${resolvedCfg.team ? `\n- Team: ${resolvedCfg.team.name}${resolvedCfg.team.id ? ` (${resolvedCfg.team.id})` : ""} — see teammates with \`oats status --team\`` : ""}
5657
5782
  - Work tree: ./work — ${workDesc}
5658
- - Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}
5783
+ - 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
5784
  ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
5660
5785
 
5661
5786
  // Launch command. Spawn IS session start: this command is persisted in
@@ -5672,7 +5797,18 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5672
5797
  // the TASK.md text is swallowed as that flag's next value — claude
5673
5798
  // errors out ("entries must be tagged: <task text>") and the window
5674
5799
  // drops to the fallback shell, which reads as a silently stuck spawn.
5675
- cmdline = `${shq(bin)}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5800
+ cmdline = `${shq(bin)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5801
+ } else if (runtime === "codex") {
5802
+ // Codex discovers this home's AGENTS.md and .agents/skills natively.
5803
+ // Keep the operator's native permission policy. --add-dir is not valid
5804
+ // under every policy (including untrusted/read-only startup). Worktrees
5805
+ // already live below home; external paths use native approval handling.
5806
+ // --yolo bypasses execution approvals but Codex still asks to trust a new
5807
+ // project. The operator's explicit yolo choice also trusts this generated
5808
+ // home for this launch, without editing the shared user config.
5809
+ const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
5810
+ cmdline = `${shq(bin)} --cd ${shq(home)}${yolo ? ` --yolo -c ${shq(codexTrust)}` : ""}`
5811
+ + `${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5676
5812
  } else {
5677
5813
  // STRICT CURRICULUM (pi): the OATS-composed set — no user, ancestor, project
5678
5814
  // or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
@@ -5729,6 +5865,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5729
5865
  const meta = {
5730
5866
  agent: agent.name, kind: agent.kind || "persistent", instance, home,
5731
5867
  repo: repoAbs, work, branch, runtime, model: model || undefined,
5868
+ ...(yolo !== undefined ? { yolo } : {}),
5732
5869
  team: resolvedCfg.team || undefined,
5733
5870
  parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
5734
5871
  siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
@@ -5742,6 +5879,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5742
5879
  settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
5743
5880
  hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
5744
5881
  ...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
5882
+ ...(cap.environmentNamespaces?.length ? { environmentNamespaces: [...cap.environmentNamespaces] } : {}),
5745
5883
  })),
5746
5884
  skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
5747
5885
  instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
@@ -5768,7 +5906,11 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5768
5906
  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
5907
  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
5908
  }
5771
- : {
5909
+ : runtime === "codex" ? {
5910
+ oatsComposed: "skills via .agents/skills; instructions via AGENTS.md; task via initial prompt",
5911
+ ambient: ["user and ancestor instructions", "user, project, admin and system skills", "user and project configuration and MCP servers"],
5912
+ 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.",
5913
+ } : {
5772
5914
  oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
5773
5915
  curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
5774
5916
  ambient: ["globally configured pi extensions, and any resources they contribute"],
@@ -5780,17 +5922,25 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5780
5922
  },
5781
5923
  capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
5782
5924
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
5783
- hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
5925
+ hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
5784
5926
  missingRequires: cap.missingRequires, trust: cap.trust,
5785
5927
  executable: cap.executable,
5786
5928
  })),
5787
- tmux: { session, window: instance },
5929
+ ...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
5788
5930
  command: cmdline, createdAt: new Date().toISOString(),
5789
5931
  };
5790
5932
  const spawnWarnings = warnings;
5791
5933
 
5792
5934
  spawnTmux = meta.tmux;
5793
- if (launch) {
5935
+ if (launch && backend === "herdr") {
5936
+ windowMayExist = true;
5937
+ spawnHerdr = allocateHerdr(herdrBase, { home, instance });
5938
+ meta.sessionTarget = spawnHerdr;
5939
+ meta.launched = true;
5940
+ writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5941
+ writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, sessionTarget: spawnHerdr });
5942
+ launchHerdr(spawnHerdr, cmdline);
5943
+ } else if (launch) {
5794
5944
  if (!tmuxAlive(session)) {
5795
5945
  const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
5796
5946
  sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
@@ -5844,7 +5994,7 @@ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spa
5844
5994
  }
5845
5995
  }
5846
5996
 
5847
- return { ...meta, attach: `tmux attach -t ${session}`, warnings: spawnWarnings.length ? spawnWarnings : undefined };
5997
+ 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
5998
  } catch (error) {
5849
5999
  const note = compensateSpawn();
5850
6000
  error.message += note;
@@ -5856,8 +6006,11 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
5856
6006
  const windows = tmuxWindows(tmuxSession);
5857
6007
  const readInstancesOf = (agentDir) => {
5858
6008
  const instancesDir = join(agentDir, "instances");
6009
+ // An instance name starts with a letter or digit (INSTANCE_NAME_RE); a
6010
+ // dot-directory under instances/ is kernel bookkeeping (.oats-retirement
6011
+ // holds baselines and recoveries), never a home (oats-5xl).
5859
6012
  return (existsSync(instancesDir) ? readdirSync(instancesDir, { withFileTypes: true }) : [])
5860
- .filter((e) => e.isDirectory())
6013
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
5861
6014
  .map((e) => {
5862
6015
  const metaPath = join(instancesDir, e.name, "instance.json");
5863
6016
  const home = join(instancesDir, e.name);
@@ -5872,12 +6025,47 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
5872
6025
  try { rollbackIncomplete = JSON.parse(readFileSync(quarantine, "utf8")); }
5873
6026
  catch { rollbackIncomplete = { reason: "rollback incomplete" }; }
5874
6027
  }
5875
- return { ...meta, running: windows.includes(meta.instance || e.name), ...(rollbackIncomplete ? { rollbackIncomplete } : {}) };
6028
+ // A self-retire that has been requested but not completed: the home is
6029
+ // owed a retirement, not live work. Read-only here — completion is the
6030
+ // detached child's or an operator's `oats retire`, never status.
6031
+ const pending = retirePendingMarkerPath(home);
6032
+ let retirePending;
6033
+ if (existsSync(pending)) {
6034
+ try { retirePending = JSON.parse(readFileSync(pending, "utf8")); }
6035
+ catch { retirePending = { reason: "retire pending" }; }
6036
+ }
6037
+ let liveness = { running: windows.includes(meta.instance || e.name) };
6038
+ if (meta.sessionTarget) {
6039
+ try { const state = inspectHerdr({ ...meta.sessionTarget, binary: "herdr" }); liveness = { running: state.present, runtimeState: state.status }; }
6040
+ catch (error) { liveness = { running: null, runtimeState: "unreachable", runtimeError: error.message }; }
6041
+ }
6042
+ return { ...meta, ...liveness, ...(rollbackIncomplete ? { rollbackIncomplete } : {}), ...(retirePending ? { retirePending } : {}) };
6043
+
5876
6044
  });
5877
6045
  };
6046
+ // Failed deferred self-retirements leave their outcome beside the home; a
6047
+ // successful one is evidence only and is not a problem to surface.
6048
+ const readRetireFailuresOf = (agentDir) => {
6049
+ const instancesDir = join(agentDir, "instances");
6050
+ if (!existsSync(instancesDir)) return [];
6051
+ const out = [];
6052
+ for (const e of readdirSync(instancesDir, { withFileTypes: true })) {
6053
+ const m = e.isFile() && /^\.oats-retired-(.+)\.json$/.exec(e.name);
6054
+ if (!m) continue;
6055
+ try {
6056
+ const r = JSON.parse(readFileSync(join(instancesDir, e.name), "utf8"));
6057
+ 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) });
6058
+ } catch { out.push({ instance: m[1], error: "unreadable result file", resultPath: join(instancesDir, e.name) }); }
6059
+ }
6060
+ return out;
6061
+ };
6062
+ const withFailures = (entry, agentDir) => {
6063
+ const retireFailures = readRetireFailuresOf(agentDir);
6064
+ return retireFailures.length ? { ...entry, retireFailures } : entry;
6065
+ };
5878
6066
  const out = listAgents(root).map((a) => {
5879
6067
  const { _dir, ...soul } = a;
5880
- return { ...soul, dir: _dir, instances: readInstancesOf(a._dir) };
6068
+ return withFailures({ ...soul, dir: _dir, instances: readInstancesOf(a._dir) }, a._dir);
5881
6069
  });
5882
6070
  // Capability-defined agents home under local-agents/<name>/ WITHOUT a local
5883
6071
  // soul (it lives read-only in the package) — surface their instances too.
@@ -5887,9 +6075,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
5887
6075
  for (const e of readdirSync(dir, { withFileTypes: true })) {
5888
6076
  if (!e.isDirectory() || seen.has(e.name)) continue;
5889
6077
  const instances = readInstancesOf(join(dir, e.name));
5890
- if (!instances.length) continue;
6078
+ const retireFailures = readRetireFailuresOf(join(dir, e.name));
6079
+ if (!instances.length && !retireFailures.length) continue;
5891
6080
  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 });
6081
+ 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
6082
  seen.add(e.name);
5894
6083
  }
5895
6084
  }
@@ -5967,7 +6156,7 @@ export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
5967
6156
 
5968
6157
  /** Retain the home and its cleanup receipt when spawn compensation or retirement
5969
6158
  * 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 }) {
6159
+ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason }) {
5971
6160
  try {
5972
6161
  writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
5973
6162
  // `reason` is optional and DEFAULTS to the spawn wording, so every existing
@@ -5983,10 +6172,11 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
5983
6172
  cleanup: {
5984
6173
  version: QUARANTINE_CLEANUP_VERSION,
5985
6174
  repo: repoAbs, work, branch, launched, tmux,
6175
+ ...(sessionTarget ? { sessionTarget } : {}),
5986
6176
  outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
5987
6177
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
5988
6178
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
5989
- hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
6179
+ hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment, environmentNamespaces: cap.environmentNamespaces,
5990
6180
  missingRequires: cap.missingRequires,
5991
6181
  trust: cap.trust, executable: cap.executable,
5992
6182
  })),
@@ -5997,7 +6187,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
5997
6187
  } catch { /* the quarantine still stands without its marker */ }
5998
6188
  if (recordRetirementBaseline) {
5999
6189
  try {
6000
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux });
6190
+ writeRetirementBaseline(home, join(home, "work"), work === "worktree", resolveWorkMode(repoAbs, work), resolvedCfg.capabilities || [], { launched: launched === true, tmux, sessionTarget });
6001
6191
  } catch (e) {
6002
6192
  incomplete.push(`independent retirement authority: ${e.message}`);
6003
6193
  }
@@ -6071,6 +6261,15 @@ function retirementBaselinePath(home) {
6071
6261
  return join(retirementStateRoot(home), "baselines", `${retirementKey(home)}.json`);
6072
6262
  }
6073
6263
 
6264
+ /** git's output for a large tree (status with untracked and ignored files,
6265
+ * the index listing, a large blob) easily exceeds Node's 1 MiB default child
6266
+ * buffer; the spawn then dies with ENOBUFS and retirement reports the
6267
+ * recovery as unverifiable (cjr, ~9500 tracked long paths). Every git call
6268
+ * on the retirement path gets this bound instead. It raises the practical
6269
+ * limit, it does not remove it: a single blob over 512 MiB would still fail,
6270
+ * loudly, and streaming is deliberately not attempted in this change. */
6271
+ const GIT_MAX_BUFFER = 512 * 1024 * 1024;
6272
+
6074
6273
  function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
6075
6274
  const hash = createHash("sha256");
6076
6275
  const rootStat = lstatSync(root);
@@ -6100,7 +6299,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
6100
6299
 
6101
6300
  function worktreeStatus(repo) {
6102
6301
  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"] });
6302
+ 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
6303
  } catch (e) {
6105
6304
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect instance worktree: ${String(e.stderr ?? e.message ?? "").trim() || "git status failed"}`);
6106
6305
  }
@@ -6151,6 +6350,7 @@ function writeRetirementBaseline(home, work, isWorktree, workMode, capabilities,
6151
6350
  runtime: {
6152
6351
  launched: runtime?.launched === true,
6153
6352
  ...(runtime?.tmux ? { tmux: { session: runtime.tmux.session, window: runtime.tmux.window, ...(runtime.tmux.socket ? { socket: resolve(runtime.tmux.socket) } : {}) } } : {}),
6353
+ ...(runtime?.sessionTarget ? { sessionTarget: runtime.sessionTarget } : {}),
6154
6354
  },
6155
6355
  };
6156
6356
  const path = retirementBaselinePath(home);
@@ -6176,7 +6376,7 @@ function branchOnlyCommits(repo, branch) {
6176
6376
  if (!repo || !branch) return [];
6177
6377
  const target = `refs/heads/${branch}`;
6178
6378
  try {
6179
- execFileSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", target], { stdio: ["ignore", "pipe", "pipe"] });
6379
+ execFileSync("git", ["-C", repo, "rev-parse", "--verify", "--quiet", target], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6180
6380
  } catch (e) {
6181
6381
  const detail = String(e.stderr ?? "").trim();
6182
6382
  // A quarantine retry may follow a successful rollback-owned branch removal.
@@ -6185,9 +6385,9 @@ function branchOnlyCommits(repo, branch) {
6185
6385
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${detail || String(e.message ?? "").trim() || "git ref probe failed"}`);
6186
6386
  }
6187
6387
  try {
6188
- const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
6388
+ const refs = execFileSync("git", ["-C", repo, "for-each-ref", "--format=%(refname)"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER })
6189
6389
  .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);
6390
+ 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
6391
  } catch (e) {
6192
6392
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${String(e.stderr ?? e.message ?? "").trim() || "git ref probe failed"}`);
6193
6393
  }
@@ -6197,11 +6397,55 @@ function runtimeAuthorityOf(baseline) {
6197
6397
  const runtime = baseline?.runtime;
6198
6398
  if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
6199
6399
  if (!runtime.launched) return { launched: false };
6400
+ if (runtime.sessionTarget !== undefined) {
6401
+ if (runtime.tmux || !validHerdrTarget(runtime.sessionTarget)) return undefined;
6402
+ return { launched: true, sessionTarget: runtime.sessionTarget };
6403
+ }
6200
6404
  const tmux = runtime.tmux;
6201
6405
  if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
6202
6406
  return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
6203
6407
  }
6204
6408
 
6409
+ /** Session control uses the same independent endpoint receipt as retirement. */
6410
+ function instanceSessionTarget(home) {
6411
+ if (typeof home !== "string" || !isAbsolute(home)) throw oatsError("E_BAD_ARGS", "session needs an absolute instance home");
6412
+ home = realPathOrNearest(home);
6413
+ let baseline, meta;
6414
+ try {
6415
+ baseline = JSON.parse(readFileSync(retirementBaselinePath(home), "utf8"));
6416
+ meta = JSON.parse(readFileSync(join(home, "instance.json"), "utf8"));
6417
+ } catch (e) { throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot read session receipt for ${home}: ${e.message}`); }
6418
+ const authority = baseline.version === RETIRE_BASELINE_VERSION && baseline.home === home && runtimeAuthorityOf(baseline);
6419
+ if (!authority) throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `independent session receipt is missing or invalid for ${home}`);
6420
+ const endpointAgrees = authority.sessionTarget
6421
+ ? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === authority.sessionTarget[key])
6422
+ : !meta.sessionTarget && meta.tmux?.session === authority.tmux?.session && meta.tmux?.window === authority.tmux?.window && resolve(meta.tmux?.socket || ".") === authority.tmux?.socket;
6423
+ if (meta.launched !== authority.launched || (authority.launched && !endpointAgrees)) throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", "instance metadata disagrees with independent session receipt");
6424
+ return { home, target: authority.launched ? authority.sessionTarget || { backend: "tmux", ...authority.tmux } : undefined };
6425
+ }
6426
+
6427
+ export function inspectInstanceSession(home) {
6428
+ if (typeof home === "string" && isAbsolute(home) && !existsSync(home)) return { home: realPathOrNearest(home), backend: null, present: false, state: "stopped" };
6429
+ const s = instanceSessionTarget(home);
6430
+ if (!s.target) return { home: s.home, backend: null, present: false, state: "not-launched" };
6431
+ try { return { home: s.home, ...inspectSessionTarget(s.target) }; }
6432
+ catch (e) { throw oatsError("E_SESSION_UNAVAILABLE", `cannot inspect session: ${e.message}`); }
6433
+ }
6434
+
6435
+ export async function attachInstanceSession(home) {
6436
+ const s = instanceSessionTarget(home);
6437
+ if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
6438
+ return attachSessionTarget(s.target);
6439
+ }
6440
+
6441
+ export function inputInstanceSession(home, text) {
6442
+ 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");
6443
+ const s = instanceSessionTarget(home);
6444
+ if (!s.target) throw oatsError("E_SESSION_NOT_RUNNING", "instance was not launched");
6445
+ try { return { home: s.home, ...inputSessionTarget(s.target, text) }; }
6446
+ catch (e) { throw oatsError("E_SESSION_INPUT_FAILED", `cannot submit session input: ${e.message}`); }
6447
+ }
6448
+
6205
6449
  function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
6206
6450
  const classes = [];
6207
6451
  let baseline;
@@ -6247,17 +6491,44 @@ const RECOVERABLE_GIT_ADMIN = [
6247
6491
  "BISECT_LOG", "BISECT_START", "BISECT_NAMES", "rebase-apply", "rebase-merge", "sequencer",
6248
6492
  ];
6249
6493
 
6494
+ /** Object ids among `oids` that `repo` does not have, from one
6495
+ * `cat-file --batch-check` process. */
6496
+ export function missingGitObjects(repo, oids) {
6497
+ if (!oids.length) return [];
6498
+ const out = execFileSync("git", ["-C", repo, "cat-file", "--batch-check=%(objectname) %(objecttype)"], { input: oids.join("\n") + "\n", encoding: "utf8", maxBuffer: GIT_MAX_BUFFER });
6499
+ const missing = [];
6500
+ for (const line of out.split("\n")) {
6501
+ if (!line.endsWith(" missing")) continue;
6502
+ missing.push(line.slice(0, line.indexOf(" ")));
6503
+ }
6504
+ return missing;
6505
+ }
6506
+
6250
6507
  function restoreStandaloneGitState(sourceWork, recoveredRepo) {
6251
- const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" }).trim();
6508
+ const sourceGit = execFileSync("git", ["-C", sourceWork, "rev-parse", "--absolute-git-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6252
6509
  const recoveredGit = join(recoveredRepo, ".git");
6253
- const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"]);
6510
+ const indexRows = execFileSync("git", ["-C", sourceWork, "ls-files", "--stage", "-z"], { maxBuffer: GIT_MAX_BUFFER });
6511
+ // The staged blobs the recovered index will point at. The clone already
6512
+ // holds every blob reachable from a commit; only content that is staged but
6513
+ // never committed is missing, so ask once which objects the recovered
6514
+ // repository lacks (one process for the whole index) and copy only those.
6515
+ // Copying every row cost two Git processes per index entry: a clean 9,500
6516
+ // file tree took ~19,000 launches and looked like a hang. Gitlink rows
6517
+ // (mode 160000) name commits of nested repositories, which
6518
+ // materializeNestedRepositories restores; they are never blobs here.
6519
+ const staged = new Set();
6254
6520
  for (const row of indexRows.toString("utf8").split("\0").filter(Boolean)) {
6255
- const match = row.match(/^\d+ ([0-9a-f]+) \d+\t/);
6256
- 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();
6259
- if (restored !== match[1]) throw new Error(`recovered Git object ${restored} did not match source ${match[1]}`);
6521
+ const match = row.match(/^(\d+) ([0-9a-f]+) \d+\t/);
6522
+ if (!match || match[1] === "160000") continue;
6523
+ staged.add(match[2]);
6260
6524
  }
6525
+ for (const oid of missingGitObjects(recoveredRepo, [...staged])) {
6526
+ const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", oid], { maxBuffer: GIT_MAX_BUFFER });
6527
+ const restored = execFileSync("git", ["-C", recoveredRepo, "hash-object", "-w", "--stdin"], { input: blob, encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6528
+ if (restored !== oid) throw new Error(`recovered Git object ${restored} did not match source ${oid}`);
6529
+ }
6530
+ const stillMissing = missingGitObjects(recoveredRepo, [...staged]);
6531
+ if (stillMissing.length) throw new Error(`recovered repository lacks ${stillMissing.length} staged object(s) after restore: ${stillMissing.slice(0, 3).join(", ")}`);
6261
6532
  copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
6262
6533
  for (const name of RECOVERABLE_GIT_ADMIN) {
6263
6534
  const source = join(sourceGit, name);
@@ -6270,11 +6541,11 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
6270
6541
 
6271
6542
  function detachRecoveryClone(source, recovered) {
6272
6543
  let stash;
6273
- try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); }
6544
+ try { stash = execFileSync("git", ["-C", source, "rev-parse", "--verify", "--quiet", "refs/stash"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER }).trim(); }
6274
6545
  catch { stash = undefined; }
6275
6546
  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();
6547
+ execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"], { maxBuffer: GIT_MAX_BUFFER });
6548
+ const sourceCommon = execFileSync("git", ["-C", source, "rev-parse", "--path-format=absolute", "--git-common-dir"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6278
6549
  const stashLog = join(sourceCommon, "logs", "refs", "stash");
6279
6550
  if (existsSync(stashLog)) {
6280
6551
  const recoveredLog = join(recovered, ".git", "logs", "refs", "stash");
@@ -6282,17 +6553,17 @@ function detachRecoveryClone(source, recovered) {
6282
6553
  copyFileSync(stashLog, recoveredLog);
6283
6554
  }
6284
6555
  }
6285
- try { execFileSync("git", ["-C", recovered, "remote", "remove", "origin"], { stdio: "ignore" }); } catch { /* no remote is already independent */ }
6556
+ try { execFileSync("git", ["-C", recovered, "remote", "remove", "origin"], { stdio: "ignore" , maxBuffer: GIT_MAX_BUFFER }); } catch { /* no remote is already independent */ }
6286
6557
  }
6287
6558
 
6288
6559
  function materializeNestedRepositories(sourceWork, recoveredRepo) {
6289
6560
  for (const source of nestedGitRoots(sourceWork)) {
6290
6561
  const rel = relative(sourceWork, source);
6291
6562
  const dest = join(recoveredRepo, rel);
6292
- const head = execFileSync("git", ["-C", source, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
6563
+ const head = execFileSync("git", ["-C", source, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6293
6564
  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]);
6565
+ execFileSync("git", ["clone", "--no-local", "--quiet", source, dest], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6566
+ execFileSync("git", ["-C", dest, "checkout", "--quiet", head], { maxBuffer: GIT_MAX_BUFFER });
6296
6567
  detachRecoveryClone(source, dest);
6297
6568
  restoreStandaloneGitState(source, dest);
6298
6569
  for (const e of readdirSync(source, { withFileTypes: true })) {
@@ -6312,14 +6583,24 @@ function preserveRetirementWork(observation, meta, instance) {
6312
6583
  const staging = mkdtempSync(join(recoveryRoot, `.${instance}-`));
6313
6584
  const recovery = join(recoveryRoot, basename(staging).slice(1));
6314
6585
  try {
6586
+ // A home-only change (notes, runtime files, credentials) needs a home
6587
+ // snapshot, not another copy of an otherwise disposable clean worktree.
6588
+ // In-progress Git operations retain the full standalone recovery even
6589
+ // when porcelain status has no changed paths.
6590
+ let homeOnly = observation.classes.length === 1 && observation.classes[0] === "changed instance-home bytes"
6591
+ && meta.work === "worktree" && existsSync(observation.work);
6592
+ if (homeOnly) {
6593
+ const gitDir = execFileSync("git", ["-C", observation.work, "rev-parse", "--absolute-git-dir"], { encoding: "utf8", maxBuffer: GIT_MAX_BUFFER }).trim();
6594
+ homeOnly = !RECOVERABLE_GIT_ADMIN.some((name) => existsSync(join(gitDir, name)));
6595
+ }
6315
6596
  const recoveredHome = join(staging, "home");
6316
6597
  copyRecoveryTree(observation.home, recoveredHome, { excludeRoot: new Set(["work"]) });
6317
6598
  if (fingerprintTree(observation.home, { excludeRoot: new Set(["work"]) }) !== fingerprintTree(recoveredHome)) {
6318
6599
  throw new Error("home recovery verification disagreed with the source");
6319
6600
  }
6320
- if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
6601
+ if (!homeOnly && meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
6321
6602
  const recoveredRepo = join(staging, "repo");
6322
- execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] });
6603
+ execFileSync("git", ["clone", "--no-local", "--quiet", "--branch", meta.branch, meta.repo, recoveredRepo], { stdio: ["ignore", "pipe", "pipe"] , maxBuffer: GIT_MAX_BUFFER });
6323
6604
  const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
6324
6605
  detachRecoveryClone(sourceGitContext, recoveredRepo);
6325
6606
  if (existsSync(observation.work)) {
@@ -6337,25 +6618,176 @@ function preserveRetirementWork(observation, meta, instance) {
6337
6618
  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
6619
  if (worktreeStatus(observation.work) !== worktreeStatus(recoveredRepo)) throw new Error("recovered Git index/status disagreed with the source");
6339
6620
  }
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();
6621
+ const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6622
+ const sourceHead = execFileSync("git", ["-C", meta.repo, "rev-parse", `refs/heads/${meta.branch}`], { encoding: "utf8" , maxBuffer: GIT_MAX_BUFFER }).trim();
6342
6623
  if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
6343
6624
  }
6344
- 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 });
6625
+ const repoCopy = homeOnly ? { copied: false, reason: "Only instance-home bytes changed; no work state requires a repository copy", source: meta.repo, branch: meta.branch } : undefined;
6626
+ writeFileSync(join(staging, "recovery.json"), JSON.stringify({ version: 1, instance, classes: observation.classes, sourceHome: observation.home, createdAt: new Date().toISOString(), ...(repoCopy ? { repoCopy } : {}) }, null, 2) + "\n", { mode: 0o600 });
6345
6627
  mkdirSync(dirname(recovery), { recursive: true });
6346
6628
  renameSync(staging, recovery);
6347
- return { path: recovery, classes: observation.classes };
6629
+ return { path: recovery, classes: observation.classes, ...(repoCopy ? { repoCopy } : {}) };
6348
6630
  } catch (e) {
6349
6631
  rmSync(staging, { recursive: true, force: true });
6350
6632
  throw oatsError("E_WORK_PRESERVATION_FAILED", `retirement work remains at ${observation.home}; recovery could not be verified: ${e.message}`);
6351
6633
  }
6352
6634
  }
6353
6635
 
6636
+ /** Marker a self-retiring instance leaves BESIDE its home: the retirement is
6637
+ * requested and owed, and a detached completion is on its way. It is not
6638
+ * written into the home, so the caller changes no instance bytes and the
6639
+ * completion's work inspection sees exactly what the instance left. */
6640
+ export function retirePendingMarkerPath(home) {
6641
+ return join(dirname(home), `.oats-retire-pending-${basename(home)}.json`);
6642
+ }
6643
+
6644
+ /** Where a deferred self-retirement writes its explicit outcome: a FILE beside
6645
+ * the (former) home, so it survives the home's removal and never reads as an
6646
+ * instance directory. */
6647
+ export function deferredRetireResultPath(home) {
6648
+ return join(dirname(home), `.oats-retired-${basename(home)}.json`);
6649
+ }
6650
+
6651
+ const DEFERRED_RETIRE_SCRIPT = `import { completeDeferredRetirement } from ${JSON.stringify(import.meta.url)};
6652
+ process.exitCode = completeDeferredRetirement(JSON.parse(process.env.OATS_RETIRE_INTENT)) ? 0 : 1;`;
6653
+
6654
+ /** Self-retire (aweb-abep): persist intent, then hand the retirement to a
6655
+ * detached process that runs it as an ORDINARY external retirement after the
6656
+ * caller's window has died. Nothing destructive happens in the caller: no
6657
+ * inspection, no hooks, no removal — the runtime is still alive, and the
6658
+ * quiesce rule stays intact. The child owns its own process group so the
6659
+ * tmux window kill (SIGHUP to the pane's group) cannot take it down, and the
6660
+ * caller's instance env is stripped so the child is an external operator,
6661
+ * not another self-retire. The intent travels to the child in its env; the
6662
+ * marker beside the home is the operator-visible promise and is written only
6663
+ * once a completion process exists (reviewer D2). */
6664
+ function scheduleDeferredSelfRetirement(root, found, name, o, session) {
6665
+ const delaySec = o.selfKillDelaySec ?? 8;
6666
+ const marker = retirePendingMarkerPath(found.home);
6667
+ const resultPath = deferredRetireResultPath(found.home);
6668
+ const logPath = resultPath.replace(/\.json$/, ".log");
6669
+ // A second `--self` while the first completion is still on its way must not
6670
+ // start a second, racing retirement: report the one already owed.
6671
+ if (existsSync(marker) && !existsSync(resultPath)) {
6672
+ let prior; try { prior = JSON.parse(readFileSync(marker, "utf8")); } catch { prior = undefined; }
6673
+ if (prior?.resultPath) {
6674
+ return { retired: name, agent: found.agent.name, deferred: true, alreadyScheduled: true, pendingMarker: marker, resultPath: prior.resultPath, logPath, completesInSec: prior.delaySec ?? delaySec, requestedAt: prior.requestedAt };
6675
+ }
6676
+ }
6677
+ const intent = {
6678
+ instance: name, agent: found.agent.name, root: resolve(root),
6679
+ requestedAt: new Date().toISOString(), requestedByPid: process.pid, delaySec,
6680
+ options: { home: found.home, deleteBranch: !!o.deleteBranch, ...(o.keepDir ? { keepDir: true } : {}), tmuxSession: session }, resultPath,
6681
+ };
6682
+ const env = { ...process.env, OATS_RETIRE_INTENT: JSON.stringify(intent) };
6683
+ for (const k of CORE_LAUNCH_ENV) delete env[k];
6684
+ rmSync(resultPath, { force: true });
6685
+ 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} --home ${found.home}\``);
6686
+ let fd, child;
6687
+ try {
6688
+ fd = openSync(logPath, "w");
6689
+ child = spawnProcess(process.execPath, ["--input-type=module", "-e", DEFERRED_RETIRE_SCRIPT], {
6690
+ detached: true, stdio: ["ignore", fd, fd], env, cwd: dirname(found.home),
6691
+ });
6692
+ } catch (e) {
6693
+ if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
6694
+ rmSync(logPath, { force: true });
6695
+ throw scheduleFailed(e.message);
6696
+ }
6697
+ closeSync(fd);
6698
+ if (!child.pid) { rmSync(logPath, { force: true }); throw scheduleFailed("no process was created"); }
6699
+ // A spawn failure Node reports asynchronously (EAGAIN, EMFILE) would arrive
6700
+ // after this returns; record it as a failed outcome so status shows the
6701
+ // debt instead of an uncaught exception behind a success message.
6702
+ child.on("error", (e) => {
6703
+ try {
6704
+ 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} --home ${found.home}` }, null, 2) + "\n");
6705
+ } catch { /* the marker alone then shows RETIRING; status names its age */ }
6706
+ });
6707
+ child.unref();
6708
+ writeFileSync(marker, JSON.stringify(intent, null, 2) + "\n");
6709
+ return {
6710
+ retired: name, agent: found.agent.name, deferred: true, pendingMarker: marker,
6711
+ resultPath, logPath, completesInSec: delaySec, completionPid: child.pid,
6712
+ };
6713
+ }
6714
+
6715
+ /** Run by the detached child: wait for the caller's window to be gone, then
6716
+ * retire the instance as an ordinary external operator. Returns true only
6717
+ * when the home is actually gone, and then leaves no file behind. A failure
6718
+ * writes an explicit outcome beside the home and leaves the pending marker
6719
+ * (and, after hooks ran, the usual quarantine) in place, so `oats status`
6720
+ * shows the debt and `oats retire <name>` retries and clears it. Accepts the
6721
+ * intent object (the child gets it in its env) or a marker path. */
6722
+ export function completeDeferredRetirement(intentOrMarkerPath, opts = {}) {
6723
+ let intent = intentOrMarkerPath;
6724
+ if (typeof intentOrMarkerPath === "string") {
6725
+ try { intent = JSON.parse(readFileSync(intentOrMarkerPath, "utf8")); }
6726
+ catch (e) { console.error(`deferred retirement: cannot read ${intentOrMarkerPath}: ${e.message}`); return false; }
6727
+ }
6728
+ if (!isPlainObject(intent) || !intent.instance || !intent.root) { console.error("deferred retirement: intent is not usable"); return false; }
6729
+ const record = (payload) => {
6730
+ if (!intent.resultPath) return;
6731
+ try {
6732
+ writeFileSync(intent.resultPath, JSON.stringify({
6733
+ instance: intent.instance, agent: intent.agent, requestedAt: intent.requestedAt,
6734
+ completedAt: new Date().toISOString(), ...payload,
6735
+ }, null, 2) + "\n");
6736
+ } catch (e) { console.error(`deferred retirement: cannot write ${intent.resultPath}: ${e.message}`); }
6737
+ };
6738
+ const delayMs = Math.max(0, Number(opts.delaySec ?? intent.delaySec ?? 8) * 1000);
6739
+ if (delayMs) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
6740
+ let result;
6741
+ try {
6742
+ result = retireInstance(intent.root, intent.instance, {
6743
+ home: intent.options?.home, deleteBranch: !!intent.options?.deleteBranch, keepDir: !!intent.options?.keepDir, tmuxSession: intent.options?.tmuxSession,
6744
+ });
6745
+ } catch (e) {
6746
+ record({ ok: false, error: { code: e.code, message: e.message }, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
6747
+ console.error(`deferred retirement of ${intent.instance} failed: ${e.message}`);
6748
+ return false;
6749
+ }
6750
+ if (result.rollbackIncomplete) {
6751
+ record({ ok: false, result, retry: `oats retire ${intent.instance}${intent.options?.home ? ` --home ${intent.options.home}` : ""}` });
6752
+ console.error(`deferred retirement of ${intent.instance} is INCOMPLETE; the home is retained:\n ${result.rollbackIncomplete.join("\n ")}`);
6753
+ return false;
6754
+ }
6755
+ if (intent.options?.keepDir) {
6756
+ // The kept home is the one the intent names, never a same-named twin.
6757
+ const retained = intent.options?.home ? { home: intent.options.home } : findInstanceHome(intent.root, intent.instance);
6758
+ if (retained) rmSync(retirePendingMarkerPath(retained.home), { force: true });
6759
+ }
6760
+ // Success leaves nothing beside the home: the home is gone, the marker went
6761
+ // with it, and a kept success record per retired reviewer or harvester would
6762
+ // accumulate forever (reviewer D4). Only failures leave files, and they are
6763
+ // the ones status surfaces and a retry clears.
6764
+ try { rmSync(intent.resultPath.replace(/\.json$/, ".log"), { force: true }); } catch { /* nothing to keep */ }
6765
+ return true;
6766
+ }
6767
+
6354
6768
  export function retireInstance(root, name, o = {}) {
6355
6769
  const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
6356
- const self = o.self === true; // self-retire: the caller IS the instance — kill the window LAST
6357
- const found = findInstanceHome(root, name);
6358
- if (!found) throw new Error(`no instance named "${name}"`);
6770
+ // self-retire: the caller IS the instance. Without --keep-dir the whole
6771
+ // retirement is deferred to a detached external completion (below); with
6772
+ // --keep-dir the old in-process path runs and kills the window LAST.
6773
+ const self = o.self === true;
6774
+ // Names are unique per agent dir only: two agents can own an instance of
6775
+ // the same name (dev --purpose foo-1 and agent dev-foo). Retirement is
6776
+ // destructive, so a name that resolves to several homes is refused unless
6777
+ // the caller says which home (--home), and a home is accepted only when it
6778
+ // is one of that name's homes under this root.
6779
+ const matches = findInstanceHomes(root, name);
6780
+ if (!matches.length) throw new Error(`no instance named "${name}"`);
6781
+ const sameHome = (a, b) => { try { return realpathSync(a) === realpathSync(b); } catch { return resolve(a) === resolve(b); } };
6782
+ let found;
6783
+ if (o.home) {
6784
+ found = matches.find((m) => sameHome(m.home, o.home));
6785
+ if (!found) throw oatsError("E_HOME_MISMATCH", `instance "${name}" has no home at ${o.home} under this agents root (its home${matches.length === 1 ? " is" : "s are"} ${matches.map((m) => m.home).join(", ")})`);
6786
+ } else if (matches.length > 1) {
6787
+ throw oatsError("E_AMBIGUOUS_INSTANCE", `"${name}" names ${matches.length} instances under this agents root (${matches.map((m) => `${m.agent.name}: ${m.home}`).join("; ")}); retire with --home <path> to say which`);
6788
+ } else {
6789
+ found = matches[0];
6790
+ }
6359
6791
  const metaPath = join(found.home, "instance.json");
6360
6792
  // A QUARANTINED home (spawn failed after a required hook and compensation did
6361
6793
  // not finish) has no instance.json — it never got that far. Its marker carries
@@ -6390,6 +6822,7 @@ export function retireInstance(root, name, o = {}) {
6390
6822
  if (quarantine && typeof quarantine.cleanup.launched === "boolean") {
6391
6823
  meta.launched = quarantine.cleanup.launched;
6392
6824
  meta.tmux = quarantine.cleanup.tmux;
6825
+ meta.sessionTarget = quarantine.cleanup.sessionTarget;
6393
6826
  }
6394
6827
  // A home with NEITHER instance.json NOR a usable cleanup descriptor cannot be
6395
6828
  // retired safely: hooks would be skipped and the directory removed, which is
@@ -6409,8 +6842,13 @@ export function retireInstance(root, name, o = {}) {
6409
6842
  const workPath = join(found.home, "work");
6410
6843
  const isWorktree = meta.work === "worktree" ||
6411
6844
  (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}`);
6845
+ // A live runtime cannot establish a stable final work inspection of itself,
6846
+ // so self-retire never inspects, runs hooks, or removes anything here. It
6847
+ // persists the intent and hands the whole retirement to a detached process
6848
+ // that runs it as an ordinary EXTERNAL retirement once the runtime is gone
6849
+ // (aweb-abep). `--keep-dir` keeps the old in-process path: nothing to inspect.
6850
+ if (self && (!o.keepDir || meta.sessionTarget)) {
6851
+ return scheduleDeferredSelfRetirement(root, found, name, o, session);
6414
6852
  }
6415
6853
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
6416
6854
  // the managed runtime; recovery copying never races a live managed Pi.
@@ -6425,11 +6863,12 @@ export function retireInstance(root, name, o = {}) {
6425
6863
  if (!runtimeAuthority) {
6426
6864
  throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot quiesce ${name}: independent runtime endpoint authority is missing or invalid`);
6427
6865
  }
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
- ));
6866
+ const endpointAgrees = runtimeAuthority.sessionTarget
6867
+ ? !meta.tmux && ["backend", "binary", "socket", "workspaceId", "paneId", "terminalId", "protocol"].every((key) => meta.sessionTarget?.[key] === runtimeAuthority.sessionTarget[key])
6868
+ : !meta.sessionTarget && meta.tmux?.session === runtimeAuthority.tmux?.session
6869
+ && meta.tmux?.window === runtimeAuthority.tmux?.window
6870
+ && resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux?.socket;
6871
+ const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || endpointAgrees);
6433
6872
  if (!metaAgrees) {
6434
6873
  throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
6435
6874
  }
@@ -6437,7 +6876,11 @@ export function retireInstance(root, name, o = {}) {
6437
6876
  // `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
6438
6877
  // A no-launch instance is already quiesced. A launched one must have exact
6439
6878
  // window absence established before recovery copying begins.
6440
- if (!self && runtimeAuthority?.launched) {
6879
+ if (!self && runtimeAuthority?.launched && runtimeAuthority.sessionTarget) {
6880
+ try { stopHerdr(runtimeAuthority.sessionTarget); }
6881
+ catch (e) { throw oatsError("E_RUNTIME_QUIESCE_FAILED", `could not establish that Herdr session for ${name} stopped: ${e.message}`); }
6882
+ }
6883
+ if (!self && runtimeAuthority?.launched && !runtimeAuthority.sessionTarget) {
6441
6884
  const runtimeSession = runtimeAuthority.tmux.session;
6442
6885
  const runtimeWindow = runtimeAuthority.tmux.window;
6443
6886
  const runtimeSocket = runtimeAuthority.tmux.socket;
@@ -6480,13 +6923,12 @@ export function retireInstance(root, name, o = {}) {
6480
6923
  // describes. A hook that reports nothing, or reports retired:true, is
6481
6924
  // unaffected; only an explicit "I did not finish" changes the outcome.
6482
6925
  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.
6926
+ // The IN-PROCESS self path (--self --keep-dir only, since aweb-abep) is
6927
+ // excluded: that caller is the instance and cannot hold the authority to
6928
+ // complete owner cleanup (reviewer-5c8b724). A plain --self never reaches
6929
+ // here as `self`: its deferred completion calls retireInstance as an
6930
+ // external operator, so a self-retiring reviewer or harvester whose hook
6931
+ // reports incomplete cleanup IS quarantined like any other instance.
6490
6932
  if (!quarantine && !self) {
6491
6933
  for (const f of hookResults?.failures || []) ordinaryIncomplete.push(`retire hook ${f.capability}: ${f.message}`);
6492
6934
  // A hook may exit 0 and still report it did not finish. Only an explicit
@@ -6669,7 +7111,15 @@ export function retireInstance(root, name, o = {}) {
6669
7111
  // unreachable remote) would be unremovable through OATS forever — the same
6670
7112
  // dead end the unusable-marker fixes closed, just reached from a valid one.
6671
7113
  const forced = !!(stillIncomplete && o.force);
6672
- if (!o.keepDir && (!stillIncomplete || forced)) rmSync(found.home, { recursive: true, force: true });
7114
+ if (!o.keepDir && (!stillIncomplete || forced)) {
7115
+ rmSync(found.home, { recursive: true, force: true });
7116
+ // The owed retirement is paid: clear the pending marker, and the failed
7117
+ // outcome an earlier deferred attempt may have left beside the home; a
7118
+ // deferred completion writes its own outcome after this returns.
7119
+ rmSync(retirePendingMarkerPath(found.home), { force: true });
7120
+ rmSync(deferredRetireResultPath(found.home), { force: true });
7121
+ rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
7122
+ }
6673
7123
 
6674
7124
 
6675
7125
  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 };