@awebai/oats 0.22.0 → 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.
Files changed (60) hide show
  1. package/README.md +40 -50
  2. package/bin/oats.mjs +242 -22
  3. package/capabilities/oats-authoring/LICENSE +21 -0
  4. package/capabilities/oats-authoring/oats-package.json +11 -0
  5. package/capabilities/oats-authoring/oats.json +4 -4
  6. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +63 -0
  7. package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +109 -0
  8. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +109 -0
  9. package/capabilities/oats-aweb/injects/aweb.md +4 -3
  10. package/capabilities/oats-aweb/oats.json +7 -7
  11. package/capabilities/oats-aweb/skills/LICENSE +21 -0
  12. package/capabilities/oats-aweb/skills/VENDORED.md +26 -0
  13. package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +201 -0
  14. package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +161 -0
  15. package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +61 -0
  16. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +328 -0
  17. package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +74 -0
  18. package/capabilities/oats-jira/oats.json +1 -1
  19. package/capabilities/oats-linear/oats.json +1 -1
  20. package/capabilities/oats-okf/agents/{memory-harvest.md → memory-harvest/AGENTS.md} +3 -1
  21. package/capabilities/oats-okf/agents/memory-harvest/soul.yaml +6 -0
  22. package/capabilities/oats-okf/bin/oats-okf.mjs +201 -54
  23. package/capabilities/oats-okf/injects/okf.md +7 -0
  24. package/capabilities/oats-okf/oats.json +5 -2
  25. package/capabilities/oats-okf/skills/memory-harvest/SKILL.md +42 -1
  26. package/capabilities/oats-review/oats.json +1 -1
  27. package/docs/2026-09-03-architecture-proposal.md +642 -0
  28. package/docs/execution-targets.md +181 -0
  29. package/docs/first-team-demo.md +87 -0
  30. package/docs/first-team.md +179 -0
  31. package/docs/implementation.md +14 -1
  32. package/docs/integrations.md +83 -65
  33. package/docs/layers.md +356 -80
  34. package/docs/migration-from-oas.md +80 -116
  35. package/docs/oats-config.schema.json +1 -0
  36. package/docs/operating-team-migration.md +217 -0
  37. package/docs/release-notes/v0.22.1.md +106 -0
  38. package/docs/release-notes/v0.22.2.md +69 -0
  39. package/docs/servers.md +94 -0
  40. package/docs/souls-and-instances.md +30 -3
  41. package/lib/core.mjs +626 -415
  42. package/lib/herdr.mjs +95 -0
  43. package/lib/servers.mjs +436 -0
  44. package/lib/session-input.mjs +78 -0
  45. package/lib/session-viewer.mjs +51 -0
  46. package/package-catalog.json +2 -2
  47. package/package.json +1 -1
  48. package/packages/record/README.md +76 -16
  49. package/packages/record/bin/capture.mjs +59 -3
  50. package/packages/record/bin/recall.mjs +67 -1
  51. package/packages/record/docs/turn-record-sot.md +1 -1
  52. package/packages/record/lib/sessions-for-home.mjs +130 -0
  53. package/packages/record/lib/store.mjs +207 -43
  54. package/skills/oats/SKILL.md +6 -2
  55. package/capabilities/oats-aweb/package.json +0 -20
  56. package/capabilities/oats-jira/package.json +0 -25
  57. package/capabilities/oats-linear/README.md +0 -234
  58. package/capabilities/oats-linear/package.json +0 -29
  59. package/capabilities/oats-linear/test/oats-linear.test.mjs +0 -168
  60. package/capabilities/oats-okf/package.json +0 -22
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 };
@@ -4395,7 +4401,7 @@ export function runLifecycleHooks(event, { home, instance, agentName, soulDir, c
4395
4401
  // OATS_INSTANCE_HOME is the runtime-neutral contract name for the
4396
4402
  // instance home (absolute). OATS_HOME predates it and stays as a
4397
4403
  // compatibility alias: shipped capability hooks read it
4398
- // (capabilities/oats-aweb, capabilities/oats-okf) and are versioned
4404
+ // (the official oats.aweb and oats.okf packages) and are versioned
4399
4405
  // independently of this kernel. Neither is OATS_HOME_DIR, which is
4400
4406
  // the package STORE root — do not conflate them.
4401
4407
  OATS_EVENT: event, OATS_INSTANCE: instance, OATS_INSTANCE_HOME: home, OATS_HOME: home, OATS_AGENT: agentName,
@@ -4665,7 +4671,7 @@ export function appendLogEntry(file, entry, title = "Log") {
4665
4671
  writeFileSync(file, lines.join("\n").replace(/\n{3,}/g, "\n\n"));
4666
4672
  }
4667
4673
 
4668
- // (soul knowledge scaffolding belongs to capabilities/oats-okf — soul-scaffold hook)
4674
+ // (soul knowledge scaffolding belongs to the oats.okf package — soul-scaffold hook)
4669
4675
 
4670
4676
  // ---------- soul scaffolding ----------
4671
4677
  function fileSnapshot(dir) {
@@ -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
@@ -5333,6 +5364,14 @@ export function spawnInstance(root, agent, o = {}) {
5333
5364
  // reconciliation, so this spawn-time check is the authoritative one.
5334
5365
  const runtimePackages = verifyRuntimePackages(runtime, resolvedCfg, repoAbs);
5335
5366
 
5367
+ // Prerequisites must fail before creating a home, worktree, or identity.
5368
+ const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
5369
+ const bin = which(runtime === "claude" ? claudeBin : runtime);
5370
+ if (!bin) throw new Error(`${runtime === "claude" ? claudeBin : runtime} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}`);
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;
5373
+ const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
5374
+
5336
5375
  mkdirSync(home, { recursive: true });
5337
5376
  // TOCTOU: the placement checks above ran BEFORE composition and the runtime
5338
5377
  // package preflight, both of which shell out — a window in which anything able
@@ -5508,41 +5547,71 @@ export function spawnInstance(root, agent, o = {}) {
5508
5547
  // Capability lifecycle hooks (spawn) — the knowledge integration scaffolds instance
5509
5548
  // memory (STATE.md/log.md/notes/ are OKF conventions, not kernel ones); the
5510
5549
  // messaging integration mints the comms identity. Kernel stays memory-agnostic.
5511
- const task = o.task ?? (o.taskFile ? readFileSync(o.taskFile, "utf8") : "");
5512
5550
  const hookRes = runLifecycleHooks("spawn", {
5513
5551
  home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
5514
5552
  workspaceDir: workspaceOf(root), resolved: resolvedCfg,
5515
5553
  extraEnv: { OATS_TASK: task, OATS_REPO: repoAbs, OATS_BRANCH: branch || "", OATS_WORK: work, OATS_RUNTIME: runtime, OATS_KIND: agent.kind || "persistent" },
5516
5554
  });
5517
5555
  warnings.push(...hookRes.warnings);
5518
- // A REQUIRED spawn hook that failed means an active capability is not actually
5519
- // configured — aweb without a minted identity is an agent that believes it can
5520
- // be woken by mail and cannot. Fail the spawn and roll back, rather than hand
5521
- // over a half-configured instance. Nothing is launched yet, so compensation is
5522
- // retire hooks + worktree/branch + home; the same three-state verification as
5523
- // the anchor-write path, because a cleanup we cannot confirm must never be
5524
- // reported as done.
5525
5556
  const requiredFailures = (hookRes.failures || []).filter((f) => f.required);
5526
- if (requiredFailures.length) {
5527
- const incomplete = [];
5557
+ let windowMayExist = false;
5558
+ let spawnTmux;
5559
+ let spawnHerdr;
5560
+ const ancillaryCleanup = [];
5561
+ // One compensation owner, from the first hook result through launch and
5562
+ // the final lineage write. Preserve the original failure and retain any
5563
+ // credentials/metadata whose cleanup could not be confirmed.
5564
+ const compensateSpawn = () => {
5565
+ const failed = requiredFailures.length
5566
+ ? requiredFailures.map((f) => ({ capability: f.capability, event: f.event, ...(f.contract ? { contract: f.contract } : {}) }))
5567
+ : [{ capability: "oats.kernel", event: "spawn" }];
5568
+ const incomplete = [...ancillaryCleanup];
5528
5569
  const probe = (argv) => {
5529
5570
  try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
5530
- // With encoding:"utf8" a silent command yields stderr === "" — FALSY — so
5531
- // `e2.stderr || e2.message` fell through to "Command failed: …" and made
5532
- // every clean probe look like a failed one. `git rev-parse --verify
5533
- // --quiet` on an absent ref is exactly that case, so a successful branch
5534
- // deletion could never be confirmed and rollback always reported
5535
- // INCOMPLETE. Distinguish "no output" from "no stderr captured".
5536
- catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
5571
+ // An absent ref exits 1 with empty stderr; preserve that distinction.
5572
+ catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
5537
5573
  };
5538
- // Which capabilities still owe cleanup, as IDS the retry can verify against —
5539
- // the prose in `incomplete` tells a human what happened, but a retry needs
5540
- // something it can check. Without this, a retry that resolves no capabilities
5541
- // at all (a descriptor naming none, or config drift since the spawn) runs zero
5542
- // hooks, finds zero failures, and clears the quarantine having done nothing
5543
- // (reviewer-dd03a98).
5574
+ // Retain cleanup owners so a retry must actually discharge their debt.
5544
5575
  const outstandingHooks = new Set();
5545
5576
  const outstandingGit = new Set();
5577
+ // A failed new-window command may still have created its window. Verify
5578
+ // quiescence before removing credentials or work that runtime may be using.
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") {
5596
+ shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
5597
+ const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
5598
+ const unresolved = !winProbe.ok || winProbe.out.split("\n").includes(instance);
5599
+ if (unresolved) {
5600
+ incomplete.push(!winProbe.ok
5601
+ ? `tmux window ${session}:${instance}: could not verify removal (${winProbe.err || "list-windows failed"})`
5602
+ : `tmux window ${session}:${instance} still running`);
5603
+ for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5604
+ if (work === "worktree") {
5605
+ outstandingGit.add("worktree");
5606
+ if (branch) outstandingGit.add("branch");
5607
+ }
5608
+ return quarantineInstanceHome({
5609
+ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit,
5610
+ repoAbs, work, branch, resolvedCfg, hookMeta: hookRes.meta || {},
5611
+ launched: true, tmux: spawnTmux, recordRetirementBaseline: true,
5612
+ });
5613
+ }
5614
+ }
5546
5615
  let compensationMeta = {};
5547
5616
  try {
5548
5617
  const comp = runLifecycleHooks("retire", {
@@ -5597,25 +5666,13 @@ export function spawnInstance(root, agent, o = {}) {
5597
5666
  incomplete.push(`${cap.id}: its spawn hook reported state it created, but the capability declares no retire hook, so OATS cannot undo it`);
5598
5667
  outstandingHooks.add(cap.id);
5599
5668
  }
5600
- const detail = requiredFailures.map((f) => ` ${f.capability} ${f.event} ${f.contract === "environment" ? "environment contract" : "hook (declared required)"}: ${f.message}`).join("\n");
5601
5669
  let note;
5602
- if (incomplete.length) {
5603
- // QUARANTINE, do not delete. Compensation could not finish, and the home
5604
- // holds the very credentials and metadata a retry needs — for aweb,
5605
- // <instance-home>/.aw is the only signing key that can self-delete the
5606
- // remote identity. Removing it converts a transient cleanup failure into
5607
- // permanent remote residue (aggregate review at 798b156). The worktree and
5608
- // branch are already gone where that was independently safe; nothing is
5609
- // launched.
5610
- // A quarantine cannot promise to prove zero work. If a diagnostic exists
5611
- // but neither category identified its owner, conservatively require every
5612
- // retire hook to succeed on retry.
5613
- if (!outstandingHooks.size && !outstandingGit.size) {
5614
- for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5615
- }
5670
+ if (outstandingHooks.size || outstandingGit.size) {
5671
+ // Preserve credentials and the original hook receipt until cleanup
5672
+ // succeeds. The runtime is stopped; Git cleanup was independently safe.
5616
5673
  note = quarantineInstanceHome({
5617
5674
  home, instance, agent, incomplete,
5618
- failed: requiredFailures.map((f) => ({ capability: f.capability, event: f.event, ...(f.contract ? { contract: f.contract } : {}) })),
5675
+ failed,
5619
5676
  outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
5620
5677
  hookMeta: hookRes.meta || {}, compensationMeta, launched: false,
5621
5678
  recordRetirementBaseline: true,
@@ -5625,342 +5682,258 @@ export function spawnInstance(root, agent, o = {}) {
5625
5682
  if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
5626
5683
  note = incomplete.length ? ` — rollback INCOMPLETE, clean up manually: ${incomplete.join("; ")}` : " — spawn rolled back";
5627
5684
  }
5628
- const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
5629
- throw oatsError(code, `a capability this soul activates could not configure itself:\n${detail}\n\nThe instance would have started with an invalid or missing capability configuration${note}`);
5630
- }
5631
- const briefLines = hookRes.briefs.length ? `\n${hookRes.briefs.join("\n")}` : "";
5632
- const workDesc = work === "worktree"
5633
- ? `a dedicated git worktree of ${repoAbs} on branch "${branch}" — commit freely there`
5634
- : work === "attached"
5635
- ? `ATTACHED to another instance's work tree (${o.workDir}, branch ${branch}) — you share it with that instance; make your changes and commits focused, and never switch branches`
5636
- : work === "workspace"
5637
- ? `the WHOLE WORKSPACE (${realpathSync(join(home, "work"))}) — every member repo is read-context; you coordinate, you do not edit member repos (see your work-mode briefing)`
5638
- : `a symlink to the ${repoAbs} checkout — you share it; work on the currently checked-out branch (${branch}) and do not switch branches without being asked`;
5639
- writeFileSync(join(home, "TASK.md"), `# Instance briefing: ${instance}
5685
+ return note;
5686
+ };
5687
+
5688
+ try {
5689
+ if (requiredFailures.length) {
5690
+ const detail = requiredFailures.map((f) => ` ${f.capability} ${f.event} ${f.contract === "environment" ? "environment contract" : "hook (declared required)"}: ${f.message}`).join("\n");
5691
+ const code = requiredFailures.some((f) => f.contract === "environment") ? "E_HOOK_ENVIRONMENT_CONTRACT" : "E_REQUIRED_HOOK_FAILED";
5692
+ throw oatsError(code, `a capability this soul activates could not configure itself:\n${detail}\n\nThe instance would have started with an invalid or missing capability configuration`);
5693
+ }
5694
+ const briefLines = hookRes.briefs.length ? `\n${hookRes.briefs.join("\n")}` : "";
5695
+ const workDesc = work === "worktree"
5696
+ ? `a dedicated git worktree of ${repoAbs} on branch "${branch}" — commit freely there`
5697
+ : work === "attached"
5698
+ ? `ATTACHED to another instance's work tree (${o.workDir}, branch ${branch}) — you share it with that instance; make your changes and commits focused, and never switch branches`
5699
+ : work === "workspace"
5700
+ ? `the WHOLE WORKSPACE (${realpathSync(join(home, "work"))}) — every member repo is read-context; you coordinate, you do not edit member repos (see your work-mode briefing)`
5701
+ : `a symlink to the ${repoAbs} checkout — you share it; work on the currently checked-out branch (${branch}) and do not switch branches without being asked`;
5702
+ writeFileSync(join(home, "TASK.md"), `# Instance briefing: ${instance}
5640
5703
 
5641
5704
  You are instance "${instance}" of agent "${agent.name}".
5642
5705
  - Home: ${home}${resolvedCfg.team ? `\n- Team: ${resolvedCfg.team.name}${resolvedCfg.team.id ? ` (${resolvedCfg.team.id})` : ""} — see teammates with \`oats status --team\`` : ""}
5643
5706
  - Work tree: ./work — ${workDesc}
5644
- - 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" : ""}
5645
5708
  ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
5646
5709
 
5647
- // Launch command. Spawn IS session start: this command is persisted in
5648
- // instance.json and executed in the instance's tmux window. Capabilities may
5649
- // contribute runtime-specific arguments via their spawn hook's `launch` map
5650
- // (e.g. aweb's Claude Code channel plugin flags).
5651
- const claudeBin = runtime === "claude" ? resolveClaudeBinary(repoAbs) : undefined;
5652
- const bin = which(runtime === "claude" ? claudeBin : "pi");
5653
- if (!bin) throw new Error(`${runtime === "claude" ? claudeBin : runtime} binary not found on PATH${claudeBin && claudeBin !== "claude" ? " (named by oats-claude-config)" : ""}`);
5654
- const hookArgs = hookRes.launch?.[runtime] ? ` ${hookRes.launch[runtime]}` : "";
5655
- let cmdline;
5656
- if (runtime === "claude") {
5657
- // .claude/skills already links the OATS-composed instance skill set.
5658
- // "--" terminates option parsing BEFORE the prompt: capability launch
5659
- // hooks can contribute greedy/variadic flags (e.g. aweb's
5660
- // --dangerously-load-development-channels), and without the separator
5661
- // the TASK.md text is swallowed as that flag's next value — claude
5662
- // errors out ("entries must be tagged: <task text>") and the window
5663
- // drops to the fallback shell, which reads as a silently stuck spawn.
5664
- cmdline = `${shq(bin)}${model ? ` --model ${shq(model)}` : ""}${hookArgs} -- "$(cat TASK.md)"`;
5665
- } else {
5666
- // STRICT CURRICULUM (pi): the OATS-composed set — no user, ancestor, project
5667
- // or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
5668
- // NOT "nothing else can contribute": extensions stay ambient by founder
5669
- // ruling (see below), and an extension's resources_discover hook can add
5670
- // skill paths that survive --no-skills. The OATS-managed root is exact; the
5671
- // extension surface is the operator's, and stating otherwise here would
5672
- // contradict the paragraph twelve lines down (reviewer-aggregate2).
5673
- //
5674
- // --no-skills ends discovery; --skill stays additive.
5675
- // --no-context-files stops ancestor AGENTS.md/CLAUDE.md auto-injection.
5676
- // It also stops the instance's OWN composed AGENTS.md
5677
- // loading, so that is delivered explicitly via
5678
- // --append-system-prompt. The work tree's AGENTS.md
5679
- // stays READABLE by the read tool: readable, not
5680
- // auto-injected, is the contract.
5681
- // --no-prompt-templates same posture for ambient prompt templates.
5682
- //
5683
- // Built-in tools and pi's native interaction model are untouched — OATS
5684
- // curates the curriculum, it does not cripple the runtime.
5685
- //
5686
- // EXTENSIONS STAY AMBIENT, by founder ruling: operators run cross-agent pi
5687
- // extensions (web search, output formatting) that every instance should
5688
- // keep. So no --no-extensions, and no -e flags either — pi discovers the
5689
- // installed extensions itself, and passing them explicitly as well would
5690
- // load the same extension twice.
5691
- //
5692
- // The trade-off is deliberate and narrow: an extension's
5693
- // `resources_discover` hook can contribute skill paths that survive
5694
- // --no-skills. Today only the OATS bridge does that, and inside an instance
5695
- // it contributes that instance's OWN .agents/skills, so the composed set is
5696
- // unchanged. A third-party extension that contributes skills WOULD add them,
5697
- // which is the accepted residue of keeping shared extensions working.
5698
- // Capability-required runtime packages are still verified and recorded
5699
- // (verifyRuntimePackages), so "aweb on pi requires the aweb pi package"
5700
- // still holds — it is loaded by pi's own discovery rather than by flag.
5701
- // pi has no `--` end-of-options marker (it rejects `--` in every position),
5702
- // so the task positional goes AHEAD of capability-contributed options:
5703
- // nothing preceding it is waiting for a value, so a trailing variadic
5704
- // contributed flag cannot consume the task.
5705
- cmdline = `${shq(bin)} --no-skills --skill ${shq(join(home, ".agents", "skills"))}`
5706
- + ` --no-context-files --no-prompt-templates`
5707
- + ` --append-system-prompt ${shq(join(home, "AGENTS.md"))}`
5708
- + ` --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${hookArgs}`;
5709
- }
5710
- // OATS_INSTANCE_HOME is the runtime-neutral contract name (absolute path to
5711
- // the instance home) exported to EVERY runtime. PI_AGENT_HOME/PI_AGENT_INSTANCE
5712
- // are pi-branded predecessors kept as compatibility aliases: the separately
5713
- // published @awebai/oats-pi extension and bin/oats.mjs still read them, and
5714
- // an older installed extension must keep working against a newer kernel.
5715
- const hookEnv = Object.keys(hookRes.env).sort().map((name) => `${name}=${shq(hookRes.env[name])}`).join(" ");
5716
- cmdline = `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${hookEnv ? ` ${hookEnv}` : ""} ${cmdline}`;
5717
-
5718
- const meta = {
5719
- agent: agent.name, kind: agent.kind || "persistent", instance, home,
5720
- repo: repoAbs, work, branch, runtime, model: model || undefined,
5721
- team: resolvedCfg.team || undefined,
5722
- parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
5723
- siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
5724
- relation: relation || undefined,
5725
- relativeTo: relation ? relativeTo : undefined,
5726
- spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
5727
- capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
5728
- layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
5729
- capabilities: resolvedCfg.capabilities.map((cap) => ({
5730
- id: cap.id, layer: cap.layer, command: cap.command, origin: cap.origin, level: cap.level,
5731
- settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
5732
- hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
5733
- ...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
5734
- })),
5735
- skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
5736
- instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
5737
- // The auditable record of the curriculum: what the resolved composition
5738
- // PROMISED, and what actually landed. Both are asserted equal before launch;
5739
- // keeping both makes an instance's surface reviewable after the fact without
5740
- // re-resolving config that may since have changed.
5741
- composition: {
5742
- expected: expectedResources.map((r) => ({ type: r.type, source: r.source, declared: r.declared, resolved: r.path, origin: r.origin, level: r.level })),
5743
- materialized: {
5744
- skills: materialized.map((m) => ({ name: m.name, source: m.source, from: m.from })),
5745
- instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
5746
- // `filtered` records that the operator's settings entry narrows this
5747
- // package's resources. A non-empty filter is a deliberate choice whose
5748
- // glob semantics belong to the runtime, so it is auditable here rather
5749
- // than second-guessed at spawn.
5750
- runtimePackages: runtimePackages.map((x) => ({ capability: x.capability, runtime: x.runtime, package: x.package, dir: x.dir, filtered: x.filtered, loadedBy: "runtime-discovery" })),
5751
- // What this instance ACTUALLY sees beyond the OATS-composed set. Recorded
5752
- // so the deviation from strict composition is auditable instead of
5753
- // implied — the honest contract, not an aspiration.
5754
- runtimePosture: runtime === "claude"
5755
- ? {
5756
- oatsComposed: "skills via .claude/skills -> ../.agents/skills; instructions via CLAUDE.md -> AGENTS.md",
5757
- ambient: ["user skills", "project and ancestor skills to the repository root", "user and project plugins", "user and project settings", "user and ancestor CLAUDE.md"],
5758
- 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.",
5759
- }
5760
- : {
5761
- oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
5762
- curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
5763
- ambient: ["globally configured pi extensions, and any resources they contribute"],
5764
- why: "founder ruling: shared cross-agent pi extensions (web search, output formatting) stay available to every instance.",
5765
- },
5766
- canonicalSkillTree: join(home, ".agents", "skills"),
5767
- skillAlias: { path: join(home, ".claude", "skills"), target: join("..", ".agents", "skills") },
5710
+ // Launch command. Spawn IS session start: this command is persisted in
5711
+ // instance.json and executed in the instance's tmux window. Capabilities may
5712
+ // contribute runtime-specific arguments via their spawn hook's `launch` map
5713
+ // (e.g. aweb's Claude Code channel plugin flags).
5714
+ const hookArgs = hookRes.launch?.[runtime] ? ` ${hookRes.launch[runtime]}` : "";
5715
+ let cmdline;
5716
+ if (runtime === "claude") {
5717
+ // .claude/skills already links the OATS-composed instance skill set.
5718
+ // "--" terminates option parsing BEFORE the prompt: capability launch
5719
+ // hooks can contribute greedy/variadic flags (e.g. aweb's
5720
+ // --dangerously-load-development-channels), and without the separator
5721
+ // the TASK.md text is swallowed as that flag's next value — claude
5722
+ // errors out ("entries must be tagged: <task text>") and the window
5723
+ // drops to the fallback shell, which reads as a silently stuck spawn.
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)"`;
5736
+ } else {
5737
+ // STRICT CURRICULUM (pi): the OATS-composed set — no user, ancestor, project
5738
+ // or package skill catalogs, and no auto-discovered AGENTS.md/CLAUDE.md.
5739
+ // NOT "nothing else can contribute": extensions stay ambient by founder
5740
+ // ruling (see below), and an extension's resources_discover hook can add
5741
+ // skill paths that survive --no-skills. The OATS-managed root is exact; the
5742
+ // extension surface is the operator's, and stating otherwise here would
5743
+ // contradict the paragraph twelve lines down (reviewer-aggregate2).
5744
+ //
5745
+ // --no-skills ends discovery; --skill stays additive.
5746
+ // --no-context-files stops ancestor AGENTS.md/CLAUDE.md auto-injection.
5747
+ // It also stops the instance's OWN composed AGENTS.md
5748
+ // loading, so that is delivered explicitly via
5749
+ // --append-system-prompt. The work tree's AGENTS.md
5750
+ // stays READABLE by the read tool: readable, not
5751
+ // auto-injected, is the contract.
5752
+ // --no-prompt-templates same posture for ambient prompt templates.
5753
+ //
5754
+ // Built-in tools and pi's native interaction model are untouched — OATS
5755
+ // curates the curriculum, it does not cripple the runtime.
5756
+ //
5757
+ // EXTENSIONS STAY AMBIENT, by founder ruling: operators run cross-agent pi
5758
+ // extensions (web search, output formatting) that every instance should
5759
+ // keep. So no --no-extensions, and no -e flags either — pi discovers the
5760
+ // installed extensions itself, and passing them explicitly as well would
5761
+ // load the same extension twice.
5762
+ //
5763
+ // The trade-off is deliberate and narrow: an extension's
5764
+ // `resources_discover` hook can contribute skill paths that survive
5765
+ // --no-skills. Today only the OATS bridge does that, and inside an instance
5766
+ // it contributes that instance's OWN .agents/skills, so the composed set is
5767
+ // unchanged. A third-party extension that contributes skills WOULD add them,
5768
+ // which is the accepted residue of keeping shared extensions working.
5769
+ // Capability-required runtime packages are still verified and recorded
5770
+ // (verifyRuntimePackages), so "aweb on pi requires the aweb pi package"
5771
+ // still holds — it is loaded by pi's own discovery rather than by flag.
5772
+ // pi has no `--` end-of-options marker (it rejects `--` in every position),
5773
+ // so the task positional goes AHEAD of capability-contributed options:
5774
+ // nothing preceding it is waiting for a value, so a trailing variadic
5775
+ // contributed flag cannot consume the task.
5776
+ cmdline = `${shq(bin)} --no-skills --skill ${shq(join(home, ".agents", "skills"))}`
5777
+ + ` --no-context-files --no-prompt-templates`
5778
+ + ` --append-system-prompt ${shq(join(home, "AGENTS.md"))}`
5779
+ + ` --approve --name ${shq(instance)}${model ? ` --model ${shq(model)}` : ""} ${shq("@TASK.md")}${hookArgs}`;
5780
+ }
5781
+ // OATS_INSTANCE_HOME is the runtime-neutral contract name (absolute path to
5782
+ // the instance home) exported to EVERY runtime. PI_AGENT_HOME/PI_AGENT_INSTANCE
5783
+ // are pi-branded predecessors kept as compatibility aliases: the separately
5784
+ // published @awebai/oats-pi extension and bin/oats.mjs still read them, and
5785
+ // an older installed extension must keep working against a newer kernel.
5786
+ const hookEnv = Object.keys(hookRes.env).sort().map((name) => `${name}=${shq(hookRes.env[name])}`).join(" ");
5787
+ cmdline = `OATS_INSTANCE=${shq(instance)} OATS_INSTANCE_HOME=${shq(home)} PI_AGENT_INSTANCE=${shq(instance)} PI_AGENT_HOME=${shq(home)}${hookEnv ? ` ${hookEnv}` : ""} ${cmdline}`;
5788
+
5789
+ const meta = {
5790
+ agent: agent.name, kind: agent.kind || "persistent", instance, home,
5791
+ repo: repoAbs, work, branch, runtime, model: model || undefined,
5792
+ ...(yolo !== undefined ? { yolo } : {}),
5793
+ team: resolvedCfg.team || undefined,
5794
+ parentInstance: parentInstance && parentInstance !== instance ? parentInstance : undefined,
5795
+ siblingInstance: siblingInstance && siblingInstance !== instance ? siblingInstance : undefined,
5796
+ relation: relation || undefined,
5797
+ relativeTo: relation ? relativeTo : undefined,
5798
+ spawnOrigin: relation || (parentInstance && parentInstance !== instance) ? "instance" : "operator",
5799
+ capabilityMeta: Object.keys(hookRes.meta).length ? hookRes.meta : undefined,
5800
+ layers: Object.keys(resolvedCfg.provenance).length ? resolvedCfg.provenance : undefined,
5801
+ capabilities: resolvedCfg.capabilities.map((cap) => ({
5802
+ id: cap.id, layer: cap.layer, command: cap.command, origin: cap.origin, level: cap.level,
5803
+ settings: cap.settings, provenance: cap.provenance, skills: cap.skills || [],
5804
+ hooks: Object.keys(cap.hooks || {}), trusted: !!cap.trust?.trusted,
5805
+ ...(cap.environment?.length ? { environment: [...cap.environment] } : {}),
5806
+ })),
5807
+ skills: [...chosen].sort(([a], [b]) => a.localeCompare(b)).map(([name, v]) => ({ name, source: v.source })),
5808
+ instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
5809
+ // The auditable record of the curriculum: what the resolved composition
5810
+ // PROMISED, and what actually landed. Both are asserted equal before launch;
5811
+ // keeping both makes an instance's surface reviewable after the fact without
5812
+ // re-resolving config that may since have changed.
5813
+ composition: {
5814
+ expected: expectedResources.map((r) => ({ type: r.type, source: r.source, declared: r.declared, resolved: r.path, origin: r.origin, level: r.level })),
5815
+ materialized: {
5816
+ skills: materialized.map((m) => ({ name: m.name, source: m.source, from: m.from })),
5817
+ instructions: composition.blocks.map((b) => ({ source: b.source, file: b.file })),
5818
+ // `filtered` records that the operator's settings entry narrows this
5819
+ // package's resources. A non-empty filter is a deliberate choice whose
5820
+ // glob semantics belong to the runtime, so it is auditable here rather
5821
+ // than second-guessed at spawn.
5822
+ runtimePackages: runtimePackages.map((x) => ({ capability: x.capability, runtime: x.runtime, package: x.package, dir: x.dir, filtered: x.filtered, loadedBy: "runtime-discovery" })),
5823
+ // What this instance ACTUALLY sees beyond the OATS-composed set. Recorded
5824
+ // so the deviation from strict composition is auditable instead of
5825
+ // implied — the honest contract, not an aspiration.
5826
+ runtimePosture: runtime === "claude"
5827
+ ? {
5828
+ oatsComposed: "skills via .claude/skills -> ../.agents/skills; instructions via CLAUDE.md -> AGENTS.md",
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"],
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.",
5831
+ }
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
+ } : {
5837
+ oatsComposed: "skills via --skill <instance-home>/.agents/skills; instructions via --append-system-prompt",
5838
+ curtailed: ["user skills", "project and ancestor skills", "package skills", "ambient AGENTS.md/CLAUDE.md discovery", "ambient prompt templates"],
5839
+ ambient: ["globally configured pi extensions, and any resources they contribute"],
5840
+ why: "founder ruling: shared cross-agent pi extensions (web search, output formatting) stay available to every instance.",
5841
+ },
5842
+ canonicalSkillTree: join(home, ".agents", "skills"),
5843
+ skillAlias: { path: join(home, ".claude", "skills"), target: join("..", ".agents", "skills") },
5844
+ },
5768
5845
  },
5769
- },
5770
- capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
5771
- id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
5772
- hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
5773
- missingRequires: cap.missingRequires, trust: cap.trust,
5774
- executable: cap.executable,
5775
- })),
5776
- tmux: { session, window: instance },
5777
- command: cmdline, createdAt: new Date().toISOString(),
5778
- };
5779
- const spawnWarnings = warnings;
5780
-
5781
- let launched = false;
5782
- if (launch) {
5783
- if (!which("tmux")) throw new Error("tmux not installed (brew install tmux)");
5784
- if (!tmuxAlive(session)) {
5785
- const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
5786
- sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
5787
- shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
5788
- shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
5789
- }
5790
- if (tmuxWindows(session).includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
5791
- meta.tmux.socket = tmuxSocket(session);
5792
- meta.launched = true;
5793
- // Commit the final child metadata and its independent byte authority before
5794
- // the managed runtime can write. No child-home transition follows launch.
5795
- writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5796
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
5797
- // Wrap the command so the window drops into an interactive shell when the
5798
- // agent exits (e.g. Ctrl-C) instead of tmux killing the window.
5799
- const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
5800
- sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)} ${shq(windowCmd)}`);
5801
- launched = true;
5802
- } else {
5803
- meta.launched = false;
5804
- writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5805
- writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
5806
- }
5807
-
5808
- // parent relation: re-point the ANCHOR's recorded lineage so its parent is
5809
- // the new instance (e.g. a maintainer reviewing the spawner sits above it).
5810
- // Committed LAST — after every other fallible step incl. launch — so a
5811
- // failed spawn (missing tmux, window collision, new-window error) never
5812
- // leaves the anchor's graph pointing at a zombie. --no-launch reaches here
5813
- // too: the scaffold itself succeeded, which is that path's definition of
5814
- // success. The write ITSELF is fallible (anchor retired concurrently,
5815
- // unwritable file): on failure the spawn is COMPENSATED — kill the launched
5816
- // window and remove the scaffold — so the operation stays all-or-nothing:
5817
- // either agent live + lineage recorded, or neither.
5818
- if (relation === "parent" && anchorMeta && anchorMetaPath) {
5819
- // Atomic anchor update: writeFileSync truncates-then-writes, so a mid-write
5820
- // failure (ENOSPC, I/O) could leave the anchor's instance.json empty.
5821
- // Write a same-directory temp file and rename it over the anchor — rename
5822
- // is atomic on POSIX, so the anchor is always either old or new, never
5823
- // truncated.
5824
- const tmpPath = `${anchorMetaPath}.tmp-${instance}`;
5825
- try {
5826
- anchorMeta.parentInstance = instance;
5827
- delete anchorMeta.siblingInstance; // the new parent carries the old sibling link
5828
- writeFileSync(tmpPath, JSON.stringify(anchorMeta, null, 2) + "\n");
5829
- renameSync(tmpPath, anchorMetaPath);
5830
- } catch (e) {
5831
- // Compensation steps are each independent and best-effort: no step may
5832
- // abort the remaining rollback or mask the original anchor-write error
5833
- // (rmSync force:true only suppresses ENOENT — EPERM/IO still throw).
5834
- // Failures are COLLECTED and reported: the thrown message must never
5835
- // claim a cleanup that did not happen.
5836
- const incomplete = [];
5837
- // Verification probes are argv-based (no shell interpolation — branch
5838
- // names may contain valid-but-hostile metacharacters like $(…)) and
5839
- // THREE-STATE: confirmed-absent | still-present | could-not-verify.
5840
- // Both of the latter are reported — a failed probe must never pass as
5841
- // a confirmed cleanup (fail closed).
5842
- const probe = (argv) => {
5843
- try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
5844
- // With encoding:"utf8" a silent command yields stderr === "" — FALSY — so
5845
- // `e2.stderr || e2.message` fell through to "Command failed: …" and made
5846
- // every clean probe look like a failed one. `git rev-parse --verify
5847
- // --quiet` on an absent ref is exactly that case, so a successful branch
5848
- // deletion could never be confirmed and rollback always reported
5849
- // INCOMPLETE. Distinguish "no output" from "no stderr captured".
5850
- catch (e2) { return { ok: false, status: e2.status, err: String(e2.stderr ?? e2.message ?? "").trim() }; }
5851
- };
5852
- let windowUnresolved = false;
5853
- try { rmSync(tmpPath, { force: true }); } catch (e2) { incomplete.push(`temp file ${tmpPath}: ${e2.message}`); }
5854
- if (launched) {
5855
- // shTry returns "" on success and undefined on failure — neither is a
5856
- // reliable signal for kill-window, so verify the EFFECT unconditionally.
5857
- shTry(`tmux kill-window -t ${shq(`=${session}:=${instance}`)}`);
5858
- const winProbe = probe(["tmux", "list-windows", "-t", session, "-F", "#{window_name}"]);
5859
- if (!winProbe.ok) { incomplete.push(`tmux window ${session}:${instance}: could not verify removal (${winProbe.err || "list-windows failed"})`); windowUnresolved = true; }
5860
- else if (winProbe.out.split("\n").includes(instance)) { incomplete.push(`tmux window ${session}:${instance} still running`); windowUnresolved = true; }
5846
+ capabilityRuntime: resolvedCfg.capabilities.map((cap) => ({
5847
+ id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
5848
+ hooks: cap.hooks, requiredHooks: cap.requiredHooks, environment: cap.environment,
5849
+ missingRequires: cap.missingRequires, trust: cap.trust,
5850
+ executable: cap.executable,
5851
+ })),
5852
+ ...(backend === "herdr" ? { backend } : { tmux: { session, window: instance } }),
5853
+ command: cmdline, createdAt: new Date().toISOString(),
5854
+ };
5855
+ const spawnWarnings = warnings;
5856
+
5857
+ spawnTmux = meta.tmux;
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) {
5867
+ if (!tmuxAlive(session)) {
5868
+ const hq = existsSync(root) ? root : workspaceOf(root); // all-local scopes may have no agents/ dir
5869
+ sh(`tmux new-session -d -s ${shq(session)} -n hq -c ${shq(hq)}`);
5870
+ shTry(`tmux set-option -t ${shq(session)} -g window-size latest`);
5871
+ shTry(`tmux set-option -t ${shq(session)} -g aggressive-resize on`);
5861
5872
  }
5862
- // Outstanding debt as IDS, so a retry can verify it — same contract the
5863
- // required-hook rollback records.
5864
- const outstandingHooks = new Set();
5865
- const outstandingGit = new Set();
5866
- let compMeta = hookRes.meta || {};
5873
+ if (tmuxWindows(session).includes(instance)) throw new Error(`tmux window "${instance}" already exists in session ${session}`);
5874
+ meta.tmux.socket = tmuxSocket(session);
5875
+ meta.launched = true;
5876
+ // Commit the final child metadata and its independent byte authority before
5877
+ // the managed runtime can write. No child-home transition follows launch.
5878
+ writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5879
+ writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: true, tmux: meta.tmux });
5880
+ // Wrap the command so the window drops into an interactive shell when the
5881
+ // agent exits (e.g. Ctrl-C) instead of tmux killing the window.
5882
+ const windowCmd = `${cmdline}; exec "\${SHELL:-/bin/zsh}"`;
5883
+ windowMayExist = true;
5884
+ sh(`tmux new-window -t ${shq(session)} -n ${shq(instance)} -c ${shq(home)} ${shq(windowCmd)}`);
5885
+ } else {
5886
+ meta.launched = false;
5887
+ writeFileSync(join(home, "instance.json"), JSON.stringify(meta, null, 2) + "\n");
5888
+ writeRetirementBaseline(home, join(home, "work"), work === "worktree", wm, resolvedCfg.capabilities, { launched: false, tmux: meta.tmux });
5889
+ }
5890
+
5891
+ // parent relation: re-point the ANCHOR's recorded lineage so its parent is
5892
+ // the new instance (e.g. a maintainer reviewing the spawner sits above it).
5893
+ // Committed LAST — after every other fallible step incl. launch — so a
5894
+ // failed spawn (missing tmux, window collision, new-window error) never
5895
+ // leaves the anchor's graph pointing at a zombie. --no-launch reaches here
5896
+ // too: the scaffold itself succeeded, which is that path's definition of
5897
+ // success. The write ITSELF is fallible (anchor retired concurrently,
5898
+ // unwritable file): on failure the spawn is COMPENSATED — kill the launched
5899
+ // window and remove the scaffold — so the operation stays all-or-nothing:
5900
+ // either agent live + lineage recorded, or neither.
5901
+ if (relation === "parent" && anchorMeta && anchorMetaPath) {
5902
+ // Atomic anchor update: writeFileSync truncates-then-writes, so a mid-write
5903
+ // failure (ENOSPC, I/O) could leave the anchor's instance.json empty.
5904
+ // Write a same-directory temp file and rename it over the anchor — rename
5905
+ // is atomic on POSIX, so the anchor is always either old or new, never
5906
+ // truncated.
5907
+ const tmpPath = `${anchorMetaPath}.tmp-${instance}`;
5867
5908
  try {
5868
- const comp = runLifecycleHooks("retire", {
5869
- home, instance, agentName: agent.name, soulDir, contextDir: repoAbs,
5870
- workspaceDir: workspaceOf(root), rootDir: root, resolved: resolvedCfg,
5871
- priorMeta: hookRes.meta || {},
5872
- });
5873
- // runLifecycleHooks catches hook errors internally — detect them via
5874
- // the structured failures field, not this outer catch.
5875
- for (const f of comp.failures || []) { incomplete.push(`retire hook ${f.capability}: ${f.message}`); outstandingHooks.add(f.capability); }
5876
- // Exit 0 while reporting "not retired" is also unfinished cleanup.
5877
- for (const [capId, m] of Object.entries(comp.meta || {})) {
5878
- if (m && typeof m === "object" && m.retired === false && m.reason !== "nothing-to-delete") {
5879
- incomplete.push(`retire hook ${capId}: reported incomplete cleanup${m.reason ? ` (${m.reason})` : ""} — external state may remain`);
5880
- outstandingHooks.add(capId);
5881
- }
5882
- }
5883
- compMeta = { ...compMeta, ...(comp.meta || {}) };
5884
- } catch (e2) {
5885
- incomplete.push(`retire hooks: ${e2.message}`);
5886
- for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5887
- }
5888
- if (work === "worktree") {
5889
- const wt = join(home, "work");
5890
- // Git canonicalizes symlinked parent components when registering a
5891
- // worktree. Use the path captured immediately after successful add —
5892
- // retire-hook compensation may already have removed/inaccessible'd wt.
5893
- const wtCanonical = worktreeCanonical;
5894
- if (!wtCanonical) incomplete.push(`git worktree ${wt}: could not verify removal (canonical path unavailable)`);
5895
- probe(["git", "-C", repoAbs, "worktree", "remove", "--force", wt]);
5896
- probe(["git", "-C", repoAbs, "worktree", "prune"]);
5897
- // Verify effects, not exit codes: parse exact NUL-delimited `worktree`
5898
- // records (never substring-match a lexical path against Git's canonical
5899
- // registered path). The tree must be deregistered or later rmSync(home)
5900
- // strands stale Git metadata.
5901
- const wtProbe = probe(["git", "-C", repoAbs, "worktree", "list", "--porcelain", "-z"]);
5902
- if (!wtProbe.ok) { incomplete.push(`git worktree ${wtCanonical}: could not verify removal (${wtProbe.err || "worktree list failed"})`); outstandingGit.add("worktree"); }
5903
- else {
5904
- const registered = wtProbe.out.split("\0").filter((field) => field.startsWith("worktree ")).map((field) => field.slice("worktree ".length));
5905
- if (wtCanonical && registered.includes(wtCanonical)) { incomplete.push(`git worktree ${wtCanonical}: still registered`); outstandingGit.add("worktree"); }
5906
- }
5907
- if (branch) {
5908
- probe(["git", "-C", repoAbs, "branch", "-D", branch]);
5909
- // rev-parse --verify: exit 0 = ref exists; exit 1 with empty stderr
5910
- // under --quiet = confirmed absent; anything else = could not verify.
5911
- const brProbe = probe(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
5912
- if (brProbe.ok) { incomplete.push(`git branch ${branch}: still exists`); outstandingGit.add("branch"); }
5913
- else if (brProbe.status !== 1 || brProbe.err) { incomplete.push(`git branch ${branch}: could not verify deletion (${brProbe.err || `rev-parse exit ${brProbe.status}`})`); outstandingGit.add("branch"); }
5914
- }
5915
- }
5916
- // QUARANTINE, do not delete. This path deleted the home unconditionally —
5917
- // including while its own retire hook was reporting failure — which is
5918
- // exactly the credential destruction the required-hook path was fixed to
5919
- // avoid (reviewer-terminal54a87fd). A quarantine with nothing outstanding
5920
- // would be a proof obligation of zero, so the record is filled
5921
- // conservatively when both categories somehow came up empty.
5922
- // Quarantine only for state that COMPENSATION owns and did not finish: a
5923
- // retire hook that failed, Git the rollback could not undo, or a window
5924
- // still running. Litter beside the anchor (a leftover temp file) is
5925
- // reported but is not the child's external state, and retaining a home
5926
- // for it would turn an ordinary failure into a --force cleanup.
5927
- const unresolved = outstandingHooks.size > 0 || outstandingGit.size > 0 || windowUnresolved;
5928
- let rollbackNote;
5929
- if (unresolved) {
5930
- if (!outstandingHooks.size && !outstandingGit.size) {
5931
- for (const cap of resolvedCfg.capabilities) if (cap.hooks?.retire) outstandingHooks.add(cap.id);
5932
- }
5933
- rollbackNote = quarantineInstanceHome({
5934
- home, instance, agent, incomplete,
5935
- failed: [{ capability: "oats.kernel", event: "spawn" }],
5936
- outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg,
5937
- // The SPAWN metadata — the aweb alias a retry needs to delete the
5938
- // remote identity. Passing the compensation result overwrote it with
5939
- // `{retired:false}`, discarding the very handle cleanup requires.
5940
- hookMeta: hookRes.meta || {}, compensationMeta: compMeta,
5941
- launched: windowUnresolved, tmux: meta.tmux,
5942
- recordRetirementBaseline: true,
5943
- }).replace(/^ — /, "");
5944
- } else {
5945
- try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
5946
- if (existsSync(home) && !incomplete.some((m) => m.startsWith("instance home"))) incomplete.push(`instance home ${home}: still present`);
5947
- rollbackNote = incomplete.length
5948
- ? `rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}`
5949
- : "spawn rolled back (window killed, hooks compensated, scaffold removed)";
5909
+ anchorMeta.parentInstance = instance;
5910
+ delete anchorMeta.siblingInstance; // the new parent carries the old sibling link
5911
+ writeFileSync(tmpPath, JSON.stringify(anchorMeta, null, 2) + "\n");
5912
+ renameSync(tmpPath, anchorMetaPath);
5913
+ } catch (e) {
5914
+ try { rmSync(tmpPath, { force: true }); }
5915
+ catch (cleanupError) { ancillaryCleanup.push(`temp file ${tmpPath}: ${cleanupError.message}`); }
5916
+ throw new Error(`relation "parent": failed to re-point anchor "${relativeTo}" (${e.message})`);
5950
5917
  }
5951
- throw new Error(`relation "parent": failed to re-point anchor "${relativeTo}" (${e.message}) — ${rollbackNote}`);
5952
5918
  }
5953
- }
5954
5919
 
5955
- 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 };
5921
+ } catch (error) {
5922
+ const note = compensateSpawn();
5923
+ error.message += note;
5924
+ throw error;
5925
+ }
5956
5926
  }
5957
5927
 
5958
5928
  export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
5959
5929
  const windows = tmuxWindows(tmuxSession);
5960
5930
  const readInstancesOf = (agentDir) => {
5961
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).
5962
5935
  return (existsSync(instancesDir) ? readdirSync(instancesDir, { withFileTypes: true }) : [])
5963
- .filter((e) => e.isDirectory())
5936
+ .filter((e) => e.isDirectory() && !e.name.startsWith("."))
5964
5937
  .map((e) => {
5965
5938
  const metaPath = join(instancesDir, e.name, "instance.json");
5966
5939
  const home = join(instancesDir, e.name);
@@ -5975,12 +5948,47 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
5975
5948
  try { rollbackIncomplete = JSON.parse(readFileSync(quarantine, "utf8")); }
5976
5949
  catch { rollbackIncomplete = { reason: "rollback incomplete" }; }
5977
5950
  }
5978
- 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
+
5979
5967
  });
5980
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
+ };
5981
5989
  const out = listAgents(root).map((a) => {
5982
5990
  const { _dir, ...soul } = a;
5983
- return { ...soul, dir: _dir, instances: readInstancesOf(a._dir) };
5991
+ return withFailures({ ...soul, dir: _dir, instances: readInstancesOf(a._dir) }, a._dir);
5984
5992
  });
5985
5993
  // Capability-defined agents home under local-agents/<name>/ WITHOUT a local
5986
5994
  // soul (it lives read-only in the package) — surface their instances too.
@@ -5990,9 +5998,10 @@ export function listInstances(root, tmuxSession = DEFAULT_TMUX_SESSION) {
5990
5998
  for (const e of readdirSync(dir, { withFileTypes: true })) {
5991
5999
  if (!e.isDirectory() || seen.has(e.name)) continue;
5992
6000
  const instances = readInstancesOf(join(dir, e.name));
5993
- if (!instances.length) continue;
6001
+ const retireFailures = readRetireFailuresOf(join(dir, e.name));
6002
+ if (!instances.length && !retireFailures.length) continue;
5994
6003
  const cap = instances.find((i) => i.capability)?.capability;
5995
- 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 } : {}) });
5996
6005
  seen.add(e.name);
5997
6006
  }
5998
6007
  }
@@ -6068,14 +6077,9 @@ export const QUARANTINE_CLEANUP_VERSION = 1;
6068
6077
  /** The rollback-owned Git steps a quarantine can still owe. */
6069
6078
  export const QUARANTINE_GIT_DEBT = ["worktree", "branch"];
6070
6079
 
6071
- /** Retain a half-built instance home and mark it, so `oats retire <instance>` can
6072
- * finish the cleanup that failed. THE one implementation: a spawn has two
6073
- * rollback paths — a failed required hook, and a failure after the instance was
6074
- * already launched (re-pointing a parent anchor) — and the second one deleted the
6075
- * home while its own retire hook was reporting failure, destroying the credential
6076
- * needed to undo the external state that survived (reviewer-terminal54a87fd).
6077
- * Two copies of this logic is how that divergence happened; there is now one. */
6078
- function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason }) {
6080
+ /** Retain the home and its cleanup receipt when spawn compensation or retirement
6081
+ * cannot finish. Keeping the original credentials makes cleanup retryable. */
6082
+ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, sessionTarget, recordRetirementBaseline = false, reason }) {
6079
6083
  try {
6080
6084
  writeFileSync(join(home, ".oats-rollback-incomplete.json"), JSON.stringify({
6081
6085
  // `reason` is optional and DEFAULTS to the spawn wording, so every existing
@@ -6091,6 +6095,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
6091
6095
  cleanup: {
6092
6096
  version: QUARANTINE_CLEANUP_VERSION,
6093
6097
  repo: repoAbs, work, branch, launched, tmux,
6098
+ ...(sessionTarget ? { sessionTarget } : {}),
6094
6099
  outstanding: { hooks: [...outstandingHooks], git: [...outstandingGit] },
6095
6100
  capabilityRuntime: (resolvedCfg.capabilities || []).map((cap) => ({
6096
6101
  id: cap.id, layer: cap.layer, level: cap.level, settings: cap.settings,
@@ -6105,7 +6110,7 @@ function quarantineInstanceHome({ home, instance, agent, incomplete, failed, out
6105
6110
  } catch { /* the quarantine still stands without its marker */ }
6106
6111
  if (recordRetirementBaseline) {
6107
6112
  try {
6108
- 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 });
6109
6114
  } catch (e) {
6110
6115
  incomplete.push(`independent retirement authority: ${e.message}`);
6111
6116
  }
@@ -6179,6 +6184,15 @@ function retirementBaselinePath(home) {
6179
6184
  return join(retirementStateRoot(home), "baselines", `${retirementKey(home)}.json`);
6180
6185
  }
6181
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
+
6182
6196
  function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = false } = {}) {
6183
6197
  const hash = createHash("sha256");
6184
6198
  const rootStat = lstatSync(root);
@@ -6208,7 +6222,7 @@ function fingerprintTree(root, { excludeRoot = new Set(), excludeGitMetadata = f
6208
6222
 
6209
6223
  function worktreeStatus(repo) {
6210
6224
  try {
6211
- 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 });
6212
6226
  } catch (e) {
6213
6227
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect instance worktree: ${String(e.stderr ?? e.message ?? "").trim() || "git status failed"}`);
6214
6228
  }
@@ -6259,6 +6273,7 @@ function writeRetirementBaseline(home, work, isWorktree, workMode, capabilities,
6259
6273
  runtime: {
6260
6274
  launched: runtime?.launched === true,
6261
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 } : {}),
6262
6277
  },
6263
6278
  };
6264
6279
  const path = retirementBaselinePath(home);
@@ -6284,7 +6299,7 @@ function branchOnlyCommits(repo, branch) {
6284
6299
  if (!repo || !branch) return [];
6285
6300
  const target = `refs/heads/${branch}`;
6286
6301
  try {
6287
- 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 });
6288
6303
  } catch (e) {
6289
6304
  const detail = String(e.stderr ?? "").trim();
6290
6305
  // A quarantine retry may follow a successful rollback-owned branch removal.
@@ -6293,9 +6308,9 @@ function branchOnlyCommits(repo, branch) {
6293
6308
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${detail || String(e.message ?? "").trim() || "git ref probe failed"}`);
6294
6309
  }
6295
6310
  try {
6296
- 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 })
6297
6312
  .split("\n").filter((ref) => ref && ref !== target);
6298
- 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);
6299
6314
  } catch (e) {
6300
6315
  throw oatsError("E_WORK_INSPECTION_FAILED", `could not inspect local branch reachability: ${String(e.stderr ?? e.message ?? "").trim() || "git ref probe failed"}`);
6301
6316
  }
@@ -6305,11 +6320,55 @@ function runtimeAuthorityOf(baseline) {
6305
6320
  const runtime = baseline?.runtime;
6306
6321
  if (!isPlainObject(runtime) || typeof runtime.launched !== "boolean") return undefined;
6307
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
+ }
6308
6327
  const tmux = runtime.tmux;
6309
6328
  if (!isPlainObject(tmux) || ![tmux.session, tmux.window, tmux.socket].every((v) => typeof v === "string" && v.length > 0)) return undefined;
6310
6329
  return { launched: true, tmux: { session: tmux.session, window: tmux.window, socket: resolve(tmux.socket) } };
6311
6330
  }
6312
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
+
6313
6372
  function inspectRetirementWork(home, work, isWorktree, { branchDeletion } = {}) {
6314
6373
  const classes = [];
6315
6374
  let baseline;
@@ -6356,14 +6415,14 @@ const RECOVERABLE_GIT_ADMIN = [
6356
6415
  ];
6357
6416
 
6358
6417
  function restoreStandaloneGitState(sourceWork, recoveredRepo) {
6359
- 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();
6360
6419
  const recoveredGit = join(recoveredRepo, ".git");
6361
- 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 });
6362
6421
  for (const row of indexRows.toString("utf8").split("\0").filter(Boolean)) {
6363
6422
  const match = row.match(/^\d+ ([0-9a-f]+) \d+\t/);
6364
6423
  if (!match) continue;
6365
- const blob = execFileSync("git", ["-C", sourceWork, "cat-file", "blob", match[1]]);
6366
- 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();
6367
6426
  if (restored !== match[1]) throw new Error(`recovered Git object ${restored} did not match source ${match[1]}`);
6368
6427
  }
6369
6428
  copyFileSync(join(sourceGit, "index"), join(recoveredGit, "index"));
@@ -6378,11 +6437,11 @@ function restoreStandaloneGitState(sourceWork, recoveredRepo) {
6378
6437
 
6379
6438
  function detachRecoveryClone(source, recovered) {
6380
6439
  let stash;
6381
- 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(); }
6382
6441
  catch { stash = undefined; }
6383
6442
  if (stash) {
6384
- execFileSync("git", ["-C", recovered, "fetch", "--quiet", source, "refs/stash:refs/stash"]);
6385
- 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();
6386
6445
  const stashLog = join(sourceCommon, "logs", "refs", "stash");
6387
6446
  if (existsSync(stashLog)) {
6388
6447
  const recoveredLog = join(recovered, ".git", "logs", "refs", "stash");
@@ -6390,17 +6449,17 @@ function detachRecoveryClone(source, recovered) {
6390
6449
  copyFileSync(stashLog, recoveredLog);
6391
6450
  }
6392
6451
  }
6393
- 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 */ }
6394
6453
  }
6395
6454
 
6396
6455
  function materializeNestedRepositories(sourceWork, recoveredRepo) {
6397
6456
  for (const source of nestedGitRoots(sourceWork)) {
6398
6457
  const rel = relative(sourceWork, source);
6399
6458
  const dest = join(recoveredRepo, rel);
6400
- 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();
6401
6460
  rmSync(dest, { recursive: true, force: true });
6402
- execFileSync("git", ["clone", "--no-local", "--quiet", source, dest], { stdio: ["ignore", "pipe", "pipe"] });
6403
- 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 });
6404
6463
  detachRecoveryClone(source, dest);
6405
6464
  restoreStandaloneGitState(source, dest);
6406
6465
  for (const e of readdirSync(source, { withFileTypes: true })) {
@@ -6427,7 +6486,7 @@ function preserveRetirementWork(observation, meta, instance) {
6427
6486
  }
6428
6487
  if (meta.work === "worktree" && meta.repo && meta.branch && (existsSync(observation.work) || observation.branchExists)) {
6429
6488
  const recoveredRepo = join(staging, "repo");
6430
- 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 });
6431
6490
  const sourceGitContext = existsSync(observation.work) ? observation.work : meta.repo;
6432
6491
  detachRecoveryClone(sourceGitContext, recoveredRepo);
6433
6492
  if (existsSync(observation.work)) {
@@ -6445,8 +6504,8 @@ function preserveRetirementWork(observation, meta, instance) {
6445
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");
6446
6505
  if (worktreeStatus(observation.work) !== worktreeStatus(recoveredRepo)) throw new Error("recovered Git index/status disagreed with the source");
6447
6506
  }
6448
- const recoveredHead = execFileSync("git", ["-C", recoveredRepo, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
6449
- 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();
6450
6509
  if (recoveredHead !== sourceHead) throw new Error("recovery clone does not retain the instance branch tip");
6451
6510
  }
6452
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 });
@@ -6459,9 +6518,143 @@ function preserveRetirementWork(observation, meta, instance) {
6459
6518
  }
6460
6519
  }
6461
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
+
6462
6652
  export function retireInstance(root, name, o = {}) {
6463
6653
  const session = o.tmuxSession || DEFAULT_TMUX_SESSION;
6464
- 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;
6465
6658
  const found = findInstanceHome(root, name);
6466
6659
  if (!found) throw new Error(`no instance named "${name}"`);
6467
6660
  const metaPath = join(found.home, "instance.json");
@@ -6498,6 +6691,7 @@ export function retireInstance(root, name, o = {}) {
6498
6691
  if (quarantine && typeof quarantine.cleanup.launched === "boolean") {
6499
6692
  meta.launched = quarantine.cleanup.launched;
6500
6693
  meta.tmux = quarantine.cleanup.tmux;
6694
+ meta.sessionTarget = quarantine.cleanup.sessionTarget;
6501
6695
  }
6502
6696
  // A home with NEITHER instance.json NOR a usable cleanup descriptor cannot be
6503
6697
  // retired safely: hooks would be skipped and the directory removed, which is
@@ -6517,8 +6711,13 @@ export function retireInstance(root, name, o = {}) {
6517
6711
  const workPath = join(found.home, "work");
6518
6712
  const isWorktree = meta.work === "worktree" ||
6519
6713
  (existsSync(workPath) && !lstatSync(workPath).isSymbolicLink());
6520
- if (self && !o.keepDir) {
6521
- 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);
6522
6721
  }
6523
6722
  // First inspection is non-destructive. Only after it succeeds may OATS quiesce
6524
6723
  // the managed runtime; recovery copying never races a live managed Pi.
@@ -6533,11 +6732,12 @@ export function retireInstance(root, name, o = {}) {
6533
6732
  if (!runtimeAuthority) {
6534
6733
  throw oatsError("E_RUNTIME_ENDPOINT_UNKNOWN", `cannot quiesce ${name}: independent runtime endpoint authority is missing or invalid`);
6535
6734
  }
6536
- const metaAgrees = meta.launched === runtimeAuthority.launched && (!runtimeAuthority.launched || (
6537
- meta.tmux?.session === runtimeAuthority.tmux.session &&
6538
- meta.tmux?.window === runtimeAuthority.tmux.window &&
6539
- resolve(meta.tmux?.socket || ".") === runtimeAuthority.tmux.socket
6540
- ));
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);
6541
6741
  if (!metaAgrees) {
6542
6742
  throw oatsError("E_RUNTIME_AUTHORITY_MISMATCH", `cannot quiesce ${name}: mutable instance metadata disagrees with independent runtime endpoint authority`);
6543
6743
  }
@@ -6545,7 +6745,11 @@ export function retireInstance(root, name, o = {}) {
6545
6745
  // `=` forces exact matching: tmux targets otherwise PREFIX-match window names.
6546
6746
  // A no-launch instance is already quiesced. A launched one must have exact
6547
6747
  // window absence established before recovery copying begins.
6548
- 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) {
6549
6753
  const runtimeSession = runtimeAuthority.tmux.session;
6550
6754
  const runtimeWindow = runtimeAuthority.tmux.window;
6551
6755
  const runtimeSocket = runtimeAuthority.tmux.socket;
@@ -6588,13 +6792,12 @@ export function retireInstance(root, name, o = {}) {
6588
6792
  // describes. A hook that reports nothing, or reports retired:true, is
6589
6793
  // unaffected; only an explicit "I did not finish" changes the outcome.
6590
6794
  let ordinaryIncomplete = [];
6591
- // SELF-RETIRE IS DELIBERATELY EXCLUDED. A self-retiring instance is the caller;
6592
- // it cannot hold the authority to complete owner cleanup, and turning its exit
6593
- // into a retained owner quarantine would change a path this change is not
6594
- // scoped to touch (reviewer-5c8b724: control deletes and schedules teardown,
6595
- // the first draft retained and quarantined). Self-retire keeps tearing down
6596
- // local state and leaving the incomplete operation for the recorded cleanup
6597
- // 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.
6598
6801
  if (!quarantine && !self) {
6599
6802
  for (const f of hookResults?.failures || []) ordinaryIncomplete.push(`retire hook ${f.capability}: ${f.message}`);
6600
6803
  // A hook may exit 0 and still report it did not finish. Only an explicit
@@ -6777,7 +6980,15 @@ export function retireInstance(root, name, o = {}) {
6777
6980
  // unreachable remote) would be unremovable through OATS forever — the same
6778
6981
  // dead end the unusable-marker fixes closed, just reached from a valid one.
6779
6982
  const forced = !!(stillIncomplete && o.force);
6780
- 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
+ }
6781
6992
 
6782
6993
 
6783
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 };